diff options
Diffstat (limited to 'src')
372 files changed, 5745 insertions, 2911 deletions
diff --git a/src/mailman/Archiver/Archiver.py b/src/mailman/Archiver/Archiver.py index 252b738f2..f8d1baa46 100644 --- a/src/mailman/Archiver/Archiver.py +++ b/src/mailman/Archiver/Archiver.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/Archiver/HyperArch.py b/src/mailman/Archiver/HyperArch.py index 11b28ae48..7281cdb0f 100644 --- a/src/mailman/Archiver/HyperArch.py +++ b/src/mailman/Archiver/HyperArch.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -34,14 +34,13 @@ import time import errno import urllib import logging -import weakref import binascii from email.Charset import Charset from email.Errors import HeaderParseError from email.Header import decode_header, make_header +from flufl.lock import Lock, TimeOutError from lazr.config import as_boolean -from locknix.lockfile import Lock from string import Template from zope.component import getUtility @@ -751,7 +750,7 @@ class HyperArchive(pipermail.T): self.maillist.fqdn_listname + '-arch.lock')) try: self._lock_file.lock(timeout=0.5) - except lockfile.TimeOutError: + except TimeOutError: return 0 return 1 diff --git a/src/mailman/Archiver/HyperDatabase.py b/src/mailman/Archiver/HyperDatabase.py index f1884f019..345979123 100644 --- a/src/mailman/Archiver/HyperDatabase.py +++ b/src/mailman/Archiver/HyperDatabase.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -27,7 +27,7 @@ import errno # package/project modules # import pipermail -from locknix import lockfile +from flufl.lock import Lock, NotLockedError CACHESIZE = pipermail.CACHESIZE @@ -58,7 +58,7 @@ class DumbBTree: def __init__(self, path): self.current_index = 0 self.path = path - self.lockfile = lockfile.Lock(self.path + ".lock") + self.lockfile = Lock(self.path + ".lock") self.lock() self.__dirty = 0 self.dict = {} @@ -80,7 +80,7 @@ class DumbBTree: def unlock(self): try: self.lockfile.unlock() - except lockfile.NotLockedError: + except NotLockedError: pass def __delitem__(self, item): diff --git a/src/mailman/Archiver/__init__.py b/src/mailman/Archiver/__init__.py index a8a9f742f..04bfb4262 100644 --- a/src/mailman/Archiver/__init__.py +++ b/src/mailman/Archiver/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/Bouncers/BouncerAPI.py b/src/mailman/Bouncers/BouncerAPI.py deleted file mode 100644 index 6f52f2a3f..000000000 --- a/src/mailman/Bouncers/BouncerAPI.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Contains all the common functionality for msg bounce scanning API. - -This module can also be used as the basis for a bounce detection testing -framework. When run as a script, it expects two arguments, the listname and -the filename containing the bounce message. -""" - -import sys - -# If a bounce detector returns Stop, that means to just discard the message. -# An example is warning messages for temporary delivery problems. These -# shouldn't trigger a bounce notification, but we also don't want to send them -# on to the list administrator. -Stop = object() - - -BOUNCE_PIPELINE = [ - 'DSN', - 'Qmail', - 'Postfix', - 'Yahoo', - 'Caiwireless', - 'Exchange', - 'Exim', - 'Netscape', - 'Compuserve', - 'Microsoft', - 'GroupWise', - 'SMTP32', - 'SimpleMatch', - 'SimpleWarning', - 'Yale', - 'LLNL', - ] - - - -# msg must be a mimetools.Message -def ScanMessages(mlist, msg): - for module in BOUNCE_PIPELINE: - modname = 'mailman.Bouncers.' + module - __import__(modname) - addrs = sys.modules[modname].process(msg) - if addrs: - # Return addrs even if it is Stop. BounceRunner needs this info. - return addrs - return [] diff --git a/src/mailman/Bouncers/Caiwireless.py b/src/mailman/Bouncers/Caiwireless.py deleted file mode 100644 index 7a0b698a6..000000000 --- a/src/mailman/Bouncers/Caiwireless.py +++ /dev/null @@ -1,45 +0,0 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Parse mystery style generated by MTA at caiwireless.net.""" - -import re -import email - -tcre = re.compile(r'the following recipients did not receive this message:', - re.IGNORECASE) -acre = re.compile(r'<(?P<addr>[^>]*)>') - - - -def process(msg): - if msg.get_content_type() <> 'multipart/mixed': - return None - # simple state machine - # 0 == nothing seen - # 1 == tag line seen - state = 0 - # This format thinks it's a MIME, but it really isn't - for line in email.Iterators.body_line_iterator(msg): - line = line.strip() - if state == 0 and tcre.match(line): - state = 1 - elif state == 1 and line: - mo = acre.match(line) - if not mo: - return None - return [mo.group('addr')] diff --git a/src/mailman/Bouncers/Exchange.py b/src/mailman/Bouncers/Exchange.py deleted file mode 100644 index f2fbb2f58..000000000 --- a/src/mailman/Bouncers/Exchange.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (C) 2002-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Recognizes (some) Microsoft Exchange formats.""" - -import re -import email.Iterators - -scre = re.compile('did not reach the following recipient') -ecre = re.compile('MSEXCH:') -a1cre = re.compile('SMTP=(?P<addr>[^;]+); on ') -a2cre = re.compile('(?P<addr>[^ ]+) on ') - - - -def process(msg): - addrs = {} - it = email.Iterators.body_line_iterator(msg) - # Find the start line - for line in it: - if scre.search(line): - break - else: - return [] - # Search each line until we hit the end line - for line in it: - if ecre.search(line): - break - mo = a1cre.search(line) - if not mo: - mo = a2cre.search(line) - if mo: - addrs[mo.group('addr')] = 1 - return addrs.keys() diff --git a/src/mailman/Bouncers/Microsoft.py b/src/mailman/Bouncers/Microsoft.py deleted file mode 100644 index 540748f05..000000000 --- a/src/mailman/Bouncers/Microsoft.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Microsoft's `SMTPSVC' nears I kin tell.""" - -import re -from cStringIO import StringIO - -scre = re.compile(r'transcript of session follows', re.IGNORECASE) - - - -def process(msg): - if msg.get_content_type() <> 'multipart/mixed': - return None - # Find the first subpart, which has no MIME type - try: - subpart = msg.get_payload(0) - except IndexError: - # The message *looked* like a multipart but wasn't - return None - data = subpart.get_payload() - if isinstance(data, list): - # The message is a multi-multipart, so not a matching bounce - return None - body = StringIO(data) - state = 0 - addrs = [] - while 1: - line = body.readline() - if not line: - break - if state == 0: - if scre.search(line): - state = 1 - if state == 1: - if '@' in line: - addrs.append(line) - return addrs diff --git a/src/mailman/Bouncers/Netscape.py b/src/mailman/Bouncers/Netscape.py deleted file mode 100644 index ae3125e68..000000000 --- a/src/mailman/Bouncers/Netscape.py +++ /dev/null @@ -1,89 +0,0 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Netscape Messaging Server bounce formats. - -I've seen at least one NMS server version 3.6 (envy.gmp.usyd.edu.au) bounce -messages of this format. Bounces come in DSN MIME format, but don't include -any -Recipient: headers. Gotta just parse the text :( - -NMS 4.1 (dfw-smtpin1.email.verio.net) seems even worse, but we'll try to -decipher the format here too. - -""" - -import re -from cStringIO import StringIO - -pcre = re.compile( - r'This Message was undeliverable due to the following reason:', - re.IGNORECASE) - -acre = re.compile( - r'(?P<reply>please reply to)?.*<(?P<addr>[^>]*)>', - re.IGNORECASE) - - - -def flatten(msg, leaves): - # give us all the leaf (non-multipart) subparts - if msg.is_multipart(): - for part in msg.get_payload(): - flatten(part, leaves) - else: - leaves.append(msg) - - - -def process(msg): - # Sigh. Some show NMS 3.6's show - # multipart/report; report-type=delivery-status - # and some show - # multipart/mixed; - if not msg.is_multipart(): - return None - # We're looking for a text/plain subpart occuring before a - # message/delivery-status subpart. - plainmsg = None - leaves = [] - flatten(msg, leaves) - for i, subpart in zip(range(len(leaves)-1), leaves): - if subpart.get_content_type() == 'text/plain': - plainmsg = subpart - break - if not plainmsg: - return None - # Total guesswork, based on captured examples... - body = StringIO(plainmsg.get_payload()) - addrs = [] - while 1: - line = body.readline() - if not line: - break - mo = pcre.search(line) - if mo: - # We found a bounce section, but I have no idea what the official - # format inside here is. :( We'll just search for <addr> - # strings. - while 1: - line = body.readline() - if not line: - break - mo = acre.search(line) - if mo and not mo.group('reply'): - addrs.append(mo.group('addr')) - return addrs diff --git a/src/mailman/Bouncers/Postfix.py b/src/mailman/Bouncers/Postfix.py deleted file mode 100644 index 3f78fbe88..000000000 --- a/src/mailman/Bouncers/Postfix.py +++ /dev/null @@ -1,86 +0,0 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Parse bounce messages generated by Postfix. - -This also matches something called `Keftamail' which looks just like Postfix -bounces with the word Postfix scratched out and the word `Keftamail' written -in in crayon. - -It also matches something claiming to be `The BNS Postfix program', and -`SMTP_Gateway'. Everybody's gotta be different, huh? -""" - -import re -from cStringIO import StringIO - - - -def flatten(msg, leaves): - # give us all the leaf (non-multipart) subparts - if msg.is_multipart(): - for part in msg.get_payload(): - flatten(part, leaves) - else: - leaves.append(msg) - - - -# are these heuristics correct or guaranteed? -pcre = re.compile(r'[ \t]*the\s*(bns)?\s*(postfix|keftamail|smtp_gateway)', - re.IGNORECASE) -rcre = re.compile(r'failure reason:$', re.IGNORECASE) -acre = re.compile(r'<(?P<addr>[^>]*)>:') - -def findaddr(msg): - addrs = [] - body = StringIO(msg.get_payload()) - # simple state machine - # 0 == nothing found - # 1 == salutation found - state = 0 - while 1: - line = body.readline() - if not line: - break - # preserve leading whitespace - line = line.rstrip() - # yes use match to match at beginning of string - if state == 0 and (pcre.match(line) or rcre.match(line)): - state = 1 - elif state == 1 and line: - mo = acre.search(line) - if mo: - addrs.append(mo.group('addr')) - # probably a continuation line - return addrs - - - -def process(msg): - if msg.get_content_type() not in ('multipart/mixed', 'multipart/report'): - return None - # We're looking for the plain/text subpart with a Content-Description: of - # `notification'. - leaves = [] - flatten(msg, leaves) - for subpart in leaves: - if subpart.get_content_type() == 'text/plain' and \ - subpart.get('content-description', '').lower() == 'notification': - # then... - return findaddr(subpart) - return None diff --git a/src/mailman/Bouncers/Qmail.py b/src/mailman/Bouncers/Qmail.py deleted file mode 100644 index 499571e47..000000000 --- a/src/mailman/Bouncers/Qmail.py +++ /dev/null @@ -1,72 +0,0 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Parse bounce messages generated by qmail. - -Qmail actually has a standard, called QSBMF (qmail-send bounce message -format), as described in - - http://cr.yp.to/proto/qsbmf.txt - -This module should be conformant. - -""" - -import re -import email.Iterators - -# Other (non-standard?) intros have been observed in the wild. -introtags = [ - 'Hi. This is the', - "We're sorry. There's a problem", - 'Check your send e-mail address.', - 'This is the mail delivery agent at', - 'Unfortunately, your mail was not delivered' - ] -acre = re.compile(r'<(?P<addr>[^>]*)>:') - - - -def process(msg): - addrs = [] - # simple state machine - # 0 = nothing seen yet - # 1 = intro paragraph seen - # 2 = recip paragraphs seen - state = 0 - for line in email.Iterators.body_line_iterator(msg): - line = line.strip() - if state == 0: - for introtag in introtags: - if line.startswith(introtag): - state = 1 - break - elif state == 1 and not line: - # Looking for the end of the intro paragraph - state = 2 - elif state == 2: - if line.startswith('-'): - # We're looking at the break paragraph, so we're done - break - # At this point we know we must be looking at a recipient - # paragraph - mo = acre.match(line) - if mo: - addrs.append(mo.group('addr')) - # Otherwise, it must be a continuation line, so just ignore it - # Not looking at anything in particular - return addrs diff --git a/src/mailman/Bouncers/Sina.py b/src/mailman/Bouncers/Sina.py deleted file mode 100644 index 15386abd3..000000000 --- a/src/mailman/Bouncers/Sina.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright (C) 2002-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""sina.com bounces""" - -import re -from email import Iterators - -acre = re.compile(r'<(?P<addr>[^>]*)>') - - - -def process(msg): - if msg.get('from', '').lower() <> 'mailer-daemon@sina.com': - print 'out 1' - return [] - if not msg.is_multipart(): - print 'out 2' - return [] - # The interesting bits are in the first text/plain multipart - part = None - try: - part = msg.get_payload(0) - except IndexError: - pass - if not part: - print 'out 3' - return [] - addrs = {} - for line in Iterators.body_line_iterator(part): - mo = acre.match(line) - if mo: - addrs[mo.group('addr')] = 1 - return addrs.keys() diff --git a/src/mailman/Bouncers/Yahoo.py b/src/mailman/Bouncers/Yahoo.py deleted file mode 100644 index 26c6183a0..000000000 --- a/src/mailman/Bouncers/Yahoo.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Yahoo! has its own weird format for bounces.""" - -import re -import email -from email.Utils import parseaddr - -tcre = re.compile(r'message\s+from\s+yahoo\.\S+', re.IGNORECASE) -acre = re.compile(r'<(?P<addr>[^>]*)>:') -ecre = re.compile(r'--- Original message follows') - - - -def process(msg): - # Yahoo! bounces seem to have a known subject value and something called - # an x-uidl: header, the value of which seems unimportant. - sender = parseaddr(msg.get('from', '').lower())[1] or '' - if not sender.startswith('mailer-daemon@yahoo'): - return None - addrs = [] - # simple state machine - # 0 == nothing seen - # 1 == tag line seen - state = 0 - for line in email.Iterators.body_line_iterator(msg): - line = line.strip() - if state == 0 and tcre.match(line): - state = 1 - elif state == 1: - mo = acre.match(line) - if mo: - addrs.append(mo.group('addr')) - continue - mo = ecre.match(line) - if mo: - # we're at the end of the error response - break - return addrs diff --git a/src/mailman/Bouncers/Yale.py b/src/mailman/Bouncers/Yale.py deleted file mode 100644 index b8a5c053e..000000000 --- a/src/mailman/Bouncers/Yale.py +++ /dev/null @@ -1,80 +0,0 @@ -# Copyright (C) 2000-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Yale's mail server is pretty dumb. - -Its reports include the end user's name, but not the full domain. I think we -can usually guess it right anyway. This is completely based on examination of -the corpse, and is subject to failure whenever Yale even slightly changes -their MTA. :( - -""" - -import re -from cStringIO import StringIO -from email.Utils import getaddresses - -scre = re.compile(r'Message not delivered to the following', re.IGNORECASE) -ecre = re.compile(r'Error Detail', re.IGNORECASE) -acre = re.compile(r'\s+(?P<addr>\S+)\s+') - - - -def process(msg): - if msg.is_multipart(): - return None - try: - whofrom = getaddresses([msg.get('from', '')])[0][1] - if not whofrom: - return None - username, domain = whofrom.split('@', 1) - except (IndexError, ValueError): - return None - if username.lower() <> 'mailer-daemon': - return None - parts = domain.split('.') - parts.reverse() - for part1, part2 in zip(parts, ('edu', 'yale')): - if part1 <> part2: - return None - # Okay, we've established that the bounce came from the mailer-daemon at - # yale.edu. Let's look for a name, and then guess the relevant domains. - names = {} - body = StringIO(msg.get_payload()) - state = 0 - # simple state machine - # 0 == init - # 1 == intro found - while 1: - line = body.readline() - if not line: - break - if state == 0 and scre.search(line): - state = 1 - elif state == 1 and ecre.search(line): - break - elif state == 1: - mo = acre.search(line) - if mo: - names[mo.group('addr')] = 1 - # Now we have a bunch of names, these are either @yale.edu or - # @cs.yale.edu. Add them both. - addrs = [] - for name in names.keys(): - addrs.append(name + '@yale.edu') - addrs.append(name + '@cs.yale.edu') - return addrs diff --git a/src/mailman/Utils.py b/src/mailman/Utils.py index 44058573f..c07888fc5 100644 --- a/src/mailman/Utils.py +++ b/src/mailman/Utils.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -32,12 +32,10 @@ __all__ = [ import os import re import cgi -import time import errno import base64 import random import logging -import htmlentitydefs # pylint: disable-msg=E0611,W0403 from email.errors import HeaderParseError @@ -50,7 +48,6 @@ import mailman.templates from mailman import passwords from mailman.config import config -from mailman.core import errors from mailman.core.i18n import _ from mailman.interfaces.languages import ILanguageManager from mailman.utilities.string import expand @@ -441,7 +438,7 @@ def uncanonstr(s, lang=None): if isinstance(s, unicode): return s.encode(charset) else: - u = unicode(s, charset) + unicode(s, charset) return s except UnicodeError: # Nope, it contains funny characters, so html-ref it diff --git a/src/mailman/__init__.py b/src/mailman/__init__.py index 5682e46fd..0b911fec9 100644 --- a/src/mailman/__init__.py +++ b/src/mailman/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py index 9d7ea0a74..7504c441b 100644 --- a/src/mailman/app/bounces.py +++ b/src/mailman/app/bounces.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -22,6 +22,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ 'bounce_message', + 'scan_message', ] import logging @@ -30,8 +31,10 @@ from email.mime.message import MIMEMessage from email.mime.text import MIMEText from mailman.Utils import oneline +from mailman.app.finder import find_components from mailman.core.i18n import _ from mailman.email.message import UserNotification +from mailman.interfaces.bounce import IBounceDetector log = logging.getLogger('mailman.config') @@ -69,3 +72,24 @@ def bounce_message(mlist, msg, e=None): bmsg.attach(txt) bmsg.attach(MIMEMessage(msg)) bmsg.send(mlist) + + + +def scan_message(mlist, msg): + """Scan all the message for heuristically determined bounce addresses. + + :param mlist: The mailing list. + :type mlist: `IMailingList` + :param msg: The bounce message to scan. + :type msg: `Message` + :return: The set of bouncing addresses found in the scanned message. The + set will be empty if no addresses were found. + :rtype: set + """ + for detector_class in find_components('mailman.bouncers', IBounceDetector): + addresses = detector_class().process(msg) + # Detectors may return None or an empty sequence to signify that no + # addresses have been found. + if addresses: + return set(addresses) + return set() diff --git a/src/mailman/app/commands.py b/src/mailman/app/commands.py index 91de5c3ad..0a9513927 100644 --- a/src/mailman/app/commands.py +++ b/src/mailman/app/commands.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/app/docs/bounces.txt b/src/mailman/app/docs/bounces.txt index a12305154..f18569743 100644 --- a/src/mailman/app/docs/bounces.txt +++ b/src/mailman/app/docs/bounces.txt @@ -4,15 +4,13 @@ Bounces An important feature of Mailman is automatic bounce process. -XXX Many more converted tests go here. - Bounces, or message rejection ============================= -Mailman can also bounce messages back to the original sender. This is -essentially equivalent to rejecting the message with notification. Mailing -lists can bounce a message with an optional error message. +Mailman can bounce messages back to the original sender. This is essentially +equivalent to rejecting the message with notification. Mailing lists can +bounce a message with an optional error message. >>> mlist = create_list('_xtest@example.com') @@ -28,7 +26,7 @@ Any message can be bounced. Bounce a message by passing in the original message, and an optional error message. The bounced message ends up in the virgin queue, awaiting sending -to the original messageauthor. +to the original message author. >>> from mailman.app.bounces import bounce_message >>> bounce_message(mlist, msg) @@ -66,7 +64,7 @@ to the original messageauthor. An error message can be given when the message is bounced, and this will be included in the payload of the text/plain part. The error message must be -passed in as an instance of a RejectMessage exception. +passed in as an instance of a ``RejectMessage`` exception. >>> from mailman.core.errors import RejectMessage >>> error = RejectMessage("This wasn't very important after all.") @@ -101,3 +99,34 @@ passed in as an instance of a RejectMessage exception. I sometimes say something important. <BLANKLINE> --...-- + + +Scanning a message +================== + +When a message hits the ``-bounces`` address for a mailing list, it is scanned +to see if it we can dig out a set of addresses that have bounced. + + >>> msg = message_from_string("""\ + ... To: test-bounces@example.com + ... From: postmaster@example.org + ... Content-Type: multipart/report; report-type=delivery-status; + ... boundary="AAA" + ... + ... --AAA + ... Content-Type: message/delivery-status + ... + ... Action: failed + ... Status: 5.0.0 (recipient reached disk quota) + ... Original-Recipient: rfc822; aperson@example.net + ... Final-Recipient: rfc822; anne.person@example.net + ... + ... --AAA-- + ... """) + +The DSN bouncer will return the ``Original-Recipient:`` in preference to the +``Final-Recipient:``. + + >>> from mailman.app.bounces import scan_message + >>> print scan_message(mlist, msg) + set([u'aperson@example.net']) diff --git a/src/mailman/app/docs/chains.txt b/src/mailman/app/docs/chains.txt index 216bf2f5b..8a8ac0cc2 100644 --- a/src/mailman/app/docs/chains.txt +++ b/src/mailman/app/docs/chains.txt @@ -13,22 +13,19 @@ processing of messages. The Discard chain ================= -The Discard chain simply throws the message away. +The `discard` chain simply throws the message away. +:: - >>> from zope.interface.verify import verifyObject - >>> from mailman.interfaces.chain import IChain >>> chain = config.chains['discard'] - >>> verifyObject(IChain, chain) - True >>> print chain.name discard >>> print chain.description Discard a message and stop processing. - >>> mlist = create_list('_xtest@example.com') + >>> mlist = create_list('test@example.com') >>> msg = message_from_string("""\ ... From: aperson@example.com - ... To: _xtest@example.com + ... To: test@example.com ... Subject: My first post ... Message-ID: <first> ... @@ -50,12 +47,11 @@ The Discard chain simply throws the message away. The Reject chain ================ -The Reject chain bounces the message back to the original sender, and logs +The `reject` chain bounces the message back to the original sender, and logs this action. +:: >>> chain = config.chains['reject'] - >>> verifyObject(IChain, chain) - True >>> print chain.name reject >>> print chain.description @@ -65,7 +61,7 @@ this action. ... process(mlist, msg, {}, 'reject') REJECT: <first> -The bounce message is now sitting in the Virgin queue. +The bounce message is now sitting in the `virgin` queue. >>> from mailman.testing.helpers import get_queue_messages >>> qfiles = get_queue_messages('virgin') @@ -73,7 +69,7 @@ The bounce message is now sitting in the Virgin queue. 1 >>> print qfiles[0].msg.as_string() Subject: My first post - From: _xtest-owner@example.com + From: test-owner@example.com To: aperson@example.com ... [No bounce details are available] @@ -82,7 +78,7 @@ The bounce message is now sitting in the Virgin queue. MIME-Version: 1.0 <BLANKLINE> From: aperson@example.com - To: _xtest@example.com + To: test@example.com Subject: My first post Message-ID: <first> <BLANKLINE> @@ -94,13 +90,11 @@ The bounce message is now sitting in the Virgin queue. 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. +The `hold` chain places the message into the administrative request database +and depending on the list's settings, sends a notification to both the +original sender and the list moderators. :: >>> chain = config.chains['hold'] - >>> verifyObject(IChain, chain) - True >>> print chain.name hold >>> print chain.description @@ -110,32 +104,39 @@ sender and the list moderators. ... process(mlist, msg, {}, 'hold') HOLD: <first> -There are now two messages in the Virgin queue, one to the list moderators and +There are now two messages in the virgin queue, one to the list moderators and one to the original author. >>> qfiles = get_queue_messages('virgin', sort_on='to') >>> len(qfiles) 2 -This message is addressed to the mailing list moderators. +One of the message is addressed to the mailing list moderators, and the other +is addressed to the original sender. - >>> print qfiles[0].msg.as_string() - Subject: _xtest@example.com post from aperson@example.com requires approval - From: _xtest-owner@example.com - To: _xtest-owner@example.com + >>> from operator import itemgetter + >>> messages = sorted((item.msg for item in qfiles), + ... key=itemgetter('to'), reverse=True) + +This one is addressed to the list moderators. + + >>> print messages[0].as_string() + Subject: test@example.com post from aperson@example.com requires approval + From: test-owner@example.com + To: test-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 + List: test@example.com From: aperson@example.com Subject: My first post Reason: XXX <BLANKLINE> At your convenience, visit: <BLANKLINE> - http://lists.example.com/admindb/_xtest@example.com + http://lists.example.com/admindb/test@example.com <BLANKLINE> to approve or deny the request. <BLANKLINE> @@ -144,7 +145,7 @@ This message is addressed to the mailing list moderators. MIME-Version: 1.0 <BLANKLINE> From: aperson@example.com - To: _xtest@example.com + To: test@example.com Subject: My first post Message-ID: <first> X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW @@ -159,7 +160,7 @@ This message is addressed to the mailing list moderators. MIME-Version: 1.0 Content-Transfer-Encoding: 7bit Subject: confirm ... - From: _xtest-request@example.com + From: test-request@example.com ... <BLANKLINE> If you reply to this message, keeping the Subject: header intact, @@ -172,15 +173,15 @@ This message is addressed to the mailing list moderators. This message is addressed to the sender of the message. - >>> print qfiles[1].msg.as_string() + >>> print messages[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 + Subject: Your message to test@example.com awaits moderator approval + From: test-bounces@example.com To: aperson@example.com ... - Your mail to '_xtest@example.com' with the subject + Your mail to 'test@example.com' with the subject <BLANKLINE> My first post <BLANKLINE> @@ -194,7 +195,7 @@ This message is addressed to the sender of the message. 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/... + http://lists.example.com/confirm/test@example.com/... <BLANKLINE> <BLANKLINE> @@ -203,10 +204,11 @@ 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].msg.get_payload().splitlines(): + >>> for line in messages[1].get_payload().splitlines(): ... mo = re.search('confirm/[^/]+/(?P<cookie>.*)$', line) ... if mo: ... cookie = mo.group('cookie') @@ -217,10 +219,12 @@ first item is a type code and the second item is a message id. >>> from zope.component import getUtility >>> data = getUtility(IPendings).confirm(cookie) - >>> sorted(data.items()) - [(u'id', ...), (u'type', u'held message')] + >>> dump_msgdata(data) + id : 1 + type: held message The message itself is held in the message store. +:: >>> from mailman.interfaces.requests import IRequests >>> list_requests = getUtility(IRequests).get_list_requests(mlist) @@ -233,7 +237,7 @@ The message itself is held in the message store. >>> print msg.as_string() From: aperson@example.com - To: _xtest@example.com + To: test@example.com Subject: My first post Message-ID: <first> X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW @@ -245,16 +249,16 @@ The message itself is held in the message store. The Accept chain ================ -The Accept chain sends the message on the 'prep' queue, where it will be +The `accept` chain sends the message on the `pipeline` queue, where it will be processed and sent on to the list membership. +:: >>> chain = config.chains['accept'] - >>> verifyObject(IChain, chain) - True >>> print chain.name accept >>> print chain.description Accept a message. + >>> with event_subscribers(print_msgid): ... process(mlist, msg, {}, 'accept') ACCEPT: <first> @@ -264,7 +268,7 @@ processed and sent on to the list membership. 1 >>> print qfiles[0].msg.as_string() From: aperson@example.com - To: _xtest@example.com + To: test@example.com Subject: My first post Message-ID: <first> X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW @@ -278,24 +282,26 @@ 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 +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 +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. +list. It runs through the default rules. >>> chain = config.chains['built-in'] - >>> verifyObject(IChain, chain) - True >>> print chain.name built-in >>> print chain.description 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 pipeline queue. +Once the sender is a member of the mailing list, the previously created +message is innocuous enough that it should pass through all default rules. +This message will end up in the `pipeline` queue. +:: + + >>> from mailman.testing.helpers import subscribe + >>> subscribe(mlist, 'Anne') >>> with event_subscribers(print_msgid): ... process(mlist, msg, {}) @@ -306,14 +312,13 @@ all default rules. This message will end up in the pipeline queue. 1 >>> print qfiles[0].msg.as_string() From: aperson@example.com - To: _xtest@example.com + To: test@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 + X-Mailman-Rule-Misses: approved; emergency; loop; member-moderation; + administrivia; implicit-dest; max-recipients; max-size; + news-moderation; no-subject; suspicious-header; nonmember-moderation <BLANKLINE> An important message. <BLANKLINE> @@ -321,10 +326,9 @@ all default rules. This message will end up in the pipeline queue. In addition, the message metadata now contains lists of all rules that have hit and all rules that have missed. - >>> sorted(qfiles[0].msgdata['rule_hits']) - [] - >>> for rule_name in sorted(qfiles[0].msgdata['rule_misses']): - ... print rule_name + >>> dump_list(qfiles[0].msgdata['rule_hits']) + *Empty* + >>> dump_list(qfiles[0].msgdata['rule_misses']) administrivia approved emergency @@ -332,6 +336,8 @@ hit and all rules that have missed. loop max-recipients max-size + member-moderation news-moderation no-subject + nonmember-moderation suspicious-header diff --git a/src/mailman/app/docs/hooks.txt b/src/mailman/app/docs/hooks.txt index 14dc76667..7e214f13f 100644 --- a/src/mailman/app/docs/hooks.txt +++ b/src/mailman/app/docs/hooks.txt @@ -4,7 +4,8 @@ Hooks Mailman defines two initialization hooks, one which is run early in the initialization process and the other run late in the initialization process. -Hooks name an importable callable so it must be accessible on sys.path. +Hooks name an importable callable so it must be accessible on ``sys.path``. +:: >>> import os, sys >>> from mailman.config import config @@ -46,12 +47,14 @@ We can set the pre-hook in the configuration file. The hooks are run in the second and third steps of initialization. However, we can't run those initialization steps in process, so call a command line script that will produce no output to force the hooks to run. +:: >>> import subprocess + >>> from mailman.testing.layers import ConfigLayer >>> def call(): ... proc = subprocess.Popen( ... 'bin/mailman lists --domain ignore -q'.split(), - ... cwd='../..', # testrunner runs from ./parts/test + ... cwd=ConfigLayer.root_directory, ... env=dict(MAILMAN_CONFIG_FILE=config_path, ... PYTHONPATH=config_directory), ... stdout=subprocess.PIPE, stderr=subprocess.PIPE) @@ -70,6 +73,7 @@ Post-hook ========= We can set the post-hook in the configuration file. +:: >>> with open(config_path, 'w') as fp: ... print >> fp, """\ @@ -91,6 +95,7 @@ Running both hooks ================== We can set the pre- and post-hooks in the configuration file. +:: >>> with open(config_path, 'w') as fp: ... print >> fp, """\ diff --git a/src/mailman/app/docs/lifecycle.txt b/src/mailman/app/docs/lifecycle.txt index 959c08cc8..b421d1800 100644 --- a/src/mailman/app/docs/lifecycle.txt +++ b/src/mailman/app/docs/lifecycle.txt @@ -3,8 +3,8 @@ Application level list life cycle ================================= The low-level way to create and delete a mailing list is to use the -IListManager interface. This interface simply adds or removes the appropriate -database entries to record the list's creation. +``IListManager`` interface. This interface simply adds or removes the +appropriate database entries to record the list's creation. There is a higher level interface for creating and deleting mailing lists which performs additional tasks such as: @@ -17,8 +17,6 @@ which performs additional tasks such as: * notifying watchers of list creation; * creating ancillary artifacts (such as the list's on-disk directory) - >>> from mailman.app.lifecycle import create_list - Posting address validation ========================== @@ -44,6 +42,7 @@ Creating a list applies its styles ================================== Start by registering a test style. +:: >>> from zope.interface import implements >>> from mailman.interfaces.styles import IStyle @@ -78,20 +77,27 @@ You can also specify a list of owner email addresses. If these addresses are not yet known, they will be registered, and new users will be linked to them. However the addresses are not verified. - >>> owners = ['aperson@example.com', 'bperson@example.com', - ... 'cperson@example.com', 'dperson@example.com'] + >>> owners = [ + ... 'aperson@example.com', + ... 'bperson@example.com', + ... 'cperson@example.com', + ... 'dperson@example.com', + ... ] >>> mlist_2 = create_list('test_2@example.com', owners) >>> print mlist_2.fqdn_listname test_2@example.com >>> print mlist_2.msg_footer test footer - >>> sorted(addr.address for addr in mlist_2.owners.addresses) - [u'aperson@example.com', u'bperson@example.com', - u'cperson@example.com', u'dperson@example.com'] + >>> dump_list(address.email for address in mlist_2.owners.addresses) + aperson@example.com + bperson@example.com + cperson@example.com + dperson@example.com None of the owner addresses are verified. - >>> any(addr.verified_on is not None for addr in mlist_2.owners.addresses) + >>> any(address.verified_on is not None + ... for address in mlist_2.owners.addresses) False However, all addresses are linked to users. @@ -102,6 +108,7 @@ However, all addresses are linked to users. If you create a mailing list with owner addresses that are already known to the system, they won't be created again. +:: >>> from mailman.interfaces.usermanager import IUserManager >>> from zope.component import getUtility @@ -117,8 +124,11 @@ the system, they won't be created again. >>> user_d.real_name = 'Dirk Person' >>> mlist_3 = create_list('test_3@example.com', owners) - >>> sorted(user.real_name for user in mlist_3.owners.users) - [u'Anne Person', u'Bart Person', u'Caty Person', u'Dirk Person'] + >>> dump_list(user.real_name for user in mlist_3.owners.users) + Anne Person + Bart Person + Caty Person + Dirk Person Deleting a list @@ -126,6 +136,7 @@ Deleting a list Removing a mailing list deletes the list, all its subscribers, and any related artifacts. +:: >>> from mailman.app.lifecycle import remove_list >>> remove_list(mlist_2.fqdn_listname, mlist_2, True) @@ -138,6 +149,8 @@ artifacts. We should now be able to completely recreate the mailing list. >>> mlist_2a = create_list('test_2@example.com', owners) - >>> sorted(addr.address for addr in mlist_2a.owners.addresses) - [u'aperson@example.com', u'bperson@example.com', - u'cperson@example.com', u'dperson@example.com'] + >>> dump_list(address.email for address in mlist_2a.owners.addresses) + aperson@example.com + bperson@example.com + cperson@example.com + dperson@example.com diff --git a/src/mailman/app/docs/message.txt b/src/mailman/app/docs/message.txt index 41607ff44..3e3293196 100644 --- a/src/mailman/app/docs/message.txt +++ b/src/mailman/app/docs/message.txt @@ -3,19 +3,19 @@ Messages ======== Mailman has its own Message classes, derived from the standard -email.message.Message class, but providing additional useful methods. +``email.message.Message`` class, but providing additional useful methods. User notifications ================== -When Mailman needs to send a message to a user, it creates a UserNotification -instance, and then calls the .send() method on this object. This method -requires a mailing list instance. +When Mailman needs to send a message to a user, it creates a +``UserNotification`` instance, and then calls the ``.send()`` method on this +object. This method requires a mailing list instance. >>> mlist = create_list('_xtest@example.com') -The UserNotification constructor takes the recipient address, the sender +The ``UserNotification`` constructor takes the recipient address, the sender address, an optional subject, optional body text, and optional language. >>> from mailman.email.message import UserNotification @@ -26,7 +26,7 @@ address, an optional subject, optional body text, and optional language. ... 'I needed to tell you this.') >>> msg.send(mlist) -The message will end up in the virgin queue. +The message will end up in the `virgin` queue. >>> switchboard = config.switchboards['virgin'] >>> len(switchboard.files) diff --git a/src/mailman/app/docs/styles.txt b/src/mailman/app/docs/styles.txt index 10312cd3a..63ec999bf 100644 --- a/src/mailman/app/docs/styles.txt +++ b/src/mailman/app/docs/styles.txt @@ -13,6 +13,7 @@ or not. And finally, application of a style to a mailing list can really modify the mailing list any way it wants. Let's start with a vanilla mailing list and a default style manager. +:: >>> from mailman.interfaces.listmanager import IListManager >>> from zope.component import getUtility @@ -44,9 +45,9 @@ last. Given a mailing list, you can ask the style manager to find all the styles that match the list. The registered styles will be sorted by decreasing -priority and each style's `match()` method will be called in turn. The sorted -list of matching styles will be returned -- but not applied -- by the style -manager's `lookup()` method. +priority and each style's ``match()`` method will be called in turn. The +sorted list of matching styles will be returned -- but not applied -- by the +style manager's ``lookup()`` method. >>> [style.name for style in style_manager.lookup(mlist)] ['default'] @@ -55,7 +56,7 @@ manager's `lookup()` method. Registering styles ================== -New styles must implement the IStyle interface. +New styles must implement the ``IStyle`` interface. >>> from zope.interface import implements >>> from mailman.interfaces.styles import IStyle @@ -93,6 +94,7 @@ Style priority When multiple styles match a particular mailing list, they are applied in descending order of priority. In other words, a priority zero style would be applied last. +:: >>> class AnotherTestStyle(TestStyle): ... name = 'another' diff --git a/src/mailman/app/docs/system.txt b/src/mailman/app/docs/system.txt index 035833047..844db9ee6 100644 --- a/src/mailman/app/docs/system.txt +++ b/src/mailman/app/docs/system.txt @@ -2,8 +2,9 @@ System versions =============== -Mailman system information is available through the System object, which -implements the ISystem interface. +Mailman system information is available through the ``system`` object, which +implements the ``ISystem`` interface. +:: >>> from mailman.interfaces.system import ISystem >>> from mailman.core.system import system @@ -12,13 +13,14 @@ implements the ISystem interface. >>> verifyObject(ISystem, system) True -The Mailman version is available via the system object. +The Mailman version is also available via the ``system`` object. >>> print system.mailman_version GNU Mailman ... -The Python version running underneath is also available via the system +The Python version running underneath is also available via the ``system`` object. +:: # The entire python_version string is variable, so this is the best test # we can do. diff --git a/src/mailman/app/finder.py b/src/mailman/app/finder.py index f6101fcaa..41685730a 100644 --- a/src/mailman/app/finder.py +++ b/src/mailman/app/finder.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -42,8 +42,9 @@ def find_components(package, interface): :type package: string :param interface: The interface that returned objects must conform to. :type interface: `Interface` + :return: The sequence of matching components. + :rtype: objects implementing `interface` """ - # Find all rules found in all modules inside our package. for filename in resource_listdir(package, ''): basename, extension = os.path.splitext(filename) if extension != '.py': diff --git a/src/mailman/app/lifecycle.py b/src/mailman/app/lifecycle.py index 6e50de9fe..39ec09aa7 100644 --- a/src/mailman/app/lifecycle.py +++ b/src/mailman/app/lifecycle.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py index d24522b8f..8ea8769a6 100644 --- a/src/mailman/app/membership.py +++ b/src/mailman/app/membership.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -31,7 +31,6 @@ from zope.component import getUtility from mailman import Utils from mailman.app.notifications import send_goodbye_message -from mailman.core import errors from mailman.core.i18n import _ from mailman.email.message import OwnerNotification from mailman.email.validate import validate @@ -42,7 +41,7 @@ from mailman.interfaces.usermanager import IUserManager -def add_member(mlist, address, realname, password, delivery_mode, language): +def add_member(mlist, email, realname, password, delivery_mode, language): """Add a member right now. The member's subscription must be approved by whatever policy the list @@ -50,16 +49,16 @@ def add_member(mlist, address, realname, password, delivery_mode, language): :param mlist: The mailing list to add the member to. :type mlist: `IMailingList` - :param address: The address to subscribe. - :type address: string + :param email: The email address to subscribe. + :type email: str :param realname: The subscriber's full name. - :type realname: string + :type realname: str :param password: The subscriber's password. - :type password: string + :type password: str :param delivery_mode: The delivery mode the subscriber has chosen. :type delivery_mode: DeliveryMode :param language: The language that the subscriber is going to use. - :type language: string + :type language: str :return: The just created member. :rtype: `IMember` :raises AlreadySubscribedError: if the user is already subscribed to @@ -68,56 +67,53 @@ def add_member(mlist, address, realname, password, delivery_mode, language): :raises MembershipIsBannedError: if the membership is not allowed. """ # Let's be extra cautious. - validate(address) - if mlist.members.get_member(address) is not None: + validate(email) + if mlist.members.get_member(email) is not None: raise AlreadySubscribedError( - mlist.fqdn_listname, address, MemberRole.member) - # Check for banned address here too for admin mass subscribes and - # confirmations. - pattern = Utils.get_pattern(address, mlist.ban_list) + mlist.fqdn_listname, email, MemberRole.member) + # Check for banned email addresses here too for administrative mass + # subscribes and confirmations. + pattern = Utils.get_pattern(email, mlist.ban_list) if pattern: - raise MembershipIsBannedError(mlist, address) - # Do the actual addition. First, see if there's already a user linked - # with the given address. + raise MembershipIsBannedError(mlist, email) + # See if there's already a user linked with the given address. user_manager = getUtility(IUserManager) - user = user_manager.get_user(address) + user = user_manager.get_user(email) if user is None: # A user linked to this address does not yet exist. Is the address # itself known but just not linked to a user? - address_obj = user_manager.get_address(address) - if address_obj is None: + address = user_manager.get_address(email) + if address is None: # Nope, we don't even know about this address, so create both the # user and address now. - user = user_manager.create_user(address, realname) + user = user_manager.create_user(email, realname) # Do it this way so we don't have to flush the previous change. - address_obj = list(user.addresses)[0] + address = list(user.addresses)[0] else: # The address object exists, but it's not linked to a user. # Create the user and link it now. user = user_manager.create_user() - user.real_name = (realname if realname else address_obj.real_name) - user.link(address_obj) + user.real_name = (realname if realname else address.real_name) + user.link(address) # Since created the user, then the member, and set preferences on the # appropriate object. user.password = password user.preferences.preferred_language = language - member = address_obj.subscribe(mlist, MemberRole.member) + member = address.subscribe(mlist, MemberRole.member) member.preferences.delivery_mode = delivery_mode else: # The user exists and is linked to the address. - for address_obj in user.addresses: - if address_obj.address == address: + for address in user.addresses: + if address.email == address: break else: raise AssertionError( 'User should have had linked address: {0}'.format(address)) # Create the member and set the appropriate preferences. # pylint: disable-msg=W0631 - member = address_obj.subscribe(mlist, MemberRole.member) + member = address.subscribe(mlist, MemberRole.member) member.preferences.preferred_language = language member.preferences.delivery_mode = delivery_mode -## mlist.setMemberOption(email, config.Moderate, -## mlist.default_member_moderation) return member diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py index 31d4e6151..cdfedd44b 100644 --- a/src/mailman/app/moderator.py +++ b/src/mailman/app/moderator.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/app/notifications.py b/src/mailman/app/notifications.py index d9409ac8d..985f4eece 100644 --- a/src/mailman/app/notifications.py +++ b/src/mailman/app/notifications.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/app/registrar.py b/src/mailman/app/registrar.py index 36a93b16e..181d48126 100644 --- a/src/mailman/app/registrar.py +++ b/src/mailman/app/registrar.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -31,7 +31,6 @@ from pkg_resources import resource_string from zope.component import getUtility from zope.interface import implements -from mailman.config import config from mailman.core.i18n import _ from mailman.email.message import UserNotification from mailman.email.validate import validate @@ -54,15 +53,15 @@ class Registrar: implements(IRegistrar) - def register(self, mlist, address, real_name=None): + def register(self, mlist, email, real_name=None): """See `IUserRegistrar`.""" # First, do validation on the email address. If the address is # invalid, it will raise an exception, otherwise it just returns. - validate(address) + validate(email) # Create a pendable for the registration. pendable = PendableRegistration( type=PendableRegistration.PEND_KEY, - address=address, + email=email, real_name=real_name) pendable['list_name'] = mlist.fqdn_listname token = getUtility(IPendings).add(pendable) @@ -73,14 +72,14 @@ class Registrar: # message and confirm through the web. subject = 'confirm ' + token confirm_address = mlist.confirm_address(token) - confirm_url = mlist.domain.confirm_url(token) # For i18n interpolation. - email_address = address + confirm_url = mlist.domain.confirm_url(token) + email_address = email domain_name = mlist.domain.email_host contact_address = mlist.domain.contact_address # Send a verification email to the address. text = _(resource_string('mailman.templates.en', 'verify.txt')) - msg = UserNotification(address, confirm_address, subject, text) + msg = UserNotification(email, confirm_address, subject, text) msg.send(mlist) return token @@ -91,7 +90,7 @@ class Registrar: if pendable is None: return False missing = object() - address = pendable.get('address', missing) + email = pendable.get('email', missing) real_name = pendable.get('real_name', missing) list_name = pendable.get('list_name', missing) if pendable.get('type') != PendableRegistration.PEND_KEY: @@ -105,41 +104,41 @@ class Registrar: # and an IUser linked to this IAddress. See if any of these objects # currently exist in our database. user_manager = getUtility(IUserManager) - addr = (user_manager.get_address(address) - if address is not missing else None) - user = (user_manager.get_user(address) - if address is not missing else None) + address = (user_manager.get_address(email) + if email is not missing else None) + user = (user_manager.get_user(email) + if email is not missing else None) # If there is neither an address nor a user matching the confirmed # record, then create the user, which will in turn create the address # and link the two together - if addr is None: + if address is None: assert user is None, 'How did we get a user but not an address?' - user = user_manager.create_user(address, real_name) + user = user_manager.create_user(email, real_name) # Because the database changes haven't been flushed, we can't use # IUserManager.get_address() to find the IAddress just created # under the hood. Instead, iterate through the IUser's addresses, # of which really there should be only one. - for addr in user.addresses: - if addr.address == address: + for address in user.addresses: + if address.email == email: break else: raise AssertionError('Could not find expected IAddress') elif user is None: user = user_manager.create_user() user.real_name = real_name - user.link(addr) + user.link(address) else: # The IAddress and linked IUser already exist, so all we need to # do is verify the address. pass - addr.verified_on = datetime.datetime.now() + address.verified_on = datetime.datetime.now() # If this registration is tied to a mailing list, subscribe the person # to the list right now. list_name = pendable.get('list_name') if list_name is not None: mlist = getUtility(IListManager).get(list_name) if mlist: - addr.subscribe(mlist, MemberRole.member) + address.subscribe(mlist, MemberRole.member) return True def discard(self, token): diff --git a/src/mailman/app/replybot.py b/src/mailman/app/replybot.py index 6cc344960..0fa73c601 100644 --- a/src/mailman/app/replybot.py +++ b/src/mailman/app/replybot.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -30,8 +30,6 @@ __all__ = [ import logging -from mailman.core.i18n import _ - log = logging.getLogger('mailman.vette') diff --git a/src/mailman/archiving/docs/common.txt b/src/mailman/archiving/docs/common.txt index 18400da42..1629458b3 100644 --- a/src/mailman/archiving/docs/common.txt +++ b/src/mailman/archiving/docs/common.txt @@ -15,12 +15,12 @@ archivers. ... Here is an archived message. ... """) -Archivers support an interface which provides the RFC 2369 List-Archive -header, and one that provides a 'permalink' to the specific message object in +Archivers support an interface which provides the RFC 2369 ``List-Archive:`` +header, and one that provides a *permalink* to the specific message object in the archive. This latter is appropriate for the message footer or for the RFC -5064 Archived-At header. +5064 ``Archived-At:`` header. -Pipermail does not support a permalink, so that interface returns None. +Pipermail does not support a permalink, so that interface returns ``None``. Mailman defines a draft spec for how list servers and archivers can interoperate. @@ -49,6 +49,7 @@ Sending the message to the archiver =================================== The archiver is also able to archive the message. +:: >>> archivers['pipermail'].archive_message(mlist, msg) @@ -71,10 +72,9 @@ Note however that the prototype archiver can't archive messages. The Mail-Archive.com ==================== -The Mail-Archive <http://www.mail-archive.com> is a public archiver that can -be used to archive message for free. Mailman comes with a plugin for this -archiver; by enabling it messages to public lists will get sent there -automatically. +`The Mail Archive`_ is a public archiver that can be used to archive message +for free. Mailman comes with a plugin for this archiver; by enabling it +messages to public lists will get sent there automatically. >>> archiver = archivers['mail-archive'] >>> print archiver.list_url(mlist) @@ -83,7 +83,8 @@ automatically. http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc= To archive the message, the archiver actually mails the message to a special -address at the Mail-Archive. The message gets no header or footer decoration. +address at The Mail Archive. The message gets no header or footer decoration. +:: >>> archiver.archive_message(mlist, msg) @@ -123,7 +124,8 @@ at this service. >>> list(smtpd.messages) [] -Additionally, this archiver can handle malformed Message-IDs. +Additionally, this archiver can handle malformed ``Message-IDs``. +:: >>> mlist.archive_private = False >>> del msg['message-id'] @@ -150,7 +152,7 @@ Additionally, this archiver can handle malformed Message-IDs. MHonArc ======= -The MHonArc archiver <http://www.mhonarc.org> is also available. +A MHonArc_ archiver is also available. >>> archiver = archivers['mhonarc'] >>> print archiver.name @@ -173,3 +175,6 @@ subprocess call. -stdout /.../logs/mhonarc -spammode -umask 022 ... + +.. _`The Mail Archive`: http://www.mail-archive.com +.. _MHonArc: http://www.mhonarc.org diff --git a/src/mailman/archiving/mailarchive.py b/src/mailman/archiving/mailarchive.py index ac750d18e..4218c549f 100644 --- a/src/mailman/archiving/mailarchive.py +++ b/src/mailman/archiving/mailarchive.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/archiving/mhonarc.py b/src/mailman/archiving/mhonarc.py index 55ca579f6..81baca7d3 100644 --- a/src/mailman/archiving/mhonarc.py +++ b/src/mailman/archiving/mhonarc.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/archiving/pipermail.py b/src/mailman/archiving/pipermail.py index f4131b294..f1bcb3b01 100644 --- a/src/mailman/archiving/pipermail.py +++ b/src/mailman/archiving/pipermail.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -29,7 +29,6 @@ import os import mailbox import tempfile -from cStringIO import StringIO from zope.interface import implements from zope.interface.interface import adapter_hooks @@ -95,7 +94,7 @@ class Pipermail: def list_url(mlist): """See `IArchiver`.""" if mlist.archive_private: - url = mlist.script_url('private') + '/index.html' + return mlist.script_url('private') + '/index.html' else: return expand(config.archiver.pipermail.base_url, dict(listname=mlist.fqdn_listname, diff --git a/src/mailman/archiving/prototype.py b/src/mailman/archiving/prototype.py index 44e2a9e34..902b9a263 100644 --- a/src/mailman/archiving/prototype.py +++ b/src/mailman/archiving/prototype.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -31,7 +31,6 @@ from base64 import b32encode from urlparse import urljoin from zope.interface import implements -from mailman.config import config from mailman.interfaces.archiver import IArchiver diff --git a/src/mailman/bin/arch.py b/src/mailman/bin/arch.py index 713af1013..cb7a81dae 100644 --- a/src/mailman/bin/arch.py +++ b/src/mailman/bin/arch.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -23,7 +23,7 @@ import errno import shutil import optparse -from locknix.lockfile import Lock +from flufl.lock import Lock from mailman.Archiver.HyperArch import HyperArchive from mailman.Defaults import hours diff --git a/src/mailman/bin/bumpdigests.py b/src/mailman/bin/bumpdigests.py index c462fc9f8..c68f22264 100644 --- a/src/mailman/bin/bumpdigests.py +++ b/src/mailman/bin/bumpdigests.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -58,7 +58,7 @@ def main(): try: # Be sure the list is locked mlist = MailList.MailList(listname) - except errors.MMListError, e: + except errors.MMListError: parser.print_help() print >> sys.stderr, _('No such list: $listname') sys.exit(1) diff --git a/src/mailman/bin/check_perms.py b/src/mailman/bin/check_perms.py index 3250dee82..5cf009f37 100644 --- a/src/mailman/bin/check_perms.py +++ b/src/mailman/bin/check_perms.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/checkdbs.py b/src/mailman/bin/checkdbs.py index 78c10ea37..fed40a215 100644 --- a/src/mailman/bin/checkdbs.py +++ b/src/mailman/bin/checkdbs.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/cleanarch.py b/src/mailman/bin/cleanarch.py index 05e8de378..5bae3244d 100644 --- a/src/mailman/bin/cleanarch.py +++ b/src/mailman/bin/cleanarch.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/config_list.py b/src/mailman/bin/config_list.py index db6755050..845a1371d 100644 --- a/src/mailman/bin/config_list.py +++ b/src/mailman/bin/config_list.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/disabled.py b/src/mailman/bin/disabled.py index 450e69be3..6058ec10c 100644 --- a/src/mailman/bin/disabled.py +++ b/src/mailman/bin/disabled.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/docs/master.txt b/src/mailman/bin/docs/master.txt index 20eddae2c..d3c07c768 100644 --- a/src/mailman/bin/docs/master.txt +++ b/src/mailman/bin/docs/master.txt @@ -3,9 +3,9 @@ Mailman queue runner control ============================ Mailman has a number of queue runners which process messages in its queue file -directories. In normal operation, the 'bin/mailman' command is used to start, -stop and manage the queue runners. This is just a wrapper around the real -queue runner watcher script called master.py. +directories. In normal operation, the ``bin/mailman`` command is used to +start, stop and manage the queue runners. This is just a wrapper around the +real queue runner watcher script called master.py. >>> from mailman.testing.helpers import TestableMaster @@ -23,6 +23,7 @@ There should be a process id for every qrunner that claims to be startable. True Now verify that all the qrunners are running. +:: >>> import os diff --git a/src/mailman/bin/export.py b/src/mailman/bin/export.py index 7632384d2..df4fa07e0 100644 --- a/src/mailman/bin/export.py +++ b/src/mailman/bin/export.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/find_member.py b/src/mailman/bin/find_member.py index c264e2655..9571219f0 100644 --- a/src/mailman/bin/find_member.py +++ b/src/mailman/bin/find_member.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/gate_news.py b/src/mailman/bin/gate_news.py index c10248c53..1873def37 100644 --- a/src/mailman/bin/gate_news.py +++ b/src/mailman/bin/gate_news.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,7 +25,7 @@ import optparse import email.Errors from email.Parser import Parser -from locknix import lockfile +from flufl.lock import Lock, TimeOutError from mailman import MailList from mailman import Message @@ -209,7 +209,7 @@ def process_lists(glock): # loop over range, and this will not include the last # element in the list. poll_newsgroup(mlist, conn, start, last + 1, glock) - except lockfile.TimeOutError: + except TimeOutError: log.error('Could not acquire list lock: %s', listname) finally: if mlist.Locked(): @@ -230,12 +230,12 @@ def main(): log = logging.getLogger('mailman.fromusenet') try: - with lockfile.Lock(GATENEWS_LOCK_FILE, - # It's okay to hijack this - lifetime=LOCK_LIFETIME) as lock: + with Lock(GATENEWS_LOCK_FILE, + # It's okay to hijack this + lifetime=LOCK_LIFETIME) as lock: process_lists(lock) clearcache() - except lockfile.TimeOutError: + except TimeOutError: log.error('Could not acquire gate_news lock') diff --git a/src/mailman/bin/import.py b/src/mailman/bin/import.py index a8d89c34a..d4173aef2 100644 --- a/src/mailman/bin/import.py +++ b/src/mailman/bin/import.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/list_owners.py b/src/mailman/bin/list_owners.py index 1d7c6a5a7..b5db2ec3c 100644 --- a/src/mailman/bin/list_owners.py +++ b/src/mailman/bin/list_owners.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2002-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/mailman.py b/src/mailman/bin/mailman.py index e8e9f38ac..4e3a36971 100644 --- a/src/mailman/bin/mailman.py +++ b/src/mailman/bin/mailman.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -44,7 +44,7 @@ def main(): parser = argparse.ArgumentParser( description=_("""\ The GNU Mailman mailing list management system - Copyright 1998-2010 by the Free Software Foundation, Inc. + Copyright 1998-2011 by the Free Software Foundation, Inc. http://www.list.org """), formatter_class=argparse.RawDescriptionHelpFormatter) diff --git a/src/mailman/bin/master.py b/src/mailman/bin/master.py index 96b265864..edc0452d7 100644 --- a/src/mailman/bin/master.py +++ b/src/mailman/bin/master.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -27,16 +27,15 @@ __all__ = [ import os import sys -import time import errno import signal import socket import logging -from datetime import datetime, timedelta +from datetime import timedelta from flufl.enum import Enum +from flufl.lock import Lock, NotLockedError, TimeOutError from lazr.config import as_boolean -from locknix import lockfile from mailman.config import config from mailman.core.i18n import _ @@ -110,25 +109,10 @@ instead of the default set. Multiple -r options may be given. The values for -def get_lock_data(): - """Get information from the master lock file. - - :return: A 3-tuple of the hostname, integer process id, and file name of - the lock file. - """ - with open(config.LOCK_FILE) as fp: - filename = os.path.split(fp.read().strip())[1] - parts = filename.split('.') - timestamp = parts.pop() - pid = parts.pop() - hostname = parts.pop() - filename = DOT.join(reversed(parts)) - return hostname, int(pid), filename - - -# pylint: disable-msg=W0232 class WatcherState(Enum): """Enum for the state of the master process watcher.""" + # No lock has been acquired by any process. + none = 0 # Another master watcher is running. conflict = 1 # No conflicting process exists. @@ -137,47 +121,60 @@ class WatcherState(Enum): host_mismatch = 3 -def master_state(): +def master_state(lock_file=None): """Get the state of the master watcher. - :return: WatcherState describing the state of the lock file. + :param lock_file: Path to the lock file, otherwise `config.LOCK_FILE`. + :type lock_file: str + :return: 2-tuple of the WatcherState describing the state of the lock + file, and the lock object. """ - # pylint: disable-msg=W0612 - hostname, pid, tempfile = get_lock_data() - if hostname != socket.gethostname(): - return WatcherState.host_mismatch + if lock_file is None: + lock_file = config.LOCK_FILE + # We'll never acquire the lock, so the lifetime doesn't matter. + lock = Lock(lock_file) + try: + hostname, pid, tempfile = lock.details + except NotLockedError: + return WatcherState.none, lock + if hostname != socket.getfqdn(): + return WatcherState.host_mismatch, lock # Find out if the process exists by calling kill with a signal 0. try: os.kill(pid, 0) - return WatcherState.conflict + return WatcherState.conflict, lock except OSError as error: if error.errno == errno.ESRCH: # No matching process id. - return WatcherState.stale_lock + return WatcherState.stale_lock, lock # Some other error occurred. raise -def acquire_lock_1(force): +def acquire_lock_1(force, lock_file=None): """Try to acquire the master queue runner lock. :param force: Flag that controls whether to force acquisition of the lock. + :type force: bool + :param lock_file: Path to the lock file, otherwise `config.LOCK_FILE`. + :type lock_file: str :return: The master queue runner lock. :raises: `TimeOutError` if the lock could not be acquired. """ - lock = lockfile.Lock(config.LOCK_FILE, LOCK_LIFETIME) + if lock_file is None: + lock_file = config.LOCK_FILE + lock = Lock(lock_file, LOCK_LIFETIME) try: lock.lock(timedelta(seconds=0.1)) return lock - except lockfile.TimeOutError: + except TimeOutError: if not force: raise # Force removal of lock first. lock.disown() - # pylint: disable-msg=W0612 - hostname, pid, tempfile = get_lock_data() - os.unlink(config.LOCK_FILE) - os.unlink(os.path.join(config.LOCK_DIR, tempfile)) + hostname, pid, tempfile = lock.details + os.unlink(lock_file) + os.unlink(tempfile) return acquire_lock_1(force=False) @@ -191,26 +188,23 @@ def acquire_lock(force): try: lock = acquire_lock_1(force) return lock - except lockfile.TimeOutError: - status = master_state() - if status == WatcherState.conflict: + except TimeOutError: + status, lock = master_state() + if status is WatcherState.conflict: # Hostname matches and process exists. message = _("""\ The master queue runner lock could not be acquired because it appears as though another master is already running.""") - elif status == WatcherState.stale_lock: + elif status is WatcherState.stale_lock: # Hostname matches but the process does not exist. program = sys.argv[0] message = _("""\ The master queue runner lock could not be acquired. It appears as though there is a stale master lock. Try re-running $program with the --force flag.""") - else: + elif status is WatcherState.host_mismatch: # Hostname doesn't even match. - assert status == WatcherState.host_mismatch, ( - 'Invalid enum value: %s' % status) - # pylint: disable-msg=W0612 - hostname, pid, tempfile = get_lock_data() + hostname, pid, tempfile = lock.details message = _("""\ The master qrunner lock could not be acquired, because it appears as if some process on some other host may have acquired it. We can't @@ -221,6 +215,18 @@ Lock file: $config.LOCK_FILE Lock host: $hostname Exiting.""") + else: + assert status is WatcherState.none, ( + 'Invalid enum value: %s' % status) + hostname, pid, tempfile = lock.details + message = _("""\ +For unknown reasons, the master qrunner lock could not be acquired. + + +Lock file: $config.LOCK_FILE +Lock host: $hostname + +Exiting.""") config.options.parser.error(message) @@ -300,7 +306,6 @@ class Loop: # Set up our signal handlers. Also set up a SIGALRM handler to # refresh the lock once per day. The lock lifetime is 1 day + 6 hours # so this should be plenty. - # pylint: disable-msg=W0613,C0111 def sigalrm_handler(signum, frame): self._lock.refresh() signal.alarm(SECONDS_IN_A_DAY) @@ -490,7 +495,6 @@ qrunner %s reached maximum restart limit of %d, not restarting.""", # Wait for all the children to go away. while self._kids: try: - # pylint: disable-msg=W0612 pid, status = os.wait() self._kids.drop(pid) except OSError as error: diff --git a/src/mailman/bin/mmsitepass.py b/src/mailman/bin/mmsitepass.py index 57adaa432..c17d87526 100644 --- a/src/mailman/bin/mmsitepass.py +++ b/src/mailman/bin/mmsitepass.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/nightly_gzip.py b/src/mailman/bin/nightly_gzip.py index 6ff200447..e8d899665 100644 --- a/src/mailman/bin/nightly_gzip.py +++ b/src/mailman/bin/nightly_gzip.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/onebounce.py b/src/mailman/bin/onebounce.py index f044d0d8c..7811de104 100644 --- a/src/mailman/bin/onebounce.py +++ b/src/mailman/bin/onebounce.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2002-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/qrunner.py b/src/mailman/bin/qrunner.py index ac8cd7dd3..f98fd98c6 100644 --- a/src/mailman/bin/qrunner.py +++ b/src/mailman/bin/qrunner.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,6 +25,7 @@ __all__ = [ ] +import os import sys import signal import logging @@ -147,7 +148,7 @@ def make_qrunner(name, slice, range, once=False): class_path = name try: qrclass = find_name(class_path) - except ImportError as error: + except ImportError: if os.environ.get('MAILMAN_UNDER_MASTER_CONTROL') is not None: # Exit with SIGTERM exit code so the master watcher won't try to # restart us. diff --git a/src/mailman/bin/senddigests.py b/src/mailman/bin/senddigests.py index 65ab9f633..5e95ffeef 100644 --- a/src/mailman/bin/senddigests.py +++ b/src/mailman/bin/senddigests.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/set_members.py b/src/mailman/bin/set_members.py index 3d5881307..15ebeb29a 100644 --- a/src/mailman/bin/set_members.py +++ b/src/mailman/bin/set_members.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/show_config.py b/src/mailman/bin/show_config.py index caf4bf87f..80b957088 100644 --- a/src/mailman/bin/show_config.py +++ b/src/mailman/bin/show_config.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/bin/show_qfiles.py b/src/mailman/bin/show_qfiles.py index d00c4d0da..982e535d4 100644 --- a/src/mailman/bin/show_qfiles.py +++ b/src/mailman/bin/show_qfiles.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/Bouncers/__init__.py b/src/mailman/bin/tests/__init__.py index e69de29bb..e69de29bb 100644 --- a/src/mailman/Bouncers/__init__.py +++ b/src/mailman/bin/tests/__init__.py diff --git a/src/mailman/bin/tests/test_master.py b/src/mailman/bin/tests/test_master.py new file mode 100644 index 000000000..8f79250ed --- /dev/null +++ b/src/mailman/bin/tests/test_master.py @@ -0,0 +1,81 @@ +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Test master watcher utilities.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'test_suite', + ] + + +import os +import errno +import tempfile +import unittest + +from flufl.lock import Lock + +from mailman.bin import master + + + +class TestMasterLock(unittest.TestCase): + def setUp(self): + fd, self.lock_file = tempfile.mkstemp() + os.close(fd) + # The lock file should not exist before we try to acquire it. + os.remove(self.lock_file) + + def tearDown(self): + # Unlocking removes the lock file, but just to be safe (i.e. in case + # of errors). + try: + os.remove(self.lock_file) + except OSError as error: + if error.errno != errno.ENOENT: + raise + + def test_acquire_lock_1(self): + lock = master.acquire_lock_1(False, self.lock_file) + is_locked = lock.is_locked + lock.unlock() + self.failUnless(is_locked) + + def test_master_state(self): + my_lock = Lock(self.lock_file) + # Mailman is not running. + state, lock = master.master_state(self.lock_file) + self.assertEqual(state, master.WatcherState.none) + # Acquire the lock as if another process had already started the + # master qrunner. + my_lock.lock() + try: + state, lock = master.master_state(self.lock_file) + finally: + my_lock.unlock() + self.assertEqual(state, master.WatcherState.conflict) + # XXX test stale_lock and host_mismatch states. + + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(unittest.makeSuite(TestMasterLock)) + return suite diff --git a/src/mailman/bin/update.py b/src/mailman/bin/update.py index c4f7f0cf1..1b9a7094b 100644 --- a/src/mailman/bin/update.py +++ b/src/mailman/bin/update.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -26,7 +26,7 @@ import cPickle import marshal import optparse -from locknix.lockfile import TimeOutError +from flufl.lock import TimeOutError from mailman import MailList from mailman import Message diff --git a/src/mailman/bouncers/AOL.py b/src/mailman/bouncers/AOL.py new file mode 100644 index 000000000..df8dd8c47 --- /dev/null +++ b/src/mailman/bouncers/AOL.py @@ -0,0 +1,45 @@ +# Copyright (C) 2009 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. + +"""Recognizes a class of messages from AOL that report only Screen Name.""" + +import re +from email.Utils import parseaddr + +scre = re.compile('mail to the following recipients could not be delivered') + +def process(msg): + if msg.get_content_type() <> 'text/plain': + return + if not parseaddr(msg.get('from', ''))[1].lower().endswith('@aol.com'): + return + addrs = [] + found = False + for line in msg.get_payload(decode=True).splitlines(): + if scre.search(line): + found = True + continue + if found: + local = line.strip() + if local: + if re.search(r'\s', local): + break + if re.search('@', local): + addrs.append(local) + else: + addrs.append('%s@aol.com' % local) + return addrs diff --git a/src/mailman/Bouncers/Compuserve.py b/src/mailman/bouncers/Compuserve.py index 13052b68e..13052b68e 100644 --- a/src/mailman/Bouncers/Compuserve.py +++ b/src/mailman/bouncers/Compuserve.py diff --git a/src/mailman/bouncers/__init__.py b/src/mailman/bouncers/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/bouncers/__init__.py diff --git a/src/mailman/bouncers/caiwireless.py b/src/mailman/bouncers/caiwireless.py new file mode 100644 index 000000000..26f46f331 --- /dev/null +++ b/src/mailman/bouncers/caiwireless.py @@ -0,0 +1,66 @@ +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Parse mystery style generated by MTA at caiwireless.net.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Caiwireless', + ] + + +import re + +from email.iterators import body_line_iterator +from flufl.enum import Enum +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + + +tcre = re.compile(r'the following recipients did not receive this message:', + re.IGNORECASE) +acre = re.compile(r'<(?P<addr>[^>]*)>') + + +class ParseState(Enum): + start = 0 + tag_seen = 1 + + + +class Caiwireless: + """Parse mystery style generated by MTA at caiwireless.net.""" + + implements(IBounceDetector) + + def process(self, msg): + if msg.get_content_type() != 'multipart/mixed': + return None + state = ParseState.start + # This format thinks it's a MIME, but it really isn't. + for line in body_line_iterator(msg): + line = line.strip() + if state is ParseState.start and tcre.match(line): + state = ParseState.tag_seen + elif state is ParseState.tag_seen and line: + mo = acre.match(line) + if not mo: + return None + return [mo.group('addr')] diff --git a/src/mailman/Bouncers/DSN.py b/src/mailman/bouncers/dsn.py index ce53df28e..d3167e034 100644 --- a/src/mailman/Bouncers/DSN.py +++ b/src/mailman/bouncers/dsn.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -21,16 +21,25 @@ RFC 3464 obsoletes 1894 which was the old DSN standard. This module has not been audited for differences between the two. """ -from email.Iterators import typed_subpart_iterator -from email.Utils import parseaddr +from __future__ import absolute_import, unicode_literals -from mailman.Bouncers.BouncerAPI import Stop +__metaclass__ = type +__all__ = [ + 'DSN', + ] + + +from email.iterators import typed_subpart_iterator +from email.utils import parseaddr +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector, NonFatal def check(msg): - # Iterate over each message/delivery-status subpart - addrs = [] + # Iterate over each message/delivery-status subpart. + addresses = [] for part in typed_subpart_iterator(msg, 'message', 'delivery-status'): if not part.is_multipart(): # Huh? @@ -48,9 +57,9 @@ def check(msg): action = msgblock.get('action', '').lower() # Some MTAs have been observed that put comments on the action. if action.startswith('delayed'): - return Stop + return NonFatal if not action.startswith('fail'): - # Some non-permanent failure, so ignore this block + # Some non-permanent failure, so ignore this block. continue params = [] foundp = False @@ -62,7 +71,7 @@ def check(msg): params.append(k) if foundp: # Note that params should already be unquoted. - addrs.extend(params) + addresses.extend(params) break else: # MAS: This is a kludge, but SMTP-GATEWAY01.intra.home.dk @@ -70,30 +79,30 @@ def check(msg): # address-type parameter at all. Non-compliant, but ... for param in params: if param.startswith('<') and param.endswith('>'): - addrs.append(param[1:-1]) - # Uniquify - rtnaddrs = {} - for a in addrs: - if a is not None: - realname, a = parseaddr(a) - rtnaddrs[a] = True - return rtnaddrs.keys() + addresses.append(param[1:-1]) + return set(parseaddr(address)[1] for address in addresses + if address is not None) -def process(msg): - # A DSN has been seen wrapped with a "legal disclaimer" by an outgoing MTA - # in a multipart/mixed outer part. - if msg.is_multipart() and msg.get_content_subtype() == 'mixed': - msg = msg.get_payload()[0] - # The above will suffice if the original message 'parts' were wrapped with - # the disclaimer added, but the original DSN can be wrapped as a - # message/rfc822 part. We need to test that too. - if msg.is_multipart() and msg.get_content_type() == 'message/rfc822': - msg = msg.get_payload()[0] - # The report-type parameter should be "delivery-status", but it seems that - # some DSN generating MTAs don't include this on the Content-Type: header, - # so let's relax the test a bit. - if not msg.is_multipart() or msg.get_content_subtype() <> 'report': - return None - return check(msg) +class DSN: + """Parse RFC 3464 (i.e. DSN) bounce formats.""" + + implements(IBounceDetector) + + def process(self, msg): + # A DSN has been seen wrapped with a "legal disclaimer" by an outgoing + # MTA in a multipart/mixed outer part. + if msg.is_multipart() and msg.get_content_subtype() == 'mixed': + msg = msg.get_payload()[0] + # The above will suffice if the original message 'parts' were wrapped + # with the disclaimer added, but the original DSN can be wrapped as a + # message/rfc822 part. We need to test that too. + if msg.is_multipart() and msg.get_content_type() == 'message/rfc822': + msg = msg.get_payload()[0] + # The report-type parameter should be "delivery-status", but it seems + # that some DSN generating MTAs don't include this on the + # Content-Type: header, so let's relax the test a bit. + if not msg.is_multipart() or msg.get_content_subtype() <> 'report': + return None + return check(msg) diff --git a/src/mailman/bouncers/exchange.py b/src/mailman/bouncers/exchange.py new file mode 100644 index 000000000..380165c94 --- /dev/null +++ b/src/mailman/bouncers/exchange.py @@ -0,0 +1,67 @@ +# Copyright (C) 2002-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Recognizes (some) Microsoft Exchange formats.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Exchange', + ] + + +import re + +from email.iterators import body_line_iterator +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + + +scre = re.compile('did not reach the following recipient') +ecre = re.compile('MSEXCH:') +a1cre = re.compile('SMTP=(?P<addr>[^;]+); on ') +a2cre = re.compile('(?P<addr>[^ ]+) on ') + + + +class Exchange: + """Recognizes (some) Microsoft Exchange formats.""" + + implements(IBounceDetector) + + def process(self, msg): + """See `IBounceDetector`.""" + addresses = set() + it = body_line_iterator(msg) + # Find the start line. + for line in it: + if scre.search(line): + break + else: + return [] + # Search each line until we hit the end line. + for line in it: + if ecre.search(line): + break + mo = a1cre.search(line) + if not mo: + mo = a2cre.search(line) + if mo: + addresses.add(mo.group('addr')) + return list(addresses) diff --git a/src/mailman/Bouncers/Exim.py b/src/mailman/bouncers/exim.py index 1a5133eed..7241eae3c 100644 --- a/src/mailman/Bouncers/Exim.py +++ b/src/mailman/bouncers/exim.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -22,10 +22,27 @@ an `addresslist' of failed addresses. """ -from email.Utils import getaddresses +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Exim', + ] + + +from email.utils import getaddresses +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector -def process(msg): - all = msg.get_all('x-failed-recipients', []) - return [a for n, a in getaddresses(all)] +class Exim: + """Parse bounce messages generated by Exim.""" + + implements(IBounceDetector) + + def process(self, msg): + """See `IBounceDetector`.""" + all = msg.get_all('x-failed-recipients', []) + return [address for name, address in getaddresses(all)] diff --git a/src/mailman/Bouncers/GroupWise.py b/src/mailman/bouncers/groupwise.py index d7d7d4a20..dea2d8b92 100644 --- a/src/mailman/Bouncers/GroupWise.py +++ b/src/mailman/bouncers/groupwise.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -22,9 +22,22 @@ X-Mailer: NTMail v4.30.0012 X-Mailer: Internet Mail Service (5.5.2653.19) """ +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'GroupWise', + ] + + import re + from email.Message import Message from cStringIO import StringIO +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + acre = re.compile(r'<(?P<addr>[^>]*)>') @@ -44,28 +57,31 @@ def find_textplain(msg): -def process(msg): - if msg.get_content_type() <> 'multipart/mixed' or not msg['x-mailer']: - return None - addrs = {} - # find the first text/plain part in the message - textplain = find_textplain(msg) - if not textplain: - return None - body = StringIO(textplain.get_payload()) - while 1: - line = body.readline() - if not line: - break - mo = acre.search(line) - if mo: - addrs[mo.group('addr')] = 1 - elif '@' in line: - i = line.find(' ') - if i == 0: - continue - if i < 0: - addrs[line] = 1 - else: - addrs[line[:i]] = 1 - return addrs.keys() +class GroupWise: + """Parse Novell GroupWise and NTMail bounces.""" + + implements(IBounceDetector) + + def process(self, msg): + """See `IBounceDetector`.""" + if msg.get_content_type() != 'multipart/mixed' or not msg['x-mailer']: + return None + addresses = set() + # Find the first text/plain part in the message. + text_plain = find_textplain(msg) + if text_plain is None: + return None + body = StringIO(text_plain.get_payload()) + for line in body: + mo = acre.search(line) + if mo: + addresses.add(mo.group('addr')) + elif '@' in line: + i = line.find(' ') + if i == 0: + continue + if i < 0: + addresses.add(line) + else: + addresses.add(line[:i]) + return list(addresses) diff --git a/src/mailman/Bouncers/LLNL.py b/src/mailman/bouncers/llnl.py index d3fe282cc..c4efc40ea 100644 --- a/src/mailman/Bouncers/LLNL.py +++ b/src/mailman/bouncers/llnl.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -17,16 +17,36 @@ """LLNL's custom Sendmail bounce message.""" +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'LLNL', + ] + + import re -import email + +from email.iterators import body_line_iterator +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + acre = re.compile(r',\s*(?P<addr>\S+@[^,]+),', re.IGNORECASE) -def process(msg): - for line in email.Iterators.body_line_iterator(msg): - mo = acre.search(line) - if mo: - return [mo.group('addr')] - return [] +class LLNL: + """LLNL's custom Sendmail bounce message.""" + + implements(IBounceDetector) + + def process(self, msg): + """See `IBounceDetector`.""" + + for line in body_line_iterator(msg): + mo = acre.search(line) + if mo: + return [mo.group('addr')] + return [] diff --git a/src/mailman/bouncers/microsoft.py b/src/mailman/bouncers/microsoft.py new file mode 100644 index 000000000..70c951933 --- /dev/null +++ b/src/mailman/bouncers/microsoft.py @@ -0,0 +1,74 @@ +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Microsoft's `SMTPSVC' nears I kin tell.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Microsoft', + ] + + +import re + +from cStringIO import StringIO +from flufl.enum import Enum +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + + +scre = re.compile(r'transcript of session follows', re.IGNORECASE) + + +class ParseState(Enum): + start = 0 + tag_seen = 1 + + + +class Microsoft: + """Microsoft's `SMTPSVC' nears I kin tell.""" + + implements(IBounceDetector) + + def process(self, msg): + if msg.get_content_type() != 'multipart/mixed': + return None + # Find the first subpart, which has no MIME type. + try: + subpart = msg.get_payload(0) + except IndexError: + # The message *looked* like a multipart but wasn't. + return None + data = subpart.get_payload() + if isinstance(data, list): + # The message is a multi-multipart, so not a matching bounce. + return None + body = StringIO(data) + state = ParseState.start + addresses = set() + for line in body: + if state is ParseState.start: + if scre.search(line): + state = ParseState.tag_seen + elif state is ParseState.tag_seen: + if '@' in line: + addresses.add(line.strip()) + return list(addresses) diff --git a/src/mailman/bouncers/netscape.py b/src/mailman/bouncers/netscape.py new file mode 100644 index 000000000..77df3f224 --- /dev/null +++ b/src/mailman/bouncers/netscape.py @@ -0,0 +1,103 @@ +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Netscape Messaging Server bounce formats. + +I've seen at least one NMS server version 3.6 (envy.gmp.usyd.edu.au) bounce +messages of this format. Bounces come in DSN MIME format, but don't include +any -Recipient: headers. Gotta just parse the text :( + +NMS 4.1 (dfw-smtpin1.email.verio.net) seems even worse, but we'll try to +decipher the format here too. + +""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Netscape', + ] + + +import re + +from cStringIO import StringIO +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + + +pcre = re.compile( + r'This Message was undeliverable due to the following reason:', + re.IGNORECASE) + +acre = re.compile( + r'(?P<reply>please reply to)?.*<(?P<addr>[^>]*)>', + re.IGNORECASE) + + + +def flatten(msg, leaves): + # Give us all the leaf (non-multipart) subparts. + if msg.is_multipart(): + for part in msg.get_payload(): + flatten(part, leaves) + else: + leaves.append(msg) + + + +class Netscape: + """Netscape Messaging Server bounce formats.""" + + implements(IBounceDetector) + + def process(self, msg): + """See `IBounceDetector`.""" + + # Sigh. Some NMS 3.6's show + # multipart/report; report-type=delivery-status + # and some show + # multipart/mixed; + if not msg.is_multipart(): + return None + # We're looking for a text/plain subpart occuring before a + # message/delivery-status subpart. + plainmsg = None + leaves = [] + flatten(msg, leaves) + for i, subpart in zip(range(len(leaves)-1), leaves): + if subpart.get_content_type() == 'text/plain': + plainmsg = subpart + break + if not plainmsg: + return None + # Total guesswork, based on captured examples... + body = StringIO(plainmsg.get_payload()) + addresses = set() + for line in body: + mo = pcre.search(line) + if mo: + # We found a bounce section, but I have no idea what the + # official format inside here is. :( We'll just search for + # <addr> strings. + for line in body: + mo = acre.search(line) + if mo and not mo.group('reply'): + addresses.add(mo.group('addr')) + return list(addresses) diff --git a/src/mailman/bouncers/postfix.py b/src/mailman/bouncers/postfix.py new file mode 100644 index 000000000..eb1c9c6cd --- /dev/null +++ b/src/mailman/bouncers/postfix.py @@ -0,0 +1,109 @@ +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Parse bounce messages generated by Postfix. + +This also matches something called 'Keftamail' which looks just like Postfix +bounces with the word Postfix scratched out and the word 'Keftamail' written +in in crayon. + +It also matches something claiming to be 'The BNS Postfix program', and +'SMTP_Gateway'. Everybody's gotta be different, huh? +""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Postfix', + ] + + +import re + +from cStringIO import StringIO +from flufl.enum import Enum +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + + +# Are these heuristics correct or guaranteed? +pcre = re.compile(r'[ \t]*the\s*(bns)?\s*(postfix|keftamail|smtp_gateway)', + re.IGNORECASE) +rcre = re.compile(r'failure reason:$', re.IGNORECASE) +acre = re.compile(r'<(?P<addr>[^>]*)>:') + +REPORT_TYPES = ('multipart/mixed', 'multipart/report') + + +class ParseState(Enum): + start = 0 + salutation_found = 1 + + + +def flatten(msg, leaves): + # Give us all the leaf (non-multipart) subparts. + if msg.is_multipart(): + for part in msg.get_payload(): + flatten(part, leaves) + else: + leaves.append(msg) + + + +def findaddr(msg): + addresses = set() + body = StringIO(msg.get_payload()) + state = ParseState.start + for line in body: + # Preserve leading whitespace. + line = line.rstrip() + # Yes, use match() to match at beginning of string. + if state is ParseState.start and ( + pcre.match(line) or rcre.match(line)): + # Then... + state = ParseState.salutation_found + elif state is ParseState.salutation_found and line: + mo = acre.search(line) + if mo: + addresses.add(mo.group('addr')) + # Probably a continuation line. + return addresses + + + +class Postfix: + """Parse bounce messages generated by Postfix.""" + + implements(IBounceDetector) + + def process(self, msg): + """See `IBounceDetector`.""" + if msg.get_content_type() not in REPORT_TYPES: + return None + # We're looking for the plain/text subpart with a Content-Description: + # of 'notification'. + leaves = [] + flatten(msg, leaves) + for subpart in leaves: + content_type = subpart.get_content_type() + content_desc = subpart.get('content-description', '').lower() + if content_type == 'text/plain' and content_desc == 'notification': + return set(findaddr(subpart)) + return None diff --git a/src/mailman/bouncers/qmail.py b/src/mailman/bouncers/qmail.py new file mode 100644 index 000000000..d5f34fd65 --- /dev/null +++ b/src/mailman/bouncers/qmail.py @@ -0,0 +1,96 @@ +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Parse bounce messages generated by qmail. + +Qmail actually has a standard, called QSBMF (qmail-send bounce message +format), as described in + + http://cr.yp.to/proto/qsbmf.txt + +This module should be conformant. + +""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Qmail', + ] + + +import re + +from email.iterators import body_line_iterator +from flufl.enum import Enum +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + + +# Other (non-standard?) intros have been observed in the wild. +introtags = [ + 'Hi. This is the', + "We're sorry. There's a problem", + 'Check your send e-mail address.', + 'This is the mail delivery agent at', + 'Unfortunately, your mail was not delivered' + ] +acre = re.compile(r'<(?P<addr>[^>]*)>:') + + +class ParseState(Enum): + start = 0 + intro_paragraph_seen = 1 + recip_paragraph_seen = 2 + + + +class Qmail: + """Parse QSBMF format bounces.""" + + implements(IBounceDetector) + + def process(self, msg): + """See `IBounceDetector`.""" + addresses = set() + state = ParseState.start + for line in body_line_iterator(msg): + line = line.strip() + if state is ParseState.start: + for introtag in introtags: + if line.startswith(introtag): + state = ParseState.intro_paragraph_seen + break + elif state is ParseState.intro_paragraph_seen and not line: + # Looking for the end of the intro paragraph. + state = ParseState.recip_paragraph_seen + elif state is ParseState.recip_paragraph_seen: + if line.startswith('-'): + # We're looking at the break paragraph, so we're done. + break + # At this point we know we must be looking at a recipient + # paragraph. + mo = acre.match(line) + if mo: + addresses.add(mo.group('addr')) + # Otherwise, it must be a continuation line, so just ignore it. + else: + # We're not looking at anything in particular. + pass + return list(addresses) diff --git a/src/mailman/Bouncers/SimpleMatch.py b/src/mailman/bouncers/simplematch.py index 29fc92ee0..972882cba 100644 --- a/src/mailman/Bouncers/SimpleMatch.py +++ b/src/mailman/bouncers/simplematch.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -17,22 +17,41 @@ """Recognizes simple heuristically delimited bounces.""" +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'SimpleMatch', + ] + + import re -import email.Iterators + +from email.iterators import body_line_iterator +from flufl.enum import Enum +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + + +class ParseState(Enum): + start = 0 + tag_seen = 1 def _c(pattern): return re.compile(pattern, re.IGNORECASE) + # This is a list of tuples of the form # # (start cre, end cre, address cre) # -# where `cre' means compiled regular expression, start is the line just before +# where 'cre' means compiled regular expression, start is the line just before # the bouncing address block, end is the line just after the bouncing address # block, and address cre is the regexp that will recognize the addresses. It -# must have a group called `addr' which will contain exactly and only the +# must have a group called 'addr' which will contain exactly and only the # address that bounced. PATTERNS = [ # sdm.de @@ -171,34 +190,37 @@ PATTERNS = [ -def process(msg, patterns=None): - if patterns is None: - patterns = PATTERNS - # simple state machine - # 0 = nothing seen yet - # 1 = intro seen - addrs = {} - # MAS: This is a mess. The outer loop used to be over the message - # so we only looped through the message once. Looping through the - # message for each set of patterns is obviously way more work, but - # if we don't do it, problems arise because scre from the wrong - # pattern set matches first and then acre doesn't match. The - # alternative is to split things into separate modules, but then - # we process the message multiple times anyway. - for scre, ecre, acre in patterns: - state = 0 - for line in email.Iterators.body_line_iterator(msg): - if state == 0: - if scre.search(line): - state = 1 - if state == 1: - mo = acre.search(line) - if mo: - addr = mo.group('addr') - if addr: - addrs[mo.group('addr')] = 1 - elif ecre.search(line): - break - if addrs: - break - return addrs.keys() +class SimpleMatch: + """Recognizes simple heuristically delimited bounces.""" + + implements(IBounceDetector) + + PATTERNS = PATTERNS + + def process(self, msg): + """See `IBounceDetector`.""" + addresses = set() + # MAS: This is a mess. The outer loop used to be over the message + # so we only looped through the message once. Looping through the + # message for each set of patterns is obviously way more work, but + # if we don't do it, problems arise because scre from the wrong + # pattern set matches first and then acre doesn't match. The + # alternative is to split things into separate modules, but then + # we process the message multiple times anyway. + for scre, ecre, acre in self.PATTERNS: + state = ParseState.start + for line in body_line_iterator(msg): + if state is ParseState.start: + if scre.search(line): + state = ParseState.tag_seen + if state is ParseState.tag_seen: + mo = acre.search(line) + if mo: + address = mo.group('addr') + if address: + addresses.add(address) + elif ecre.search(line): + break + if len(addresses) > 0: + break + return list(addresses) diff --git a/src/mailman/Bouncers/SimpleWarning.py b/src/mailman/bouncers/simplewarning.py index c20375a91..a23fc1aef 100644 --- a/src/mailman/Bouncers/SimpleWarning.py +++ b/src/mailman/bouncers/simplewarning.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -17,9 +17,15 @@ """Recognizes simple heuristically delimited warnings.""" -from mailman.Bouncers.BouncerAPI import Stop -from mailman.Bouncers.SimpleMatch import _c -from mailman.Bouncers.SimpleMatch import process as _process +__metaclass__ = type +__all__ = [ + 'SimpleWarning', + ] + + +from mailman.bouncers.simplematch import _c +from mailman.bouncers.simplematch import SimpleMatch +from mailman.interfaces.bounce import NonFatal @@ -27,12 +33,12 @@ from mailman.Bouncers.SimpleMatch import process as _process # # (start cre, end cre, address cre) # -# where `cre' means compiled regular expression, start is the line just before +# where 'cre' means compiled regular expression, start is the line just before # the bouncing address block, end is the line just after the bouncing address # block, and address cre is the regexp that will recognize the addresses. It -# must have a group called `addr' which will contain exactly and only the +# must have a group called 'addr' which will contain exactly and only the # address that bounced. -patterns = [ +PATTERNS = [ # pop3.pta.lia.net (_c('The address to which the message has not yet been delivered is'), _c('No action is required on your part'), @@ -54,9 +60,15 @@ patterns = [ -def process(msg): - if _process(msg, patterns): - # It's a recognized warning so stop now - return Stop - else: - return [] +class SimpleWarning(SimpleMatch): + """Recognizes simple heuristically delimited warnings.""" + + PATTERNS = PATTERNS + + def process(self, msg): + """See `SimpleMatch`.""" + if super(SimpleWarning, self).process(msg): + # It's a recognized warning so stop now. + return NonFatal + else: + return None diff --git a/src/mailman/bouncers/sina.py b/src/mailman/bouncers/sina.py new file mode 100644 index 000000000..d003063c7 --- /dev/null +++ b/src/mailman/bouncers/sina.py @@ -0,0 +1,64 @@ +# Copyright (C) 2002-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""sina.com bounces""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Sina', + ] + + +import re + +from email.iterators import body_line_iterator +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + + +acre = re.compile(r'<(?P<addr>[^>]*)>') + + + +class Sina: + """sina.com bounces""" + + implements(IBounceDetector) + + def process(self, msg): + """See `IBounceDetector`.""" + if msg.get('from', '').lower() != 'mailer-daemon@sina.com': + return [] + if not msg.is_multipart(): + return [] + # The interesting bits are in the first text/plain multipart. + part = None + try: + part = msg.get_payload(0) + except IndexError: + pass + if not part: + return [] + addresses = set() + for line in body_line_iterator(part): + mo = acre.match(line) + if mo: + addresses.add(mo.group('addr')) + return list(addresses) diff --git a/src/mailman/Bouncers/SMTP32.py b/src/mailman/bouncers/smtp32.py index 6cace9c24..85bd50e78 100644 --- a/src/mailman/Bouncers/SMTP32.py +++ b/src/mailman/bouncers/smtp32.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -28,8 +28,21 @@ Escape character is '^]'. """ +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'SMTP32', + ] + + import re -import email + +from email.iterators import body_line_iterator +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + ecre = re.compile('original message follows', re.IGNORECASE) acre = re.compile(r''' @@ -46,15 +59,23 @@ acre = re.compile(r''' -def process(msg): - mailer = msg.get('x-mailer', '') - if not mailer.startswith('<SMTP32 v'): - return - addrs = {} - for line in email.Iterators.body_line_iterator(msg): - if ecre.search(line): - break - mo = acre.search(line) - if mo: - addrs[mo.group('addr')] = 1 - return addrs.keys() +class SMTP32: + """Something which claims + + X-Mailer: <SMTP32 vXXXXXX> + """ + + implements(IBounceDetector) + + def process(self, msg): + mailer = msg.get('x-mailer', '') + if not mailer.startswith('<SMTP32 v'): + return None + addrs = set() + for line in body_line_iterator(msg): + if ecre.search(line): + break + mo = acre.search(line) + if mo: + addrs.add(mo.group('addr')) + return list(addrs) diff --git a/src/mailman/bouncers/yahoo.py b/src/mailman/bouncers/yahoo.py new file mode 100644 index 000000000..150fb66ef --- /dev/null +++ b/src/mailman/bouncers/yahoo.py @@ -0,0 +1,76 @@ +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Yahoo! has its own weird format for bounces.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Yahoo', + ] + + +import re +import email + +from email.utils import parseaddr +from flufl.enum import Enum +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + + +tcre = re.compile(r'message\s+from\s+yahoo\.\S+', re.IGNORECASE) +acre = re.compile(r'<(?P<addr>[^>]*)>:') +ecre = re.compile(r'--- Original message follows') + + +class ParseState(Enum): + start = 0 + tag_seen = 1 + + + +class Yahoo: + """Yahoo! bounce detection.""" + + implements(IBounceDetector) + + def process(self, msg): + """See `IBounceDetector`.""" + # Yahoo! bounces seem to have a known subject value and something + # called an x-uidl: header, the value of which seems unimportant. + sender = parseaddr(msg.get('from', '').lower())[1] or '' + if not sender.startswith('mailer-daemon@yahoo'): + return None + addresses = set() + state = ParseState.start + for line in email.Iterators.body_line_iterator(msg): + line = line.strip() + if state is ParseState.start and tcre.match(line): + state = ParseState.tag_seen + elif state is ParseState.tag_seen: + mo = acre.match(line) + if mo: + addresses.add(mo.group('addr')) + continue + mo = ecre.match(line) + if mo: + # We're at the end of the error response. + break + return list(addresses) diff --git a/src/mailman/bouncers/yale.py b/src/mailman/bouncers/yale.py new file mode 100644 index 000000000..707d66534 --- /dev/null +++ b/src/mailman/bouncers/yale.py @@ -0,0 +1,100 @@ +# Copyright (C) 2000-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Yale's mail server is pretty dumb. + +Its reports include the end user's name, but not the full domain. I think we +can usually guess it right anyway. This is completely based on examination of +the corpse, and is subject to failure whenever Yale even slightly changes +their MTA. :( + +""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Yale', + ] + + +import re + +from cStringIO import StringIO +from email.utils import getaddresses +from flufl.enum import Enum +from zope.interface import implements + +from mailman.interfaces.bounce import IBounceDetector + + +scre = re.compile(r'Message not delivered to the following', re.IGNORECASE) +ecre = re.compile(r'Error Detail', re.IGNORECASE) +acre = re.compile(r'\s+(?P<addr>\S+)\s+') + + +class ParseState(Enum): + start = 0 + intro_found = 1 + + + +class Yale: + """Parse Yale's bounces (or what used to be).""" + + implements(IBounceDetector) + + def process(self, msg): + """See `IBounceDetector`.""" + if msg.is_multipart(): + return None + try: + whofrom = getaddresses([msg.get('from', '')])[0][1] + if not whofrom: + return None + username, domain = whofrom.split('@', 1) + except (IndexError, ValueError): + return None + if username.lower() != 'mailer-daemon': + return None + parts = domain.split('.') + parts.reverse() + for part1, part2 in zip(parts, ('edu', 'yale')): + if part1 != part2: + return None + # Okay, we've established that the bounce came from the mailer-daemon + # at yale.edu. Let's look for a name, and then guess the relevant + # domains. + names = set() + body = StringIO(msg.get_payload()) + state = ParseState.start + for line in body: + if state is ParseState.start and scre.search(line): + state = ParseState.intro_found + elif state is ParseState.intro_found and ecre.search(line): + break + elif state is ParseState.intro_found: + mo = acre.search(line) + if mo: + names.add(mo.group('addr')) + # Now we have a bunch of names, these are either @yale.edu or + # @cs.yale.edu. Add them both. + addresses = [] + for name in names: + addresses.append(name + '@yale.edu') + addresses.append(name + '@cs.yale.edu') + return addresses diff --git a/src/mailman/bounces/__init__.py b/src/mailman/bounces/__init__.py index e69de29bb..5c6724697 100644 --- a/src/mailman/bounces/__init__.py +++ b/src/mailman/bounces/__init__.py @@ -0,0 +1,32 @@ +# Copyright (C) 2010 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Bounce detection helpers.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Stop', + ] + + +# If a bounce detector returns Stop, that means to just discard the message. +# An example is warning messages for temporary delivery problems. These +# shouldn't trigger a bounce notification, but we also don't want to send them +# on to the list administrator. +Stop = object() diff --git a/src/mailman/bounces/tests/__init__.py b/src/mailman/bounces/tests/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/bounces/tests/__init__.py diff --git a/src/mailman/bounces/tests/data/__init__.py b/src/mailman/bounces/tests/data/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/bounces/tests/data/__init__.py diff --git a/src/mailman/bounces/tests/test_bounces.py b/src/mailman/bounces/tests/test_bounces.py index 8e5645496..5a73c744b 100644 --- a/src/mailman/bounces/tests/test_bounces.py +++ b/src/mailman/bounces/tests/test_bounces.py @@ -33,7 +33,7 @@ from contextlib import closing from email import message_from_file, message_from_string from pkg_resources import resource_stream -from mailman.bouncers import Stop +from mailman.bounces import Stop COMMASPACE = ', ' @@ -45,14 +45,14 @@ class BounceTestCase(unittest.TestCase): def __init__(self, bounce_module, sample_file, expected): """See `unittest.TestCase`.""" - unittest.TestCase.__init__('test_detection') + unittest.TestCase.__init__(self) self.bounce_module = bounce_module self.sample_file = sample_file self.expected = expected def setUp(self): """See `unittest.TestCase`.""" - module_name = 'mailman.bounces.' + modname + module_name = 'mailman.bouncers.' + self.bounce_module __import__(module_name) self.module = sys.modules[module_name] with closing(resource_stream('mailman.bounces.tests.data', @@ -70,7 +70,9 @@ class BounceTestCase(unittest.TestCase): return '{0}: detecting {1} in {2}'.format( self.bounce_module, expected, self.sample_file) - def test_detection(self): + __repr__ = shortDescription + + def runTest(self): """Test one bounce detection.""" found_expected = self.module.process(self.message) self.assertEqual(found_expected, self.expected) @@ -85,7 +87,7 @@ def make_test_cases(): class OtherBounceTests(unittest.TestCase): def test_SMTP32_failure(self): - from Mailman.Bouncers import SMTP32 + from mailman.bouncers import SMTP32 # This file has no X-Mailer: header with open(os.path.join('tests', 'bounces', 'postfix_01.txt')) as fp: msg = message_from_file(fp) @@ -93,7 +95,7 @@ class OtherBounceTests(unittest.TestCase): self.failIf(SMTP32.process(msg)) def test_caiwireless(self): - from Mailman.Bouncers import Caiwireless + from mailman.bouncers import Caiwireless # BAW: this is a mostly bogus test; I lost the samples. :( msg = message_from_string("""\ Content-Type: multipart/report; boundary=BOUNDARY @@ -106,7 +108,7 @@ Content-Type: multipart/report; boundary=BOUNDARY self.assertEqual(None, Caiwireless.process(msg)) def test_microsoft(self): - from Mailman.Bouncers import Microsoft + from mailman.bouncers import Microsoft # BAW: similarly as above, I lost the samples. :( msg = message_from_string("""\ Content-Type: multipart/report; boundary=BOUNDARY diff --git a/src/mailman/chains/accept.py b/src/mailman/chains/accept.py index 929d4b7da..589199fdc 100644 --- a/src/mailman/chains/accept.py +++ b/src/mailman/chains/accept.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/chains/base.py b/src/mailman/chains/base.py index e8b90537a..d42eced3e 100644 --- a/src/mailman/chains/base.py +++ b/src/mailman/chains/base.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -46,6 +46,19 @@ class Link: self.chain = chain self.function = function + def __repr__(self): + message = '<Link "if {0.rule.name} then {0.action} ' + if self.chain is None and self.function is not None: + message += '{0.function}()' + elif self.chain is not None and self.function is None: + message += '{0.chain.name}' + elif self.chain is None and self.function is None: + pass + else: + message += '{0.chain.name} {0.function}()' + message += '">' + return message.format(self) + class TerminalChainBase: @@ -90,8 +103,6 @@ class Chain: self.name = name self.description = description self._links = [] - # Register the chain. - config.chains[name] = self def append_link(self, link): """See `IMutableChain`.""" diff --git a/src/mailman/chains/builtin.py b/src/mailman/chains/builtin.py index fc31085f3..8bc2567e1 100644 --- a/src/mailman/chains/builtin.py +++ b/src/mailman/chains/builtin.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -51,8 +51,9 @@ class BuiltInChain: ('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. + # Determine whether the member or nonmember has an action shortcut. + ('member-moderation', LinkAction.jump, 'moderation'), + # Do all of the following before deciding whether to hold the message. ('administrivia', LinkAction.defer, None), ('implicit-dest', LinkAction.defer, None), ('max-recipients', LinkAction.defer, None), @@ -65,6 +66,8 @@ class BuiltInChain: # Take a detour through the header matching chain, which we'll create # later. ('truth', LinkAction.detour, 'header-match'), + # Check for nonmember moderation. + ('nonmember-moderation', LinkAction.jump, 'moderation'), # Finally, the builtin chain jumps to acceptance. ('truth', LinkAction.jump, 'accept'), ) diff --git a/src/mailman/chains/discard.py b/src/mailman/chains/discard.py index 1c4d396c6..2839b9e93 100644 --- a/src/mailman/chains/discard.py +++ b/src/mailman/chains/discard.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/chains/docs/__init__.py b/src/mailman/chains/docs/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/src/mailman/chains/docs/__init__.py diff --git a/src/mailman/chains/docs/moderation.txt b/src/mailman/chains/docs/moderation.txt new file mode 100644 index 000000000..ce16d808d --- /dev/null +++ b/src/mailman/chains/docs/moderation.txt @@ -0,0 +1,222 @@ +========== +Moderation +========== + +Posts by members and nonmembers are subject to moderation checks during +incoming processing. Different situations can cause such posts to be held for +moderator approval. + + >>> mlist = create_list('test@example.com') + +Members and nonmembers have a *moderation action* which can shortcut the +normal moderation checks. The built-in chain does just a few checks first, +such as seeing if the message has a matching `Approved:` header, or if the +emergency flag has been set on the mailing list, or whether a mail loop has +been detected. + +After those, the moderation action for the sender is checked. Members +generally have a `defer` action, meaning the normal moderation checks are +done, but it is also common for first-time posters to have a `hold` action, +meaning that their messages are held for moderator approval for a while. + +Nonmembers almost always have a `hold` action, though some mailing lists may +choose to set this default action to `discard`, meaning their posts would be +immediately thrown away. + + +Member moderation +================= + +Posts by list members are moderated if the member's moderation action is not +deferred. The default setting for the moderation action of new members is +determined by the mailing list's settings. By default, a mailing list is not +set to moderate new member postings. + + >>> from mailman.app.membership import add_member + >>> from mailman.interfaces.member import DeliveryMode + >>> member = add_member(mlist, 'anne@example.com', 'Anne', 'aaa', + ... DeliveryMode.regular, 'en') + >>> member + <Member: Anne <anne@example.com> on test@example.com as MemberRole.member> + >>> print member.moderation_action + Action.defer + +In order to find out whether the message is held or accepted, we can subscribe +to Zope events that are triggered on each case. +:: + + >>> from mailman.chains.base import ChainNotification + >>> def on_chain(event): + ... if isinstance(event, ChainNotification): + ... print event + ... print event.chain + ... print 'Subject:', event.msg['subject'] + ... print 'Hits:' + ... for hit in event.msgdata.get('rule_hits', []): + ... print ' ', hit + ... print 'Misses:' + ... for miss in event.msgdata.get('rule_misses', []): + ... print ' ', miss + +Anne's post to the mailing list runs through the incoming runner's default +built-in chain. No rules hit and so the message is accepted. +:: + + >>> msg = message_from_string("""\ + ... From: anne@example.com + ... To: test@example.com + ... Subject: aardvark + ... + ... This is a test. + ... """) + + >>> from mailman.core.chains import process + >>> from mailman.testing.helpers import event_subscribers + >>> with event_subscribers(on_chain): + ... process(mlist, msg, {}, 'built-in') + <mailman.chains.accept.AcceptNotification ...> + <mailman.chains.accept.AcceptChain ...> + Subject: aardvark + Hits: + Misses: + approved + emergency + loop + member-moderation + administrivia + implicit-dest + max-recipients + max-size + news-moderation + no-subject + suspicious-header + nonmember-moderation + +However, when Anne's moderation action is set to `hold`, her post is held for +moderator approval. +:: + + >>> from mailman.interfaces.action import Action + >>> member.moderation_action = Action.hold + + >>> msg = message_from_string("""\ + ... From: anne@example.com + ... To: test@example.com + ... Subject: badger + ... + ... This is a test. + ... """) + + >>> with event_subscribers(on_chain): + ... process(mlist, msg, {}, 'built-in') + <mailman.chains.hold.HoldNotification ...> + <mailman.chains.hold.HoldChain ...> + Subject: badger + Hits: + member-moderation + Misses: + approved + emergency + loop + +The list's member moderation action can also be set to `discard`... +:: + + >>> member.moderation_action = Action.discard + + >>> msg = message_from_string("""\ + ... From: anne@example.com + ... To: test@example.com + ... Subject: cougar + ... + ... This is a test. + ... """) + + >>> with event_subscribers(on_chain): + ... process(mlist, msg, {}, 'built-in') + <mailman.chains.discard.DiscardNotification ...> + <mailman.chains.discard.DiscardChain ...> + Subject: cougar + Hits: + member-moderation + Misses: + approved + emergency + loop + +... or `reject`. + + >>> member.moderation_action = Action.reject + + >>> msg = message_from_string("""\ + ... From: anne@example.com + ... To: test@example.com + ... Subject: dingo + ... + ... This is a test. + ... """) + + >>> with event_subscribers(on_chain): + ... process(mlist, msg, {}, 'built-in') + <mailman.chains.reject.RejectNotification ...> + <mailman.chains.reject.RejectChain ...> + Subject: dingo + Hits: + member-moderation + Misses: + approved + emergency + loop + + +Nonmembers +========== + +Registered nonmembers are handled very similarly to members, the main +difference being that they usually have a default moderation action. This is +how the incoming queue runner adds sender addresses as nonmembers. + + >>> from zope.component import getUtility + >>> from mailman.interfaces.usermanager import IUserManager + >>> user_manager = getUtility(IUserManager) + >>> address = user_manager.create_address('bart@example.com') + >>> address + <Address: bart@example.com [not verified] at ...> + +When the moderation rule runs on a message from this sender, this address will +be registered as a nonmember of the mailing list, and it will be held for +moderator approval. +:: + + >>> msg = message_from_string("""\ + ... From: bart@example.com + ... To: test@example.com + ... Subject: elephant + ... + ... """) + + >>> with event_subscribers(on_chain): + ... process(mlist, msg, {}, 'built-in') + <mailman.chains.hold.HoldNotification ...> + <mailman.chains.hold.HoldChain ...> + Subject: elephant + Hits: + nonmember-moderation + Misses: + approved + emergency + loop + member-moderation + administrivia + implicit-dest + max-recipients + max-size + news-moderation + no-subject + suspicious-header + + >>> nonmember = mlist.nonmembers.get_member('bart@example.com') + >>> nonmember + <Member: bart@example.com on test@example.com as MemberRole.nonmember> + >>> print nonmember.moderation_action + Action.hold diff --git a/src/mailman/chains/headers.py b/src/mailman/chains/headers.py index 8a7f73763..fd6edebc0 100644 --- a/src/mailman/chains/headers.py +++ b/src/mailman/chains/headers.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/chains/hold.py b/src/mailman/chains/hold.py index f5ff237d9..00dd9cf4b 100644 --- a/src/mailman/chains/hold.py +++ b/src/mailman/chains/hold.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -102,16 +102,16 @@ def autorespond_to_sender(mlist, sender, lang=None): log.info('hold autoresponse limit hit: %s', sender) response_set.response_sent(address, Response.hold) # Send this notification message instead. - text = Utils.maketext( + text = maketext( 'nomoretoday.txt', {'sender' : sender, 'listname': mlist.fqdn_listname, - 'num' : count, + 'num' : todays_count, 'owneremail': mlist.owner_address, }, lang=lang) with _.using(lang.code): - msg = Message.UserNotification( + msg = UserNotification( sender, mlist.owner_address, _('Last autoresponse notification for today'), text, lang=lang) diff --git a/src/mailman/chains/moderation.py b/src/mailman/chains/moderation.py new file mode 100644 index 000000000..d6104fd66 --- /dev/null +++ b/src/mailman/chains/moderation.py @@ -0,0 +1,89 @@ +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Moderation chain. + +When a member or nonmember posting to the mailing list has a moderation action +that is not `defer`, the built-in chain jumps to this chain. This chain then +determines the disposition of the message based on the member's or nonmember's +moderation action. + +For example, these actions jump to the appropriate terminal chain: + + * accept - the message is immediately accepted + * hold - the message is held for moderator approval + * reject - the message is bounced + * discard - the message is immediately thrown away + +Note that if the moderation action is `defer` then the normal decisions are +made as to the disposition of the message. `defer` is the default for +members, while `hold` is the default for nonmembers. +""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'ModerationChain', + ] + + +from zope.interface import implements + +from mailman.chains.base import Link +from mailman.config import config +from mailman.core.i18n import _ +from mailman.interfaces.action import Action +from mailman.interfaces.chain import IChain, LinkAction + + + +class ModerationChain: + """Dynamically produce a link jumping to the appropriate terminal chain. + + The terminal chain will be one of the Accept, Hold, Discard, or Reject + chains, based on the member's or nonmember's moderation action setting. + """ + + implements(IChain) + + name = 'moderation' + description = _('Moderation chain') + + def get_links(self, mlist, msg, msgdata): + """See `IChain`.""" + # Get the moderation action from the message metadata. It can only be + # one of the expected values (i.e. not Action.defer). See the + # moderation.py rule for details. This is stored in the metadata as a + # string so that it can be stored in the pending table. + action = Action[msgdata.get('moderation_action')] + # defer and accept are not valid moderation actions. + jump_chain = { + Action.accept: 'accept', + Action.discard: 'discard', + Action.hold: 'hold', + Action.reject: 'reject', + }.get(action) + assert jump_chain is not None, ( + '{0}: Invalid moderation action: {1} for sender: {2}'.format( + mlist.fqdn_listname, action, + msgdata.get('moderation_sender', '(unknown)'))) + truth = config.rules['truth'] + chain = config.chains[jump_chain] + return iter([ + Link(truth, LinkAction.jump, chain), + ]) diff --git a/src/mailman/chains/reject.py b/src/mailman/chains/reject.py index 3dbf21869..7501b7fac 100644 --- a/src/mailman/chains/reject.py +++ b/src/mailman/chains/reject.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/cli_aliases.py b/src/mailman/commands/cli_aliases.py index f540d735d..dcb494ebb 100644 --- a/src/mailman/commands/cli_aliases.py +++ b/src/mailman/commands/cli_aliases.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/cli_control.py b/src/mailman/commands/cli_control.py index 83432a6e8..0572280f7 100644 --- a/src/mailman/commands/cli_control.py +++ b/src/mailman/commands/cli_control.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -36,6 +36,7 @@ import logging from zope.interface import implements +from mailman.bin.master import WatcherState, master_state from mailman.config import config from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand @@ -54,6 +55,7 @@ class Start: def add(self, parser, command_parser): """See `ICLISubCommand`.""" + self.parser = parser command_parser.add_argument( '-f', '--force', default=False, action='store_true', @@ -92,6 +94,15 @@ class Start: def process(self, args): """See `ICLISubCommand`.""" + # Although there's a potential race condition here, it's a better user + # experience for the parent process to refuse to start twice, rather + # than having it try to start the master, which will error exit. + status, lock = master_state() + if status is WatcherState.conflict: + self.parser.error(_('GNU Mailman is already running')) + elif status in (WatcherState.stale_lock, WatcherState.host_mismatch): + self.parser.error(_('A previous run of GNU Mailman did not exit ' + 'cleanly. Try using --force.')) def log(message): if not args.quiet: print message @@ -152,7 +163,7 @@ def kill_watcher(sig): class SignalCommand: """Common base class for simple, signal sending commands.""" - + implements(ICLISubCommand) name = None diff --git a/src/mailman/commands/cli_help.py b/src/mailman/commands/cli_help.py index 0f13148f8..7a364c260 100644 --- a/src/mailman/commands/cli_help.py +++ b/src/mailman/commands/cli_help.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/cli_import.py b/src/mailman/commands/cli_import.py index 5ee7eb06c..6e56a8d8a 100644 --- a/src/mailman/commands/cli_import.py +++ b/src/mailman/commands/cli_import.py @@ -1,4 +1,4 @@ -# Copyright (C) 2010 by the Free Software Foundation, Inc. +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/cli_info.py b/src/mailman/commands/cli_info.py index 1a4ea75b4..4b7f2a2fb 100644 --- a/src/mailman/commands/cli_info.py +++ b/src/mailman/commands/cli_info.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -32,6 +32,7 @@ from zope.interface import implements from mailman.config import config from mailman.core.i18n import _ from mailman.interfaces.command import ICLISubCommand +from mailman.rest.helpers import path_to from mailman.version import MAILMAN_VERSION_FULL @@ -68,6 +69,9 @@ class Info: print >> output, 'Python', sys.version print >> output, 'config file:', config.filename print >> output, 'db url:', config.db.url + print >> output, 'REST root url:', path_to('/') + print >> output, 'REST credentials: {0}:{1}'.format( + config.webservice.admin_user, config.webservice.admin_pass) if args.verbose: print >> output, 'File system paths:' longest = 0 diff --git a/src/mailman/commands/cli_inject.py b/src/mailman/commands/cli_inject.py index 9fe9e3d2a..431280fdf 100644 --- a/src/mailman/commands/cli_inject.py +++ b/src/mailman/commands/cli_inject.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/cli_lists.py b/src/mailman/commands/cli_lists.py index 38c1823a1..a127dd816 100644 --- a/src/mailman/commands/cli_lists.py +++ b/src/mailman/commands/cli_lists.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/cli_members.py b/src/mailman/commands/cli_members.py index e18b4b686..cd7fcfbf1 100644 --- a/src/mailman/commands/cli_members.py +++ b/src/mailman/commands/cli_members.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -156,21 +156,21 @@ class Members: if len(addresses) == 0: print >> fp, mlist.fqdn_listname, 'has no members' return - for address in sorted(addresses, key=attrgetter('address')): + for address in sorted(addresses, key=attrgetter('email')): if args.regular: - member = mlist.members.get_member(address.address) + member = mlist.members.get_member(address.email) if member.delivery_mode != DeliveryMode.regular: continue if args.digest is not None: - member = mlist.members.get_member(address.address) + member = mlist.members.get_member(address.email) if member.delivery_mode not in digest_types: continue if args.nomail is not None: - member = mlist.members.get_member(address.address) + member = mlist.members.get_member(address.email) if member.delivery_status not in status_types: continue print >> fp, formataddr( - (address.real_name, address.original_address)) + (address.real_name, address.original_email)) finally: if fp is not sys.stdout: fp.close() diff --git a/src/mailman/commands/cli_qfile.py b/src/mailman/commands/cli_qfile.py index 63e5344e1..1faa78cf1 100644 --- a/src/mailman/commands/cli_qfile.py +++ b/src/mailman/commands/cli_qfile.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -28,10 +28,8 @@ __all__ = [ import cPickle from pprint import PrettyPrinter -from zope.component import getUtility from zope.interface import implements -from mailman.config import config from mailman.core.i18n import _ from mailman.interact import interact from mailman.interfaces.command import ICLISubCommand diff --git a/src/mailman/commands/cli_status.py b/src/mailman/commands/cli_status.py new file mode 100644 index 000000000..eee1ac73b --- /dev/null +++ b/src/mailman/commands/cli_status.py @@ -0,0 +1,68 @@ +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Module stuff.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Status', + ] + + +import socket + +from zope.interface import implements + +from mailman.bin.master import WatcherState, master_state +from mailman.core.i18n import _ +from mailman.interfaces.command import ICLISubCommand + + + +class Status: + """Status of the Mailman system.""" + + implements(ICLISubCommand) + + name = 'status' + + def add(self, parser, command_parser): + """See `ICLISubCommand`.""" + pass + + def process(self, args): + """See `ICLISubCommand`.""" + status, lock = master_state() + if status is WatcherState.none: + message = _('GNU Mailman is not running') + elif status is WatcherState.conflict: + hostname, pid, tempfile = lock.details + message = _('GNU Mailman is running (master pid: $pid)') + elif status is WatcherState.stale_lock: + hostname, pid, tempfile = lock.details + message =_('GNU Mailman is stopped (stale pid: $pid)') + else: + hostname, pid, tempfile = lock.details + fqdn_name = socket.getfqdn() + assert status is WatcherState.host_mismatch, ( + 'Invalid enum value: %s' % status) + message = _('GNU Mailman is in an unexpected state ' + '($hostname != $fqdn_name)') + print message + return int(status) diff --git a/src/mailman/commands/cli_unshunt.py b/src/mailman/commands/cli_unshunt.py index ac26c63f1..ee8f347a6 100644 --- a/src/mailman/commands/cli_unshunt.py +++ b/src/mailman/commands/cli_unshunt.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/cli_version.py b/src/mailman/commands/cli_version.py index 959656315..084df5a79 100644 --- a/src/mailman/commands/cli_version.py +++ b/src/mailman/commands/cli_version.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/cli_withlist.py b/src/mailman/commands/cli_withlist.py index 3bd2046a1..1b5cab336 100644 --- a/src/mailman/commands/cli_withlist.py +++ b/src/mailman/commands/cli_withlist.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -26,7 +26,6 @@ __all__ = [ import re -import sys from zope.component import getUtility from zope.interface import implements diff --git a/src/mailman/commands/docs/create.txt b/src/mailman/commands/docs/create.txt index 7c7b43805..31663a851 100644 --- a/src/mailman/commands/docs/create.txt +++ b/src/mailman/commands/docs/create.txt @@ -35,6 +35,7 @@ the mailing list and domain will be created. Created mailing list: test@example.xx Now both the domain and the mailing list exist in the database. +:: >>> from mailman.interfaces.listmanager import IListManager >>> from zope.component import getUtility @@ -49,6 +50,7 @@ Now both the domain and the mailing list exist in the database. You can also create mailing lists in existing domains without the auto-creation flag. +:: >>> args.domain = False >>> args.listname = ['test1@example.com'] @@ -59,6 +61,7 @@ auto-creation flag. <mailing list "test1@example.com" at ...> The command can also operate quietly. +:: >>> args.quiet = True >>> args.listname = ['test2@example.com'] @@ -74,11 +77,12 @@ Setting the owner By default, no list owners are specified. - >>> print list(mlist.owners.addresses) - [] + >>> dump_list(mlist.owners.addresses) + *Empty* But you can specify an owner address on the command line when you create the mailing list. +:: >>> args.quiet = False >>> args.listname = ['test4@example.com'] @@ -87,10 +91,11 @@ mailing list. Created mailing list: test4@example.com >>> mlist = list_manager.get('test4@example.com') - >>> print list(mlist.owners.addresses) - [<Address: foo@example.org [not verified] at ...>] + >>> dump_list(repr(address) for address in mlist.owners.addresses) + <Address: foo@example.org [not verified] at ...> You can even specify more than one address for the owners. +:: >>> args.owners = ['foo@example.net', 'bar@example.net', 'baz@example.net'] >>> args.listname = ['test5@example.com'] @@ -99,10 +104,10 @@ You can even specify more than one address for the owners. >>> mlist = list_manager.get('test5@example.com') >>> from operator import attrgetter - >>> print sorted(mlist.owners.addresses, key=attrgetter('address')) - [<Address: bar@example.net [not verified] at ...>, - <Address: baz@example.net [not verified] at ...>, - <Address: foo@example.net [not verified] at ...>] + >>> dump_list(repr(address) for address in mlist.owners.addresses) + <Address: bar@example.net [not verified] at ...> + <Address: baz@example.net [not verified] at ...> + <Address: foo@example.net [not verified] at ...> Setting the language @@ -110,6 +115,7 @@ Setting the language You can set the default language for the new mailing list when you create it. The language must be known to Mailman. +:: >>> args.listname = ['test3@example.com'] >>> args.language = 'ee' @@ -142,6 +148,7 @@ When told to, Mailman will notify the list owners of their new mailing list. Created mailing list: test6@example.com The notification message is in the virgin queue. +:: >>> from mailman.testing.helpers import get_queue_messages >>> messages = get_queue_messages('virgin') diff --git a/src/mailman/commands/docs/echo.txt b/src/mailman/commands/docs/echo.txt index 99cb25589..a01172d04 100644 --- a/src/mailman/commands/docs/echo.txt +++ b/src/mailman/commands/docs/echo.txt @@ -13,6 +13,7 @@ to the sender. Echo an acknowledgement. Arguments are return unchanged. The original message is ignored, but the results receive the echoed command. +:: >>> mlist = create_list('test@example.com') diff --git a/src/mailman/commands/docs/import.txt b/src/mailman/commands/docs/import.txt index f1ab072ec..34521026d 100644 --- a/src/mailman/commands/docs/import.txt +++ b/src/mailman/commands/docs/import.txt @@ -4,6 +4,7 @@ Importing list data If you have the config.pck file for a version 2.1 mailing list, you can import that into an existing mailing list in Mailman 3.0. +:: >>> from mailman.commands.cli_import import Import21 >>> command = Import21() @@ -18,6 +19,7 @@ that into an existing mailing list in Mailman 3.0. >>> command.parser = FakeParser() You must specify the mailing list you are importing into, and it must exist. +:: >>> command.process(FakeArgs) List name is required @@ -28,6 +30,7 @@ You must specify the mailing list you are importing into, and it must exist. When the mailing list exists, you must specify a real pickle file to import from. +:: >>> mlist = create_list('import@example.com') >>> command.process(FakeArgs) @@ -39,6 +42,7 @@ from. Now we can import the test pickle file. As a simple illustration of the import, the mailing list's 'real name' has changed. +:: >>> from pkg_resources import resource_filename >>> FakeArgs.pickle_file = [ @@ -50,5 +54,3 @@ import, the mailing list's 'real name' has changed. >>> command.process(FakeArgs) >>> print mlist.real_name Test - -See `../../utilities/docs/importer.txt` for more details. diff --git a/src/mailman/commands/docs/info.txt b/src/mailman/commands/docs/info.txt index d990b514e..bccb78fda 100644 --- a/src/mailman/commands/docs/info.txt +++ b/src/mailman/commands/docs/info.txt @@ -3,7 +3,8 @@ Getting information =================== You can get information about Mailman's environment by using the command line -script 'mailman info'. By default, the info is printed to standard output. +script ``mailman info``. By default, the info is printed to standard output. +:: >>> from mailman.commands.cli_info import Info >>> command = Info() @@ -19,8 +20,10 @@ script 'mailman info'. By default, the info is printed to standard output. ... config file: .../test.cfg db url: sqlite:.../mailman.db + REST root url: http://localhost:9001/3.0/ + REST credentials: restadmin:restpass -By passing in the -o/--output option, you can print the info to a file. +By passing in the ``-o/--output`` option, you can print the info to a file. >>> from mailman.config import config >>> import os @@ -34,6 +37,8 @@ By passing in the -o/--output option, you can print the info to a file. ... config file: .../test.cfg db url: sqlite:.../mailman.db + REST root url: http://localhost:9001/3.0/ + REST credentials: restadmin:restpass You can also get more verbose information, which contains a list of the file system paths that Mailman is using. diff --git a/src/mailman/commands/docs/inject.txt b/src/mailman/commands/docs/inject.txt index 189dc1920..e8c405b07 100644 --- a/src/mailman/commands/docs/inject.txt +++ b/src/mailman/commands/docs/inject.txt @@ -4,6 +4,7 @@ Command line message injection You can inject a message directly into a queue directory via the command line. +:: >>> from mailman.commands.cli_inject import Inject >>> command = Inject() @@ -21,6 +22,7 @@ line. >>> command.parser = FakeParser() It's easy to find out which queues are available. +:: >>> args.show = True >>> command.process(args) @@ -63,6 +65,7 @@ However, the mailing list name is always required. List name is required Let's provide a list name and try again. +:: >>> mlist = create_list('test@example.com') >>> transaction.commit() @@ -74,6 +77,7 @@ Let's provide a list name and try again. >>> command.process(args) By default, the incoming queue is used. +:: >>> len(in_queue.files) 1 @@ -98,6 +102,7 @@ By default, the incoming queue is used. version : 3 But a different queue can be specified on the command line. +:: >>> args.queue = 'virgin' >>> command.process(args) @@ -133,6 +138,7 @@ Standard input ============== The message text can also be provided on standard input. +:: >>> from StringIO import StringIO diff --git a/src/mailman/commands/docs/lists.txt b/src/mailman/commands/docs/lists.txt index 887e69bd4..036147a23 100644 --- a/src/mailman/commands/docs/lists.txt +++ b/src/mailman/commands/docs/lists.txt @@ -4,6 +4,7 @@ Command line list display A system administrator can display all the mailing lists via the command line. When there are no mailing lists, a helpful message is displayed. +:: >>> class FakeArgs: ... advertised = False @@ -19,6 +20,7 @@ line. When there are no mailing lists, a helpful message is displayed. When there are a few mailing lists, they are shown in alphabetical order by their fully qualified list names, with a description. +:: >>> from mailman.interfaces.domain import IDomainManager >>> from zope.component import getUtility @@ -45,7 +47,7 @@ Names ===== You can display the mailing list names with their posting addresses, using the ---names/-n switch. +``--names/-n`` switch. >>> FakeArgs.names = True >>> command.process(FakeArgs) @@ -59,7 +61,7 @@ Descriptions ============ You can also display the mailing list descriptions, using the ---descriptions/-d option. +``--descriptions/-d`` option. >>> FakeArgs.descriptions = True >>> command.process(FakeArgs) @@ -81,7 +83,7 @@ Maybe you want the descriptions but not the names. Less verbosity ============== -There's also a --quiet/-q switch which reduces the verbosity a bit. +There's also a ``--quiet/-q`` switch which reduces the verbosity a bit. >>> FakeArgs.quiet = True >>> FakeArgs.descriptions = False @@ -127,6 +129,7 @@ Advertised lists Mailing lists can be 'advertised' meaning their existence is public knowledge. Non-advertised lists are considered private. Display through the command line can select on this attribute. +:: >>> FakeArgs.domains = [] >>> FakeArgs.advertised = True diff --git a/src/mailman/commands/docs/members.txt b/src/mailman/commands/docs/members.txt index af7f13da7..18a916781 100644 --- a/src/mailman/commands/docs/members.txt +++ b/src/mailman/commands/docs/members.txt @@ -2,8 +2,9 @@ Managing members ================ -The `bin/mailman members` command allows a site administrator to display, add, -and remove members from a mailing list. +The ``bin/mailman members`` command allows a site administrator to display, +add, and remove members from a mailing list. +:: >>> mlist1 = create_list('test1@example.com') @@ -31,6 +32,7 @@ options. To start with, there are no members of the mailing list. test1@example.com has no members Once the mailing list add some members, they will be displayed. +:: >>> from mailman.interfaces.member import DeliveryMode >>> from mailman.app.membership import add_member @@ -48,6 +50,7 @@ Once the mailing list add some members, they will be displayed. Bart Person <bart@example.com> Members are displayed in alphabetical order based on their address. +:: >>> add_member(mlist1, 'anne@aaaxample.com', 'Anne Person', 'xxx', ... DeliveryMode.regular, mlist1.preferred_language.code) @@ -114,6 +117,7 @@ members... Anne Person <anne@example.com> ...just MIME digest members. +:: >>> args.digest = 'mime' >>> command.process(args) @@ -129,6 +133,7 @@ Filtering on delivery status You can also filter the display on the member's delivery status. By default, all members are displayed, but you can filter out only those whose delivery status is enabled... +:: >>> from mailman.interfaces.member import DeliveryStatus >>> member = mlist1.members.get_member('anne@aaaxample.com') @@ -178,6 +183,7 @@ status is enabled... Cris Person <cris@example.com> You can also display all members who have delivery disabled for any reason. +:: >>> args.nomail = 'any' >>> command.process(args) @@ -195,11 +201,10 @@ Adding members You can add members to a mailing list from the command line. To do so, you need a file containing email addresses and full names that can be parsed by -email.utils.parseaddr(). +``email.utils.parseaddr()``. +:: >>> mlist2 = create_list('test2@example.com') - >>> addresses = [ - ... ] >>> import os >>> path = os.path.join(config.VAR_DIR, 'addresses.txt') @@ -214,11 +219,15 @@ email.utils.parseaddr(). >>> args.listname = [mlist2.fqdn_listname] >>> command.process(args) - >>> sorted(address.address for address in mlist2.members.addresses) - [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] + >>> from operator import attrgetter + >>> dump_list(mlist2.members.addresses, key=attrgetter('email')) + aperson@example.com + Bart Person <bperson@example.com> + Cate Person <cperson@example.com> -You can also specify '-' as the filename, in which case the addresses are +You can also specify ``-`` as the filename, in which case the addresses are taken from standard input. +:: >>> from StringIO import StringIO >>> fp = StringIO() @@ -236,11 +245,16 @@ taken from standard input. >>> command.process(args) >>> sys.stdin = sys.__stdin__ - >>> sorted(address.address for address in mlist2.members.addresses) - [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com', - u'dperson@example.com', u'eperson@example.com', u'fperson@example.com'] + >>> dump_list(mlist2.members.addresses, key=attrgetter('email')) + aperson@example.com + Bart Person <bperson@example.com> + Cate Person <cperson@example.com> + dperson@example.com + Elly Person <eperson@example.com> + Fred Person <fperson@example.com> Blank lines and lines that begin with '#' are ignored. +:: >>> with open(path, 'w') as fp: ... for address in ('gperson@example.com', @@ -253,13 +267,19 @@ Blank lines and lines that begin with '#' are ignored. >>> args.input_filename = path >>> command.process(args) - >>> sorted(address.address for address in mlist2.members.addresses) - [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com', - u'dperson@example.com', u'eperson@example.com', u'fperson@example.com', - u'gperson@example.com', u'iperson@example.com'] + >>> dump_list(mlist2.members.addresses, key=attrgetter('email')) + aperson@example.com + Bart Person <bperson@example.com> + Cate Person <cperson@example.com> + dperson@example.com + Elly Person <eperson@example.com> + Fred Person <fperson@example.com> + gperson@example.com + iperson@example.com Addresses which are already subscribed are ignored, although a warning is printed. +:: >>> with open(path, 'w') as fp: ... for address in ('gperson@example.com', @@ -272,10 +292,16 @@ printed. Already subscribed (skipping): gperson@example.com Already subscribed (skipping): aperson@example.com - >>> sorted(address.address for address in mlist2.members.addresses) - [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com', - u'dperson@example.com', u'eperson@example.com', u'fperson@example.com', - u'gperson@example.com', u'iperson@example.com', u'jperson@example.com'] + >>> dump_list(mlist2.members.addresses, key=attrgetter('email')) + aperson@example.com + Bart Person <bperson@example.com> + Cate Person <cperson@example.com> + dperson@example.com + Elly Person <eperson@example.com> + Fred Person <fperson@example.com> + gperson@example.com + iperson@example.com + jperson@example.com Displaying members diff --git a/src/mailman/commands/docs/membership.txt b/src/mailman/commands/docs/membership.txt index a0af15ba7..0da7ffadf 100644 --- a/src/mailman/commands/docs/membership.txt +++ b/src/mailman/commands/docs/membership.txt @@ -3,15 +3,15 @@ Membership changes via email ============================ Membership changes such as joining and leaving a mailing list, can be effected -via the email interface. The Mailman email commands 'join', 'leave', and -'confirm' are used. +via the email interface. The Mailman email commands ``join``, ``leave``, and +``confirm`` are used. Joining a mailing list ====================== -The mail command 'join' subscribes an email address to the mailing list. -'subscribe' is an alias for 'join'. +The mail command ``join`` subscribes an email address to the mailing list. +``subscribe`` is an alias for ``join``. >>> from mailman.commands.eml_membership import Join >>> join = Join() @@ -39,6 +39,7 @@ No address to join When no address argument is given, the message's From address will be used. If that's missing though, then an error is returned. +:: >>> from mailman.queue.command import Results >>> results = Results() @@ -52,7 +53,7 @@ If that's missing though, then an error is returned. join: No valid address found to subscribe <BLANKLINE> -The 'subscribe' command is an alias. +The ``subscribe`` command is an alias. >>> from mailman.commands.eml_membership import Subscribe >>> subscribe = Subscribe() @@ -133,6 +134,7 @@ Mailman has sent her the confirmation message. Once Anne confirms her registration, she will be made a member of the mailing list. +:: >>> def extract_token(message): ... return str(message['subject']).split()[1].strip() @@ -190,7 +192,9 @@ But she is not a member of the mailing list. >>> print mlist_2.members.get_member('anne@example.com') None -One Anne confirms this subscription, she becomes a member of the mailing list. +One Anne confirms this subscription, she becomes a member of the mailing +list. +:: >>> items = get_queue_messages('virgin') >>> len(items) @@ -220,8 +224,8 @@ One Anne confirms this subscription, she becomes a member of the mailing list. Leaving a mailing list ====================== -The mail command 'leave' unsubscribes an email address from the mailing list. -'unsubscribe' is an alias for 'leave'. +The mail command ``leave`` unsubscribes an email address from the mailing +list. ``unsubscribe`` is an alias for ``leave``. >>> from mailman.commands.eml_membership import Leave >>> leave = Leave() @@ -230,9 +234,9 @@ The mail command 'leave' unsubscribes an email address from the mailing list. >>> print leave.description Leave this mailing list. You will be asked to confirm your request. -Anne is a member of the baker@example.com mailing list, when she decides to -leave it. She sends a message to the -leave address for the list and is sent -a confirmation message for her request. +Anne is a member of the ``baker@example.com`` mailing list, when she decides +to leave it. She sends a message to the ``-leave`` address for the list and +is sent a confirmation message for her request. >>> results = Results() >>> print leave.process(mlist_2, msg, {}, (), results) @@ -251,6 +255,7 @@ Anne is no longer a member of the mailing list. Anne does not need to leave a mailing list with the same email address she's subscribe with. Any of her registered, linked, and validated email addresses will do. +:: >>> anne = user_manager.get_user('anne@example.com') >>> address = anne.register('anne.person@example.org') @@ -268,6 +273,7 @@ will do. Since Anne's alternative address has not yet been verified, it can't be used to unsubscribe Anne from the alpha mailing list. +:: >>> print leave.process(mlist, msg, {}, (), results) ContinueProcessing.no @@ -275,7 +281,7 @@ to unsubscribe Anne from the alpha mailing list. >>> print unicode(results) The results of your email command are provided below. <BLANKLINE> - Invalid or unverified address: anne.person@example.org + Invalid or unverified email address: anne.person@example.org <BLANKLINE> >>> print mlist.members.get_member('anne@example.com') @@ -284,6 +290,7 @@ to unsubscribe Anne from the alpha mailing list. Once Anne has verified her alternative address though, it can be used to unsubscribe her from the list. +:: >>> from datetime import datetime >>> address.verified_on = datetime.now() @@ -306,6 +313,7 @@ Confirmations ============= Bart wants to join the alpha list, so he sends his subscription request. +:: >>> msg = message_from_string("""\ ... From: Bart Person <bart@example.com> @@ -333,6 +341,7 @@ Bart is still not a user. Bart replies to the original message, specifically keeping the Subject header intact except for any prefix. Mailman matches the token and confirms Bart as a user of the system. +:: >>> msg = message_from_string("""\ ... From: Bart Person <bart@example.com> diff --git a/src/mailman/commands/docs/qfile.txt b/src/mailman/commands/docs/qfile.txt index c54f37c50..74ede1b64 100644 --- a/src/mailman/commands/docs/qfile.txt +++ b/src/mailman/commands/docs/qfile.txt @@ -2,7 +2,7 @@ Dumping queue files =================== -The 'qfile' command dumps the contents of a queue pickle file. This is +The ``qfile`` command dumps the contents of a queue pickle file. This is especially useful when you have shunt files you want to inspect. XXX Test the interactive operation of qfile @@ -11,8 +11,9 @@ XXX Test the interactive operation of qfile Pretty printing =============== -By default, the qfile command pretty prints the contents of a queue pickle +By default, the ``qfile`` command pretty prints the contents of a queue pickle file to standard output. +:: >>> from mailman.commands.cli_qfile import QFile >>> command = QFile() @@ -23,6 +24,7 @@ file to standard output. ... qfile = [] Let's say Mailman shunted a message file. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -36,6 +38,7 @@ Let's say Mailman shunted a message file. >>> basename = shuntq.enqueue(msg, foo=7, bar='baz', bad='yes') Once we've figured out the file name of the shunted message, we can print it. +:: >>> from os.path import join >>> qfile = join(shuntq.queue_directory, basename + '.pck') diff --git a/src/mailman/commands/docs/remove.txt b/src/mailman/commands/docs/remove.txt index 0158c9b37..f0f4e64f6 100644 --- a/src/mailman/commands/docs/remove.txt +++ b/src/mailman/commands/docs/remove.txt @@ -3,6 +3,7 @@ Command line list removal ========================= A system administrator can remove mailing lists by the command line. +:: >>> create_list('test@example.com') <mailing list "test@example.com" at ...> @@ -29,6 +30,7 @@ A system administrator can remove mailing lists by the command line. None You can also remove lists quietly. +:: >>> create_list('test@example.com') <mailing list "test@example.com" at ...> @@ -44,6 +46,7 @@ Removing archives ================= By default 'mailman remove' does not remove a mailing list's archives. +:: >>> create_list('test@example.com') <mailing list "test@example.com" at ...> @@ -71,6 +74,7 @@ By default 'mailman remove' does not remove a mailing list's archives. Even if the mailing list has been deleted, you can still delete the archives afterward. +:: >>> args.archives = True diff --git a/src/mailman/commands/docs/status.txt b/src/mailman/commands/docs/status.txt new file mode 100644 index 000000000..6deb7fdc0 --- /dev/null +++ b/src/mailman/commands/docs/status.txt @@ -0,0 +1,37 @@ +============== +Getting status +============== + +The status of the Mailman master process can be queried from the command line. +It's clear at this point that nothing is running. +:: + + >>> from mailman.commands.cli_status import Status + >>> status = Status() + + >>> class FakeArgs: + ... pass + +The status is printed to stdout and a status code is returned. + + >>> status.process(FakeArgs) + GNU Mailman is not running + 0 + +We can simulate the master queue runner starting up by acquiring its lock. + + >>> from flufl.lock import Lock + >>> lock = Lock(config.LOCK_FILE) + >>> lock.lock() + +Getting the status confirms that the master queue runner is running. + + >>> status.process(FakeArgs) + GNU Mailman is running (master pid: ... + +We shutdown the master queue runner, and confirm the status. + + >>> lock.unlock() + >>> status.process(FakeArgs) + GNU Mailman is not running + 0 diff --git a/src/mailman/commands/docs/unshunt.txt b/src/mailman/commands/docs/unshunt.txt index dcf71f3d1..ce9d70316 100644 --- a/src/mailman/commands/docs/unshunt.txt +++ b/src/mailman/commands/docs/unshunt.txt @@ -3,8 +3,9 @@ Unshunt ======= When errors occur while processing email messages, the messages will end up in -the 'shunt' queue. The 'unshunt' command allows system administrators to +the ``shunt`` queue. The ``unshunt`` command allows system administrators to manage the shunt queue. +:: >>> from mailman.commands.cli_unshunt import Unshunt >>> command = Unshunt() @@ -13,6 +14,7 @@ manage the shunt queue. ... discard = False Let's say there is a message in the shunt queue. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -29,7 +31,9 @@ Let's say there is a message in the shunt queue. >>> len(list(shuntq.files)) 1 -The unshunt command by default moves the message back to the incoming queue. +The ``unshunt`` command by default moves the message back to the incoming +queue. +:: >>> inq = config.switchboards['in'] >>> len(list(inq.files)) @@ -49,7 +53,8 @@ The unshunt command by default moves the message back to the incoming queue. <BLANKLINE> <BLANKLINE> -'unshunt' moves all shunt queue messages. +``unshunt`` moves all shunt queue messages. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -97,6 +102,7 @@ different queue, it will be returned to the queue it came from. ... """) The queue that the message comes from is in message metadata. +:: >>> base_name = shuntq.enqueue(msg, {}, whichq='bounces') @@ -106,6 +112,7 @@ The queue that the message comes from is in message metadata. 0 The message is automatically re-queued to the bounces queue. +:: >>> command.process(FakeArgs) >>> len(list(shuntq.files)) @@ -127,6 +134,7 @@ Discarding all shunted messages =============================== If you don't care about the shunted messages, just discard them. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com diff --git a/src/mailman/commands/docs/version.txt b/src/mailman/commands/docs/version.txt index 9987066e9..8032df20a 100644 --- a/src/mailman/commands/docs/version.txt +++ b/src/mailman/commands/docs/version.txt @@ -3,6 +3,7 @@ Printing the version ==================== You can print the Mailman version number. +:: >>> from mailman.commands.cli_version import Version >>> command = Version() diff --git a/src/mailman/commands/docs/withlist.txt b/src/mailman/commands/docs/withlist.txt index f85607ab9..7632c726a 100644 --- a/src/mailman/commands/docs/withlist.txt +++ b/src/mailman/commands/docs/withlist.txt @@ -2,7 +2,7 @@ Operating on mailing lists ========================== -The 'withlist' command is a pretty powerful way to operate on mailing lists +The ``withlist`` command is a pretty powerful way to operate on mailing lists from the command line. This command allows you to interact with a list at a Python prompt, or process one or more mailing lists through custom made Python functions. @@ -13,7 +13,8 @@ XXX Test the interactive operation of withlist Getting detailed help ===================== -Because withlist is so complex, you need to request detailed help. +Because ``withlist`` is so complex, you need to request detailed help. +:: >>> from mailman.commands.cli_withlist import Withlist >>> command = Withlist() @@ -39,9 +40,10 @@ Because withlist is so complex, you need to request detailed help. Running a command ================= -By putting a Python function somewhere on your sys.path, you can have withlist -call that function on a given mailing list. The function takes a single -argument, the mailing list. +By putting a Python function somewhere on your ``sys.path``, you can have +``withlist`` call that function on a given mailing list. The function takes a +single argument, the mailing list. +:: >>> import os, sys >>> old_path = sys.path[:] @@ -78,8 +80,9 @@ Multiple lists ============== You can run a command over more than one list by using a regular expression in -the LISTNAME argument. To indicate a regular expression is used, the string +the `listname` argument. To indicate a regular expression is used, the string must start with a caret. +:: >>> mlist_2 = create_list('badger@example.com') >>> mlist_3 = create_list('badboys@example.com') diff --git a/src/mailman/commands/eml_confirm.py b/src/mailman/commands/eml_confirm.py index 4c8f6fb8b..185e447df 100644 --- a/src/mailman/commands/eml_confirm.py +++ b/src/mailman/commands/eml_confirm.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/eml_echo.py b/src/mailman/commands/eml_echo.py index 12ec0c37e..71f8b9e31 100644 --- a/src/mailman/commands/eml_echo.py +++ b/src/mailman/commands/eml_echo.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2002-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/eml_end.py b/src/mailman/commands/eml_end.py index ed6b38790..764a0d3f0 100644 --- a/src/mailman/commands/eml_end.py +++ b/src/mailman/commands/eml_end.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2002-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/commands/eml_membership.py b/src/mailman/commands/eml_membership.py index 17dc0d0d6..9b0c21ebd 100644 --- a/src/mailman/commands/eml_membership.py +++ b/src/mailman/commands/eml_membership.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2002-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -32,7 +32,6 @@ from email.utils import formataddr, parseaddr from zope.component import getUtility from zope.interface import implements -from mailman.config import config from mailman.core.i18n import _ from mailman.interfaces.command import ContinueProcessing, IEmailCommand from mailman.interfaces.member import DeliveryMode @@ -145,36 +144,36 @@ class Leave: def process(self, mlist, msg, msgdata, arguments, results): """See `IEmailCommand`.""" - address = msg.sender - if not address: + email = msg.sender + if not email: print >> results, _( - '$self.name: No valid address found to unsubscribe') + '$self.name: No valid email address found to unsubscribe') return ContinueProcessing.no user_manager = getUtility(IUserManager) - user = user_manager.get_user(address) + user = user_manager.get_user(email) if user is None: - print >> results, _('No registered user for address: $address') + print >> results, _('No registered user for email address: $email') return ContinueProcessing.no # The address that the -leave command was sent from, must be verified. # Otherwise you could link a bogus address to anyone's account, and # then send a leave command from that address. - if user_manager.get_address(address).verified_on is None: - print >> results, _('Invalid or unverified address: $address') + if user_manager.get_address(email).verified_on is None: + print >> results, _('Invalid or unverified email address: $email') return ContinueProcessing.no for user_address in user.addresses: # Only recognize verified addresses. if user_address.verified_on is None: continue - member = mlist.members.get_member(user_address.address) + member = mlist.members.get_member(user_address.email) if member is not None: break else: # None of the user's addresses are subscribed to this mailing list. print >> results, _( - '$self.name: $address is not a member of $mlist.fqdn_listname') + '$self.name: $email is not a member of $mlist.fqdn_listname') return ContinueProcessing.no member.unsubscribe() - person = formataddr((user.real_name, address)) + person = formataddr((user.real_name, email)) print >> results, _('$person left $mlist.fqdn_listname') return ContinueProcessing.yes diff --git a/src/mailman/config/__init__.py b/src/mailman/config/__init__.py index 531728a5a..391dcf453 100644 --- a/src/mailman/config/__init__.py +++ b/src/mailman/config/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py index 6d0e48ef1..4cfb6b5a5 100644 --- a/src/mailman/config/config.py +++ b/src/mailman/config/config.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -27,8 +27,6 @@ __all__ = [ import os import sys -import errno -import logging from lazr.config import ConfigSchema, as_boolean from pkg_resources import resource_stream @@ -37,7 +35,6 @@ from zope.component import getUtility from zope.interface import Interface, implements from mailman import version -from mailman.core import errors from mailman.interfaces.languages import ILanguageManager from mailman.styles.manager import StyleManager from mailman.utilities.filesystem import makedirs diff --git a/src/mailman/config/mailman.cfg b/src/mailman/config/mailman.cfg index 5681dff57..f6811d7c9 100644 --- a/src/mailman/config/mailman.cfg +++ b/src/mailman/config/mailman.cfg @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg index 2c2aade12..f789d28f9 100644 --- a/src/mailman/config/schema.cfg +++ b/src/mailman/config/schema.cfg @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -126,7 +126,7 @@ enabled: no # Set this to an address to force the SMTP RCPT TO recipents when devmode is # enabled. This way messages can't be accidentally sent to real addresses. -recipient: +recipient: [passwords] @@ -298,6 +298,12 @@ show_tracebacks: yes # The API version number for the current API. api_version: 3.0 +# The administrative username. +admin_user: restadmin + +# The administrative password. +admin_pass: restpass + [language.master] # Template for language definitions. The section name must be [language.xx] @@ -335,9 +341,12 @@ incoming: mailman.mta.postfix.LMTP # message metadata dictionary. outgoing: mailman.mta.deliver.deliver -# How to connect to the outgoing MTA. +# How to connect to the outgoing MTA. If smtp_user and smtp_pass is given, +# then Mailman will attempt to log into the MTA when making a new connection. smtp_host: localhost smtp_port: 25 +smtp_user: +smtp_pass: # Where the LMTP server listens for connections. Use 127.0.0.1 instead of # localhost for Postfix integration, because Postfix only consults DNS diff --git a/src/mailman/core/chains.py b/src/mailman/core/chains.py index 35e886d69..31652a684 100644 --- a/src/mailman/core/chains.py +++ b/src/mailman/core/chains.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -26,14 +26,12 @@ __all__ = [ ] -from mailman.chains.accept import AcceptChain -from mailman.chains.builtin import BuiltInChain -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 zope.interface.verify import verifyObject + +from mailman.app.finder import find_components +from mailman.chains.base import Chain, TerminalChainBase from mailman.config import config -from mailman.interfaces.chain import LinkAction +from mailman.interfaces.chain import LinkAction, IChain @@ -67,7 +65,6 @@ def process(mlist, msg, msgdata, start_chain='built-in'): 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) @@ -103,16 +100,19 @@ def process(mlist, msg, msgdata, start_chain='built-in'): def initialize(): """Set up chains, both built-in and from the database.""" - for chain_class in (DiscardChain, HoldChain, RejectChain, AcceptChain): + for chain_class in find_components('mailman.chains', IChain): + # FIXME 2010-12-28 barry: We need a generic way to disable automatic + # instantiation of discovered classes. This is useful not just for + # chains, but also for rules, handlers, etc. Ideally it should be + # part of find_components(). For now, hard code the ones we do not + # want to instantiate. + if chain_class in (Chain, TerminalChainBase): + continue chain = chain_class() + verifyObject(IChain, chain) assert chain.name not in config.chains, ( - 'Duplicate chain name: {0}'.format(chain.name)) + 'Duplicate chain "{0}" found in {1} (previously: {2}'.format( + chain.name, chain_class, config.chains[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/src/mailman/core/constants.py b/src/mailman/core/constants.py index c0566e5d8..83bd91c55 100644 --- a/src/mailman/core/constants.py +++ b/src/mailman/core/constants.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/core/errors.py b/src/mailman/core/errors.py index 3faa46884..8b30f2700 100644 --- a/src/mailman/core/errors.py +++ b/src/mailman/core/errors.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -35,7 +35,6 @@ __all__ = [ 'BadPasswordSchemeError', 'CantDigestError', 'DiscardMessage', - 'EmailAddressError', 'HandlerError', 'HoldMessage', 'LostHeldMessage', @@ -43,7 +42,6 @@ __all__ = [ 'MailmanException', 'MemberError', 'MustDigestError', - 'NotAMemberError', 'PasswordError', 'RejectMessage', ] diff --git a/src/mailman/core/i18n.py b/src/mailman/core/i18n.py index 372a4685d..e858a3b2a 100644 --- a/src/mailman/core/i18n.py +++ b/src/mailman/core/i18n.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -24,7 +24,6 @@ __all__ = [ '_', 'ctime', 'initialize', - 'set_language', ] diff --git a/src/mailman/core/initialize.py b/src/mailman/core/initialize.py index e55e97ee5..148d1a150 100644 --- a/src/mailman/core/initialize.py +++ b/src/mailman/core/initialize.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -37,7 +37,6 @@ __all__ = [ import os -import sys from pkg_resources import resource_string from zope.configuration import xmlconfig diff --git a/src/mailman/core/logging.py b/src/mailman/core/logging.py index edbacef31..e15924177 100644 --- a/src/mailman/core/logging.py +++ b/src/mailman/core/logging.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -55,10 +55,10 @@ class ReopenableFileHandler(logging.Handler): """A file handler that supports reopening.""" def __init__(self, name, filename): + logging.Handler.__init__(self) self.name = name self._filename = filename self._stream = self._open() - logging.Handler.__init__(self) def _open(self): return codecs.open(self._filename, 'a', 'utf-8') diff --git a/src/mailman/core/pipelines.py b/src/mailman/core/pipelines.py index ffd196f85..15adca501 100644 --- a/src/mailman/core/pipelines.py +++ b/src/mailman/core/pipelines.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -116,7 +116,7 @@ def initialize(): verifyObject(IHandler, handler) assert handler.name not in config.handlers, ( 'Duplicate handler "{0}" found in {1}'.format( - handler.name, handler_finder)) + handler.name, handler_class)) config.handlers[handler.name] = handler # Set up some pipelines. for pipeline_class in (BuiltInPipeline, VirginPipeline): diff --git a/src/mailman/core/rules.py b/src/mailman/core/rules.py index 015d0d2e4..95de29190 100644 --- a/src/mailman/core/rules.py +++ b/src/mailman/core/rules.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -41,5 +41,5 @@ def initialize(): verifyObject(IRule, rule) assert rule.name not in config.rules, ( 'Duplicate rule "{0}" found in {1}'.format( - rule.name, rule_finder)) + rule.name, rule_class)) config.rules[rule.name] = rule diff --git a/src/mailman/core/system.py b/src/mailman/core/system.py index 6048fa187..34518cea5 100644 --- a/src/mailman/core/system.py +++ b/src/mailman/core/system.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/database/mailman.sql b/src/mailman/database/mailman.sql index 84a906fb1..bb7f9dc57 100644 --- a/src/mailman/database/mailman.sql +++ b/src/mailman/database/mailman.sql @@ -23,7 +23,7 @@ CREATE INDEX ix_acceptablealias_alias CREATE TABLE address ( id INTEGER NOT NULL, - address TEXT, + email TEXT, _original TEXT, real_name TEXT, verified_on TIMESTAMP, @@ -97,7 +97,7 @@ CREATE TABLE mailinglist ( next_digest_number INTEGER, digest_last_sent_at TIMESTAMP, volume INTEGER, - last_post_time TIMESTAMP, + last_post_at TIMESTAMP, accept_these_nonmembers BLOB, acceptable_aliases_id INTEGER, admin_immed_notify BOOLEAN, @@ -131,7 +131,8 @@ CREATE TABLE mailinglist ( filter_content BOOLEAN, collapse_alternatives BOOLEAN, convert_html_to_plaintext BOOLEAN, - default_member_moderation BOOLEAN, + default_member_action INTEGER, + default_nonmember_action INTEGER, description TEXT, digest_footer TEXT, digest_header TEXT, @@ -156,7 +157,6 @@ CREATE TABLE mailinglist ( max_days_to_hold INTEGER, max_message_size INTEGER, max_num_recipients INTEGER, - member_moderation_action BOOLEAN, member_moderation_notice TEXT, mime_is_default_digest BOOLEAN, moderator_password TEXT, @@ -199,7 +199,7 @@ CREATE TABLE member ( id INTEGER NOT NULL, role TEXT, mailing_list TEXT, - is_moderated BOOLEAN, + moderation_action INTEGER, address_id INTEGER, preferences_id INTEGER, PRIMARY KEY (id), diff --git a/src/mailman/database/model.py b/src/mailman/database/model.py index 5fb957699..3e5dcad57 100644 --- a/src/mailman/database/model.py +++ b/src/mailman/database/model.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/database/stock.py b/src/mailman/database/stock.py index 047b76cb9..e69fe9c7c 100644 --- a/src/mailman/database/stock.py +++ b/src/mailman/database/stock.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,7 +25,7 @@ __all__ = [ import os import logging -from locknix.lockfile import Lock +from flufl.lock import Lock from lazr.config import as_boolean from pkg_resources import resource_string from storm.cache import GenerationalCache @@ -37,9 +37,6 @@ import mailman.version from mailman.config import config from mailman.interfaces.database import IDatabase, SchemaVersionMismatchError -from mailman.model.messagestore import MessageStore -from mailman.model.pending import Pendings -from mailman.model.requests import Requests from mailman.model.version import Version from mailman.utilities.string import expand diff --git a/src/mailman/database/transaction.py b/src/mailman/database/transaction.py index d0387be79..f0e9e5f9f 100644 --- a/src/mailman/database/transaction.py +++ b/src/mailman/database/transaction.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/database/types.py b/src/mailman/database/types.py index a4e76a7e8..1195802ff 100644 --- a/src/mailman/database/types.py +++ b/src/mailman/database/types.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -26,8 +26,6 @@ __all__ = [ ] -import sys - from storm.properties import SimpleProperty from storm.variables import Variable diff --git a/src/mailman/docs/ACKNOWLEDGMENTS.txt b/src/mailman/docs/ACKNOWLEDGMENTS.txt index b17d97f27..bb971a91d 100644 --- a/src/mailman/docs/ACKNOWLEDGMENTS.txt +++ b/src/mailman/docs/ACKNOWLEDGMENTS.txt @@ -4,7 +4,7 @@ GNU Mailman Acknowledgments =========================== -Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +Copyright (C) 1998-2011 by the Free Software Foundation, Inc. Core Developers @@ -81,6 +81,7 @@ left off the list! * Mike Avery * Stonewall Ballard * Moreno Baricevic +* Jimmy Bergman * Jeff Berliner * Stuart Bishop * David Blomquist diff --git a/src/mailman/docs/NEWS.txt b/src/mailman/docs/NEWS.txt index 59b6cd936..ed91fb7aa 100644 --- a/src/mailman/docs/NEWS.txt +++ b/src/mailman/docs/NEWS.txt @@ -2,21 +2,76 @@ Mailman - The GNU Mailing List Management System ================================================ -Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +Copyright (C) 1998-2011 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 7 -- "Mission" +======================== +(201X-XX-XX) + +Architecture +------------ + * In all interfaces, "email" refers to the textual email address while + "address" refers to the `IAddress` object. + * mailman.chains.base.Chain no longer self registers. + * New member and nonmember moderation rules and chains. This effectively + ports moderation rules from Mailman 2 and replaces attributes such as + member_moderation_action, default_member_moderation, and + generic_nonmember_action. Now, nonmembers exist as subscriptions on a + mailing list and members have a moderation_action attribute which describes + the disposition for postings from that address. + * Member.is_moderated was removed because of the above change. + * default_member_action and default_nonmember_action were added to mailing + lists. + * All sender addresses are registered (unverified) with the user manager by + the incoming queue runner. This way, nonmember moderation rules will + always have an IAddress that they can subscribe to the list (as + MemberRole.nonmember). + * Support for SMTP AUTH added via smtp_user and smtp_pass configuration + variables in the [mta] section. (LP: #490044) + +Commands +-------- + * 'bin/mailman start' does a better job of producing an error when Mailman is + already running. + * 'bin/mailman status' added for providing command line status on the master + queue runner watcher process. + * 'bin/mailman info' now prints the REST root url and credentials. + +REST +---- + * Add Basic Auth support for REST API security. (Jimmy Bergman) + * Include the fqdn_listname and email address in the member JSON + representation. + * Added reply_goes_to_list, send_welcome_msg, welcome_msg, + default_member_moderation to the mailing list's writable attributes in the + REST service. (Jimmy Bergman) + +Build +----- + * Support Python 2.7. (LP: #667472) + * Disable site-packages in buildout.cfg because of LP: #659231. + * Don't include eggs/ or parts/ in the source tarball. (LP: #656946) + * flufl.lock is now required instead of locknix. + +Bugs fixed +---------- + * Typo in scan_message(). (LP: #645897) + * Clean up many pyflakes problems. + + 3.0 alpha 6 -- "Cut to the Chase" ================================= -(2010-XX-XX) +(2010-09-20) Commands -------- * The functionality of 'bin/list_members' has been moved to - 'bin/mailman members' - * 'bin/mailman info' grew a -p/--paths option to display the file system + 'bin/mailman members'. + * 'bin/mailman info' -v/--verbose output displays the file system layout paths Mailman is currently configured to use. Configuration @@ -28,6 +83,40 @@ Configuration development, and 'fhs' for Filesystem Hierarchy Standard 2.3 (LP #490144). * Queue file directories now live in $var_dir/queues. +REST +---- + * lazr.restful has been replaced by restish as the REST publishing technology + used by Mailman. + * New REST API for getting all the members of a roster for a specific mailing + list. + * New REST API for getting and setting a mailing list's configuration. GET + and PUT are supported to retrieve the current configuration, and set all + the list's writable attributes in one request. PATCH is supported to + partially update a mailing list's configuration. Individual options can be + set and retrieved by using subpaths. + * Subscribing an already subscribed member via REST now returns a 409 HTTP + error. LP: #552917 + * Fixed a bug when deleting a list via the REST API. LP: #601899 + +Architecture +------------ + * X-BeenThere header is removed. + * Mailman no longer touches the Sender or Errors-To headers. + * Chain actions can now fire Zope events in their _process() + implementations. + * Environment variable $MAILMAN_VAR_DIR can be used to control the var/ + directory for Mailman's runtime files. New environment variable + $MAILMAN_UNDER_MASTER_CONTROL is used instead of the qrunner's --subproc/-s + option. + +Miscellaneous +------------- + * Allow X-Approved and X-Approve headers, equivalent to Approved and + Approve. LP: #557750 + * Various test failure fixes. LP: #543618, LP: #544477 + * List-Post header is retained in MIME digest messages. LP: #526143 + * Importing from a Mailman 2.1.x list is partially supported. + 3.0 alpha 5 -- "Distant Early Warning" ====================================== diff --git a/src/mailman/docs/README.txt b/src/mailman/docs/README.txt index 9f008f16a..d912a680f 100644 --- a/src/mailman/docs/README.txt +++ b/src/mailman/docs/README.txt @@ -18,7 +18,7 @@ Learn more about GNU Mailman in the `Getting Started`_ documentation. Copyright ========= -Copyright 1998-2010 by the Free Software Foundation, Inc. +Copyright 1998-2011 by the Free Software Foundation, Inc. This file is part of GNU Mailman. @@ -108,6 +108,7 @@ Table of Contents ../pipeline/docs/* ../queue/docs/* ../rest/docs/* + ../chains/docs/* ../rules/docs/* ../archiving/docs/* ../mta/docs/* diff --git a/src/mailman/docs/START.txt b/src/mailman/docs/START.txt index 19def80d7..3c9e30280 100644 --- a/src/mailman/docs/START.txt +++ b/src/mailman/docs/START.txt @@ -2,7 +2,7 @@ Getting started with GNU Mailman ================================ -Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +Copyright (C) 2008-2011 by the Free Software Foundation, Inc. Alpha Release diff --git a/src/mailman/docs/STYLEGUIDE.txt b/src/mailman/docs/STYLEGUIDE.txt index cba98e17e..5a276317c 100644 --- a/src/mailman/docs/STYLEGUIDE.txt +++ b/src/mailman/docs/STYLEGUIDE.txt @@ -2,7 +2,7 @@ GNU Mailman Coding Style Guide ============================== -Copyright (C) 2002-2010 Barry A. Warsaw +Copyright (C) 2002-2011 Barry A. Warsaw Python coding style guide for GNU Mailman diff --git a/src/mailman/docs/__init__.py b/src/mailman/docs/__init__.py index 72968122b..2679603ad 100644 --- a/src/mailman/docs/__init__.py +++ b/src/mailman/docs/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/email/message.py b/src/mailman/email/message.py index ec3753eab..4eb049f17 100644 --- a/src/mailman/email/message.py +++ b/src/mailman/email/message.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -33,14 +33,11 @@ __all__ = [ ] -import re import email import email.message import email.utils -from email.charset import Charset from email.header import Header -from lazr.config import as_boolean from mailman.config import config @@ -147,7 +144,8 @@ class Message(email.message.Message): field_values = self.get_all(header, []) senders.extend(address.lower() for (real_name, address) in email.utils.getaddresses(field_values)) - return senders + # Filter out None and the empty string. + return [sender for sender in senders if sender] def get_filename(self, failobj=None): """Some MUA have bugs in RFC2231 filename encoding and cause diff --git a/src/mailman/email/utils.py b/src/mailman/email/utils.py index e92b2d898..b4f3ac5f7 100644 --- a/src/mailman/email/utils.py +++ b/src/mailman/email/utils.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/email/validate.py b/src/mailman/email/validate.py index 55acd76a9..4eb13eee8 100644 --- a/src/mailman/email/validate.py +++ b/src/mailman/email/validate.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/extras/mailman.cfg.in b/src/mailman/extras/mailman.cfg.in index 774aae987..03ed1aeaa 100644 --- a/src/mailman/extras/mailman.cfg.in +++ b/src/mailman/extras/mailman.cfg.in @@ -1,6 +1,6 @@ # -*- python -*- -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 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 diff --git a/src/mailman/inject.py b/src/mailman/inject.py index b3ade993a..9292b62dd 100644 --- a/src/mailman/inject.py +++ b/src/mailman/inject.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interact.py b/src/mailman/interact.py index e40ee476a..758226ef7 100644 --- a/src/mailman/interact.py +++ b/src/mailman/interact.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/action.py b/src/mailman/interfaces/action.py index 0e4ba7d35..ce8983a19 100644 --- a/src/mailman/interfaces/action.py +++ b/src/mailman/interfaces/action.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -15,6 +15,8 @@ # You should have received a copy of the GNU General Public License along with # GNU Mailman. If not, see <http://www.gnu.org/licenses/>. +"""Message actions.""" + __metaclass__ = type __all__ = [ 'Action', diff --git a/src/mailman/interfaces/address.py b/src/mailman/interfaces/address.py index d54ea64c3..446bae3f3 100644 --- a/src/mailman/interfaces/address.py +++ b/src/mailman/interfaces/address.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -60,20 +60,20 @@ class InvalidEmailAddressError(AddressError): class IAddress(Interface): """Email address related information.""" - address = Attribute( + email = Attribute( """Read-only text email address.""") - original_address = Attribute( - """Read-only original case-preserved address. + original_email = Attribute( + """Read-only original case-preserved email address. - For almost all intents and purposes, addresses in Mailman are case - insensitive, however because RFC 2821 allows for case sensitive local - parts, Mailman preserves the case of the original address when - emailing the user. + For almost all intents and purposes, email addresses in Mailman are + case insensitive, however because RFC 2821 allows for case sensitive + local parts, Mailman preserves the case of the original email address + when delivering a message to the user. - `original_address` will be the same as address if the original address - was all lower case. Otherwise `original_address` will be the case - preserved address; `address` will always be lower case. + `original_email` will be the same as `email` if the original email + address was all lower case. Otherwise `original_email` will be the + case preserved email address; `email` will always be lower case. """) real_name = Attribute( @@ -84,7 +84,7 @@ class IAddress(Interface): Registeration is really the date at which this address became known to us. It may have been explicitly registered by a user, or it may have - been implicitly registered, e.g. by showing up in a non-member + been implicitly registered, e.g. by showing up in a nonmember posting.""") verified_on = Attribute( diff --git a/src/mailman/interfaces/archiver.py b/src/mailman/interfaces/archiver.py index d2b1974ca..0c01b4a1b 100644 --- a/src/mailman/interfaces/archiver.py +++ b/src/mailman/interfaces/archiver.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/autorespond.py b/src/mailman/interfaces/autorespond.py index 6f5487842..584e76cb7 100644 --- a/src/mailman/interfaces/autorespond.py +++ b/src/mailman/interfaces/autorespond.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/bounce.py b/src/mailman/interfaces/bounce.py new file mode 100644 index 000000000..fba269609 --- /dev/null +++ b/src/mailman/interfaces/bounce.py @@ -0,0 +1,52 @@ +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Interface to bounce detection components.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'IBounceDetector', + 'NonFatal', + ] + + +from zope.interface import Interface + + + +# Matching addresses were found, but they were determined to be non-fatal. In +# this case, processing is halted but no bounces are registered. +NonFatal = object() + + + +class IBounceDetector(Interface): + """Detect a bounce in an email message.""" + + def process(self, msg): + """Scan an email message looking for bounce addresses. + + :param msg: An email message. + :type msg: `Message` + :return: The detected bouncing addresses. When bouncing addresses are + found but are determined to be non-fatal, the special marker + `NonFatal` can be returned to halt any bounce processing + pipeline. None can be returned if no addresses are found. + :rtype: A sequence of strings, None, or NonFatal. + """ diff --git a/src/mailman/interfaces/chain.py b/src/mailman/interfaces/chain.py index da91fd982..df2fddfbc 100644 --- a/src/mailman/interfaces/chain.py +++ b/src/mailman/interfaces/chain.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/command.py b/src/mailman/interfaces/command.py index 08012b863..ade69c9af 100644 --- a/src/mailman/interfaces/command.py +++ b/src/mailman/interfaces/command.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/database.py b/src/mailman/interfaces/database.py index 42a0b2e34..7f04be5de 100644 --- a/src/mailman/interfaces/database.py +++ b/src/mailman/interfaces/database.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -26,7 +26,8 @@ __all__ = [ 'SchemaVersionMismatchError', ] -from zope.interface import Interface, Attribute + +from zope.interface import Interface from mailman.interfaces.errors import MailmanError from mailman.version import DATABASE_SCHEMA_VERSION diff --git a/src/mailman/interfaces/digests.py b/src/mailman/interfaces/digests.py index 488e01bba..e342159c7 100644 --- a/src/mailman/interfaces/digests.py +++ b/src/mailman/interfaces/digests.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/domain.py b/src/mailman/interfaces/domain.py index b7fc1c91f..e1e41035a 100644 --- a/src/mailman/interfaces/domain.py +++ b/src/mailman/interfaces/domain.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -30,7 +30,6 @@ __all__ = [ from zope.interface import Interface, Attribute from mailman.core.errors import MailmanError -from mailman.core.i18n import _ @@ -55,6 +54,9 @@ class IDomain(Interface): The base url for the Mailman server at this domain, which includes the scheme and host name.""") + scheme = Attribute( + """The protocol scheme used to contact this list's server.""") + description = Attribute( 'The human readable description of the domain name.') diff --git a/src/mailman/interfaces/errors.py b/src/mailman/interfaces/errors.py index fc7363b3a..46e4c55b0 100644 --- a/src/mailman/interfaces/errors.py +++ b/src/mailman/interfaces/errors.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/handler.py b/src/mailman/interfaces/handler.py index e8cb0993c..dab919fce 100644 --- a/src/mailman/interfaces/handler.py +++ b/src/mailman/interfaces/handler.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/languages.py b/src/mailman/interfaces/languages.py index 120b7de38..844c72d54 100644 --- a/src/mailman/interfaces/languages.py +++ b/src/mailman/interfaces/languages.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/listmanager.py b/src/mailman/interfaces/listmanager.py index f24230852..1efb4342f 100644 --- a/src/mailman/interfaces/listmanager.py +++ b/src/mailman/interfaces/listmanager.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -30,7 +30,6 @@ __all__ = [ from zope.interface import Interface, Attribute from mailman.interfaces.errors import MailmanError -from mailman.interfaces.mailinglist import IMailingList diff --git a/src/mailman/interfaces/mailinglist.py b/src/mailman/interfaces/mailinglist.py index 27ea7a2b8..d8c0ebb26 100644 --- a/src/mailman/interfaces/mailinglist.py +++ b/src/mailman/interfaces/mailinglist.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -21,7 +21,6 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'DigestFrequency', 'IAcceptableAlias', 'IAcceptableAliasSet', 'IMailingList', @@ -33,8 +32,6 @@ __all__ = [ from flufl.enum import Enum from zope.interface import Interface, Attribute -from mailman.core.i18n import _ - class Personalization(Enum): @@ -62,6 +59,9 @@ class IMailingList(Interface): # List identity + created_at = Attribute( + """The date and time that the mailing list was created.""") + list_name = Attribute("""\ The read-only short name of the mailing list. Note that where a Mailman installation supports multiple domains, this short name may @@ -70,6 +70,7 @@ class IMailingList(Interface): part of the posting email address. For example, if messages are posted to mylist@example.com, then the list_name is 'mylist'. """) + host_name = Attribute("""\ The read-only domain name 'hosting' this mailing list. This is always the domain name part of the posting email address, and it may bear no @@ -95,6 +96,13 @@ class IMailingList(Interface): Subject prefix. """) + description = Attribute("""\ + A terse phrase identifying this mailing list. + + This description is used when the mailing list is listed with other + mailing lists, or in headers, and so forth. It should be as succinct + as you can get it, while still identifying what the list is.""") + list_id = Attribute( """The RFC 2919 List-ID header value.""") @@ -106,6 +114,18 @@ class IMailingList(Interface): """Flag specifying whether to include any RFC 2369 header, including the RFC 2919 List-ID header.""") + anonymous_list = Attribute( + """Flag controlling whether messages to this list are anonymized. + + Anonymizing messages is not perfect, however setting this flag removes + the sender of the message (in the From, Sender, and Reply-To fields) + and replaces these with the list's posting address. + """) + + advertised = Attribute( + """Advertise this mailing list when people ask for an overview of the + available mailing lists.""") + # Contact addresses posting_address = Attribute( @@ -142,42 +162,27 @@ class IMailingList(Interface): """) join_address = Attribute( - """The address to which subscription requests should be sent. See - subscribe_address for a backward compatible alias. - """) + """The address to which subscription requests should be sent.""") leave_address = Attribute( - """The address to which unsubscription requests should be sent. See - unsubscribe_address for a backward compatible alias. - """) + """The address to which unsubscription requests should be sent.""") subscribe_address = Attribute( """Deprecated address to which subscription requests may be sent. This address is provided for backward compatibility only. See - join_address for the preferred alias. + `join_address` for the preferred alias. """) - leave_address = Attribute( + unsubscribe_address = Attribute( """Deprecated address to which unsubscription requests may be sent. This address is provided for backward compatibility only. See - leave_address for the preferred alias. + `leave_address` for the preferred alias. """) def confirm_address(cookie=''): """The address used for various forms of email confirmation.""" - creation_date = Attribute( - """The date and time that the mailing list was created.""") - - last_post_date = Attribute( - """The date and time a message was last posted to the mailing list.""") - - post_id = Attribute( - """A monotonically increasing integer sequentially assigned to each - list posting.""") - - digest_last_sent_at = Attribute( - """The date and time a digest of this mailing list was last sent.""") + # Rosters. owners = Attribute( """The IUser owners of this mailing list. @@ -227,6 +232,20 @@ class IMailingList(Interface): :rtype: Roster """ + # Posting history. + + last_post_at = Attribute( + """The date and time a message was last posted to the mailing list.""") + + post_id = Attribute( + """A monotonically increasing integer sequentially assigned to each + list posting.""") + + # Digests. + + digest_last_sent_at = Attribute( + """The date and time a digest of this mailing list was last sent.""") + volume = Attribute( """A monotonically increasing integer sequentially assigned to each new digest volume. The volume number may be bumped either @@ -268,10 +287,12 @@ class IMailingList(Interface): When a digest is being sent, each decorator may modify the final digest text.""") - protocol = Attribute( + # Web access. + + scheme = Attribute( """The protocol scheme used to contact this list's server. - The web server on thi protocol provides the web interface for this + The web server on this protocol provides the web interface for this mailing list. The protocol scheme should be 'http' or 'https'.""") web_host = Attribute( @@ -289,6 +310,78 @@ class IMailingList(Interface): 'location' attribute. """ + # Notifications. + + admin_immed_notify = Attribute( + """Flag controlling immediate notification of requests. + + List moderators normally get daily notices about pending + administrative requests. This flag controls whether moderators also + receive immediate notification of such pending requests. + """) + + admin_notify_mchanges = Attribute( + """Flag controlling notification of joins and leaves. + + List moderators can receive notifications for every member that joins + or leaves their mailing lists. This flag controls those + notifications. + """) + + # Autoresponses. + + autoresponse_grace_period = Attribute( + """Time period (in days) between automatic responses. + + When this mailing list is set to send an auto-response for messages + sent to mailing list posts, the mailing list owners, or the `-request` + address, such reponses will not be sent to the same user more than + once during the grace period. Set to zero (or a negative value) for + no grace period (i.e. auto-respond to every message). + """) + + autorespond_owner = Attribute( + """How should the mailing list automatically respond to messages sent + to the -owner or -moderator address? + + Options are: + * No response sent + * Send a response and discard the original messge + * Send a response and continue processing the original message + """) + + autoresponse_owner_text = Attribute( + """The text sent in an autoresponse to the owner or moderator.""") + + autorespond_postings = Attribute( + """How should the mailing list automatically respond to messages sent + to the list's posting address? + + Options are: + * No response sent + * Send a response and discard the original messge + * Send a response and continue processing the original message + """) + + autoresponse_postings_text = Attribute( + """The text sent in an autoresponse to the list's posting address.""") + + autorespond_requests = Attribute( + """How should the mailing list automatically respond to messages sent + to the list's `-request` address? + + Options are: + * No response sent + * Send a response and discard the original messge + * Send a response and continue processing the original message + """) + + autoresponse_request_text = Attribute( + """The text sent in an autoresponse to the list's `-request` + address.""") + + # Processing. + pipeline = Attribute( """The name of this mailing list's processing pipeline. @@ -303,6 +396,17 @@ class IMailingList(Interface): that gets created to accumlate messages for the digest. """) + administrative = Attribute( + """Flag controlling `administrivia` checks. + + Administrivia tests check whether postings to the mailing list are + really meant for the -request address. Examples include messages with + `help`, `subscribe`, `unsubscribe`, and other commands. When such + messages are incorrectly posted to the general mailing list, they are + just noise, and when this flag is set will be intercepted and in + general held for moderator approval. + """) + filter_content = Attribute( """Flag specifying whether to filter a message's content. @@ -338,7 +442,7 @@ class IMailingList(Interface): to a sequence to change it, or to None to empty it. Pass types are consulted after filter types, and only if `pass_types` is non-empty. """) - + filter_extensions = Attribute( """Sequence of file extensions that should be filtered out. @@ -352,7 +456,28 @@ class IMailingList(Interface): Pass extensions are consulted after filter extensions, and only if `pass_extensions` is non-empty. """) - + + # Moderation. + + default_member_action = Attribute( + """The default action to take for postings from members. + + When an address is subscribed to the mailing list, this attribute sets + the initial moderation action (as an `Action`). When the action is + `Action.defer` (the default), then normal posting decisions are made. + When the action is `Action.accept`, the postings are accepted without + any other checks. + """) + + default_nonmember_action = Attribute( + """The default action to take for postings from nonmembers. + + When a nonmember address posts to the mailing list, this attribute + sets the initial moderation action (as an `Action`). When the action + is `Action.defer` (the default), then normal posting decisions are + made. When the action is `Action.accept`, the postings are accepted + without any other checks. + """) diff --git a/src/mailman/interfaces/member.py b/src/mailman/interfaces/member.py index 8e316785b..d20580498 100644 --- a/src/mailman/interfaces/member.py +++ b/src/mailman/interfaces/member.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -69,6 +69,7 @@ class MemberRole(Enum): member = 1 owner = 2 moderator = 3 + nonmember = 4 @@ -94,7 +95,7 @@ class MembershipIsBannedError(MembershipError): """The address is not allowed to subscribe to the mailing list.""" def __init__(self, mlist, address): - super(MembershipIsBanned, self).__init__() + super(MembershipIsBannedError, self).__init__() self._mlist = mlist self._address = address @@ -132,8 +133,8 @@ class IMember(Interface): role = Attribute( """The role of this membership.""") - is_moderated = Attribute( - """True if the membership is moderated, otherwise False.""") + moderation_action = Attribute( + """The moderation action for this member as an `Action`.""") def unsubscribe(): """Unsubscribe (and delete) this member from the mailing list.""" diff --git a/src/mailman/interfaces/membership.py b/src/mailman/interfaces/membership.py index f42516ad1..fb3e0c6a2 100644 --- a/src/mailman/interfaces/membership.py +++ b/src/mailman/interfaces/membership.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -21,15 +21,12 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'SubscriptionService', + 'ISubscriptionService', ] from zope.interface import Interface -from mailman.core.i18n import _ -from mailman.interfaces.member import IMember - class ISubscriptionService(Interface): diff --git a/src/mailman/interfaces/messages.py b/src/mailman/interfaces/messages.py index 33e2a087d..d364e9e60 100644 --- a/src/mailman/interfaces/messages.py +++ b/src/mailman/interfaces/messages.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/mime.py b/src/mailman/interfaces/mime.py index e1928bc5c..ce99b74ee 100644 --- a/src/mailman/interfaces/mime.py +++ b/src/mailman/interfaces/mime.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/mlistrequest.py b/src/mailman/interfaces/mlistrequest.py index c0aa1cdab..5e928a640 100644 --- a/src/mailman/interfaces/mlistrequest.py +++ b/src/mailman/interfaces/mlistrequest.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/mta.py b/src/mailman/interfaces/mta.py index 95d453a8b..2aeb65172 100644 --- a/src/mailman/interfaces/mta.py +++ b/src/mailman/interfaces/mta.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -35,7 +35,7 @@ from mailman.core.errors import MailmanError class SomeRecipientsFailed(MailmanError): """Delivery to some or all recipients failed""" def __init__(self, temporary_failures, permanent_failures): - HandlerError.__init__(self) + super(SomeRecipientsFailed, self).__init__() self.temporary_failures = temporary_failures self.permanent_failures = permanent_failures diff --git a/src/mailman/interfaces/nntp.py b/src/mailman/interfaces/nntp.py index 6eb02ef68..8bd0282f2 100644 --- a/src/mailman/interfaces/nntp.py +++ b/src/mailman/interfaces/nntp.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/pending.py b/src/mailman/interfaces/pending.py index ad81f4144..1c5b737f8 100644 --- a/src/mailman/interfaces/pending.py +++ b/src/mailman/interfaces/pending.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/permissions.py b/src/mailman/interfaces/permissions.py index 264b3526a..75a1fde04 100644 --- a/src/mailman/interfaces/permissions.py +++ b/src/mailman/interfaces/permissions.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/pipeline.py b/src/mailman/interfaces/pipeline.py index 87db135e5..1bdb6a836 100644 --- a/src/mailman/interfaces/pipeline.py +++ b/src/mailman/interfaces/pipeline.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/preferences.py b/src/mailman/interfaces/preferences.py index 5661539eb..bbf767d9f 100644 --- a/src/mailman/interfaces/preferences.py +++ b/src/mailman/interfaces/preferences.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/registrar.py b/src/mailman/interfaces/registrar.py index c85a0103c..6deacfea8 100644 --- a/src/mailman/interfaces/registrar.py +++ b/src/mailman/interfaces/registrar.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -35,28 +35,29 @@ from zope.interface import Interface class IRegistrar(Interface): - """Interface for registering and verifying addresses and users. + """Interface for registering and verifying email addresses and users. - This is a higher level interface to user registration, address + This is a higher level interface to user registration, email address confirmation, etc. than the IUserManager. The latter does no validation, syntax checking, or confirmation, while this interface does. """ - def register(mlist, address, real_name=None): + def register(mlist, email, real_name=None): """Register the email address, requesting verification. - No IAddress or IUser is created during this step, but after successful - confirmation, it is guaranteed that an IAddress with a linked IUser - will exist. When a verified IAddress matching address already exists, - this method will do nothing, except link a new IUser to the IAddress - if one is not yet associated with the address. + No `IAddress` or `IUser` is created during this step, but after + successful confirmation, it is guaranteed that an `IAddress` with a + linked `IUser` will exist. When a verified `IAddress` matching + `email` already exists, this method will do nothing, except link a new + `IUser` to the `IAddress` if one is not yet associated with the + email address. In all cases, the email address is sanity checked for validity first. :param mlist: The mailing list that is the focus of this registration. :type mlist: `IMailingList` - :param address: The email address to register. - :type address: str + :param email: The email address to register. + :type email: str :param real_name: The optional real name of the user. :type real_name: str :return: The confirmation token string. diff --git a/src/mailman/interfaces/requests.py b/src/mailman/interfaces/requests.py index 779102f9a..226e71c2c 100644 --- a/src/mailman/interfaces/requests.py +++ b/src/mailman/interfaces/requests.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/roster.py b/src/mailman/interfaces/roster.py index 01d81cd2a..880087e77 100644 --- a/src/mailman/interfaces/roster.py +++ b/src/mailman/interfaces/roster.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -30,7 +30,7 @@ from zope.interface import Interface, Attribute class IRoster(Interface): - """A roster is a collection of IMembers.""" + """A roster is a collection of `IMembers`.""" name = Attribute( """The name for this roster. diff --git a/src/mailman/interfaces/rules.py b/src/mailman/interfaces/rules.py index a1d0a5544..a41870584 100644 --- a/src/mailman/interfaces/rules.py +++ b/src/mailman/interfaces/rules.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/runner.py b/src/mailman/interfaces/runner.py index 95ec8fc48..baf9bd2a1 100644 --- a/src/mailman/interfaces/runner.py +++ b/src/mailman/interfaces/runner.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/styles.py b/src/mailman/interfaces/styles.py index c21e54826..7043b703e 100644 --- a/src/mailman/interfaces/styles.py +++ b/src/mailman/interfaces/styles.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/switchboard.py b/src/mailman/interfaces/switchboard.py index 5c34f6fc7..a0c6f784e 100644 --- a/src/mailman/interfaces/switchboard.py +++ b/src/mailman/interfaces/switchboard.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/interfaces/system.py b/src/mailman/interfaces/system.py index 39156315f..f1a28ea84 100644 --- a/src/mailman/interfaces/system.py +++ b/src/mailman/interfaces/system.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -27,8 +27,6 @@ __all__ = [ from zope.interface import Interface, Attribute -from mailman.core.i18n import _ - class ISystem(Interface): diff --git a/src/mailman/interfaces/user.py b/src/mailman/interfaces/user.py index 824f6e99c..5e894701f 100644 --- a/src/mailman/interfaces/user.py +++ b/src/mailman/interfaces/user.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -33,29 +33,31 @@ class IUser(Interface): """A basic user.""" real_name = Attribute( - """This user's Real Name.""") + """This user's real name.""") password = Attribute( """This user's password information.""") addresses = Attribute( - """An iterator over all the IAddresses controlled by this user.""") + """An iterator over all the `IAddresses` controlled by this user.""") memberships = Attribute( """A roster of this user's memberships.""") - def register(address, real_name=None): + def register(email, real_name=None): """Register the given email address and link it to this user. - In this case, 'address' is a text email address, not an IAddress - object. If real_name is not given, the empty string is used. - - Raises AddressAlreadyLinkedError if this IAddress is already linked to - another user. If the corresponding IAddress already exists but is not - linked, then it is simply linked to the user, in which case - real_name is ignored. - - Return the new IAddress object. + :param email: The text email address to register. + :type email: str + :param real_name: The user's real name. If not given the empty string + is used. + :type real_name: str + :return: The address object linked to the user. If the associated + address object already existed (unlinked to a user) then the + `real_name` parameter is ignored. + :rtype: `IAddress` + :raises AddressAlreadyLinkedError: if this `IAddress` is already + linked to another user. """ def link(address): @@ -73,11 +75,13 @@ class IUser(Interface): some other user. """ - def controls(address): + def controls(email): """Determine whether this user controls the given email address. - 'address' is a text email address. This method returns true if the - user controls the given email address, otherwise false. + :param email: The text email address to register. + :type email: str + :return: True if the user controls the given email address. + :rtype: bool """ preferences = Attribute( diff --git a/src/mailman/interfaces/usermanager.py b/src/mailman/interfaces/usermanager.py index 16c45ebcc..e742505d4 100644 --- a/src/mailman/interfaces/usermanager.py +++ b/src/mailman/interfaces/usermanager.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -15,7 +15,7 @@ # You should have received a copy of the GNU General Public License along with # GNU Mailman. If not, see <http://www.gnu.org/licenses/>. -"""Interface describing a user manager service.""" +"""Interface describing the user management service.""" from __future__ import absolute_import, unicode_literals @@ -30,67 +30,74 @@ from zope.interface import Interface, Attribute class IUserManager(Interface): - """The interface of a global user manager service. + """The global user management service.""" - Different user managers have different concepts of what a user is, and the - users managed by different IUserManagers are completely independent. This - is how you can separate the user contexts for different domains, on a - multiple domain system. + def create_user(email=None, real_name=None): + """Create and return an `IUser`. - There is one special roster, the null roster ('') which contains all - IUsers in all IRosters. - """ - - def create_user(address=None, real_name=None): - """Create and return an IUser. - - When address is given, an IAddress is also created and linked to the - new IUser object. If the address already exists, an - `ExistingAddressError` is raised. If the address exists but is - already linked to another user, an AddressAlreadyLinkedError is - raised. - - When real_name is given, the IUser's real_name is set to this string. - If an IAddress is also created and linked, its real_name is set to the - same string. + :param email: The text email address for the user being created. + :type email: str + :param real_name: The real name of the user. + :type real_name: str + :return: The newly created user, with the given email address and real + name, if given. + :rtype: `IUser` + :raises ExistingAddressError: when the email address is already + registered. """ def delete_user(user): - """Delete the given IUser.""" + """Delete the given user. - def get_user(address): + :param user: The user to delete. + :type user: `IUser`. + """ + + def get_user(email): """Get the user that controls the given email address, or None. - 'address' is a text email address. + :param email: The email address to look up. + :type email: str + :return: The user found or None. + :rtype: `IUser`. """ users = Attribute( - """An iterator over all the IUsers managed by this user manager.""") + """An iterator over all the `IUsers` managed by this user manager.""") - def create_address(address, real_name=None): - """Create and return an unlinked IAddress object. + def create_address(email, real_name=None): + """Create and return an address unlinked to any user. - address is the text email address. If real_name is not given, it - defaults to the empty string. If the IAddress already exists an - ExistingAddressError is raised. + :param email: The text email address for the address being created. + :type email: str + :param real_name: The real name associated with the address. + :type real_name: str + :return: The newly created address object, with the given email + address and real name, if given. + :rtype: `IAddress` + :raises ExistingAddressError: when the email address is already + registered. """ def delete_address(address): - """Delete the given IAddress object. + """Delete the given `IAddress` object. + + If the `IAddress` is linked to a user, it is first unlinked before it + is deleted. - If this IAddress linked to a user, it is first unlinked before it is - deleted. + :param address: The address to delete. + :type address: `IAddress`. """ - def get_address(address): - """Find and return the `IAddress` matching a text address. + def get_address(email): + """Find and return the `IAddress` matching an email address. - :param address: the text email address - :type address: string + :param email: The text email address. + :type email: str :return: The matching `IAddress` object, or None if no registered - `IAddress` matches the text address + `IAddress` matches the text address. :rtype: `IAddress` or None """ addresses = Attribute( - """An iterator over all the IAddresses managed by this manager.""") + """An iterator over all the `IAddresses` managed by this manager.""") diff --git a/src/mailman/languages/language.py b/src/mailman/languages/language.py index 177bf5f5a..f27780be3 100644 --- a/src/mailman/languages/language.py +++ b/src/mailman/languages/language.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/languages/manager.py b/src/mailman/languages/manager.py index 4689d8b4d..e5f63d89b 100644 --- a/src/mailman/languages/manager.py +++ b/src/mailman/languages/manager.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/model/address.py b/src/mailman/model/address.py index 6209858a8..6fa310c48 100644 --- a/src/mailman/model/address.py +++ b/src/mailman/model/address.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -41,7 +41,7 @@ class Address(Model): implements(IAddress) id = Int(primary=True) - address = Unicode() + email = Unicode() _original = Unicode() real_name = Unicode() verified_on = DateTime() @@ -52,15 +52,15 @@ class Address(Model): preferences_id = Int() preferences = Reference(preferences_id, 'Preferences.id') - def __init__(self, address, real_name): + def __init__(self, email, real_name): super(Address, self).__init__() - lower_case = address.lower() - self.address = lower_case + lower_case = email.lower() + self.email = lower_case self.real_name = real_name - self._original = (None if lower_case == address else address) + self._original = (None if lower_case == email else email) def __str__(self): - addr = (self.address if self._original is None else self._original) + addr = (self.email if self._original is None else self._original) return formataddr((self.real_name, addr)) def __repr__(self): @@ -71,7 +71,7 @@ class Address(Model): address_str, verified, id(self)) else: return '<Address: {0} [{1}] key: {2} at {3:#x}>'.format( - address_str, verified, self.address, id(self)) + address_str, verified, self.email, id(self)) def subscribe(self, mailing_list, role): # This member has no preferences by default. @@ -83,7 +83,7 @@ class Address(Model): Member.address == self).one() if member: raise AlreadySubscribedError( - mailing_list.fqdn_listname, self.address, role) + mailing_list.fqdn_listname, self.email, role) member = Member(role=role, mailing_list=mailing_list.fqdn_listname, address=self) @@ -92,5 +92,5 @@ class Address(Model): return member @property - def original_address(self): - return (self.address if self._original is None else self._original) + def original_email(self): + return (self.email if self._original is None else self._original) diff --git a/src/mailman/model/autorespond.py b/src/mailman/model/autorespond.py index 79eedd34a..8097fb013 100644 --- a/src/mailman/model/autorespond.py +++ b/src/mailman/model/autorespond.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -33,8 +33,7 @@ from mailman.config import config from mailman.database.model import Model from mailman.database.types import Enum from mailman.interfaces.autorespond import ( - IAutoResponseRecord, IAutoResponseSet, Response) -from mailman.interfaces.mailinglist import IMailingList + IAutoResponseRecord, IAutoResponseSet) from mailman.utilities.datetime import today diff --git a/src/mailman/model/digests.py b/src/mailman/model/digests.py index e3e6a89f9..ded0fe44f 100644 --- a/src/mailman/model/digests.py +++ b/src/mailman/model/digests.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/model/docs/addresses.txt b/src/mailman/model/docs/addresses.txt index 5388a3cc8..0ddacb321 100644 --- a/src/mailman/model/docs/addresses.txt +++ b/src/mailman/model/docs/addresses.txt @@ -18,42 +18,45 @@ Creating addresses Addresses are created directly through the user manager, which starts out with no addresses. - >>> sorted(address.address for address in user_manager.addresses) - [] + >>> dump_list(address.email for address in user_manager.addresses) + *Empty* Creating an unlinked email address is straightforward. >>> address_1 = user_manager.create_address('aperson@example.com') - >>> sorted(address.address for address in user_manager.addresses) - [u'aperson@example.com'] + >>> dump_list(address.email for address in user_manager.addresses) + aperson@example.com However, such addresses have no real name. - >>> address_1.real_name - u'' + >>> print address_1.real_name + <BLANKLINE> You can also create an email address object with a real name. >>> address_2 = user_manager.create_address( ... 'bperson@example.com', 'Ben Person') - >>> sorted(address.address for address in user_manager.addresses) - [u'aperson@example.com', u'bperson@example.com'] - >>> sorted(address.real_name for address in user_manager.addresses) - [u'', u'Ben Person'] + >>> dump_list(address.email for address in user_manager.addresses) + aperson@example.com + bperson@example.com + >>> dump_list(address.real_name for address in user_manager.addresses) + <BLANKLINE> + Ben Person -The str() of the address is the RFC 2822 preferred originator format, while -the repr() carries more information. +The ``str()`` of the address is the RFC 2822 preferred originator format, +while the ``repr()`` carries more information. - >>> str(address_2) - 'Ben Person <bperson@example.com>' - >>> repr(address_2) - '<Address: Ben Person <bperson@example.com> [not verified] at 0x...>' + >>> print str(address_2) + Ben Person <bperson@example.com> + >>> print repr(address_2) + <Address: Ben Person <bperson@example.com> [not verified] at 0x...> You can assign real names to existing addresses. >>> address_1.real_name = 'Anne Person' - >>> sorted(address.real_name for address in user_manager.addresses) - [u'Anne Person', u'Ben Person'] + >>> dump_list(address.real_name for address in user_manager.addresses) + Anne Person + Ben Person These addresses are not linked to users, and can be seen by searching the user manager for an associated user. @@ -68,12 +71,16 @@ interface. >>> user_1 = user_manager.create_user( ... 'cperson@example.com', u'Claire Person') - >>> sorted(address.address for address in user_1.addresses) - [u'cperson@example.com'] - >>> sorted(address.address for address in user_manager.addresses) - [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] - >>> sorted(address.real_name for address in user_manager.addresses) - [u'Anne Person', u'Ben Person', u'Claire Person'] + >>> dump_list(address.email for address in user_1.addresses) + cperson@example.com + >>> dump_list(address.email for address in user_manager.addresses) + aperson@example.com + bperson@example.com + cperson@example.com + >>> dump_list(address.real_name for address in user_manager.addresses) + Anne Person + Ben Person + Claire Person And now you can find the associated user. @@ -91,26 +98,28 @@ Deleting addresses You can remove an unlinked address from the user manager. >>> user_manager.delete_address(address_1) - >>> sorted(address.address for address in user_manager.addresses) - [u'bperson@example.com', u'cperson@example.com'] - >>> sorted(address.real_name for address in user_manager.addresses) - [u'Ben Person', u'Claire Person'] + >>> dump_list(address.email for address in user_manager.addresses) + bperson@example.com + cperson@example.com + >>> dump_list(address.real_name for address in user_manager.addresses) + Ben Person + Claire Person Deleting a linked address does not delete the user, but it does unlink the address from the user. - >>> sorted(address.address for address in user_1.addresses) - [u'cperson@example.com'] + >>> dump_list(address.email for address in user_1.addresses) + cperson@example.com >>> user_1.controls('cperson@example.com') True >>> address_3 = list(user_1.addresses)[0] >>> user_manager.delete_address(address_3) - >>> sorted(address.address for address in user_1.addresses) - [] + >>> dump_list(address.email for address in user_1.addresses) + *Empty* >>> user_1.controls('cperson@example.com') False - >>> sorted(address.address for address in user_manager.addresses) - [u'bperson@example.com'] + >>> dump_list(address.email for address in user_manager.addresses) + bperson@example.com Registration and validation @@ -149,37 +158,40 @@ Subscriptions Addresses get subscribed to mailing lists, not users. When the address is subscribed, a role is specified. +:: >>> address_5 = user_manager.create_address( ... 'eperson@example.com', 'Elly Person') - >>> mlist = create_list('_xtext@example.com') + >>> mlist = create_list('test@example.com') >>> from mailman.interfaces.member import MemberRole >>> address_5.subscribe(mlist, MemberRole.owner) <Member: Elly Person <eperson@example.com> on - _xtext@example.com as MemberRole.owner> + test@example.com as MemberRole.owner> >>> address_5.subscribe(mlist, MemberRole.member) <Member: Elly Person <eperson@example.com> on - _xtext@example.com as MemberRole.member> + test@example.com as MemberRole.member> Now Elly is both an owner and a member of the mailing list. - >>> sorted(mlist.owners.members) - [<Member: Elly Person <eperson@example.com> on - _xtext@example.com as MemberRole.owner>] - >>> sorted(mlist.moderators.members) - [] - >>> sorted(mlist.administrators.members) - [<Member: Elly Person <eperson@example.com> on - _xtext@example.com as MemberRole.owner>] - >>> sorted(mlist.members.members) - [<Member: Elly Person <eperson@example.com> on - _xtext@example.com as MemberRole.member>] - >>> sorted(mlist.regular_members.members) - [<Member: Elly Person <eperson@example.com> on - _xtext@example.com as MemberRole.member>] - >>> sorted(mlist.digest_members.members) - [] + >>> def memberkey(member): + ... return member.mailing_list, member.address.email, int(member.role) + >>> dump_list(mlist.owners.members, key=memberkey) + <Member: Elly Person <eperson@example.com> on + test@example.com as MemberRole.owner> + >>> dump_list(mlist.moderators.members, key=memberkey) + *Empty* + >>> dump_list(mlist.administrators.members, key=memberkey) + <Member: Elly Person <eperson@example.com> on + test@example.com as MemberRole.owner> + >>> dump_list(mlist.members.members, key=memberkey) + <Member: Elly Person <eperson@example.com> on + test@example.com as MemberRole.member> + >>> dump_list(mlist.regular_members.members, key=memberkey) + <Member: Elly Person <eperson@example.com> on + test@example.com as MemberRole.member> + >>> dump_list(mlist.digest_members.members, key=memberkey) + *Empty* Case-preserved addresses @@ -197,18 +209,18 @@ The str() of such an address prints the RFC 2822 preferred originator format with the original case-preserved address. The repr() contains all the gory details. - >>> str(address_6) - 'Frank Person <FPERSON@example.com>' - >>> repr(address_6) - '<Address: Frank Person <FPERSON@example.com> [not verified] - key: fperson@example.com at 0x...>' + >>> print str(address_6) + Frank Person <FPERSON@example.com> + >>> print repr(address_6) + <Address: Frank Person <FPERSON@example.com> [not verified] + key: fperson@example.com at 0x...> Both the case-insensitive version of the address and the original -case-preserved version are available on attributes of the IAddress object. +case-preserved version are available on attributes of the `IAddress` object. - >>> print address_6.address + >>> print address_6.email fperson@example.com - >>> print address_6.original_address + >>> print address_6.original_email FPERSON@example.com Because addresses are case-insensitive for all other purposes, you cannot @@ -230,7 +242,7 @@ create an address that differs only in case. You can get the address using either the lower cased version or case-preserved version. In fact, searching for an address is case insensitive. - >>> print user_manager.get_address('fperson@example.com').address + >>> print user_manager.get_address('fperson@example.com').email fperson@example.com - >>> print user_manager.get_address('FPERSON@example.com').address + >>> print user_manager.get_address('FPERSON@example.com').email fperson@example.com diff --git a/src/mailman/model/docs/autorespond.txt b/src/mailman/model/docs/autorespond.txt index ba0521a89..3a9ad01b2 100644 --- a/src/mailman/model/docs/autorespond.txt +++ b/src/mailman/model/docs/autorespond.txt @@ -3,11 +3,12 @@ Automatic responder =================== In various situations, Mailman will send an automatic response to the author -of an email message. For example, if someone sends a command to the -request -address, Mailman will send a response, but to cut down on third party spam, -the sender will only get a certain number of responses per day. +of an email message. For example, if someone sends a command to the +``-request`` address, Mailman will send a response, but to cut down on third +party spam, the sender will only get a certain number of responses per day. -First, given a mailing list you need to adapt it to an IAutoResponseSet. +First, given a mailing list you need to adapt it to an ``IAutoResponseSet``. +:: >>> mlist = create_list('test@example.com') >>> from mailman.interfaces.autorespond import IAutoResponseSet @@ -17,7 +18,7 @@ First, given a mailing list you need to adapt it to an IAutoResponseSet. >>> verifyObject(IAutoResponseSet, response_set) True -You can't adapt other objects to an IAutoResponseSet. +You can't adapt other objects to an ``IAutoResponseSet``. >>> IAutoResponseSet(object()) Traceback (most recent call last): @@ -28,6 +29,7 @@ There are various kinds of response types. For example, Mailman will send an automatic response when messages are held for approval, or when it receives an email command. You can find out how many responses for a particular address have already been sent today. +:: >>> from mailman.interfaces.usermanager import IUserManager >>> from zope.component import getUtility @@ -66,6 +68,7 @@ Let's send one more. 2 Now the day flips over and all the counts reset. +:: >>> from mailman.utilities.datetime import factory >>> factory.fast_forward() @@ -92,6 +95,7 @@ You can also use the response set to get the date of the last response sent. datetime.date(2005, 8, 1) When another response is sent today, that becomes the last one sent. +:: >>> response_set.response_sent(address, Response.command) >>> response_set.last_response(address, Response.command).date_sent diff --git a/src/mailman/model/docs/domains.txt b/src/mailman/model/docs/domains.txt index 5673e6ee9..9fe43a5f1 100644 --- a/src/mailman/model/docs/domains.txt +++ b/src/mailman/model/docs/domains.txt @@ -2,7 +2,7 @@ Domains ======= - # The test framework starts out with an example domain, so let's delete +.. # The test framework starts out with an example domain, so let's delete # that first. >>> from mailman.interfaces.domain import IDomainManager >>> from zope.component import getUtility @@ -11,6 +11,7 @@ Domains <Domain example.com...> Domains are how Mailman interacts with email host names and web host names. +:: >>> from operator import attrgetter >>> def show_domains(): @@ -52,6 +53,7 @@ web interface for the domain. contact_address: postmaster@example.com> Domains can have explicit descriptions and contact addresses. +:: >>> manager.add( ... 'example.net', @@ -70,6 +72,7 @@ Domains can have explicit descriptions and contact addresses. contact_address: postmaster@example.com> In the global domain manager, domains are indexed by their email host name. +:: >>> for domain in sorted(manager, key=attrgetter('email_host')): ... print domain.email_host @@ -87,7 +90,8 @@ In the global domain manager, domains are indexed by their email host name. KeyError: u'doesnotexist.com' As with a dictionary, you can also get the domain. If the domain does not -exist, None or a default is returned. +exist, ``None`` or a default is returned. +:: >>> print manager.get('example.net') <Domain example.net, The example domain, diff --git a/src/mailman/model/docs/languages.txt b/src/mailman/model/docs/languages.txt index a724a0510..21143f28b 100644 --- a/src/mailman/model/docs/languages.txt +++ b/src/mailman/model/docs/languages.txt @@ -5,6 +5,7 @@ Languages Mailman is multilingual. A language manager handles the known set of languages at run time, as well as enabling those languages for use in a running Mailman instance. +:: >>> from mailman.interfaces.languages import ILanguageManager >>> from zope.component import getUtility @@ -94,6 +95,7 @@ Clearing the known languages ============================ The language manager can forget about all the language codes it knows about. +:: >>> 'en' in mgr True diff --git a/src/mailman/model/docs/listmanager.txt b/src/mailman/model/docs/listmanager.txt index e07659066..7235049c7 100644 --- a/src/mailman/model/docs/listmanager.txt +++ b/src/mailman/model/docs/listmanager.txt @@ -2,10 +2,8 @@ The mailing list manager ======================== -The IListManager is how you create, delete, and retrieve mailing list -objects. The Mailman system instantiates an IListManager for you based on the -configuration variable MANAGERS_INIT_FUNCTION. The instance is accessible -on the global config object. +The ``IListManager`` is how you create, delete, and retrieve mailing list +objects. >>> from mailman.interfaces.listmanager import IListManager >>> from zope.component import getUtility @@ -76,12 +74,12 @@ always get the same object back. >>> mlist_2 is mlist True -If you try to get a list that doesn't existing yet, you get None. +If you try to get a list that doesn't existing yet, you get ``None``. >>> print list_manager.get('_xtest_2@example.com') None -You also get None if the list name is invalid. +You also get ``None`` if the list name is invalid. >>> print list_manager.get('foo') None diff --git a/src/mailman/model/docs/mailinglist.txt b/src/mailman/model/docs/mailinglist.txt index 687f7b39c..33d681762 100644 --- a/src/mailman/model/docs/mailinglist.txt +++ b/src/mailman/model/docs/mailinglist.txt @@ -2,7 +2,7 @@ Mailing lists ============= -XXX 2010-06-18 BAW: This documentation needs a lot more detail. +.. XXX 2010-06-18 BAW: This documentation needs a lot more detail. The mailing list is a core object in Mailman. It is uniquely identified in the system by its posting address, i.e. the email address you would send a @@ -25,10 +25,11 @@ name (i.e. local part) and host name. Rosters ======= -Mailing list membership is represented by 'rosters'. Each mailing list has +Mailing list membership is represented by `rosters`. Each mailing list has several rosters of members, representing the subscribers to the mailing list, the owners, the moderators, and so on. The rosters are defined by a membership role. +:: >>> from mailman.interfaces.member import MemberRole >>> from mailman.testing.helpers import subscribe diff --git a/src/mailman/model/docs/membership.txt b/src/mailman/model/docs/membership.txt index 41660f0d2..00e79d733 100644 --- a/src/mailman/model/docs/membership.txt +++ b/src/mailman/model/docs/membership.txt @@ -4,159 +4,116 @@ List memberships Users represent people in Mailman. Users control email addresses, and rosters are collections of members. A member gives an email address a role, such as -'member', 'administrator', or 'moderator'. Roster sets are collections of -rosters and a mailing list has a single roster set that contains all its -members, regardless of that member's role. +`member`, `administrator`, or `moderator`. Even nonmembers are represented by +a roster. + +Roster sets are collections of rosters and a mailing list has a single roster +set that contains all its members, regardless of that member's role. Mailing lists and roster sets have an indirect relationship, through the roster set's name. Roster also have names, but are related to roster sets by a more direct containment relationship. This is because it is possible to store mailing list data in a different database than user data. -When we create a mailing list, it starts out with no members... - - >>> mlist = create_list('_xtest@example.com') - >>> mlist - <mailing list "_xtest@example.com" at ...> - >>> sorted(member.address.address for member in mlist.members.members) - [] - >>> sorted(user.real_name for user in mlist.members.users) - [] - >>> sorted(address.address for member in mlist.members.addresses) - [] - -...no owners... - - >>> sorted(member.address.address for member in mlist.owners.members) - [] - >>> sorted(user.real_name for user in mlist.owners.users) - [] - >>> sorted(address.address for member in mlist.owners.addresses) - [] - -...no moderators... - - >>> sorted(member.address.address for member in mlist.moderators.members) - [] - >>> sorted(user.real_name for user in mlist.moderators.users) - [] - >>> sorted(address.address for member in mlist.moderators.addresses) - [] +When we create a mailing list, it starts out with no members, owners, +moderators, administrators, or nonmembers. -...and no administrators. - - >>> sorted(member.address.address - ... for member in mlist.administrators.members) - [] - >>> sorted(user.real_name for user in mlist.administrators.users) - [] - >>> sorted(address.address for member in mlist.administrators.addresses) - [] + >>> mlist = create_list('test@example.com') + >>> dump_list(mlist.members.members) + *Empty* + >>> dump_list(mlist.owners.members) + *Empty* + >>> dump_list(mlist.moderators.members) + *Empty* + >>> dump_list(mlist.administrators.members) + *Empty* + >>> dump_list(mlist.nonmembers.members) + *Empty* Administrators ============== A mailing list's administrators are defined as union of the list's owners and -the list's moderators. We can add new owners or moderators to this list by -assigning roles to users. First we have to create the user, because there are -no users in the user database yet. +moderators. We can add new owners or moderators to this list by assigning +roles to users. First we have to create the user, because there are no users +in the user database yet. >>> from mailman.interfaces.usermanager import IUserManager >>> from zope.component import getUtility >>> user_manager = getUtility(IUserManager) >>> user_1 = user_manager.create_user('aperson@example.com', 'Anne Person') - >>> print user_1.real_name - Anne Person - >>> sorted(address.address for address in user_1.addresses) - [u'aperson@example.com'] + >>> print user_1 + <User "Anne Person" at ...> We can add Anne as an owner of the mailing list, by creating a member role for her. >>> from mailman.interfaces.member import MemberRole >>> address_1 = list(user_1.addresses)[0] - >>> print address_1.address - aperson@example.com >>> address_1.subscribe(mlist, MemberRole.owner) <Member: Anne Person <aperson@example.com> on - _xtest@example.com as MemberRole.owner> - >>> sorted(member.address.address for member in mlist.owners.members) - [u'aperson@example.com'] - >>> sorted(user.real_name for user in mlist.owners.users) - [u'Anne Person'] - >>> sorted(address.address for address in mlist.owners.addresses) - [u'aperson@example.com'] + test@example.com as MemberRole.owner> + >>> dump_list(member.address for member in mlist.owners.members) + Anne Person <aperson@example.com> Adding Anne as a list owner also makes her an administrator, but does not make her a moderator. Nor does it make her a member of the list. - >>> sorted(user.real_name for user in mlist.administrators.users) - [u'Anne Person'] - >>> sorted(user.real_name for user in mlist.moderators.users) - [] - >>> sorted(user.real_name for user in mlist.members.users) - [] + >>> dump_list(member.address for member in mlist.administrators.members) + Anne Person <aperson@example.com> + >>> dump_list(member.address for member in mlist.moderators.members) + *Empty* + >>> dump_list(member.address for member in mlist.members.members) + *Empty* -We can add Ben as a moderator of the list, by creating a different member role -for him. +Bart becomes a moderator of the list. - >>> user_2 = user_manager.create_user('bperson@example.com', 'Ben Person') - >>> print user_2.real_name - Ben Person + >>> user_2 = user_manager.create_user('bperson@example.com', 'Bart Person') + >>> print user_2 + <User "Bart Person" at ...> >>> address_2 = list(user_2.addresses)[0] - >>> print address_2.address - bperson@example.com >>> address_2.subscribe(mlist, MemberRole.moderator) - <Member: Ben Person <bperson@example.com> - on _xtest@example.com as MemberRole.moderator> - >>> sorted(member.address.address for member in mlist.moderators.members) - [u'bperson@example.com'] - >>> sorted(user.real_name for user in mlist.moderators.users) - [u'Ben Person'] - >>> sorted(address.address for address in mlist.moderators.addresses) - [u'bperson@example.com'] + <Member: Bart Person <bperson@example.com> + on test@example.com as MemberRole.moderator> + >>> dump_list(member.address for member in mlist.moderators.members) + Bart Person <bperson@example.com> + +Now, both Anne and Bart are list administrators. +:: -Now, both Anne and Ben are list administrators. + >>> from operator import attrgetter + >>> def dump_members(roster): + ... all_addresses = list(member.address for member in roster) + ... sorted_addresses = sorted(all_addresses, key=attrgetter('email')) + ... dump_list(sorted_addresses) - >>> sorted(member.address.address - ... for member in mlist.administrators.members) - [u'aperson@example.com', u'bperson@example.com'] - >>> sorted(user.real_name for user in mlist.administrators.users) - [u'Anne Person', u'Ben Person'] - >>> sorted(address.address for address in mlist.administrators.addresses) - [u'aperson@example.com', u'bperson@example.com'] + >>> dump_members(mlist.administrators.members) + Anne Person <aperson@example.com> + Bart Person <bperson@example.com> Members ======= -Similarly, list members are born of users being given the proper role. It's -more interesting here because these roles should have a preference which can -be used to decide whether the member is to get regular delivery or digest -delivery. Without a preference, Mailman will fall back first to the address's -preference, then the user's preference, then the list's preference. Start -without any member preference to see the system defaults. +Similarly, list members are born of users being subscribed with the proper +role. >>> user_3 = user_manager.create_user( - ... 'cperson@example.com', 'Claire Person') - >>> print user_3.real_name - Claire Person + ... 'cperson@example.com', 'Cris Person') >>> address_3 = list(user_3.addresses)[0] - >>> print address_3.address - cperson@example.com >>> address_3.subscribe(mlist, MemberRole.member) - <Member: Claire Person <cperson@example.com> - on _xtest@example.com as MemberRole.member> + <Member: Cris Person <cperson@example.com> + on test@example.com as MemberRole.member> -Claire will be a regular delivery member but not a digest member. +Cris will be a regular delivery member but not a digest member. - >>> sorted(address.address for address in mlist.members.addresses) - [u'cperson@example.com'] - >>> sorted(address.address for address in mlist.regular_members.addresses) - [u'cperson@example.com'] - >>> sorted(address.address for address in mlist.digest_members.addresses) - [] + >>> dump_members(mlist.members.members) + Cris Person <cperson@example.com> + >>> dump_members(mlist.regular_members.members) + Cris Person <cperson@example.com> + >>> dump_members(mlist.digest_members.addresses) + *Empty* It's easy to make the list administrators members of the mailing list too. @@ -164,34 +121,75 @@ It's easy to make the list administrators members of the mailing list too. >>> for address in mlist.administrators.addresses: ... member = address.subscribe(mlist, MemberRole.member) ... members.append(member) - >>> sorted(members, key=lambda m: m.address.address) - [<Member: Anne Person <aperson@example.com> on - _xtest@example.com as MemberRole.member>, - <Member: Ben Person <bperson@example.com> on - _xtest@example.com as MemberRole.member>] - >>> sorted(address.address for address in mlist.members.addresses) - [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] - >>> sorted(address.address for address in mlist.regular_members.addresses) - [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] - >>> sorted(address.address for address in mlist.digest_members.addresses) - [] + >>> dump_list(members, key=attrgetter('address.email')) + <Member: Anne Person <aperson@example.com> on + test@example.com as MemberRole.member> + <Member: Bart Person <bperson@example.com> on + test@example.com as MemberRole.member> + >>> dump_members(mlist.members.members) + Anne Person <aperson@example.com> + Bart Person <bperson@example.com> + Cris Person <cperson@example.com> + >>> dump_members(mlist.regular_members.members) + Anne Person <aperson@example.com> + Bart Person <bperson@example.com> + Cris Person <cperson@example.com> + >>> dump_members(mlist.digest_members.members) + *Empty* + + +Nonmembers +========== + +Nonmembers are used to represent people who have posted to the mailing list +but are not subscribed to the mailing list. These may be legitimate users who +have found the mailing list and wish to interact without a direct +subscription, or they may be spammers who should never be allowed to contact +the mailing list. Because all the same moderation rules can be applied to +nonmembers, we represent them as the same type of object but with a different +role. + + >>> user_6 = user_manager.create_user('fperson@example.com', 'Fred Person') + >>> address_6 = list(user_6.addresses)[0] + >>> member_6 = address_6.subscribe(mlist, MemberRole.nonmember) + >>> member_6 + <Member: Fred Person <fperson@example.com> on test@example.com + as MemberRole.nonmember> + >>> dump_members(mlist.nonmembers.members) + Fred Person <fperson@example.com> + +Nonmembers do not get delivery of any messages. + + >>> dump_members(mlist.members.members) + Anne Person <aperson@example.com> + Bart Person <bperson@example.com> + Cris Person <cperson@example.com> + >>> dump_members(mlist.regular_members.members) + Anne Person <aperson@example.com> + Bart Person <bperson@example.com> + Cris Person <cperson@example.com> + >>> dump_members(mlist.digest_members.members) + *Empty* Finding members =============== -You can find the IMember object that is a member of a roster for a given text -email address by using an IRoster's .get_member() method. +You can find the ``IMember`` object that is a member of a roster for a given +text email address by using the ``IRoster.get_member()`` method. >>> mlist.owners.get_member('aperson@example.com') <Member: Anne Person <aperson@example.com> on - _xtest@example.com as MemberRole.owner> + test@example.com as MemberRole.owner> >>> mlist.administrators.get_member('aperson@example.com') <Member: Anne Person <aperson@example.com> on - _xtest@example.com as MemberRole.owner> + test@example.com as MemberRole.owner> >>> mlist.members.get_member('aperson@example.com') <Member: Anne Person <aperson@example.com> on - _xtest@example.com as MemberRole.member> + test@example.com as MemberRole.member> + >>> mlist.nonmembers.get_member('fperson@example.com') + <Member: Fred Person <fperson@example.com> on + test@example.com as MemberRole.nonmember> However, if the address is not subscribed with the appropriate role, then None is returned. @@ -202,6 +200,8 @@ is returned. None >>> print mlist.members.get_member('zperson@example.com') None + >>> print mlist.nonmembers.get_member('aperson@example.com') + None All subscribers @@ -211,14 +211,15 @@ There is also a roster containing all the subscribers of a mailing list, regardless of their role. >>> def sortkey(member): - ... return (member.address.address, int(member.role)) - >>> [(member.address.address, str(member.role)) - ... for member in sorted(mlist.subscribers.members, key=sortkey)] - [(u'aperson@example.com', 'MemberRole.member'), - (u'aperson@example.com', 'MemberRole.owner'), - (u'bperson@example.com', 'MemberRole.member'), - (u'bperson@example.com', 'MemberRole.moderator'), - (u'cperson@example.com', 'MemberRole.member')] + ... return (member.address.email, int(member.role)) + >>> for member in sorted(mlist.subscribers.members, key=sortkey): + ... print member.address.email, member.role + aperson@example.com MemberRole.member + aperson@example.com MemberRole.owner + bperson@example.com MemberRole.member + bperson@example.com MemberRole.moderator + cperson@example.com MemberRole.member + fperson@example.com MemberRole.nonmember Double subscriptions @@ -230,4 +231,34 @@ It is an error to subscribe someone to a list with the same role twice. Traceback (most recent call last): ... AlreadySubscribedError: aperson@example.com is already a MemberRole.owner - of mailing list _xtest@example.com + of mailing list test@example.com + + +Moderation actions +================== + +All members of any role have a *moderation action* which specifies how +postings from that member are handled. By default, owners and moderators are +automatically accepted for posting to the mailing list. + + >>> for member in sorted(mlist.administrators.members, + ... key=attrgetter('address.email')): + ... print member.address.email, member.role, member.moderation_action + aperson@example.com MemberRole.owner Action.accept + bperson@example.com MemberRole.moderator Action.accept + +By default, members have a *deferred* action which specifies that the posting +should go through the normal moderation checks. + + >>> for member in sorted(mlist.members.members, + ... key=attrgetter('address.email')): + ... print member.address.email, member.role, member.moderation_action + aperson@example.com MemberRole.member Action.defer + bperson@example.com MemberRole.member Action.defer + cperson@example.com MemberRole.member Action.defer + +Postings by nonmembers are held for moderator approval by default. + + >>> for member in mlist.nonmembers.members: + ... print member.address.email, member.role, member.moderation_action + fperson@example.com MemberRole.nonmember Action.hold diff --git a/src/mailman/model/docs/messagestore.txt b/src/mailman/model/docs/messagestore.txt index aabfd55fb..3ee59129b 100644 --- a/src/mailman/model/docs/messagestore.txt +++ b/src/mailman/model/docs/messagestore.txt @@ -2,17 +2,17 @@ The message store ================= -The message store is a collection of messages keyed off of Message-ID and -X-Message-ID-Hash headers. Either of these values can be combined with the -message's List-Archive header to create a globally unique URI to the message -object in the internet facing interface of the message store. The -X-Message-ID-Hash is the Base32 SHA1 hash of the Message-ID. +The message store is a collection of messages keyed off of ``Message-ID`` and +``X-Message-ID-Hash`` headers. Either of these values can be combined with +the message's ``List-Archive`` header to create a globally unique URI to the +message object in the internet facing interface of the message store. The +``X-Message-ID-Hash`` is the Base32 SHA1 hash of the ``Message-ID``. >>> from mailman.interfaces.messages import IMessageStore >>> from zope.component import getUtility >>> message_store = getUtility(IMessageStore) -If you try to add a message to the store which is missing the Message-ID +If you try to add a message to the store which is missing the ``Message-ID`` header, you will get an exception. >>> msg = message_from_string("""\ @@ -25,7 +25,7 @@ header, you will get an exception. ... ValueError: Exactly one Message-ID header required -However, if the message has a Message-ID header, it can be stored. +However, if the message has a ``Message-ID`` header, it can be stored. >>> msg['Message-ID'] = '<87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp>' >>> message_store.add(msg) @@ -42,16 +42,16 @@ However, if the message has a Message-ID header, it can be stored. Finding messages ================ -There are several ways to find a message given either the Message-ID or -X-Message-ID-Hash headers. In either case, if no matching message is found, -None is returned. +There are several ways to find a message given either the ``Message-ID`` or +``X-Message-ID-Hash`` headers. In either case, if no matching message is +found, ``None`` is returned. >>> print message_store.get_message_by_id('nothing') None >>> print message_store.get_message_by_hash('nothing') None -Given an existing Message-ID, the message can be found. +Given an existing ``Message-ID``, the message can be found. >>> message = message_store.get_message_by_id(msg['message-id']) >>> print message.as_string() @@ -62,7 +62,7 @@ Given an existing Message-ID, the message can be found. This message is very important. <BLANKLINE> -Similarly, we can find messages by the X-Message-ID-Hash: +Similarly, we can find messages by the ``X-Message-ID-Hash``: >>> message = message_store.get_message_by_hash(msg['x-message-id-hash']) >>> print message.as_string() @@ -95,9 +95,9 @@ contains. Deleting messages from the store ================================ -You delete a message from the storage service by providing the Message-ID for -the message you want to delete. If you try to delete a Message-ID that isn't -in the store, you get an exception. +You delete a message from the storage service by providing the ``Message-ID`` +for the message you want to delete. If you try to delete a ``Message-ID`` +that isn't in the store, you get an exception. >>> message_store.delete_message('nothing') Traceback (most recent call last): diff --git a/src/mailman/model/docs/mlist-addresses.txt b/src/mailman/model/docs/mlist-addresses.txt index 3f44008fb..2a021f67f 100644 --- a/src/mailman/model/docs/mlist-addresses.txt +++ b/src/mailman/model/docs/mlist-addresses.txt @@ -3,7 +3,7 @@ Mailing list addresses ====================== Every mailing list has a number of addresses which are publicly available. -These are defined in the IMailingListAddresses interface. +These are defined in the ``IMailingListAddresses`` interface. >>> mlist = create_list('_xtest@example.com') @@ -15,7 +15,7 @@ list. This is exactly the same as the fully qualified list name. >>> print mlist.posting_address _xtest@example.com -Messages to the mailing list's 'no reply' address always get discarded without +Messages to the mailing list's `no reply` address always get discarded without prejudice. >>> print mlist.no_reply_address @@ -61,7 +61,8 @@ Email confirmations Email confirmation messages are sent when actions such as subscriptions need to be confirmed. It requires that a cookie be provided, which will be included in the local part of the email address. The exact format of this is -dependent on the VERP_CONFIRM_FORMAT configuration variable. +dependent on the ``verp_confirm_format`` configuration variable. +:: >>> print mlist.confirm_address('cookie') _xtest-confirm+cookie@example.com diff --git a/src/mailman/model/docs/pending.txt b/src/mailman/model/docs/pending.txt index dc27b6bee..e85d8e484 100644 --- a/src/mailman/model/docs/pending.txt +++ b/src/mailman/model/docs/pending.txt @@ -17,8 +17,8 @@ available by adapting the list manager. >>> from zope.component import getUtility >>> pendingdb = getUtility(IPendings) -The pending database can add any IPendable to the database, returning a token -that can be used in urls and such. +The pending database can add any ``IPendable`` to the database, returning a +token that can be used in urls and such. >>> from mailman.interfaces.pending import IPendable >>> class SimplePendable(dict): @@ -33,10 +33,10 @@ that can be used in urls and such. >>> len(token) 40 -There's not much you can do with tokens except to 'confirm' them, which -basically means returning the IPendable structure (as a dict) from the -database that matches the token. If the token isn't in the database, None is -returned. +There's not much you can do with tokens except to `confirm` them, which +basically means returning the ``IPendable`` structure (as a dictionary) from +the database that matches the token. If the token isn't in the database, +``None`` is returned. >>> pendable = pendingdb.confirm(bytes('missing')) >>> print pendable @@ -56,7 +56,7 @@ After confirmation, the token is no longer in the database. None There are a few other things you can do with the pending database. When you -confirm a token, you can leave it in the database, or in otherwords, not +confirm a token, you can leave it in the database, or in other words, not expunge it. >>> event_1 = SimplePendable(type='one') diff --git a/src/mailman/model/docs/registration.txt b/src/mailman/model/docs/registration.txt index abc7f2c93..e92c63f52 100644 --- a/src/mailman/model/docs/registration.txt +++ b/src/mailman/model/docs/registration.txt @@ -7,10 +7,10 @@ The only thing they must supply is an email address, although there is additional information they may supply. All registered email addresses must be verified before Mailman will send them any list traffic. -The IUserManager manages users, but it does so at a fairly low level. +The ``IUserManager`` manages users, but it does so at a fairly low level. Specifically, it does not handle verifications, email address syntax validity -checks, etc. The IRegistrar is the interface to the object handling all this -stuff. +checks, etc. The ``IRegistrar`` is the interface to the object handling all +this stuff. >>> from mailman.interfaces.registrar import IRegistrar >>> from zope.component import getUtility @@ -76,9 +76,9 @@ Register an email address ========================= Registration of an unknown address creates nothing until the confirmation step -is complete. No IUser or IAddress is created at registration time, but a -record is added to the pending database, and the token for that record is -returned. +is complete. No ``IUser`` or ``IAddress`` is created at registration time, +but a record is added to the pending database, and the token for that record +is returned. >>> token = registrar.register(mlist, 'aperson@example.com', 'Anne Person') >>> check_token(token) @@ -100,7 +100,7 @@ But this address is waiting for confirmation. >>> pendingdb = getUtility(IPendings) >>> dump_msgdata(pendingdb.confirm(token, expunge=False)) - address : aperson@example.com + email : aperson@example.com list_name: alpha@example.com real_name: Anne Person type : registration @@ -161,12 +161,12 @@ appear in a URL in the body of the message. >>> sent_token == token True -The same token will appear in the From header. +The same token will appear in the ``From`` header. >>> items[0].msg['from'] == 'alpha-confirm+' + token + '@example.com' True -It will also appear in the Subject header. +It will also appear in the ``Subject`` header. >>> items[0].msg['subject'] == 'confirm ' + token True @@ -178,8 +178,8 @@ token and uses that to confirm the pending registration. >>> registrar.confirm(token) True -Now, there is an IAddress in the database matching the address, as well as an -IUser linked to this address. The IAddress is verified. +Now, there is an `IAddress` in the database matching the address, as well as +an `IUser` linked to this address. The `IAddress` is verified. >>> found_address = user_manager.get_address('aperson@example.com') >>> found_address @@ -187,7 +187,7 @@ IUser linked to this address. The IAddress is verified. >>> found_user = user_manager.get_user('aperson@example.com') >>> found_user <User "Anne Person" at ...> - >>> found_user.controls(found_address.address) + >>> found_user.controls(found_address.email) True >>> from datetime import datetime >>> isinstance(found_address.verified_on, datetime) @@ -247,7 +247,8 @@ Discarding ========== A confirmation token can also be discarded, say if the user changes his or her -mind about registering. When discarded, no IAddress or IUser is created. +mind about registering. When discarded, no `IAddress` or `IUser` is created. +:: >>> token = registrar.register(mlist, 'eperson@example.com', 'Elly Person') >>> check_token(token) @@ -270,6 +271,7 @@ Registering a new address for an existing user When a new address for an existing user is registered, there isn't too much different except that the new address will still need to be verified before it can be used. +:: >>> dperson = user_manager.create_user( ... 'dperson@example.com', 'Dave Person') @@ -279,8 +281,8 @@ can be used. >>> address.verified_on = datetime.now() >>> from operator import attrgetter - >>> sorted((addr for addr in dperson.addresses), key=attrgetter('address')) - [<Address: Dave Person <dperson@example.com> [verified] at ...>] + >>> dump_list(repr(address) for address in dperson.addresses) + <Address: Dave Person <dperson@example.com> [verified] at ...> >>> dperson.register('david.person@example.com', 'David Person') <Address: David Person <david.person@example.com> [not verified] at ...> >>> token = registrar.register(mlist, 'david.person@example.com') @@ -296,9 +298,9 @@ can be used. True >>> user <User "Dave Person" at ...> - >>> sorted((addr for addr in user.addresses), key=attrgetter('address')) - [<Address: David Person <david.person@example.com> [verified] at ...>, - <Address: Dave Person <dperson@example.com> [verified] at ...>] + >>> dump_list(repr(address) for address in user.addresses) + <Address: Dave Person <dperson@example.com> [verified] at ...> + <Address: David Person <david.person@example.com> [verified] at ...> Corner cases @@ -310,9 +312,9 @@ confirm method will just return False. >>> registrar.confirm(bytes('no token')) False -Likewise, if you try to confirm, through the IUserRegistrar interface, a token -that doesn't match a registration event, you will get None. However, the -pending event matched with that token will still be removed. +Likewise, if you try to confirm, through the `IUserRegistrar` interface, a +token that doesn't match a registration event, you will get ``None``. +However, the pending event matched with that token will still be removed. >>> from mailman.interfaces.pending import IPendable >>> from zope.interface import implements diff --git a/src/mailman/model/docs/requests.txt b/src/mailman/model/docs/requests.txt index 8cd027297..94c81e1dc 100644 --- a/src/mailman/model/docs/requests.txt +++ b/src/mailman/model/docs/requests.txt @@ -35,6 +35,7 @@ Mailing list centric A set of requests are always related to a particular mailing list, so given a mailing list you need to get its requests object. +:: >>> from mailman.interfaces.requests import IListRequests, IRequests >>> from zope.component import getUtility @@ -55,8 +56,8 @@ The list's requests database starts out empty. >>> requests.count 0 - >>> list(requests.held_requests) - [] + >>> dump_list(requests.held_requests) + *Empty* At the lowest level, the requests database is very simple. Holding a request requires a request type (as an enum value), a key, and an optional dictionary @@ -203,10 +204,10 @@ For the next section, we first clean up all the current requests. Application support =================== -There are several higher level interfaces available in the mailman.app package -which can be used to hold messages, subscription, and unsubscriptions. There -are also interfaces for disposing of these requests in an application specific -and consistent way. +There are several higher level interfaces available in the ``mailman.app`` +package which can be used to hold messages, subscription, and unsubscriptions. +There are also interfaces for disposing of these requests in an application +specific and consistent way. >>> from mailman.app import moderator @@ -237,6 +238,7 @@ this case, we won't include any additional metadata. True We can also hold a message with some additional metadata. +:: # Delete the Message-ID from the previous hold so we don't try to store # collisions in the message storage. @@ -336,6 +338,7 @@ re-added to the message store). When handling a message, we can tell the moderator interface to also preserve a copy, essentially telling it not to delete the message from the storage. First, without the switch, the message is deleted. +:: >>> msg = message_from_string("""\ ... From: aperson@example.org @@ -374,6 +377,7 @@ the message store after disposition. Orthogonal to preservation, the message can also be forwarded to another address. This is helpful for getting the message into the inbox of one of the moderators. +:: # Set a new Message-ID from the previous hold so we don't try to store # collisions in the message storage. @@ -678,6 +682,7 @@ The admin message is sent to the moderators. version : 3 Frank Person is now a member of the mailing list. +:: >>> member = mlist.members.get_member('fperson@example.org') >>> member @@ -692,7 +697,7 @@ Frank Person is now a member of the mailing list. >>> from zope.component import getUtility >>> user_manager = getUtility(IUserManager) - >>> user = user_manager.get_user(member.address.address) + >>> user = user_manager.get_user(member.address.email) >>> print user.real_name Frank Person >>> print user.password @@ -704,7 +709,9 @@ Holding unsubscription requests Some lists, though it is rare, require moderator approval for unsubscriptions. In this case, only the unsubscribing address is required. Like subscriptions, -unsubscription holds can send the list's moderators an immediate notification. +unsubscription holds can send the list's moderators an immediate +notification. +:: >>> mlist.admin_immed_notify = False >>> from mailman.interfaces.member import MemberRole @@ -776,6 +783,7 @@ subscribed. The request can be rejected, in which case a message is sent to the member, and the person remains a member of the mailing list. +:: >>> moderator.handle_unsubscription(mlist, id_6, Action.reject, ... 'This list is a prison.') diff --git a/src/mailman/model/docs/usermanager.txt b/src/mailman/model/docs/usermanager.txt index 856221952..7b333248c 100644 --- a/src/mailman/model/docs/usermanager.txt +++ b/src/mailman/model/docs/usermanager.txt @@ -2,10 +2,7 @@ The user manager ================ -The IUserManager is how you create, delete, and manage users. The Mailman -system instantiates an IUserManager for you based on the configuration -variable MANAGERS_INIT_FUNCTION. The instance is accessible on the global -config object. +The ``IUserManager`` is how you create, delete, and manage users. >>> from mailman.interfaces.usermanager import IUserManager >>> from zope.component import getUtility @@ -16,9 +13,10 @@ Creating users ============== There are several ways you can create a user object. The simplest is to -create a 'blank' user by not providing an address or real name at creation +create a `blank` user by not providing an address or real name at creation time. This user will have an empty string as their real name, but will not have a password. +:: >>> from mailman.interfaces.user import IUser >>> from zope.interface.verify import verifyObject @@ -26,10 +24,10 @@ have a password. >>> verifyObject(IUser, user) True - >>> sorted(address.address for address in user.addresses) - [] - >>> user.real_name - u'' + >>> dump_list(address.email for address in user.addresses) + *Empty* + >>> print user.real_name + <BLANKLINE> >>> print user.password None @@ -41,52 +39,59 @@ The user has preferences, but none of them will be specified. A user can be assigned a real name. >>> user.real_name = 'Anne Person' - >>> sorted(user.real_name for user in user_manager.users) - [u'Anne Person'] + >>> dump_list(user.real_name for user in user_manager.users) + Anne Person A user can be assigned a password. >>> user.password = 'secret' - >>> sorted(user.password for user in user_manager.users) - [u'secret'] + >>> dump_list(user.password for user in user_manager.users) + secret You can also create a user with an address to start out with. >>> user_2 = user_manager.create_user('bperson@example.com') >>> verifyObject(IUser, user_2) True - >>> sorted(address.address for address in user_2.addresses) - [u'bperson@example.com'] - >>> sorted(user.real_name for user in user_manager.users) - [u'', u'Anne Person'] + >>> dump_list(address.email for address in user_2.addresses) + bperson@example.com + >>> dump_list(user.real_name for user in user_manager.users) + <BLANKLINE> + Anne Person As above, you can assign a real name to such users. >>> user_2.real_name = 'Ben Person' - >>> sorted(user.real_name for user in user_manager.users) - [u'Anne Person', u'Ben Person'] + >>> dump_list(user.real_name for user in user_manager.users) + Anne Person + Ben Person You can also create a user with just a real name. >>> user_3 = user_manager.create_user(real_name='Claire Person') >>> verifyObject(IUser, user_3) True - >>> sorted(address.address for address in user.addresses) - [] - >>> sorted(user.real_name for user in user_manager.users) - [u'Anne Person', u'Ben Person', u'Claire Person'] + >>> dump_list(address.email for address in user.addresses) + *Empty* + >>> dump_list(user.real_name for user in user_manager.users) + Anne Person + Ben Person + Claire Person Finally, you can create a user with both an address and a real name. >>> user_4 = user_manager.create_user('dperson@example.com', 'Dan Person') >>> verifyObject(IUser, user_3) True - >>> sorted(address.address for address in user_4.addresses) - [u'dperson@example.com'] - >>> sorted(address.real_name for address in user_4.addresses) - [u'Dan Person'] - >>> sorted(user.real_name for user in user_manager.users) - [u'Anne Person', u'Ben Person', u'Claire Person', u'Dan Person'] + >>> dump_list(address.email for address in user_4.addresses) + dperson@example.com + >>> dump_list(address.real_name for address in user_4.addresses) + Dan Person + >>> dump_list(user.real_name for user in user_manager.users) + Anne Person + Ben Person + Claire Person + Dan Person Deleting users @@ -96,30 +101,32 @@ You delete users by going through the user manager. The deleted user is no longer available through the user manager iterator. >>> user_manager.delete_user(user) - >>> sorted(user.real_name for user in user_manager.users) - [u'Ben Person', u'Claire Person', u'Dan Person'] + >>> dump_list(user.real_name for user in user_manager.users) + Ben Person + Claire Person + Dan Person Finding users ============= -You can ask the user manager to find the IUser that controls a particular +You can ask the user manager to find the ``IUser`` that controls a particular email address. You'll get back the original user object if it's found. Note -that the .get_user() method takes a string email address, not an IAddress -object. +that the ``.get_user()`` method takes a string email address, not an +``IAddress`` object. >>> address = list(user_4.addresses)[0] - >>> found_user = user_manager.get_user(address.address) + >>> found_user = user_manager.get_user(address.email) >>> found_user <User "Dan Person" at ...> >>> found_user is user_4 True If the address is not in the user database or does not have a user associated -with it, you will get None back. +with it, you will get ``None`` back. >>> print user_manager.get_user('zperson@example.com') None >>> user_4.unlink(address) - >>> print user_manager.get_user(address.address) + >>> print user_manager.get_user(address.email) None diff --git a/src/mailman/model/docs/users.txt b/src/mailman/model/docs/users.txt index bb0301772..bbfef8391 100644 --- a/src/mailman/model/docs/users.txt +++ b/src/mailman/model/docs/users.txt @@ -21,19 +21,19 @@ Users may have a real name and a password. >>> user_1 = user_manager.create_user() >>> user_1.password = 'my password' >>> user_1.real_name = 'Zoe Person' - >>> sorted(user.real_name for user in user_manager.users) - [u'Zoe Person'] - >>> sorted(user.password for user in user_manager.users) - [u'my password'] + >>> dump_list(user.real_name for user in user_manager.users) + Zoe Person + >>> dump_list(user.password for user in user_manager.users) + my password The password and real name can be changed at any time. >>> user_1.real_name = 'Zoe X. Person' >>> user_1.password = 'another password' - >>> sorted(user.real_name for user in user_manager.users) - [u'Zoe X. Person'] - >>> sorted(user.password for user in user_manager.users) - [u'another password'] + >>> dump_list(user.real_name for user in user_manager.users) + Zoe X. Person + >>> dump_list(user.password for user in user_manager.users) + another password Users addresses @@ -50,19 +50,25 @@ address on a user object. <Address: Zoe Person <zperson@example.com> [not verified] at 0x...> >>> user_1.register('zperson@example.org') <Address: zperson@example.org [not verified] at 0x...> - >>> sorted(address.address for address in user_1.addresses) - [u'zperson@example.com', u'zperson@example.org'] - >>> sorted(address.real_name for address in user_1.addresses) - [u'', u'Zoe Person'] + >>> dump_list(address.email for address in user_1.addresses) + zperson@example.com + zperson@example.org + >>> dump_list(address.real_name for address in user_1.addresses) + <BLANKLINE> + Zoe Person You can also create the address separately and then link it to the user. >>> address_1 = user_manager.create_address('zperson@example.net') >>> user_1.link(address_1) - >>> sorted(address.address for address in user_1.addresses) - [u'zperson@example.com', u'zperson@example.net', u'zperson@example.org'] - >>> sorted(address.real_name for address in user_1.addresses) - [u'', u'', u'Zoe Person'] + >>> dump_list(address.email for address in user_1.addresses) + zperson@example.com + zperson@example.net + zperson@example.org + >>> dump_list(address.real_name for address in user_1.addresses) + <BLANKLINE> + <BLANKLINE> + Zoe Person But don't try to link an address to more than one user. @@ -74,7 +80,7 @@ But don't try to link an address to more than one user. You can also ask whether a given user controls a given address. - >>> user_1.controls(address_1.address) + >>> user_1.controls(address_1.email) True >>> user_1.controls('bperson@example.com') False @@ -133,7 +139,9 @@ Users have preferences, but these preferences have no default settings. receive_own_postings : None delivery_mode : None -Some of these preferences are booleans and they can be set to True or False. +Some of these preferences are booleans and they can be set to ``True`` or +``False``. +:: >>> from mailman.interfaces.languages import ILanguageManager >>> getUtility(ILanguageManager).add('it', 'iso-8859-1', 'Italian') @@ -158,10 +166,13 @@ Subscriptions Users know which mailing lists they are subscribed to, regardless of membership role. +:: >>> user_1.link(address_1) - >>> sorted(address.address for address in user_1.addresses) - [u'zperson@example.com', u'zperson@example.net', u'zperson@example.org'] + >>> dump_list(address.email for address in user_1.addresses) + zperson@example.com + zperson@example.net + zperson@example.org >>> com = user_manager.get_address('zperson@example.com') >>> org = user_manager.get_address('zperson@example.org') >>> net = user_manager.get_address('zperson@example.net') @@ -191,18 +202,14 @@ membership role. >>> len(members) 4 >>> def sortkey(member): - ... return (member.address.address, member.mailing_list, + ... return (member.address.email, member.mailing_list, ... int(member.role)) >>> for member in sorted(members, key=sortkey): - ... print member.address.address, member.mailing_list, member.role + ... print member.address.email, member.mailing_list, member.role zperson@example.com xtest_1@example.com MemberRole.member zperson@example.net xtest_3@example.com MemberRole.moderator zperson@example.org xtest_2@example.com MemberRole.member zperson@example.org xtest_2@example.com MemberRole.owner -Cross references -================ - .. _`usermanager.txt`: usermanager.html - diff --git a/src/mailman/model/domain.py b/src/mailman/model/domain.py index e19890cc3..89b071477 100644 --- a/src/mailman/model/domain.py +++ b/src/mailman/model/domain.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -77,10 +77,16 @@ class Domain(Model): @property def url_host(self): + """See `IDomain`.""" # pylint: disable-msg=E1101 # no netloc member; yes it does return urlparse(self.base_url).netloc + @property + def scheme(self): + """See `IDomain`.""" + return urlparse(self.base_url).scheme + def confirm_url(self, token=''): """See `IDomain`.""" return urljoin(self.base_url, 'confirm/' + token) diff --git a/src/mailman/model/language.py b/src/mailman/model/language.py index c1870a1b5..57f5497f5 100644 --- a/src/mailman/model/language.py +++ b/src/mailman/model/language.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,7 +25,7 @@ __all__ = [ ] -from storm.locals import * +from storm.locals import Int, Unicode from zope.interface import implements from mailman.database import Model diff --git a/src/mailman/model/listmanager.py b/src/mailman/model/listmanager.py index e123c47f4..99c961e85 100644 --- a/src/mailman/model/listmanager.py +++ b/src/mailman/model/listmanager.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py index 65611f563..cdebd5ca6 100644 --- a/src/mailman/model/mailinglist.py +++ b/src/mailman/model/mailinglist.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -61,12 +61,17 @@ class MailingList(Model): id = Int(primary=True) + # XXX denotes attributes that should be part of the public interface but + # are currently missing. + # List identity list_name = Unicode() host_name = Unicode() list_id = Unicode() include_list_post_header = Bool() include_rfc2369_headers = Bool() + advertised = Bool() + anonymous_list = Bool() # Attributes not directly modifiable via the web u/i created_at = DateTime() admin_member_chunksize = Int() @@ -77,22 +82,20 @@ class MailingList(Model): next_digest_number = Int() digest_last_sent_at = DateTime() volume = Int() - last_post_time = DateTime() + last_post_at = DateTime() # Implicit destination. acceptable_aliases_id = Int() acceptable_alias = Reference(acceptable_aliases_id, 'AcceptableAlias.id') # Attributes which are directly modifiable via the web u/i. The more # complicated attributes are currently stored as pickles, though that # will change as the schema and implementation is developed. - accept_these_nonmembers = Pickle() + accept_these_nonmembers = Pickle() # XXX admin_immed_notify = Bool() admin_notify_mchanges = Bool() administrivia = Bool() - advertised = Bool() - anonymous_list = Bool() - archive = Bool() - archive_private = Bool() - archive_volume_frequency = Int() + archive = Bool() # XXX + archive_private = Bool() # XXX + archive_volume_frequency = Int() # XXX # Automatic responses. autoresponse_grace_period = TimeDelta() autorespond_owner = Enum() @@ -106,17 +109,18 @@ class MailingList(Model): collapse_alternatives = Bool() convert_html_to_plaintext = Bool() # Bounces and bans. - ban_list = Pickle() - bounce_info_stale_after = TimeDelta() - bounce_matching_headers = Unicode() - bounce_notify_owner_on_disable = Bool() - bounce_notify_owner_on_removal = Bool() - bounce_processing = Bool() - bounce_score_threshold = Int() - bounce_unrecognized_goes_to_list_owner = Bool() - bounce_you_are_disabled_warnings = Int() - bounce_you_are_disabled_warnings_interval = TimeDelta() - default_member_moderation = Bool() + ban_list = Pickle() # XXX + bounce_info_stale_after = TimeDelta() # XXX + bounce_matching_headers = Unicode() # XXX + bounce_notify_owner_on_disable = Bool() # XXX + bounce_notify_owner_on_removal = Bool() # XXX + bounce_processing = Bool() # XXX + bounce_score_threshold = Int() # XXX + bounce_unrecognized_goes_to_list_owner = Bool() # XXX + bounce_you_are_disabled_warnings = Int() # XXX + bounce_you_are_disabled_warnings_interval = TimeDelta() # XXX + default_member_action = Enum() + default_nonmember_action = Enum() description = Unicode() digest_footer = Unicode() digest_header = Unicode() @@ -141,7 +145,6 @@ class MailingList(Model): max_days_to_hold = Int() max_message_size = Int() max_num_recipients = Int() - member_moderation_action = Enum() member_moderation_notice = Unicode() mime_is_default_digest = Bool() moderator_password = Unicode() @@ -202,6 +205,7 @@ class MailingList(Model): self.regular_members = roster.RegularMemberRoster(self) self.digest_members = roster.DigestMemberRoster(self) self.subscribers = roster.Subscribers(self) + self.nonmembers = roster.NonmemberRoster(self) def __repr__(self): return '<mailing list "{0}" at {1:#x}>'.format( @@ -218,6 +222,11 @@ class MailingList(Model): return getUtility(IDomainManager)[self.host_name] @property + def scheme(self): + """See `IMailingList`.""" + return self.domain.scheme + + @property def web_host(self): """See `IMailingList`.""" return self.domain.url_host diff --git a/src/mailman/model/member.py b/src/mailman/model/member.py index 34fde5f2f..5e8619324 100644 --- a/src/mailman/model/member.py +++ b/src/mailman/model/member.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -24,14 +24,17 @@ __all__ = [ 'Member', ] -from storm.locals import * +from storm.locals import Int, Reference, Unicode +from zope.component import getUtility from zope.interface import implements from mailman.config import config from mailman.core.constants import system_preferences from mailman.database.model import Model from mailman.database.types import Enum -from mailman.interfaces.member import IMember +from mailman.interfaces.action import Action +from mailman.interfaces.listmanager import IListManager +from mailman.interfaces.member import IMember, MemberRole @@ -41,7 +44,7 @@ class Member(Model): id = Int(primary=True) role = Enum() mailing_list = Unicode() - is_moderated = Bool() + moderation_action = Enum() address_id = Int() address = Reference(address_id, 'Address.id') @@ -52,7 +55,16 @@ class Member(Model): self.role = role self.mailing_list = mailing_list self.address = address - self.is_moderated = False + if role in (MemberRole.owner, MemberRole.moderator): + self.moderation_action = Action.accept + elif role is MemberRole.member: + self.moderation_action = getUtility(IListManager).get( + mailing_list).default_member_action + else: + assert role is MemberRole.nonmember, ( + 'Invalid MemberRole: {0}'.format(role)) + self.moderation_action = getUtility(IListManager).get( + mailing_list).default_nonmember_action def __repr__(self): return '<Member: {0} on {1} as {2}>'.format( @@ -98,7 +110,7 @@ class Member(Model): @property def options_url(self): # XXX Um, this is definitely wrong - return 'http://example.com/' + self.address.address + return 'http://example.com/' + self.address.email def unsubscribe(self): config.db.store.remove(self.preferences) diff --git a/src/mailman/model/message.py b/src/mailman/model/message.py index 3e70b144d..c1e44d384 100644 --- a/src/mailman/model/message.py +++ b/src/mailman/model/message.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,7 +25,7 @@ __all__ = [ 'Message', ] -from storm.locals import * +from storm.locals import AutoReload, Int, RawStr, Unicode from zope.interface import implements from mailman.config import config diff --git a/src/mailman/model/messagestore.py b/src/mailman/model/messagestore.py index bc2323a2c..0301b2188 100644 --- a/src/mailman/model/messagestore.py +++ b/src/mailman/model/messagestore.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/model/mime.py b/src/mailman/model/mime.py index 3f4871a1d..8ba9adf39 100644 --- a/src/mailman/model/mime.py +++ b/src/mailman/model/mime.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,7 +25,7 @@ __all__ = [ ] -from storm.locals import Bool, Int, Reference, Unicode +from storm.locals import Int, Reference, Unicode from zope.interface import implements from mailman.database.model import Model diff --git a/src/mailman/model/pending.py b/src/mailman/model/pending.py index ae36703ce..b411e6e88 100644 --- a/src/mailman/model/pending.py +++ b/src/mailman/model/pending.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,14 +25,14 @@ __all__ = [ 'Pendings', ] -import sys + import time import random import hashlib import datetime from lazr.config import as_timedelta -from storm.locals import * +from storm.locals import DateTime, Int, RawStr, ReferenceSet, Unicode from zope.interface import implements from zope.interface.verify import verifyObject diff --git a/src/mailman/model/preferences.py b/src/mailman/model/preferences.py index a5064957d..a874bc398 100644 --- a/src/mailman/model/preferences.py +++ b/src/mailman/model/preferences.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,11 +25,10 @@ __all__ = [ ] -from storm.locals import * +from storm.locals import Bool, Int, Unicode from zope.component import getUtility from zope.interface import implements -from mailman.config import config from mailman.database.model import Model from mailman.database.types import Enum from mailman.interfaces.languages import ILanguageManager diff --git a/src/mailman/model/requests.py b/src/mailman/model/requests.py index 78d077879..e1db9e63f 100644 --- a/src/mailman/model/requests.py +++ b/src/mailman/model/requests.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -26,7 +26,7 @@ __all__ = [ from datetime import timedelta -from storm.locals import * +from storm.locals import AutoReload, Int, RawStr, Reference, Unicode from zope.component import getUtility from zope.interface import implements diff --git a/src/mailman/model/roster.py b/src/mailman/model/roster.py index daf964581..f3a71ee5e 100644 --- a/src/mailman/model/roster.py +++ b/src/mailman/model/roster.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -37,7 +37,7 @@ __all__ = [ ] -from storm.expr import And, LeftJoin, Or +from storm.expr import And, Or from zope.interface import implements from mailman.config import config @@ -45,7 +45,6 @@ from mailman.interfaces.member import DeliveryMode, MemberRole from mailman.interfaces.roster import IRoster from mailman.model.address import Address from mailman.model.member import Member -from mailman.model.preferences import Preferences @@ -100,7 +99,7 @@ class AbstractRoster: Member, Member.mailing_list == self._mlist.fqdn_listname, Member.role == self.role, - Address.address == address, + Address.email == address, Member.address_id == Address.id) if results.count() == 0: return None @@ -121,6 +120,14 @@ class MemberRoster(AbstractRoster): +class NonmemberRoster(AbstractRoster): + """Return all the nonmembers of a list.""" + + name = 'nonmember' + role = MemberRole.nonmember + + + class OwnerRoster(AbstractRoster): """Return all the owners of a list.""" @@ -162,7 +169,7 @@ class AdministratorRoster(AbstractRoster): Member.mailing_list == self._mlist.fqdn_listname, Or(Member.role == MemberRole.moderator, Member.role == MemberRole.owner), - Address.address == address, + Address.email == address, Member.address_id == Address.id) if results.count() == 0: return None diff --git a/src/mailman/model/user.py b/src/mailman/model/user.py index d633c5d17..f2a7c9d18 100644 --- a/src/mailman/model/user.py +++ b/src/mailman/model/user.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -24,7 +24,7 @@ __all__ = [ 'User', ] -from storm.locals import * +from storm.locals import Int, Reference, ReferenceSet, Unicode from zope.interface import implements from mailman.config import config @@ -66,28 +66,28 @@ class User(Model): raise AddressNotLinkedError(address) address.user = None - def controls(self, address): + def controls(self, email): """See `IUser`.""" - found = config.db.store.find(Address, address=address) + found = config.db.store.find(Address, email=email) if found.count() == 0: return False assert found.count() == 1, 'Unexpected count' return found[0].user is self - def register(self, address, real_name=None): + def register(self, email, real_name=None): """See `IUser`.""" # First, see if the address already exists - addrobj = config.db.store.find(Address, address=address).one() - if addrobj is None: + address = config.db.store.find(Address, email=email).one() + if address is None: if real_name is None: real_name = '' - addrobj = Address(address=address, real_name=real_name) - addrobj.preferences = Preferences() + address = Address(email=email, real_name=real_name) + address.preferences = Preferences() # Link the address to the user if it is not already linked. - if addrobj.user is not None: - raise AddressAlreadyLinkedError(addrobj) - addrobj.user = self - return addrobj + if address.user is not None: + raise AddressAlreadyLinkedError(address) + address.user = self + return address @property def memberships(self): diff --git a/src/mailman/model/usermanager.py b/src/mailman/model/usermanager.py index da12ba33c..067ed7795 100644 --- a/src/mailman/model/usermanager.py +++ b/src/mailman/model/usermanager.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -39,13 +39,12 @@ from mailman.model.user import User class UserManager: implements(IUserManager) - def create_user(self, address=None, real_name=None): + def create_user(self, email=None, real_name=None): user = User() user.real_name = ('' if real_name is None else real_name) - if address: - addrobj = Address(address, user.real_name) - addrobj.preferences = Preferences() - user.link(addrobj) + if email: + address = self.create_address(email, real_name) + user.link(address) user.preferences = Preferences() config.db.store.add(user) return user @@ -53,13 +52,8 @@ class UserManager: def delete_user(self, user): config.db.store.remove(user) - @property - def users(self): - for user in config.db.store.find(User): - yield user - - def get_user(self, address): - addresses = config.db.store.find(Address, address=address.lower()) + def get_user(self, email): + addresses = config.db.store.find(Address, email=email.lower()) if addresses.count() == 0: return None elif addresses.count() == 1: @@ -67,17 +61,22 @@ class UserManager: else: raise AssertionError('Unexpected query count') - def create_address(self, address, real_name=None): - addresses = config.db.store.find(Address, address=address.lower()) + @property + def users(self): + for user in config.db.store.find(User): + yield user + + def create_address(self, email, real_name=None): + addresses = config.db.store.find(Address, email=email.lower()) if addresses.count() == 1: found = addresses[0] - raise ExistingAddressError(found.original_address) + raise ExistingAddressError(found.original_email) assert addresses.count() == 0, 'Unexpected results' if real_name is None: real_name = '' - # It's okay not to lower case the 'address' argument because the + # It's okay not to lower case the 'email' argument because the # constructor will do the right thing. - address = Address(address, real_name) + address = Address(email, real_name) address.preferences = Preferences() config.db.store.add(address) return address @@ -89,8 +88,8 @@ class UserManager: address.user.unlink(address) config.db.store.remove(address) - def get_address(self, address): - addresses = config.db.store.find(Address, address=address.lower()) + def get_address(self, email): + addresses = config.db.store.find(Address, email=email.lower()) if addresses.count() == 0: return None elif addresses.count() == 1: diff --git a/src/mailman/model/version.py b/src/mailman/model/version.py index d56f41353..418018ac3 100644 --- a/src/mailman/model/version.py +++ b/src/mailman/model/version.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -24,7 +24,7 @@ __all__ = [ 'Version', ] -from storm.locals import * +from storm.locals import Int, Unicode from mailman.database.model import Model diff --git a/src/mailman/mta/base.py b/src/mailman/mta/base.py index 2f1470d56..e90fbcf8f 100644 --- a/src/mailman/mta/base.py +++ b/src/mailman/mta/base.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -49,9 +49,12 @@ class BaseDelivery: def __init__(self): """Create a basic deliverer.""" + username = (config.mta.smtp_user if config.mta.smtp_user else None) + password = (config.mta.smtp_pass if config.mta.smtp_pass else None) self._connection = Connection( config.mta.smtp_host, int(config.mta.smtp_port), - int(config.mta.max_sessions_per_connection)) + int(config.mta.max_sessions_per_connection), + username, password) def _deliver_to_recipients(self, mlist, msg, msgdata, recipients): """Low-level delivery to a set of recipients. diff --git a/src/mailman/mta/bulk.py b/src/mailman/mta/bulk.py index 63d883be7..9c8380277 100644 --- a/src/mailman/mta/bulk.py +++ b/src/mailman/mta/bulk.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/mta/connection.py b/src/mailman/mta/connection.py index e3e264a1f..369e43570 100644 --- a/src/mailman/mta/connection.py +++ b/src/mailman/mta/connection.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -38,7 +38,8 @@ log = logging.getLogger('mailman.smtp') class Connection: """Manage a connection to the SMTP server.""" - def __init__(self, host, port, sessions_per_connection): + def __init__(self, host, port, sessions_per_connection, + smtp_user=None, smtp_pass=None): """Create a connection manager. :param host: The host name of the SMTP server to connect to. @@ -51,10 +52,17 @@ class Connection: opened. Set to zero for an unlimited number of sessions per connection (i.e. your MTA has no limit). :type sessions_per_connection: integer + :param smtp_user: Optional SMTP authentication user name. If given, + `smtp_pass` must also be given. + :type smtp_user: str + :param smtp_pass: Optional SMTP authentication password. If given, + `smtp_user` must also be given. """ self._host = host self._port = port self._sessions_per_connection = sessions_per_connection + self._username = smtp_user + self._password = smtp_pass self._session_count = None self._connection = None @@ -63,6 +71,9 @@ class Connection: self._connection = smtplib.SMTP() log.debug('Connecting to %s:%s', self._host, self._port) self._connection.connect(self._host, self._port) + if self._username is not None and self._password is not None: + log.debug('Logging in') + self._connection.login(self._username, self._password) self._session_count = self._sessions_per_connection def sendmail(self, envsender, recipients, msgtext): diff --git a/src/mailman/mta/decorating.py b/src/mailman/mta/decorating.py index 80c9131b8..d8fa6be86 100644 --- a/src/mailman/mta/decorating.py +++ b/src/mailman/mta/decorating.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/mta/deliver.py b/src/mailman/mta/deliver.py index 4783296cf..1d7f727ae 100644 --- a/src/mailman/mta/deliver.py +++ b/src/mailman/mta/deliver.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -28,8 +28,6 @@ __all__ = [ import time import logging -from lazr.config import as_boolean - from mailman.config import config from mailman.interfaces.mailinglist import Personalization from mailman.interfaces.mta import SomeRecipientsFailed @@ -150,9 +148,9 @@ def deliver(mlist, msg, msgdata): template = config.logging.smtp.failure if template.lower() != 'no': substitutions.update( - recip = recip, + recip = recipient, smtpcode = code, - smtpmsg = smtpmsg, + smtpmsg = smtp_message, ) log.info('%s', expand(template, substitutions)) # Return the results diff --git a/src/mailman/mta/docs/authentication.txt b/src/mailman/mta/docs/authentication.txt new file mode 100644 index 000000000..9f78859a0 --- /dev/null +++ b/src/mailman/mta/docs/authentication.txt @@ -0,0 +1,68 @@ +=================== +SMTP authentication +=================== + +The SMTP server may require authentication. Mailman supports setting the SMTP +user name and password. The actual authentication mechanism used is +determined by Python's `smtplib module`_, which tries the more secure +`CRAM-MD5` method first, followed by the less secure mechanisms `PLAIN` and +`LOGIN`. + +When sending authentication data between Mailman and the MTA over an unsecured +network, the submission (mail) server should offer `CRAM-MD5` as mechanism to +have Python's `smtplib module` automatically choose the more secure mechanism. + +When the user name and password match what's expected by the server, +everything is a-okay. + + >>> mlist = create_list('test@example.com') + +By default there is no user name and password, but this matches what's +expected by the test server. + + >>> config.push('auth', """ + ... [mta] + ... smtp_user: testuser + ... smtp_pass: testpass + ... """) + +Attempting delivery first must authorize with the mail server. +:: + + >>> from mailman.mta.bulk import BulkDelivery + >>> bulk = BulkDelivery() + + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: test@example.com + ... Subject: My first post + ... Message-ID: <first> + ... + ... First post! + ... """) + + >>> bulk.deliver(mlist, msg, dict(recipients=['bperson@example.com'])) + {} + + >>> print smtpd.get_authentication_credentials() + PLAIN AHRlc3R1c2VyAHRlc3RwYXNz + >>> config.pop('auth') + +But if the user name and password does not match, the connection will fail. + + >>> config.push('auth', """ + ... [mta] + ... smtp_user: baduser + ... smtp_pass: badpass + ... """) + + >>> bulk = BulkDelivery() + >>> response = bulk.deliver( + ... mlist, msg, dict(recipients=['bperson@example.com'])) + >>> dump_msgdata(response) + bperson@example.com: (571, 'Bad authentication') + + >>> config.pop('auth') + + +.. _`smtplib module`: http://docs.python.org/library/smtplib.html diff --git a/src/mailman/mta/docs/bulk.txt b/src/mailman/mta/docs/bulk.txt index a15a30279..8bacd4957 100644 --- a/src/mailman/mta/docs/bulk.txt +++ b/src/mailman/mta/docs/bulk.txt @@ -31,9 +31,10 @@ Delivery strategies must implement the proper interface. Chunking recipients =================== -The set of final recipients is contained in the 'recipients' key in the -message metadata. When `max_recipients` is specified as zero, then the bulk +The set of final recipients is contained in the ``recipients`` key in the +message metadata. When ``max_recipients`` is specified as zero, then the bulk deliverer puts all recipients into one big chunk. +:: >>> from string import ascii_letters >>> recipients = set(letter + 'person@example.com' @@ -56,6 +57,7 @@ more than 4 recipients, though they can have fewer (but still not zero). True The chunking algorithm sorts recipients by top level domain by length. +:: >>> recipients = set([ ... 'anne@example.com', @@ -83,8 +85,9 @@ The chunking algorithm sorts recipients by top level domain by length. 6 We can't make any guarantees about sorting within each chunk, but we can tell -a few things. For example, the first two chunks will be composed of .net (4) -and .org (3) domains (for a total of 7). +a few things. For example, the first two chunks will be composed of ``.net`` +(4) and ``.org`` (3) domains (for a total of 7). +:: >>> len(chunks[0]) 4 @@ -101,7 +104,8 @@ and .org (3) domains (for a total of 7). neil@example.net ocho@example.org -We also know that the next two chunks will contain .com (5) addresses. +We also know that the next two chunks will contain ``.com`` (5) addresses. +:: >>> len(chunks[2]) 4 @@ -116,7 +120,7 @@ We also know that the next two chunks will contain .com (5) addresses. john@example.com kate@example.com -The next chunk will contain the .us (2) and .ca (1) domains. +The next chunk will contain the ``.us`` (2) and ``.ca`` (1) domains. >>> len(chunks[4]) 3 @@ -126,7 +130,8 @@ The next chunk will contain the .us (2) and .ca (1) domains. liam@example.ca mary@example.us -The final chunk will contain the outliers, .xx (1) and .zz (2). +The final chunk will contain the outliers, ``.xx`` (1) and ``.zz`` (2). +:: >>> len(chunks[5]) 2 @@ -141,6 +146,7 @@ Bulk delivery The set of recipients for bulk delivery comes from the message metadata. If there are no calculated recipients, nothing gets sent. +:: >>> mlist = create_list('test@example.com') >>> msg = message_from_string("""\ @@ -165,7 +171,8 @@ there are no calculated recipients, nothing gets sent. With bulk delivery and no maximum number of recipients, there will be just one message sent, with all the recipients packed into the envelope recipients -(i.e. RCTP TO). +(i.e. ``RCTP TO``). +:: >>> recipients = set('person_{0:02d}'.format(i) for i in range(100)) >>> msgdata = dict(recipients=recipients) @@ -187,13 +194,14 @@ message sent, with all the recipients packed into the envelope recipients <BLANKLINE> This is a test. -The X-RcptTo header contains the set of recipients, in random order. +The ``X-RcptTo:`` header contains the set of recipients, in random order. >>> len(messages[0]['x-rcptto'].split(',')) 100 When the maximum number of recipients is set to 20, 5 messages will be sent, -each with 20 addresses in the RCPT TO header. +each with 20 addresses in the ``RCPT TO``. +:: >>> bulk = BulkDelivery(20) >>> bulk.deliver(mlist, msg, msgdata) @@ -215,11 +223,12 @@ each with 20 addresses in the RCPT TO header. Delivery headers ================ -The sending agent shows up in the RFC 5321 MAIL FROM header, which shows up in -the X-MailFrom header in the sample message. +The sending agent shows up in the RFC 5321 ``MAIL FROM``, which shows up in +the ``X-MailFrom:`` header in the sample message. The bulk delivery module calculates the sending agent address first from the message metadata... +:: >>> bulk = BulkDelivery() >>> recipients = set(['aperson@example.com']) @@ -241,6 +250,7 @@ message metadata... This is a test. ...followed by the mailing list's bounces robot address... +:: >>> del msgdata['sender'] >>> bulk.deliver(mlist, msg, msgdata) @@ -260,6 +270,7 @@ message metadata... ...and finally the site owner, if there is no mailing list target for this message. +:: >>> config.push('site-owner', """\ ... [mailman] @@ -295,6 +306,7 @@ certain situations. For example, there could be a problem delivering to any of the specified recipients. +:: # Tell the mail server to fail on the next 3 RCPT TO commands, one for # each recipient in the following message. @@ -330,6 +342,7 @@ recipients. 0 Or there could be some other problem causing an SMTP response failure. +:: # Tell the mail server to register a temporary failure on the next MAIL # FROM command. diff --git a/src/mailman/mta/docs/connection.txt b/src/mailman/mta/docs/connection.txt index a15dc4c6b..515a773bd 100644 --- a/src/mailman/mta/docs/connection.txt +++ b/src/mailman/mta/docs/connection.txt @@ -3,14 +3,14 @@ MTA connections =============== Outgoing connections to the outgoing mail transport agent (MTA) are mitigated -through a Connection class, which can transparently manage multiple sessions -in a single connection. +through a ``Connection`` class, which can transparently manage multiple +sessions in a single connection. >>> from mailman.mta.connection import Connection -The number of sessions per connections is specified when the Connection object -is created, as is the host and port number of the SMTP server. Zero means -there's an unlimited number of sessions per connection. +The number of sessions per connections is specified when the ``Connection`` +object is created, as is the host and port number of the SMTP server. Zero +means there's an unlimited number of sessions per connection. >>> connection = Connection( ... config.mta.smtp_host, int(config.mta.smtp_port), 0) @@ -21,6 +21,7 @@ At the start, there have been no connections to the server. 0 By sending a message to the server, a connection is opened. +:: >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ ... From: anne@example.com @@ -34,6 +35,7 @@ By sending a message to the server, a connection is opened. 1 We can reset the connection count back to zero. +:: >>> from smtplib import SMTP >>> def reset(): @@ -47,6 +49,56 @@ We can reset the connection count back to zero. >>> connection.quit() +By providing an SMTP user name and password in the configuration file, Mailman +will authenticate with the mail server after each new connection. +:: + + >>> config.push('auth', """ + ... [mta] + ... smtp_user: testuser + ... smtp_pass: testpass + ... """) + + >>> connection = Connection( + ... config.mta.smtp_host, int(config.mta.smtp_port), 0, + ... config.mta.smtp_user, config.mta.smtp_pass) + >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ + ... From: anne@example.com + ... To: bart@example.com + ... Subject: aardvarks + ... + ... """) + {} + >>> print smtpd.get_authentication_credentials() + PLAIN AHRlc3R1c2VyAHRlc3RwYXNz + + >>> reset() + >>> config.pop('auth') + +However, a bad user name or password generates an error. + + >>> config.push('auth', """ + ... [mta] + ... smtp_user: baduser + ... smtp_pass: badpass + ... """) + + >>> connection = Connection( + ... config.mta.smtp_host, int(config.mta.smtp_port), 0, + ... config.mta.smtp_user, config.mta.smtp_pass) + >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ + ... From: anne@example.com + ... To: bart@example.com + ... Subject: aardvarks + ... + ... """) + Traceback (most recent call last): + ... + SMTPAuthenticationError: (571, 'Bad authentication') + + >>> reset() + >>> config.pop('auth') + Sessions per connection ======================= @@ -56,6 +108,7 @@ the third message is sent, the connection is torn down and a new one is created. The connection count starts at zero. +:: >>> connection = Connection( ... config.mta.smtp_host, int(config.mta.smtp_port), 2) @@ -63,8 +116,9 @@ The connection count starts at zero. >>> smtpd.get_connection_count() 0 -We send two messages through the Connection object. Only one connection is -opened. +We send two messages through the ``Connection`` object. Only one connection +is opened. +:: >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ ... From: anne@example.com @@ -88,8 +142,9 @@ opened. >>> smtpd.get_connection_count() 1 -The third message causes a third session, which exceeds the maximum. So the +The third message would cause a third session, exceeding the maximum. So the current connection is closed and a new one opened. +:: >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ ... From: anne@example.com @@ -103,6 +158,7 @@ current connection is closed and a new one opened. 2 A fourth message does not cause a new connection to be made. +:: >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ ... From: anne@example.com @@ -116,6 +172,7 @@ A fourth message does not cause a new connection to be made. 2 But a fifth one does. +:: >>> connection.sendmail('anne@example.com', ['bart@example.com'], """\ ... From: anne@example.com @@ -141,6 +198,7 @@ connection. Even after ten messages are sent, there's still been only one connection to the server. +:: >>> connection.debug = True >>> for i in range(10): @@ -163,6 +221,7 @@ Development mode By putting Mailman into development mode, you can force the recipients to a given hard-coded address. This allows you to test Mailman without worrying about accidental deliveries to unintended recipients. +:: >>> config.push('devmode', """ ... [devmode] diff --git a/src/mailman/mta/docs/decorating.txt b/src/mailman/mta/docs/decorating.txt index f89d0b102..4edac481f 100644 --- a/src/mailman/mta/docs/decorating.txt +++ b/src/mailman/mta/docs/decorating.txt @@ -21,6 +21,7 @@ Decorations Decorations are added when the mailing list had a header and/or footer defined, and the decoration handler is told to do personalized decorations. +:: >>> mlist = create_list('test@example.com') >>> mlist.msg_header = """\ @@ -59,6 +60,7 @@ defined, and the decoration handler is told to do personalized decorations. More information is included when the recipient is a member of the mailing list. +:: >>> from zope.component import getUtility >>> from mailman.interfaces.member import MemberRole @@ -81,6 +83,7 @@ list. <Member: Cris Person <cperson@example.com> ... The decorations happen when the message is delivered. +:: >>> decorating.deliver(mlist, msg, msgdata) {} @@ -154,8 +157,9 @@ The decorations happen when the message is delivered. Decorate only once ================== -Do not decorate a message twice. Decorators must insert the 'decorated' key +Do not decorate a message twice. Decorators must insert the ``decorated`` key into the message metadata. +:: >>> msgdata['nodecorate'] = True >>> decorating.deliver(mlist, msg, msgdata) diff --git a/src/mailman/mta/docs/personalized.txt b/src/mailman/mta/docs/personalized.txt index 38494b725..6c1c9eb4f 100644 --- a/src/mailman/mta/docs/personalized.txt +++ b/src/mailman/mta/docs/personalized.txt @@ -3,12 +3,10 @@ Fully personalized delivery =========================== Fully personalized mail delivery is an enhancement over VERP_ delivery where -the To field of the message is replaced with the recipient's address. A +the ``To:`` field of the message is replaced with the recipient's address. A typical email message is sent to the mailing list's posting address and copied to the list membership that way. Some people like the more personal address. -.. _VERP: verp.txt - Personalized delivery still does VERP. >>> from mailman.mta.personalized import PersonalizedDelivery @@ -25,7 +23,8 @@ Delivery strategies must implement the proper interface. No personalization ================== -By default, the To header is not personalized. +By default, the ``To:`` header is not personalized. +:: >>> mlist = create_list('test@example.com') >>> msg = message_from_string("""\ @@ -88,8 +87,9 @@ By default, the To header is not personalized. To header ========= -When the mailing list requests personalization, the To header is replaced with -the recipient's address and name. +When the mailing list requests personalization, the ``To:`` header is replaced +with the recipient's address and name. +:: >>> from mailman.interfaces.mailinglist import Personalization >>> mlist.personalize = Personalization.full @@ -136,7 +136,8 @@ the recipient's address and name. ---------- If the recipient is a user registered with Mailman, and the user has an -associated real name, then this name also shows up in the To header. +associated real name, then this name also shows up in the ``To:`` header. +:: >>> from zope.component import getUtility >>> from mailman.interfaces.usermanager import IUserManager @@ -186,3 +187,6 @@ associated real name, then this name also shows up in the To header. <BLANKLINE> This is a test. ---------- + + +.. _VERP: verp.html diff --git a/src/mailman/mta/docs/verp.txt b/src/mailman/mta/docs/verp.txt index aa7f27793..2f2f09828 100644 --- a/src/mailman/mta/docs/verp.txt +++ b/src/mailman/mta/docs/verp.txt @@ -5,9 +5,6 @@ Standard VERP delivery Variable Envelope Return Path (VERP_) delivery is an alternative to bulk_ delivery, where an individual message is crafted uniquely for each recipient. -.. _VERP: http://en.wikipedia.org/wiki/Variable_envelope_return_path -.. _bulk: bulk.html - The cost of enabling VERP is that Mailman must send to the upstream MTA, one message per recipient. Under bulk delivery, an exact copy of one message can be sent to many recipients, greatly reducing the bandwidth for delivery. @@ -20,9 +17,7 @@ e.g. pointing the user to their account URL or including a user-specific unsubscription link. In theory, VERP delivery means we can do sophisticated `mail merge`_ operations. -.. _`mail merge`: http://en.wikipedia.org/wiki/Mail_merge - -Mailman's use of the term VERP really means "message personalization". +Mailman's use of the term VERP really means *message personalization*. >>> from mailman.mta.verp import VERPDelivery >>> verp = VERPDelivery() @@ -40,6 +35,7 @@ No recipients The message metadata specifies the set of recipients to send this message to. If there are no recipients, there's nothing to do. +:: >>> mlist = create_list('test@example.com') >>> msg = message_from_string("""\ @@ -77,6 +73,7 @@ intended recipient's delivery address. ... ]) VERPing is only actually done if the metadata requests it. +:: >>> msgdata = dict(recipients=recipients, verp=True) >>> verp.deliver(mlist, msg, msgdata) @@ -130,3 +127,8 @@ The deliverer made a copy of the original message, so it wasn't changed. <BLANKLINE> This is a test. <BLANKLINE> + + +.. _VERP: http://en.wikipedia.org/wiki/Variable_envelope_return_path +.. _bulk: bulk.html +.. _`mail merge`: http://en.wikipedia.org/wiki/Mail_merge diff --git a/src/mailman/mta/null.py b/src/mailman/mta/null.py index 4d6ec5838..0afda5fec 100644 --- a/src/mailman/mta/null.py +++ b/src/mailman/mta/null.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/mta/personalized.py b/src/mailman/mta/personalized.py index 19bf31cb2..bb45760e6 100644 --- a/src/mailman/mta/personalized.py +++ b/src/mailman/mta/personalized.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/mta/postfix.py b/src/mailman/mta/postfix.py index b0fdfb195..81a2a7945 100644 --- a/src/mailman/mta/postfix.py +++ b/src/mailman/mta/postfix.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -26,20 +26,14 @@ __all__ = [ import os -import grp -import pwd -import time -import errno import logging import datetime -from locknix.lockfile import Lock +from flufl.lock import Lock from zope.component import getUtility from zope.interface import implements -from mailman import Utils from mailman.config import config -from mailman.core.i18n import _ from mailman.interfaces.listmanager import IListManager from mailman.interfaces.mta import IMailTransportAgentAliases diff --git a/src/mailman/mta/verp.py b/src/mailman/mta/verp.py index 00d1104a2..85b777486 100644 --- a/src/mailman/mta/verp.py +++ b/src/mailman/mta/verp.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -68,7 +68,8 @@ class VERPMixin: # deliver it to this person, nor can we craft a valid verp # header. I don't think there's much we can do except ignore # this recipient. - log.info('Skipping VERP delivery to unqual recip: %s', recip) + log.info('Skipping VERP delivery to unqual recip: %s', + recipient) return sender return '{0}@{1}'.format( expand(config.mta.verp_format, dict( diff --git a/src/mailman/options.py b/src/mailman/options.py index 731da512c..372c5fbf3 100644 --- a/src/mailman/options.py +++ b/src/mailman/options.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/passwords.py b/src/mailman/passwords.py index b7563f374..c14584748 100644 --- a/src/mailman/passwords.py +++ b/src/mailman/passwords.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/acknowledge.py b/src/mailman/pipeline/acknowledge.py index 7fc1d4520..744238f44 100644 --- a/src/mailman/pipeline/acknowledge.py +++ b/src/mailman/pipeline/acknowledge.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/after_delivery.py b/src/mailman/pipeline/after_delivery.py index 145d674ab..a1f2cee19 100644 --- a/src/mailman/pipeline/after_delivery.py +++ b/src/mailman/pipeline/after_delivery.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/avoid_duplicates.py b/src/mailman/pipeline/avoid_duplicates.py index 9f1fe0159..c3a7b3274 100644 --- a/src/mailman/pipeline/avoid_duplicates.py +++ b/src/mailman/pipeline/avoid_duplicates.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2002-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/calculate_recipients.py b/src/mailman/pipeline/calculate_recipients.py index 8ff799f85..36f391fd0 100644 --- a/src/mailman/pipeline/calculate_recipients.py +++ b/src/mailman/pipeline/calculate_recipients.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -91,12 +91,12 @@ delivery. The original message as received by Mailman is attached. """) raise errors.RejectMessage(Utils.wrap(text)) # Calculate the regular recipients of the message - recipients = set(member.address.address + recipients = set(member.address.email for member in mlist.regular_members.members if member.delivery_status == DeliveryStatus.enabled) # Remove the sender if they don't want to receive their own posts - if not include_sender and member.address.address in recipients: - recipients.remove(member.address.address) + if not include_sender and member.address.email in recipients: + recipients.remove(member.address.email) # Handle topic classifications do_topic_filters(mlist, msg, msgdata, recipients) # Bookkeeping diff --git a/src/mailman/pipeline/cleanse.py b/src/mailman/pipeline/cleanse.py index a74a381e8..1089e805b 100644 --- a/src/mailman/pipeline/cleanse.py +++ b/src/mailman/pipeline/cleanse.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -53,6 +53,8 @@ class Cleanse: # Remove headers that could contain passwords. del msg['approved'] del msg['approve'] + del msg['x-approved'] + del msg['x-approve'] del msg['urgent'] # We remove other headers from anonymous lists. if mlist.anonymous_list: diff --git a/src/mailman/pipeline/cleanse_dkim.py b/src/mailman/pipeline/cleanse_dkim.py index c58c0c7de..7f98e19ca 100644 --- a/src/mailman/pipeline/cleanse_dkim.py +++ b/src/mailman/pipeline/cleanse_dkim.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/cook_headers.py b/src/mailman/pipeline/cook_headers.py index e91c575c1..89e9a5663 100644 --- a/src/mailman/pipeline/cook_headers.py +++ b/src/mailman/pipeline/cook_headers.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/decorate.py b/src/mailman/pipeline/decorate.py index 66ec83e90..d71e05cdc 100644 --- a/src/mailman/pipeline/decorate.py +++ b/src/mailman/pipeline/decorate.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -57,12 +57,12 @@ def process(mlist, msg, msgdata): member = mlist.members.get_member(recipient) d['user_address'] = recipient if user is not None and member is not None: - d['user_delivered_to'] = member.address.original_address + d['user_delivered_to'] = member.address.original_email # BAW: Hmm, should we allow this? d['user_password'] = user.password d['user_language'] = member.preferred_language.description d['user_name'] = (user.real_name if user.real_name - else member.address.original_address) + else member.address.original_email) d['user_optionsurl'] = member.options_url # These strings are descriptive for the log file and shouldn't be i18n'd d.update(msgdata.get('decoration-data', {})) diff --git a/src/mailman/pipeline/docs/ack-headers.txt b/src/mailman/pipeline/docs/ack-headers.txt index 0f4d8ab9e..dba2169e2 100644 --- a/src/mailman/pipeline/docs/ack-headers.txt +++ b/src/mailman/pipeline/docs/ack-headers.txt @@ -2,7 +2,7 @@ Acknowledgment headers ====================== -Messages that flow through the global pipeline get their headers 'cooked', +Messages that flow through the global pipeline get their headers `cooked`, which basically means that their headers go through several mostly unrelated transformations. Some headers get added, others get changed. Some of these changes depend on mailing list settings and others depend on how the message @@ -11,8 +11,9 @@ is getting sent through the system. We'll take things one-by-one. >>> mlist = create_list('_xtest@example.com') >>> mlist.subject_prefix = '' -When the message's metadata has a 'noack' key set, an 'X-Ack: no' header is +When the message's metadata has a `noack` key set, an ``X-Ack: no`` header is added. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -27,7 +28,7 @@ added. X-Ack: no ... -Any existing X-Ack header in the original message is removed. +Any existing ``X-Ack`` header in the original message is removed. >>> msg = message_from_string("""\ ... X-Ack: yes diff --git a/src/mailman/pipeline/docs/acknowledge.txt b/src/mailman/pipeline/docs/acknowledge.txt index f8e9d9d87..5e4240626 100644 --- a/src/mailman/pipeline/docs/acknowledge.txt +++ b/src/mailman/pipeline/docs/acknowledge.txt @@ -5,6 +5,7 @@ Message acknowledgment When a user posts a message to a mailing list, and that user has chosen to receive acknowledgments of their postings, Mailman will sent them such an acknowledgment. +:: >>> mlist = create_list('_xtest@example.com') >>> mlist.real_name = 'XTest' @@ -19,6 +20,7 @@ acknowledgment. [] Subscribe a user to the mailing list. +:: >>> from mailman.interfaces.usermanager import IUserManager >>> from zope.component import getUtility @@ -35,6 +37,7 @@ Non-member posts ================ Non-members can't get acknowledgments of their posts to the mailing list. +:: >>> msg = message_from_string("""\ ... From: bperson@example.com @@ -75,6 +78,7 @@ Unless the user has requested acknowledgments, they will not get one. Similarly if the original sender is specified in the message metadata, and that sender is a member but not one who has requested acknowledgments, none will be sent. +:: >>> user_2 = user_manager.create_user('dperson@example.com') >>> address_2 = list(user_2.addresses)[0] diff --git a/src/mailman/pipeline/docs/after-delivery.txt b/src/mailman/pipeline/docs/after-delivery.txt index eef153c7d..c3e393cf2 100644 --- a/src/mailman/pipeline/docs/after-delivery.txt +++ b/src/mailman/pipeline/docs/after-delivery.txt @@ -14,6 +14,7 @@ bookkeeping pieces of information are updated. Processing a message with this handler updates the last_post_time and post_id attributes. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com diff --git a/src/mailman/pipeline/docs/archives.txt b/src/mailman/pipeline/docs/archives.txt index 51fd600bc..323d121e8 100644 --- a/src/mailman/pipeline/docs/archives.txt +++ b/src/mailman/pipeline/docs/archives.txt @@ -19,10 +19,10 @@ A helper function. ... msg, msgdata = switchboard.dequeue(filebase) ... switchboard.finish(filebase) -The purpose of the ToArchive handler is to make a simple decision as to -whether the message should get archived and if so, to drop the message in the -archiving queue. Really the most important things are to determine when a -message should /not/ get archived. +The purpose of this handler is to make a simple decision as to whether the +message should get archived and if so, to drop the message in the archiving +queue. Really the most important things are to determine when a message +should *not* get archived. For example, no digests should ever get archived. @@ -46,8 +46,8 @@ won't be archived. There are two de-facto standards for a message to indicate that it does not want to be archived. We've seen both in the wild so both are supported. The -X-No-Archive: header can be used to indicate that the message should not be -archived. Confusingly, this header's value is actually ignored. +``X-No-Archive:`` header can be used to indicate that the message should not +be archived. Confusingly, this header's value is actually ignored. >>> mlist.archive = True >>> msg = message_from_string("""\ @@ -60,7 +60,7 @@ archived. Confusingly, this header's value is actually ignored. >>> switchboard.files [] -Even a 'no' value will stop the archiving of the message. +Even a ``no`` value will stop the archiving of the message. >>> msg = message_from_string("""\ ... Subject: A sample message @@ -72,8 +72,8 @@ Even a 'no' value will stop the archiving of the message. >>> switchboard.files [] -Another header that's been observed is the X-Archive: header. Here, the -header's case folded value must be 'no' in order to prevent archiving. +Another header that's been observed is the ``X-Archive:`` header. Here, the +header's case folded value must be ``no`` in order to prevent archiving. >>> msg = message_from_string("""\ ... Subject: A sample message @@ -85,7 +85,7 @@ header's case folded value must be 'no' in order to prevent archiving. >>> switchboard.files [] -But if the value is 'yes', then the message will be archived. +But if the value is ``yes``, then the message will be archived. >>> msg = message_from_string("""\ ... Subject: A sample message diff --git a/src/mailman/pipeline/docs/avoid-duplicates.txt b/src/mailman/pipeline/docs/avoid-duplicates.txt index 22fc85207..bd753f9e9 100644 --- a/src/mailman/pipeline/docs/avoid-duplicates.txt +++ b/src/mailman/pipeline/docs/avoid-duplicates.txt @@ -2,14 +2,14 @@ Avoid duplicates ================ -The AvoidDuplicates handler module implements several strategies to try to -reduce the reception of duplicate messages. It does this by removing certain -recipients from the list of recipients that earlier handler modules -(e.g. CalcRecips) calculates. +This handler implements several strategies to reduce the reception of +duplicate messages. It does this by removing certain recipients from the list +of recipients calculated earlier. >>> mlist = create_list('_xtest@example.com') Create some members we're going to use. +:: >>> from mailman.interfaces.usermanager import IUserManager >>> from zope.component import getUtility @@ -31,6 +31,7 @@ Short circuiting ================ The module short-circuits if there are no recipients. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -57,9 +58,9 @@ Suppressing the list copy Members can elect not to receive a list copy of any message on which they are explicitly named as a recipient. This is done by setting their -receive_list_copy preference to False. However, if they aren't mentioned in -one of the recipient headers (i.e. To, CC, Resent-To, or Resent-CC), then they -will get a list copy. +``receive_list_copy`` preference to ``False``. However, if they aren't +mentioned in one of the recipient headers (i.e. ``To``, ``CC``, ``Resent-To``, +or ``Resent-CC``), then they will get a list copy. >>> member_a.preferences.receive_list_copy = False >>> msg = message_from_string("""\ @@ -77,7 +78,7 @@ will get a list copy. Something of great import. <BLANKLINE> -If they're mentioned on the CC line, they won't get a list copy. +If they're mentioned on the ``CC`` line, they won't get a list copy. >>> msg = message_from_string("""\ ... From: Claire Person <cperson@example.com> @@ -96,8 +97,8 @@ If they're mentioned on the CC line, they won't get a list copy. Something of great import. <BLANKLINE> -But if they're mentioned on the CC line and have receive_list_copy set to True -(the default), then they still get a list copy. +But if they're mentioned on the ``CC`` line and have ``receive_list_copy`` set +to ``True`` (the default), then they still get a list copy. >>> msg = message_from_string("""\ ... From: Claire Person <cperson@example.com> @@ -116,7 +117,7 @@ But if they're mentioned on the CC line and have receive_list_copy set to True Something of great import. <BLANKLINE> -Other headers checked for recipients include the To... +Other headers checked for recipients include the ``To``... >>> msg = message_from_string("""\ ... From: Claire Person <cperson@example.com> @@ -135,7 +136,7 @@ Other headers checked for recipients include the To... Something of great import. <BLANKLINE> -...Resent-To... +... ``Resent-To`` ... >>> msg = message_from_string("""\ ... From: Claire Person <cperson@example.com> @@ -154,7 +155,7 @@ Other headers checked for recipients include the To... Something of great import. <BLANKLINE> -...and Resent-CC headers. +...and ``Resent-CC`` headers. >>> msg = message_from_string("""\ ... From: Claire Person <cperson@example.com> diff --git a/src/mailman/pipeline/docs/calc-recips.txt b/src/mailman/pipeline/docs/calc-recips.txt index 1b1903bf8..efa1bc9c7 100644 --- a/src/mailman/pipeline/docs/calc-recips.txt +++ b/src/mailman/pipeline/docs/calc-recips.txt @@ -10,6 +10,7 @@ modules and depends on a host of factors. Recipients are calculate from the list members, so add a bunch of members to start out with. First, create a bunch of addresses... +:: >>> from mailman.interfaces.usermanager import IUserManager >>> from zope.component import getUtility @@ -46,6 +47,7 @@ Short-circuiting Sometimes, the list of recipients already exists in the message metadata. This can happen for example, when a message was previously delivered to some but not all of the recipients. +:: >>> msg = message_from_string("""\ ... From: Xavier Person <xperson@example.com> @@ -57,8 +59,9 @@ but not all of the recipients. >>> handler = config.handlers['calculate-recipients'] >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recipients']) - [u'qperson@example.com', u'zperson@example.com'] + >>> dump_list(msgdata['recipients']) + qperson@example.com + zperson@example.com Regular delivery recipients @@ -69,8 +72,10 @@ soon as they are posted. In other words, these folks are not digest members. >>> msgdata = {} >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recipients']) - [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com'] + >>> dump_list(msgdata['recipients']) + aperson@example.com + bperson@example.com + cperson@example.com Members can elect not to receive a list copy of their own postings. @@ -82,12 +87,13 @@ Members can elect not to receive a list copy of their own postings. ... """) >>> msgdata = {} >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recipients']) - [u'aperson@example.com', u'bperson@example.com'] + >>> dump_list(msgdata['recipients']) + aperson@example.com + bperson@example.com Members can also elect not to receive a list copy of any message on which they -are explicitly named as a recipient. However, see the AvoidDuplicates handler -for details. +are explicitly named as a recipient. However, see the `avoid duplicates`_ +handler for details. Digest recipients @@ -103,3 +109,6 @@ XXX Test various urgent deliveries: * test_urgent_moderator() * test_urgent_admin() * test_urgent_reject() + + +.. _`avoid duplicates`: avoid-duplicates.html diff --git a/src/mailman/pipeline/docs/cleanse.txt b/src/mailman/pipeline/docs/cleanse.txt index 155de0673..61dfa8f52 100644 --- a/src/mailman/pipeline/docs/cleanse.txt +++ b/src/mailman/pipeline/docs/cleanse.txt @@ -8,17 +8,20 @@ headers can be used to fish for membership. >>> mlist = create_list('_xtest@example.com') -Headers such as Approved, Approve, and Urgent are used to grant special -pemissions to individual messages. All may contain a password; the first two -headers are used by list administrators to pre-approve a message normal held -for approval. The latter header is used to send a regular message to all -members, regardless of whether they get digests or not. Because all three -headers contain passwords, they must be removed from any posted message. +Headers such as ``Approved``, ``Approve``, (as well as their ``X-`` variants) +and ``Urgent`` are used to grant special permissions to individual messages. +All may contain a password; the first two headers are used by list +administrators to pre-approve a message normal held for approval. The latter +header is used to send a regular message to all members, regardless of whether +they get digests or not. Because all three headers contain passwords, they +must be removed from any posted message. :: >>> msg = message_from_string("""\ ... From: aperson@example.com ... Approved: foobar ... Approve: barfoo + ... X-Approved: bazbar + ... X-Approve: barbaz ... Urgent: notreally ... Subject: A message of great import ... @@ -36,9 +39,9 @@ headers contain passwords, they must be removed from any posted message. Other headers can be used by list members to fish the list for membership, so we don't let them go through. These are a mix of standard headers and custom -headers supported by some mail readers. For example, X-PMRC is supported by -Pegasus mail. I don't remember what program uses X-Confirm-Reading-To though -(Some Microsoft product perhaps?). +headers supported by some mail readers. For example, ``X-PMRC`` is supported +by Pegasus mail. I don't remember what program uses ``X-Confirm-Reading-To`` +though (Some Microsoft product perhaps?). >>> msg = message_from_string("""\ ... From: bperson@example.com @@ -69,10 +72,11 @@ Anonymous lists Anonymous mailing lists also try to cleanse certain identifying headers from the original posting, so that it is at least a bit more difficult to determine who sent the message. This isn't perfect though, for example, the body of the -messages are never scrubbed (though that might not be a bad idea). The From -and Reply-To headers in the posted message are taken from list attributes. +messages are never scrubbed (though that might not be a bad idea). The +``From`` and ``Reply-To`` headers in the posted message are taken from list +attributes. -Hotmail apparently sets X-Originating-Email. +Hotmail apparently sets ``X-Originating-Email``. >>> mlist.anonymous_list = True >>> mlist.description = 'A Test Mailing List' diff --git a/src/mailman/pipeline/docs/cook-headers.txt b/src/mailman/pipeline/docs/cook-headers.txt index 5d078c342..834b140fa 100644 --- a/src/mailman/pipeline/docs/cook-headers.txt +++ b/src/mailman/pipeline/docs/cook-headers.txt @@ -17,8 +17,9 @@ is getting sent through the system. We'll take things one-by-one. Saving the original sender ========================== -Because the original sender headers may get deleted or changed, CookHeaders +Because the original sender headers may get deleted or changed, this handler will place the sender in the message metadata for safe keeping. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -48,7 +49,7 @@ But if there was no original sender, then the empty string will be saved. Mailman version header ====================== -Mailman will also insert an X-Mailman-Version header... +Mailman will also insert an ``X-Mailman-Version`` header... >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -76,8 +77,8 @@ Mailman will also insert an X-Mailman-Version header... Precedence header ================= -Mailman will insert a Precedence header, which is a de-facto standard for -telling automatic reply software (e.g. vacation(1)) not to respond to this +Mailman will insert a ``Precedence`` header, which is a de-facto standard for +telling automatic reply software (e.g. ``vacation(1)``) not to respond to this message. >>> msg = message_from_string("""\ @@ -165,12 +166,12 @@ But normally, a list will include these headers. List-Id: <_xtest.example.com> List-Post: <mailto:_xtest@example.com> List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-join@example.com> + <mailto:_xtest-join@example.com> List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-leave@example.com> + <mailto:_xtest-leave@example.com> ---end--- -If the mailing list has a description, then it is included in the List-Id +If the mailing list has a description, then it is included in the ``List-Id`` header. >>> mlist.description = 'My test mailing list' @@ -186,13 +187,14 @@ header. List-Id: My test mailing list <_xtest.example.com> List-Post: <mailto:_xtest@example.com> List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-join@example.com> + <mailto:_xtest-join@example.com> List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-leave@example.com> + <mailto:_xtest-leave@example.com> ---end--- There are some circumstances when the list administrator wants to explicitly -set the List-ID header. Start by creating a new domain. +set the ``List-ID`` header. Start by creating a new domain. +:: >>> from mailman.interfaces.domain import IDomainManager >>> from zope.component import getUtility @@ -211,7 +213,7 @@ set the List-ID header. Start by creating a new domain. >>> mlist.host_name = 'example.com' >>> mlist.list_id = '_xtest.example.com' -Any existing List-ID headers are removed from the original message. +Any existing ``List-ID`` headers are removed from the original message. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -235,14 +237,14 @@ Administrative messages crafted by Mailman will have a reduced set of headers. List-Help: <mailto:_xtest-request@example.com?subject=help> List-Id: My test mailing list <_xtest.example.com> List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-join@example.com> + <mailto:_xtest-join@example.com> List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-leave@example.com> + <mailto:_xtest-leave@example.com> X-List-Administrivia: yes ---end--- -With the normal set of List-* headers, it's still possible to suppress the -List-Post header, which is reasonable for an announce only mailing list. +With the normal set of ``List-*`` headers, it's still possible to suppress the +``List-Post`` header, which is reasonable for an announce only mailing list. >>> mlist.include_list_post_header = False >>> msg = message_from_string("""\ @@ -256,13 +258,13 @@ List-Post header, which is reasonable for an announce only mailing list. List-Help: <mailto:_xtest-request@example.com?subject=help> List-Id: My test mailing list <_xtest.example.com> List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-join@example.com> + <mailto:_xtest-join@example.com> List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-leave@example.com> + <mailto:_xtest-leave@example.com> ---end--- And if the list isn't being archived, it makes no sense to add the -List-Archive header either. +``List-Archive`` header either. >>> mlist.include_list_post_header = True >>> mlist.archive = False @@ -277,16 +279,16 @@ List-Archive header either. List-Id: My test mailing list <_xtest.example.com> List-Post: <mailto:_xtest@example.com> List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-join@example.com> + <mailto:_xtest-join@example.com> List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-leave@example.com> + <mailto:_xtest-leave@example.com> ---end--- Archived-At =========== -RFC 5064 (draft) defines a new Archived-At header which contains the url to +RFC 5064 (draft) defines a new ``Archived-At`` header which contains the url to the individual message in the archives. The stock Pipermail archiver doesn't support this because the url can't be calculated until after the message is archived. Because this is done by the archive runner, this information isn't @@ -299,7 +301,7 @@ available to us now. Personalization =============== -The To field normally contains the list posting address. However when +The ``To`` field normally contains the list posting address. However when messages are fully personalized, that header will get overwritten with the address of the recipient. The list's posting address will be added to one of the recipient headers so that users will be able to reply back to the list. @@ -321,10 +323,10 @@ the recipient headers so that users will be able to reply back to the list. Cc: My test mailing list <_xtest@example.com> List-Id: My test mailing list <_xtest.example.com> List-Unsubscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-leave@example.com> + <mailto:_xtest-leave@example.com> List-Post: <mailto:_xtest@example.com> List-Help: <mailto:_xtest-request@example.com?subject=help> List-Subscribe: <http://lists.example.com/listinfo/_xtest@example.com>, - <mailto:_xtest-join@example.com> + <mailto:_xtest-join@example.com> <BLANKLINE> <BLANKLINE> diff --git a/src/mailman/pipeline/docs/decorate.txt b/src/mailman/pipeline/docs/decorate.txt index e5b3db8d8..1c94cff1e 100644 --- a/src/mailman/pipeline/docs/decorate.txt +++ b/src/mailman/pipeline/docs/decorate.txt @@ -20,6 +20,7 @@ Short circuiting Digest messages get decorated during the digest creation phase so no extra decorations are added for digest messages. +:: >>> from mailman.pipeline.decorate import process >>> process(mlist, msg, dict(isdigest=True)) @@ -39,7 +40,7 @@ Decorating simple text messages =============================== Text messages that have no declared content type character set are by default, -encoded in us-ascii. When the mailing list's preferred language is 'en' +encoded in us-ascii. When the mailing list's preferred language is ``en`` (i.e. English), the character set of the mailing list and of the message will match. In this case, and when the header and footer have no interpolation placeholder variables, the message's payload will be prepended by the verbatim @@ -60,7 +61,7 @@ header, and appended with the verbatim footer. Mailman supports a number of interpolation variables, placeholders in the header and footer for information to be filled in with mailing list specific -data. An example of such information is the mailing list's "real name" (a +data. An example of such information is the mailing list's `real name` (a short descriptive name for the mailing list). >>> msg = message_from_string(msg_text) @@ -95,9 +96,9 @@ Handling RFC 3676 'format=flowed' parameters RFC 3676 describes a standard by which text/plain messages can marked by generating MUAs for better readability in compatible receiving MUAs. The -'format' parameter on the text/plain Content-Type header gives hints as to how -the receiving MUA may flow and delete trailing whitespace for better display -in a proportional font. +``format`` parameter on the text/plain ``Content-Type`` header gives hints as +to how the receiving MUA may flow and delete trailing whitespace for better +display in a proportional font. When Mailman sees text/plain messages with such RFC 3676 parameters, it preserves these parameters when it concatenates headers and footers to the @@ -130,6 +131,7 @@ When a message has no explicit character set, it is assumed to be us-ascii. However, if the mailing list's preferred language has a different character set, Mailman will still try to concatenate the header and footer, but it will convert the text to utf-8 and base-64 encode the message payload. +:: # 'ja' = Japanese; charset = 'euc-jp' >>> mlist.preferred_language = 'ja' @@ -205,9 +207,9 @@ cannot be simply concatenated into the payload because that will break the MIME structure of the message. Instead, the header and footer are attached as separate MIME subparts. -When the outerpart is multipart/mixed, the header and footer can have a -Content-Disposition of 'inline' so that MUAs can display these headers as if -they were simply concatenated. +When the outer part is ``multipart/mixed``, the header and footer can have a +``Content-Disposition`` of ``inline`` so that MUAs can display these headers +as if they were simply concatenated. >>> mlist.preferred_language = 'en' >>> mlist.msg_header = 'header' @@ -260,8 +262,8 @@ they were simply concatenated. Decorating other content types ============================== -Non-multipart non-text content types will get wrapped in a multipart/mixed so -that the header and footer can be added as attachments. +Non-multipart non-text content types will get wrapped in a ``multipart/mixed`` +so that the header and footer can be added as attachments. >>> msg = message_from_string("""\ ... From: aperson@example.org diff --git a/src/mailman/pipeline/docs/digests.txt b/src/mailman/pipeline/docs/digests.txt index 14d2a5636..b0a44ec5b 100644 --- a/src/mailman/pipeline/docs/digests.txt +++ b/src/mailman/pipeline/docs/digests.txt @@ -12,6 +12,7 @@ digests, although only two are currently supported: MIME digests and RFC 1153 This is a helper function used to iterate through all the accumulated digest messages, in the order in which they were posted. This makes it easier to update the tests when we switch to a different mailbox format. +:: >>> from mailman.testing.helpers import digest_mbox >>> from itertools import count diff --git a/src/mailman/pipeline/docs/file-recips.txt b/src/mailman/pipeline/docs/file-recips.txt index 3180df1fb..c994f820e 100644 --- a/src/mailman/pipeline/docs/file-recips.txt +++ b/src/mailman/pipeline/docs/file-recips.txt @@ -3,8 +3,8 @@ File recipients =============== Mailman can calculate the recipients for a message from a Sendmail-style -include file. This file must be called members.txt and it must live in the -list's data directory. +include file. This file must be called ``members.txt`` and it must live in +the list's data directory. >>> mlist = create_list('_xtest@example.com') @@ -14,6 +14,7 @@ Short circuiting If the message's metadata already has recipients, this handler immediately returns. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -29,15 +30,15 @@ returns. <BLANKLINE> A message. <BLANKLINE> - >>> msgdata - {u'recipients': 7} + >>> dump_msgdata(msgdata) + recipients: 7 Missing file ============ The include file must live inside the list's data directory, under the name -members.txt. If the file doesn't exist, the list of recipients will be +``members.txt``. If the file doesn't exist, the list of recipients will be empty. >>> import os @@ -49,8 +50,8 @@ empty. No such file or directory: u'.../_xtest@example.com/members.txt' >>> msgdata = {} >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recipients']) - [] + >>> dump_list(msgdata['recipients']) + *Empty* Existing file @@ -58,6 +59,7 @@ Existing file If the file exists, it contains a list of addresses, one per line. These addresses are returned as the set of recipients. +:: >>> fp = open(file_path, 'w') >>> try: @@ -72,13 +74,18 @@ addresses are returned as the set of recipients. >>> msgdata = {} >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recipients']) - ['bperson@example.com', 'cperson@example.com', 'dperson@example.com', - 'eperson@example.com', 'fperson@example.com', 'gperson@example.com'] + >>> dump_list(msgdata['recipients']) + bperson@example.com + cperson@example.com + dperson@example.com + eperson@example.com + fperson@example.com + gperson@example.com However, if the sender of the original message is a member of the list and -their address is in the include file, the sender's address is /not/ included +their address is in the include file, the sender's address is *not* included in the recipients list. +:: >>> from mailman.interfaces.usermanager import IUserManager >>> from zope.component import getUtility @@ -96,6 +103,9 @@ in the recipients list. ... """) >>> msgdata = {} >>> handler.process(mlist, msg, msgdata) - >>> sorted(msgdata['recipients']) - ['bperson@example.com', 'dperson@example.com', - 'eperson@example.com', 'fperson@example.com', 'gperson@example.com'] + >>> dump_list(msgdata['recipients']) + bperson@example.com + dperson@example.com + eperson@example.com + fperson@example.com + gperson@example.com diff --git a/src/mailman/pipeline/docs/filtering.txt b/src/mailman/pipeline/docs/filtering.txt index 241f282d9..5b54424e4 100644 --- a/src/mailman/pipeline/docs/filtering.txt +++ b/src/mailman/pipeline/docs/filtering.txt @@ -11,8 +11,8 @@ message. Several mailing list options control content filtering. First, the feature must be enabled, then there are two options that control which MIME types get filtered and which get passed. Finally, there is an option to control whether -text/html parts will get converted to plain text. Let's set up some defaults -for these variables, then we'll explain them in more detail below. +``text/html`` parts will get converted to plain text. Let's set up some +defaults for these variables, then we'll explain them in more detail below. >>> mlist.filter_content = True >>> mlist.filter_types = [] @@ -26,6 +26,7 @@ Filtering the outer content type A simple filtering setting will just search the content types of the messages parts, discarding all parts with a matching MIME type. If the message's outer content type matches the filter, the entire message will be discarded. +:: >>> from mailman.interfaces.mime import FilterAction @@ -82,6 +83,7 @@ Simple multipart filtering If one of the subparts in a multipart message matches the filter type, then just that subpart will be stripped. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -121,18 +123,18 @@ just that subpart will be stripped. Collapsing multipart/alternative messages ========================================= -When content filtering encounters a multipart/alternative part, and the +When content filtering encounters a ``multipart/alternative`` part, and the results of filtering leave only one of the subparts, then the -multipart/alternative may be collapsed. For example, in the following -message, the outer content type is a multipart/mixed. Inside this part is -just a single subpart that has a content type of multipart/alternative. This -inner multipart has two subparts, a jpeg and a gif. +``multipart/alternative`` may be collapsed. For example, in the following +message, the outer content type is a ``multipart/mixed``. Inside this part is +just a single subpart that has a content type of ``multipart/alternative``. +This inner multipart has two subparts, a jpeg and a gif. -Content filtering will remove the jpeg part, leaving the multipart/alternative -with only a single gif subpart. Because there's only one subpart left, the -MIME structure of the message will be reorganized, removing the inner -multipart/alternative so that the outer multipart/mixed has just a single gif -subpart. +Content filtering will remove the jpeg part, leaving the +``multipart/alternative`` with only a single gif subpart. Because there's +only one subpart left, the MIME structure of the message will be reorganized, +removing the inner ``multipart/alternative`` so that the outer +``multipart/mixed`` has just a single gif subpart. >>> mlist.collapse_alternatives = True >>> msg = message_from_string("""\ @@ -174,10 +176,11 @@ subpart. --BOUNDARY-- <BLANKLINE> -When the outer part is a multipart/alternative and filtering leaves this outer -part with just one subpart, the entire message is converted to the left over -part's content type. In other words, the left over inner part is promoted to -being the outer part. +When the outer part is a ``multipart/alternative`` and filtering leaves this +outer part with just one subpart, the entire message is converted to the left +over part's content type. In other words, the left over inner part is +promoted to being the outer part. +:: >>> mlist.filter_types = ['image/jpeg', 'text/html'] >>> msg = message_from_string("""\ @@ -214,16 +217,16 @@ Conversion to plain text Many mailing lists prohibit HTML email, and in fact, such email can be a phishing or spam vector. However, many mail readers will send HTML email by default because users think it looks pretty. One approach to handling this -would be to filter out text/html parts and rely on multipart/alternative -collapsing to leave just a plain text part. This works because many mail -readers that send HTML email actually send a plain text part in the second -subpart of such multipart/alternatives. +would be to filter out ``text/html`` parts and rely on +``multipart/alternative`` collapsing to leave just a plain text part. This +works because many mail readers that send HTML email actually send a plain +text part in the second subpart of such ``multipart/alternatives``. While this is a good suggestion for plain text-only mailing lists, often a -mail reader will send only a text/html part with no plain text alternative. -in this case, the site administer can enable text/html to text/plain -conversion by defining a conversion command. A list administrator still needs -to enable such conversion for their list though. +mail reader will send only a ``text/html`` part with no plain text +alternative. in this case, the site administer can enable ``text/html`` to +``text/plain`` conversion by defining a conversion command. A list +administrator still needs to enable such conversion for their list though. >>> mlist.convert_html_to_plaintext = True @@ -270,8 +273,8 @@ Discarding empty parts Similarly, if after filtering a multipart section ends up empty, then the entire multipart is discarded. For example, here's a message where an inner -multipart/mixed contains two jpeg subparts. Both jpegs are filtered out, so -the entire inner multipart/mixed is discarded. +``multipart/mixed`` contains two jpeg subparts. Both jpegs are filtered out, +so the entire inner ``multipart/mixed`` is discarded. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -341,4 +344,4 @@ Passing MIME types ================== XXX Describe the pass_mime_types setting and how it interacts with -filter_mime_types. +``filter_mime_types``. diff --git a/src/mailman/pipeline/docs/nntp.txt b/src/mailman/pipeline/docs/nntp.txt index 5c859c0ae..874712397 100644 --- a/src/mailman/pipeline/docs/nntp.txt +++ b/src/mailman/pipeline/docs/nntp.txt @@ -9,12 +9,13 @@ NNTP is to Usenet as IP is to the web, it's more general than that. >>> mlist = create_list('_xtest@example.com') Gatewaying from the mailing list to the newsgroup happens through a separate -'nntp' queue and happen immediately when the message is posted through to the -list. Note that gatewaying from the newsgroup to the list happens via a -cronjob (currently not shown). +``nntp`` queue and happen immediately when the message is posted through to +the list. Note that gatewaying from the newsgroup to the list happens via a +cronjob (currently not shown). There are several situations which prevent a message from being gatewayed to the newsgroup. The feature could be disabled, as is the default. +:: >>> mlist.gateway_to_news = False >>> msg = message_from_string("""\ diff --git a/src/mailman/pipeline/docs/reply-to.txt b/src/mailman/pipeline/docs/reply-to.txt index 841320f7f..e08fea81d 100644 --- a/src/mailman/pipeline/docs/reply-to.txt +++ b/src/mailman/pipeline/docs/reply-to.txt @@ -2,7 +2,7 @@ Reply-to munging ================ -Messages that flow through the global pipeline get their headers 'cooked', +Messages that flow through the global pipeline get their headers *cooked*, which basically means that their headers go through several mostly unrelated transformations. Some headers get added, others get changed. Some of these changes depend on mailing list settings and others depend on how the message @@ -10,20 +10,21 @@ is getting sent through the system. We'll take things one-by-one. >>> mlist = create_list('_xtest@example.com') -Reply-to munging refers to the behavior where a mailing list can be configured -to change or augment an existing Reply-To header in a message posted to the -list. Reply-to munging is fairly controversial, with arguments made either -for or against munging. +*Reply-to munging* refers to the behavior where a mailing list can be +configured to change or augment an existing ``Reply-To`` header in a message +posted to the list. Reply-to munging is fairly controversial, with arguments +made either for or against munging. The Mailman developers, and I believe the majority consensus is to do no -Reply-to munging, under several principles. Primarily, most reply-to munging -is requested by people who do not have both a Reply and Reply All button on -their mail reader. If you do not munge Reply-To, then these buttons will work -properly, but if you munge the header, it is impossible for these buttons to -work right, because both will reply to the list. This leads to unfortunate -accidents where a private message is accidentally posted to the entire list. +reply-to munging, under several principles. Primarily, most reply-to munging +is requested by people who do not have both a `Reply` and `Reply All` button +on their mail reader. If you do not munge ``Reply-To``, then these buttons +will work properly, but if you munge the header, it is impossible for these +buttons to work right, because both will reply to the list. This leads to +unfortunate accidents where a private message is accidentally posted to the +entire list. -However, Mailman gives list owners the option to do Reply-To munging anyway, +However, Mailman gives list owners the option to do reply-To munging anyway, mostly as a way to shut up the really vocal minority who seem to insist on this mis-feature. @@ -31,9 +32,10 @@ this mis-feature. Reply to list ============= -A list can be configured to add a Reply-To header pointing back to the mailing -list's posting address. If there's no Reply-To header in the original -message, the list's posting address simply gets inserted. +A list can be configured to add a ``Reply-To`` header pointing back to the +mailing list's posting address. If there's no ``Reply-To`` header in the +original message, the list's posting address simply gets inserted. +:: >>> from mailman.interfaces.mailinglist import ReplyToMunging >>> mlist.reply_goes_to_list = ReplyToMunging.point_to_list @@ -51,8 +53,8 @@ message, the list's posting address simply gets inserted. >>> print msg['reply-to'] _xtest@example.com -It's also possible to strip any existing Reply-To header first, before adding -the list's posting address. +It's also possible to strip any existing ``Reply-To`` header first, before +adding the list's posting address. >>> mlist.first_strip_reply_to = True >>> msg = message_from_string("""\ @@ -85,7 +87,7 @@ get appended to whatever the original version was. Explicit Reply-To ================= -The list can also be configured to have an explicit Reply-To header. +The list can also be configured to have an explicit ``Reply-To`` header. >>> mlist.reply_goes_to_list = ReplyToMunging.explicit_header >>> mlist.reply_to_address = 'my-list@example.com' @@ -99,7 +101,8 @@ The list can also be configured to have an explicit Reply-To header. >>> print msg['reply-to'] my-list@example.com -And as before, it's possible to either strip any existing Reply-To header... +And as before, it's possible to either strip any existing ``Reply-To`` +header... >>> mlist.first_strip_reply_to = True >>> msg = message_from_string("""\ diff --git a/src/mailman/pipeline/docs/replybot.txt b/src/mailman/pipeline/docs/replybot.txt index f02b90254..3a6d75499 100644 --- a/src/mailman/pipeline/docs/replybot.txt +++ b/src/mailman/pipeline/docs/replybot.txt @@ -2,7 +2,7 @@ Automatic response handler ========================== -Mailman has a replybot handler that sends automatic responses to messages it +Mailman has a autoreply handler that sends automatic responses to messages it receives on its posting address, owner address, or robot address. Automatic responses are subject to various conditions, such as headers in the original message or the amount of time since the last auto-response. @@ -15,10 +15,11 @@ Basic automatic responding ========================== Basic automatic responding occurs when the list is set up to respond to either -its -owner address, its -request address, or to the posting address, and a -message is sent to one of these addresses. A mailing list also has an +its ``-owner`` address, its ``-request`` address, or to the posting address, +and a message is sent to one of these addresses. A mailing list also has an automatic response grace period which specifies how much time must pass before a second response will be sent, with 0 meaning "there is no grace period". +:: >>> import datetime >>> from mailman.interfaces.autorespond import ResponseAction @@ -36,6 +37,7 @@ a second response will be sent, with 0 meaning "there is no grace period". The preceding message to the mailing list's owner will trigger an automatic response. +:: >>> from mailman.testing.helpers import get_queue_messages @@ -73,8 +75,9 @@ Short circuiting ================ Several headers in the original message determine whether an automatic -response should even be sent. For example, if the message has an "X-Ack: No" -header, no auto-response is sent. +response should even be sent. For example, if the message has an +``X-Ack: No`` header, no auto-response is sent. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -88,7 +91,8 @@ header, no auto-response is sent. [] Mailman itself can suppress automatic responses for certain types of -internally crafted messages, by setting the 'noack' metadata key. +internally crafted messages, by setting the ``noack`` metadata key. +:: >>> msg = message_from_string("""\ ... From: mailman@example.com @@ -100,8 +104,9 @@ internally crafted messages, by setting the 'noack' metadata key. >>> get_queue_messages('virgin') [] -If there is a Precedence: header with any of the values 'bulk', 'junk', or -'list', then the automatic response is also suppressed. +If there is a ``Precedence:`` header with any of the values ``bulk``, +``junk``, or ``list``, then the automatic response is also suppressed. +:: >>> msg = message_from_string("""\ ... From: asystem@example.com @@ -124,8 +129,9 @@ If there is a Precedence: header with any of the values 'bulk', 'junk', or >>> get_queue_messages('virgin') [] -Unless the X-Ack: header has a value of "yes", in which case, the Precedence -header is ignored. +Unless the ``X-Ack:`` header has a value of ``yes``, in which case, the +``Precedence`` header is ignored. +:: >>> msg['X-Ack'] = 'yes' >>> handler.process(mlist, msg, dict(to_owner=True)) @@ -160,9 +166,10 @@ header is ignored. Available auto-responses ======================== -As shown above, a message sent to the -owner address will get an auto-response -with the text set for owner responses. Two other types of email will get -auto-responses: those sent to the -request address... +As shown above, a message sent to the ``-owner`` address will get an +auto-response with the text set for owner responses. Two other types of email +will get auto-responses: those sent to the ``-request`` address... +:: >>> mlist.autorespond_requests = ResponseAction.respond_and_continue >>> mlist.autoresponse_request_text = 'robot autoresponse text' @@ -195,6 +202,7 @@ auto-responses: those sent to the -request address... robot autoresponse text ...and those sent to the posting address. +:: >>> mlist.autorespond_postings = ResponseAction.respond_and_continue >>> mlist.autoresponse_postings_text = 'postings autoresponse text' @@ -261,6 +269,7 @@ right now. 0 Fast forward 9 days and you still don't get a response. +:: >>> from mailman.utilities.datetime import factory >>> factory.fast_forward(days=9) @@ -278,6 +287,7 @@ But tomorrow, the sender will get a new auto-response. Of course, everything works the same way for messages to the request address, even if the sender is the same person... +:: >>> msg = message_from_string("""\ ... From: bperson@example.com @@ -305,6 +315,7 @@ address, even if the sender is the same person... 1 ...and for messages to the posting address. +:: >>> msg = message_from_string("""\ ... From: bperson@example.com diff --git a/src/mailman/pipeline/docs/scrubber.txt b/src/mailman/pipeline/docs/scrubber.txt index 9c019d6e4..86a8161a7 100644 --- a/src/mailman/pipeline/docs/scrubber.txt +++ b/src/mailman/pipeline/docs/scrubber.txt @@ -11,6 +11,7 @@ archive message. >>> mlist.preferred_language = 'en' Helper functions for getting the attachment data. +:: >>> import os, re >>> def read_attachment(filename, remove=True): @@ -40,15 +41,16 @@ Helper functions for getting the attachment data. Saving attachments ================== -The Scrubber handler exposes a function called save_attachment() which can be -used to strip various types of attachments and store them in the archive +The Scrubber handler exposes a function called ``save_attachment()`` which can +be used to strip various types of attachments and store them in the archive directory. This is a public interface used by components outside the normal processing pipeline. Site administrators can decide whether the scrubber should use the attachment -filename suggested in the message's Content-Disposition: header or not. If -enabled, the filename will be used when this header attribute is present (yes, -this is an unfortunate double negative). +filename suggested in the message's ``Content-Disposition:`` header or not. +If enabled, the filename will be used when this header attribute is present +(yes, this is an unfortunate double negative). +:: >>> config.push('test config', """ ... [scrubber] @@ -81,7 +83,7 @@ Saving the attachment does not alter the original message. R0lGODdhAQABAIAAAAAAAAAAACwAAAAAAQABAAACAQUAOw== The site administrator can also configure Mailman to ignore the -Content-Disposition: filename. This is the default. +``Content-Disposition:`` filename. This is the default. >>> config.pop('test config') >>> config.push('test config', """ @@ -132,7 +134,7 @@ a reference to the attachment file as available through the on-line archive. ... """) >>> msgdata = {} -The Scrubber.process() function is different than other handler process +The ``Scrubber.process()`` function is different than other handler process functions in that it returns the scrubbed message. >>> from mailman.pipeline.scrubber import process diff --git a/src/mailman/pipeline/docs/subject-munging.txt b/src/mailman/pipeline/docs/subject-munging.txt index 20f3b04ed..e7a6553ce 100644 --- a/src/mailman/pipeline/docs/subject-munging.txt +++ b/src/mailman/pipeline/docs/subject-munging.txt @@ -2,7 +2,7 @@ Subject munging =============== -Messages that flow through the global pipeline get their headers 'cooked', +Messages that flow through the global pipeline get their headers *cooked*, which basically means that their headers go through several mostly unrelated transformations. Some headers get added, others get changed. Some of these changes depend on mailing list settings and others depend on how the message @@ -14,10 +14,11 @@ is getting sent through the system. We'll take things one-by-one. Inserting a prefix ================== -Another thing CookHeaders does is 'munge' the Subject header by inserting the -subject prefix for the list at the front. If there's no subject header in the -original message, Mailman uses a canned default. In order to do subject -munging, a mailing list must have a preferred language. +Another thing header cooking does is *munge* the ``Subject`` header by +inserting the subject prefix for the list at the front. If there's no subject +header in the original message, Mailman uses a canned default. In order to do +subject munging, a mailing list must have a preferred language. +:: >>> mlist.subject_prefix = '[XTest] ' >>> mlist.preferred_language = 'en' @@ -32,16 +33,16 @@ munging, a mailing list must have a preferred language. >>> process(mlist, msg, msgdata) The original subject header is stored in the message metadata. We must print -the new Subject header because it gets converted from a string to an -email.header.Header instance which has an unhelpful repr. +the new ``Subject`` header because it gets converted from a string to an +``email.header.Header`` instance which has an unhelpful ``repr``. >>> msgdata['origsubj'] u'' >>> print msg['subject'] [XTest] (no subject) -If the original message had a Subject header, then the prefix is inserted at -the beginning of the header's value. +If the original message had a ``Subject`` header, then the prefix is inserted +at the beginning of the header's value. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -56,7 +57,7 @@ the beginning of the header's value. >>> print msg['subject'] [XTest] Something important -Subject headers are not munged for digest messages. +``Subject`` headers are not munged for digest messages. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -68,7 +69,7 @@ Subject headers are not munged for digest messages. >>> print msg['subject'] Something important -Nor are they munged for 'fast tracked' messages, which are generally defined +Nor are they munged for *fast tracked* messages, which are generally defined as messages that Mailman crafts internally. >>> msg = message_from_string("""\ @@ -81,9 +82,9 @@ as messages that Mailman crafts internally. >>> print msg['subject'] Something important -If a Subject header already has a prefix, usually following a Re: marker, -another one will not be added but the prefix will be moved to the front of the -header text. +If a ``Subject`` header already has a prefix, usually following a ``Re:`` +marker, another one will not be added but the prefix will be moved to the +front of the header text. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -95,8 +96,8 @@ header text. >>> print msg['subject'] [XTest] Re: Something important -If the Subjec header has a prefix at the front of the header text, that's -where it will stay. This is called 'new style' prefixing and is the only +If the ``Subject`` header has a prefix at the front of the header text, that's +where it will stay. This is called *new style* prefixing and is the only option available in Mailman 3. >>> msg = message_from_string("""\ @@ -133,8 +134,8 @@ Prefix numbers ============== Subject prefixes support a placeholder for the numeric post id. Every time a -message is posted to the mailing list, a 'post id' gets incremented. This is -a purely sequential integer that increases monotonically. By added a '%d' +message is posted to the mailing list, a *post id* gets incremented. This is +a purely sequential integer that increases monotonically. By added a ``%d`` placeholder to the subject prefix, this post id can be included in the prefix. >>> mlist.subject_prefix = '[XTest %d] ' @@ -159,7 +160,7 @@ id. >>> print msg['subject'] [XTest 456] Re: Something important -If the Subject header had old style prefixing, the prefix is moved to the +If the ``Subject`` header had old style prefixing, the prefix is moved to the front of the header text. >>> msg = message_from_string("""\ @@ -172,7 +173,7 @@ front of the header text. And of course, the proper thing is done when posting id numbers are included -in the subject prefix, and the subject is encoded non-ascii. +in the subject prefix, and the subject is encoded non-ASCII. >>> msg = message_from_string("""\ ... Subject: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?= @@ -184,8 +185,8 @@ in the subject prefix, and the subject is encoded non-ascii. >>> unicode(msg['subject']) u'[XTest 456] \u30e1\u30fc\u30eb\u30de\u30f3' -Even more fun is when the i18n Subject header already has a prefix, possibly -with a different posting number. +Even more fun is when the internationalized ``Subject`` header already has a +prefix, possibly with a different posting number. >>> msg = message_from_string("""\ ... Subject: [XTest 123] Re: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?= @@ -195,9 +196,10 @@ with a different posting number. >>> print msg['subject'] [XTest 456] Re: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?= -# XXX This requires Python email patch #1681333 to succeed. -# >>> unicode(msg['subject']) -# u'[XTest 456] Re: \u30e1\u30fc\u30eb\u30de\u30f3' +.. + # XXX This requires Python email patch #1681333 to succeed. + # >>> unicode(msg['subject']) + # u'[XTest 456] Re: \u30e1\u30fc\u30eb\u30de\u30f3' As before, old style subject prefixes are re-ordered. @@ -210,13 +212,14 @@ As before, old style subject prefixes are re-ordered. [XTest 456] Re: =?iso-2022-jp?b?GyRCJWEhPCVrJV4lcxsoQg==?= -# XXX This requires Python email patch #1681333 to succeed. -# >>> unicode(msg['subject']) -# u'[XTest 456] Re: \u30e1\u30fc\u30eb\u30de\u30f3' +.. + # XXX This requires Python email patch #1681333 to succeed. + # >>> unicode(msg['subject']) + # u'[XTest 456] Re: \u30e1\u30fc\u30eb\u30de\u30f3' In this test case, we get an extra space between the prefix and the original -subject. It's because the original is 'crooked'. Note that a Subject +subject. It's because the original is *crooked*. Note that a ``Subject`` starting with '\n ' is generated by some version of Eudora Japanese edition. >>> mlist.subject_prefix = '[XTest] ' @@ -238,8 +241,9 @@ And again, with an RFC 2047 encoded header. ... """) >>> process(mlist, msg, {}) -# XXX This one does not appear to work the same way as -# test_subject_munging_prefix_crooked() in the old Python-based tests. I need -# to get Tokio to look at this. -# >>> print msg['subject'] -# [XTest] =?iso-2022-jp?b?IBskQiVhITwlayVeJXMbKEI=?= +.. + # XXX This one does not appear to work the same way as + # test_subject_munging_prefix_crooked() in the old Python-based tests. I need + # to get Tokio to look at this. + # >>> print msg['subject'] + # [XTest] =?iso-2022-jp?b?IBskQiVhITwlayVeJXMbKEI=?= diff --git a/src/mailman/pipeline/docs/tagger.txt b/src/mailman/pipeline/docs/tagger.txt index ea3355f1a..80e682119 100644 --- a/src/mailman/pipeline/docs/tagger.txt +++ b/src/mailman/pipeline/docs/tagger.txt @@ -5,14 +5,15 @@ Message tagger Mailman has a topics system which works like this: a mailing list administrator sets up one or more topics, which is essentially a named regular expression. The topic name can be any arbitrary string, and the name serves -double duty as the 'topic tag'. Each message that flows the mailing list has -its Subject: and Keywords: headers compared against these regular +double duty as the *topic tag*. Each message that flows the mailing list has +its ``Subject:`` and ``Keywords:`` headers compared against these regular expressions. The message then gets tagged with the topic names of each hit. >>> mlist = create_list('_xtest@example.com') Topics must be enabled for Mailman to do any topic matching, even if topics are defined. +:: >>> mlist.topics = [('bar fight', '.*bar.*', 'catch any bars', False)] >>> mlist.topics_enabled = False @@ -36,8 +37,8 @@ are defined. {} However, once topics are enabled, message will be tagged. There are two -artifacts of tagging; an X-Topics: header is added with the topic name, and -the message metadata gets a key with a list of matching topic names. +artifacts of tagging; an ``X-Topics:`` header is added with the topic name, +and the message metadata gets a key with a list of matching topic names. >>> mlist.topics_enabled = True >>> msg = message_from_string("""\ @@ -61,8 +62,8 @@ Scanning body lines =================== The tagger can also look at a certain number of body lines, but only for -Subject: and Keyword: header-like lines. When set to zero, no body lines are -scanned. +``Subject:`` and ``Keyword:`` header-like lines. When set to zero, no body +lines are scanned. >>> msg = message_from_string("""\ ... From: aperson@example.com diff --git a/src/mailman/pipeline/docs/to-outgoing.txt b/src/mailman/pipeline/docs/to-outgoing.txt index c7adca444..816aa4ca6 100644 --- a/src/mailman/pipeline/docs/to-outgoing.txt +++ b/src/mailman/pipeline/docs/to-outgoing.txt @@ -10,6 +10,7 @@ message into the outgoing queue. Craft a message destined for the outgoing queue. Include some random metadata as if this message had passed through some other handlers. +:: >>> msg = message_from_string("""\ ... Subject: Here is a message diff --git a/src/mailman/pipeline/file_recipients.py b/src/mailman/pipeline/file_recipients.py index bd163a283..5cc7c7914 100644 --- a/src/mailman/pipeline/file_recipients.py +++ b/src/mailman/pipeline/file_recipients.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -61,5 +61,5 @@ class FileRecipients: # recipients. member = mlist.members.get_member(msg.sender) if member is not None: - addrs.discard(member.address.address) + addrs.discard(member.address.email) msgdata['recipients'] = addrs diff --git a/src/mailman/pipeline/mime_delete.py b/src/mailman/pipeline/mime_delete.py index 515888001..3066f4003 100644 --- a/src/mailman/pipeline/mime_delete.py +++ b/src/mailman/pipeline/mime_delete.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2002-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -238,7 +238,7 @@ def dispose(mlist, msg, msgdata, why): mlist.ForwardMessage( msg, text=_("""\ -The attached message matched the %(listname)s mailing list's content filtering +The attached message matched the $listname mailing list's content filtering rules and was prevented from being forwarded on to the list membership. You are receiving the only remaining copy of the discarded message. diff --git a/src/mailman/pipeline/moderate.py b/src/mailman/pipeline/moderate.py deleted file mode 100644 index a2acf4714..000000000 --- a/src/mailman/pipeline/moderate.py +++ /dev/null @@ -1,173 +0,0 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. -# -# This file is part of GNU Mailman. -# -# GNU Mailman 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 3 of the License, or (at your option) -# any later version. -# -# GNU Mailman 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 -# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. - -"""Posting moderation filter.""" - -from __future__ import absolute_import, unicode_literals - -__metaclass__ = type -__all__ = [ - 'process', - ] - - -import re - -from email.MIMEMessage import MIMEMessage -from email.MIMEText import MIMEText - -from mailman.Utils import wrap -from mailman.config import config -from mailman.core import errors -from mailman.core.i18n import _ -from mailman.email.message import UserNotification - - - -## class ModeratedMemberPost(Hold.ModeratedPost): -## # BAW: I wanted to use the reason below to differentiate between this -## # situation and normal ModeratedPost reasons. Greg Ward and Stonewall -## # Ballard thought the language was too harsh and mentioned offense taken -## # by some list members. I'd still like this class's reason to be -## # different than the base class's reason, but we'll use this until -## # someone can come up with something more clever but inoffensive. -## # -## # reason = _('Posts by member are currently quarantined for moderation') -## pass - - - -def process(mlist, msg, msgdata): - if msgdata.get('approved') or msgdata.get('fromusenet'): - return - # First of all, is the poster a member or not? - for sender in msg.senders: - if mlist.isMember(sender): - break - else: - sender = None - if sender: - # If the member's moderation flag is on, then perform the moderation - # action. - if mlist.getMemberOption(sender, config.Moderate): - # Note that for member_moderation_action, 0==Hold, 1=Reject, - # 2==Discard - if mlist.member_moderation_action == 0: - # Hold. BAW: WIBNI we could add the member_moderation_notice - # to the notice sent back to the sender? - msgdata['sender'] = sender - Hold.hold_for_approval(mlist, msg, msgdata, - ModeratedMemberPost) - elif mlist.member_moderation_action == 1: - # Reject - text = mlist.member_moderation_notice - if text: - text = Utils.wrap(text) - else: - # Use the default RejectMessage notice string - text = None - raise errors.RejectMessage, text - elif mlist.member_moderation_action == 2: - # Discard. BAW: Again, it would be nice if we could send a - # discard notice to the sender - raise errors.DiscardMessage - else: - assert 0, 'bad member_moderation_action' - # Should we do anything explict to mark this message as getting past - # this point? No, because further pipeline handlers will need to do - # their own thing. - return - else: - sender = msg.sender - # From here on out, we're dealing with non-members. - if matches_p(sender, mlist.accept_these_nonmembers): - return - if matches_p(sender, mlist.hold_these_nonmembers): - Hold.hold_for_approval(mlist, msg, msgdata, Hold.NonMemberPost) - # No return - if matches_p(sender, mlist.reject_these_nonmembers): - do_reject(mlist) - # No return - if matches_p(sender, mlist.discard_these_nonmembers): - do_discard(mlist, msg) - # No return - # Okay, so the sender wasn't specified explicitly by any of the non-member - # moderation configuration variables. Handle by way of generic non-member - # action. - assert 0 <= mlist.generic_nonmember_action <= 4 - if mlist.generic_nonmember_action == 0: - # Accept - return - elif mlist.generic_nonmember_action == 1: - Hold.hold_for_approval(mlist, msg, msgdata, Hold.NonMemberPost) - elif mlist.generic_nonmember_action == 2: - do_reject(mlist) - elif mlist.generic_nonmember_action == 3: - do_discard(mlist, msg) - - - -def matches_p(sender, nonmembers): - # First strip out all the regular expressions. - addresses = set(address.lower() for address in nonmembers - if not address.startswith('^')) - if sender in addresses: - return True - # Now do the regular expression matches. - for regexp in nonmembers: - if regexp.startswith('^'): - try: - cre = re.compile(regexp, re.IGNORECASE) - except re.error: - continue - if cre.search(sender): - return True - return False - - - -def do_reject(mlist): - listowner = mlist.GetOwnerEmail() - if mlist.nonmember_rejection_notice: - raise errors.RejectMessage, \ - Utils.wrap(_(mlist.nonmember_rejection_notice)) - else: - raise errors.RejectMessage, Utils.wrap(_("""\ -You are not allowed to post to this mailing list, and your message has been -automatically rejected. If you think that your messages are being rejected in -error, contact the mailing list owner at %(listowner)s.""")) - - - -def do_discard(mlist, msg): - # Do we forward auto-discards to the list owners? - if mlist.forward_auto_discards: - varhelp = '%s/?VARHELP=privacy/sender/discard_these_nonmembers' % \ - mlist.GetScriptURL('admin', absolute=1) - nmsg = UserNotification(mlist.GetOwnerEmail(), - mlist.GetBouncesEmail(), - _('Auto-discard notification'), - lang=mlist.preferred_language) - nmsg.set_type('multipart/mixed') - text = MIMEText(Utils.wrap(_( - 'The attached message has been automatically discarded.')), - _charset=mlist.preferred_language.charset) - nmsg.attach(text) - nmsg.attach(MIMEMessage(msg)) - nmsg.send(mlist) - # Discard this sucker - raise errors.DiscardMessage diff --git a/src/mailman/pipeline/owner_recipients.py b/src/mailman/pipeline/owner_recipients.py index d4dbb278b..aa7948586 100644 --- a/src/mailman/pipeline/owner_recipients.py +++ b/src/mailman/pipeline/owner_recipients.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/replybot.py b/src/mailman/pipeline/replybot.py index a6df7e175..5f17160c1 100644 --- a/src/mailman/pipeline/replybot.py +++ b/src/mailman/pipeline/replybot.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/scrubber.py b/src/mailman/pipeline/scrubber.py index 1f425b54e..a102d9f6b 100644 --- a/src/mailman/pipeline/scrubber.py +++ b/src/mailman/pipeline/scrubber.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -27,18 +27,15 @@ __all__ = [ import os import re -import sys import time -import errno import hashlib import logging import binascii from email.charset import Charset -from email.generator import Generator from email.utils import make_msgid, parsedate +from flufl.lock import Lock from lazr.config import as_boolean -from locknix.lockfile import Lock from mimetypes import guess_all_extensions from string import Template from zope.interface import implements diff --git a/src/mailman/pipeline/tagger.py b/src/mailman/pipeline/tagger.py index 7971dea51..fbf6c0749 100644 --- a/src/mailman/pipeline/tagger.py +++ b/src/mailman/pipeline/tagger.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/to_archive.py b/src/mailman/pipeline/to_archive.py index be544f981..de6144a62 100644 --- a/src/mailman/pipeline/to_archive.py +++ b/src/mailman/pipeline/to_archive.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/to_digest.py b/src/mailman/pipeline/to_digest.py index 4690328ae..e4cce8a82 100644 --- a/src/mailman/pipeline/to_digest.py +++ b/src/mailman/pipeline/to_digest.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/to_outgoing.py b/src/mailman/pipeline/to_outgoing.py index 9a5d1e79a..ebb079a24 100644 --- a/src/mailman/pipeline/to_outgoing.py +++ b/src/mailman/pipeline/to_outgoing.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/pipeline/to_usenet.py b/src/mailman/pipeline/to_usenet.py index 14963a7b2..46ec21ed9 100644 --- a/src/mailman/pipeline/to_usenet.py +++ b/src/mailman/pipeline/to_usenet.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/queue/__init__.py b/src/mailman/queue/__init__.py index 4e8ba35e2..901e99cfc 100644 --- a/src/mailman/queue/__init__.py +++ b/src/mailman/queue/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -34,15 +34,12 @@ __all__ = [ import os -import sys import time import email -import errno import pickle import cPickle import hashlib import logging -import marshal import traceback from cStringIO import StringIO diff --git a/src/mailman/queue/archive.py b/src/mailman/queue/archive.py index 65c1e066e..24dab34f5 100644 --- a/src/mailman/queue/archive.py +++ b/src/mailman/queue/archive.py @@ -1,4 +1,4 @@ -# Copyright (C) 2000-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2000-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -24,14 +24,12 @@ __all__ = [ import os -import sys -import time import logging from datetime import datetime from email.Utils import parsedate_tz, mktime_tz, formatdate -from lazr.config import as_boolean, as_timedelta -from locknix.lockfile import Lock +from flufl.lock import Lock +from lazr.config import as_timedelta from mailman.config import config from mailman.queue import Runner diff --git a/src/mailman/queue/bounce.py b/src/mailman/queue/bounce.py index 5b705a5c7..a53b2d072 100644 --- a/src/mailman/queue/bounce.py +++ b/src/mailman/queue/bounce.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -26,10 +26,10 @@ import datetime from email.Utils import parseaddr from lazr.config import as_timedelta -from mailman.Bouncers import BouncerAPI from mailman.config import config from mailman.core.i18n import _ from mailman.email.utils import split_email +from mailman.interfaces.bounce import NonFatal from mailman.queue import Runner @@ -192,7 +192,7 @@ class BounceRunner(Runner, BounceMixin): addrs = verp_bounce(mlist, msg) if addrs: # We have an address, but check if the message is non-fatal. - if BouncerAPI.ScanMessages(mlist, msg) is BouncerAPI.Stop: + if scan_messages(mlist, msg) is NonFatal: return else: # See if this was a probe message. @@ -202,8 +202,8 @@ class BounceRunner(Runner, BounceMixin): return # That didn't give us anything useful, so try the old fashion # bounce matching modules. - addrs = BouncerAPI.ScanMessages(mlist, msg) - if addrs is BouncerAPI.Stop: + addrs = scan_messages(mlist, msg) + if addrs is NonFatal: # This is a recognized, non-fatal notice. Ignore it. return # If that still didn't return us any useful addresses, then send it on @@ -214,7 +214,7 @@ class BounceRunner(Runner, BounceMixin): maybe_forward(mlist, msg) return # BAW: It's possible that there are None's in the list of addresses, - # although I'm unsure how that could happen. Possibly ScanMessages() + # although I'm unsure how that could happen. Possibly scan_messages() # can let None's sneak through. In any event, this will kill them. addrs = filter(None, addrs) self._queue_bounces(mlist.fqdn_listname, addrs, msg) diff --git a/src/mailman/queue/command.py b/src/mailman/queue/command.py index ea86ca17a..ec3ec0089 100644 --- a/src/mailman/queue/command.py +++ b/src/mailman/queue/command.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -40,7 +40,7 @@ from zope.interface import implements from mailman.config import config from mailman.core.i18n import _ -from mailman.email.message import Message, UserNotification +from mailman.email.message import UserNotification from mailman.interfaces.command import ContinueProcessing, IEmailResults from mailman.interfaces.languages import ILanguageManager from mailman.queue import Runner diff --git a/src/mailman/queue/digest.py b/src/mailman/queue/digest.py index 0d1e458d2..aaade6c4d 100644 --- a/src/mailman/queue/digest.py +++ b/src/mailman/queue/digest.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -130,7 +130,7 @@ class Digester: # through in the digest messages. keepers = {} for header in self._keepers: - keepers[header] = msg.get_all(keeper, []) + keepers[header] = msg.get_all(header, []) # Remove all the unkempt <wink> headers. Use .keys() to allow for # destructive iteration... for header in msg.keys(): @@ -337,7 +337,7 @@ class DigestRunner(Runner): continue # Send the digest to the case-preserved address of the digest # members. - email_address = member.address.original_address + email_address = member.address.original_email if member.delivery_mode == DeliveryMode.plaintext_digests: rfc1153_recipients.add(email_address) elif member.delivery_mode == DeliveryMode.mime_digests: @@ -349,9 +349,9 @@ class DigestRunner(Runner): # Add also the folks who are receiving one last digest. for address, delivery_mode in mlist.last_digest_recipients: if delivery_mode == DeliveryMode.plaintext_digests: - rfc1153_recipients.add(address.original_address) + rfc1153_recipients.add(address.original_email) elif delivery_mode == DeliveryMode.mime_digests: - mime_recipients.add(address.original_address) + mime_recipients.add(address.original_email) else: raise AssertionError( 'OLD recipient "{0}" unexpected delivery mode: {1}'.format( diff --git a/src/mailman/queue/docs/OVERVIEW.txt b/src/mailman/queue/docs/OVERVIEW.txt index 46c70f574..41ccc18c9 100644 --- a/src/mailman/queue/docs/OVERVIEW.txt +++ b/src/mailman/queue/docs/OVERVIEW.txt @@ -13,16 +13,16 @@ wrapped scripts. E.g. for a list named ``mylist``, you'd have:: 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. +``-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. +``-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:: @@ -58,21 +58,22 @@ So delivery flow of messages look like this:: |[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. +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). +[*] 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. +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 diff --git a/src/mailman/queue/docs/archiver.txt b/src/mailman/queue/docs/archiver.txt index 11fd1d98e..cdee449e1 100644 --- a/src/mailman/queue/docs/archiver.txt +++ b/src/mailman/queue/docs/archiver.txt @@ -2,8 +2,9 @@ Archiving ========= -Mailman can archive to any number of archivers that adhere to the IArchiver -interface. By default, there's a Pipermail archiver. +Mailman can archive to any number of archivers that adhere to the +``IArchiver`` interface. By default, there's a Pipermail archiver. +:: >>> mlist = create_list('test@example.com') >>> transaction.commit() diff --git a/src/mailman/queue/docs/command.txt b/src/mailman/queue/docs/command.txt index 4ffb0323c..dfe6b8c19 100644 --- a/src/mailman/queue/docs/command.txt +++ b/src/mailman/queue/docs/command.txt @@ -3,9 +3,9 @@ The command queue runner ======================== This queue runner's purpose is to process and respond to email commands. -Commands are extensible using the Mailman plugin system, but Mailman comes +Commands are extensible using the Mailman plug-in system, but Mailman comes with a number of email commands out of the box. These are processed when a -message is sent to the list's -request address. +message is sent to the list's ``-request`` address. >>> mlist = create_list('test@example.com') @@ -13,8 +13,9 @@ message is sent to the list's -request address. A command in the Subject ======================== -For example, the 'echo' command simply echoes the original command back to the -sender. The command can be in the Subject header. +For example, the ``echo`` command simply echoes the original command back to +the sender. The command can be in the ``Subject`` header. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -31,7 +32,7 @@ sender. The command can be in the Subject header. >>> command = make_testable_runner(CommandRunner) >>> command.run() -And now the response is in the virgin queue. +And now the response is in the ``virgin`` queue. >>> from mailman.queue import Switchboard >>> virgin_queue = config.switchboards['virgin'] @@ -72,6 +73,7 @@ A command in the body The command can also be found in the body of the message, as long as the message is plain text. +:: >>> msg = message_from_string("""\ ... From: bperson@example.com @@ -113,12 +115,14 @@ Implicit commands For some commands, specifically for joining and leaving a mailing list, there are email aliases that act like commands, even when there's nothing else in -the Subject or body. For example, to join a mailing list, a user need only -email the -join address or -subscribe address (the latter is deprecated). +the ``Subject`` or body. For example, to join a mailing list, a user need +only email the ``-join`` address or ``-subscribe`` address (the latter is +deprecated). Because Dirk has never registered with Mailman before, he gets two responses. The first is a confirmation message so that Dirk can validate his email address, and the other is the results of his email command. +:: >>> msg = message_from_string("""\ ... From: Dirk Person <dperson@example.com> @@ -148,8 +152,9 @@ address, and the other is the results of his email command. Subject: The results of your email commands Subject: confirm ... -Similarly, to leave a mailing list, the user need only email the -leave or --unsubscribe address (the latter is deprecated). +Similarly, to leave a mailing list, the user need only email the ``-leave`` or +``-unsubscribe`` address (the latter is deprecated). +:: >>> msg = message_from_string("""\ ... From: dperson@example.com @@ -182,7 +187,8 @@ Similarly, to leave a mailing list, the user need only email the -leave or - Done. <BLANKLINE> -The -confirm address is also available as an implicit command. +The ``-confirm`` address is also available as an implicit command. +:: >>> msg = message_from_string("""\ ... From: dperson@example.com @@ -219,8 +225,9 @@ The -confirm address is also available as an implicit command. Stopping command processing =========================== -The 'end' command stops email processing, so that nothing following is looked -at by the command queue. +The ``end`` command stops email processing, so that nothing following is +looked at by the command queue. +:: >>> msg = message_from_string("""\ ... From: cperson@example.com @@ -250,7 +257,8 @@ at by the command queue. - Done. <BLANKLINE> -The 'stop' command is an alias for 'end'. +The ``stop`` command is an alias for ``end``. +:: >>> msg = message_from_string("""\ ... From: cperson@example.com diff --git a/src/mailman/queue/docs/digester.txt b/src/mailman/queue/docs/digester.txt index 1dbf24ec4..56aaf17c5 100644 --- a/src/mailman/queue/docs/digester.txt +++ b/src/mailman/queue/docs/digester.txt @@ -4,6 +4,7 @@ Digesting Mailman crafts and sends digests by a separate digest queue runner process. This starts by a number of messages being posted to the mailing list. +:: >>> mlist = create_list('test@example.com') >>> mlist.digest_size_threshold = 0.6 @@ -35,6 +36,7 @@ This starts by a number of messages being posted to the mailing list. The queue runner gets kicked off when a marker message gets dropped into the digest queue. The message metadata points to the mailbox file containing the messages to put in the digest. +:: >>> digestq = config.switchboards['digest'] >>> len(digestq.files) @@ -48,6 +50,7 @@ The marker message is empty. >>> print entry.msg.as_string() But the message metadata has a reference to the digest file. +:: >>> dump_msgdata(entry.msgdata) _parsemsg : False @@ -82,6 +85,7 @@ delivery. 2 The MIME digest is a multipart, and the RFC 1153 digest is the other one. +:: >>> def mime_rfc1153(messages): ... if messages[0].msg.is_multipart(): @@ -283,6 +287,7 @@ Internationalized digests When messages come in with a content-type character set different than that of the list's preferred language, recipients will get an internationalized digest. French is not enabled by default site-wide, so enable that now. +:: # Simulate the site administrator setting the default server language to # French in the configuration file. Without this, the English template @@ -324,6 +329,7 @@ The marker message is sitting in the digest queue. The digest queue runner runs a loop, placing the two digests into the virgin queue. +:: # Put the messages back in the queue for the runner to handle. >>> filebase = digestq.enqueue(entry.msg, entry.msgdata) @@ -422,6 +428,7 @@ French and Japanese characters. <BLANKLINE> The content can be decoded to see the actual digest text. +:: # We must display the repr of the decoded value because doctests cannot # handle the non-ascii characters. @@ -477,6 +484,7 @@ Digest delivery A mailing list's members can choose to receive normal delivery, plain text digests, or MIME digests. +:: >>> len(get_queue_messages('virgin')) 0 @@ -518,6 +526,7 @@ When a digest gets sent, the appropriate recipient list is chosen. The digests are sitting in the virgin queue. One of them is the MIME digest and the other is the RFC 1153 digest. +:: >>> messages = get_queue_messages('virgin') >>> len(messages) @@ -536,6 +545,7 @@ Only yperson and zperson get the RFC 1153 digests. [u'yperson@example.com', u'zperson@example.com'] Now uperson decides that they would like to start receiving digests too. +:: >>> member_1.preferences.delivery_mode = DeliveryMode.mime_digests >>> fill_digest() @@ -556,6 +566,7 @@ At this point, both uperson and wperson decide that they'd rather receive regular deliveries instead of digests. uperson would like to get any last digest that may be sent so that she doesn't miss anything. wperson does care as much and does not want to receive one last digest. +:: >>> mlist.send_one_last_digest_to( ... member_1.address, member_1.preferences.delivery_mode) @@ -576,6 +587,7 @@ as much and does not want to receive one last digest. Since uperson has received their last digest, they will not get any more of them. +:: >>> fill_digest() >>> runner.run() diff --git a/src/mailman/queue/docs/incoming.txt b/src/mailman/queue/docs/incoming.txt index 74326820f..6455db20b 100644 --- a/src/mailman/queue/docs/incoming.txt +++ b/src/mailman/queue/docs/incoming.txt @@ -12,29 +12,47 @@ 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. - >>> mlist = create_list('_xtest@example.com') + >>> mlist = create_list('test@example.com') >>> print mlist.start_chain built-in -Accepted messages -================= +Sender addresses +================ -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 -pipeline queue. +The incoming queue runner ensures that the sender addresses on the message are +registered with the system. This is used for determining nonmember posting +privileges. The addresses will not be linked to a user and will be +unverified, so if the real user comes along later and claims the address, it +will be linked to their user account (and must be verified). + +While configurable, the *sender addresses* by default are those named in the +`From:`, `Sender:`, and `Reply-To:` headers, as well as the envelope sender +(though we won't worry about the latter). +:: >>> msg = message_from_string("""\ - ... From: aperson@example.com - ... To: _xtest@example.com - ... Subject: My first post - ... Message-ID: <first> + ... From: zperson@example.com + ... Reply-To: yperson@example.com + ... Sender: xperson@example.com + ... To: test@example.com + ... Subject: This is spiced ham + ... Message-ID: <bogus> ... - ... First post! ... """) -Normally, the upstream mail server would drop the message in the incoming -queue, but this is an effective simulation. + >>> from zope.component import getUtility + >>> from mailman.interfaces.usermanager import IUserManager + >>> user_manager = getUtility(IUserManager) + >>> print user_manager.get_address('xperson@example.com') + None + >>> print user_manager.get_address('yperson@example.com') + None + >>> print user_manager.get_address('zperson@example.com') + None + +Inject the message into the incoming queue, similar to the way the upstream +mail server normally would. >>> from mailman.inject import inject_message >>> inject_message(mlist, msg) @@ -46,7 +64,50 @@ The incoming queue runner runs until it is empty. >>> incoming = make_testable_runner(IncomingRunner, 'in') >>> incoming.run() -And now the message is in the pipeline queue. +And now the addresses are known to the system. As mentioned above, they are +not linked to a user and are unverified. + + >>> for localpart in ('xperson', 'yperson', 'zperson'): + ... email = '{0}@example.com'.format(localpart) + ... address = user_manager.get_address(email) + ... print '{0}; verified? {1}; user? {2}'.format( + ... address.email, + ... ('No' if address.verified_on is None else 'Yes'), + ... user_manager.get_user(email)) + xperson@example.com; verified? No; user? None + yperson@example.com; verified? No; user? None + zperson@example.com; verified? No; user? None + +.. + Clear the pipeline queue of artifacts that affect the following tests. + >>> from mailman.testing.helpers import get_queue_messages + >>> ignore = get_queue_messages('pipeline') + + +Accepted messages +================= + +We have a message that is going to be sent to the mailing list. Once Anne is +a member of the mailing list, this message is so perfectly fine for posting +that it will be accepted and forward to the pipeline queue. + + >>> from mailman.testing.helpers import subscribe + >>> subscribe(mlist, 'Anne') + >>> msg = message_from_string("""\ + ... From: aperson@example.com + ... To: test@example.com + ... Subject: My first post + ... Message-ID: <first> + ... + ... First post! + ... """) + +Inject the message into the incoming queue and run until the queue is empty. + + >>> inject_message(mlist, msg) + >>> incoming.run() + +Now the message is in the pipeline queue. >>> pipeline_queue = config.switchboards['pipeline'] >>> len(pipeline_queue.files) @@ -54,18 +115,16 @@ And now the message is in the pipeline queue. >>> incoming_queue = config.switchboards['in'] >>> len(incoming_queue.files) 0 - >>> from mailman.testing.helpers import get_queue_messages >>> item = get_queue_messages('pipeline')[0] >>> print item.msg.as_string() From: aperson@example.com - To: _xtest@example.com + To: test@example.com Subject: My first post Message-ID: <first> Date: ... - X-Mailman-Rule-Misses: approved; emergency; loop; administrivia; - implicit-dest; - max-recipients; max-size; news-moderation; no-subject; - suspicious-header + X-Mailman-Rule-Misses: approved; emergency; loop; member-moderation; + administrivia; implicit-dest; max-recipients; max-size; + news-moderation; no-subject; suspicious-header; nonmember-moderation <BLANKLINE> First post! <BLANKLINE> @@ -81,26 +140,28 @@ 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 pipeline 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) + >>> from mailman.chains.base import ChainNotification + >>> def on_chain(event): + ... if isinstance(event, ChainNotification): + ... print event + ... print event.chain + ... print 'From: {0}\nTo: {1}\nMessage-ID: {2}'.format( + ... event.msg['from'], event.msg['to'], + ... event.msg['message-id']) >>> mlist.emergency = True - >>> inject_message(mlist, msg) - >>> file_pos = fp.tell() - >>> incoming.run() - >>> len(pipeline_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> + + >>> from mailman.testing.helpers import event_subscribers + >>> with event_subscribers(on_chain): + ... inject_message(mlist, msg) + ... incoming.run() + <mailman.chains.hold.HoldNotification ...> + <mailman.chains.hold.HoldChain ...> + From: aperson@example.com + To: test@example.com + Message-ID: <first> >>> mlist.emergency = False @@ -111,29 +172,40 @@ 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.chain import LinkAction - >>> truth_rule = config.rules['truth'] - >>> discard_chain = config.chains['discard'] - >>> test_chain = Chain('always-discard', 'Testing discards') - >>> link = Link(truth_rule, LinkAction.jump, discard_chain) - >>> test_chain.append_link(link) - >>> mlist.start_chain = 'always-discard' + >>> def make_chain(name, target_chain): + ... truth_rule = config.rules['truth'] + ... target_chain = config.chains[target_chain] + ... test_chain = Chain(name, 'Testing {0}'.format(target_chain)) + ... config.chains[test_chain.name] = test_chain + ... link = Link(truth_rule, LinkAction.jump, target_chain) + ... test_chain.append_link(link) + ... return test_chain - >>> inject_message(mlist, msg) - >>> file_pos = fp.tell() - >>> incoming.run() - >>> len(pipeline_queue.files) - 0 - >>> len(incoming_queue.files) - 0 - >>> fp.seek(file_pos) - >>> print 'LOG:', fp.read() - LOG: ... DISCARD: <first> - <BLANKLINE> + >>> test_chain = make_chain('always-discard', 'discard') + >>> mlist.start_chain = test_chain.name + + >>> msg.replace_header('message-id', '<second>') + >>> with event_subscribers(on_chain): + ... inject_message(mlist, msg) + ... incoming.run() + <mailman.chains.discard.DiscardNotification ...> + <mailman.chains.discard.DiscardChain ...> + From: aperson@example.com + To: test@example.com + Message-ID: <second> - >>> del config.chains['always-discard'] + >>> del config.chains[test_chain.name] + +.. + The virgin queue needs to be cleared out due to artifacts from the + previous tests above. + + >>> virgin_queue = config.switchboards['virgin'] + >>> ignore = get_queue_messages('virgin') Rejected messages @@ -143,32 +215,28 @@ 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', 'Testing rejections') - >>> link = Link(truth_rule, LinkAction.jump, reject_chain) - >>> test_chain.append_link(link) - >>> mlist.start_chain = 'always-reject' + >>> test_chain = make_chain('always-reject', 'reject') + >>> mlist.start_chain = test_chain.name -The virgin queue needs to be cleared out due to artifacts from the previous -tests above. + >>> msg.replace_header('message-id', '<third>') + >>> with event_subscribers(on_chain): + ... inject_message(mlist, msg) + ... incoming.run() + <mailman.chains.reject.RejectNotification ...> + <mailman.chains.reject.RejectChain ...> + From: aperson@example.com + To: test@example.com + Message-ID: <third> - >>> virgin_queue = config.switchboards['virgin'] - >>> ignore = get_queue_messages('virgin') - - >>> inject_message(mlist, msg) - >>> file_pos = fp.tell() - >>> incoming.run() - >>> len(pipeline_queue.files) - 0 - >>> len(incoming_queue.files) - 0 +The rejection message is sitting in the virgin queue waiting to be delivered +to the original sender. >>> len(virgin_queue.files) 1 >>> item = get_queue_messages('virgin')[0] >>> print item.msg.as_string() Subject: My first post - From: _xtest-owner@example.com + From: test-owner@example.com To: aperson@example.com ... <BLANKLINE> @@ -183,24 +251,13 @@ tests above. MIME-Version: 1.0 <BLANKLINE> From: aperson@example.com - To: _xtest@example.com + To: test@example.com Subject: My first post - Message-ID: <first> + Message-ID: <third> Date: ... <BLANKLINE> First post! <BLANKLINE> --===============... - >>> dump_msgdata(item.msgdata) - _parsemsg : False - ... - recipients : [u'aperson@example.com'] - ... - - >>> fp.seek(file_pos) - >>> print 'LOG:', fp.read() - LOG: ... REJECT: <first> - <BLANKLINE> - >>> del config.chains['always-reject'] diff --git a/src/mailman/queue/docs/lmtp.txt b/src/mailman/queue/docs/lmtp.txt index 549161b08..c95c6aa2b 100644 --- a/src/mailman/queue/docs/lmtp.txt +++ b/src/mailman/queue/docs/lmtp.txt @@ -7,7 +7,7 @@ support LMTP local delivery, so this is a very portable way to connect Mailman with your mail server. Our LMTP server is fairly simple though; all it does is make sure that the -message is destined for a valid endpoint, e.g. mylist-join@example.com. +message is destined for a valid endpoint, e.g. ``mylist-join@example.com``. Let's start a testable LMTP queue runner. @@ -44,6 +44,7 @@ will get a 550 error. SMTPDataError: (550, 'Requested action not taken: mailbox unavailable') Once the mailing list is created, the posting address is valid. +:: >>> create_list('mylist@example.com') <mailing list "mylist@example.com" at ...> @@ -197,6 +198,7 @@ Confirmation messages go to the command processor... version : ... ...as do join messages... +:: >>> lmtp.sendmail( ... 'anne.person@example.com', @@ -237,6 +239,7 @@ Confirmation messages go to the command processor... version : ... ...and leave messages. +:: >>> lmtp.sendmail( ... 'anne.person@example.com', diff --git a/src/mailman/queue/docs/news.txt b/src/mailman/queue/docs/news.txt index 01b554097..7261aa333 100644 --- a/src/mailman/queue/docs/news.txt +++ b/src/mailman/queue/docs/news.txt @@ -13,6 +13,7 @@ was originally written to gate to Usenet, which has its own rules). Some NNTP servers such as INN reject messages containing a set of prohibited headers, so one of the things that the news runner does is remove these prohibited headers. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -49,7 +50,7 @@ prohibited headers. Some NNTP servers will reject messages where certain headers are duplicated, so the news runner must collapse or move these duplicate headers to an -X-Original-* header that the news server doesn't care about. +``X-Original-*`` header that the news server doesn't care about. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -116,8 +117,9 @@ the message. Newsgroup moderation ==================== -When the newsgroup is moderated, an Approved: header with the list's posting -address is added for the benefit of the Usenet system. +When the newsgroup is moderated, an ``Approved:`` header with the list's +posting address is added for the benefit of the Usenet system. +:: >>> from mailman.interfaces.nntp import NewsModeration >>> mlist.news_moderation = NewsModeration.open_moderated @@ -142,7 +144,7 @@ address is added for the benefit of the Usenet system. >>> print msg['approved'] _xtest@example.com -But if the newsgroup is not moderated, the Approved: header is not changed. +But if the newsgroup is not moderated, the ``Approved:`` header is not changed. >>> mlist.news_moderation = NewsModeration.none >>> msg = message_from_string("""\ diff --git a/src/mailman/queue/docs/outgoing.txt b/src/mailman/queue/docs/outgoing.txt index 73a9200ef..0af22b808 100644 --- a/src/mailman/queue/docs/outgoing.txt +++ b/src/mailman/queue/docs/outgoing.txt @@ -7,10 +7,11 @@ directly upstream SMTP server. It is this upstream SMTP server that performs final delivery to the intended recipients. Messages that appear in the outgoing queue are processed individually through -a 'delivery module', essentially a pluggable interface for determining how the +a *delivery module*, essentially a pluggable interface for determining how the recipient set will be batched, whether messages will be personalized and VERP'd, etc. The outgoing runner doesn't itself support retrying but it can move messages to the 'retry queue' for handling delivery failures. +:: >>> mlist = create_list('test@example.com') @@ -32,8 +33,9 @@ move messages to the 'retry queue' for handling delivery failures. Normally, messages would show up in the outgoing queue after the message has been processed by the rule set and pipeline. But we can simulate that here by injecting a message directly into the outgoing queue. First though, we must -call the calculate-recipients handler so that the message metadata will be +call the ``calculate-recipients`` handler so that the message metadata will be populated with the list of addresses to deliver the message to. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -49,8 +51,8 @@ populated with the list of addresses to deliver the message to. >>> handler.process(mlist, msg, msgdata) >>> outgoing_queue = config.switchboards['out'] -The to-outgoing handler populates the message metadata with the destination -mailing list name. Simulate that here too. +The ``to-outgoing`` handler populates the message metadata with the +destination mailing list name. Simulate that here too. >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -66,6 +68,7 @@ upstream SMTP. >>> outgoing.run() Every recipient got the same copy of the message. +:: >>> messages = list(smtpd.messages) >>> len(messages) @@ -111,6 +114,7 @@ just one. Since we've done no other configuration, the only difference in the messages is the recipient address. Specifically, the Sender header is the same for all recipients. +:: >>> from operator import itemgetter >>> def show_headers(messages): @@ -137,7 +141,8 @@ the Sender header. Forcing VERP ------------ -A handler can force VERP by setting the 'verp' key in the message metadata. +A handler can force VERP by setting the ``verp`` key in the message metadata. +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -164,7 +169,8 @@ The site administrator can enable VERP whenever messages are personalized. ... verp_personalized_deliveries: yes ... """) -Again, we get three individual messages, with VERP'd Sender headers. +Again, we get three individual messages, with VERP'd ``Sender`` headers. +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -192,6 +198,7 @@ still like to occasionally get the benefits of VERP. The site administrator can enable occasional VERPing of messages every so often, by setting a delivery interval. Every N non-personalized deliveries turns on VERP for just the next one. +:: >>> config.push('verp occasionally', """ ... [mta] @@ -204,6 +211,7 @@ the next one. The first message is sent to the list, and it is neither personalized nor VERP'd. +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -223,6 +231,7 @@ VERP'd. >>> transaction.commit() The second message sent to the list is also not VERP'd. +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -241,6 +250,7 @@ The second message sent to the list is also not VERP'd. >>> transaction.commit() The third message though is VERP'd. +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -260,6 +270,7 @@ The third message though is VERP'd. >>> transaction.commit() The next one is back to bulk delivery. +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -281,6 +292,7 @@ VERP every time If the site administrator wants to enable VERP for every delivery, even if no personalization is going on, they can set the interval to 1. +:: >>> config.push('always verp', """ ... [mta] @@ -292,6 +304,7 @@ personalization is going on, they can set the interval to 1. >>> transaction.commit() The first message is VERP'd. +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -311,6 +324,7 @@ The first message is VERP'd. >>> transaction.commit() As is the second message. +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -330,6 +344,7 @@ As is the second message. >>> transaction.commit() And the third message. +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -356,6 +371,7 @@ Never VERP Similarly, the site administrator can disable occasional VERP'ing of non-personalized messages by setting the interval to zero. +:: >>> config.push('never verp', """ ... [mta] @@ -367,6 +383,7 @@ non-personalized messages by setting the interval to zero. >>> transaction.commit() Neither the first message... +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, @@ -381,6 +398,7 @@ Neither the first message... test-bounces@example.com ...nor the second message is VERP'd. +:: >>> ignore = outgoing_queue.enqueue( ... msg, msgdata, diff --git a/src/mailman/queue/docs/rest.txt b/src/mailman/queue/docs/rest.txt index 2df4da9e4..9e8851eca 100644 --- a/src/mailman/queue/docs/rest.txt +++ b/src/mailman/queue/docs/rest.txt @@ -2,7 +2,7 @@ REST server =========== -Mailman is controllable through an administrative RESTful HTTP server. +Mailman is controllable through an administrative `REST`_ HTTP server. >>> from mailman.testing import helpers >>> master = helpers.TestableMaster(helpers.wait_for_webservice) @@ -10,14 +10,16 @@ Mailman is controllable through an administrative RESTful HTTP server. The RESTful server can be used to access basic version information. - >>> dump_json('http://localhost:8001/3.0/system') + >>> dump_json('http://localhost:9001/3.0/system') http_etag: "..." mailman_version: GNU Mailman 3.0... (...) python_version: ... - self_link: http://localhost:8001/3.0/system + self_link: http://localhost:9001/3.0/system Clean up ======== >>> master.stop() + +.. _REST: http://en.wikipedia.org/wiki/REST diff --git a/src/mailman/queue/docs/runner.txt b/src/mailman/queue/docs/runner.txt index 8438f2576..39e8fede2 100644 --- a/src/mailman/queue/docs/runner.txt +++ b/src/mailman/queue/docs/runner.txt @@ -2,7 +2,7 @@ Queue runners ============= -The queue runners (qrunner) are the processes that move messages around the +The queue runners (*qrunner*) are the processes that move messages around the Mailman system. Each qrunner is responsible for a slice of the hash space in a queue directory. It processes all the files in its slice, sleeps a little while, then wakes up and runs through its queue files again. @@ -12,8 +12,8 @@ Basic architecture ================== The basic architecture of qrunner is implemented in the base class that all -runners inherit from. This base class implements a .run() method that runs -continuously in a loop until the .stop() method is called. +runners inherit from. This base class implements a ``.run()`` method that +runs continuously in a loop until the ``.stop()`` method is called. >>> mlist = create_list('_xtest@example.com') @@ -21,6 +21,7 @@ Here is a very simple derived qrunner class. Queue runners use a configuration section in the configuration files to determine run characteristics, such as the queue directory to use. Here we push a configuration section for the test runner. +:: >>> config.push('test-runner', """ ... [qrunner.test] diff --git a/src/mailman/queue/docs/switchboard.txt b/src/mailman/queue/docs/switchboard.txt index ca44b20ac..d89aa3693 100644 --- a/src/mailman/queue/docs/switchboard.txt +++ b/src/mailman/queue/docs/switchboard.txt @@ -156,6 +156,7 @@ The files can be recovered explicitly. But the files will only be recovered at most three times before they are considered defective. In order to prevent mail bombs and loops, once this maximum is reached, the files will be preserved in the 'bad' queue. +:: >>> for filebase in switchboard.files: ... msg, msgdata = switchboard.dequeue(filebase) @@ -170,7 +171,9 @@ maximum is reached, the files will be preserved in the 'bad' queue. >>> check_qfiles(bad.queue_directory) .psv: 3 + Clean up +-------- >>> for file in os.listdir(bad.queue_directory): ... os.remove(os.path.join(bad.queue_directory, file)) diff --git a/src/mailman/queue/http.py b/src/mailman/queue/http.py index 42be55d24..dcc6b1255 100644 --- a/src/mailman/queue/http.py +++ b/src/mailman/queue/http.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/queue/incoming.py b/src/mailman/queue/incoming.py index f91f6c90c..f8d671177 100644 --- a/src/mailman/queue/incoming.py +++ b/src/mailman/queue/incoming.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -34,7 +34,12 @@ __all__ = [ ] +from zope.component import getUtility + +from mailman.config import config from mailman.core.chains import process +from mailman.interfaces.address import ExistingAddressError +from mailman.interfaces.usermanager import IUserManager from mailman.queue import Runner @@ -46,6 +51,15 @@ class IncomingRunner(Runner): """See `IRunner`.""" if msgdata.get('envsender') is None: msgdata['envsender'] = mlist.no_reply_address + # Ensure that the email addresses of the message's senders are known + # to Mailman. This will be used in nonmember posting dispositions. + user_manager = getUtility(IUserManager) + for sender in msg.senders: + try: + user_manager.create_address(sender) + except ExistingAddressError: + pass + config.db.commit() # Process the message through the mailing list's start chain. process(mlist, msg, msgdata, mlist.start_chain) # Do not keep this message queued. diff --git a/src/mailman/queue/lmtp.py b/src/mailman/queue/lmtp.py index 3db4ad4c7..9163a88e6 100644 --- a/src/mailman/queue/lmtp.py +++ b/src/mailman/queue/lmtp.py @@ -1,4 +1,4 @@ -# Copyright (C) 2006-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2006-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -146,7 +146,7 @@ class LMTPRunner(Runner, smtpd.SMTPServer): def handle_accept(self): conn, addr = self.accept() - channel = Channel(self, conn, addr) + Channel(self, conn, addr) qlog.debug('LMTP accept from %s', addr) @txn @@ -163,7 +163,7 @@ class LMTPRunner(Runner, smtpd.SMTPServer): return ERR_501 msg['X-MailFrom'] = mailfrom message_id = msg['message-id'] - except Exception, e: + except Exception: elog.exception('LMTP message parsing') config.db.abort() return CRLF.join(ERR_451 for to in rcpttos) @@ -217,7 +217,7 @@ class LMTPRunner(Runner, smtpd.SMTPServer): qlog.debug('%s subaddress: %s, queue: %s', message_id, canonical_subaddress, queue) status.append('250 Ok') - except Exception, e: + except Exception: elog.exception('Queue detection: %s', msg['message-id']) config.db.abort() status.append(ERR_550) diff --git a/src/mailman/queue/maildir.py b/src/mailman/queue/maildir.py index 64bb511ea..3d0b9497c 100644 --- a/src/mailman/queue/maildir.py +++ b/src/mailman/queue/maildir.py @@ -1,4 +1,4 @@ -# Copyright (C) 2002-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2002-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -58,7 +58,8 @@ from email.Utils import parseaddr from mailman.config import config from mailman.message import Message -from mailman.queue import Runner +from mailman.queue import Runner, Switchboard + log = logging.getLogger('mailman.error') @@ -67,6 +68,7 @@ log = logging.getLogger('mailman.error') subqnames = ('admin', 'bounces', 'confirm', 'join', 'leave', 'owner', 'request', 'subscribe', 'unsubscribe') + def getlistq(address): localpart, domain = address.split('@', 1) # TK: FIXME I only know configs of Postfix. @@ -83,6 +85,7 @@ def getlistq(address): subq = None return listname, subq, domain + class MaildirRunner(Runner): # This class is much different than most runners because it pulls files diff --git a/src/mailman/queue/news.py b/src/mailman/queue/news.py index c66607bd1..dcb0deba6 100644 --- a/src/mailman/queue/news.py +++ b/src/mailman/queue/news.py @@ -1,4 +1,4 @@ -# Copyright (C) 2000-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2000-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/queue/outgoing.py b/src/mailman/queue/outgoing.py index c97eba098..7ff194219 100644 --- a/src/mailman/queue/outgoing.py +++ b/src/mailman/queue/outgoing.py @@ -1,4 +1,4 @@ -# Copyright (C) 2000-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2000-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -17,8 +17,6 @@ """Outgoing queue runner.""" -import os -import sys import socket import logging diff --git a/src/mailman/queue/pipeline.py b/src/mailman/queue/pipeline.py index 187dc4089..099ebd032 100644 --- a/src/mailman/queue/pipeline.py +++ b/src/mailman/queue/pipeline.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/queue/rest.py b/src/mailman/queue/rest.py index a7c9727e2..31e840a51 100644 --- a/src/mailman/queue/rest.py +++ b/src/mailman/queue/rest.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/queue/retry.py b/src/mailman/queue/retry.py index 7ccd4bad6..24aa7f82b 100644 --- a/src/mailman/queue/retry.py +++ b/src/mailman/queue/retry.py @@ -1,4 +1,4 @@ -# Copyright (C) 2003-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2003-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/queue/virgin.py b/src/mailman/queue/virgin.py index 3573bb096..2dcdca910 100644 --- a/src/mailman/queue/virgin.py +++ b/src/mailman/queue/virgin.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rest/adapters.py b/src/mailman/rest/adapters.py index 1edac3a34..30ce99f44 100644 --- a/src/mailman/rest/adapters.py +++ b/src/mailman/rest/adapters.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -21,7 +21,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'DomainCollection', + 'SubscriptionService', ] @@ -51,7 +51,7 @@ class SubscriptionService: # XXX 2010-02-24 barry Clean this up. # lazr.restful requires the return value to be a concrete list. members = [] - address_of_member = attrgetter('address.address') + address_of_member = attrgetter('address.email') list_manager = getUtility(IListManager) for fqdn_listname in sorted(list_manager.names): mailing_list = list_manager.get(fqdn_listname) @@ -76,7 +76,7 @@ class SubscriptionService: # Convert from string to enum. mode = (DeliveryMode.regular if delivery_mode is None - else DeliveryMode(delivery_mode)) + else delivery_mode) if real_name is None: real_name, at, domain = address.partition('@') if len(at) == 0: diff --git a/src/mailman/rest/configuration.py b/src/mailman/rest/configuration.py new file mode 100644 index 000000000..c1b798e7d --- /dev/null +++ b/src/mailman/rest/configuration.py @@ -0,0 +1,298 @@ +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Mailing list configuration via REST API.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'ListConfiguration', + ] + + +from lazr.config import as_boolean, as_timedelta +from restish import http, resource + +from mailman.config import config +from mailman.interfaces.action import Action +from mailman.interfaces.autorespond import ResponseAction +from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging +from mailman.rest.helpers import PATCH, etag +from mailman.rest.validator import Validator, enum_validator + + + +class GetterSetter: + """Get and set attributes on mailing lists. + + Most attributes are fairly simple - a getattr() or setattr() on the + mailing list does the trick, with the appropriate encoding or decoding on + the way in and out. Encoding doesn't happen here though; the standard + JSON library handles most types, but see ExtendedEncoder in + mailman.rest.helpers for additional support. + + Others are more complicated since they aren't kept in the model as direct + columns in the database. These will use subclasses of this base class. + Read-only attributes will have a decoder which always raises ValueError. + """ + + def __init__(self, decoder=None): + """Create a getter/setter for a specific list attribute. + + :param decoder: The callable for decoding a web request value string + into the specific data type needed by the `IMailingList` + attribute. Use None to indicate a read-only attribute. The + callable should raise ValueError when the web request value cannot + be converted. + :type decoder: callable + """ + self.decoder = decoder + + def get(self, mlist, attribute): + """Return the named mailing list attribute value. + + :param mlist: The mailing list. + :type mlist: `IMailingList` + :param attribute: The attribute name. + :type attribute: string + :return: The attribute value, ready for JSON encoding. + :rtype: object + """ + return getattr(mlist, attribute) + + def put(self, mlist, attribute, value): + """Set the named mailing list attribute value. + + :param mlist: The mailing list. + :type mlist: `IMailingList` + :param attribute: The attribute name. + :type attribute: string + :param value: The new value for the attribute. + :type request_value: object + """ + setattr(mlist, attribute, value) + + def __call__(self, value): + """Convert the value to its internal format. + + :param value: The web request value to convert. + :type value: string + :return: The converted value. + :rtype: object + """ + if self.decoder is None: + return value + return self.decoder(value) + + +class AcceptableAliases(GetterSetter): + """Resource for the acceptable aliases of a mailing list.""" + + def get(self, mlist, attribute): + """Return the mailing list's acceptable aliases.""" + assert attribute == 'acceptable_aliases', ( + 'Unexpected attribute: {0}'.format(attribute)) + aliases = IAcceptableAliasSet(mlist) + return sorted(aliases.aliases) + + def put(self, mlist, attribute, value): + """Change the acceptable aliases. + + Because this is a PUT operation, all previous aliases are cleared + first. Thus, this is an overwrite. The keys in the request are + ignored. + """ + assert attribute == 'acceptable_aliases', ( + 'Unexpected attribute: {0}'.format(attribute)) + alias_set = IAcceptableAliasSet(mlist) + alias_set.clear() + for alias in value: + alias_set.add(unicode(alias)) + + + +# Additional validators for converting from web request strings to internal +# data types. See below for details. + +def pipeline_validator(pipeline_name): + """Convert the pipeline name to a string, but only if it's known.""" + if pipeline_name in config.pipelines: + return unicode(pipeline_name) + raise ValueError('Unknown pipeline: {0}'.format(pipeline_name)) + + +def list_of_unicode(values): + """Turn a list of things into a list of unicodes.""" + return [unicode(value) for value in values] + + + +# This is the list of IMailingList attributes that are exposed through the +# REST API. The values of the keys are the GetterSetter instance holding the +# decoder used to convert the web request string to an internally valid value. +# The instance also contains the get() and put() methods used to retrieve and +# set the attribute values. Its .decoder attribute will be None for read-only +# attributes. +# +# The decoder must either return the internal value or raise a ValueError if +# the conversion failed (e.g. trying to turn 'Nope' into a boolean). +# +# Many internal value types can be automatically JSON encoded, but see +# mailman.rest.helpers.ExtendedEncoder for specializations of certain types +# (e.g. datetimes, timedeltas, enums). + +ATTRIBUTES = dict( + acceptable_aliases=AcceptableAliases(list_of_unicode), + admin_immed_notify=GetterSetter(as_boolean), + admin_notify_mchanges=GetterSetter(as_boolean), + administrivia=GetterSetter(as_boolean), + advertised=GetterSetter(as_boolean), + anonymous_list=GetterSetter(as_boolean), + autorespond_owner=GetterSetter(enum_validator(ResponseAction)), + autorespond_postings=GetterSetter(enum_validator(ResponseAction)), + autorespond_requests=GetterSetter(enum_validator(ResponseAction)), + autoresponse_grace_period=GetterSetter(as_timedelta), + autoresponse_owner_text=GetterSetter(unicode), + autoresponse_postings_text=GetterSetter(unicode), + autoresponse_request_text=GetterSetter(unicode), + bounces_address=GetterSetter(None), + collapse_alternatives=GetterSetter(as_boolean), + convert_html_to_plaintext=GetterSetter(as_boolean), + created_at=GetterSetter(None), + default_member_action=GetterSetter(enum_validator(Action)), + default_nonmember_action=GetterSetter(enum_validator(Action)), + description=GetterSetter(unicode), + digest_last_sent_at=GetterSetter(None), + digest_size_threshold=GetterSetter(float), + filter_content=GetterSetter(as_boolean), + fqdn_listname=GetterSetter(None), + generic_nonmember_action=GetterSetter(int), + host_name=GetterSetter(None), + include_list_post_header=GetterSetter(as_boolean), + include_rfc2369_headers=GetterSetter(as_boolean), + join_address=GetterSetter(None), + last_post_at=GetterSetter(None), + leave_address=GetterSetter(None), + list_id=GetterSetter(None), + list_name=GetterSetter(None), + next_digest_number=GetterSetter(None), + no_reply_address=GetterSetter(None), + owner_address=GetterSetter(None), + pipeline=GetterSetter(pipeline_validator), + post_id=GetterSetter(None), + posting_address=GetterSetter(None), + real_name=GetterSetter(unicode), + reply_goes_to_list=GetterSetter(enum_validator(ReplyToMunging)), + request_address=GetterSetter(None), + scheme=GetterSetter(None), + send_welcome_msg=GetterSetter(as_boolean), + volume=GetterSetter(None), + web_host=GetterSetter(None), + welcome_msg=GetterSetter(unicode), + ) + + +VALIDATORS = ATTRIBUTES.copy() +for attribute, gettersetter in VALIDATORS.items(): + if gettersetter.decoder is None: + del VALIDATORS[attribute] + + + +class ListConfiguration(resource.Resource): + """A mailing list configuration resource.""" + + def __init__(self, mailing_list, attribute): + self._mlist = mailing_list + self._attribute = attribute + + @resource.GET() + def get_configuration(self, request): + """Get a mailing list configuration.""" + resource = {} + if self._attribute is None: + # Return all readable attributes. + for attribute in ATTRIBUTES: + value = ATTRIBUTES[attribute].get(self._mlist, attribute) + resource[attribute] = value + elif self._attribute not in ATTRIBUTES: + return http.bad_request( + [], b'Unknown attribute: {0}'.format(self._attribute)) + else: + attribute = self._attribute + value = ATTRIBUTES[attribute].get(self._mlist, attribute) + resource[attribute] = value + return http.ok([], etag(resource)) + + # XXX 2010-09-01 barry: Refactor {put,patch}_configuration() for common + # code paths. + + def _set_writable_attributes(self, validator, request): + """Common code for setting all attributes given in the request. + + Returns an HTTP 400 when a request tries to write to a read-only + attribute. + """ + converted = validator(request) + for key, value in converted.items(): + ATTRIBUTES[key].put(self._mlist, key, value) + + @resource.PUT() + def put_configuration(self, request): + """Set a mailing list configuration.""" + attribute = self._attribute + if attribute is None: + validator = Validator(**VALIDATORS) + try: + self._set_writable_attributes(validator, request) + except ValueError as error: + return http.bad_request([], str(error)) + elif attribute not in ATTRIBUTES: + return http.bad_request( + [], b'Unknown attribute: {0}'.format(attribute)) + elif ATTRIBUTES[attribute].decoder is None: + return http.bad_request( + [], b'Read-only attribute: {0}'.format(attribute)) + else: + validator = Validator(**{attribute: VALIDATORS[attribute]}) + try: + self._set_writable_attributes(validator, request) + except ValueError as error: + return http.bad_request([], str(error)) + return http.ok([], '') + + @PATCH() + def patch_configuration(self, request): + """Patch the configuration (i.e. partial update).""" + # Validate only the partial subset of attributes given in the request. + validationators = {} + for attribute in request.PATCH: + if attribute not in ATTRIBUTES: + return http.bad_request( + [], b'Unknown attribute: {0}'.format(attribute)) + elif ATTRIBUTES[attribute].decoder is None: + return http.bad_request( + [], b'Read-only attribute: {0}'.format(attribute)) + else: + validationators[attribute] = VALIDATORS[attribute] + validator = Validator(**validationators) + try: + self._set_writable_attributes(validator, request) + except ValueError as error: + return http.bad_request([], str(error)) + return http.ok([], '') diff --git a/src/mailman/rest/docs/__init__.py b/src/mailman/rest/docs/__init__.py index 8313e1889..7688d7c1b 100644 --- a/src/mailman/rest/docs/__init__.py +++ b/src/mailman/rest/docs/__init__.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rest/docs/basic.txt b/src/mailman/rest/docs/basic.txt index 643d6d906..cf02fa4ec 100644 --- a/src/mailman/rest/docs/basic.txt +++ b/src/mailman/rest/docs/basic.txt @@ -5,27 +5,72 @@ REST server Mailman exposes a REST HTTP server for administrative control. The server listens for connections on a configurable host name and port. + +It is always protected by HTTP basic authentication using a single global +username and password. The credentials are set in the webservice section +of the config using the admin_user and admin_pass properties. + Because the REST server has full administrative access, it should always be -run only on localhost, unless you really know what you're doing. The Mailman -major and minor version numbers are in the URL. +run only on localhost, unless you really know what you're doing. In addition +you should set the username and password to secure values and distribute them +to any REST clients with reasonable precautions. + +The Mailman major and minor version numbers are in the URL. System information can be retrieved from the server. By default JSON is returned. - >>> dump_json('http://localhost:8001/3.0/system') + >>> dump_json('http://localhost:9001/3.0/system') http_etag: "..." mailman_version: GNU Mailman 3.0... (...) python_version: ... - self_link: http://localhost:8001/3.0/system + self_link: http://localhost:9001/3.0/system Non-existent links ================== -When you try to access an admin link that doesn't exist, you get the -appropriate HTTP 404 Not Found error. +When you try to access a link that doesn't exist, you get the appropriate HTTP +404 Not Found error. - >>> dump_json('http://localhost:8001/3.0/does-not-exist') + >>> dump_json('http://localhost:9001/3.0/does-not-exist') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found + + +Invalid credentials +=================== + +When you try to access the REST server using invalid credentials you will get +an appropriate HTTP 401 Unauthorized error. +:: + + >>> from base64 import b64encode + >>> auth = b64encode('baduser:badpass') + + >>> url = 'http://localhost:9001/3.0/system' + >>> headers = { + ... 'Content-Type': 'application/x-www-form-urlencode', + ... 'Authorization': 'Basic ' + auth, + ... } + + >>> from httplib2 import Http + >>> response, content = Http().request(url, 'GET', None, headers) + >>> print content + 401 Unauthorized + <BLANKLINE> + User is not authorized for the REST API + <BLANKLINE> + +But with the right headers, the request succeeds. + + >>> auth = b64encode('{0}:{1}'.format(config.webservice.admin_user, + ... config.webservice.admin_pass)) + >>> headers['Authorization'] = 'Basic ' + auth + >>> response, content = Http().request(url, 'GET', None, headers) + >>> print response.status + 200 + + +.. _REST: http://en.wikipedia.org/wiki/REST diff --git a/src/mailman/rest/docs/configuration.txt b/src/mailman/rest/docs/configuration.txt new file mode 100644 index 000000000..b149a9431 --- /dev/null +++ b/src/mailman/rest/docs/configuration.txt @@ -0,0 +1,400 @@ +========================== +Mailing list configuration +========================== + +Mailing lists can be configured via the REST API. + + >>> mlist = create_list('test-one@example.com') + >>> transaction.commit() + + +Reading a configuration +======================= + +All readable attributes for a list are available on a sub-resource. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config') + acceptable_aliases: [] + admin_immed_notify: True + admin_notify_mchanges: False + administrivia: True + advertised: True + anonymous_list: False + autorespond_owner: none + autorespond_postings: none + autorespond_requests: none + autoresponse_grace_period: 90d + autoresponse_owner_text: + autoresponse_postings_text: + autoresponse_request_text: + bounces_address: test-one-bounces@example.com + collapse_alternatives: True + convert_html_to_plaintext: False + created_at: 20...T... + default_member_action: defer + default_nonmember_action: hold + description: + digest_last_sent_at: None + digest_size_threshold: 30.0 + filter_content: False + fqdn_listname: test-one@example.com + generic_nonmember_action: 1 + host_name: example.com + http_etag: "..." + include_list_post_header: True + include_rfc2369_headers: True + join_address: test-one-join@example.com + last_post_at: None + leave_address: test-one-leave@example.com + list_id: test-one.example.com + list_name: test-one + next_digest_number: 1 + no_reply_address: noreply@example.com + owner_address: test-one-owner@example.com + pipeline: built-in + post_id: 1 + posting_address: test-one@example.com + real_name: Test-one + reply_goes_to_list: no_munging + request_address: test-one-request@example.com + scheme: http + send_welcome_msg: True + volume: 1 + web_host: lists.example.com + welcome_msg: + + +Changing the full configuration +=============================== + +Not all of the readable attributes can be set through the web interface. The +ones that can, can either be set via ``PUT`` or ``PATCH``. ``PUT`` changes +all the writable attributes in one request. + + >>> from mailman.interfaces.action import Action + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config', + ... dict( + ... acceptable_aliases=['one@example.com', 'two@example.com'], + ... admin_immed_notify=False, + ... admin_notify_mchanges=True, + ... administrivia=False, + ... advertised=False, + ... anonymous_list=True, + ... autorespond_owner='respond_and_discard', + ... autorespond_postings='respond_and_continue', + ... autorespond_requests='respond_and_discard', + ... autoresponse_grace_period='45d', + ... autoresponse_owner_text='the owner', + ... autoresponse_postings_text='the mailing list', + ... autoresponse_request_text='the robot', + ... real_name='Fnords', + ... description='This is my mailing list', + ... include_rfc2369_headers=False, + ... include_list_post_header=False, + ... digest_size_threshold=10.5, + ... pipeline='virgin', + ... filter_content=True, + ... convert_html_to_plaintext=True, + ... collapse_alternatives=False, + ... reply_goes_to_list='point_to_list', + ... send_welcome_msg=False, + ... welcome_msg='Welcome!', + ... default_member_action='hold', + ... default_nonmember_action='discard', + ... generic_nonmember_action=2, + ... ), + ... 'PUT') + content-length: 0 + date: ... + server: WSGIServer/... + status: 200 + +These values are changed permanently. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config') + acceptable_aliases: [u'one@example.com', u'two@example.com'] + admin_immed_notify: False + admin_notify_mchanges: True + administrivia: False + advertised: False + anonymous_list: True + autorespond_owner: respond_and_discard + autorespond_postings: respond_and_continue + autorespond_requests: respond_and_discard + autoresponse_grace_period: 45d + autoresponse_owner_text: the owner + autoresponse_postings_text: the mailing list + autoresponse_request_text: the robot + ... + collapse_alternatives: False + convert_html_to_plaintext: True + ... + default_member_action: hold + default_nonmember_action: discard + description: This is my mailing list + ... + digest_size_threshold: 10.5 + filter_content: True + ... + include_list_post_header: False + include_rfc2369_headers: False + ... + pipeline: virgin + ... + real_name: Fnords + reply_goes_to_list: point_to_list + ... + send_welcome_msg: False + ... + welcome_msg: Welcome! + +If you use ``PUT`` to change a list's configuration, all writable attributes +must be included. It is an error to leave one or more out... + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config', + ... dict( + ... #acceptable_aliases=['one', 'two'], + ... admin_immed_notify=False, + ... admin_notify_mchanges=True, + ... administrivia=False, + ... advertised=False, + ... anonymous_list=True, + ... autorespond_owner='respond_and_discard', + ... autorespond_postings='respond_and_continue', + ... autorespond_requests='respond_and_discard', + ... autoresponse_grace_period='45d', + ... autoresponse_owner_text='the owner', + ... autoresponse_postings_text='the mailing list', + ... autoresponse_request_text='the robot', + ... real_name='Fnords', + ... description='This is my mailing list', + ... include_rfc2369_headers=False, + ... include_list_post_header=False, + ... digest_size_threshold=10.5, + ... pipeline='virgin', + ... filter_content=True, + ... convert_html_to_plaintext=True, + ... collapse_alternatives=False, + ... reply_goes_to_list='point_to_list', + ... send_welcome_msg=True, + ... welcome_msg='welcome message', + ... default_member_action='accept', + ... default_nonmember_action='accept', + ... generic_nonmember_action=2, + ... ), + ... 'PUT') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 400: Missing parameters: acceptable_aliases + +...or to add an unknown one. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config', + ... dict( + ... a_mailing_list_attribute=False, + ... acceptable_aliases=['one', 'two'], + ... admin_immed_notify=False, + ... admin_notify_mchanges=True, + ... administrivia=False, + ... advertised=False, + ... anonymous_list=True, + ... autorespond_owner='respond_and_discard', + ... autorespond_postings='respond_and_continue', + ... autorespond_requests='respond_and_discard', + ... autoresponse_grace_period='45d', + ... autoresponse_owner_text='the owner', + ... autoresponse_postings_text='the mailing list', + ... autoresponse_request_text='the robot', + ... real_name='Fnords', + ... description='This is my mailing list', + ... include_rfc2369_headers=False, + ... include_list_post_header=False, + ... digest_size_threshold=10.5, + ... pipeline='virgin', + ... filter_content=True, + ... convert_html_to_plaintext=True, + ... collapse_alternatives=False, + ... ), + ... 'PUT') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 400: Unexpected parameters: a_mailing_list_attribute + +It is also an error to spell an attribute value incorrectly... + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config', + ... dict( + ... admin_immed_notify='Nope', + ... acceptable_aliases=['one', 'two'], + ... admin_notify_mchanges=True, + ... administrivia=False, + ... advertised=False, + ... anonymous_list=True, + ... autorespond_owner='respond_and_discard', + ... autorespond_postings='respond_and_continue', + ... autorespond_requests='respond_and_discard', + ... autoresponse_grace_period='45d', + ... autoresponse_owner_text='the owner', + ... autoresponse_postings_text='the mailing list', + ... autoresponse_request_text='the robot', + ... real_name='Fnords', + ... description='This is my mailing list', + ... include_rfc2369_headers=False, + ... include_list_post_header=False, + ... digest_size_threshold=10.5, + ... pipeline='virgin', + ... filter_content=True, + ... convert_html_to_plaintext=True, + ... collapse_alternatives=False, + ... ), + ... 'PUT') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 400: Cannot convert parameters: admin_immed_notify + +...or to name a pipeline that doesn't exist... + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config', + ... dict( + ... acceptable_aliases=['one', 'two'], + ... admin_immed_notify=False, + ... admin_notify_mchanges=True, + ... advertised=False, + ... anonymous_list=True, + ... autorespond_owner='respond_and_discard', + ... autorespond_postings='respond_and_continue', + ... autorespond_requests='respond_and_discard', + ... autoresponse_grace_period='45d', + ... autoresponse_owner_text='the owner', + ... autoresponse_postings_text='the mailing list', + ... autoresponse_request_text='the robot', + ... real_name='Fnords', + ... description='This is my mailing list', + ... include_rfc2369_headers=False, + ... include_list_post_header=False, + ... digest_size_threshold=10.5, + ... pipeline='dummy', + ... filter_content=True, + ... convert_html_to_plaintext=True, + ... collapse_alternatives=False, + ... ), + ... 'PUT') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 400: Cannot convert parameters: pipeline + +...or to name an invalid auto-response enumeration value. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config', + ... dict( + ... acceptable_aliases=['one', 'two'], + ... admin_immed_notify=False, + ... admin_notify_mchanges=True, + ... advertised=False, + ... anonymous_list=True, + ... autorespond_owner='do_not_respond', + ... autorespond_postings='respond_and_continue', + ... autorespond_requests='respond_and_discard', + ... autoresponse_grace_period='45d', + ... autoresponse_owner_text='the owner', + ... autoresponse_postings_text='the mailing list', + ... autoresponse_request_text='the robot', + ... real_name='Fnords', + ... description='This is my mailing list', + ... include_rfc2369_headers=False, + ... include_list_post_header=False, + ... digest_size_threshold=10.5, + ... pipeline='virgin', + ... filter_content=True, + ... convert_html_to_plaintext=True, + ... collapse_alternatives=False, + ... ), + ... 'PUT') + Traceback (most recent call last): + ... + HTTPError: HTTP Error 400: Cannot convert parameters: autorespond_owner + + +Changing a partial configuration +================================ + +Using ``PATCH``, you can change just one attribute. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config', + ... dict(real_name='My List'), + ... 'PATCH') + content-length: 0 + date: ... + server: ... + status: 200 + +These values are changed permanently. + + >>> print mlist.real_name + My List + + +Sub-resources +============= + +Many of the mailing list configuration variables are actually available as +sub-resources on the mailing list. This is because they are collections, +sequences, and other complex configuration types. Their values can be +retrieved and set through the sub-resource. + + +Acceptable aliases +------------------ + +These are recipient aliases that can be used in the ``To:`` and ``CC:`` +headers instead of the posting address. They are often used in forwarded +emails. By default, a mailing list has no acceptable aliases. + + >>> from mailman.interfaces.mailinglist import IAcceptableAliasSet + >>> IAcceptableAliasSet(mlist).clear() + >>> transaction.commit() + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config/acceptable_aliases') + acceptable_aliases: [] + http_etag: "..." + +We can add a few by ``PUT``-ing them on the sub-resource. The keys in the +dictionary are ignored. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config/acceptable_aliases', + ... dict(acceptable_aliases=['foo@example.com', + ... 'bar@example.net']), + ... 'PUT') + content-length: 0 + date: ... + server: WSGIServer/... + status: 200 + +Aliases are returned as a list on the ``aliases`` key. + + >>> response = call_http( + ... 'http://localhost:9001/3.0/lists/' + ... 'test-one@example.com/config/acceptable_aliases') + >>> for alias in response['acceptable_aliases']: + ... print alias + bar@example.net + foo@example.com + +The mailing list has its aliases set. + + >>> from mailman.interfaces.mailinglist import IAcceptableAliasSet + >>> aliases = IAcceptableAliasSet(mlist) + >>> for alias in sorted(aliases.aliases): + ... print alias + bar@example.net + foo@example.com diff --git a/src/mailman/rest/docs/domains.txt b/src/mailman/rest/docs/domains.txt index b8e0170b0..29592fadb 100644 --- a/src/mailman/rest/docs/domains.txt +++ b/src/mailman/rest/docs/domains.txt @@ -2,6 +2,9 @@ Domains ======= +`Domains`_ are how Mailman interacts with email host names and web host names. +:: + # The test framework starts out with an example domain, so let's delete # that first. >>> from mailman.interfaces.domain import IDomainManager @@ -15,12 +18,13 @@ Domains The REST API can be queried for the set of known domains, of which there are initially none. - >>> dump_json('http://localhost:8001/3.0/domains') + >>> dump_json('http://localhost:9001/3.0/domains') http_etag: "..." start: 0 total_size: 0 -Once a domain is added though, it is accessible through the API. +Once a domain is added, it is accessible through the API. +:: >>> domain_manager.add( ... 'example.com', 'An example domain', 'http://lists.example.com') @@ -29,20 +33,21 @@ Once a domain is added though, it is accessible through the API. contact_address: postmaster@example.com> >>> transaction.commit() - >>> dump_json('http://localhost:8001/3.0/domains') + >>> dump_json('http://localhost:9001/3.0/domains') entry 0: base_url: http://lists.example.com contact_address: postmaster@example.com description: An example domain email_host: example.com http_etag: "..." - self_link: http://localhost:8001/3.0/domains/example.com + self_link: http://localhost:9001/3.0/domains/example.com url_host: lists.example.com http_etag: "..." start: 0 total_size: 1 At the top level, all domains are returned as separate entries. +:: >>> domain_manager.add( ... 'example.org', @@ -60,14 +65,14 @@ At the top level, all domains are returned as separate entries. contact_address: porkmaster@example.net> >>> transaction.commit() - >>> dump_json('http://localhost:8001/3.0/domains') + >>> dump_json('http://localhost:9001/3.0/domains') entry 0: base_url: http://lists.example.com contact_address: postmaster@example.com description: An example domain email_host: example.com http_etag: "..." - self_link: http://localhost:8001/3.0/domains/example.com + self_link: http://localhost:9001/3.0/domains/example.com url_host: lists.example.com entry 1: base_url: http://mail.example.org @@ -75,7 +80,7 @@ At the top level, all domains are returned as separate entries. description: None email_host: example.org http_etag: "..." - self_link: http://localhost:8001/3.0/domains/example.org + self_link: http://localhost:9001/3.0/domains/example.org url_host: mail.example.org entry 2: base_url: http://example.net @@ -83,7 +88,7 @@ At the top level, all domains are returned as separate entries. description: Porkmasters email_host: lists.example.net http_etag: "..." - self_link: http://localhost:8001/3.0/domains/lists.example.net + self_link: http://localhost:9001/3.0/domains/lists.example.net url_host: example.net http_etag: "..." start: 0 @@ -94,50 +99,51 @@ Individual domains ================== The information for a single domain is available by following one of the -self_links from the above collection. +``self_links`` from the above collection. - >>> dump_json('http://localhost:8001/3.0/domains/lists.example.net') + >>> dump_json('http://localhost:9001/3.0/domains/lists.example.net') base_url: http://example.net contact_address: porkmaster@example.net description: Porkmasters email_host: lists.example.net http_etag: "..." - self_link: http://localhost:8001/3.0/domains/lists.example.net + self_link: http://localhost:9001/3.0/domains/lists.example.net url_host: example.net But we get a 404 for a non-existent domain. - >>> dump_json('http://localhost:8001/3.0/domains/does-not-exist') + >>> dump_json('http://localhost:9001/3.0/domains/does-not-exist') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found Creating new domains ==================== -New domains can be created by posting to the 'domains' url. +New domains can be created by posting to the ``domains`` url. - >>> dump_json('http://localhost:8001/3.0/domains', { + >>> dump_json('http://localhost:9001/3.0/domains', { ... 'email_host': 'lists.example.com', ... }) content-length: 0 date: ... - location: http://localhost:8001/3.0/domains/lists.example.com + location: http://localhost:9001/3.0/domains/lists.example.com ... Now the web service knows about our new domain. - >>> dump_json('http://localhost:8001/3.0/domains/lists.example.com') + >>> dump_json('http://localhost:9001/3.0/domains/lists.example.com') base_url: http://lists.example.com contact_address: postmaster@lists.example.com description: None email_host: lists.example.com http_etag: "..." - self_link: http://localhost:8001/3.0/domains/lists.example.com + self_link: http://localhost:9001/3.0/domains/lists.example.com url_host: lists.example.com And the new domain is in our database. +:: >>> domain_manager['lists.example.com'] <Domain lists.example.com, @@ -149,8 +155,9 @@ And the new domain is in our database. You can also create a new domain with a description, a base url, and a contact address. +:: - >>> dump_json('http://localhost:8001/3.0/domains', { + >>> dump_json('http://localhost:9001/3.0/domains', { ... 'email_host': 'my.example.com', ... 'description': 'My new domain', ... 'base_url': 'http://allmy.example.com', @@ -158,16 +165,16 @@ address. ... }) content-length: 0 date: ... - location: http://localhost:8001/3.0/domains/my.example.com + location: http://localhost:9001/3.0/domains/my.example.com ... - >>> dump_json('http://localhost:8001/3.0/domains/my.example.com') + >>> dump_json('http://localhost:9001/3.0/domains/my.example.com') base_url: http://allmy.example.com contact_address: helpme@example.com description: My new domain email_host: my.example.com http_etag: "..." - self_link: http://localhost:8001/3.0/domains/my.example.com + self_link: http://localhost:9001/3.0/domains/my.example.com url_host: allmy.example.com >>> domain_manager['my.example.com'] @@ -177,3 +184,6 @@ address. # Unlock the database. >>> transaction.abort() + + +.. _Domains: ../../model/docs/domains.html diff --git a/src/mailman/rest/docs/helpers.txt b/src/mailman/rest/docs/helpers.txt index 7b9aa9863..4f0b1c804 100644 --- a/src/mailman/rest/docs/helpers.txt +++ b/src/mailman/rest/docs/helpers.txt @@ -14,10 +14,11 @@ function can return them the full path to the resource. >>> from mailman.rest.helpers import path_to >>> print path_to('system') - http://localhost:8001/3.0/system + http://localhost:9001/3.0/system -Parameters like the scheme, host, port, and API version number can be set in -the configuration file. +Parameters like the ``scheme``, ``host``, ``port``, and API version number can +be set in the configuration file. +:: >>> config.push('helpers', """ ... [webservice] @@ -34,10 +35,10 @@ the configuration file. Etags ===== -HTTP etags are a way for clients to decide whether their copy of a resource +HTTP *etags* are a way for clients to decide whether their copy of a resource has changed or not. Mailman's REST API calculates this in a cheap and dirty way. Pass in the dictionary representing the resource and that dictionary -gets modified to contain the etag under the `http_etag` key. +gets modified to contain the etag under the ``http_etag`` key. >>> from mailman.rest.helpers import etag >>> resource = dict(geddy='bass', alex='guitar', neil='drums') @@ -47,6 +48,7 @@ gets modified to contain the etag under the `http_etag` key. For convenience, the etag function also returns the JSON representation of the dictionary after tagging, since that's almost always what you want. +:: >>> import json >>> data = json.loads(json_data) @@ -62,17 +64,18 @@ dictionary after tagging, since that's almost always what you want. POST unpacking ============== -Another helper unpacks POST request variables, validating and converting their -values. +Another helper unpacks ``POST`` request variables, validating and converting +their values. +:: - >>> from mailman.rest.helpers import Validator + >>> from mailman.rest.validator import Validator >>> validator = Validator(one=int, two=unicode, three=bool) >>> class FakeRequest: ... POST = {} >>> FakeRequest.POST = dict(one='1', two='two', three='yes') -On valid input, the validator can be used as a **kw argument. +On valid input, the validator can be used as a ``**keyword`` argument. >>> def print_request(one, two, three): ... print repr(one), repr(two), repr(three) @@ -113,6 +116,7 @@ Extra keys are also not allowed. ValueError: Unexpected parameters: five, four However, if optional keys are missing, it's okay. +:: >>> validator = Validator(one=int, two=unicode, three=bool, ... four=int, five=int, @@ -141,3 +145,58 @@ But if the optional values are present, they must of course also be valid. Traceback (most recent call last): ... ValueError: Cannot convert parameters: five, four + + +Arrays +------ + +Some ``POST`` forms include more than one value for a particular key. This is +how lists and arrays are modeled. The validator does the right thing with +such form data. Specifically, when a key shows up multiple times in the form +data, a list is given to the validator. +:: + + # Of course we can't use a normal dictionary, but webob has a useful data + # type we can use. + >>> from webob.multidict import MultiDict + >>> form_data = MultiDict(one='1', many='3') + >>> form_data.add('many', '4') + >>> form_data.add('many', '5') + +This is a validation function that ensures the value is a list. + + >>> def must_be_list(value): + ... if not isinstance(value, list): + ... raise ValueError('not a list') + ... return [int(item) for item in value] + +This is a validation function that ensure the value is *not* a list. + + >>> def must_be_scalar(value): + ... if isinstance(value, list): + ... raise ValueError('is a list') + ... return int(value) + +And a validator to pull it all together. + + >>> validator = Validator(one=must_be_scalar, many=must_be_list) + >>> FakeRequest.POST = form_data + >>> values = validator(FakeRequest) + >>> print values['one'] + 1 + >>> print values['many'] + [3, 4, 5] + +The list values are guaranteed to be in the same order they show up in the +form data. + + >>> from webob.multidict import MultiDict + >>> form_data = MultiDict(one='1', many='3') + >>> form_data.add('many', '5') + >>> form_data.add('many', '4') + >>> FakeRequest.POST = form_data + >>> values = validator(FakeRequest) + >>> print values['one'] + 1 + >>> print values['many'] + [3, 5, 4] diff --git a/src/mailman/rest/docs/lists.txt b/src/mailman/rest/docs/lists.txt index 504d16feb..32cca5fb7 100644 --- a/src/mailman/rest/docs/lists.txt +++ b/src/mailman/rest/docs/lists.txt @@ -6,25 +6,26 @@ The REST API can be queried for the set of known mailing lists. There is a top level collection that can return all the mailing lists. There aren't any yet though. - >>> dump_json('http://localhost:8001/3.0/lists') + >>> dump_json('http://localhost:9001/3.0/lists') http_etag: "..." start: 0 total_size: 0 Create a mailing list in a domain and it's accessible via the API. +:: >>> create_list('test-one@example.com') <mailing list "test-one@example.com" at ...> >>> transaction.commit() - >>> dump_json('http://localhost:8001/3.0/lists') + >>> dump_json('http://localhost:9001/3.0/lists') entry 0: fqdn_listname: test-one@example.com host_name: example.com http_etag: "..." list_name: test-one real_name: Test-one - self_link: http://localhost:8001/3.0/lists/test-one@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com http_etag: "..." start: 0 total_size: 1 @@ -34,17 +35,18 @@ Creating lists via the API ========================== New mailing lists can also be created through the API, by posting to the -'lists' URL. +``lists`` URL. - >>> dump_json('http://localhost:8001/3.0/lists', { + >>> dump_json('http://localhost:9001/3.0/lists', { ... 'fqdn_listname': 'test-two@example.com', ... }) content-length: 0 date: ... - location: http://localhost:8001/3.0/lists/test-two@example.com + location: http://localhost:9001/3.0/lists/test-two@example.com ... The mailing list exists in the database. +:: >>> from mailman.interfaces.listmanager import IListManager >>> from zope.component import getUtility @@ -59,41 +61,42 @@ The mailing list exists in the database. It is also available via the location given in the response. - >>> dump_json('http://localhost:8001/3.0/lists/test-two@example.com') + >>> dump_json('http://localhost:9001/3.0/lists/test-two@example.com') fqdn_listname: test-two@example.com host_name: example.com http_etag: "..." list_name: test-two real_name: Test-two - self_link: http://localhost:8001/3.0/lists/test-two@example.com + self_link: http://localhost:9001/3.0/lists/test-two@example.com However, you are not allowed to create a mailing list in a domain that does not exist. - >>> dump_json('http://localhost:8001/3.0/lists', { + >>> dump_json('http://localhost:9001/3.0/lists', { ... 'fqdn_listname': 'test-three@example.org', ... }) Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Domain does not exist example.org Nor can you create a mailing list that already exists. - >>> dump_json('http://localhost:8001/3.0/lists', { + >>> dump_json('http://localhost:9001/3.0/lists', { ... 'fqdn_listname': 'test-one@example.com', ... }) Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Mailing list exists Deleting lists via the API ========================== -Existing mailing lists can be deleted through the API, by doing an HTTP DELETE -on the mailing list URL. +Existing mailing lists can be deleted through the API, by doing an HTTP +``DELETE`` on the mailing list URL. +:: - >>> dump_json('http://localhost:8001/3.0/lists/test-two@example.com', + >>> dump_json('http://localhost:9001/3.0/lists/test-two@example.com', ... method='DELETE') content-length: 0 date: ... @@ -111,15 +114,16 @@ The mailing list does not exist. You cannot delete a mailing list that does not exist or has already been deleted. +:: - >>> dump_json('http://localhost:8001/3.0/lists/test-two@example.com', + >>> dump_json('http://localhost:9001/3.0/lists/test-two@example.com', ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found - >>> dump_json('http://localhost:8001/3.0/lists/test-ten@example.com', + >>> dump_json('http://localhost:9001/3.0/lists/test-ten@example.com', ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found diff --git a/src/mailman/rest/docs/membership.txt b/src/mailman/rest/docs/membership.txt index 59af8f2f7..553f6fde9 100644 --- a/src/mailman/rest/docs/membership.txt +++ b/src/mailman/rest/docs/membership.txt @@ -3,22 +3,23 @@ Membership ========== The REST API can be used to subscribe and unsubscribe users to mailing lists. -A subscribed user is called a 'member'. There is a top level collection that +A subscribed user is called a *member*. There is a top level collection that returns all the members of all known mailing lists. There are no mailing lists and no members yet. - >>> dump_json('http://localhost:8001/3.0/members') + >>> dump_json('http://localhost:9001/3.0/members') http_etag: "..." start: 0 total_size: 0 We create a mailing list, which starts out with no members. +:: >>> mlist_one = create_list('test-one@example.com') >>> transaction.commit() - >>> dump_json('http://localhost:8001/3.0/members') + >>> dump_json('http://localhost:9001/3.0/members') http_etag: "..." start: 0 total_size: 0 @@ -29,6 +30,7 @@ Subscribers After Bart subscribes to the mailing list, his subscription is available via the REST interface. +:: >>> from mailman.interfaces.member import MemberRole >>> from mailman.interfaces.usermanager import IUserManager @@ -37,10 +39,12 @@ the REST interface. >>> from mailman.testing.helpers import subscribe >>> subscribe(mlist_one, 'Bart') - >>> dump_json('http://localhost:8001/3.0/members') + >>> dump_json('http://localhost:9001/3.0/members') entry 0: + address: bperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/bperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/bperson@example.com http_etag: "..." start: 0 total_size: 1 @@ -49,59 +53,81 @@ When Cris also joins the mailing list, her subscription is also available via the REST interface. >>> subscribe(mlist_one, 'Cris') - >>> dump_json('http://localhost:8001/3.0/members') + >>> dump_json('http://localhost:9001/3.0/members') entry 0: + address: bperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/bperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/bperson@example.com entry 1: + address: cperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/cperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/cperson@example.com http_etag: "..." start: 0 total_size: 2 The subscribed members are returned in alphabetical order, so when Anna subscribes, she is returned first. +:: >>> subscribe(mlist_one, 'Anna') - >>> dump_json('http://localhost:8001/3.0/members') + >>> dump_json('http://localhost:9001/3.0/members') entry 0: + address: aperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/aperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/aperson@example.com entry 1: + address: bperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/bperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/bperson@example.com entry 2: + address: cperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/cperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/cperson@example.com http_etag: "..." start: 0 total_size: 3 Subscriptions are also returned alphabetically by mailing list posting address. Anna and Cris subscribe to this new mailing list. +:: >>> mlist_two = create_list('alpha@example.com') >>> subscribe(mlist_two, 'Anna') >>> subscribe(mlist_two, 'Cris') - >>> dump_json('http://localhost:8001/3.0/members') + >>> dump_json('http://localhost:9001/3.0/members') entry 0: + address: aperson@example.com + fqdn_listname: alpha@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/aperson@example.com + self_link: http://localhost:9001/3.0/lists/alpha@example.com/member/aperson@example.com entry 1: + address: cperson@example.com + fqdn_listname: alpha@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/cperson@example.com + self_link: http://localhost:9001/3.0/lists/alpha@example.com/member/cperson@example.com entry 2: + address: aperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/aperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/aperson@example.com entry 3: + address: bperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/bperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/bperson@example.com entry 4: + address: cperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/cperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/cperson@example.com http_etag: "..." start: 0 total_size: 5 @@ -109,13 +135,17 @@ address. Anna and Cris subscribe to this new mailing list. We can also get just the members of a single mailing list. >>> dump_json( - ... 'http://localhost:8001/3.0/lists/alpha@example.com/roster/members') + ... 'http://localhost:9001/3.0/lists/alpha@example.com/roster/members') entry 0: + address: aperson@example.com + fqdn_listname: alpha@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/aperson@example.com + self_link: http://localhost:9001/3.0/lists/alpha@example.com/member/aperson@example.com entry 1: + address: cperson@example.com + fqdn_listname: alpha@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/cperson@example.com + self_link: http://localhost:9001/3.0/lists/alpha@example.com/member/cperson@example.com http_etag: ... start: 0 total_size: 2 @@ -127,32 +157,47 @@ Owners and moderators Mailing list owners and moderators also show up in the REST API. Cris becomes an owner of the alpha mailing list and Dave becomes a moderator of the test-one mailing list. +:: >>> subscribe(mlist_one, 'Cris', MemberRole.owner) >>> subscribe(mlist_two, 'Dave', MemberRole.moderator) - >>> dump_json('http://localhost:8001/3.0/members') + >>> dump_json('http://localhost:9001/3.0/members') entry 0: + address: dperson@example.com + fqdn_listname: alpha@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/alpha@example.com/moderator/dperson@example.com + self_link: http://localhost:9001/3.0/lists/alpha@example.com/moderator/dperson@example.com entry 1: + address: aperson@example.com + fqdn_listname: alpha@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/aperson@example.com + self_link: http://localhost:9001/3.0/lists/alpha@example.com/member/aperson@example.com entry 2: + address: cperson@example.com + fqdn_listname: alpha@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/cperson@example.com + self_link: http://localhost:9001/3.0/lists/alpha@example.com/member/cperson@example.com entry 3: + address: cperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/owner/cperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/owner/cperson@example.com entry 4: + address: aperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/aperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/aperson@example.com entry 5: + address: bperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/bperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/bperson@example.com entry 6: + address: cperson@example.com + fqdn_listname: test-one@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/test-one@example.com/member/cperson@example.com + self_link: http://localhost:9001/3.0/lists/test-one@example.com/member/cperson@example.com http_etag: "..." start: 0 total_size: 7 @@ -169,17 +214,18 @@ Elly subscribes to the alpha mailing list. By default, get gets a regular delivery. Since Elly's email address is not yet known to Mailman, a user is created for her. - >>> dump_json('http://localhost:8001/3.0/members', { + >>> dump_json('http://localhost:9001/3.0/members', { ... 'fqdn_listname': 'alpha@example.com', ... 'address': 'eperson@example.com', ... 'real_name': 'Elly Person', ... }) content-length: 0 date: ... - location: http://localhost:8001/3.0/lists/alpha@example.com/member/eperson@example.com + location: http://localhost:9001/3.0/lists/alpha@example.com/member/eperson@example.com ... Elly is now a member of the mailing list. +:: >>> elly = user_manager.get_user('eperson@example.com') >>> elly @@ -188,12 +234,14 @@ Elly is now a member of the mailing list. >>> set(member.mailing_list for member in elly.memberships.members) set([u'alpha@example.com']) - >>> dump_json('http://localhost:8001/3.0/members') + >>> dump_json('http://localhost:9001/3.0/members') entry 0: ... entry 3: + address: eperson@example.com + fqdn_listname: alpha@example.com http_etag: ... - self_link: http://localhost:8001/3.0/lists/alpha@example.com/member/eperson@example.com + self_link: http://localhost:9001/3.0/lists/alpha@example.com/member/eperson@example.com ... @@ -202,10 +250,11 @@ Leaving a mailing list Elly decides she does not want to be a member of the mailing list after all, so she leaves from the mailing list. +:: # Ensure our previous reads don't keep the database lock. >>> transaction.abort() - >>> dump_json('http://localhost:8001/3.0/lists/alpha@example.com' + >>> dump_json('http://localhost:9001/3.0/lists/alpha@example.com' ... '/member/eperson@example.com', ... method='DELETE') content-length: 0 @@ -222,9 +271,10 @@ Digest delivery =============== Fred joins the alpha mailing list but wants MIME digest delivery. +:: >>> transaction.abort() - >>> dump_json('http://localhost:8001/3.0/members', { + >>> dump_json('http://localhost:9001/3.0/members', { ... 'fqdn_listname': 'alpha@example.com', ... 'address': 'fperson@example.com', ... 'real_name': 'Fred Person', @@ -232,7 +282,7 @@ Fred joins the alpha mailing list but wants MIME digest delivery. ... }) content-length: 0 ... - location: http://localhost:8001/3.0/lists/alpha@example.com/member/fperson@example.com + location: http://localhost:9001/3.0/lists/alpha@example.com/member/fperson@example.com ... status: 201 @@ -250,56 +300,56 @@ Corner cases For some reason Elly tries to join a mailing list that does not exist. - >>> dump_json('http://localhost:8001/3.0/members', { + >>> dump_json('http://localhost:9001/3.0/members', { ... 'fqdn_listname': 'beta@example.com', ... 'address': 'eperson@example.com', ... 'real_name': 'Elly Person', ... }) Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: No such list Then, she tries to leave a mailing list that does not exist. - >>> dump_json('http://localhost:8001/3.0/lists/beta@example.com' + >>> dump_json('http://localhost:9001/3.0/lists/beta@example.com' ... '/members/eperson@example.com', ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found She then tries to leave a mailing list with a bogus address. - >>> dump_json('http://localhost:8001/3.0/lists/alpha@example.com' + >>> dump_json('http://localhost:9001/3.0/lists/alpha@example.com' ... '/members/elly', ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found For some reason, Elly tries to leave the mailing list again, but she's already been unsubscribed. - >>> dump_json('http://localhost:8001/3.0/lists/alpha@example.com' + >>> dump_json('http://localhost:9001/3.0/lists/alpha@example.com' ... '/members/eperson@example.com', ... method='DELETE') Traceback (most recent call last): ... - HTTPError: HTTP Error 404: Not Found + HTTPError: HTTP Error 404: 404 Not Found Anna tries to join a mailing list she's already a member of. - >>> dump_json('http://localhost:8001/3.0/members', { + >>> dump_json('http://localhost:9001/3.0/members', { ... 'fqdn_listname': 'alpha@example.com', ... 'address': 'aperson@example.com', ... }) Traceback (most recent call last): ... - HTTPError: HTTP Error 409: Conflict + HTTPError: HTTP Error 409: Member already subscribed Gwen tries to join the alpha mailing list using an invalid delivery mode. - >>> dump_json('http://localhost:8001/3.0/members', { + >>> dump_json('http://localhost:9001/3.0/members', { ... 'fqdn_listname': 'alpha@example.com', ... 'address': 'gperson@example.com', ... 'real_name': 'Gwen Person', @@ -307,17 +357,17 @@ Gwen tries to join the alpha mailing list using an invalid delivery mode. ... }) Traceback (most recent call last): ... - HTTPError: HTTP Error 400: Bad Request + HTTPError: HTTP Error 400: Cannot convert parameters: delivery_mode Even using an address with "funny" characters Hugh can join the mailing list. >>> transaction.abort() - >>> dump_json('http://localhost:8001/3.0/members', { + >>> dump_json('http://localhost:9001/3.0/members', { ... 'fqdn_listname': 'alpha@example.com', ... 'address': 'hugh/person@example.com', ... 'real_name': 'Hugh Person', ... }) content-length: 0 date: ... - location: http://localhost:8001/3.0/lists/alpha@example.com/member/hugh%2Fperson@example.com + location: http://localhost:9001/3.0/lists/alpha@example.com/member/hugh%2Fperson@example.com ... diff --git a/src/mailman/rest/domains.py b/src/mailman/rest/domains.py index 8bc68a6c1..edb72af61 100644 --- a/src/mailman/rest/domains.py +++ b/src/mailman/rest/domains.py @@ -1,4 +1,4 @@ -# Copyright (C) 2010 by the Free Software Foundation, Inc. +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -31,7 +31,8 @@ from zope.component import getUtility from mailman.interfaces.domain import ( BadDomainSpecificationError, IDomainManager) -from mailman.rest.helpers import CollectionMixin, Validator, etag, path_to +from mailman.rest.helpers import CollectionMixin, etag, path_to +from mailman.rest.validator import Validator diff --git a/src/mailman/rest/helpers.py b/src/mailman/rest/helpers.py index be5d2b565..7257a78fa 100644 --- a/src/mailman/rest/helpers.py +++ b/src/mailman/rest/helpers.py @@ -1,4 +1,4 @@ -# Copyright (C) 2010 by the Free Software Foundation, Inc. +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -21,7 +21,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'ContainerMixin', + 'PATCH', 'etag', 'no_content', 'path_to', @@ -29,18 +29,21 @@ __all__ = [ ] +import cgi import json import hashlib +from cStringIO import StringIO +from datetime import datetime, timedelta +from flufl.enum import Enum from lazr.config import as_boolean from restish.http import Response +from restish.resource import MethodDecorator +from webob.multidict import MultiDict from mailman.config import config -COMMASPACE = ', ' - - def path_to(resource): """Return the url path to a resource. @@ -61,6 +64,26 @@ def path_to(resource): +class ExtendedEncoder(json.JSONEncoder): + """An extended JSON encoder which knows about other data types.""" + + def default(self, obj): + if isinstance(obj, datetime): + return obj.isoformat() + elif isinstance(obj, timedelta): + # as_timedelta() does not recognize microseconds, so convert these + # to floating seconds, but only if there are any seconds. + if obj.seconds > 0 or obj.microseconds > 0: + seconds = obj.seconds + obj.microseconds / 1000000.0 + return '{0}d{1}s'.format(obj.days, seconds) + return '{0}d'.format(obj.days) + elif hasattr(obj, 'enumclass') and issubclass(obj.enumclass, Enum): + # It's up to the decoding validator to associate this name with + # the right Enum class. + return obj.enumname + return json.JSONEncoder.default(self, obj) + + def etag(resource): """Calculate the etag and return a JSON representation. @@ -78,7 +101,7 @@ def etag(resource): assert 'http_etag' not in resource, 'Resource already etagged' etag = hashlib.sha1(repr(resource)).hexdigest() resource['http_etag'] = '"{0}"'.format(etag) - return json.dumps(resource) + return json.dumps(resource, cls=ExtendedEncoder) @@ -131,45 +154,6 @@ class CollectionMixin: -class Validator: - """A validator of parameter input.""" - - def __init__(self, **kws): - if '_optional' in kws: - self._optional = set(kws.pop('_optional')) - else: - self._optional = set() - self._converters = kws.copy() - - def __call__(self, request): - values = {} - extras = set() - cannot_convert = set() - for key, value in request.POST.items(): - try: - values[key] = self._converters[key](value) - except KeyError: - extras.add(key) - except (TypeError, ValueError): - cannot_convert.add(key) - # Make sure there are no unexpected values. - if len(extras) != 0: - extras = COMMASPACE.join(sorted(extras)) - raise ValueError('Unexpected parameters: {0}'.format(extras)) - # Make sure everything could be converted. - if len(cannot_convert) != 0: - bad = COMMASPACE.join(sorted(cannot_convert)) - raise ValueError('Cannot convert parameters: {0}'.format(bad)) - # Make sure nothing's missing. - value_keys = set(values) - required_keys = set(self._converters) - self._optional - if value_keys & required_keys != required_keys: - missing = COMMASPACE.join(sorted(required_keys - value_keys)) - raise ValueError('Missing parameters: {0}'.format(missing)) - return values - - - # XXX 2010-02-24 barry Seems like contrary to the documentation, matchers # cannot be plain functions, because matchers must have a .score attribute. # OTOH, I think they support regexps, so that might be a better way to go. @@ -183,3 +167,41 @@ def restish_matcher(function): def no_content(): """204 No Content.""" return Response('204 No Content', [], None) + + +# These two classes implement an ugly, dirty hack to work around the fact that +# neither WebOb nor really the stdlib cgi module support non-standard HTTP +# verbs such as PATCH. Note that restish handles it just fine in the sense +# that the right method gets called, but without the following kludge, the +# body of the request will never get decoded, so the method won't see any +# data. +# +# Stuffing the MultiDict on request.PATCH is pretty ugly, but it mirrors +# WebOb's use of request.POST and request.PUT for those standard verbs. +# Besides, WebOb refuses to allow us to set request.POST. This does make +# validators.py a bit more complicated. :( + +class PATCHWrapper: + """Hack to decode the request body for PATCH.""" + def __init__(self, func): + self.func = func + + def __call__(self, resource, request): + # We can't use request.body_file because that's a socket that's + # already had its data read off of. IOW, if we use that directly, + # we'll block here. + field_storage = cgi.FieldStorage( + fp=StringIO(request.body), + # Yes, lie about the method so cgi will do the right thing. + environ=dict(REQUEST_METHOD='POST'), + keep_blank_values=True) + request.PATCH = MultiDict.from_fieldstorage(field_storage) + return self.func(resource, request) + + +class PATCH(MethodDecorator): + method = 'PATCH' + + def __call__(self, func): + really_wrapped_func = PATCHWrapper(func) + return super(PATCH, self).__call__(really_wrapped_func) diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py index ddda613c9..686dd27f3 100644 --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -1,4 +1,4 @@ -# Copyright (C) 2010 by the Free Software Foundation, Inc. +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -23,6 +23,7 @@ __metaclass__ = type __all__ = [ 'AList', 'AllLists', + 'ListConfiguration', ] @@ -34,9 +35,11 @@ from mailman.interfaces.domain import BadDomainSpecificationError from mailman.interfaces.listmanager import ( IListManager, ListAlreadyExistsError) from mailman.interfaces.member import MemberRole +from mailman.rest.configuration import ListConfiguration from mailman.rest.helpers import ( - CollectionMixin, Validator, etag, no_content, path_to, restish_matcher) + CollectionMixin, etag, no_content, path_to, restish_matcher) from mailman.rest.members import AMember, MembersOfList +from mailman.rest.validator import Validator @@ -80,6 +83,23 @@ def roster_matcher(request, segments): return None +@restish_matcher +def config_matcher(request, segments): + """A matcher for a mailing list's configuration resource. + + e.g. /config + e.g. /config/description + """ + if len(segments) < 1 or segments[0] != 'config': + return None + if len(segments) == 1: + return (), {}, () + if len(segments) == 2: + return (), dict(attribute=segments[1]), () + # More segments are not allowed. + return None + + class _ListBase(resource.Resource, CollectionMixin): """Shared base class for mailing list representations.""" @@ -133,7 +153,13 @@ class AList(_ListBase): """Return the collection of all a mailing list's members.""" return MembersOfList(self._mlist, role) + @resource.child(config_matcher) + def config(self, request, segments, attribute=None): + """Return a mailing list configuration object.""" + return ListConfiguration(self._mlist, attribute) + + class AllLists(_ListBase): """The mailing lists.""" diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py index abd0eff67..3235f8738 100644 --- a/src/mailman/rest/members.py +++ b/src/mailman/rest/members.py @@ -1,4 +1,4 @@ -# Copyright (C) 2010 by the Free Software Foundation, Inc. +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -38,7 +38,8 @@ from mailman.interfaces.listmanager import NoSuchListError from mailman.interfaces.member import ( AlreadySubscribedError, DeliveryMode, MemberRole) from mailman.interfaces.membership import ISubscriptionService -from mailman.rest.helpers import CollectionMixin, Validator, etag, path_to +from mailman.rest.helpers import CollectionMixin, etag, path_to +from mailman.rest.validator import Validator, enum_validator @@ -49,8 +50,10 @@ class _MemberBase(resource.Resource, CollectionMixin): """See `CollectionMixin`.""" enum, dot, role = str(member.role).partition('.') return dict( + fqdn_listname=member.mailing_list, + address=member.address.email, self_link=path_to('lists/{0}/{1}/{2}'.format( - member.mailing_list, role, member.address.address)), + member.mailing_list, role, member.address.email)), ) def _get_collection(self, request): @@ -97,7 +100,7 @@ class AllMembers(_MemberBase): validator = Validator(fqdn_listname=unicode, address=unicode, real_name=unicode, - delivery_mode=unicode, + delivery_mode=enum_validator(DeliveryMode), _optional=('real_name', 'delivery_mode')) member = service.join(**validator(request)) except AlreadySubscribedError: @@ -111,7 +114,7 @@ class AllMembers(_MemberBase): # wsgiref wants headers to be bytes, not unicodes. Also, we have to # quote any unsafe characters in the address. Specifically, we need # to quote forward slashes, but not @-signs. - quoted_address = quote(member.address.address, safe=b'@') + quoted_address = quote(member.address.email, safe=b'@') location = path_to('lists/{0}/member/{1}'.format( member.mailing_list, quoted_address)) # Include no extra headers or body. @@ -137,7 +140,7 @@ class MembersOfList(_MemberBase): # Overrides _MemberBase._get_collection() because we only want to # return the members from the requested roster. roster = self._mlist.get_roster(self._role) - address_of_member = attrgetter('address.address') + address_of_member = attrgetter('address.email') return list(sorted(roster.members, key=address_of_member)) @resource.GET() diff --git a/src/mailman/rest/root.py b/src/mailman/rest/root.py index 6835586b8..9d8c92428 100644 --- a/src/mailman/rest/root.py +++ b/src/mailman/rest/root.py @@ -1,4 +1,4 @@ -# Copyright (C) 2010 by the Free Software Foundation, Inc. +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,7 +25,8 @@ __all__ = [ ] -from restish import http, resource +from base64 import b64decode +from restish import guard, http, resource from mailman.config import config from mailman.core.system import system @@ -36,6 +37,19 @@ from mailman.rest.members import AllMembers +def webservice_auth_checker(request, obj): + auth = request.environ.get('HTTP_AUTHORIZATION', '') + if auth.startswith('Basic '): + credentials = b64decode(auth[6:]) + username, password = credentials.split(':', 1) + if (username != config.webservice.admin_user or + password != config.webservice.admin_pass): + # Not authorized. + raise guard.GuardError(b'User is not authorized for the REST API') + else: + raise guard.GuardError(b'The REST API requires authentication') + + class Root(resource.Resource): """The RESTful root resource. @@ -44,7 +58,9 @@ class Root(resource.Resource): and we start at 3.0 to match the Mailman version number. That may not always be the case though. """ + @resource.child(config.webservice.api_version) + @guard.guard(webservice_auth_checker) def api_version(self, request, segments): return TopLevel() diff --git a/src/mailman/rest/validator.py b/src/mailman/rest/validator.py new file mode 100644 index 000000000..d8297e519 --- /dev/null +++ b/src/mailman/rest/validator.py @@ -0,0 +1,99 @@ +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman 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 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman 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 +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""REST web form validation.""" + +from __future__ import absolute_import, unicode_literals + +__metaclass__ = type +__all__ = [ + 'Validator', + 'enum_validator', + ] + + +COMMASPACE = ', ' + + + +class enum_validator: + """Convert an enum value name into an enum value.""" + + def __init__(self, enum_class): + self._enum_class = enum_class + + def __call__(self, enum_value): + # This will raise a ValueError if the enum value is unknown. Let that + # percolate up. + return self._enum_class[enum_value] + + +class Validator: + """A validator of parameter input.""" + + def __init__(self, **kws): + if '_optional' in kws: + self._optional = set(kws.pop('_optional')) + else: + self._optional = set() + self._converters = kws.copy() + + def __call__(self, request): + values = {} + extras = set() + cannot_convert = set() + form_data = {} + # All keys which show up only once in the form data get a scalar value + # in the pre-converted dictionary. All keys which show up more than + # once get a list value. + missing = object() + # This is a gross hack to allow PATCH. See helpers.py for details. + try: + items = request.PATCH.items() + except AttributeError: + items = request.POST.items() + for key, new_value in items: + old_value = form_data.get(key, missing) + if old_value is missing: + form_data[key] = new_value + elif isinstance(old_value, list): + old_value.append(new_value) + else: + form_data[key] = [old_value, new_value] + # Now do all the conversions. + for key, value in form_data.items(): + try: + values[key] = self._converters[key](value) + except KeyError: + extras.add(key) + except (TypeError, ValueError): + cannot_convert.add(key) + # Make sure there are no unexpected values. + if len(extras) != 0: + extras = COMMASPACE.join(sorted(extras)) + raise ValueError('Unexpected parameters: {0}'.format(extras)) + # Make sure everything could be converted. + if len(cannot_convert) != 0: + bad = COMMASPACE.join(sorted(cannot_convert)) + raise ValueError('Cannot convert parameters: {0}'.format(bad)) + # Make sure nothing's missing. + value_keys = set(values) + required_keys = set(self._converters) - self._optional + if value_keys & required_keys != required_keys: + missing = COMMASPACE.join(sorted(required_keys - value_keys)) + raise ValueError('Missing parameters: {0}'.format(missing)) + return values diff --git a/src/mailman/rest/wsgiapp.py b/src/mailman/rest/wsgiapp.py index 4ee674fa4..48a749726 100644 --- a/src/mailman/rest/wsgiapp.py +++ b/src/mailman/rest/wsgiapp.py @@ -1,4 +1,4 @@ -# Copyright (C) 2010 by the Free Software Foundation, Inc. +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/administrivia.py b/src/mailman/rules/administrivia.py index b72a35839..a390e29b0 100644 --- a/src/mailman/rules/administrivia.py +++ b/src/mailman/rules/administrivia.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/any.py b/src/mailman/rules/any.py index e0174b924..0275c2928 100644 --- a/src/mailman/rules/any.py +++ b/src/mailman/rules/any.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/approved.py b/src/mailman/rules/approved.py index f7e62c511..51314cc02 100644 --- a/src/mailman/rules/approved.py +++ b/src/mailman/rules/approved.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -34,6 +34,12 @@ from mailman.interfaces.rules import IRule EMPTYSTRING = '' +HEADERS = [ + 'approve', + 'approved', + 'x-approve', + 'x-approved', + ] @@ -45,13 +51,20 @@ class Approved: description = _('The message has a matching Approve or Approved header.') record = True + def _get_password(self, msg, missing): + for header in HEADERS: + password = msg.get(header, missing) + if password is not missing: + return password + return missing + 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)) + password = self._get_password(msg, missing) if password is missing: # Find the first text/plain part in the message part = None @@ -60,13 +73,14 @@ class Approved: break payload = part.get_payload(decode=True) if payload is not None: + line = '' 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'): + if header.lower() in HEADERS: password = value.strip() # Now strip the first line from the payload so the # password doesn't leak. @@ -99,8 +113,8 @@ class Approved: if re.search(pattern, payload): reset_payload(part, re.sub(pattern, '', payload)) else: - del msg['approved'] - del msg['approve'] + for header in HEADERS: + del msg[header] return password is not missing and password == mlist.moderator_password diff --git a/src/mailman/rules/docs/administrivia.txt b/src/mailman/rules/docs/administrivia.txt index 082409622..bfc5efdcc 100644 --- a/src/mailman/rules/docs/administrivia.txt +++ b/src/mailman/rules/docs/administrivia.txt @@ -2,10 +2,10 @@ 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. +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. >>> mlist = create_list('_xtest@example.com') >>> mlist.administrivia = True @@ -13,8 +13,8 @@ used to catch messages posted to the list which should have been sent to the >>> print rule.name administrivia -For example, if the Subject header contains the word 'unsubscribe', the rule -matches. +For example, if the ``Subject:`` header contains the word ``unsubscribe``, the +rule matches. >>> msg_1 = message_from_string("""\ ... From: aperson@example.com @@ -24,7 +24,7 @@ matches. >>> rule.check(mlist, msg_1, {}) True -Similarly, if the body of the message contains the word 'subscribe' in the +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("""\ @@ -46,9 +46,9 @@ In both cases, administrivia checking can be disabled. 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. +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("""\ @@ -59,7 +59,7 @@ match. >>> rule.check(mlist, msg, {}) False -But a real 'confirm' message will match. +But a real ``confirm`` message will match. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -86,7 +86,7 @@ Of course, messages that don't contain administrivia, don't match the rule. >>> rule.check(mlist, msg, {}) False -Also, only text/plain parts are checked for administrivia, so any email +Also, only ``text/plain`` parts are checked for administrivia, so any email commands in other content type subparts are ignored. >>> msg = message_from_string("""\ diff --git a/src/mailman/rules/docs/approve.txt b/src/mailman/rules/docs/approve.txt index b92df3bdc..3e1206563 100644 --- a/src/mailman/rules/docs/approve.txt +++ b/src/mailman/rules/docs/approve.txt @@ -11,13 +11,13 @@ approval queue. This has several use cases: - An automated script can be programmed to send a message to an otherwise moderated list. -In order to support this, a mailing list can be given a 'moderator password' +In order to support this, a mailing list can be given a *moderator password* which is shared among all the administrators. >>> mlist = create_list('_xtest@example.com') >>> mlist.moderator_password = 'abcxyz' -The 'approved' rule determines whether the message contains the proper +The ``approved`` rule determines whether the message contains the proper approval or not. >>> rule = config.rules['approved'] @@ -28,8 +28,8 @@ approval or not. No approval =========== -If the message has no Approve or Approved header, then the rule does not -match. +If the message has no ``Approve:`` or ``Approved:`` header (or their ``X-`` +equivalents), then the rule does not match. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -39,9 +39,10 @@ match. >>> 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. +If the message has an ``Approve:``, ``Approved:``, ``X-Approve:``, or +``X-Approved:`` header with a value that does not match the moderator +password, then the rule does not match. However, the header is still removed. +:: >>> msg['Approve'] = '12345' >>> rule.check(mlist, msg, {}) @@ -57,13 +58,27 @@ header is still removed. None >>> del msg['approved'] + >>> msg['X-Approve'] = '12345' + >>> rule.check(mlist, msg, {}) + False + >>> print msg['x-approve'] + None + + >>> del msg['x-approve'] + >>> msg['X-Approved'] = '12345' + >>> rule.check(mlist, msg, {}) + False + >>> print msg['x-approved'] + None + + >>> del msg['x-approved'] Using an approval header ======================== -If the moderator password is given in an Approve header, then the rule -matches, and the Approve header is stripped. +If the moderator password is given in an ``Approve:`` header, then the rule +matches, and the ``Approve:`` header is stripped. >>> msg['Approve'] = 'abcxyz' >>> rule.check(mlist, msg, {}) @@ -71,23 +86,44 @@ matches, and the Approve header is stripped. >>> print msg['approve'] None -Similarly, for the Approved header. +Similarly, for the ``Approved:`` header. + >>> del msg['approve'] >>> msg['Approved'] = 'abcxyz' >>> rule.check(mlist, msg, {}) True >>> print msg['approved'] None +The headers ``X-Approve:`` and ``X-Approved:`` are treated the same way. +:: + + >>> del msg['approved'] + >>> msg['X-Approve'] = 'abcxyz' + >>> rule.check(mlist, msg, {}) + True + >>> print msg['x-approve'] + None + + >>> del msg['x-approve'] + >>> msg['X-Approved'] = 'abcxyz' + >>> rule.check(mlist, msg, {}) + True + >>> print msg['x-approved'] + None + + >>> del msg['x-approved'] + 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. If this pseudo-header looks like a matching -Approve or Approved header, the message is similarly allowed to pass. +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. If this pseudo-header +looks like a matching ``Approve:`` or ``Approved:`` header, the message is +similarly allowed to pass. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -109,7 +145,8 @@ The pseudo-header is removed. An important message. <BLANKLINE> -Similarly for the Approved header. +Similarly for the ``Approved:`` header. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -131,6 +168,7 @@ Similarly for the Approved header. As before, a mismatch in the pseudo-header does not approve the message, but the pseudo-header line is still removed. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -150,7 +188,8 @@ the pseudo-header line is still removed. An important message. <BLANKLINE> -Similarly for the Approved header. +Similarly for the ``Approved:`` header. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -175,8 +214,8 @@ MIME multipart support ====================== Mailman searches for the pseudo-header as the first non-whitespace line in the -first text/plain message part of the message. This allows the feature to be -used with MIME documents. +first ``text/plain`` message part of the message. This allows the feature to +be used with MIME documents. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -221,7 +260,7 @@ Like before, the pseudo-header is removed, but only from the text parts. --AAA-- <BLANKLINE> -The same goes for the Approved message. +The same goes for the ``Approved:`` message. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -266,7 +305,7 @@ And the header is removed. --AAA-- <BLANKLINE> -Here, the correct password is in the non-text/plain part, so it is ignored. +Here, the correct password is in the non-``text/plain`` part, so it is ignored. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -310,7 +349,7 @@ And yet the pseudo-header is still stripped. An important message. --AAA-- -As before, the same goes for the Approved header. +As before, the same goes for the ``Approved:`` header. >>> msg = message_from_string("""\ ... From: aperson@example.com @@ -358,9 +397,10 @@ And the pseudo-header is removed. 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. +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("""\ ... From: aperson@example.com @@ -388,7 +428,7 @@ anything that looks like an Approve or Approved headers. >>> rule.check(mlist, msg, {}) True -And the header-like text in the text/html part was stripped. +And the header-like text in the ``text/html`` part was stripped. >>> print msg.as_string() From: aperson@example.com @@ -418,6 +458,7 @@ And the header-like text in the text/html part was stripped. <BLANKLINE> This is true even if the rule does not match. +:: >>> msg = message_from_string("""\ ... From: aperson@example.com diff --git a/src/mailman/rules/docs/emergency.txt b/src/mailman/rules/docs/emergency.txt index 70f455dca..f28f9eed9 100644 --- a/src/mailman/rules/docs/emergency.txt +++ b/src/mailman/rules/docs/emergency.txt @@ -5,74 +5,33 @@ Emergency When the mailing list has its emergency flag set, all messages posted to the list are held for moderator approval. - >>> mlist = create_list('_xtest@example.com') + >>> mlist = create_list('test@example.com') + >>> rule = config.rules['emergency'] >>> msg = message_from_string("""\ ... From: aperson@example.com - ... To: _xtest@example.com + ... To: test@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. +By default, the mailing list does not have its emergency flag set. - >>> from mailman.core.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. - - >>> virginq = config.switchboards['virgin'] + >>> mlist.emergency + False + >>> rule.check(mlist, msg, {}) + False - >>> from mailman.interfaces.messages import IMessageStore - >>> from mailman.interfaces.pending import IPendings - >>> from mailman.interfaces.requests import IRequests - >>> from zope.component import getUtility - >>> message_store = getUtility(IMessageStore) +The emergency rule matches if the flag is set on the mailing list. - >>> 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 = getUtility(IPendings).confirm(cookie) - ... requestdb = getUtility(IRequests).get_list_requests(mlist) - ... rkey, rdata = requestdb.get_request(data['id']) - ... return 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> + >>> mlist.emergency = True + >>> rule.check(mlist, msg, {}) + True -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. +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 + >>> rule.check(mlist, msg, dict(moderator_approved=True)) + False diff --git a/src/mailman/rules/docs/header-matching.txt b/src/mailman/rules/docs/header-matching.txt index 217625fb0..b07118e11 100644 --- a/src/mailman/rules/docs/header-matching.txt +++ b/src/mailman/rules/docs/header-matching.txt @@ -4,23 +4,24 @@ Header matching Mailman can do pattern based header matching during its normal rule processing. There is a set of site-wide default header matches specified in -the configuration file under the [spam.headers] section. +the configuration file under the ``[spam.headers]`` section. - >>> mlist = create_list('_xtest@example.com') + >>> mlist = create_list('test@example.com') -Because the default [spam.headers] section is empty, we'll just extend the +Because the default ``[spam.headers]`` section is empty, we'll just extend the current header matching chain with a pattern that matches 4 or more stars, discarding the message if it hits. >>> 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). +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 + ... To: test@example.com ... Subject: Not spam ... Message-ID: <one> ... @@ -30,6 +31,7 @@ the chain untouched (i.e. no disposition). >>> from mailman.core.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. @@ -98,7 +100,7 @@ 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 [spam.headers] section. +with the same semantics as the global ``[spam.headers]`` section. The list administrator wants to match not on four stars, but on three plus signs, but only for the current mailing list. diff --git a/src/mailman/rules/docs/implicit-dest.txt b/src/mailman/rules/docs/implicit-dest.txt index 04e93615e..b0464d0a5 100644 --- a/src/mailman/rules/docs/implicit-dest.txt +++ b/src/mailman/rules/docs/implicit-dest.txt @@ -2,7 +2,7 @@ Implicit destination ==================== -The 'implicit-dest' rule matches when the mailing list's posting address is +The ``implicit-dest`` rule matches when the mailing list's posting address is not explicitly mentioned in the set of message recipients. >>> mlist = create_list('_xtest@example.com') @@ -18,6 +18,7 @@ to the appropriate interface. This rule matches messages that have an implicit destination, meaning that the mailing list's posting address isn't included in the explicit recipients. +:: >>> mlist.require_explicit_destination = True >>> alias_set.clear() @@ -52,6 +53,7 @@ Add the posting address as a recipient and the rule will no longer match. 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, {}) @@ -69,6 +71,7 @@ that Mailman pulled it from the appropriate news group. False Additional aliases can be added. +:: >>> alias_set.add('other@example.com') >>> del msg['to'] @@ -86,6 +89,7 @@ Aliases can be removed. True Aliases can also be cleared. +:: >>> msg['Cc'] = 'myfriend@example.com' >>> rule.check(mlist, msg, {}) @@ -101,7 +105,8 @@ 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. +recipient in the ``example.net`` domain, then the rule does not match. +:: >>> alias_set.add('^.*@example.net') >>> rule.check(mlist, msg, {}) diff --git a/src/mailman/rules/docs/loop.txt b/src/mailman/rules/docs/loop.txt index d6be10f8a..716029065 100644 --- a/src/mailman/rules/docs/loop.txt +++ b/src/mailman/rules/docs/loop.txt @@ -3,7 +3,7 @@ Posting loops ============= To avoid a posting loop, Mailman has a rule to check for the existence of an -RFC 2369 List-Post header with the value of the list's posting address. +RFC 2369 ``List-Post:`` header with the value of the list's posting address. >>> mlist = create_list('_xtest@example.com') >>> rule = config.rules['loop'] @@ -34,8 +34,8 @@ matches. >>> rule.check(mlist, msg, {}) True -Even if there are multiple List-Post headers, as long as one with the posting -address exists, the rule matches. +Even if there are multiple ``List-Post:`` headers, as long as one with the +posting address exists, the rule matches. >>> msg = message_from_string("""\ ... From: aperson@example.com diff --git a/src/mailman/rules/docs/max-size.txt b/src/mailman/rules/docs/max-size.txt index e3cc0ccf9..87856f0f1 100644 --- a/src/mailman/rules/docs/max-size.txt +++ b/src/mailman/rules/docs/max-size.txt @@ -2,7 +2,7 @@ Message size ============ -The 'message-size' rule matches when the posted message is bigger than a +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). diff --git a/src/mailman/rules/docs/moderation.txt b/src/mailman/rules/docs/moderation.txt index b268e9d4a..fdca04599 100644 --- a/src/mailman/rules/docs/moderation.txt +++ b/src/mailman/rules/docs/moderation.txt @@ -1,73 +1,164 @@ -================= +========== +Moderation +========== + +All members and nonmembers have a moderation action. When the action is not +`defer`, the `moderation` rule flags the message as needing moderation. This +might be to automatically accept, discard, reject, or hold the message. + +Two separate rules check for member and nonmember moderation. Member +moderation happens early in the built-in chain, while nonmember moderation +happens later in the chain, after normal moderation checks. + + >>> mlist = create_list('test@example.com') + + 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. + >>> member_rule = config.rules['member-moderation'] + >>> print member_rule.name + member-moderation - >>> mlist = create_list('_xtest@example.com') - >>> rule = config.rules['moderation'] - >>> print rule.name - moderation +Anne, a mailing list member, sends a message to the mailing list. Her +postings are not moderated. +:: -In the simplest case, the sender is not a member of the mailing list, so the -moderation rule can't match. + >>> from mailman.testing.helpers import subscribe + >>> subscribe(mlist, 'Anne') + >>> member = mlist.members.get_member('aperson@example.com') + >>> print member.moderation_action + Action.defer - >>> msg = message_from_string("""\ - ... From: aperson@example.org - ... To: _xtest@example.com +Because Anne is not moderated, the member moderation rule does not match. + + >>> member_msg = message_from_string("""\ + ... From: aperson@example.com + ... To: test@example.com ... Subject: A posted message ... ... """) - >>> rule.check(mlist, msg, {}) + >>> member_rule.check(mlist, member_msg, {}) False -Let's add the message author as a non-moderated member. +Once the member's moderation action is set to something other than `defer`, +the rule matches. Also, the message metadata has a few extra pieces of +information for the eventual moderation chain. - >>> from mailman.interfaces.usermanager import IUserManager - >>> from zope.component import getUtility - >>> user = getUtility(IUserManager).create_user( - ... 'aperson@example.org', 'Anne Person') + >>> from mailman.interfaces.action import Action + >>> member.moderation_action = Action.hold + >>> msgdata = {} + >>> member_rule.check(mlist, member_msg, msgdata) + True + >>> dump_msgdata(msgdata) + moderation_action: hold + moderation_sender: aperson@example.com - >>> address = list(user.addresses)[0] - >>> from mailman.interfaces.member 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. +Nonmembers +========== - >>> member.is_moderated = True - >>> rule.check(mlist, msg, {}) - True +Nonmembers are handled in a similar way, although by default, nonmember +postings are held for moderator approval. + >>> nonmember_rule = config.rules['nonmember-moderation'] + >>> print nonmember_rule.name + nonmember-moderation -Non-members -=========== +Bart, who is not a member of the mailing list, sends a message to the list. -There is another, related rule for matching non-members, which simply matches -if the sender is /not/ a member of the mailing list. + >>> from mailman.interfaces.member import MemberRole + >>> subscribe(mlist, 'Bart', MemberRole.nonmember) + >>> nonmember = mlist.nonmembers.get_member('bperson@example.com') + >>> print nonmember.moderation_action + Action.hold - >>> rule = config.rules['non-member'] - >>> print rule.name - non-member +When Bart is registered as a nonmember of the list, his moderation action is +set to hold by default. Thus the rule matches and the message metadata again +carries some useful information. -If the sender is a member of this mailing list, the rule does not match. + >>> nonmember_msg = message_from_string("""\ + ... From: bperson@example.com + ... To: test@example.com + ... Subject: A posted message + ... + ... """) + >>> msgdata = {} + >>> nonmember_rule.check(mlist, nonmember_msg, msgdata) + True + >>> dump_msgdata(msgdata) + moderation_action: hold + moderation_sender: bperson@example.com + +Of course, the nonmember action can be set to defer the decision, in which +case the rule does not match. - >>> rule.check(mlist, msg, {}) + >>> nonmember.moderation_action = Action.defer + >>> nonmember_rule.check(mlist, nonmember_msg, {}) False -But if the sender is not a member of this mailing list, the rule matches. + +Unregistered nonmembers +======================= + +The incoming queue runner ensures that all sender addresses are registered in +the system, but it is the moderation rule that subscribes nonmember addresses +to the mailing list if they are not already subscribed. +:: + + >>> from mailman.interfaces.usermanager import IUserManager + >>> from zope.component import getUtility + >>> address = getUtility(IUserManager).create_address( + ... 'cperson@example.com') + >>> address + <Address: cperson@example.com [not verified] at ...> >>> msg = message_from_string("""\ - ... From: bperson@example.org - ... To: _xtest@example.com + ... From: cperson@example.com + ... To: test@example.com ... Subject: A posted message ... ... """) - >>> rule.check(mlist, msg, {}) + +cperson is neither a member, nor a nonmember of the mailing list. +:: + + >>> def memberkey(member): + ... return member.mailing_list, member.address.email, int(member.role) + + >>> dump_list(mlist.members.members, key=memberkey) + <Member: Anne Person <aperson@example.com> + on test@example.com as MemberRole.member> + >>> dump_list(mlist.nonmembers.members, key=memberkey) + <Member: Bart Person <bperson@example.com> + on test@example.com as MemberRole.nonmember> + +However, when the nonmember moderation rule runs, it adds the cperson as a +nonmember of the list. The rule also matches. + + >>> msgdata = {} + >>> nonmember_rule.check(mlist, msg, msgdata) True + >>> dump_msgdata(msgdata) + moderation_action: hold + moderation_sender: cperson@example.com + + >>> dump_list(mlist.members.members, key=memberkey) + <Member: Anne Person <aperson@example.com> + on test@example.com as MemberRole.member> + >>> dump_list(mlist.nonmembers.members, key=memberkey) + <Member: Bart Person <bperson@example.com> + on test@example.com as MemberRole.nonmember> + <Member: cperson@example.com + on test@example.com as MemberRole.nonmember> + + +Cross-membership checks +======================= + +Of course, the member moderation rule does not match for nonmembers... + + >>> member_rule.check(mlist, nonmember_msg, {}) + False + >>> nonmember_rule.check(mlist, member_msg, {}) + False diff --git a/src/mailman/rules/docs/news-moderation.txt b/src/mailman/rules/docs/news-moderation.txt index 79ebde772..c695740fa 100644 --- a/src/mailman/rules/docs/news-moderation.txt +++ b/src/mailman/rules/docs/news-moderation.txt @@ -2,7 +2,7 @@ Newsgroup moderation ==================== -The 'news-moderation' rule matches all messages posted to mailing lists that +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 diff --git a/src/mailman/rules/docs/no-subject.txt b/src/mailman/rules/docs/no-subject.txt index 733de00e4..4876bc82c 100644 --- a/src/mailman/rules/docs/no-subject.txt +++ b/src/mailman/rules/docs/no-subject.txt @@ -2,8 +2,8 @@ No Subject header ================= -This rule matches if the message has no Subject header, or if the header is -the empty string when stripped. +This rule matches if the message has no ``Subject:`` header, or if the header +is the empty string when stripped. >>> mlist = create_list('_xtest@example.com') >>> rule = config.rules['no-subject'] @@ -21,13 +21,13 @@ A message with a non-empty subject does not match the rule. >>> rule.check(mlist, msg, {}) False -Delete the Subject header and the rule matches. +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. +Even a ``Subject:`` header with only whitespace still matches the rule. >>> msg['Subject'] = ' ' >>> rule.check(mlist, msg, {}) diff --git a/src/mailman/rules/docs/recipients.txt b/src/mailman/rules/docs/recipients.txt index 344a5f95f..aabf397a5 100644 --- a/src/mailman/rules/docs/recipients.txt +++ b/src/mailman/rules/docs/recipients.txt @@ -2,16 +2,16 @@ 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. +This rule matches when there are more than the maximum allowed number of +explicit recipients addressed by the message. >>> mlist = create_list('_xtest@example.com') >>> rule = config.rules['max-recipients'] >>> print 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. +In this case, we'll create a message with five recipients. These include all +addresses in the ``To:`` and ``CC:`` headers. >>> msg = message_from_string("""\ ... From: aperson@example.com diff --git a/src/mailman/rules/docs/rules.txt b/src/mailman/rules/docs/rules.txt index e61ea547e..3c2eab04d 100644 --- a/src/mailman/rules/docs/rules.txt +++ b/src/mailman/rules/docs/rules.txt @@ -26,10 +26,10 @@ names to rule objects. loop True max-recipients True max-size True - moderation True + member-moderation True news-moderation True no-subject True - non-member True + nonmember-moderation True suspicious-header True truth True @@ -44,7 +44,7 @@ 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 +``check()`` method. This returns a boolean indicating whether the rule was matched or not. >>> mlist = create_list('_xtest@example.com') @@ -54,9 +54,9 @@ matched or not. ... 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. +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. >>> print rule.name emergency diff --git a/src/mailman/rules/docs/suspicious.txt b/src/mailman/rules/docs/suspicious.txt index e99fb49c4..9eb8ae7ae 100644 --- a/src/mailman/rules/docs/suspicious.txt +++ b/src/mailman/rules/docs/suspicious.txt @@ -24,7 +24,7 @@ Set the so-called suspicious header configuration variable. True But if the header doesn't match the regular expression, the rule won't match. -This one comes from a .org address. +This one comes from a ``.org`` address. >>> msg = message_from_string("""\ ... From: aperson@example.org diff --git a/src/mailman/rules/docs/truth.txt b/src/mailman/rules/docs/truth.txt index f331e852b..c715b98aa 100644 --- a/src/mailman/rules/docs/truth.txt +++ b/src/mailman/rules/docs/truth.txt @@ -1,7 +1,8 @@ +===== Truth ===== -The 'truth' rule always matches. This makes it useful as a terminus rule for +This rule always matches. This makes it useful as a terminus rule for unconditionally jumping to another chain. >>> rule = config.rules['truth'] diff --git a/src/mailman/rules/emergency.py b/src/mailman/rules/emergency.py index 2ed3030eb..b7807e5e8 100644 --- a/src/mailman/rules/emergency.py +++ b/src/mailman/rules/emergency.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/implicit_dest.py b/src/mailman/rules/implicit_dest.py index 97eeb5611..21f6a5265 100644 --- a/src/mailman/rules/implicit_dest.py +++ b/src/mailman/rules/implicit_dest.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/loop.py b/src/mailman/rules/loop.py index 49fdd6ea8..c8415da15 100644 --- a/src/mailman/rules/loop.py +++ b/src/mailman/rules/loop.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/max_recipients.py b/src/mailman/rules/max_recipients.py index 870e704d5..64034b2e5 100644 --- a/src/mailman/rules/max_recipients.py +++ b/src/mailman/rules/max_recipients.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/max_size.py b/src/mailman/rules/max_size.py index 3be98ce75..833d2cadb 100644 --- a/src/mailman/rules/max_size.py +++ b/src/mailman/rules/max_size.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/moderation.py b/src/mailman/rules/moderation.py index 1e2b46529..733edd70c 100644 --- a/src/mailman/rules/moderation.py +++ b/src/mailman/rules/moderation.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -21,23 +21,27 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'Moderation', - 'NonMember', + 'MemberModeration', + 'NonmemberModeration', ] +from zope.component import getUtility from zope.interface import implements from mailman.core.i18n import _ +from mailman.interfaces.action import Action +from mailman.interfaces.member import MemberRole from mailman.interfaces.rules import IRule +from mailman.interfaces.usermanager import IUserManager -class Moderation: +class MemberModeration: """The member moderation rule.""" implements(IRule) - name = 'moderation' + name = 'member-moderation' description = _('Match messages sent by moderated members.') record = True @@ -45,24 +49,57 @@ class Moderation: """See `IRule`.""" for sender in msg.senders: member = mlist.members.get_member(sender) - if member is not None and member.is_moderated: + action = (None if member is None + else member.moderation_action) + if action is Action.defer: + # The regular moderation rules apply. + return False + elif action is not None: + # We must stringify the moderation action so that it can be + # stored in the pending request table. + msgdata['moderation_action'] = action.enumname + msgdata['moderation_sender'] = sender return True + # The sender is not a member so this rule does not match. return False -class NonMember: - """The non-membership rule.""" +class NonmemberModeration: + """The nonmember moderation rule.""" implements(IRule) - name = 'non-member' - description = _('Match messages sent by non-members.') + name = 'nonmember-moderation' + description = _('Match messages sent by nonmembers.') record = True def check(self, mlist, msg, msgdata): """See `IRule`.""" + user_manager = getUtility(IUserManager) + # First ensure that all senders are already either members or + # nonmembers. If they are not subscribed in some role to the mailing + # list, make them nonmembers. + for sender in msg.senders: + if (mlist.members.get_member(sender) is None and + mlist.nonmembers.get_member(sender) is None): + # The address is neither a member nor nonmember. + address = user_manager.get_address(sender) + assert address is not None, ( + 'Posting address is not registered: {0}'.format(sender)) + address.subscribe(mlist, MemberRole.nonmember) + # Do nonmember moderation check. for sender in msg.senders: - if mlist.members.get_member(sender) is not None: - # The sender is a member of the mailing list. + nonmember = mlist.nonmembers.get_member(sender) + action = (None if nonmember is None + else nonmember.moderation_action) + if action is Action.defer: + # The regular moderation rules apply. return False - return True + elif action is not None: + # We must stringify the moderation action so that it can be + # stored in the pending request table. + msgdata['moderation_action'] = action.enumname + msgdata['moderation_sender'] = sender + return True + # The sender must be a member, so this rule does not match. + return False diff --git a/src/mailman/rules/news_moderation.py b/src/mailman/rules/news_moderation.py index a081d1254..2d752f38f 100644 --- a/src/mailman/rules/news_moderation.py +++ b/src/mailman/rules/news_moderation.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/no_subject.py b/src/mailman/rules/no_subject.py index 486a775d1..44c43e976 100644 --- a/src/mailman/rules/no_subject.py +++ b/src/mailman/rules/no_subject.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/suspicious.py b/src/mailman/rules/suspicious.py index 6f1ebdc32..675e5636d 100644 --- a/src/mailman/rules/suspicious.py +++ b/src/mailman/rules/suspicious.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/rules/truth.py b/src/mailman/rules/truth.py index 6a1cf0397..96e5f6fa2 100644 --- a/src/mailman/rules/truth.py +++ b/src/mailman/rules/truth.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/styles/default.py b/src/mailman/styles/default.py index 4caa01424..f35b2519a 100644 --- a/src/mailman/styles/default.py +++ b/src/mailman/styles/default.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -30,7 +30,6 @@ import datetime from zope.interface import implements -from mailman.config import config from mailman.core.i18n import _ from mailman.interfaces.action import Action from mailman.interfaces.digests import DigestFrequency @@ -97,6 +96,8 @@ from: .*@uplinkpro.com mlist.administrivia = True mlist.preferred_language = 'en' mlist.collapse_alternatives = True + mlist.convert_html_to_plaintext = False + mlist.filter_content = False # Digest related variables mlist.digestable = True mlist.digest_is_default = False @@ -115,13 +116,13 @@ ${listinfo_page} mlist.nondigestable = True mlist.personalize = Personalization.none # New sender-centric moderation (privacy) options - mlist.default_member_moderation = False + mlist.default_member_action = Action.defer + mlist.default_nonmember_action = Action.hold # Archiver mlist.archive = True mlist.archive_private = 0 mlist.archive_volume_frequency = 1 mlist.emergency = False - mlist.member_moderation_action = Action.hold mlist.member_moderation_notice = '' mlist.accept_these_nonmembers = [] mlist.hold_these_nonmembers = [] diff --git a/src/mailman/styles/manager.py b/src/mailman/styles/manager.py index e08d649f4..6cff32f9e 100644 --- a/src/mailman/styles/manager.py +++ b/src/mailman/styles/manager.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,8 +25,6 @@ __all__ = [ ] -import sys - from operator import attrgetter from zope.interface import implements from zope.interface.verify import verifyObject diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py index 67e2ad21a..fd2b9ffb3 100644 --- a/src/mailman/testing/helpers.py +++ b/src/mailman/testing/helpers.py @@ -1,4 +1,3 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -37,7 +36,6 @@ import time import errno import signal import socket -import logging import smtplib import datetime import threading @@ -254,19 +252,28 @@ def event_subscribers(*subscribers): """ old_subscribers = event.subscribers[:] event.subscribers = list(subscribers) - yield - event.subscribers[:] = old_subscribers + try: + yield + finally: + event.subscribers[:] = old_subscribers def subscribe(mlist, first_name, role=MemberRole.member): """Helper for subscribing a sample person to a mailing list.""" user_manager = getUtility(IUserManager) - address = '{0}person@example.com'.format(first_name[0].lower()) + email = '{0}person@example.com'.format(first_name[0].lower()) full_name = '{0} Person'.format(first_name) - person = user_manager.get_user(address) + person = user_manager.get_user(email) if person is None: - person = user_manager.create_user(address, full_name) - preferred_address = list(person.addresses)[0] - preferred_address.subscribe(mlist, role) + address = user_manager.get_address(email) + if address is None: + person = user_manager.create_user(email, full_name) + preferred_address = list(person.addresses)[0] + preferred_address.subscribe(mlist, role) + else: + address.subscribe(mlist, role) + else: + preferred_address = list(person.addresses)[0] + preferred_address.subscribe(mlist, role) config.db.commit() diff --git a/src/mailman/testing/i18n.py b/src/mailman/testing/i18n.py index 1f97d8df3..2f856a3d5 100644 --- a/src/mailman/testing/i18n.py +++ b/src/mailman/testing/i18n.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -21,7 +21,8 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'install_testing_i18n', + 'TestingStrategy', + 'initialize', ] diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py index 34dc46807..319248ebb 100644 --- a/src/mailman/testing/layers.py +++ b/src/mailman/testing/layers.py @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -35,15 +35,15 @@ import logging import datetime import tempfile +from base64 import b64encode from pkg_resources import resource_string from textwrap import dedent -from urllib2 import urlopen, URLError +from urllib2 import Request, URLError, urlopen from zope.component import getUtility from mailman.config import config from mailman.core import initialize from mailman.core.initialize import INHIBIT_CONFIG_FILE -from mailman.core.i18n import _ from mailman.core.logging import get_handler from mailman.interfaces.domain import IDomainManager from mailman.interfaces.messages import IMessageStore @@ -217,6 +217,21 @@ class ConfigLayer(MockAndMonkeyLayer): if len(os.environ.get('MM_VERBOSE_TESTLOG', '').strip()) > 0: cls.stderr = True + # The top of our source tree, for tests that care (e.g. hooks.txt). + root_directory = None + + @classmethod + def set_root_directory(cls, directory): + """Set the directory at the root of our source tree. + + zc.recipe.testrunner runs from parts/test/working-directory, but + that's actually changed over the life of the package. Some tests + care, e.g. because they need to find our built-out bin directory. + Fortunately, buildout can give us this information. See the + `buildout.cfg` file for where this method is called. + """ + cls.root_directory = directory + class SMTPLayer(ConfigLayer): @@ -244,6 +259,7 @@ class SMTPLayer(ConfigLayer): @classmethod def testTearDown(cls): + cls.smtpd.reset() cls.smtpd.clear() @@ -258,7 +274,12 @@ class RESTLayer(SMTPLayer): until = datetime.datetime.now() + TEST_TIMEOUT while datetime.datetime.now() < until: try: - fp = urlopen('http://localhost:8001/3.0/system') + request = Request('http://localhost:9001/3.0/system') + basic_auth = '{0}:{1}'.format(config.webservice.admin_user, + config.webservice.admin_pass) + request.add_header('Authorization', + 'Basic ' + b64encode(basic_auth)) + fp = urlopen(request) except URLError: pass else: diff --git a/src/mailman/testing/mta.py b/src/mailman/testing/mta.py index 040e07c0d..8fae233fa 100644 --- a/src/mailman/testing/mta.py +++ b/src/mailman/testing/mta.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -59,11 +59,31 @@ class FakeMTA: class StatisticsChannel(Channel): """A channel that can answers to the fake STAT command.""" + def smtp_EHLO(self, arg): + if not arg: + self.push(b'501 Syntax: HELO hostname') + return + if self._SMTPChannel__greeting: + self.push(b'503 Duplicate HELO/EHLO') + else: + self._SMTPChannel__greeting = arg + self.push(b'250-%s' % self._SMTPChannel__fqdn) + self.push(b'250 AUTH PLAIN') + def smtp_STAT(self, arg): """Cause the server to send statistics to its controller.""" self._server.send_statistics() self.push(b'250 Ok') + def smtp_AUTH(self, arg): + """Record that the AUTH occurred.""" + if arg == 'PLAIN AHRlc3R1c2VyAHRlc3RwYXNz': + # testuser:testpass + self.push(b'235 Ok') + self._server.send_auth(arg) + else: + self.push(b'571 Bad authentication') + def smtp_RCPT(self, arg): """For testing, sometimes cause a non-25x response.""" code = self._server.next_error('rcpt') @@ -103,6 +123,7 @@ class ConnectionCountingServer(QueueServer): """ QueueServer.__init__(self, host, port, queue) self._connection_count = 0 + self.last_auth = None # The out-of-band queue is where the server sends statistics to the # controller upon request. self._oob_queue = oob_queue @@ -152,6 +173,10 @@ class ConnectionCountingServer(QueueServer): self._connection_count -= 1 self._oob_queue.put(self._connection_count) + def send_auth(self, arg): + """Echo back the authentication data.""" + self._oob_queue.put(arg) + class ConnectionCountingController(QueueController): @@ -187,6 +212,10 @@ class ConnectionCountingController(QueueController): # seconds. Let that propagate. return self.oob_queue.get(block=True, timeout=10) + def get_authentication_credentials(self): + """Retrieve the last authentication credentials.""" + return self.oob_queue.get(block=True, timeout=10) + @property def messages(self): """Return all the messages received by the SMTP server.""" @@ -196,3 +225,7 @@ class ConnectionCountingController(QueueController): def clear(self): """Clear all the messages from the queue.""" list(self) + + def reset(self): + smtpd = self._connect() + smtpd.docmd(b'RSET') diff --git a/src/mailman/testing/testing.cfg b/src/mailman/testing/testing.cfg index 8c56d2135..ddb0f8440 100644 --- a/src/mailman/testing/testing.cfg +++ b/src/mailman/testing/testing.cfg @@ -1,4 +1,4 @@ -# Copyright (C) 2008-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2008-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -19,8 +19,12 @@ [mta] smtp_port: 9025 +lmtp_port: 9024 incoming: mailman.testing.mta.FakeMTA +[webservice] +port: 9001 + [qrunner.archive] max_restarts: 1 diff --git a/src/mailman/tests/test_bounces.py b/src/mailman/tests/test_bounces.py index 90387315f..5bbede4a1 100644 --- a/src/mailman/tests/test_bounces.py +++ b/src/mailman/tests/test_bounces.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,15 +25,17 @@ __all__ = [ ] -import os -import sys import email import unittest from contextlib import closing from pkg_resources import resource_stream -from mailman.Bouncers.BouncerAPI import Stop +from mailman.app.finder import find_components +from mailman.bouncers.caiwireless import Caiwireless +from mailman.bouncers.microsoft import Microsoft +from mailman.bouncers.smtp32 import SMTP32 +from mailman.interfaces.bounce import IBounceDetector, NonFatal @@ -80,9 +82,9 @@ class BounceTest(unittest.TestCase): ('SimpleMatch', 'bounce_02.txt', ['acinsp1@midsouth.rr.com']), ('SimpleMatch', 'bounce_03.txt', ['james@jeborall.demon.co.uk']), # SimpleWarning - ('SimpleWarning', 'simple_03.txt', Stop), - ('SimpleWarning', 'simple_21.txt', Stop), - ('SimpleWarning', 'simple_22.txt', Stop), + ('SimpleWarning', 'simple_03.txt', NonFatal), + ('SimpleWarning', 'simple_21.txt', NonFatal), + ('SimpleWarning', 'simple_22.txt', NonFatal), # GroupWise ('GroupWise', 'groupwise_01.txt', ['thoff@MAINEX1.ASU.EDU']), # This one really sucks 'cause it's text/html. Just make sure it @@ -99,10 +101,10 @@ class BounceTest(unittest.TestCase): ('DSN', 'dsn_02.txt', ['zzzzz@zeus.hud.ac.uk']), ('DSN', 'dsn_03.txt', ['ddd.kkk@advalvas.be']), ('DSN', 'dsn_04.txt', ['max.haas@unibas.ch']), - ('DSN', 'dsn_05.txt', Stop), - ('DSN', 'dsn_06.txt', Stop), - ('DSN', 'dsn_07.txt', Stop), - ('DSN', 'dsn_08.txt', Stop), + ('DSN', 'dsn_05.txt', NonFatal), + ('DSN', 'dsn_06.txt', NonFatal), + ('DSN', 'dsn_07.txt', NonFatal), + ('DSN', 'dsn_08.txt', NonFatal), ('DSN', 'dsn_09.txt', ['pr@allen-heath.com']), ('DSN', 'dsn_10.txt', ['anne.person@dom.ain']), ('DSN', 'dsn_11.txt', ['joem@example.com']), @@ -172,32 +174,28 @@ class BounceTest(unittest.TestCase): return email.message_from_file(fp) def test_bounce(self): - for modname, filename, addrs in self.DATA: - module = 'mailman.Bouncers.' + modname - __import__(module) - # XXX Convert this tousing package resources + detectors = {} + for detector in find_components('mailman.bouncers', IBounceDetector): + detectors[detector.__name__] = detector() + for detector_name, filename, expected_addresses in self.DATA: msg = self._getmsg(filename) - foundaddrs = sys.modules[module].process(msg) - # Some modules return None instead of [] for failure - if foundaddrs is None: - foundaddrs = [] - if foundaddrs is not Stop: - # MAS: The following strip() is only because of my - # hybrid test environment. It is not otherwise needed. - foundaddrs = [found.strip() for found in foundaddrs] - addrs.sort() - foundaddrs.sort() - self.assertEqual(addrs, foundaddrs) + found_addresses = detectors[detector_name].process(msg) + # Some modules return None instead of the empty sequence. + if found_addresses is None: + found_addresses = set() + elif found_addresses is not NonFatal: + found_addresses = set(found_addresses) + if expected_addresses is not NonFatal: + expected_addresses = set(expected_addresses) + self.assertEqual(found_addresses, expected_addresses) def test_SMTP32_failure(self): - from mailman.Bouncers import SMTP32 # This file has no X-Mailer: header msg = self._getmsg('postfix_01.txt') self.failIf(msg['x-mailer'] is not None) - self.failIf(SMTP32.process(msg)) + self.failIf(SMTP32().process(msg)) def test_caiwireless(self): - from mailman.Bouncers import Caiwireless # BAW: this is a mostly bogus test; I lost the samples. :( msg = email.message_from_string("""\ Content-Type: multipart/report; boundary=BOUNDARY @@ -207,10 +205,9 @@ Content-Type: multipart/report; boundary=BOUNDARY --BOUNDARY-- """) - self.assertEqual(None, Caiwireless.process(msg)) + self.assertEqual(None, Caiwireless().process(msg)) def test_microsoft(self): - from mailman.Bouncers import Microsoft # BAW: similarly as above, I lost the samples. :( msg = email.message_from_string("""\ Content-Type: multipart/report; boundary=BOUNDARY @@ -220,7 +217,7 @@ Content-Type: multipart/report; boundary=BOUNDARY --BOUNDARY-- """) - self.assertEqual(None, Microsoft.process(msg)) + self.assertEqual(None, Microsoft().process(msg)) diff --git a/src/mailman/tests/test_configfile.py b/src/mailman/tests/test_configfile.py index decac277b..245b84df8 100644 --- a/src/mailman/tests/test_configfile.py +++ b/src/mailman/tests/test_configfile.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/tests/test_documentation.py b/src/mailman/tests/test_documentation.py index d03762188..e65885d79 100644 --- a/src/mailman/tests/test_documentation.py +++ b/src/mailman/tests/test_documentation.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -35,6 +35,7 @@ import json import doctest import unittest +from base64 import b64encode from email import message_from_string from httplib2 import Http from urllib import urlencode @@ -98,6 +99,9 @@ def stop(): def dump_msgdata(msgdata, *additional_skips): """Dump in a more readable way a message metadata dictionary.""" + if len(msgdata) == 0: + print '*Empty*' + return skips = set(additional_skips) # Some stuff we always want to skip, because their values will always be # variable data. @@ -109,8 +113,22 @@ def dump_msgdata(msgdata, *additional_skips): print '{0:{2}}: {1}'.format(key, msgdata[key], longest) -def dump_json(url, data=None, method=None): - """Print the JSON dictionary read from a URL. +def dump_list(list_of_things, key=str): + """Print items in a string to get rid of stupid u'' prefixes.""" + # List of things may be a generator. + list_of_things = list(list_of_things) + if len(list_of_things) == 0: + print '*Empty*' + if key is not None: + list_of_things = sorted(list_of_things, key=key) + for item in list_of_things: + print item + + +def call_http(url, data=None, method=None, username=None, password=None): + """'Call' a URL with a given HTTP method and return the resulting object. + + The object will have been JSON decoded. :param url: The url to open, read, and print. :type url: string @@ -118,36 +136,69 @@ def dump_json(url, data=None, method=None): :type data: dict :param method: Alternative HTTP method to use. :type method: str + :param username: The HTTP Basic Auth user name. None means use the value + from the configuration. + :type username: str + :param password: The HTTP Basic Auth password. None means use the value + from the configuration. + :type username: str """ - if data is not None: - data = urlencode(data) headers = {} + if data is not None: + data = urlencode(data, doseq=True) + headers['Content-Type'] = 'application/x-www-form-urlencoded' if method is None: if data is None: method = 'GET' else: method = 'POST' - headers['Content-Type'] = 'application/x-www-form-urlencoded' method = method.upper() + basic_auth = '{0}:{1}'.format( + (config.webservice.admin_user if username is None else username), + (config.webservice.admin_pass if password is None else password)) + headers['Authorization'] = 'Basic ' + b64encode(basic_auth) response, content = Http().request(url, method, data, headers) # If we did not get a 2xx status code, make this look like a urllib2 # exception, for backward compatibility with existing doctests. if response.status // 100 != 2: - raise HTTPError(url, response.status, response.reason, response, None) + raise HTTPError(url, response.status, content, response, None) if len(content) == 0: for header in sorted(response): print '{0}: {1}'.format(header, response[header]) + return None + # XXX Workaround http://bugs.python.org/issue10038 + content = unicode(content) + return json.loads(content) + + +def dump_json(url, data=None, method=None, username=None, password=None): + """Print the JSON dictionary read from a URL. + + :param url: The url to open, read, and print. + :type url: string + :param data: Data to use to POST to a URL. + :type data: dict + :param method: Alternative HTTP method to use. + :type method: str + :param username: The HTTP Basic Auth user name. None means use the value + from the configuration. + :type username: str + :param password: The HTTP Basic Auth password. None means use the value + from the configuration. + :type username: str + """ + results = call_http(url, data, method, username, password) + if results is None: return - data = json.loads(content) - for key in sorted(data): + for key in sorted(results): if key == 'entries': - for i, entry in enumerate(data[key]): + for i, entry in enumerate(results[key]): # entry is a dictionary. print 'entry %d:' % i for entry_key in sorted(entry): print ' {0}: {1}'.format(entry_key, entry[entry_key]) else: - print '{0}: {1}'.format(key, data[key]) + print '{0}: {1}'.format(key, results[key]) @@ -161,10 +212,12 @@ def setup(testobj): # doctests should do the imports themselves. It makes for better # documentation that way. However, a few are really useful, or help to # hide some icky test implementation details. + testobj.globs['call_http'] = call_http testobj.globs['config'] = config testobj.globs['create_list'] = create_list testobj.globs['dump_json'] = dump_json testobj.globs['dump_msgdata'] = dump_msgdata + testobj.globs['dump_list'] = dump_list testobj.globs['message_from_string'] = specialized_message_from_string testobj.globs['smtpd'] = SMTPLayer.smtpd testobj.globs['stop'] = stop diff --git a/src/mailman/tests/test_membership.py b/src/mailman/tests/test_membership.py index 175dba8ba..c3e40cfcc 100644 --- a/src/mailman/tests/test_membership.py +++ b/src/mailman/tests/test_membership.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/tests/test_passwords.py b/src/mailman/tests/test_passwords.py index 9e154be89..49f55e82f 100644 --- a/src/mailman/tests/test_passwords.py +++ b/src/mailman/tests/test_passwords.py @@ -1,4 +1,4 @@ -# Copyright (C) 2007-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2007-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/tests/test_security_mgr.py b/src/mailman/tests/test_security_mgr.py index 7cb4e9afe..a4f9c1cf4 100644 --- a/src/mailman/tests/test_security_mgr.py +++ b/src/mailman/tests/test_security_mgr.py @@ -1,4 +1,4 @@ -# Copyright (C) 2001-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2001-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/utilities/datetime.py b/src/mailman/utilities/datetime.py index 3d6692b06..7e727346d 100644 --- a/src/mailman/utilities/datetime.py +++ b/src/mailman/utilities/datetime.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/utilities/filesystem.py b/src/mailman/utilities/filesystem.py index 66ad8b0ac..3296a4a6c 100644 --- a/src/mailman/utilities/filesystem.py +++ b/src/mailman/utilities/filesystem.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/utilities/importer.py b/src/mailman/utilities/importer.py index 2dfcef2b3..7622fd1c9 100644 --- a/src/mailman/utilities/importer.py +++ b/src/mailman/utilities/importer.py @@ -1,4 +1,4 @@ -# Copyright (C) 2010 by the Free Software Foundation, Inc. +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -25,7 +25,7 @@ __all__ = [ ] -import cPickle +import sys import datetime from mailman.interfaces.action import Action @@ -47,7 +47,6 @@ TYPES = dict( bounce_info_stale_after=seconds_to_delta, bounce_you_are_disabled_warnings_interval=seconds_to_delta, digest_volume_frequency=DigestFrequency, - member_moderation_action=Action, news_moderation=NewsModeration, personalize=Personalization, reply_goes_to_list=ReplyToMunging, @@ -76,6 +75,6 @@ def import_config_pck(mlist, config_dict): value = converter(value) try: setattr(mlist, key, value) - except TypeError as error: + except TypeError: print >> sys.stderr, 'Type conversion error:', key raise diff --git a/src/mailman/utilities/mailbox.py b/src/mailman/utilities/mailbox.py index 18ca97bc5..73f09b633 100644 --- a/src/mailman/utilities/mailbox.py +++ b/src/mailman/utilities/mailbox.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/utilities/modules.py b/src/mailman/utilities/modules.py index 024c8172e..b1dd42520 100644 --- a/src/mailman/utilities/modules.py +++ b/src/mailman/utilities/modules.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -21,7 +21,7 @@ from __future__ import absolute_import, unicode_literals __metaclass__ = type __all__ = [ - 'call_name' + 'call_name', 'find_name', ] diff --git a/src/mailman/utilities/string.py b/src/mailman/utilities/string.py index e838e1ba1..44b99876e 100644 --- a/src/mailman/utilities/string.py +++ b/src/mailman/utilities/string.py @@ -1,4 +1,4 @@ -# Copyright (C) 2009-2010 by the Free Software Foundation, Inc. +# Copyright (C) 2009-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/utilities/tests/test_import.py b/src/mailman/utilities/tests/test_import.py index 9269c06f5..c54a5c0b2 100644 --- a/src/mailman/utilities/tests/test_import.py +++ b/src/mailman/utilities/tests/test_import.py @@ -1,4 +1,4 @@ -# Copyright (C) 2010 by the Free Software Foundation, Inc. +# Copyright (C) 2010-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # diff --git a/src/mailman/version.py b/src/mailman/version.py index 34cffedb1..702043d49 100644 --- a/src/mailman/version.py +++ b/src/mailman/version.py @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2010 by the Free Software Foundation, Inc. +# Copyright (C) 1998-2011 by the Free Software Foundation, Inc. # # This file is part of GNU Mailman. # @@ -18,8 +18,8 @@ """Mailman version strings.""" # Mailman version -VERSION = "3.0.0a6" -CODENAME = 'Cut to the Chase' +VERSION = "3.0.0a7" +CODENAME = 'Mission' # And as a hex number in the manner of PY_VERSION_HEX ALPHA = 0xa @@ -34,7 +34,7 @@ MINOR_REV = 0 MICRO_REV = 0 REL_LEVEL = ALPHA # at most 15 beta releases! -REL_SERIAL = 6 +REL_SERIAL = 7 HEX_VERSION = ((MAJOR_REV << 24) | (MINOR_REV << 16) | (MICRO_REV << 8) | (REL_LEVEL << 4) | (REL_SERIAL << 0)) |
