'
+ % (url_quote(self.prev.filename),
+ self.quote(subject)))
+ else:
+ prev = prev_wsubj = ""
+ return prev, prev_wsubj
+
+ def _get_subject_enc(self, art):
+ """Return the subject of art, decoded if possible.
+
+ If the charset of the current message and art match and the
+ article's subject is encoded, decode it.
+ """
+ return art.decoded.get('subject', art.subject)
+
+ def _get_next(self):
+ """Return the href and subject for the previous message"""
+ if self.next:
+ subject = self._get_subject_enc(self.next)
+ next = (''
+ % (url_quote(self.next.filename)))
+ next_wsubj = ('
')
+ else:
+ # Do fancy formatting here
+ if self.SHOWBR:
+ lines = map(lambda x:x + " ", lines)
+ else:
+ for i in range(0, len(lines)):
+ s = lines[i]
+ if s[0:1] in ' \t\n':
+ lines[i] = '
' + s
+ article.html_body = lines
+ return article
+
+ def update_article(self, arcdir, article, prev, next):
+ seq = article.sequence
+ filename = os.path.join(arcdir, article.filename)
+ self.message(_('Updating HTML for article %(seq)s'))
+ try:
+ f = open(filename)
+ article.loadbody_fromHTML(f)
+ f.close()
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ self.message(_('article file %(filename)s is missing!'))
+ article.prev = prev
+ article.next = next
+ omask = os.umask(002)
+ try:
+ f = open(filename, 'w')
+ finally:
+ os.umask(omask)
+ f.write(article.as_html())
+ f.close()
diff --git a/src/mailman/Archiver/HyperDatabase.py b/src/mailman/Archiver/HyperDatabase.py
new file mode 100644
index 000000000..49928d7b3
--- /dev/null
+++ b/src/mailman/Archiver/HyperDatabase.py
@@ -0,0 +1,339 @@
+# Copyright (C) 1998-2009 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 .
+
+#
+# site modules
+#
+import os
+import marshal
+import time
+import errno
+
+#
+# package/project modules
+#
+import pipermail
+from locknix import lockfile
+
+CACHESIZE = pipermail.CACHESIZE
+
+try:
+ import cPickle
+ pickle = cPickle
+except ImportError:
+ import pickle
+
+#
+# we're using a python dict in place of
+# of bsddb.btree database. only defining
+# the parts of the interface used by class HyperDatabase
+# only one thing can access this at a time.
+#
+class DumbBTree:
+ """Stores pickles of Article objects
+
+ This dictionary-like object stores pickles of all the Article
+ objects. The object itself is stored using marshal. It would be
+ much simpler, and probably faster, to store the actual objects in
+ the DumbBTree and pickle it.
+
+ TBD: Also needs a more sensible name, like IteratableDictionary or
+ SortedDictionary.
+ """
+
+ def __init__(self, path):
+ self.current_index = 0
+ self.path = path
+ self.lockfile = lockfile.Lock(self.path + ".lock")
+ self.lock()
+ self.__dirty = 0
+ self.dict = {}
+ self.sorted = []
+ self.load()
+
+ def __repr__(self):
+ return "DumbBTree(%s)" % self.path
+
+ def __sort(self, dirty=None):
+ if self.__dirty == 1 or dirty:
+ self.sorted = self.dict.keys()
+ self.sorted.sort()
+ self.__dirty = 0
+
+ def lock(self):
+ self.lockfile.lock()
+
+ def unlock(self):
+ try:
+ self.lockfile.unlock()
+ except lockfile.NotLockedError:
+ pass
+
+ def __delitem__(self, item):
+ # if first hasn't been called, we can skip the sort
+ if self.current_index == 0:
+ del self.dict[item]
+ self.__dirty = 1
+ return
+ try:
+ ci = self.sorted[self.current_index]
+ except IndexError:
+ ci = None
+ if ci == item:
+ try:
+ ci = self.sorted[self.current_index + 1]
+ except IndexError:
+ ci = None
+ del self.dict[item]
+ self.__sort(dirty=1)
+ if ci is not None:
+ self.current_index = self.sorted.index(ci)
+ else:
+ self.current_index = self.current_index + 1
+
+ def clear(self):
+ # bulk clearing much faster than deleting each item, esp. with the
+ # implementation of __delitem__() above :(
+ self.dict = {}
+
+ def first(self):
+ self.__sort() # guarantee that the list is sorted
+ if not self.sorted:
+ raise KeyError
+ else:
+ key = self.sorted[0]
+ self.current_index = 1
+ return key, self.dict[key]
+
+ def last(self):
+ if not self.sorted:
+ raise KeyError
+ else:
+ key = self.sorted[-1]
+ self.current_index = len(self.sorted) - 1
+ return key, self.dict[key]
+
+ def next(self):
+ try:
+ key = self.sorted[self.current_index]
+ except IndexError:
+ raise KeyError
+ self.current_index = self.current_index + 1
+ return key, self.dict[key]
+
+ def has_key(self, key):
+ return self.dict.has_key(key)
+
+ def set_location(self, loc):
+ if not self.dict.has_key(loc):
+ raise KeyError
+ self.current_index = self.sorted.index(loc)
+
+ def __getitem__(self, item):
+ return self.dict[item]
+
+ def __setitem__(self, item, val):
+ # if first hasn't been called, then we don't need to worry
+ # about sorting again
+ if self.current_index == 0:
+ self.dict[item] = val
+ self.__dirty = 1
+ return
+ try:
+ current_item = self.sorted[self.current_index]
+ except IndexError:
+ current_item = item
+ self.dict[item] = val
+ self.__sort(dirty=1)
+ self.current_index = self.sorted.index(current_item)
+
+ def __len__(self):
+ return len(self.sorted)
+
+ def load(self):
+ try:
+ fp = open(self.path)
+ try:
+ self.dict = marshal.load(fp)
+ finally:
+ fp.close()
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ pass
+ except EOFError:
+ pass
+ else:
+ self.__sort(dirty=1)
+
+ def close(self):
+ omask = os.umask(007)
+ try:
+ fp = open(self.path, 'w')
+ finally:
+ os.umask(omask)
+ fp.write(marshal.dumps(self.dict))
+ fp.close()
+ self.unlock()
+
+
+# this is lifted straight out of pipermail with
+# the bsddb.btree replaced with above class.
+# didn't use inheritance because of all the
+# __internal stuff that needs to be here -scott
+#
+class HyperDatabase(pipermail.Database):
+ __super_addArticle = pipermail.Database.addArticle
+
+ def __init__(self, basedir, mlist):
+ self.__cache = {}
+ self.__currentOpenArchive = None # The currently open indices
+ self._mlist = mlist
+ self.basedir = os.path.expanduser(basedir)
+ # Recently added articles, indexed only by message ID
+ self.changed={}
+
+ def firstdate(self, archive):
+ self.__openIndices(archive)
+ date = 'None'
+ try:
+ datekey, msgid = self.dateIndex.first()
+ date = time.asctime(time.localtime(float(datekey[0])))
+ except KeyError:
+ pass
+ return date
+
+ def lastdate(self, archive):
+ self.__openIndices(archive)
+ date = 'None'
+ try:
+ datekey, msgid = self.dateIndex.last()
+ date = time.asctime(time.localtime(float(datekey[0])))
+ except KeyError:
+ pass
+ return date
+
+ def numArticles(self, archive):
+ self.__openIndices(archive)
+ return len(self.dateIndex)
+
+ def addArticle(self, archive, article, subject=None, author=None,
+ date=None):
+ self.__openIndices(archive)
+ self.__super_addArticle(archive, article, subject, author, date)
+
+ def __openIndices(self, archive):
+ if self.__currentOpenArchive == archive:
+ return
+ self.__closeIndices()
+ arcdir = os.path.join(self.basedir, 'database')
+ omask = os.umask(0)
+ try:
+ try:
+ os.mkdir(arcdir, 02770)
+ except OSError, e:
+ if e.errno <> errno.EEXIST: raise
+ finally:
+ os.umask(omask)
+ for i in ('date', 'author', 'subject', 'article', 'thread'):
+ t = DumbBTree(os.path.join(arcdir, archive + '-' + i))
+ setattr(self, i + 'Index', t)
+ self.__currentOpenArchive = archive
+
+ def __closeIndices(self):
+ for i in ('date', 'author', 'subject', 'thread', 'article'):
+ attr = i + 'Index'
+ if hasattr(self, attr):
+ index = getattr(self, attr)
+ if i == 'article':
+ if not hasattr(self, 'archive_length'):
+ self.archive_length = {}
+ l = len(index)
+ self.archive_length[self.__currentOpenArchive] = l
+ index.close()
+ delattr(self, attr)
+ self.__currentOpenArchive = None
+
+ def close(self):
+ self.__closeIndices()
+
+ def hasArticle(self, archive, msgid):
+ self.__openIndices(archive)
+ return self.articleIndex.has_key(msgid)
+
+ def setThreadKey(self, archive, key, msgid):
+ self.__openIndices(archive)
+ self.threadIndex[key]=msgid
+
+ def getArticle(self, archive, msgid):
+ self.__openIndices(archive)
+ if not self.__cache.has_key(msgid):
+ # get the pickled object out of the DumbBTree
+ buf = self.articleIndex[msgid]
+ article = self.__cache[msgid] = pickle.loads(buf)
+ # For upgrading older archives
+ article.setListIfUnset(self._mlist)
+ else:
+ article = self.__cache[msgid]
+ return article
+
+ def first(self, archive, index):
+ self.__openIndices(archive)
+ index = getattr(self, index + 'Index')
+ try:
+ key, msgid = index.first()
+ return msgid
+ except KeyError:
+ return None
+
+ def next(self, archive, index):
+ self.__openIndices(archive)
+ index = getattr(self, index + 'Index')
+ try:
+ key, msgid = index.next()
+ return msgid
+ except KeyError:
+ return None
+
+ def getOldestArticle(self, archive, subject):
+ self.__openIndices(archive)
+ subject = subject.lower()
+ try:
+ key, tempid=self.subjectIndex.set_location(subject)
+ self.subjectIndex.next()
+ [subject2, date]= key.split('\0')
+ if subject!=subject2: return None
+ return tempid
+ except KeyError:
+ return None
+
+ def newArchive(self, archive):
+ pass
+
+ def clearIndex(self, archive, index):
+ self.__openIndices(archive)
+ if hasattr(self.threadIndex, 'clear'):
+ self.threadIndex.clear()
+ return
+ finished=0
+ try:
+ key, msgid=self.threadIndex.first()
+ except KeyError: finished=1
+ while not finished:
+ del self.threadIndex[key]
+ try:
+ key, msgid=self.threadIndex.next()
+ except KeyError: finished=1
diff --git a/src/mailman/Archiver/__init__.py b/src/mailman/Archiver/__init__.py
new file mode 100644
index 000000000..322010acb
--- /dev/null
+++ b/src/mailman/Archiver/__init__.py
@@ -0,0 +1,18 @@
+# Copyright (C) 1998-2009 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 .
+
+from Archiver import *
diff --git a/src/mailman/Archiver/pipermail.py b/src/mailman/Archiver/pipermail.py
new file mode 100644
index 000000000..19bc05c3f
--- /dev/null
+++ b/src/mailman/Archiver/pipermail.py
@@ -0,0 +1,874 @@
+#! /usr/bin/env python
+
+import os
+import re
+import sys
+import time
+import logging
+import mailbox
+
+import cPickle as pickle
+
+from cStringIO import StringIO
+from email.Utils import parseaddr, parsedate_tz, mktime_tz, formatdate
+from string import lowercase
+
+__version__ = '0.11 (Mailman edition)'
+VERSION = __version__
+CACHESIZE = 100 # Number of slots in the cache
+
+from mailman.Mailbox import ArchiverMailbox
+from mailman.core import errors
+from mailman.i18n import _
+
+SPACE = ' '
+
+log = logging.getLogger('mailman.error')
+
+
+
+msgid_pat = re.compile(r'(<.*>)')
+def strip_separators(s):
+ "Remove quotes or parenthesization from a Message-ID string"
+ if not s:
+ return ""
+ if s[0] in '"<([' and s[-1] in '">)]':
+ s = s[1:-1]
+ return s
+
+smallNameParts = ['van', 'von', 'der', 'de']
+
+def fixAuthor(author):
+ "Canonicalize a name into Last, First format"
+ # If there's a comma, guess that it's already in "Last, First" format
+ if ',' in author:
+ return author
+ L = author.split()
+ i = len(L) - 1
+ if i == 0:
+ return author # The string's one word--forget it
+ if author.upper() == author or author.lower() == author:
+ # Damn, the name is all upper- or lower-case.
+ while i > 0 and L[i-1].lower() in smallNameParts:
+ i = i - 1
+ else:
+ # Mixed case; assume that small parts of the last name will be
+ # in lowercase, and check them against the list.
+ while i>0 and (L[i-1][0] in lowercase or
+ L[i-1].lower() in smallNameParts):
+ i = i - 1
+ author = SPACE.join(L[-1:] + L[i:-1]) + ', ' + SPACE.join(L[:i])
+ return author
+
+# Abstract class for databases
+
+class DatabaseInterface:
+ def __init__(self): pass
+ def close(self): pass
+ def getArticle(self, archive, msgid): pass
+ def hasArticle(self, archive, msgid): pass
+ def addArticle(self, archive, article, subject=None, author=None,
+ date=None): pass
+ def firstdate(self, archive): pass
+ def lastdate(self, archive): pass
+ def first(self, archive, index): pass
+ def next(self, archive, index): pass
+ def numArticles(self, archive): pass
+ def newArchive(self, archive): pass
+ def setThreadKey(self, archive, key, msgid): pass
+ def getOldestArticle(self, subject): pass
+
+class Database(DatabaseInterface):
+ """Define the basic sorting logic for a database
+
+ Assumes that the database internally uses dateIndex, authorIndex,
+ etc.
+ """
+
+ # TBD Factor out more of the logic shared between BSDDBDatabase
+ # and HyperDatabase and place it in this class.
+
+ def __init__(self):
+ # This method need not be called by subclasses that do their
+ # own initialization.
+ self.dateIndex = {}
+ self.authorIndex = {}
+ self.subjectIndex = {}
+ self.articleIndex = {}
+ self.changed = {}
+
+ def addArticle(self, archive, article, subject=None, author=None,
+ date=None):
+ # create the keys; always end w/ msgid which will be unique
+ authorkey = (author or article.author, article.date,
+ article.msgid)
+ subjectkey = (subject or article.subject, article.date,
+ article.msgid)
+ datekey = date or article.date, article.msgid
+
+ # Add the new article
+ self.dateIndex[datekey] = article.msgid
+ self.authorIndex[authorkey] = article.msgid
+ self.subjectIndex[subjectkey] = article.msgid
+
+ self.store_article(article)
+ self.changed[archive, article.msgid] = None
+
+ parentID = article.parentID
+ if parentID is not None and self.articleIndex.has_key(parentID):
+ parent = self.getArticle(archive, parentID)
+ myThreadKey = parent.threadKey + article.date + '-'
+ else:
+ myThreadKey = article.date + '-'
+ article.threadKey = myThreadKey
+ key = myThreadKey, article.msgid
+ self.setThreadKey(archive, key, article.msgid)
+
+ def store_article(self, article):
+ """Store article without message body to save space"""
+ # TBD this is not thread safe!
+ temp = article.body
+ temp2 = article.html_body
+ article.body = []
+ del article.html_body
+ self.articleIndex[article.msgid] = pickle.dumps(article)
+ article.body = temp
+ article.html_body = temp2
+
+
+# The Article class encapsulates a single posting. The attributes
+# are:
+#
+# sequence : Sequence number, unique for each article in a set of archives
+# subject : Subject
+# datestr : The posting date, in human-readable format
+# date : The posting date, in purely numeric format
+# headers : Any other headers of interest
+# author : The author's name (and possibly organization)
+# email : The author's e-mail address
+# msgid : A unique message ID
+# in_reply_to: If != "", this is the msgid of the article being replied to
+# references : A (possibly empty) list of msgid's of earlier articles
+# in the thread
+# body : A list of strings making up the message body
+
+class Article:
+ _last_article_time = time.time()
+
+ def __init__(self, message = None, sequence = 0, keepHeaders = []):
+ if message is None:
+ return
+ self.sequence = sequence
+
+ self.parentID = None
+ self.threadKey = None
+ # otherwise the current sequence number is used.
+ id = strip_separators(message['Message-Id'])
+ if id == "":
+ self.msgid = str(self.sequence)
+ else: self.msgid = id
+
+ if message.has_key('Subject'):
+ self.subject = str(message['Subject'])
+ else:
+ self.subject = _('No subject')
+ if self.subject == "": self.subject = _('No subject')
+
+ self._set_date(message)
+
+ # Figure out the e-mail address and poster's name. Use the From:
+ # field first, followed by Reply-To:
+ self.author, self.email = parseaddr(message.get('From', ''))
+ e = message['Reply-To']
+ if not self.email and e is not None:
+ ignoreauthor, self.email = parseaddr(e)
+ self.email = strip_separators(self.email)
+ self.author = strip_separators(self.author)
+
+ if self.author == "":
+ self.author = self.email
+
+ # Save the In-Reply-To:, References:, and Message-ID: lines
+ #
+ # TBD: The original code does some munging on these fields, which
+ # shouldn't be necessary, but changing this may break code. For
+ # safety, I save the original headers on different attributes for use
+ # in writing the plain text periodic flat files.
+ self._in_reply_to = message['in-reply-to']
+ self._references = message['references']
+ self._message_id = message['message-id']
+
+ i_r_t = message['In-Reply-To']
+ if i_r_t is None:
+ self.in_reply_to = ''
+ else:
+ match = msgid_pat.search(i_r_t)
+ if match is None: self.in_reply_to = ''
+ else: self.in_reply_to = strip_separators(match.group(1))
+
+ references = message['References']
+ if references is None:
+ self.references = []
+ else:
+ self.references = map(strip_separators, references.split())
+
+ # Save any other interesting headers
+ self.headers = {}
+ for i in keepHeaders:
+ if message.has_key(i):
+ self.headers[i] = message[i]
+
+ # Read the message body
+ s = StringIO(message.get_payload(decode=True)\
+ or message.as_string().split('\n\n',1)[1])
+ self.body = s.readlines()
+
+ def _set_date(self, message):
+ def floatdate(header):
+ missing = []
+ datestr = message.get(header, missing)
+ if datestr is missing:
+ return None
+ date = parsedate_tz(datestr)
+ try:
+ return mktime_tz(date)
+ except (TypeError, ValueError, OverflowError):
+ return None
+ date = floatdate('date')
+ if date is None:
+ date = floatdate('x-list-received-date')
+ if date is None:
+ # What's left to try?
+ date = self._last_article_time + 1
+ self._last_article_time = date
+ self.date = '%011i' % date
+ self.datestr = message.get('date') \
+ or message.get('x-list-received-date') \
+ or formatdate(date)
+
+ def __repr__(self):
+ return ''
+
+ def finished_update_article(self):
+ pass
+
+# Pipermail formatter class
+
+class T:
+ DIRMODE = 0755 # Mode to give to created directories
+ FILEMODE = 0644 # Mode to give to created files
+ INDEX_EXT = ".html" # Extension for indexes
+
+ def __init__(self, basedir = None, reload = 1, database = None):
+ # If basedir isn't provided, assume the current directory
+ if basedir is None:
+ self.basedir = os.getcwd()
+ else:
+ basedir = os.path.expanduser(basedir)
+ self.basedir = basedir
+ self.database = database
+
+ # If the directory doesn't exist, create it. This code shouldn't get
+ # run anymore, we create the directory in Archiver.py. It should only
+ # get used by legacy lists created that are only receiving their first
+ # message in the HTML archive now -- Marc
+ try:
+ os.stat(self.basedir)
+ except os.error, errdata:
+ errno, errmsg = errdata
+ if errno != 2:
+ raise os.error, errdata
+ else:
+ self.message(_('Creating archive directory ') + self.basedir)
+ omask = os.umask(0)
+ try:
+ os.mkdir(self.basedir, self.DIRMODE)
+ finally:
+ os.umask(omask)
+
+ # Try to load previously pickled state
+ try:
+ if not reload:
+ raise IOError
+ f = open(os.path.join(self.basedir, 'pipermail.pck'), 'r')
+ self.message(_('Reloading pickled archive state'))
+ d = pickle.load(f)
+ f.close()
+ for key, value in d.items():
+ setattr(self, key, value)
+ except (IOError, EOFError):
+ # No pickled version, so initialize various attributes
+ self.archives = [] # Archives
+ self._dirty_archives = [] # Archives that will have to be updated
+ self.sequence = 0 # Sequence variable used for
+ # numbering articles
+ self.update_TOC = 0 # Does the TOC need updating?
+ #
+ # make the basedir variable work when passed in as an __init__ arg
+ # and different from the one in the pickle. Let the one passed in
+ # as an __init__ arg take precedence if it's stated. This way, an
+ # archive can be moved from one place to another and still work.
+ #
+ if basedir != self.basedir:
+ self.basedir = basedir
+
+ def close(self):
+ "Close an archive, save its state, and update any changed archives."
+ self.update_dirty_archives()
+ self.update_TOC = 0
+ self.write_TOC()
+ # Save the collective state
+ self.message(_('Pickling archive state into ')
+ + os.path.join(self.basedir, 'pipermail.pck'))
+ self.database.close()
+ del self.database
+
+ omask = os.umask(007)
+ try:
+ f = open(os.path.join(self.basedir, 'pipermail.pck'), 'w')
+ finally:
+ os.umask(omask)
+ pickle.dump(self.getstate(), f)
+ f.close()
+
+ def getstate(self):
+ # can override this in subclass
+ return self.__dict__
+
+ #
+ # Private methods
+ #
+ # These will be neither overridden nor called by custom archivers.
+ #
+
+
+ # Create a dictionary of various parameters that will be passed
+ # to the write_index_{header,footer} functions
+ def __set_parameters(self, archive):
+ # Determine the earliest and latest date in the archive
+ firstdate = self.database.firstdate(archive)
+ lastdate = self.database.lastdate(archive)
+
+ # Get the current time
+ now = time.asctime(time.localtime(time.time()))
+ self.firstdate = firstdate
+ self.lastdate = lastdate
+ self.archivedate = now
+ self.size = self.database.numArticles(archive)
+ self.archive = archive
+ self.version = __version__
+
+ # Find the message ID of an article's parent, or return None
+ # if no parent can be found.
+
+ def __findParent(self, article, children = []):
+ parentID = None
+ if article.in_reply_to:
+ parentID = article.in_reply_to
+ elif article.references:
+ # Remove article IDs that aren't in the archive
+ refs = filter(self.articleIndex.has_key, article.references)
+ if not refs:
+ return None
+ maxdate = self.database.getArticle(self.archive,
+ refs[0])
+ for ref in refs[1:]:
+ a = self.database.getArticle(self.archive, ref)
+ if a.date > maxdate.date:
+ maxdate = a
+ parentID = maxdate.msgid
+ else:
+ # Look for the oldest matching subject
+ try:
+ key, tempid = \
+ self.subjectIndex.set_location(article.subject)
+ print key, tempid
+ self.subjectIndex.next()
+ [subject, date] = key.split('\0')
+ print article.subject, subject, date
+ if subject == article.subject and tempid not in children:
+ parentID = tempid
+ except KeyError:
+ pass
+ return parentID
+
+ # Update the threaded index completely
+ def updateThreadedIndex(self):
+ # Erase the threaded index
+ self.database.clearIndex(self.archive, 'thread')
+
+ # Loop over all the articles
+ msgid = self.database.first(self.archive, 'date')
+ while msgid is not None:
+ try:
+ article = self.database.getArticle(self.archive, msgid)
+ except KeyError:
+ pass
+ else:
+ if article.parentID is None or \
+ not self.database.hasArticle(self.archive,
+ article.parentID):
+ # then
+ pass
+ else:
+ parent = self.database.getArticle(self.archive,
+ article.parentID)
+ article.threadKey = parent.threadKey+article.date+'-'
+ self.database.setThreadKey(self.archive,
+ (article.threadKey, article.msgid),
+ msgid)
+ msgid = self.database.next(self.archive, 'date')
+
+ #
+ # Public methods:
+ #
+ # These are part of the public interface of the T class, but will
+ # never be overridden (unless you're trying to do something very new).
+
+ # Update a single archive's indices, whether the archive's been
+ # dirtied or not.
+ def update_archive(self, archive):
+ self.archive = archive
+ self.message(_("Updating index files for archive [%(archive)s]"))
+ arcdir = os.path.join(self.basedir, archive)
+ self.__set_parameters(archive)
+
+ for hdr in ('Date', 'Subject', 'Author'):
+ self._update_simple_index(hdr, archive, arcdir)
+
+ self._update_thread_index(archive, arcdir)
+
+ def _update_simple_index(self, hdr, archive, arcdir):
+ self.message(" " + hdr)
+ self.type = hdr
+ hdr = hdr.lower()
+
+ self._open_index_file_as_stdout(arcdir, hdr)
+ self.write_index_header()
+ count = 0
+ # Loop over the index entries
+ msgid = self.database.first(archive, hdr)
+ while msgid is not None:
+ try:
+ article = self.database.getArticle(self.archive, msgid)
+ except KeyError:
+ pass
+ else:
+ count = count + 1
+ self.write_index_entry(article)
+ msgid = self.database.next(archive, hdr)
+ # Finish up this index
+ self.write_index_footer()
+ self._restore_stdout()
+
+ def _update_thread_index(self, archive, arcdir):
+ self.message(_(" Thread"))
+ self._open_index_file_as_stdout(arcdir, "thread")
+ self.type = 'Thread'
+ self.write_index_header()
+
+ # To handle the prev./next in thread pointers, we need to
+ # track articles 5 at a time.
+
+ # Get the first 5 articles
+ L = [None] * 5
+ i = 2
+ msgid = self.database.first(self.archive, 'thread')
+
+ while msgid is not None and i < 5:
+ L[i] = self.database.getArticle(self.archive, msgid)
+ i = i + 1
+ msgid = self.database.next(self.archive, 'thread')
+
+ while L[2] is not None:
+ article = L[2]
+ artkey = None
+ if article is not None:
+ artkey = article.threadKey
+ if artkey is not None:
+ self.write_threadindex_entry(article, artkey.count('-') - 1)
+ if self.database.changed.has_key((archive,article.msgid)):
+ a1 = L[1]
+ a3 = L[3]
+ self.update_article(arcdir, article, a1, a3)
+ if a3 is not None:
+ self.database.changed[(archive, a3.msgid)] = None
+ if a1 is not None:
+ key = archive, a1.msgid
+ if not self.database.changed.has_key(key):
+ self.update_article(arcdir, a1, L[0], L[2])
+ else:
+ del self.database.changed[key]
+ if L[0]:
+ L[0].finished_update_article()
+ L = L[1:] # Rotate the list
+ if msgid is None:
+ L.append(msgid)
+ else:
+ L.append(self.database.getArticle(self.archive, msgid))
+ msgid = self.database.next(self.archive, 'thread')
+
+ self.write_index_footer()
+ self._restore_stdout()
+
+ def _open_index_file_as_stdout(self, arcdir, index_name):
+ path = os.path.join(arcdir, index_name + self.INDEX_EXT)
+ omask = os.umask(002)
+ try:
+ self.__f = open(path, 'w')
+ finally:
+ os.umask(omask)
+ self.__stdout = sys.stdout
+ sys.stdout = self.__f
+
+ def _restore_stdout(self):
+ sys.stdout = self.__stdout
+ self.__f.close()
+ del self.__f
+ del self.__stdout
+
+ # Update only archives that have been marked as "changed".
+ def update_dirty_archives(self):
+ for i in self._dirty_archives:
+ self.update_archive(i)
+ self._dirty_archives = []
+
+ # Read a Unix mailbox file from the file object ,
+ # and create a series of Article objects. Each article
+ # object will then be archived.
+
+ def _makeArticle(self, msg, sequence):
+ return Article(msg, sequence)
+
+ def processUnixMailbox(self, input, start=None, end=None):
+ mbox = ArchiverMailbox(input, self.maillist)
+ if start is None:
+ start = 0
+ counter = 0
+ while counter < start:
+ try:
+ m = mbox.next()
+ except errors.DiscardMessage:
+ continue
+ if m is None:
+ return
+ counter += 1
+ while 1:
+ try:
+ pos = input.tell()
+ m = mbox.next()
+ except errors.DiscardMessage:
+ continue
+ except Exception:
+ log.error('uncaught archiver exception at filepos: %s', pos)
+ raise
+ if m is None:
+ break
+ if m == '':
+ # It was an unparseable message
+ continue
+ msgid = m.get('message-id', 'n/a')
+ self.message(_('#%(counter)05d %(msgid)s'))
+ a = self._makeArticle(m, self.sequence)
+ self.sequence += 1
+ self.add_article(a)
+ if end is not None and counter >= end:
+ break
+ counter += 1
+
+ def new_archive(self, archive, archivedir):
+ self.archives.append(archive)
+ self.update_TOC = 1
+ self.database.newArchive(archive)
+ # If the archive directory doesn't exist, create it
+ try:
+ os.stat(archivedir)
+ except os.error, errdata:
+ errno, errmsg = errdata
+ if errno == 2:
+ omask = os.umask(0)
+ try:
+ os.mkdir(archivedir, self.DIRMODE)
+ finally:
+ os.umask(omask)
+ else:
+ raise os.error, errdata
+ self.open_new_archive(archive, archivedir)
+
+ def add_article(self, article):
+ archives = self.get_archives(article)
+ if not archives:
+ return
+ if type(archives) == type(''):
+ archives = [archives]
+
+ article.filename = filename = self.get_filename(article)
+ temp = self.format_article(article)
+ for arch in archives:
+ self.archive = arch # why do this???
+ archivedir = os.path.join(self.basedir, arch)
+ if arch not in self.archives:
+ self.new_archive(arch, archivedir)
+
+ # Write the HTML-ized article
+ self.write_article(arch, temp, os.path.join(archivedir,
+ filename))
+
+ if article.decoded.has_key('author'):
+ author = fixAuthor(article.decoded['author'])
+ else:
+ author = fixAuthor(article.author)
+ if article.decoded.has_key('stripped'):
+ subject = article.decoded['stripped'].lower()
+ else:
+ subject = article.subject.lower()
+
+ article.parentID = parentID = self.get_parent_info(arch, article)
+ if parentID:
+ parent = self.database.getArticle(arch, parentID)
+ article.threadKey = parent.threadKey + article.date + '-'
+ else:
+ article.threadKey = article.date + '-'
+ key = article.threadKey, article.msgid
+
+ self.database.setThreadKey(arch, key, article.msgid)
+ self.database.addArticle(arch, temp, author=author,
+ subject=subject)
+
+ if arch not in self._dirty_archives:
+ self._dirty_archives.append(arch)
+
+ def get_parent_info(self, archive, article):
+ parentID = None
+ if article.in_reply_to:
+ parentID = article.in_reply_to
+ elif article.references:
+ refs = self._remove_external_references(article.references)
+ if refs:
+ maxdate = self.database.getArticle(archive, refs[0])
+ for ref in refs[1:]:
+ a = self.database.getArticle(archive, ref)
+ if a.date > maxdate.date:
+ maxdate = a
+ parentID = maxdate.msgid
+ else:
+ # Get the oldest article with a matching subject, and
+ # assume this is a follow-up to that article
+ parentID = self.database.getOldestArticle(archive,
+ article.subject)
+
+ if parentID and not self.database.hasArticle(archive, parentID):
+ parentID = None
+ return parentID
+
+ def write_article(self, index, article, path):
+ omask = os.umask(002)
+ try:
+ f = open(path, 'w')
+ finally:
+ os.umask(omask)
+ temp_stdout, sys.stdout = sys.stdout, f
+ self.write_article_header(article)
+ sys.stdout.writelines(article.body)
+ self.write_article_footer(article)
+ sys.stdout = temp_stdout
+ f.close()
+
+ def _remove_external_references(self, refs):
+ keep = []
+ for ref in refs:
+ if self.database.hasArticle(self.archive, ref):
+ keep.append(ref)
+ return keep
+
+ # Abstract methods: these will need to be overridden by subclasses
+ # before anything useful can be done.
+
+ def get_filename(self, article):
+ pass
+ def get_archives(self, article):
+ """Return a list of indexes where the article should be filed.
+ A string can be returned if the list only contains one entry,
+ and the empty list is legal."""
+ pass
+ def format_article(self, article):
+ pass
+ def write_index_header(self):
+ pass
+ def write_index_footer(self):
+ pass
+ def write_index_entry(self, article):
+ pass
+ def write_threadindex_entry(self, article, depth):
+ pass
+ def write_article_header(self, article):
+ pass
+ def write_article_footer(self, article):
+ pass
+ def write_article_entry(self, article):
+ pass
+ def update_article(self, archivedir, article, prev, next):
+ pass
+ def write_TOC(self):
+ pass
+ def open_new_archive(self, archive, dir):
+ pass
+ def message(self, msg):
+ pass
+
+
+class BSDDBdatabase(Database):
+ __super_addArticle = Database.addArticle
+
+ def __init__(self, basedir):
+ self.__cachekeys = []
+ self.__cachedict = {}
+ self.__currentOpenArchive = None # The currently open indices
+ self.basedir = os.path.expanduser(basedir)
+ self.changed = {} # Recently added articles, indexed only by
+ # message ID
+
+ def firstdate(self, archive):
+ self.__openIndices(archive)
+ date = 'None'
+ try:
+ date, msgid = self.dateIndex.first()
+ date = time.asctime(time.localtime(float(date)))
+ except KeyError:
+ pass
+ return date
+
+ def lastdate(self, archive):
+ self.__openIndices(archive)
+ date = 'None'
+ try:
+ date, msgid = self.dateIndex.last()
+ date = time.asctime(time.localtime(float(date)))
+ except KeyError:
+ pass
+ return date
+
+ def numArticles(self, archive):
+ self.__openIndices(archive)
+ return len(self.dateIndex)
+
+ def addArticle(self, archive, article, subject=None, author=None,
+ date=None):
+ self.__openIndices(archive)
+ self.__super_addArticle(archive, article, subject, author, date)
+
+ # Open the BSDDB files that are being used as indices
+ # (dateIndex, authorIndex, subjectIndex, articleIndex)
+ def __openIndices(self, archive):
+ if self.__currentOpenArchive == archive:
+ return
+
+ import bsddb
+ self.__closeIndices()
+ arcdir = os.path.join(self.basedir, 'database')
+ omask = os.umask(0)
+ try:
+ try:
+ os.mkdir(arcdir, 02775)
+ except OSError:
+ # BAW: Hmm...
+ pass
+ finally:
+ os.umask(omask)
+ for hdr in ('date', 'author', 'subject', 'article', 'thread'):
+ path = os.path.join(arcdir, archive + '-' + hdr)
+ t = bsddb.btopen(path, 'c')
+ setattr(self, hdr + 'Index', t)
+ self.__currentOpenArchive = archive
+
+ # Close the BSDDB files that are being used as indices (if they're
+ # open--this is safe to call if they're already closed)
+ def __closeIndices(self):
+ if self.__currentOpenArchive is not None:
+ pass
+ for hdr in ('date', 'author', 'subject', 'thread', 'article'):
+ attr = hdr + 'Index'
+ if hasattr(self, attr):
+ index = getattr(self, attr)
+ if hdr == 'article':
+ if not hasattr(self, 'archive_length'):
+ self.archive_length = {}
+ self.archive_length[self.__currentOpenArchive] = len(index)
+ index.close()
+ delattr(self,attr)
+ self.__currentOpenArchive = None
+
+ def close(self):
+ self.__closeIndices()
+ def hasArticle(self, archive, msgid):
+ self.__openIndices(archive)
+ return self.articleIndex.has_key(msgid)
+ def setThreadKey(self, archive, key, msgid):
+ self.__openIndices(archive)
+ self.threadIndex[key] = msgid
+ def getArticle(self, archive, msgid):
+ self.__openIndices(archive)
+ if self.__cachedict.has_key(msgid):
+ self.__cachekeys.remove(msgid)
+ self.__cachekeys.append(msgid)
+ return self.__cachedict[msgid]
+ if len(self.__cachekeys) == CACHESIZE:
+ delkey, self.__cachekeys = (self.__cachekeys[0],
+ self.__cachekeys[1:])
+ del self.__cachedict[delkey]
+ s = self.articleIndex[msgid]
+ article = pickle.loads(s)
+ self.__cachekeys.append(msgid)
+ self.__cachedict[msgid] = article
+ return article
+
+ def first(self, archive, index):
+ self.__openIndices(archive)
+ index = getattr(self, index+'Index')
+ try:
+ key, msgid = index.first()
+ return msgid
+ except KeyError:
+ return None
+ def next(self, archive, index):
+ self.__openIndices(archive)
+ index = getattr(self, index+'Index')
+ try:
+ key, msgid = index.next()
+ except KeyError:
+ return None
+ else:
+ return msgid
+
+ def getOldestArticle(self, archive, subject):
+ self.__openIndices(archive)
+ subject = subject.lower()
+ try:
+ key, tempid = self.subjectIndex.set_location(subject)
+ self.subjectIndex.next()
+ [subject2, date] = key.split('\0')
+ if subject != subject2:
+ return None
+ return tempid
+ except KeyError: # XXX what line raises the KeyError?
+ return None
+
+ def newArchive(self, archive):
+ pass
+
+ def clearIndex(self, archive, index):
+ self.__openIndices(archive)
+ index = getattr(self, index+'Index')
+ finished = 0
+ try:
+ key, msgid = self.threadIndex.first()
+ except KeyError:
+ finished = 1
+ while not finished:
+ del self.threadIndex[key]
+ try:
+ key, msgid = self.threadIndex.next()
+ except KeyError:
+ finished = 1
+
+
diff --git a/src/mailman/Bouncers/BouncerAPI.py b/src/mailman/Bouncers/BouncerAPI.py
new file mode 100644
index 000000000..f4712ec20
--- /dev/null
+++ b/src/mailman/Bouncers/BouncerAPI.py
@@ -0,0 +1,64 @@
+# Copyright (C) 1998-2009 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 .
+
+"""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
new file mode 100644
index 000000000..3bf03cc62
--- /dev/null
+++ b/src/mailman/Bouncers/Caiwireless.py
@@ -0,0 +1,45 @@
+# Copyright (C) 1998-2009 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 .
+
+"""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[^>]*)>')
+
+
+
+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/Compuserve.py b/src/mailman/Bouncers/Compuserve.py
new file mode 100644
index 000000000..2297a72a9
--- /dev/null
+++ b/src/mailman/Bouncers/Compuserve.py
@@ -0,0 +1,46 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Compuserve has its own weird format for bounces."""
+
+import re
+import email
+
+dcre = re.compile(r'your message could not be delivered', re.IGNORECASE)
+acre = re.compile(r'Invalid receiver address: (?P.*)')
+
+
+
+def process(msg):
+ # simple state machine
+ # 0 = nothing seen yet
+ # 1 = intro line seen
+ state = 0
+ addrs = []
+ for line in email.Iterators.body_line_iterator(msg):
+ if state == 0:
+ mo = dcre.search(line)
+ if mo:
+ state = 1
+ elif state == 1:
+ mo = dcre.search(line)
+ if mo:
+ break
+ mo = acre.search(line)
+ if mo:
+ addrs.append(mo.group('addr'))
+ return addrs
diff --git a/src/mailman/Bouncers/DSN.py b/src/mailman/Bouncers/DSN.py
new file mode 100644
index 000000000..37e5bcb83
--- /dev/null
+++ b/src/mailman/Bouncers/DSN.py
@@ -0,0 +1,99 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Parse RFC 3464 (i.e. DSN) bounce formats.
+
+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 mailman.Bouncers.BouncerAPI import Stop
+
+
+
+def check(msg):
+ # Iterate over each message/delivery-status subpart
+ addrs = []
+ for part in typed_subpart_iterator(msg, 'message', 'delivery-status'):
+ if not part.is_multipart():
+ # Huh?
+ continue
+ # Each message/delivery-status contains a list of Message objects
+ # which are the header blocks. Iterate over those too.
+ for msgblock in part.get_payload():
+ # We try to dig out the Original-Recipient (which is optional) and
+ # Final-Recipient (which is mandatory, but may not exactly match
+ # an address on our list). Some MTA's also use X-Actual-Recipient
+ # as a synonym for Original-Recipient, but some apparently use
+ # that for other purposes :(
+ #
+ # Also grok out Action so we can do something with that too.
+ action = msgblock.get('action', '').lower()
+ # Some MTAs have been observed that put comments on the action.
+ if action.startswith('delayed'):
+ return Stop
+ if not action.startswith('fail'):
+ # Some non-permanent failure, so ignore this block
+ continue
+ params = []
+ foundp = False
+ for header in ('original-recipient', 'final-recipient'):
+ for k, v in msgblock.get_params([], header):
+ if k.lower() == 'rfc822':
+ foundp = True
+ else:
+ params.append(k)
+ if foundp:
+ # Note that params should already be unquoted.
+ addrs.extend(params)
+ break
+ else:
+ # MAS: This is a kludge, but SMTP-GATEWAY01.intra.home.dk
+ # has a final-recipient with an angle-addr and no
+ # 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()
+
+
+
+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)
diff --git a/src/mailman/Bouncers/Exchange.py b/src/mailman/Bouncers/Exchange.py
new file mode 100644
index 000000000..cf8beefce
--- /dev/null
+++ b/src/mailman/Bouncers/Exchange.py
@@ -0,0 +1,48 @@
+# Copyright (C) 2002-2009 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 .
+
+"""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[^;]+); on ')
+a2cre = re.compile('(?P[^ ]+) 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/Exim.py b/src/mailman/Bouncers/Exim.py
new file mode 100644
index 000000000..0f4e7f4cf
--- /dev/null
+++ b/src/mailman/Bouncers/Exim.py
@@ -0,0 +1,31 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Parse bounce messages generated by Exim.
+
+Exim adds an X-Failed-Recipients: header to bounce messages containing
+an `addresslist' of failed addresses.
+
+"""
+
+from email.Utils import getaddresses
+
+
+
+def process(msg):
+ all = msg.get_all('x-failed-recipients', [])
+ return [a for n, a in getaddresses(all)]
diff --git a/src/mailman/Bouncers/GroupWise.py b/src/mailman/Bouncers/GroupWise.py
new file mode 100644
index 000000000..e74291217
--- /dev/null
+++ b/src/mailman/Bouncers/GroupWise.py
@@ -0,0 +1,71 @@
+# Copyright (C) 1998-2009 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 .
+
+"""This appears to be the format for Novell GroupWise and NTMail
+
+X-Mailer: Novell GroupWise Internet Agent 5.5.3.1
+X-Mailer: NTMail v4.30.0012
+X-Mailer: Internet Mail Service (5.5.2653.19)
+"""
+
+import re
+from email.Message import Message
+from cStringIO import StringIO
+
+acre = re.compile(r'<(?P[^>]*)>')
+
+
+
+def find_textplain(msg):
+ if msg.get_content_type() == 'text/plain':
+ return msg
+ if msg.is_multipart:
+ for part in msg.get_payload():
+ if not isinstance(part, Message):
+ continue
+ ret = find_textplain(part)
+ if ret:
+ return ret
+ return None
+
+
+
+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()
diff --git a/src/mailman/Bouncers/LLNL.py b/src/mailman/Bouncers/LLNL.py
new file mode 100644
index 000000000..cc1a08542
--- /dev/null
+++ b/src/mailman/Bouncers/LLNL.py
@@ -0,0 +1,32 @@
+# Copyright (C) 2001-2009 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 .
+
+"""LLNL's custom Sendmail bounce message."""
+
+import re
+import email
+
+acre = re.compile(r',\s*(?P\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 []
diff --git a/src/mailman/Bouncers/Microsoft.py b/src/mailman/Bouncers/Microsoft.py
new file mode 100644
index 000000000..98d27d4ee
--- /dev/null
+++ b/src/mailman/Bouncers/Microsoft.py
@@ -0,0 +1,53 @@
+# Copyright (C) 1998-2009 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 .
+
+"""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
new file mode 100644
index 000000000..319329e84
--- /dev/null
+++ b/src/mailman/Bouncers/Netscape.py
@@ -0,0 +1,89 @@
+# Copyright (C) 1998-2009 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 .
+
+"""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'(?Pplease reply to)?.*<(?P[^>]*)>',
+ 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
+ # 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
new file mode 100644
index 000000000..cfc97a05e
--- /dev/null
+++ b/src/mailman/Bouncers/Postfix.py
@@ -0,0 +1,86 @@
+# Copyright (C) 1998-2009 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 .
+
+"""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[^>]*)>:')
+
+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
new file mode 100644
index 000000000..2431da653
--- /dev/null
+++ b/src/mailman/Bouncers/Qmail.py
@@ -0,0 +1,72 @@
+# Copyright (C) 1998-2009 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 .
+
+"""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[^>]*)>:')
+
+
+
+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/SMTP32.py b/src/mailman/Bouncers/SMTP32.py
new file mode 100644
index 000000000..a7fff2ed3
--- /dev/null
+++ b/src/mailman/Bouncers/SMTP32.py
@@ -0,0 +1,60 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Something which claims
+X-Mailer:
+
+What the heck is this thing? Here's a recent host:
+
+% telnet 207.51.255.218 smtp
+Trying 207.51.255.218...
+Connected to 207.51.255.218.
+Escape character is '^]'.
+220 X1 NT-ESMTP Server 208.24.118.205 (IMail 6.00 45595-15)
+
+"""
+
+import re
+import email
+
+ecre = re.compile('original message follows', re.IGNORECASE)
+acre = re.compile(r'''
+ ( # several different prefixes
+ user\ mailbox[^:]*: # have been spotted in the
+ |delivery\ failed[^:]*: # wild...
+ |unknown\ user[^:]*:
+ |undeliverable\ +to
+ |delivery\ userid[^:]*:
+ )
+ \s* # space separator
+ (?P[^\s]*) # and finally, the address
+ ''', re.IGNORECASE | re.VERBOSE)
+
+
+
+def process(msg):
+ mailer = msg.get('x-mailer', '')
+ if not mailer.startswith('.
+
+"""Recognizes simple heuristically delimited bounces."""
+
+import re
+import email.Iterators
+
+
+
+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
+# 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
+# address that bounced.
+PATTERNS = [
+ # sdm.de
+ (_c('here is your list of failed recipients'),
+ _c('here is your returned mail'),
+ _c(r'<(?P[^>]*)>')),
+ # sz-sb.de, corridor.com, nfg.nl
+ (_c('the following addresses had'),
+ _c('transcript of session follows'),
+ _c(r'<(?P[^>]*)>|\(expanded from: (?P[^>)]*)>?\)')),
+ # robanal.demon.co.uk
+ (_c('this message was created automatically by mail delivery software'),
+ _c('original message follows'),
+ _c('rcpt to:\s*<(?P[^>]*)>')),
+ # s1.com (InterScan E-Mail VirusWall NT ???)
+ (_c('message from interscan e-mail viruswall nt'),
+ _c('end of message'),
+ _c('rcpt to:\s*<(?P[^>]*)>')),
+ # Smail
+ (_c('failed addresses follow:'),
+ _c('message text follows:'),
+ _c(r'\s*(?P\S+@\S+)')),
+ # newmail.ru
+ (_c('This is the machine generated message from mail service.'),
+ _c('--- Below the next line is a copy of the message.'),
+ _c('<(?P[^>]*)>')),
+ # turbosport.com runs something called `MDaemon 3.5.2' ???
+ (_c('The following addresses did NOT receive a copy of your message:'),
+ _c('--- Session Transcript ---'),
+ _c('[>]\s*(?P.*)$')),
+ # usa.net
+ (_c('Intended recipient:\s*(?P.*)$'),
+ _c('--------RETURNED MAIL FOLLOWS--------'),
+ _c('Intended recipient:\s*(?P.*)$')),
+ # hotpop.com
+ (_c('Undeliverable Address:\s*(?P.*)$'),
+ _c('Original message attached'),
+ _c('Undeliverable Address:\s*(?P.*)$')),
+ # Another demon.co.uk format
+ (_c('This message was created automatically by mail delivery'),
+ _c('^---- START OF RETURNED MESSAGE ----'),
+ _c("addressed to '(?P[^']*)'")),
+ # Prodigy.net full mailbox
+ (_c("User's mailbox is full:"),
+ _c('Unable to deliver mail.'),
+ _c("User's mailbox is full:\s*<(?P[^>]*)>")),
+ # Microsoft SMTPSVC
+ (_c('The email below could not be delivered to the following user:'),
+ _c('Old message:'),
+ _c('<(?P[^>]*)>')),
+ # Yahoo on behalf of other domains like sbcglobal.net
+ (_c('Unable to deliver message to the following address\(es\)\.'),
+ _c('--- Original message follows\.'),
+ _c('<(?P[^>]*)>:')),
+ # googlemail.com
+ (_c('Delivery to the following recipient failed'),
+ _c('----- Original message -----'),
+ _c('^\s*(?P[^\s@]+@[^\s@]+)\s*$')),
+ # kundenserver.de
+ (_c('A message that you sent could not be delivered'),
+ _c('^---'),
+ _c('<(?P[^>]*)>')),
+ # another kundenserver.de
+ (_c('A message that you sent could not be delivered'),
+ _c('^---'),
+ _c('^(?P[^\s@]+@[^\s@:]+):')),
+ # thehartford.com
+ (_c('Delivery to the following recipients failed'),
+ # this one may or may not have the original message, but there's nothing
+ # unique to stop on, so stop on the first line of at least 3 characters
+ # that doesn't start with 'D' (to not stop immediately) and has no '@'.
+ _c('^[^D][^@]{2,}$'),
+ _c('^\s*(?P[^\s@]+@[^\s@]+)\s*$')),
+ # and another thehartfod.com/hartfordlife.com
+ (_c('^Your message\s*$'),
+ _c('^because:'),
+ _c('^\s*(?P[^\s@]+@[^\s@]+)\s*$')),
+ # kviv.be (InterScan NT)
+ (_c('^Unable to deliver message to'),
+ _c(r'\*+\s+End of message\s+\*+'),
+ _c('<(?P[^>]*)>')),
+ # earthlink.net supported domains
+ (_c('^Sorry, unable to deliver your message to'),
+ _c('^A copy of the original message'),
+ _c('\s*(?P[^\s@]+@[^\s@]+)\s+')),
+ # ademe.fr
+ (_c('^A message could not be delivered to:'),
+ _c('^Subject:'),
+ _c('^\s*(?P[^\s@]+@[^\s@]+)\s*$')),
+ # andrew.ac.jp
+ (_c('^Invalid final delivery userid:'),
+ _c('^Original message follows.'),
+ _c('\s*(?P[^\s@]+@[^\s@]+)\s*$')),
+ # E500_SMTP_Mail_Service@lerctr.org
+ (_c('------ Failed Recipients ------'),
+ _c('-------- Returned Mail --------'),
+ _c('<(?P[^>]*)>')),
+ # cynergycom.net
+ (_c('A message that you sent could not be delivered'),
+ _c('^---'),
+ _c('(?P[^\s@]+@[^\s@)]+)')),
+ # LSMTP for Windows
+ (_c('^--> Error description:\s*$'),
+ _c('^Error-End:'),
+ _c('^Error-for:\s+(?P[^\s@]+@[^\s@]+)')),
+ # Qmail with a tri-language intro beginning in spanish
+ (_c('Your message could not be delivered'),
+ _c('^-'),
+ _c('<(?P[^>]*)>:')),
+ # socgen.com
+ (_c('Your message could not be delivered to'),
+ _c('^\s*$'),
+ _c('(?P[^\s@]+@[^\s@]+)')),
+ # dadoservice.it
+ (_c('Your message has encountered delivery problems'),
+ _c('Your message reads'),
+ _c('addressed to\s*(?P[^\s@]+@[^\s@)]+)')),
+ # gomaps.com
+ (_c('Did not reach the following recipient'),
+ _c('^\s*$'),
+ _c('\s(?P[^\s@]+@[^\s@]+)')),
+ # EYOU MTA SYSTEM
+ (_c('This is the deliver program at'),
+ _c('^-'),
+ _c('^(?P[^\s@]+@[^\s@<>]+)')),
+ # A non-standard qmail at ieo.it
+ (_c('this is the email server at'),
+ _c('^-'),
+ _c('\s(?P[^\s@]+@[^\s@]+)[\s,]')),
+ # pla.net.py (MDaemon.PRO ?)
+ (_c('- no such user here'),
+ _c('There is no user'),
+ _c('^(?P[^\s@]+@[^\s@]+)\s')),
+ # Next one goes here...
+ ]
+
+
+
+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()
diff --git a/src/mailman/Bouncers/SimpleWarning.py b/src/mailman/Bouncers/SimpleWarning.py
new file mode 100644
index 000000000..ab18d2530
--- /dev/null
+++ b/src/mailman/Bouncers/SimpleWarning.py
@@ -0,0 +1,62 @@
+# Copyright (C) 2001-2009 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 .
+
+"""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
+
+
+
+# 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
+# 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
+# address that bounced.
+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'),
+ _c(r'\s*(?P\S+@\S+)\s*')),
+ # This is from MessageSwitch. It is a kludge because the text that
+ # identifies it as a warning only comes after the address. We can't
+ # use ecre, because it really isn't significant, so we fake it. Once
+ # we see the start, we know it's a warning, and we're going to return
+ # Stop anyway, so we match anything for the address and end.
+ (_c('This is just a warning, you do not need to take any action'),
+ _c('.+'),
+ _c('(?P.+)')),
+ # Symantec_AntiVirus_for_SMTP_Gateways - see comments for MessageSwitch
+ (_c('Delivery attempts will continue to be made'),
+ _c('.+'),
+ _c('(?P.+)')),
+ # Next one goes here...
+ ]
+
+
+
+def process(msg):
+ if _process(msg, patterns):
+ # It's a recognized warning so stop now
+ return Stop
+ else:
+ return []
diff --git a/src/mailman/Bouncers/Sina.py b/src/mailman/Bouncers/Sina.py
new file mode 100644
index 000000000..a6b8e0911
--- /dev/null
+++ b/src/mailman/Bouncers/Sina.py
@@ -0,0 +1,48 @@
+# Copyright (C) 2002-2009 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 .
+
+"""sina.com bounces"""
+
+import re
+from email import Iterators
+
+acre = re.compile(r'<(?P[^>]*)>')
+
+
+
+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
new file mode 100644
index 000000000..b0480b818
--- /dev/null
+++ b/src/mailman/Bouncers/Yahoo.py
@@ -0,0 +1,54 @@
+# Copyright (C) 1998-2009 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 .
+
+"""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[^>]*)>:')
+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
new file mode 100644
index 000000000..956dfb838
--- /dev/null
+++ b/src/mailman/Bouncers/Yale.py
@@ -0,0 +1,80 @@
+# Copyright (C) 2000-2009 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 .
+
+"""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\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/Bouncers/__init__.py b/src/mailman/Bouncers/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mailman/Mailbox.py b/src/mailman/Mailbox.py
new file mode 100644
index 000000000..3a2f079c4
--- /dev/null
+++ b/src/mailman/Mailbox.py
@@ -0,0 +1,106 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Extend mailbox.UnixMailbox.
+"""
+
+import sys
+import email
+import mailbox
+
+from email.errors import MessageParseError
+from email.generator import Generator
+
+from mailman.Message import Message
+from mailman.config import config
+
+
+
+def _safeparser(fp):
+ try:
+ return email.message_from_file(fp, Message)
+ except MessageParseError:
+ # Don't return None since that will stop a mailbox iterator
+ return ''
+
+
+
+class Mailbox(mailbox.PortableUnixMailbox):
+ def __init__(self, fp):
+ mailbox.PortableUnixMailbox.__init__(self, fp, _safeparser)
+
+ # msg should be an rfc822 message or a subclass.
+ def AppendMessage(self, msg):
+ # Check the last character of the file and write a newline if it isn't
+ # a newline (but not at the beginning of an empty file).
+ try:
+ self.fp.seek(-1, 2)
+ except IOError, e:
+ # Assume the file is empty. We can't portably test the error code
+ # returned, since it differs per platform.
+ pass
+ else:
+ if self.fp.read(1) <> '\n':
+ self.fp.write('\n')
+ # Seek to the last char of the mailbox
+ self.fp.seek(1, 2)
+ # Create a Generator instance to write the message to the file
+ g = Generator(self.fp)
+ g.flatten(msg, unixfrom=True)
+ # Add one more trailing newline for separation with the next message
+ # to be appended to the mbox.
+ print >> self.fp
+
+
+
+# This stuff is used by pipermail.py:processUnixMailbox(). It provides an
+# opportunity for the built-in archiver to scrub archived messages of nasty
+# things like attachments and such...
+def _archfactory(mailbox):
+ # The factory gets a file object, but it also needs to have a MailList
+ # object, so the clearest way to do this is to build a factory
+ # function that has a reference to the mailbox object, which in turn holds
+ # a reference to the mailing list. Nested scopes would help here, BTW,
+ # but we can't rely on them being around (e.g. Python 2.0).
+ def scrubber(fp, mailbox=mailbox):
+ msg = _safeparser(fp)
+ if msg == '':
+ return msg
+ return mailbox.scrub(msg)
+ return scrubber
+
+
+class ArchiverMailbox(Mailbox):
+ # This is a derived class which is instantiated with a reference to the
+ # MailList object. It is build such that the factory calls back into its
+ # scrub() method, giving the scrubber module a chance to do its thing
+ # before the message is archived.
+ def __init__(self, fp, mlist):
+ scrubber_module = config.scrubber.archive_scrubber
+ if scrubber_module:
+ __import__(scrubber_module)
+ self._scrubber = sys.modules[scrubber_module].process
+ else:
+ self._scrubber = None
+ self._mlist = mlist
+ mailbox.PortableUnixMailbox.__init__(self, fp, _archfactory(self))
+
+ def scrub(self, msg):
+ if self._scrubber:
+ return self._scrubber(self._mlist, msg)
+ else:
+ return msg
diff --git a/src/mailman/Message.py b/src/mailman/Message.py
new file mode 100644
index 000000000..ac41a758c
--- /dev/null
+++ b/src/mailman/Message.py
@@ -0,0 +1,297 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Standard Mailman message object.
+
+This is a subclass of email.message.Message but provides a slightly extended
+interface which is more convenient for use inside Mailman.
+"""
+
+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 import Utils
+from mailman.config import config
+
+COMMASPACE = ', '
+
+mo = re.match(r'([\d.]+)', email.__version__)
+VERSION = tuple(int(s) for s in mo.group().split('.'))
+
+
+
+class Message(email.message.Message):
+ def __init__(self):
+ # We need a version number so that we can optimize __setstate__()
+ self.__version__ = VERSION
+ email.message.Message.__init__(self)
+
+ def __getitem__(self, key):
+ value = email.message.Message.__getitem__(self, key)
+ if isinstance(value, str):
+ return unicode(value, 'ascii')
+ return value
+
+ def get(self, name, failobj=None):
+ value = email.message.Message.get(self, name, failobj)
+ if isinstance(value, str):
+ return unicode(value, 'ascii')
+ return value
+
+ def get_all(self, name, failobj=None):
+ missing = object()
+ all_values = email.message.Message.get_all(self, name, missing)
+ if all_values is missing:
+ return failobj
+ return [(unicode(value, 'ascii') if isinstance(value, str) else value)
+ for value in all_values]
+
+ # BAW: For debugging w/ bin/dumpdb. Apparently pprint uses repr.
+ def __repr__(self):
+ return self.__str__()
+
+ def __setstate__(self, d):
+ # The base class attributes have changed over time. Which could
+ # affect Mailman if messages are sitting in the queue at the time of
+ # upgrading the email package. We shouldn't burden email with this,
+ # so we handle schema updates here.
+ self.__dict__ = d
+ # We know that email 2.4.3 is up-to-date
+ version = d.get('__version__', (0, 0, 0))
+ d['__version__'] = VERSION
+ if version >= VERSION:
+ return
+ # Messages grew a _charset attribute between email version 0.97 and 1.1
+ if not d.has_key('_charset'):
+ self._charset = None
+ # Messages grew a _default_type attribute between v2.1 and v2.2
+ if not d.has_key('_default_type'):
+ # We really have no idea whether this message object is contained
+ # inside a multipart/digest or not, so I think this is the best we
+ # can do.
+ self._default_type = 'text/plain'
+ # Header instances used to allow both strings and Charsets in their
+ # _chunks, but by email 2.4.3 now it's just Charsets.
+ headers = []
+ hchanged = 0
+ for k, v in self._headers:
+ if isinstance(v, Header):
+ chunks = []
+ cchanged = 0
+ for s, charset in v._chunks:
+ if isinstance(charset, str):
+ charset = Charset(charset)
+ cchanged = 1
+ chunks.append((s, charset))
+ if cchanged:
+ v._chunks = chunks
+ hchanged = 1
+ headers.append((k, v))
+ if hchanged:
+ self._headers = headers
+
+ # I think this method ought to eventually be deprecated
+ def get_sender(self):
+ """Return the address considered to be the author of the email.
+
+ This can return either the From: header, the Sender: header or the
+ envelope header (a.k.a. the unixfrom header). The first non-empty
+ header value found is returned. However the search order is
+ determined by the following:
+
+ - If config.mailman.use_envelope_sender is true, then the search order
+ is Sender:, From:, unixfrom
+
+ - Otherwise, the search order is From:, Sender:, unixfrom
+
+ unixfrom should never be empty. The return address is always
+ lower cased.
+
+ This method differs from get_senders() in that it returns one and only
+ one address, and uses a different search order.
+ """
+ senderfirst = as_boolean(config.mailman.use_envelope_sender)
+ if senderfirst:
+ headers = ('sender', 'from')
+ else:
+ headers = ('from', 'sender')
+ for h in headers:
+ # Use only the first occurrance of Sender: or From:, although it's
+ # not likely there will be more than one.
+ fieldval = self[h]
+ if not fieldval:
+ continue
+ addrs = email.utils.getaddresses([fieldval])
+ try:
+ realname, address = addrs[0]
+ except IndexError:
+ continue
+ if address:
+ break
+ else:
+ # We didn't find a non-empty header, so let's fall back to the
+ # unixfrom address. This should never be empty, but if it ever
+ # is, it's probably a Really Bad Thing. Further, we just assume
+ # that if the unixfrom exists, the second field is the address.
+ unixfrom = self.get_unixfrom()
+ if unixfrom:
+ address = unixfrom.split()[1]
+ else:
+ # TBD: now what?!
+ address = ''
+ return address.lower()
+
+ def get_senders(self):
+ """Return a list of addresses representing the author of the email.
+
+ The list will contain the following addresses (in order)
+ depending on availability:
+
+ 1. From:
+ 2. unixfrom (From_)
+ 3. Reply-To:
+ 4. Sender:
+
+ The return addresses are always lower cased.
+ """
+ pairs = []
+ for header in config.mailman.sender_headers.split():
+ header = header.lower()
+ if header == 'from_':
+ # get_unixfrom() returns None if there's no envelope
+ unix_from = self.get_unixfrom()
+ fieldval = (unix_from if unix_from is not None else '')
+ try:
+ pairs.append(('', fieldval.split()[1]))
+ except IndexError:
+ # Ignore badly formatted unixfroms
+ pass
+ else:
+ fieldvals = self.get_all(header)
+ if fieldvals:
+ pairs.extend(email.utils.getaddresses(fieldvals))
+ authors = []
+ for pair in pairs:
+ address = pair[1]
+ if address is not None:
+ address = address.lower()
+ authors.append(address)
+ return authors
+
+ def get_filename(self, failobj=None):
+ """Some MUA have bugs in RFC2231 filename encoding and cause
+ Mailman to stop delivery in Scrubber.py (called from ToDigest.py).
+ """
+ try:
+ filename = email.message.Message.get_filename(self, failobj)
+ return filename
+ except (UnicodeError, LookupError, ValueError):
+ return failobj
+
+
+
+class UserNotification(Message):
+ """Class for internally crafted messages."""
+
+ def __init__(self, recip, sender, subject=None, text=None, lang=None):
+ Message.__init__(self)
+ charset = 'us-ascii'
+ if lang is not None:
+ charset = Utils.GetCharSet(lang)
+ if text is not None:
+ self.set_payload(text.encode(charset), charset)
+ if subject is None:
+ subject = '(no subject)'
+ self['Subject'] = Header(subject.encode(charset), charset,
+ header_name='Subject', errors='replace')
+ self['From'] = sender
+ if isinstance(recip, list):
+ self['To'] = COMMASPACE.join(recip)
+ self.recips = recip
+ else:
+ self['To'] = recip
+ self.recips = [recip]
+
+ def send(self, mlist, **_kws):
+ """Sends the message by enqueuing it to the 'virgin' queue.
+
+ This is used for all internally crafted messages.
+ """
+ # Since we're crafting the message from whole cloth, let's make sure
+ # this message has a Message-ID.
+ if 'message-id' not in self:
+ self['Message-ID'] = email.utils.make_msgid()
+ # Ditto for Date: as required by RFC 2822.
+ if 'date' not in self:
+ self['Date'] = email.utils.formatdate(localtime=True)
+ # UserNotifications are typically for admin messages, and for messages
+ # other than list explosions. Send these out as Precedence: bulk, but
+ # don't override an existing Precedence: header.
+ if 'precedence' not in self:
+ self['Precedence'] = 'bulk'
+ self._enqueue(mlist, **_kws)
+
+ def _enqueue(self, mlist, **_kws):
+ # Not imported at module scope to avoid import loop
+ virginq = config.switchboards['virgin']
+ # The message metadata better have a 'recip' attribute.
+ enqueue_kws = dict(
+ recips=self.recips,
+ nodecorate=True,
+ reduced_list_headers=True,
+ )
+ if mlist is not None:
+ enqueue_kws['listname'] = mlist.fqdn_listname
+ enqueue_kws.update(_kws)
+ virginq.enqueue(self, **enqueue_kws)
+
+
+
+class OwnerNotification(UserNotification):
+ """Like user notifications, but this message goes to the list owners."""
+
+ def __init__(self, mlist, subject=None, text=None, tomoderators=True):
+ if tomoderators:
+ roster = mlist.moderators
+ else:
+ roster = mlist.owners
+ recips = [address.address for address in roster.addresses]
+ sender = config.mailman.site_owner
+ lang = mlist.preferred_language
+ UserNotification.__init__(self, recips, sender, subject, text, lang)
+ # Hack the To header to look like it's going to the -owner address
+ del self['to']
+ self['To'] = mlist.owner_address
+ self._sender = sender
+
+ def _enqueue(self, mlist, **_kws):
+ # Not imported at module scope to avoid import loop
+ virginq = config.switchboards['virgin']
+ # The message metadata better have a `recip' attribute
+ virginq.enqueue(self,
+ listname=mlist.fqdn_listname,
+ recips=self.recips,
+ nodecorate=True,
+ reduced_list_headers=True,
+ envsender=self._sender,
+ **_kws)
diff --git a/src/mailman/Utils.py b/src/mailman/Utils.py
new file mode 100644
index 000000000..9946273c9
--- /dev/null
+++ b/src/mailman/Utils.py
@@ -0,0 +1,702 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Miscellaneous essential routines.
+
+This includes actual message transmission routines, address checking and
+message and address munging, a handy-dandy routine to map a function on all
+the mailing lists, and whatever else doesn't belong elsewhere.
+"""
+
+import os
+import re
+import cgi
+import time
+import errno
+import base64
+import random
+import logging
+import htmlentitydefs
+import email.Header
+import email.Iterators
+
+from email.Errors import HeaderParseError
+from lazr.config import as_boolean
+from string import ascii_letters, digits, whitespace
+
+import mailman.templates
+
+from mailman import passwords
+from mailman.config import config
+from mailman.core import errors
+from mailman.utilities.string import expand
+
+
+AT = '@'
+CR = '\r'
+DOT = '.'
+EMPTYSTRING = ''
+IDENTCHARS = ascii_letters + digits + '_'
+NL = '\n'
+UEMPTYSTRING = u''
+TEMPLATE_DIR = os.path.dirname(mailman.templates.__file__)
+
+# Search for $(identifier)s strings, except that the trailing s is optional,
+# since that's a common mistake
+cre = re.compile(r'%\(([_a-z]\w*?)\)s?', re.IGNORECASE)
+# Search for $$, $identifier, or ${identifier}
+dre = re.compile(r'(\${2})|\$([_a-z]\w*)|\${([_a-z]\w*)}', re.IGNORECASE)
+
+log = logging.getLogger('mailman.error')
+
+
+
+# a much more naive implementation than say, Emacs's fill-paragraph!
+def wrap(text, column=70, honor_leading_ws=True):
+ """Wrap and fill the text to the specified column.
+
+ Wrapping is always in effect, although if it is not possible to wrap a
+ line (because some word is longer than `column' characters) the line is
+ broken at the next available whitespace boundary. Paragraphs are also
+ always filled, unless honor_leading_ws is true and the line begins with
+ whitespace. This is the algorithm that the Python FAQ wizard uses, and
+ seems like a good compromise.
+
+ """
+ wrapped = ''
+ # first split the text into paragraphs, defined as a blank line
+ paras = re.split('\n\n', text)
+ for para in paras:
+ # fill
+ lines = []
+ fillprev = False
+ for line in para.split(NL):
+ if not line:
+ lines.append(line)
+ continue
+ if honor_leading_ws and line[0] in whitespace:
+ fillthis = False
+ else:
+ fillthis = True
+ if fillprev and fillthis:
+ # if the previous line should be filled, then just append a
+ # single space, and the rest of the current line
+ lines[-1] = lines[-1].rstrip() + ' ' + line
+ else:
+ # no fill, i.e. retain newline
+ lines.append(line)
+ fillprev = fillthis
+ # wrap each line
+ for text in lines:
+ while text:
+ if len(text) <= column:
+ line = text
+ text = ''
+ else:
+ bol = column
+ # find the last whitespace character
+ while bol > 0 and text[bol] not in whitespace:
+ bol -= 1
+ # now find the last non-whitespace character
+ eol = bol
+ while eol > 0 and text[eol] in whitespace:
+ eol -= 1
+ # watch out for text that's longer than the column width
+ if eol == 0:
+ # break on whitespace after column
+ eol = column
+ while eol < len(text) and text[eol] not in whitespace:
+ eol += 1
+ bol = eol
+ while bol < len(text) and text[bol] in whitespace:
+ bol += 1
+ bol -= 1
+ line = text[:eol+1] + '\n'
+ # find the next non-whitespace character
+ bol += 1
+ while bol < len(text) and text[bol] in whitespace:
+ bol += 1
+ text = text[bol:]
+ wrapped += line
+ wrapped += '\n'
+ # end while text
+ wrapped += '\n'
+ # end for text in lines
+ # the last two newlines are bogus
+ return wrapped[:-2]
+
+
+
+def QuotePeriods(text):
+ JOINER = '\n .\n'
+ SEP = '\n.\n'
+ return JOINER.join(text.split(SEP))
+
+
+# This takes an email address, and returns a tuple containing (user,host)
+def ParseEmail(email):
+ user = None
+ domain = None
+ email = email.lower()
+ at_sign = email.find('@')
+ if at_sign < 1:
+ return email, None
+ user = email[:at_sign]
+ rest = email[at_sign+1:]
+ domain = rest.split('.')
+ return user, domain
+
+
+def LCDomain(addr):
+ "returns the address with the domain part lowercased"
+ atind = addr.find('@')
+ if atind == -1: # no domain part
+ return addr
+ return addr[:atind] + '@' + addr[atind+1:].lower()
+
+
+# TBD: what other characters should be disallowed?
+_badchars = re.compile(r'[][()<>|;^,\000-\037\177-\377]')
+
+def ValidateEmail(s):
+ """Verify that the an email address isn't grossly evil."""
+ # Pretty minimal, cheesy check. We could do better...
+ if not s or ' ' in s:
+ raise errors.InvalidEmailAddress(repr(s))
+ if _badchars.search(s) or s[0] == '-':
+ raise errors.InvalidEmailAddress(repr(s))
+ user, domain_parts = ParseEmail(s)
+ # Local, unqualified addresses are not allowed.
+ if not domain_parts:
+ raise errors.InvalidEmailAddress(repr(s))
+ if len(domain_parts) < 2:
+ raise errors.InvalidEmailAddress(repr(s))
+
+
+
+# Patterns which may be used to form malicious path to inject a new
+# line in the mailman error log. (TK: advisory by Moritz Naumann)
+CRNLpat = re.compile(r'[^\x21-\x7e]')
+
+def GetPathPieces(envar='PATH_INFO'):
+ path = os.environ.get(envar)
+ if path:
+ if CRNLpat.search(path):
+ path = CRNLpat.split(path)[0]
+ log.error('Warning: Possible malformed path attack.')
+ return [p for p in path.split('/') if p]
+ return []
+
+
+
+def ScriptURL(target):
+ up = '../' * len(GetPathPieces())
+ return '%s%s' % (up, target + config.CGIEXT)
+
+
+
+def GetPossibleMatchingAddrs(name):
+ """returns a sorted list of addresses that could possibly match
+ a given name.
+
+ For Example, given scott@pobox.com, return ['scott@pobox.com'],
+ given scott@blackbox.pobox.com return ['scott@blackbox.pobox.com',
+ 'scott@pobox.com']"""
+
+ name = name.lower()
+ user, domain = ParseEmail(name)
+ res = [name]
+ if domain:
+ domain = domain[1:]
+ while len(domain) >= 2:
+ res.append("%s@%s" % (user, DOT.join(domain)))
+ domain = domain[1:]
+ return res
+
+
+
+def List2Dict(L, foldcase=False):
+ """Return a dict keyed by the entries in the list passed to it."""
+ d = {}
+ if foldcase:
+ for i in L:
+ d[i.lower()] = True
+ else:
+ for i in L:
+ d[i] = True
+ return d
+
+
+
+_vowels = ('a', 'e', 'i', 'o', 'u')
+_consonants = ('b', 'c', 'd', 'f', 'g', 'h', 'k', 'm', 'n',
+ 'p', 'r', 's', 't', 'v', 'w', 'x', 'z')
+_syllables = []
+
+for v in _vowels:
+ for c in _consonants:
+ _syllables.append(c+v)
+ _syllables.append(v+c)
+del c, v
+
+def UserFriendly_MakeRandomPassword(length):
+ syls = []
+ while len(syls) * 2 < length:
+ syls.append(random.choice(_syllables))
+ return EMPTYSTRING.join(syls)[:length]
+
+
+def Secure_MakeRandomPassword(length):
+ bytesread = 0
+ bytes = []
+ fd = None
+ try:
+ while bytesread < length:
+ try:
+ # Python 2.4 has this on available systems.
+ newbytes = os.urandom(length - bytesread)
+ except (AttributeError, NotImplementedError):
+ if fd is None:
+ try:
+ fd = os.open('/dev/urandom', os.O_RDONLY)
+ except OSError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ # We have no available source of cryptographically
+ # secure random characters. Log an error and fallback
+ # to the user friendly passwords.
+ log.error(
+ 'urandom not available, passwords not secure')
+ return UserFriendly_MakeRandomPassword(length)
+ newbytes = os.read(fd, length - bytesread)
+ bytes.append(newbytes)
+ bytesread += len(newbytes)
+ s = base64.encodestring(EMPTYSTRING.join(bytes))
+ # base64 will expand the string by 4/3rds
+ return s.replace('\n', '')[:length]
+ finally:
+ if fd is not None:
+ os.close(fd)
+
+
+def MakeRandomPassword(length=None):
+ if length is None:
+ length = int(config.passwords.member_password_length)
+ if as_boolean(config.passwords.user_friendly_passwords):
+ password = UserFriendly_MakeRandomPassword(length)
+ else:
+ password = Secure_MakeRandomPassword(length)
+ return password.decode('ascii')
+
+
+def GetRandomSeed():
+ chr1 = int(random.random() * 52)
+ chr2 = int(random.random() * 52)
+ def mkletter(c):
+ if 0 <= c < 26:
+ c += 65
+ if 26 <= c < 52:
+ #c = c - 26 + 97
+ c += 71
+ return c
+ return "%c%c" % tuple(map(mkletter, (chr1, chr2)))
+
+
+
+def set_global_password(pw, siteadmin=True, scheme=None):
+ if scheme is None:
+ scheme = passwords.Schemes.ssha
+ if siteadmin:
+ filename = config.SITE_PW_FILE
+ else:
+ filename = config.LISTCREATOR_PW_FILE
+ try:
+ fp = open(filename, 'w')
+ print >> fp, passwords.make_secret(pw, scheme)
+ finally:
+ fp.close()
+
+
+def get_global_password(siteadmin=True):
+ if siteadmin:
+ filename = config.SITE_PW_FILE
+ else:
+ filename = config.LISTCREATOR_PW_FILE
+ try:
+ fp = open(filename)
+ challenge = fp.read()[:-1] # strip off trailing nl
+ fp.close()
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ # It's okay not to have a site admin password
+ return None
+ return challenge
+
+
+def check_global_password(response, siteadmin=True):
+ challenge = get_global_password(siteadmin)
+ if challenge is None:
+ return False
+ return passwords.check_response(challenge, response)
+
+
+
+def websafe(s):
+ return cgi.escape(s, quote=True)
+
+
+def nntpsplit(s):
+ parts = s.split(':', 1)
+ if len(parts) == 2:
+ try:
+ return parts[0], int(parts[1])
+ except ValueError:
+ pass
+ # Use the defaults
+ return s, 119
+
+
+
+# Just changing these two functions should be enough to control the way
+# that email address obscuring is handled.
+def ObscureEmail(addr, for_text=False):
+ """Make email address unrecognizable to web spiders, but invertable.
+
+ When for_text option is set (not default), make a sentence fragment
+ instead of a token."""
+ if for_text:
+ return addr.replace('@', ' at ')
+ else:
+ return addr.replace('@', '--at--')
+
+def UnobscureEmail(addr):
+ """Invert ObscureEmail() conversion."""
+ # Contrived to act as an identity operation on already-unobscured
+ # emails, so routines expecting obscured ones will accept both.
+ return addr.replace('--at--', '@')
+
+
+
+class OuterExit(Exception):
+ pass
+
+def findtext(templatefile, raw_dict=None, raw=False, lang=None, mlist=None):
+ # Make some text from a template file. The order of searches depends on
+ # whether mlist and lang are provided. Once the templatefile is found,
+ # string substitution is performed by interpolation in `dict'. If `raw'
+ # is false, the resulting text is wrapped/filled by calling wrap().
+ #
+ # When looking for a template in a specific language, there are 4 places
+ # that are searched, in this order:
+ #
+ # 1. the list-specific language directory
+ # lists//
+ #
+ # 2. the domain-specific language directory
+ # templates//
+ #
+ # 3. the site-wide language directory
+ # templates/site/
+ #
+ # 4. the global default language directory
+ # templates/
+ #
+ # The first match found stops the search. In this way, you can specialize
+ # templates at the desired level, or, if you use only the default
+ # templates, you don't need to change anything. You should never modify
+ # files in the templates/ subdirectory, since Mailman will
+ # overwrite these when you upgrade. That's what the templates/site
+ # language directories are for.
+ #
+ # A further complication is that the language to search for is determined
+ # by both the `lang' and `mlist' arguments. The search order there is
+ # that if lang is given, then the 4 locations above are searched,
+ # substituting lang for . If no match is found, and mlist is
+ # given, then the 4 locations are searched using the list's preferred
+ # language. After that, the server default language is used for
+ # . If that still doesn't yield a template, then the standard
+ # distribution's English language template is used as an ultimate
+ # fallback, and when lang is not 'en', the resulting template is passed
+ # through the translation service. If this template is missing you've got
+ # big problems. ;)
+ #
+ # A word on backwards compatibility: Mailman versions prior to 2.1 stored
+ # templates in templates/*.{html,txt} and lists//*.{html,txt}.
+ # Those directories are no longer searched so if you've got customizations
+ # in those files, you should move them to the appropriate directory based
+ # on the above description. Mailman's upgrade script cannot do this for
+ # you.
+ #
+ # The function has been revised and renamed as it now returns both the
+ # template text and the path from which it retrieved the template. The
+ # original function is now a wrapper which just returns the template text
+ # as before, by calling this renamed function and discarding the second
+ # item returned.
+ #
+ # Calculate the languages to scan
+ languages = set()
+ if lang is not None:
+ languages.add(lang)
+ if mlist is not None:
+ languages.add(mlist.preferred_language)
+ languages.add(config.mailman.default_language)
+ assert None not in languages, 'None in languages'
+ # Calculate the locations to scan
+ searchdirs = []
+ if mlist is not None:
+ searchdirs.append(mlist.data_path)
+ searchdirs.append(os.path.join(TEMPLATE_DIR, mlist.host_name))
+ searchdirs.append(os.path.join(TEMPLATE_DIR, 'site'))
+ searchdirs.append(TEMPLATE_DIR)
+ # Start scanning
+ fp = None
+ try:
+ for lang in languages:
+ for dir in searchdirs:
+ filename = os.path.join(dir, lang, templatefile)
+ try:
+ fp = open(filename)
+ raise OuterExit
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ # Okay, it doesn't exist, keep looping
+ fp = None
+ except OuterExit:
+ pass
+ if fp is None:
+ # Try one last time with the distro English template, which, unless
+ # you've got a really broken installation, must be there.
+ try:
+ filename = os.path.join(TEMPLATE_DIR, 'en', templatefile)
+ fp = open(filename)
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ # We never found the template. BAD!
+ raise IOError(errno.ENOENT, 'No template file found', templatefile)
+ else:
+ from mailman.i18n import get_translation
+ # XXX BROKEN HACK
+ data = fp.read()[:-1]
+ template = get_translation().ugettext(data)
+ fp.close()
+ else:
+ template = fp.read()
+ fp.close()
+ template = unicode(template, GetCharSet(lang), 'replace')
+ text = template
+ if raw_dict is not None:
+ text = expand(template, raw_dict)
+ if raw:
+ return text, filename
+ return wrap(text), filename
+
+
+def maketext(templatefile, dict=None, raw=False, lang=None, mlist=None):
+ return findtext(templatefile, dict, raw, lang, mlist)[0]
+
+
+
+def GetRequestURI(fallback=None, escape=True):
+ """Return the full virtual path this CGI script was invoked with.
+
+ Newer web servers seems to supply this info in the REQUEST_URI
+ environment variable -- which isn't part of the CGI/1.1 spec.
+ Thus, if REQUEST_URI isn't available, we concatenate SCRIPT_NAME
+ and PATH_INFO, both of which are part of CGI/1.1.
+
+ Optional argument `fallback' (default `None') is returned if both of
+ the above methods fail.
+
+ The url will be cgi escaped to prevent cross-site scripting attacks,
+ unless `escape' is set to 0.
+ """
+ url = fallback
+ if 'REQUEST_URI' in os.environ:
+ url = os.environ['REQUEST_URI']
+ elif 'SCRIPT_NAME' in os.environ and 'PATH_INFO' in os.environ:
+ url = os.environ['SCRIPT_NAME'] + os.environ['PATH_INFO']
+ if escape:
+ return websafe(url)
+ return url
+
+
+
+# XXX Replace this with direct calls. For now, existing uses of GetCharSet()
+# are too numerous to change.
+def GetCharSet(lang):
+ return config.languages.get_charset(lang)
+
+
+
+def get_request_domain():
+ host = os.environ.get('HTTP_HOST', os.environ.get('SERVER_NAME'))
+ port = os.environ.get('SERVER_PORT')
+ # Strip off the port if there is one
+ if port and host.endswith(':' + port):
+ host = host[:-len(port)-1]
+ return host.lower()
+
+
+def get_site_noreply():
+ return '%s@%s' % (config.NO_REPLY_ADDRESS, config.DEFAULT_EMAIL_HOST)
+
+
+
+# Figure out epoch seconds of midnight at the start of today (or the given
+# 3-tuple date of (year, month, day).
+def midnight(date=None):
+ if date is None:
+ date = time.localtime()[:3]
+ # -1 for dst flag tells the library to figure it out
+ return time.mktime(date + (0,)*5 + (-1,))
+
+
+
+# The opposite of canonstr() -- sorta. I.e. it attempts to encode s in the
+# charset of the given language, which is the character set that the page will
+# be rendered in, and failing that, replaces non-ASCII characters with their
+# html references. It always returns a byte string.
+def uncanonstr(s, lang=None):
+ if s is None:
+ s = u''
+ if lang is None:
+ charset = 'us-ascii'
+ else:
+ charset = GetCharSet(lang)
+ # See if the string contains characters only in the desired character
+ # set. If so, return it unchanged, except for coercing it to a byte
+ # string.
+ try:
+ if isinstance(s, unicode):
+ return s.encode(charset)
+ else:
+ u = unicode(s, charset)
+ return s
+ except UnicodeError:
+ # Nope, it contains funny characters, so html-ref it
+ return uquote(s)
+
+
+def uquote(s):
+ a = []
+ for c in s:
+ o = ord(c)
+ if o > 127:
+ a.append('%3d;' % o)
+ else:
+ a.append(c)
+ # Join characters together and coerce to byte string
+ return str(EMPTYSTRING.join(a))
+
+
+def oneline(s, cset='us-ascii', in_unicode=False):
+ # Decode header string in one line and convert into specified charset
+ try:
+ h = email.Header.make_header(email.Header.decode_header(s))
+ ustr = h.__unicode__()
+ line = UEMPTYSTRING.join(ustr.splitlines())
+ if in_unicode:
+ return line
+ else:
+ return line.encode(cset, 'replace')
+ except (LookupError, UnicodeError, ValueError, HeaderParseError):
+ # possibly charset problem. return with undecoded string in one line.
+ return EMPTYSTRING.join(s.splitlines())
+
+
+def strip_verbose_pattern(pattern):
+ # Remove white space and comments from a verbose pattern and return a
+ # non-verbose, equivalent pattern. Replace CR and NL in the result
+ # with '\\r' and '\\n' respectively to avoid multi-line results.
+ if not isinstance(pattern, str):
+ return pattern
+ newpattern = ''
+ i = 0
+ inclass = False
+ skiptoeol = False
+ copynext = False
+ while i < len(pattern):
+ c = pattern[i]
+ if copynext:
+ if c == NL:
+ newpattern += '\\n'
+ elif c == CR:
+ newpattern += '\\r'
+ else:
+ newpattern += c
+ copynext = False
+ elif skiptoeol:
+ if c == NL:
+ skiptoeol = False
+ elif c == '#' and not inclass:
+ skiptoeol = True
+ elif c == '[' and not inclass:
+ inclass = True
+ newpattern += c
+ copynext = True
+ elif c == ']' and inclass:
+ inclass = False
+ newpattern += c
+ elif re.search('\s', c):
+ if inclass:
+ if c == NL:
+ newpattern += '\\n'
+ elif c == CR:
+ newpattern += '\\r'
+ else:
+ newpattern += c
+ elif c == '\\' and not inclass:
+ newpattern += c
+ copynext = True
+ else:
+ if c == NL:
+ newpattern += '\\n'
+ elif c == CR:
+ newpattern += '\\r'
+ else:
+ newpattern += c
+ i += 1
+ return newpattern
+
+
+
+def get_pattern(email, pattern_list):
+ """Returns matched entry in pattern_list if email matches.
+ Otherwise returns None.
+ """
+ if not pattern_list:
+ return None
+ matched = None
+ for pattern in pattern_list:
+ if pattern.startswith('^'):
+ # This is a regular expression match
+ try:
+ if re.search(pattern, email, re.IGNORECASE):
+ matched = pattern
+ break
+ except re.error:
+ # BAW: we should probably remove this pattern
+ pass
+ else:
+ # Do the comparison case insensitively
+ if pattern.lower() == email.lower():
+ matched = pattern
+ break
+ return matched
diff --git a/src/mailman/__init__.py b/src/mailman/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mailman/app/__init__.py b/src/mailman/app/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mailman/app/bounces.py b/src/mailman/app/bounces.py
new file mode 100644
index 000000000..875f615a5
--- /dev/null
+++ b/src/mailman/app/bounces.py
@@ -0,0 +1,63 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Application level bounce handling."""
+
+from __future__ import unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'bounce_message',
+ ]
+
+import logging
+
+from email.mime.message import MIMEMessage
+from email.mime.text import MIMEText
+
+from mailman import Message
+from mailman import Utils
+from mailman.i18n import _
+
+log = logging.getLogger('mailman.config')
+
+
+
+def bounce_message(mlist, msg, e=None):
+ # Bounce a message back to the sender, with an error message if provided
+ # in the exception argument.
+ sender = msg.get_sender()
+ subject = msg.get('subject', _('(no subject)'))
+ subject = Utils.oneline(subject,
+ Utils.GetCharSet(mlist.preferred_language))
+ if e is None:
+ notice = _('[No bounce details are available]')
+ else:
+ notice = _(e.notice)
+ # Currently we always craft bounces as MIME messages.
+ bmsg = Message.UserNotification(msg.get_sender(),
+ mlist.owner_address,
+ subject,
+ lang=mlist.preferred_language)
+ # BAW: Be sure you set the type before trying to attach, or you'll get
+ # a MultipartConversionError.
+ bmsg.set_type('multipart/mixed')
+ txt = MIMEText(notice,
+ _charset=Utils.GetCharSet(mlist.preferred_language))
+ bmsg.attach(txt)
+ bmsg.attach(MIMEMessage(msg))
+ bmsg.send(mlist)
diff --git a/src/mailman/app/commands.py b/src/mailman/app/commands.py
new file mode 100644
index 000000000..d7676af9c
--- /dev/null
+++ b/src/mailman/app/commands.py
@@ -0,0 +1,44 @@
+# Copyright (C) 2008-2009 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 .
+
+"""Initialize the email commands."""
+
+from __future__ import unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'initialize',
+ ]
+
+
+from mailman.config import config
+from mailman.core.plugins import get_plugins
+from mailman.interfaces.command import IEmailCommand
+
+
+
+def initialize():
+ """Initialize the email commands."""
+ for module in get_plugins('mailman.commands'):
+ for name in module.__all__:
+ command_class = getattr(module, name)
+ if not IEmailCommand.implementedBy(command_class):
+ continue
+ assert command_class.name not in config.commands, (
+ 'Duplicate email command "{0}" found in {1}'.format(
+ command_class.name, module))
+ config.commands[command_class.name] = command_class()
diff --git a/src/mailman/app/lifecycle.py b/src/mailman/app/lifecycle.py
new file mode 100644
index 000000000..eec00dc86
--- /dev/null
+++ b/src/mailman/app/lifecycle.py
@@ -0,0 +1,114 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Application level list creation."""
+
+from __future__ import unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'create_list',
+ 'remove_list',
+ ]
+
+
+import os
+import sys
+import shutil
+import logging
+
+from mailman import Utils
+from mailman.Utils import ValidateEmail
+from mailman.config import config
+from mailman.core import errors
+from mailman.interfaces.member import MemberRole
+
+
+log = logging.getLogger('mailman.error')
+
+
+
+def create_list(fqdn_listname, owners=None):
+ """Create the named list and apply styles."""
+ if owners is None:
+ owners = []
+ ValidateEmail(fqdn_listname)
+ listname, domain = fqdn_listname.split('@', 1)
+ if domain not in config.domains:
+ raise errors.BadDomainSpecificationError(domain)
+ mlist = config.db.list_manager.create(fqdn_listname)
+ for style in config.style_manager.lookup(mlist):
+ style.apply(mlist)
+ # Coordinate with the MTA, as defined in the configuration file.
+ module_name, class_name = config.mta.incoming.rsplit('.', 1)
+ __import__(module_name)
+ getattr(sys.modules[module_name], class_name)().create(mlist)
+ # Create any owners that don't yet exist, and subscribe all addresses as
+ # owners of the mailing list.
+ usermgr = config.db.user_manager
+ for owner_address in owners:
+ addr = usermgr.get_address(owner_address)
+ if addr is None:
+ # XXX Make this use an IRegistrar instead, but that requires
+ # sussing out the IDomain stuff. For now, fake it.
+ user = usermgr.create_user(owner_address)
+ addr = list(user.addresses)[0]
+ addr.subscribe(mlist, MemberRole.owner)
+ return mlist
+
+
+
+def remove_list(fqdn_listname, mailing_list=None, archives=True):
+ """Remove the list and all associated artifacts and subscriptions."""
+ removeables = []
+ # mailing_list will be None when only residual archives are being removed.
+ if mailing_list:
+ # Remove all subscriptions, regardless of role.
+ for member in mailing_list.subscribers.members:
+ member.unsubscribe()
+ # Delete the mailing list from the database.
+ config.db.list_manager.delete(mailing_list)
+ # Do the MTA-specific list deletion tasks
+ module_name, class_name = config.mta.incoming.rsplit('.', 1)
+ __import__(module_name)
+ getattr(sys.modules[module_name], class_name)().create(mailing_list)
+ # Remove the list directory.
+ removeables.append(os.path.join(config.LIST_DATA_DIR, fqdn_listname))
+ # Remove any stale locks associated with the list.
+ for filename in os.listdir(config.LOCK_DIR):
+ fn_listname = filename.split('.')[0]
+ if fn_listname == fqdn_listname:
+ removeables.append(os.path.join(config.LOCK_DIR, filename))
+ if archives:
+ private_dir = config.PRIVATE_ARCHIVE_FILE_DIR
+ public_dir = config.PUBLIC_ARCHIVE_FILE_DIR
+ removeables.extend([
+ os.path.join(private_dir, fqdn_listname),
+ os.path.join(private_dir, fqdn_listname + '.mbox'),
+ os.path.join(public_dir, fqdn_listname),
+ os.path.join(public_dir, fqdn_listname + '.mbox'),
+ ])
+ # Now that we know what files and directories to delete, delete them.
+ for target in removeables:
+ if os.path.islink(target):
+ os.unlink(target)
+ elif os.path.isdir(target):
+ shutil.rmtree(target)
+ elif os.path.isfile(target):
+ os.unlink(target)
+ else:
+ log.error('Could not delete list artifact: %s', target)
diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py
new file mode 100644
index 000000000..4b9609469
--- /dev/null
+++ b/src/mailman/app/membership.py
@@ -0,0 +1,137 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Application support for membership management."""
+
+from __future__ import unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'add_member',
+ 'delete_member',
+ ]
+
+
+from email.utils import formataddr
+
+from mailman import Message
+from mailman import Utils
+from mailman import i18n
+from mailman.app.notifications import send_goodbye_message
+from mailman.config import config
+from mailman.core import errors
+from mailman.interfaces.member import AlreadySubscribedError, MemberRole
+
+_ = i18n._
+
+
+
+def add_member(mlist, address, realname, password, delivery_mode, language):
+ """Add a member right now.
+
+ The member's subscription must be approved by whatever policy the list
+ enforces.
+
+ :param mlist: the mailing list to add the member to
+ :type mlist: IMailingList
+ :param address: the address to subscribe
+ :type address: string
+ :param realname: the subscriber's full name
+ :type realname: string
+ :param password: the subscriber's password
+ :type password: string
+ :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
+ """
+ # Let's be extra cautious.
+ Utils.ValidateEmail(address)
+ if mlist.members.get_member(address) 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)
+ if pattern:
+ raise errors.MembershipIsBanned(pattern)
+ # Do the actual addition. First, see if there's already a user linked
+ # with the given address.
+ user = config.db.user_manager.get_user(address)
+ 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 = config.db.user_manager.get_address(address)
+ if address_obj is None:
+ # Nope, we don't even know about this address, so create both the
+ # user and address now.
+ user = config.db.user_manager.create_user(address, realname)
+ # Do it this way so we don't have to flush the previous change.
+ address_obj = 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 = config.db.user_manager.create_user()
+ user.real_name = (realname if realname else address_obj.real_name)
+ user.link(address_obj)
+ # 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.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:
+ break
+ else:
+ raise AssertionError(
+ 'User should have had linked address: {0}'.format(address))
+ # Create the member and set the appropriate preferences.
+ member = address_obj.subscribe(mlist, MemberRole.member)
+ member.preferences.preferred_language = language
+ member.preferences.delivery_mode = delivery_mode
+## mlist.setMemberOption(email, config.Moderate,
+## mlist.default_member_moderation)
+
+
+
+def delete_member(mlist, address, admin_notif=None, userack=None):
+ if userack is None:
+ userack = mlist.send_goodbye_msg
+ if admin_notif is None:
+ admin_notif = mlist.admin_notify_mchanges
+ # Delete a member, for which we know the approval has been made
+ member = mlist.members.get_member(address)
+ language = member.preferred_language
+ member.unsubscribe()
+ # And send an acknowledgement to the user...
+ if userack:
+ send_goodbye_message(mlist, address, language)
+ # ...and to the administrator.
+ if admin_notif:
+ user = config.db.user_manager.get_user(address)
+ realname = user.real_name
+ subject = _('$mlist.real_name unsubscription notification')
+ text = Utils.maketext(
+ 'adminunsubscribeack.txt',
+ {'listname': mlist.real_name,
+ 'member' : formataddr((realname, address)),
+ }, mlist=mlist)
+ msg = Message.OwnerNotification(mlist, subject, text)
+ msg.send(mlist)
diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py
new file mode 100644
index 000000000..b40a34344
--- /dev/null
+++ b/src/mailman/app/moderator.py
@@ -0,0 +1,351 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Application support for moderators."""
+
+from __future__ import unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'handle_message',
+ 'handle_subscription',
+ 'handle_unsubscription',
+ 'hold_message',
+ 'hold_subscription',
+ 'hold_unsubscription',
+ ]
+
+import logging
+
+from datetime import datetime
+from email.utils import formataddr, formatdate, getaddresses, make_msgid
+
+from mailman import Message
+from mailman import Utils
+from mailman import i18n
+from mailman.app.membership import add_member, delete_member
+from mailman.app.notifications import (
+ send_admin_subscription_notice, send_welcome_message)
+from mailman.config import config
+from mailman.core import errors
+from mailman.interfaces import Action
+from mailman.interfaces.member import AlreadySubscribedError, DeliveryMode
+from mailman.interfaces.requests import RequestType
+
+_ = i18n._
+
+vlog = logging.getLogger('mailman.vette')
+slog = logging.getLogger('mailman.subscribe')
+
+
+
+def hold_message(mlist, msg, msgdata=None, reason=None):
+ """Hold a message for moderator approval.
+
+ The message is added to the mailing list's request database.
+
+ :param mlist: The mailing list to hold the message on.
+ :param msg: The message to hold.
+ :param msgdata: Optional message metadata to hold. If not given, a new
+ metadata dictionary is created and held with the message.
+ :param reason: Optional string reason why the message is being held. If
+ not given, the empty string is used.
+ :return: An id used to handle the held message later.
+ """
+ if msgdata is None:
+ msgdata = {}
+ else:
+ # Make a copy of msgdata so that subsequent changes won't corrupt the
+ # request database. TBD: remove the `filebase' key since this will
+ # not be relevant when the message is resurrected.
+ msgdata = msgdata.copy()
+ if reason is None:
+ reason = ''
+ # Add the message to the message store. It is required to have a
+ # Message-ID header.
+ message_id = msg.get('message-id')
+ if message_id is None:
+ msg['Message-ID'] = message_id = unicode(make_msgid())
+ assert isinstance(message_id, unicode), (
+ 'Message-ID is not a unicode: %s' % message_id)
+ config.db.message_store.add(msg)
+ # Prepare the message metadata with some extra information needed only by
+ # the moderation interface.
+ msgdata['_mod_message_id'] = message_id
+ msgdata['_mod_fqdn_listname'] = mlist.fqdn_listname
+ msgdata['_mod_sender'] = msg.get_sender()
+ msgdata['_mod_subject'] = msg.get('subject', _('(no subject)'))
+ msgdata['_mod_reason'] = reason
+ msgdata['_mod_hold_date'] = datetime.now().isoformat()
+ # Now hold this request. We'll use the message_id as the key.
+ requestsdb = config.db.requests.get_list_requests(mlist)
+ request_id = requestsdb.hold_request(
+ RequestType.held_message, message_id, msgdata)
+ return request_id
+
+
+
+def handle_message(mlist, id, action,
+ comment=None, preserve=False, forward=None):
+ requestdb = config.db.requests.get_list_requests(mlist)
+ key, msgdata = requestdb.get_request(id)
+ # Handle the action.
+ rejection = None
+ message_id = msgdata['_mod_message_id']
+ sender = msgdata['_mod_sender']
+ subject = msgdata['_mod_subject']
+ if action is Action.defer:
+ # Nothing to do, but preserve the message for later.
+ preserve = True
+ elif action is Action.discard:
+ rejection = 'Discarded'
+ elif action is Action.reject:
+ rejection = 'Refused'
+ member = mlist.members.get_member(sender)
+ if member:
+ language = member.preferred_language
+ else:
+ language = None
+ _refuse(mlist, _('Posting of your message titled "$subject"'),
+ sender, comment or _('[No reason given]'), language)
+ elif action is Action.accept:
+ # Start by getting the message from the message store.
+ msg = config.db.message_store.get_message_by_id(message_id)
+ # Delete moderation-specific entries from the message metadata.
+ for key in msgdata.keys():
+ if key.startswith('_mod_'):
+ del msgdata[key]
+ # Add some metadata to indicate this message has now been approved.
+ msgdata['approved'] = True
+ msgdata['moderator_approved'] = True
+ # Calculate a new filebase for the approved message, otherwise
+ # delivery errors will cause duplicates.
+ if 'filebase' in msgdata:
+ del msgdata['filebase']
+ # Queue the file for delivery by qrunner. Trying to deliver the
+ # message directly here can lead to a huge delay in web turnaround.
+ # Log the moderation and add a header.
+ msg['X-Mailman-Approved-At'] = formatdate(localtime=True)
+ vlog.info('held message approved, message-id: %s',
+ msg.get('message-id', 'n/a'))
+ # Stick the message back in the incoming queue for further
+ # processing.
+ config.switchboards['in'].enqueue(msg, _metadata=msgdata)
+ else:
+ raise AssertionError('Unexpected action: {0}'.format(action))
+ # Forward the message.
+ if forward:
+ # Get a copy of the original message from the message store.
+ msg = config.db.message_store.get_message_by_id(message_id)
+ # It's possible the forwarding address list is a comma separated list
+ # of realname/address pairs.
+ addresses = [addr[1] for addr in getaddresses(forward)]
+ language = mlist.preferred_language
+ if len(addresses) == 1:
+ # If the address getting the forwarded message is a member of
+ # the list, we want the headers of the outer message to be
+ # encoded in their language. Otherwise it'll be the preferred
+ # language of the mailing list. This is better than sending a
+ # separate message per recipient.
+ member = mlist.members.get_member(addresses[0])
+ if member:
+ language = member.preferred_language
+ with i18n.using_language(language):
+ fmsg = Message.UserNotification(
+ addresses, mlist.bounces_address,
+ _('Forward of moderated message'),
+ lang=language)
+ fmsg.set_type('message/rfc822')
+ fmsg.attach(msg)
+ fmsg.send(mlist)
+ # Delete the message from the message store if it is not being preserved.
+ if not preserve:
+ config.db.message_store.delete_message(message_id)
+ requestdb.delete_request(id)
+ # Log the rejection
+ if rejection:
+ note = """%s: %s posting:
+\tFrom: %s
+\tSubject: %s"""
+ if comment:
+ note += '\n\tReason: ' + comment
+ vlog.info(note, mlist.fqdn_listname, rejection, sender, subject)
+
+
+
+def hold_subscription(mlist, address, realname, password, mode, language):
+ data = dict(when=datetime.now().isoformat(),
+ address=address,
+ realname=realname,
+ password=password,
+ delivery_mode=str(mode),
+ language=language)
+ # Now hold this request. We'll use the address as the key.
+ requestsdb = config.db.requests.get_list_requests(mlist)
+ request_id = requestsdb.hold_request(
+ RequestType.subscription, address, data)
+ vlog.info('%s: held subscription request from %s',
+ mlist.fqdn_listname, address)
+ # Possibly notify the administrator in default list language
+ if mlist.admin_immed_notify:
+ subject = _(
+ 'New subscription request to list $mlist.real_name from $address')
+ text = Utils.maketext(
+ 'subauth.txt',
+ {'username' : address,
+ 'listname' : mlist.fqdn_listname,
+ 'admindb_url': mlist.script_url('admindb'),
+ }, mlist=mlist)
+ # This message should appear to come from the -owner so as
+ # to avoid any useless bounce processing.
+ msg = Message.UserNotification(
+ mlist.owner_address, mlist.owner_address,
+ subject, text, mlist.preferred_language)
+ msg.send(mlist, tomoderators=True)
+ return request_id
+
+
+
+def handle_subscription(mlist, id, action, comment=None):
+ requestdb = config.db.requests.get_list_requests(mlist)
+ if action is Action.defer:
+ # Nothing to do.
+ return
+ elif action is Action.discard:
+ # Nothing to do except delete the request from the database.
+ pass
+ elif action is Action.reject:
+ key, data = requestdb.get_request(id)
+ _refuse(mlist, _('Subscription request'),
+ data['address'],
+ comment or _('[No reason given]'),
+ lang=data['language'])
+ elif action is Action.accept:
+ key, data = requestdb.get_request(id)
+ enum_value = data['delivery_mode'].split('.')[-1]
+ delivery_mode = DeliveryMode(enum_value)
+ address = data['address']
+ realname = data['realname']
+ language = data['language']
+ password = data['password']
+ try:
+ add_member(mlist, address, realname, password,
+ delivery_mode, language)
+ except AlreadySubscribedError:
+ # The address got subscribed in some other way after the original
+ # request was made and accepted.
+ pass
+ else:
+ if mlist.send_welcome_msg:
+ send_welcome_message(mlist, address, language, delivery_mode)
+ if mlist.admin_notify_mchanges:
+ send_admin_subscription_notice(
+ mlist, address, realname, language)
+ slog.info('%s: new %s, %s %s', mlist.fqdn_listname,
+ delivery_mode, formataddr((realname, address)),
+ 'via admin approval')
+ else:
+ raise AssertionError('Unexpected action: {0}'.format(action))
+ # Delete the request from the database.
+ requestdb.delete_request(id)
+
+
+
+def hold_unsubscription(mlist, address):
+ data = dict(address=address)
+ requestsdb = config.db.requests.get_list_requests(mlist)
+ request_id = requestsdb.hold_request(
+ RequestType.unsubscription, address, data)
+ vlog.info('%s: held unsubscription request from %s',
+ mlist.fqdn_listname, address)
+ # Possibly notify the administrator of the hold
+ if mlist.admin_immed_notify:
+ subject = _(
+ 'New unsubscription request from $mlist.real_name by $address')
+ text = Utils.maketext(
+ 'unsubauth.txt',
+ {'address' : address,
+ 'listname' : mlist.fqdn_listname,
+ 'admindb_url': mlist.script_url('admindb'),
+ }, mlist=mlist)
+ # This message should appear to come from the -owner so as
+ # to avoid any useless bounce processing.
+ msg = Message.UserNotification(
+ mlist.owner_address, mlist.owner_address,
+ subject, text, mlist.preferred_language)
+ msg.send(mlist, tomoderators=True)
+ return request_id
+
+
+
+def handle_unsubscription(mlist, id, action, comment=None):
+ requestdb = config.db.requests.get_list_requests(mlist)
+ key, data = requestdb.get_request(id)
+ address = data['address']
+ if action is Action.defer:
+ # Nothing to do.
+ return
+ elif action is Action.discard:
+ # Nothing to do except delete the request from the database.
+ pass
+ elif action is Action.reject:
+ key, data = requestdb.get_request(id)
+ _refuse(mlist, _('Unsubscription request'), address,
+ comment or _('[No reason given]'))
+ elif action is Action.accept:
+ key, data = requestdb.get_request(id)
+ try:
+ delete_member(mlist, address)
+ except errors.NotAMemberError:
+ # User has already been unsubscribed.
+ pass
+ slog.info('%s: deleted %s', mlist.fqdn_listname, address)
+ else:
+ raise AssertionError('Unexpected action: {0}'.format(action))
+ # Delete the request from the database.
+ requestdb.delete_request(id)
+
+
+
+def _refuse(mlist, request, recip, comment, origmsg=None, lang=None):
+ # As this message is going to the requester, try to set the language to
+ # his/her language choice, if they are a member. Otherwise use the list's
+ # preferred language.
+ realname = mlist.real_name
+ if lang is None:
+ member = mlist.members.get_member(recip)
+ if member:
+ lang = member.preferred_language
+ text = Utils.maketext(
+ 'refuse.txt',
+ {'listname' : mlist.fqdn_listname,
+ 'request' : request,
+ 'reason' : comment,
+ 'adminaddr': mlist.owner_address,
+ }, lang=lang, mlist=mlist)
+ with i18n.using_language(lang):
+ # add in original message, but not wrap/filled
+ if origmsg:
+ text = NL.join(
+ [text,
+ '---------- ' + _('Original Message') + ' ----------',
+ str(origmsg)
+ ])
+ subject = _('Request to mailing list "$realname" rejected')
+ msg = Message.UserNotification(recip, mlist.bounces_address,
+ subject, text, lang)
+ msg.send(mlist)
diff --git a/src/mailman/app/notifications.py b/src/mailman/app/notifications.py
new file mode 100644
index 000000000..9bef9998b
--- /dev/null
+++ b/src/mailman/app/notifications.py
@@ -0,0 +1,136 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Sending notifications."""
+
+from __future__ import unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'send_admin_subscription_notice',
+ 'send_goodbye_message',
+ 'send_welcome_message',
+ ]
+
+
+from email.utils import formataddr
+from lazr.config import as_boolean
+
+from mailman import Message
+from mailman import Utils
+from mailman import i18n
+from mailman.config import config
+from mailman.interfaces.member import DeliveryMode
+
+
+_ = i18n._
+
+
+
+def send_welcome_message(mlist, address, language, delivery_mode, text=''):
+ """Send a welcome message to a subscriber.
+
+ Prepending to the standard welcome message template is the mailing list's
+ welcome message, if there is one.
+
+ :param mlist: the mailing list
+ :type mlist: IMailingList
+ :param address: The address to respond to
+ :type address: string
+ :param language: the language of the response
+ :type language: string
+ :param delivery_mode: the type of delivery the subscriber is getting
+ :type delivery_mode: DeliveryMode
+ """
+ if mlist.welcome_msg:
+ welcome = Utils.wrap(mlist.welcome_msg) + '\n'
+ else:
+ welcome = ''
+ # Find the IMember object which is subscribed to the mailing list, because
+ # from there, we can get the member's options url.
+ member = mlist.members.get_member(address)
+ options_url = member.options_url
+ # Get the text from the template.
+ text += Utils.maketext(
+ 'subscribeack.txt', {
+ 'real_name' : mlist.real_name,
+ 'posting_address' : mlist.fqdn_listname,
+ 'listinfo_url' : mlist.script_url('listinfo'),
+ 'optionsurl' : options_url,
+ 'request_address' : mlist.request_address,
+ 'welcome' : welcome,
+ }, lang=language, mlist=mlist)
+ if delivery_mode is not DeliveryMode.regular:
+ digmode = _(' (Digest mode)')
+ else:
+ digmode = ''
+ msg = Message.UserNotification(
+ address, mlist.request_address,
+ _('Welcome to the "$mlist.real_name" mailing list${digmode}'),
+ text, language)
+ msg['X-No-Archive'] = 'yes'
+ msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries))
+
+
+
+def send_goodbye_message(mlist, address, language):
+ """Send a goodbye message to a subscriber.
+
+ Prepending to the standard goodbye message template is the mailing list's
+ goodbye message, if there is one.
+
+ :param mlist: the mailing list
+ :type mlist: IMailingList
+ :param address: The address to respond to
+ :type address: string
+ :param language: the language of the response
+ :type language: string
+ """
+ if mlist.goodbye_msg:
+ goodbye = Utils.wrap(mlist.goodbye_msg) + '\n'
+ else:
+ goodbye = ''
+ msg = Message.UserNotification(
+ address, mlist.bounces_address,
+ _('You have been unsubscribed from the $mlist.real_name mailing list'),
+ goodbye, language)
+ msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries))
+
+
+
+def send_admin_subscription_notice(mlist, address, full_name, language):
+ """Send the list administrators a subscription notice.
+
+ :param mlist: the mailing list
+ :type mlist: IMailingList
+ :param address: the address being subscribed
+ :type address: string
+ :param full_name: the name of the subscriber
+ :type full_name: string
+ :param language: the language of the address's realname
+ :type language: string
+ """
+ with i18n.using_language(mlist.preferred_language):
+ subject = _('$mlist.real_name subscription notification')
+ full_name = full_name.encode(Utils.GetCharSet(language), 'replace')
+ text = Utils.maketext(
+ 'adminsubscribeack.txt',
+ {'listname' : mlist.real_name,
+ 'member' : formataddr((full_name, address)),
+ }, mlist=mlist)
+ msg = Message.OwnerNotification(mlist, subject, text)
+ msg.send(mlist)
diff --git a/src/mailman/app/registrar.py b/src/mailman/app/registrar.py
new file mode 100644
index 000000000..6a2abeba9
--- /dev/null
+++ b/src/mailman/app/registrar.py
@@ -0,0 +1,163 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Implementation of the IUserRegistrar interface."""
+
+from __future__ import unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Registrar',
+ 'adapt_domain_to_registrar',
+ ]
+
+
+import datetime
+
+from pkg_resources import resource_string
+from zope.interface import implements
+
+from mailman.Message import UserNotification
+from mailman.Utils import ValidateEmail
+from mailman.config import config
+from mailman.i18n import _
+from mailman.interfaces.domain import IDomain
+from mailman.interfaces.member import MemberRole
+from mailman.interfaces.pending import IPendable
+from mailman.interfaces.registrar import IRegistrar
+
+
+
+class PendableRegistration(dict):
+ implements(IPendable)
+ PEND_KEY = 'registration'
+
+
+
+class Registrar:
+ implements(IRegistrar)
+
+ def __init__(self, context):
+ self._context = context
+
+ def register(self, address, real_name=None, mlist=None):
+ """See `IUserRegistrar`."""
+ # First, do validation on the email address. If the address is
+ # invalid, it will raise an exception, otherwise it just returns.
+ ValidateEmail(address)
+ # Create a pendable for the registration.
+ pendable = PendableRegistration(
+ type=PendableRegistration.PEND_KEY,
+ address=address,
+ real_name=real_name)
+ if mlist is not None:
+ pendable['list_name'] = mlist.fqdn_listname
+ token = config.db.pendings.add(pendable)
+ # Set up some local variables for translation interpolation.
+ domain = IDomain(self._context)
+ domain_name = _(domain.email_host)
+ contact_address = domain.contact_address
+ confirm_url = domain.confirm_url(token)
+ confirm_address = domain.confirm_address(token)
+ email_address = address
+ # Calculate the message's Subject header. XXX Have to deal with
+ # translating this subject header properly. XXX Must deal with
+ # VERP_CONFIRMATIONS as well.
+ subject = 'confirm ' + token
+ # Send a verification email to the address.
+ text = _(resource_string('mailman.templates.en', 'verify.txt'))
+ msg = UserNotification(address, confirm_address, subject, text)
+ msg.send(mlist=None)
+ return token
+
+ def confirm(self, token):
+ """See `IUserRegistrar`."""
+ # For convenience
+ pendable = config.db.pendings.confirm(token)
+ if pendable is None:
+ return False
+ missing = object()
+ address = pendable.get('address', missing)
+ real_name = pendable.get('real_name', missing)
+ list_name = pendable.get('list_name', missing)
+ if pendable.get('type') != PendableRegistration.PEND_KEY:
+ # It seems like it would be very difficult to accurately guess
+ # tokens, or brute force an attack on the SHA1 hash, so we'll just
+ # throw the pendable away in that case. It's possible we'll need
+ # to repend the event or adjust the API to handle this case
+ # better, but for now, the simpler the better.
+ return False
+ # We are going to end up with an IAddress for the verified address
+ # and an IUser linked to this IAddress. See if any of these objects
+ # currently exist in our database.
+ usermgr = config.db.user_manager
+ addr = (usermgr.get_address(address)
+ if address is not missing else None)
+ user = (usermgr.get_user(address)
+ if address 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:
+ assert user is None, 'How did we get a user but not an address?'
+ user = usermgr.create_user(address, 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:
+ break
+ else:
+ raise AssertionError('Could not find expected IAddress')
+ elif user is None:
+ user = usermgr.create_user()
+ user.real_name = real_name
+ user.link(addr)
+ 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()
+ # 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 = config.db.list_manager.get(list_name)
+ if mlist:
+ addr.subscribe(mlist, MemberRole.member)
+ return True
+
+ def discard(self, token):
+ # Throw the record away.
+ config.db.pendings.confirm(token)
+
+
+
+def adapt_domain_to_registrar(iface, obj):
+ """Adapt `IDomain` to `IRegistrar`.
+
+ :param iface: The interface to adapt to.
+ :type iface: `zope.interface.Interface`
+ :param obj: The object being adapted.
+ :type obj: `IDomain`
+ :return: An `IRegistrar` instance if adaptation succeeded or None if it
+ didn't.
+ """
+ return (Registrar(obj)
+ if IDomain.providedBy(obj) and iface is IRegistrar
+ else None)
diff --git a/src/mailman/app/replybot.py b/src/mailman/app/replybot.py
new file mode 100644
index 000000000..0537f6645
--- /dev/null
+++ b/src/mailman/app/replybot.py
@@ -0,0 +1,125 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Application level auto-reply code."""
+
+# XXX This should undergo a rewrite to move this functionality off of the
+# mailing list. The reply governor should really apply site-wide per
+# recipient (I think).
+
+from __future__ import unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'autorespond_to_sender',
+ 'can_acknowledge',
+ ]
+
+import logging
+import datetime
+
+from mailman import Utils
+from mailman import i18n
+from mailman.config import config
+
+
+log = logging.getLogger('mailman.vette')
+_ = i18n._
+
+
+
+def autorespond_to_sender(mlist, sender, lang=None):
+ """Return True if Mailman should auto-respond to this sender.
+
+ This is only consulted for messages sent to the -request address, or
+ for posting hold notifications, and serves only as a safety value for
+ mail loops with email 'bots.
+ """
+ if lang is None:
+ lang = mlist.preferred_language
+ max_autoresponses_per_day = int(config.mta.max_autoresponses_per_day)
+ if max_autoresponses_per_day == 0:
+ # Unlimited.
+ return True
+ today = datetime.date.today()
+ info = mlist.hold_and_cmd_autoresponses.get(sender)
+ if info is None or info[0] <> today:
+ # This is the first time we've seen a -request/post-hold for this
+ # sender today.
+ mlist.hold_and_cmd_autoresponses[sender] = (today, 1)
+ return True
+ date, count = info
+ if count < 0:
+ # They've already hit the limit for today, and we've already notified
+ # them of this fact, so there's nothing more to do.
+ log.info('-request/hold autoresponse discarded for: %s', sender)
+ return False
+ if count >= max_autoresponses_per_day:
+ log.info('-request/hold autoresponse limit hit for: %s', sender)
+ mlist.hold_and_cmd_autoresponses[sender] = (today, -1)
+ # Send this notification message instead.
+ text = Utils.maketext(
+ 'nomoretoday.txt',
+ {'sender' : sender,
+ 'listname': mlist.fqdn_listname,
+ 'num' : count,
+ 'owneremail': mlist.owner_address,
+ },
+ lang=lang)
+ with i18n.using_language(lang):
+ msg = Message.UserNotification(
+ sender, mlist.owner_address,
+ _('Last autoresponse notification for today'),
+ text, lang=lang)
+ msg.send(mlist)
+ return False
+ mlist.hold_and_cmd_autoresponses[sender] = (today, count + 1)
+ return True
+
+
+
+def can_acknowledge(msg):
+ """A boolean specifying whether this message can be acknowledged.
+
+ There are several reasons why a message should not be acknowledged, mostly
+ related to competing standards or common practices. These include:
+
+ * The message has a X-No-Ack header with any value
+ * The message has an X-Ack header with a 'no' value
+ * The message has a Precedence header
+ * The message has an Auto-Submitted header and that header does not have a
+ value of 'no'
+ * The message has an empty Return-Path header, e.g. <>
+ * The message has any RFC 2369 headers (i.e. List-* headers)
+
+ :param msg: a Message object.
+ :return: Boolean specifying whether the message can be acknowledged or not
+ (which is different from whether it will be acknowledged).
+ """
+ # I wrote it this way for clarity and consistency with the docstring.
+ for header in msg.keys():
+ if header in ('x-no-ack', 'precedence'):
+ return False
+ if header.lower().startswith('list-'):
+ return False
+ if msg.get('x-ack', '').lower() == 'no':
+ return False
+ if msg.get('auto-submitted', 'no').lower() <> 'no':
+ return False
+ if msg.get('return-path') == '<>':
+ return False
+ return True
diff --git a/src/mailman/archiving/__init__.py b/src/mailman/archiving/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mailman/archiving/mailarchive.py b/src/mailman/archiving/mailarchive.py
new file mode 100644
index 000000000..a5eb27db0
--- /dev/null
+++ b/src/mailman/archiving/mailarchive.py
@@ -0,0 +1,87 @@
+# Copyright (C) 2008-2009 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 .
+
+"""The Mail-Archive.com archiver."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'MailArchive',
+ ]
+
+
+import hashlib
+
+from base64 import urlsafe_b64encode
+from urllib import quote
+from urlparse import urljoin
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.interfaces.archiver import IArchiver
+
+
+
+class MailArchive:
+ """Public archiver at the Mail-Archive.com.
+
+ Messages get archived at http://go.mail-archive.com.
+ """
+
+ implements(IArchiver)
+
+ name = 'mail-archive'
+
+ @staticmethod
+ def list_url(mlist):
+ """See `IArchiver`."""
+ if mlist.archive_private:
+ return None
+ return urljoin(config.archiver.mail_archive.base_url,
+ quote(mlist.posting_address))
+
+ @staticmethod
+ def permalink(mlist, msg):
+ """See `IArchiver`."""
+ if mlist.archive_private:
+ return None
+ message_id = msg.get('message-id')
+ # It is not the archiver's job to ensure the message has a Message-ID.
+ # If no Message-ID is available, there is no permalink.
+ if message_id is None:
+ return None
+ # The angle brackets are not part of the Message-ID. See RFC 2822.
+ if message_id.startswith('<') and message_id.endswith('>'):
+ message_id = message_id[1:-1]
+ else:
+ message_id = message_id.strip()
+ sha = hashlib.sha1(message_id)
+ sha.update(str(mlist.posting_address))
+ message_id_hash = urlsafe_b64encode(sha.digest())
+ del msg['x-message-id-hash']
+ msg['X-Message-ID-Hash'] = message_id_hash
+ return urljoin(config.archiver.mail_archive.base_url, message_id_hash)
+
+ @staticmethod
+ def archive_message(mlist, msg):
+ """See `IArchiver`."""
+ if not mlist.archive_private:
+ config.switchboards['out'].enqueue(
+ msg,
+ listname=mlist.fqdn_listname,
+ recips=[config.archiver.mail_archive.recipient])
diff --git a/src/mailman/archiving/mhonarc.py b/src/mailman/archiving/mhonarc.py
new file mode 100644
index 000000000..949a79144
--- /dev/null
+++ b/src/mailman/archiving/mhonarc.py
@@ -0,0 +1,97 @@
+# Copyright (C) 2008-2009 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 .
+
+"""MHonArc archiver."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'MHonArc',
+ ]
+
+
+import hashlib
+import logging
+import subprocess
+
+from base64 import b32encode
+from urlparse import urljoin
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.interfaces.archiver import IArchiver
+from mailman.utilities.string import expand
+
+
+log = logging.getLogger('mailman.archiver')
+
+
+
+class MHonArc:
+ """Local MHonArc archiver."""
+
+ implements(IArchiver)
+
+ name = 'mhonarc'
+
+ @staticmethod
+ def list_url(mlist):
+ """See `IArchiver`."""
+ # XXX What about private MHonArc archives?
+ web_host = config.domains[mlist.host_name].url_host
+ return expand(config.archiver.mhonarc.base_url,
+ dict(listname=mlist.fqdn_listname,
+ hostname=web_host,
+ fqdn_listname=mlist.fqdn_listname,
+ ))
+
+ @staticmethod
+ def permalink(mlist, msg):
+ """See `IArchiver`."""
+ # XXX What about private MHonArc archives?
+ message_id = msg.get('message-id')
+ # It is not the archiver's job to ensure the message has a Message-ID.
+ # If no Message-ID is available, there is no permalink.
+ if message_id is None:
+ return None
+ # The angle brackets are not part of the Message-ID. See RFC 2822.
+ if message_id.startswith('<') and message_id.endswith('>'):
+ message_id = message_id[1:-1]
+ else:
+ message_id = message_id.strip()
+ sha = hashlib.sha1(message_id)
+ message_id_hash = b32encode(sha.digest())
+ del msg['x-message-id-hash']
+ msg['X-Message-ID-Hash'] = message_id_hash
+ return urljoin(MHonArc.list_url(mlist), message_id_hash)
+
+ @staticmethod
+ def archive_message(mlist, msg):
+ """See `IArchiver`."""
+ substitutions = config.__dict__.copy()
+ substitutions['listname'] = mlist.fqdn_listname
+ command = expand(config.archiver.mhonarc.command, substitutions)
+ proc = subprocess.Popen(
+ command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ shell=True)
+ stdout, stderr = proc.communicate(msg.as_string())
+ if proc.returncode <> 0:
+ log.error('%s: mhonarc subprocess had non-zero exit code: %s' %
+ (msg['message-id'], proc.returncode))
+ log.info(stdout)
+ log.error(stderr)
diff --git a/src/mailman/archiving/pipermail.py b/src/mailman/archiving/pipermail.py
new file mode 100644
index 000000000..377f4ab53
--- /dev/null
+++ b/src/mailman/archiving/pipermail.py
@@ -0,0 +1,121 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Pipermail archiver."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Pipermail',
+ ]
+
+
+import os
+
+from cStringIO import StringIO
+from zope.interface import implements
+from zope.interface.interface import adapter_hooks
+
+from mailman.config import config
+from mailman.interfaces.archiver import IArchiver, IPipermailMailingList
+from mailman.interfaces.mailinglist import IMailingList
+from mailman.utilities.filesystem import makedirs
+from mailman.utilities.string import expand
+
+from mailman.Archiver.HyperArch import HyperArchive
+
+
+
+class PipermailMailingListAdapter:
+ """An adapter for MailingList objects to work with Pipermail."""
+
+ implements(IPipermailMailingList)
+
+ def __init__(self, mlist):
+ self._mlist = mlist
+
+ def __getattr__(self, name):
+ return getattr(self._mlist, name)
+
+ def archive_dir(self):
+ """See `IPipermailMailingList`."""
+ if self._mlist.archive_private:
+ basedir = config.PRIVATE_ARCHIVE_FILE_DIR
+ else:
+ basedir = config.PUBLIC_ARCHIVE_FILE_DIR
+ # Make sure the archive directory exists.
+ archive_dir = os.path.join(basedir, self._mlist.fqdn_listname)
+ makedirs(archive_dir)
+ return archive_dir
+
+
+def adapt_mailing_list_for_pipermail(iface, obj):
+ """Adapt `IMailingLists` to `IPipermailMailingList`.
+
+ :param iface: The interface to adapt to.
+ :type iface: `zope.interface.Interface`
+ :param obj: The object being adapted.
+ :type obj: any object
+ :return: An `IPipermailMailingList` instance if adaptation succeeded or
+ None if it didn't.
+ """
+ return (PipermailMailingListAdapter(obj)
+ if IMailingList.providedBy(obj) and iface is IPipermailMailingList
+ else None)
+
+adapter_hooks.append(adapt_mailing_list_for_pipermail)
+
+
+
+class Pipermail:
+ """The stock Pipermail archiver."""
+
+ implements(IArchiver)
+
+ name = 'pipermail'
+
+ @staticmethod
+ def list_url(mlist):
+ """See `IArchiver`."""
+ if mlist.archive_private:
+ url = mlist.script_url('private') + '/index.html'
+ else:
+ web_host = config.domains[mlist.host_name].url_host
+ return expand(config.archiver.pipermail.base_url,
+ dict(listname=mlist.fqdn_listname,
+ hostname=web_host,
+ fqdn_listname=mlist.fqdn_listname,
+ ))
+
+ @staticmethod
+ def permalink(mlist, message):
+ """See `IArchiver`."""
+ # Not currently implemented.
+ return None
+
+ @staticmethod
+ def archive_message(mlist, message):
+ """See `IArchiver`."""
+ text = str(message)
+ fileobj = StringIO(text)
+ h = HyperArchive(IPipermailMailingList(mlist))
+ h.processUnixMailbox(fileobj)
+ h.close()
+ fileobj.close()
+ # There's no good way to know the url for the archived message.
+ return None
diff --git a/src/mailman/archiving/prototype.py b/src/mailman/archiving/prototype.py
new file mode 100644
index 000000000..81163e184
--- /dev/null
+++ b/src/mailman/archiving/prototype.py
@@ -0,0 +1,77 @@
+# Copyright (C) 2008-2009 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 .
+
+"""Prototypical permalinking archiver."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Prototype',
+ ]
+
+
+import hashlib
+
+from base64 import b32encode
+from urlparse import urljoin
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.interfaces.archiver import IArchiver
+
+
+
+class Prototype:
+ """A prototype of a third party archiver.
+
+ Mailman proposes a draft specification for interoperability between list
+ servers and archivers: .
+ """
+
+ implements(IArchiver)
+
+ name = 'prototype'
+
+ @staticmethod
+ def list_url(mlist):
+ """See `IArchiver`."""
+ return config.domains[mlist.host_name].base_url
+
+ @staticmethod
+ def permalink(mlist, msg):
+ """See `IArchiver`."""
+ message_id = msg.get('message-id')
+ # It is not the archiver's job to ensure the message has a Message-ID.
+ # If this header is missing, there is no permalink.
+ if message_id is None:
+ return None
+ # The angle brackets are not part of the Message-ID. See RFC 2822.
+ if message_id.startswith('<') and message_id.endswith('>'):
+ message_id = message_id[1:-1]
+ else:
+ message_id = message_id.strip()
+ digest = hashlib.sha1(message_id).digest()
+ message_id_hash = b32encode(digest)
+ del msg['x-message-id-hash']
+ msg['X-Message-ID-Hash'] = message_id_hash
+ return urljoin(Prototype.list_url(mlist), message_id_hash)
+
+ @staticmethod
+ def archive_message(mlist, message):
+ """See `IArchiver`."""
+ raise NotImplementedError
diff --git a/src/mailman/attic/Bouncer.py b/src/mailman/attic/Bouncer.py
new file mode 100644
index 000000000..e2de3c915
--- /dev/null
+++ b/src/mailman/attic/Bouncer.py
@@ -0,0 +1,250 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Handle delivery bounces."""
+
+import sys
+import time
+import logging
+
+from email.MIMEMessage import MIMEMessage
+from email.MIMEText import MIMEText
+
+from mailman import Defaults
+from mailman import Message
+from mailman import Utils
+from mailman import i18n
+from mailman.configuration import config
+from mailman.interfaces import DeliveryStatus
+
+EMPTYSTRING = ''
+
+# This constant is supposed to represent the day containing the first midnight
+# after the epoch. We'll add (0,)*6 to this tuple to get a value appropriate
+# for time.mktime().
+ZEROHOUR_PLUSONEDAY = time.localtime(60 * 60 * 24)[:3]
+
+def _(s): return s
+
+REASONS = {
+ DeliveryStatus.by_bounces : _('due to excessive bounces'),
+ DeliveryStatus.by_user : _('by yourself'),
+ DeliveryStatus.by_moderator : _('by the list administrator'),
+ DeliveryStatus.unknown : _('for unknown reasons'),
+ }
+
+_ = i18n._
+
+log = logging.getLogger('mailman.bounce')
+slog = logging.getLogger('mailman.subscribe')
+
+
+
+class _BounceInfo:
+ def __init__(self, member, score, date, noticesleft):
+ self.member = member
+ self.cookie = None
+ self.reset(score, date, noticesleft)
+
+ def reset(self, score, date, noticesleft):
+ self.score = score
+ self.date = date
+ self.noticesleft = noticesleft
+ self.lastnotice = ZEROHOUR_PLUSONEDAY
+
+ def __repr__(self):
+ # For debugging
+ return """\
+""" % self.__dict__
+
+
+
+class Bouncer:
+ def registerBounce(self, member, msg, weight=1.0, day=None):
+ if not self.isMember(member):
+ return
+ info = self.getBounceInfo(member)
+ if day is None:
+ # Use today's date
+ day = time.localtime()[:3]
+ if not isinstance(info, _BounceInfo):
+ # This is the first bounce we've seen from this member
+ info = _BounceInfo(member, weight, day,
+ self.bounce_you_are_disabled_warnings)
+ self.setBounceInfo(member, info)
+ log.info('%s: %s bounce score: %s', self.internal_name(),
+ member, info.score)
+ # Continue to the check phase below
+ elif self.getDeliveryStatus(member) <> DeliveryStatus.enabled:
+ # The user is already disabled, so we can just ignore subsequent
+ # bounces. These are likely due to residual messages that were
+ # sent before disabling the member, but took a while to bounce.
+ log.info('%s: %s residual bounce received',
+ self.internal_name(), member)
+ return
+ elif info.date == day:
+ # We've already scored any bounces for this day, so ignore it.
+ log.info('%s: %s already scored a bounce for date %s',
+ self.internal_name(), member,
+ time.strftime('%d-%b-%Y', day + (0,0,0,0,1,0)))
+ # Continue to check phase below
+ else:
+ # See if this member's bounce information is stale.
+ now = Utils.midnight(day)
+ lastbounce = Utils.midnight(info.date)
+ if lastbounce + self.bounce_info_stale_after < now:
+ # Information is stale, so simply reset it
+ info.reset(weight, day, self.bounce_you_are_disabled_warnings)
+ log.info('%s: %s has stale bounce info, resetting',
+ self.internal_name(), member)
+ else:
+ # Nope, the information isn't stale, so add to the bounce
+ # score and take any necessary action.
+ info.score += weight
+ info.date = day
+ log.info('%s: %s current bounce score: %s',
+ self.internal_name(), member, info.score)
+ # Continue to the check phase below
+ #
+ # Now that we've adjusted the bounce score for this bounce, let's
+ # check to see if the disable-by-bounce threshold has been reached.
+ if info.score >= self.bounce_score_threshold:
+ if config.VERP_PROBES:
+ log.info('sending %s list probe to: %s (score %s >= %s)',
+ self.internal_name(), member, info.score,
+ self.bounce_score_threshold)
+ self.sendProbe(member, msg)
+ info.reset(0, info.date, info.noticesleft)
+ else:
+ self.disableBouncingMember(member, info, msg)
+
+ def disableBouncingMember(self, member, info, msg):
+ # Initialize their confirmation cookie. If we do it when we get the
+ # first bounce, it'll expire by the time we get the disabling bounce.
+ cookie = self.pend_new(Pending.RE_ENABLE, self.internal_name(), member)
+ info.cookie = cookie
+ # Disable them
+ if config.VERP_PROBES:
+ log.info('%s: %s disabling due to probe bounce received',
+ self.internal_name(), member)
+ else:
+ log.info('%s: %s disabling due to bounce score %s >= %s',
+ self.internal_name(), member,
+ info.score, self.bounce_score_threshold)
+ self.setDeliveryStatus(member, DeliveryStatus.by_bounces)
+ self.sendNextNotification(member)
+ if self.bounce_notify_owner_on_disable:
+ self.__sendAdminBounceNotice(member, msg)
+
+ def __sendAdminBounceNotice(self, member, msg):
+ # BAW: This is a bit kludgey, but we're not providing as much
+ # information in the new admin bounce notices as we used to (some of
+ # it was of dubious value). However, we'll provide empty, strange, or
+ # meaningless strings for the unused %()s fields so that the language
+ # translators don't have to provide new templates.
+ text = Utils.maketext(
+ 'bounce.txt',
+ {'listname' : self.real_name,
+ 'addr' : member,
+ 'negative' : '',
+ 'did' : _('disabled'),
+ 'but' : '',
+ 'reenable' : '',
+ 'owneraddr': self.no_reply_address,
+ }, mlist=self)
+ subject = _('Bounce action notification')
+ umsg = Message.UserNotification(self.GetOwnerEmail(),
+ self.no_reply_address,
+ subject,
+ lang=self.preferred_language)
+ # BAW: Be sure you set the type before trying to attach, or you'll get
+ # a MultipartConversionError.
+ umsg.set_type('multipart/mixed')
+ umsg.attach(
+ MIMEText(text, _charset=Utils.GetCharSet(self.preferred_language)))
+ if isinstance(msg, str):
+ umsg.attach(MIMEText(msg))
+ else:
+ umsg.attach(MIMEMessage(msg))
+ umsg.send(self)
+
+ def sendNextNotification(self, member):
+ info = self.getBounceInfo(member)
+ if info is None:
+ return
+ reason = self.getDeliveryStatus(member)
+ if info.noticesleft <= 0:
+ # BAW: Remove them now, with a notification message
+ self.ApprovedDeleteMember(
+ member, 'disabled address',
+ admin_notif=self.bounce_notify_owner_on_removal,
+ userack=1)
+ # Expunge the pending cookie for the user. We throw away the
+ # returned data.
+ self.pend_confirm(info.cookie)
+ if reason == DeliveryStatus.by_bounces:
+ log.info('%s: %s deleted after exhausting notices',
+ self.internal_name(), member)
+ slog.info('%s: %s auto-unsubscribed [reason: %s]',
+ self.internal_name(), member,
+ {DeliveryStatus.by_bounces: 'BYBOUNCE',
+ DeliveryStatus.by_user: 'BYUSER',
+ DeliveryStatus.by_moderator: 'BYADMIN',
+ DeliveryStatus.unknown: 'UNKNOWN'}.get(
+ reason, 'invalid value'))
+ return
+ # Send the next notification
+ confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1),
+ info.cookie)
+ optionsurl = self.GetOptionsURL(member, absolute=1)
+ reqaddr = self.GetRequestEmail()
+ lang = self.getMemberLanguage(member)
+ txtreason = REASONS.get(reason)
+ if txtreason is None:
+ txtreason = _('for unknown reasons')
+ else:
+ txtreason = _(txtreason)
+ # Give a little bit more detail on bounce disables
+ if reason == DeliveryStatus.by_bounces:
+ date = time.strftime('%d-%b-%Y',
+ time.localtime(Utils.midnight(info.date)))
+ extra = _(' The last bounce received from you was dated %(date)s')
+ txtreason += extra
+ text = Utils.maketext(
+ 'disabled.txt',
+ {'listname' : self.real_name,
+ 'noticesleft': info.noticesleft,
+ 'confirmurl' : confirmurl,
+ 'optionsurl' : optionsurl,
+ 'password' : self.getMemberPassword(member),
+ 'owneraddr' : self.GetOwnerEmail(),
+ 'reason' : txtreason,
+ }, lang=lang, mlist=self)
+ msg = Message.UserNotification(member, reqaddr, text=text, lang=lang)
+ # BAW: See the comment in MailList.py ChangeMemberAddress() for why we
+ # set the Subject this way.
+ del msg['subject']
+ msg['Subject'] = 'confirm ' + info.cookie
+ msg.send(self)
+ info.noticesleft -= 1
+ info.lastnotice = time.localtime()[:3]
diff --git a/src/mailman/attic/Defaults.py b/src/mailman/attic/Defaults.py
new file mode 100644
index 000000000..6f72ed535
--- /dev/null
+++ b/src/mailman/attic/Defaults.py
@@ -0,0 +1,1324 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Distributed default settings for significant Mailman config variables."""
+
+from datetime import timedelta
+
+from mailman.interfaces.mailinglist import ReplyToMunging
+
+
+
+class CompatibleTimeDelta(timedelta):
+ def __float__(self):
+ # Convert to float seconds.
+ return (self.days * 24 * 60 * 60 +
+ self.seconds + self.microseconds / 1.0e6)
+
+ def __int__(self):
+ return int(float(self))
+
+
+def seconds(s):
+ return CompatibleTimeDelta(seconds=s)
+
+def minutes(m):
+ return CompatibleTimeDelta(minutes=m)
+
+def hours(h):
+ return CompatibleTimeDelta(hours=h)
+
+def days(d):
+ return CompatibleTimeDelta(days=d)
+
+
+# Some convenient constants
+Yes = yes = On = on = True
+No = no = Off = off = False
+
+
+
+#####
+# General system-wide defaults
+#####
+
+# Should image logos be used? Set this to 0 to disable image logos from "our
+# sponsors" and just use textual links instead (this will also disable the
+# shortcut "favicon"). Otherwise, this should contain the URL base path to
+# the logo images (and must contain the trailing slash).. If you want to
+# disable Mailman's logo footer altogther, hack
+# mailman/htmlformat.py:MailmanLogo(), which also contains the hardcoded links
+# and image names.
+IMAGE_LOGOS = '/icons/'
+
+# The name of the Mailman favicon
+SHORTCUT_ICON = 'mm-icon.png'
+
+# Don't change MAILMAN_URL, unless you want to point it at one of the mirrors.
+MAILMAN_URL = 'http://www.gnu.org/software/mailman/index.html'
+#MAILMAN_URL = 'http://www.list.org/'
+#MAILMAN_URL = 'http://mailman.sf.net/'
+
+DEFAULT_URL_PATTERN = 'http://%s/mailman/'
+
+# This address is used as the from address whenever a message comes from some
+# entity to which there is no natural reply recipient. Set this to a real
+# human or to /dev/null. It will be appended with the hostname of the list
+# involved or the DEFAULT_EMAIL_HOST if none is available. Address must not
+# bounce and it must not point to a Mailman process.
+NO_REPLY_ADDRESS = 'noreply'
+
+# This address is the "site owner" address. Certain messages which must be
+# delivered to a human, but which can't be delivered to a list owner (e.g. a
+# bounce from a list owner), will be sent to this address. It should point to
+# a human.
+SITE_OWNER_ADDRESS = 'changeme@example.com'
+
+# Normally when a site administrator authenticates to a web page with the site
+# password, they get a cookie which authorizes them as the list admin. It
+# makes me nervous to hand out site auth cookies because if this cookie is
+# cracked or intercepted, the intruder will have access to every list on the
+# site. OTOH, it's dang handy to not have to re-authenticate to every list on
+# the site. Set this value to Yes to allow site admin cookies.
+ALLOW_SITE_ADMIN_COOKIES = No
+
+# Command that is used to convert text/html parts into plain text. This
+# should output results to standard output. %(filename)s will contain the
+# name of the temporary file that the program should operate on.
+HTML_TO_PLAIN_TEXT_COMMAND = '/usr/bin/lynx -dump %(filename)s'
+
+# Default password hashing scheme. See 'bin/mmsitepass -P' for a list of
+# available schemes.
+PASSWORD_SCHEME = 'ssha'
+
+# Default run-time directory.
+DEFAULT_VAR_DIRECTORY = '/var/mailman'
+
+
+
+#####
+# Database options
+#####
+
+# Use this to set the SQLAlchemy database engine URL. You generally have one
+# primary database connection for all of Mailman. List data and most rosters
+# will store their data in this database, although external rosters may access
+# other databases in their own way. This string support substitutions using
+# any variable in the Configuration object.
+DEFAULT_DATABASE_URL = 'sqlite:///$DATA_DIR/mailman.db'
+
+
+
+#####
+# Spam avoidance defaults
+#####
+
+# This variable contains a list of tuple of the format:
+#
+# (header, pattern[, chain])
+#
+# which is used to match against the current message's headers. If the
+# pattern matches the given header in the current message, then the named
+# chain is jumped to. header is case-insensitive and should not include the
+# trailing colon. pattern is always matched with re.IGNORECASE. chain is
+# optional; if not given the 'hold' chain is used, but if given it may be any
+# existing chain, such as 'discard', 'reject', or 'accept'.
+#
+# Note that the more searching done, the slower the whole process gets.
+# Header matching is run against all messages coming to either the list, or
+# the -owners address, unless the message is explicitly approved.
+HEADER_MATCHES = []
+
+
+
+#####
+# Web UI defaults
+#####
+
+# Almost all the colors used in Mailman's web interface are parameterized via
+# the following variables. This lets you easily change the color schemes for
+# your preferences without having to do major surgery on the source code.
+# Note that in general, the template colors are not included here since it is
+# easy enough to override the default template colors via site-wide,
+# vdomain-wide, or list-wide specializations.
+
+WEB_BG_COLOR = 'white' # Page background
+WEB_HEADER_COLOR = '#99ccff' # Major section headers
+WEB_SUBHEADER_COLOR = '#fff0d0' # Minor section headers
+WEB_ADMINITEM_COLOR = '#dddddd' # Option field background
+WEB_ADMINPW_COLOR = '#99cccc' # Password box color
+WEB_ERROR_COLOR = 'red' # Error message foreground
+WEB_LINK_COLOR = '' # If true, forces LINK=
+WEB_ALINK_COLOR = '' # If true, forces ALINK=
+WEB_VLINK_COLOR = '' # If true, forces VLINK=
+WEB_HIGHLIGHT_COLOR = '#dddddd' # If true, alternating rows
+ # in listinfo & admin display
+# CGI file extension.
+CGIEXT = ''
+
+
+
+#####
+# Archive defaults
+#####
+
+# The url template for the public archives. This will be used in several
+# places, including the List-Archive: header, links to the archive on the
+# list's listinfo page, and on the list's admin page.
+#
+# This variable supports several substitution variables
+# - $hostname -- the host on which the archive resides
+# - $listname -- the short name of the list being accessed
+# - $fqdn_listname -- the long name of the list being accessed
+PUBLIC_ARCHIVE_URL = 'http://$hostname/pipermail/$fqdn_listname'
+
+# The public Mail-Archive.com service's base url.
+MAIL_ARCHIVE_BASEURL = 'http://go.mail-archive.com/'
+# The posting address for the Mail-Archive.com service
+MAIL_ARCHIVE_RECIPIENT = 'archive@mail-archive.com'
+
+# The command for archiving to a local MHonArc instance.
+MHONARC_COMMAND = """\
+/usr/bin/mhonarc \
+-add \
+-dbfile $PRIVATE_ARCHIVE_FILE_DIR/${listname}.mbox/mhonarc.db \
+-outdir $VAR_DIR/mhonarc/${listname} \
+-stderr $LOG_DIR/mhonarc \
+-stdout $LOG_DIR/mhonarc \
+-spammode \
+-umask 022"""
+
+# Are archives on or off by default?
+DEFAULT_ARCHIVE = On
+
+# Are archives public or private by default?
+# 0=public, 1=private
+DEFAULT_ARCHIVE_PRIVATE = 0
+
+# ARCHIVE_TO_MBOX
+#-1 - do not do any archiving
+# 0 - do not archive to mbox, use builtin mailman html archiving only
+# 1 - do not use builtin mailman html archiving, archive to mbox only
+# 2 - archive to both mbox and builtin mailman html archiving.
+# See the settings below for PUBLIC_EXTERNAL_ARCHIVER and
+# PRIVATE_EXTERNAL_ARCHIVER which can be used to replace mailman's
+# builtin html archiving with an external archiver. The flat mail
+# mbox file can be useful for searching, and is another way to
+# interface external archivers, etc.
+ARCHIVE_TO_MBOX = 2
+
+# 0 - yearly
+# 1 - monthly
+# 2 - quarterly
+# 3 - weekly
+# 4 - daily
+DEFAULT_ARCHIVE_VOLUME_FREQUENCY = 1
+DEFAULT_DIGEST_VOLUME_FREQUENCY = 1
+
+# These variables control the use of an external archiver. Normally if
+# archiving is turned on (see ARCHIVE_TO_MBOX above and the list's archive*
+# attributes) the internal Pipermail archiver is used. This is the default if
+# both of these variables are set to No. When either is set, the value should
+# be a shell command string which will get passed to os.popen(). This string
+# can contain the following substitution strings:
+#
+# $listname -- gets the internal name of the list
+# $hostname -- gets the email hostname for the list
+#
+# being archived will be substituted for this. Please note that os.popen() is
+# used.
+#
+# Note that if you set one of these variables, you should set both of them
+# (they can be the same string). This will mean your external archiver will
+# be used regardless of whether public or private archives are selected.
+PUBLIC_EXTERNAL_ARCHIVER = No
+PRIVATE_EXTERNAL_ARCHIVER = No
+
+# A filter module that converts from multipart messages to "flat" messages
+# (i.e. containing a single payload). This is required for Pipermail, and you
+# may want to set it to 0 for external archivers. You can also replace it
+# with your own module as long as it contains a process() function that takes
+# a MailList object and a Message object. It should raise
+# Errors.DiscardMessage if it wants to throw the message away. Otherwise it
+# should modify the Message object as necessary.
+ARCHIVE_SCRUBBER = 'mailman.pipeline.scrubber'
+
+# Control parameter whether mailman.Handlers.Scrubber should use message
+# attachment's filename as is indicated by the filename parameter or use
+# 'attachement-xxx' instead. The default is set True because the applications
+# on PC and Mac begin to use longer non-ascii filenames. Historically, it
+# was set False in 2.1.6 for backward compatiblity but it was reset to True
+# for safer operation in mailman-2.1.7.
+SCRUBBER_DONT_USE_ATTACHMENT_FILENAME = True
+
+# Use of attachment filename extension per se is may be dangerous because
+# virus fakes it. You can set this True if you filter the attachment by
+# filename extension
+SCRUBBER_USE_ATTACHMENT_FILENAME_EXTENSION = False
+
+# This variable defines what happens to text/html subparts. They can be
+# stripped completely, escaped, or filtered through an external program. The
+# legal values are:
+# 0 - Strip out text/html parts completely, leaving a notice of the removal in
+# the message. If the outer part is text/html, the entire message is
+# discarded.
+# 1 - Remove any embedded text/html parts, leaving them as HTML-escaped
+# attachments which can be separately viewed. Outer text/html parts are
+# simply HTML-escaped.
+# 2 - Leave it inline, but HTML-escape it
+# 3 - Remove text/html as attachments but don't HTML-escape them. Note: this
+# is very dangerous because it essentially means anybody can send an HTML
+# email to your site containing evil JavaScript or web bugs, or other
+# nasty things, and folks viewing your archives will be susceptible. You
+# should only consider this option if you do heavy moderation of your list
+# postings.
+#
+# Note: given the current archiving code, it is not possible to leave
+# text/html parts inline and un-escaped. I wouldn't think it'd be a good idea
+# to do anyway.
+#
+# The value can also be a string, in which case it is the name of a command to
+# filter the HTML page through. The resulting output is left in an attachment
+# or as the entirety of the message when the outer part is text/html. The
+# format of the string must include a "%(filename)s" which will contain the
+# name of the temporary file that the program should operate on. It should
+# write the processed message to stdout. Set this to
+# HTML_TO_PLAIN_TEXT_COMMAND to specify an HTML to plain text conversion
+# program.
+ARCHIVE_HTML_SANITIZER = 1
+
+# Set this to Yes to enable gzipping of the downloadable archive .txt file.
+# Note that this is /extremely/ inefficient, so an alternative is to just
+# collect the messages in the associated .txt file and run a cron job every
+# night to generate the txt.gz file. See cron/nightly_gzip for details.
+GZIP_ARCHIVE_TXT_FILES = No
+
+# This sets the default `clobber date' policy for the archiver. When a
+# message is to be archived either by Pipermail or an external archiver,
+# Mailman can modify the Date: header to be the date the message was received
+# instead of the Date: in the original message. This is useful if you
+# typically receive messages with outrageous dates. Set this to 0 to retain
+# the date of the original message, or to 1 to always clobber the date. Set
+# it to 2 to perform `smart overrides' on the date; when the date is outside
+# ARCHIVER_ALLOWABLE_SANE_DATE_SKEW (either too early or too late), then the
+# received date is substituted instead.
+ARCHIVER_CLOBBER_DATE_POLICY = 2
+ARCHIVER_ALLOWABLE_SANE_DATE_SKEW = days(15)
+
+# Pipermail archives contain the raw email addresses of the posting authors.
+# Some view this as a goldmine for spam harvesters. Set this to Yes to
+# moderately obscure email addresses, but note that this breaks mailto: URLs
+# in the archives too.
+ARCHIVER_OBSCURES_EMAILADDRS = Yes
+
+# Pipermail assumes that messages bodies contain US-ASCII text.
+# Change this option to define a different character set to be used as
+# the default character set for the archive. The term "character set"
+# is used in MIME to refer to a method of converting a sequence of
+# octets into a sequence of characters. If you change the default
+# charset, you might need to add it to VERBATIM_ENCODING below.
+DEFAULT_CHARSET = None
+
+# Most character set encodings require special HTML entity characters to be
+# quoted, otherwise they won't look right in the Pipermail archives. However
+# some character sets must not quote these characters so that they can be
+# rendered properly in the browsers. The primary issue is multi-byte
+# encodings where the octet 0x26 does not always represent the & character.
+# This variable contains a list of such characters sets which are not
+# HTML-quoted in the archives.
+VERBATIM_ENCODING = ['iso-2022-jp']
+
+# When the archive is public, should Mailman also make the raw Unix mbox file
+# publically available?
+PUBLIC_MBOX = No
+
+
+
+#####
+# Delivery defaults
+#####
+
+# Final delivery module for outgoing mail. This handler is used for message
+# delivery to the list via the smtpd, and to an individual user. This value
+# must be a string naming an IHandler.
+DELIVERY_MODULE = 'smtp-direct'
+
+# MTA should name a module in mailman/MTA which provides the MTA specific
+# functionality for creating and removing lists. Some MTAs like Exim can be
+# configured to automatically recognize new lists, in which case the MTA
+# variable should be set to None. Use 'Manual' to print new aliases to
+# standard out (or send an email to the site list owner) for manual twiddling
+# of an /etc/aliases style file. Use 'Postfix' if you are using the Postfix
+# MTA -- but then also see POSTFIX_STYLE_VIRTUAL_DOMAINS.
+MTA = 'Manual'
+
+# If you set MTA='Postfix', then you also want to set the following variable,
+# depending on whether you're using virtual domains in Postfix, and which
+# style of virtual domain you're using. Set this to the empty list if you're
+# not using virtual domains in Postfix, or if you're using Sendmail-style
+# virtual domains (where all addresses are visible in all domains). If you're
+# using Postfix-style virtual domains, where aliases should only show up in
+# the virtual domain, set this variable to the list of host_name values to
+# write separate virtual entries for. I.e. if you run dom1.ain, dom2.ain, and
+# dom3.ain, but only dom2 and dom3 are virtual, set this variable to the list
+# ['dom2.ain', 'dom3.ain']. Matches are done against the host_name attribute
+# of the mailing lists. See the Postfix section of the installation manual
+# for details.
+POSTFIX_STYLE_VIRTUAL_DOMAINS = []
+
+# We should use a separator in place of '@' for list-etc@dom2.ain in both
+# aliases and mailman-virtual files.
+POSTFIX_VIRTUAL_SEPARATOR = '_at_'
+
+# These variables describe the program to use for regenerating the aliases.db
+# and virtual-mailman.db files, respectively, from the associated plain text
+# files. The file being updated will be appended to this string (with a
+# separating space), so it must be appropriate for os.system().
+POSTFIX_ALIAS_CMD = '/usr/sbin/postalias'
+POSTFIX_MAP_CMD = '/usr/sbin/postmap'
+
+# Ceiling on the number of recipients that can be specified in a single SMTP
+# transaction. Set to 0 to submit the entire recipient list in one
+# transaction. Only used with the SMTPDirect DELIVERY_MODULE.
+SMTP_MAX_RCPTS = 500
+
+# Ceiling on the number of SMTP sessions to perform on a single socket
+# connection. Some MTAs have limits. Set this to 0 to do as many as we like
+# (i.e. your MTA has no limits). Set this to some number great than 0 and
+# Mailman will close the SMTP connection and re-open it after this number of
+# consecutive sessions.
+SMTP_MAX_SESSIONS_PER_CONNECTION = 0
+
+# Maximum number of simultaneous subthreads that will be used for SMTP
+# delivery. After the recipients list is chunked according to SMTP_MAX_RCPTS,
+# each chunk is handed off to the smptd by a separate such thread. If your
+# Python interpreter was not built for threads, this feature is disabled. You
+# can explicitly disable it in all cases by setting MAX_DELIVERY_THREADS to
+# 0. This feature is only supported with the SMTPDirect DELIVERY_MODULE.
+#
+# NOTE: This is an experimental feature and limited testing shows that it may
+# in fact degrade performance, possibly due to Python's global interpreter
+# lock. Use with caution.
+MAX_DELIVERY_THREADS = 0
+
+# SMTP host and port, when DELIVERY_MODULE is 'SMTPDirect'. Make sure the
+# host exists and is resolvable (i.e., if it's the default of "localhost" be
+# sure there's a localhost entry in your /etc/hosts file!)
+SMTPHOST = 'localhost'
+SMTPPORT = 0 # default from smtplib
+
+# Command for direct command pipe delivery to sendmail compatible program,
+# when DELIVERY_MODULE is 'Sendmail'.
+SENDMAIL_CMD = '/usr/lib/sendmail'
+
+# Set these variables if you need to authenticate to your NNTP server for
+# Usenet posting or reading. If no authentication is necessary, specify None
+# for both variables.
+NNTP_USERNAME = None
+NNTP_PASSWORD = None
+
+# Set this if you have an NNTP server you prefer gatewayed lists to use.
+DEFAULT_NNTP_HOST = u''
+
+# These variables controls how headers must be cleansed in order to be
+# accepted by your NNTP server. Some servers like INN reject messages
+# containing prohibited headers, or duplicate headers. The NNTP server may
+# reject the message for other reasons, but there's little that can be
+# programmatically done about that. See mailman/Queue/NewsRunner.py
+#
+# First, these headers (case ignored) are removed from the original message.
+NNTP_REMOVE_HEADERS = ['nntp-posting-host', 'nntp-posting-date', 'x-trace',
+ 'x-complaints-to', 'xref', 'date-received', 'posted',
+ 'posting-version', 'relay-version', 'received']
+
+# Next, these headers are left alone, unless there are duplicates in the
+# original message. Any second and subsequent headers are rewritten to the
+# second named header (case preserved).
+NNTP_REWRITE_DUPLICATE_HEADERS = [
+ ('To', 'X-Original-To'),
+ ('CC', 'X-Original-CC'),
+ ('Content-Transfer-Encoding', 'X-Original-Content-Transfer-Encoding'),
+ ('MIME-Version', 'X-MIME-Version'),
+ ]
+
+# Some list posts and mail to the -owner address may contain DomainKey or
+# DomainKeys Identified Mail (DKIM) signature headers .
+# Various list transformations to the message such as adding a list header or
+# footer or scrubbing attachments or even reply-to munging can break these
+# signatures. It is generally felt that these signatures have value, even if
+# broken and even if the outgoing message is resigned. However, some sites
+# may wish to remove these headers by setting this to Yes.
+REMOVE_DKIM_HEADERS = No
+
+# This is the pipeline which messages sent to the -owner address go through
+OWNER_PIPELINE = [
+ 'SpamDetect',
+ 'Replybot',
+ 'CleanseDKIM',
+ 'OwnerRecips',
+ 'ToOutgoing',
+ ]
+
+
+# This defines a logging subsystem confirmation file, which overrides the
+# default log settings. This is a ConfigParser formatted file which can
+# contain sections named after the logger name (without the leading 'mailman.'
+# common prefix). Each section may contain the following options:
+#
+# - level -- Overrides the default level; this may be any of the
+# standard Python logging levels, case insensitive.
+# - format -- Overrides the default format string; see below.
+# - datefmt -- Overrides the default date format string; see below.
+# - path -- Overrides the default logger path. This may be a relative
+# path name, in which case it is relative to Mailman's LOG_DIR,
+# or it may be an absolute path name. You cannot change the
+# handler class that will be used.
+# - propagate -- Boolean specifying whether to propagate log message from this
+# logger to the root "mailman" logger. You cannot override
+# settings for the root logger.
+#
+# The file name may be absolute, or relative to Mailman's etc directory.
+LOG_CONFIG_FILE = None
+
+# This defines log format strings for the SMTPDirect delivery module (see
+# DELIVERY_MODULE above). Valid %()s string substitutions include:
+#
+# time -- the time in float seconds that it took to complete the smtp
+# hand-off of the message from Mailman to your smtpd.
+#
+# size -- the size of the entire message, in bytes
+#
+# #recips -- the number of actual recipients for this message.
+#
+# #refused -- the number of smtp refused recipients (use this only in
+# SMTP_LOG_REFUSED).
+#
+# listname -- the `internal' name of the mailing list for this posting
+#
+# msg_ -- the value of the delivered message's given header. If
+# the message had no such header, then "n/a" will be used. Note though
+# that if the message had multiple such headers, then it is undefined
+# which will be used.
+#
+# allmsg_ - Same as msg_ above, but if there are multiple
+# such headers in the message, they will all be printed, separated by
+# comma-space.
+#
+# sender -- the "sender" of the messages, which will be the From: or
+# envelope-sender as determeined by the USE_ENVELOPE_SENDER variable
+# below.
+#
+# The format of the entries is a 2-tuple with the first element naming the
+# logger (as a child of the root 'mailman' logger) to print the message to,
+# and the second being a format string appropriate for Python's %-style string
+# interpolation. The file name is arbitrary; qfiles/ will be created
+# automatically if it does not exist.
+
+# The format of the message printed for every delivered message, regardless of
+# whether the delivery was successful or not. Set to None to disable the
+# printing of this log message.
+SMTP_LOG_EVERY_MESSAGE = (
+ 'smtp',
+ ('${message-id} smtp to $listname for ${#recips} recips, '
+ 'completed in $time seconds'))
+
+# This will only be printed if there were no immediate smtp failures.
+# Mutually exclusive with SMTP_LOG_REFUSED.
+SMTP_LOG_SUCCESS = (
+ 'post',
+ '${message-id} post to $listname from $sender, size=$size, success')
+
+# This will only be printed if there were any addresses which encountered an
+# immediate smtp failure. Mutually exclusive with SMTP_LOG_SUCCESS.
+SMTP_LOG_REFUSED = (
+ 'post',
+ ('${message-id} post to $listname from $sender, size=$size, '
+ '${#refused} failures'))
+
+# This will be logged for each specific recipient failure. Additional %()s
+# keys are:
+#
+# recipient -- the failing recipient address
+# failcode -- the smtp failure code
+# failmsg -- the actual smtp message, if available
+SMTP_LOG_EACH_FAILURE = (
+ 'smtp-failure',
+ ('${message-id} delivery to $recipient failed with code $failcode: '
+ '$failmsg'))
+
+# These variables control the format and frequency of VERP-like delivery for
+# better bounce detection. VERP is Variable Envelope Return Path, defined
+# here:
+#
+# http://cr.yp.to/proto/verp.txt
+#
+# This involves encoding the address of the recipient as we (Mailman) know it
+# into the envelope sender address (i.e. the SMTP `MAIL FROM:' address).
+# Thus, no matter what kind of forwarding the recipient has in place, should
+# it eventually bounce, we will receive an unambiguous notice of the bouncing
+# address.
+#
+# However, we're technically only "VERP-like" because we're doing the envelope
+# sender encoding in Mailman, not in the MTA. We do require cooperation from
+# the MTA, so you must be sure your MTA can be configured for extended address
+# semantics.
+#
+# The first variable describes how to encode VERP envelopes. It must contain
+# these three string interpolations:
+#
+# %(bounces)s -- the list-bounces mailbox will be set here
+# %(mailbox)s -- the recipient's mailbox will be set here
+# %(host)s -- the recipient's host name will be set here
+#
+# This example uses the default below.
+#
+# FQDN list address is: mylist@dom.ain
+# Recipient is: aperson@a.nother.dom
+#
+# The envelope sender will be mylist-bounces+aperson=a.nother.dom@dom.ain
+#
+# Note that your MTA /must/ be configured to deliver such an addressed message
+# to mylist-bounces!
+VERP_DELIMITER = '+'
+VERP_FORMAT = '%(bounces)s+%(mailbox)s=%(host)s'
+
+# The second describes a regular expression to unambiguously decode such an
+# address, which will be placed in the To: header of the bounce message by the
+# bouncing MTA. Getting this right is critical -- and tricky. Learn your
+# Python regular expressions. It must define exactly three named groups,
+# bounces, mailbox and host, with the same definition as above. It will be
+# compiled case-insensitively.
+VERP_REGEXP = r'^(?P[^+]+?)\+(?P[^=]+)=(?P[^@]+)@.*$'
+
+# VERP format and regexp for probe messages
+VERP_PROBE_FORMAT = '%(bounces)s+%(token)s'
+VERP_PROBE_REGEXP = r'^(?P[^+]+?)\+(?P[^@]+)@.*$'
+# Set this Yes to activate VERP probe for disabling by bounce
+VERP_PROBES = No
+
+# A perfect opportunity for doing VERP is the password reminders, which are
+# already addressed individually to each recipient. Set this to Yes to enable
+# VERPs on all password reminders.
+VERP_PASSWORD_REMINDERS = No
+
+# Another good opportunity is when regular delivery is personalized. Here
+# again, we're already incurring the performance hit for addressing each
+# individual recipient. Set this to Yes to enable VERPs on all personalized
+# regular deliveries (personalized digests aren't supported yet).
+VERP_PERSONALIZED_DELIVERIES = No
+
+# And finally, we can VERP normal, non-personalized deliveries. However,
+# because it can be a significant performance hit, we allow you to decide how
+# often to VERP regular deliveries. This is the interval, in number of
+# messages, to do a VERP recipient address. The same variable controls both
+# regular and digest deliveries. Set to 0 to disable occasional VERPs, set to
+# 1 to VERP every delivery, or to some number > 1 for only occasional VERPs.
+VERP_DELIVERY_INTERVAL = 0
+
+# For nicer confirmation emails, use a VERP-like format which encodes the
+# confirmation cookie in the reply address. This lets us put a more user
+# friendly Subject: on the message, but requires cooperation from the MTA.
+# Format is like VERP_FORMAT above, but with the following substitutions:
+#
+# $address -- the list-confirm address
+# $cookie -- the confirmation cookie
+VERP_CONFIRM_FORMAT = '$address+$cookie'
+
+# This is analogous to VERP_REGEXP, but for splitting apart the
+# VERP_CONFIRM_FORMAT. MUAs have been observed that mung
+# From: local_part@host
+# into
+# To: "local_part"
+# when replying, so we skip everything up to '<' if any.
+VERP_CONFIRM_REGEXP = r'^(.*<)?(?P[^+]+?)\+(?P[^@]+)@.*$'
+
+# Set this to Yes to enable VERP-like (more user friendly) confirmations
+VERP_CONFIRMATIONS = No
+
+# This is the maximum number of automatic responses sent to an address because
+# of -request messages or posting hold messages. This limit prevents response
+# loops between Mailman and misconfigured remote email robots. Mailman
+# already inhibits automatic replies to any message labeled with a header
+# "Precendence: bulk|list|junk". This is a fallback safety valve so it should
+# be set fairly high. Set to 0 for no limit (probably useful only for
+# debugging).
+MAX_AUTORESPONSES_PER_DAY = 10
+
+
+
+#####
+# Qrunner defaults
+#####
+
+# Which queues should the qrunner master watchdog spawn? add_qrunner() takes
+# one required argument, which is the name of the qrunner to start
+# (capitalized and without the 'Runner' suffix). Optional second argument
+# specifies the number of parallel processes to fork for each qrunner. If
+# more than one process is used, each will take an equal subdivision of the
+# hash space, so the number must be a power of 2.
+#
+# del_qrunners() takes one argument which is the name of the qrunner not to
+# start. This is used because by default, Mailman starts the Arch, Bounce,
+# Command, Incoming, News, Outgoing, Retry, and Virgin queues.
+#
+# Set this to Yes to use the `Maildir' delivery option. If you change this
+# you will need to re-run bin/genaliases for MTAs that don't use list
+# auto-detection.
+#
+# WARNING: If you want to use Maildir delivery, you /must/ start Mailman's
+# qrunner as root, or you will get permission problems.
+USE_MAILDIR = No
+
+# Set this to Yes to use the `LMTP' delivery option. If you change this
+# you will need to re-run bin/genaliases for MTAs that don't use list
+# auto-detection.
+#
+# You have to set following line in postfix main.cf:
+# transport_maps = hash:/data/transport
+# Also needed is following line if your list is in $mydestination:
+# alias_maps = hash:/etc/aliases, hash:/data/aliases
+USE_LMTP = No
+
+# Name of the domains which operate on LMTP Mailman only. Currently valid
+# only for Postfix alias generation.
+LMTP_ONLY_DOMAINS = []
+
+# If the list is not present in LMTP_ONLY_DOMAINS, LMTPRunner would return
+# 550 response to the master SMTP agent. This may cause 'bounce spam relay'
+# in that a spammer expects to deliver the message as bounce info to the
+# 'From:' address. You can override this behavior by setting
+# LMTP_ERR_550 = '250 Ok. But, blackholed because mailbox unavailable'.
+LMTP_ERR_550 = '550 Requested action not taken: mailbox unavailable'
+
+# WSGI Server.
+#
+# You must enable PROXY of Apache httpd server and configure to pass Mailman
+# CGI requests to this WSGI Server:
+#
+# ProxyPass /mailman/ http://localhost:2580/mailman/
+#
+# Note that local URI part should be the same.
+# XXX If you are running Apache 2.2, you will probably also want to set
+# ProxyPassReverseCookiePath
+#
+# Also you have to add following line to /etc/mailman.cfg
+# add_qrunner('HTTP')
+HTTP_HOST = 'localhost'
+HTTP_PORT = 2580
+
+# After processing every file in the qrunner's slice, how long should the
+# runner sleep for before checking the queue directory again for new files?
+# This can be a fraction of a second, or zero to check immediately
+# (essentially busy-loop as fast as possible).
+QRUNNER_SLEEP_TIME = seconds(1)
+
+# When a message that is unparsable (by the email package) is received, what
+# should we do with it? The most common cause of unparsable messages is
+# broken MIME encapsulation, and the most common cause of that is viruses like
+# Nimda. Set this variable to No to discard such messages, or to Yes to store
+# them in qfiles/bad subdirectory.
+QRUNNER_SAVE_BAD_MESSAGES = Yes
+
+# This flag causes Mailman to fsync() its data files after writing and
+# flushing its contents. While this ensures the data is written to disk,
+# avoiding data loss, it may be a performance killer. Note that this flag
+# affects both message pickles and MailList config.pck files.
+SYNC_AFTER_WRITE = No
+
+# The maximum number of times that the mailmanctl watcher will try to restart
+# a qrunner that exits uncleanly.
+MAX_RESTARTS = 10
+
+
+
+#####
+# General defaults
+#####
+
+# The default language for this server. Whenever we can't figure out the list
+# context or user context, we'll fall back to using this language. This code
+# must be in the list of available language codes.
+DEFAULT_SERVER_LANGUAGE = u'en'
+
+# When allowing only members to post to a mailing list, how is the sender of
+# the message determined? If this variable is set to Yes, then first the
+# message's envelope sender is used, with a fallback to the sender if there is
+# no envelope sender. Set this variable to No to always use the sender.
+#
+# The envelope sender is set by the SMTP delivery and is thus less easily
+# spoofed than the sender, which is typically just taken from the From: header
+# and thus easily spoofed by the end-user. However, sometimes the envelope
+# sender isn't set correctly and this will manifest itself by postings being
+# held for approval even if they appear to come from a list member. If you
+# are having this problem, set this variable to No, but understand that some
+# spoofed messages may get through.
+USE_ENVELOPE_SENDER = No
+
+# Membership tests for posting purposes are usually performed by looking at a
+# set of headers, passing the test if any of their values match a member of
+# the list. Headers are checked in the order given in this variable. The
+# value None means use the From_ (envelope sender) header. Field names are
+# case insensitive.
+SENDER_HEADERS = ('from', None, 'reply-to', 'sender')
+
+# How many members to display at a time on the admin cgi to unsubscribe them
+# or change their options?
+DEFAULT_ADMIN_MEMBER_CHUNKSIZE = 30
+
+# how many bytes of a held message post should be displayed in the admindb web
+# page? Use a negative number to indicate the entire message, regardless of
+# size (though this will slow down rendering those pages).
+ADMINDB_PAGE_TEXT_LIMIT = 4096
+
+# Set this variable to Yes to allow list owners to delete their own mailing
+# lists. You may not want to give them this power, in which case, setting
+# this variable to No instead requires list removal to be done by the site
+# administrator, via the command line script bin/rmlist.
+OWNERS_CAN_DELETE_THEIR_OWN_LISTS = No
+
+# Set this variable to Yes to allow list owners to set the "personalized"
+# flags on their mailing lists. Turning these on tells Mailman to send
+# separate email messages to each user instead of batching them together for
+# delivery to the MTA. This gives each member a more personalized message,
+# but can have a heavy impact on the performance of your system.
+OWNERS_CAN_ENABLE_PERSONALIZATION = No
+
+# Should held messages be saved on disk as Python pickles or as plain text?
+# The former is more efficient since we don't need to go through the
+# parse/generate roundtrip each time, but the latter might be preferred if you
+# want to edit the held message on disk.
+HOLD_MESSAGES_AS_PICKLES = Yes
+
+# This variable controls the order in which list-specific category options are
+# presented in the admin cgi page.
+ADMIN_CATEGORIES = [
+ # First column
+ 'general', 'passwords', 'language', 'members', 'nondigest', 'digest',
+ # Second column
+ 'privacy', 'bounce', 'archive', 'gateway', 'autoreply',
+ 'contentfilter', 'topics',
+ ]
+
+# See "Bitfield for user options" below; make this a sum of those options, to
+# make all new members of lists start with those options flagged. We assume
+# by default that people don't want to receive two copies of posts. Note
+# however that the member moderation flag's initial value is controlled by the
+# list's config variable default_member_moderation.
+DEFAULT_NEW_MEMBER_OPTIONS = 256
+
+# Specify the type of passwords to use, when Mailman generates the passwords
+# itself, as would be the case for membership requests where the user did not
+# fill in a password, or during list creation, when auto-generation of admin
+# passwords was selected.
+#
+# Set this value to Yes for classic Mailman user-friendly(er) passwords.
+# These generate semi-pronounceable passwords which are easier to remember.
+# Set this value to No to use more cryptographically secure, but harder to
+# remember, passwords -- if your operating system and Python version support
+# the necessary feature (specifically that /dev/urandom be available).
+USER_FRIENDLY_PASSWORDS = Yes
+# This value specifies the default lengths of member and list admin passwords
+MEMBER_PASSWORD_LENGTH = 8
+ADMIN_PASSWORD_LENGTH = 10
+
+
+
+#####
+# List defaults. NOTE: Changing these values does NOT change the
+# configuration of an existing list. It only defines the default for new
+# lists you subsequently create.
+#####
+
+# Should a list, by default be advertised? What is the default maximum number
+# of explicit recipients allowed? What is the default maximum message size
+# allowed?
+DEFAULT_LIST_ADVERTISED = Yes
+DEFAULT_MAX_NUM_RECIPIENTS = 10
+DEFAULT_MAX_MESSAGE_SIZE = 40 # KB
+
+# These format strings will be expanded w.r.t. the dictionary for the
+# mailing list instance.
+DEFAULT_SUBJECT_PREFIX = u'[$mlist.real_name] '
+# DEFAULT_SUBJECT_PREFIX = "[$mlist.real_name %%d]" # for numbering
+DEFAULT_MSG_HEADER = u''
+DEFAULT_MSG_FOOTER = u"""\
+_______________________________________________
+$real_name mailing list
+$fqdn_listname
+${listinfo_page}
+"""
+
+# Scrub regular delivery
+DEFAULT_SCRUB_NONDIGEST = False
+
+# Mail command processor will ignore mail command lines after designated max.
+EMAIL_COMMANDS_MAX_LINES = 10
+
+# Is the list owner notified of admin requests immediately by mail, as well as
+# by daily pending-request reminder?
+DEFAULT_ADMIN_IMMED_NOTIFY = Yes
+
+# Is the list owner notified of subscribes/unsubscribes?
+DEFAULT_ADMIN_NOTIFY_MCHANGES = No
+
+# Discard held messages after this days
+DEFAULT_MAX_DAYS_TO_HOLD = 0
+
+# Should list members, by default, have their posts be moderated?
+DEFAULT_DEFAULT_MEMBER_MODERATION = No
+
+# Should non-member posts which are auto-discarded also be forwarded to the
+# moderators?
+DEFAULT_FORWARD_AUTO_DISCARDS = Yes
+
+# What shold happen to non-member posts which are do not match explicit
+# non-member actions?
+# 0 = Accept
+# 1 = Hold
+# 2 = Reject
+# 3 = Discard
+DEFAULT_GENERIC_NONMEMBER_ACTION = 1
+
+# Bounce if 'To:', 'Cc:', or 'Resent-To:' fields don't explicitly name list?
+# This is an anti-spam measure
+DEFAULT_REQUIRE_EXPLICIT_DESTINATION = Yes
+
+# Alternate names acceptable as explicit destinations for this list.
+DEFAULT_ACCEPTABLE_ALIASES = """
+"""
+# For mailing lists that have only other mailing lists for members:
+DEFAULT_UMBRELLA_LIST = No
+
+# For umbrella lists, the suffix for the account part of address for
+# administrative notices (subscription confirmations, password reminders):
+DEFAULT_UMBRELLA_MEMBER_ADMIN_SUFFIX = "-owner"
+
+# This variable controls whether monthly password reminders are sent.
+DEFAULT_SEND_REMINDERS = Yes
+
+# Send welcome messages to new users?
+DEFAULT_SEND_WELCOME_MSG = Yes
+
+# Send goodbye messages to unsubscribed members?
+DEFAULT_SEND_GOODBYE_MSG = Yes
+
+# Wipe sender information, and make it look like the list-admin
+# address sends all messages
+DEFAULT_ANONYMOUS_LIST = No
+
+# {header-name: regexp} spam filtering - we include some for example sake.
+DEFAULT_BOUNCE_MATCHING_HEADERS = u"""
+# Lines that *start* with a '#' are comments.
+to: friend@public.com
+message-id: relay.comanche.denmark.eu
+from: list@listme.com
+from: .*@uplinkpro.com
+"""
+
+# Mailman can be configured to "munge" Reply-To: headers for any passing
+# messages. One the one hand, there are a lot of good reasons not to munge
+# Reply-To: but on the other, people really seem to want this feature. See
+# the help for reply_goes_to_list in the web UI for links discussing the
+# issue.
+# 0 - Reply-To: not munged
+# 1 - Reply-To: set back to the list
+# 2 - Reply-To: set to an explicit value (reply_to_address)
+DEFAULT_REPLY_GOES_TO_LIST = ReplyToMunging.no_munging
+
+# Mailman can be configured to strip any existing Reply-To: header, or simply
+# extend any existing Reply-To: with one based on the above setting.
+DEFAULT_FIRST_STRIP_REPLY_TO = No
+
+# SUBSCRIBE POLICY
+# 0 - open list (only when ALLOW_OPEN_SUBSCRIBE is set to 1) **
+# 1 - confirmation required for subscribes
+# 2 - admin approval required for subscribes
+# 3 - both confirmation and admin approval required
+#
+# ** please do not choose option 0 if you are not allowing open
+# subscribes (next variable)
+DEFAULT_SUBSCRIBE_POLICY = 1
+
+# Does this site allow completely unchecked subscriptions?
+ALLOW_OPEN_SUBSCRIBE = No
+
+# This is the default list of addresses and regular expressions (beginning
+# with ^) that are exempt from approval if SUBSCRIBE_POLICY is 2 or 3.
+DEFAULT_SUBSCRIBE_AUTO_APPROVAL = []
+
+# The default policy for unsubscriptions. 0 (unmoderated unsubscribes) is
+# highly recommended!
+# 0 - unmoderated unsubscribes
+# 1 - unsubscribes require approval
+DEFAULT_UNSUBSCRIBE_POLICY = 0
+
+# Private_roster == 0: anyone can see, 1: members only, 2: admin only.
+DEFAULT_PRIVATE_ROSTER = 1
+
+# When exposing members, make them unrecognizable as email addrs, so
+# web-spiders can't pick up addrs for spam purposes.
+DEFAULT_OBSCURE_ADDRESSES = Yes
+
+# RFC 2369 defines List-* headers which are added to every message sent
+# through to the mailing list membership. These are a very useful aid to end
+# users and should always be added. However, not all MUAs are compliant and
+# if a list's membership has many such users, they may clamor for these
+# headers to be suppressed. By setting this variable to Yes, list owners will
+# be given the option to suppress these headers. By setting it to No, list
+# owners will not be given the option to suppress these headers (although some
+# header suppression may still take place, i.e. for announce-only lists, or
+# lists with no archives).
+ALLOW_RFC2369_OVERRIDES = Yes
+
+# Defaults for content filtering on mailing lists. DEFAULT_FILTER_CONTENT is
+# a flag which if set to true, turns on content filtering.
+DEFAULT_FILTER_CONTENT = No
+
+# DEFAULT_FILTER_MIME_TYPES is a list of MIME types to be removed. This is a
+# list of strings of the format "maintype/subtype" or simply "maintype".
+# E.g. "text/html" strips all html attachments while "image" strips all image
+# types regardless of subtype (jpeg, gif, etc.).
+DEFAULT_FILTER_MIME_TYPES = []
+
+# DEFAULT_PASS_MIME_TYPES is a list of MIME types to be passed through.
+# Format is the same as DEFAULT_FILTER_MIME_TYPES
+DEFAULT_PASS_MIME_TYPES = ['multipart/mixed',
+ 'multipart/alternative',
+ 'text/plain']
+
+# DEFAULT_FILTER_FILENAME_EXTENSIONS is a list of filename extensions to be
+# removed. It is useful because many viruses fake their content-type as
+# harmless ones while keep their extension as executable and expect to be
+# executed when victims 'open' them.
+DEFAULT_FILTER_FILENAME_EXTENSIONS = [
+ 'exe', 'bat', 'cmd', 'com', 'pif', 'scr', 'vbs', 'cpl'
+ ]
+
+# DEFAULT_PASS_FILENAME_EXTENSIONS is a list of filename extensions to be
+# passed through. Format is the same as DEFAULT_FILTER_FILENAME_EXTENSIONS.
+DEFAULT_PASS_FILENAME_EXTENSIONS = []
+
+# Replace multipart/alternative with its first alternative.
+DEFAULT_COLLAPSE_ALTERNATIVES = Yes
+
+# Whether text/html should be converted to text/plain after content filtering
+# is performed. Conversion is done according to HTML_TO_PLAIN_TEXT_COMMAND
+DEFAULT_CONVERT_HTML_TO_PLAINTEXT = Yes
+
+# Default action to take on filtered messages.
+# 0 = Discard, 1 = Reject, 2 = Forward, 3 = Preserve
+DEFAULT_FILTER_ACTION = 0
+
+# Whether to allow list owners to preserve content filtered messages to a
+# special queue on the disk.
+OWNERS_CAN_PRESERVE_FILTERED_MESSAGES = Yes
+
+# Check for administrivia in messages sent to the main list?
+DEFAULT_ADMINISTRIVIA = Yes
+
+
+
+#####
+# Digestification defaults. Same caveat applies here as with list defaults.
+#####
+
+# Will list be available in non-digested form?
+DEFAULT_NONDIGESTABLE = Yes
+
+# Will list be available in digested form?
+DEFAULT_DIGESTABLE = Yes
+DEFAULT_DIGEST_HEADER = u''
+DEFAULT_DIGEST_FOOTER = DEFAULT_MSG_FOOTER
+
+DEFAULT_DIGEST_IS_DEFAULT = No
+DEFAULT_MIME_IS_DEFAULT_DIGEST = No
+DEFAULT_DIGEST_SIZE_THRESHOLD = 30 # KB
+DEFAULT_DIGEST_SEND_PERIODIC = Yes
+
+# Headers which should be kept in both RFC 1153 (plain) and MIME digests. RFC
+# 1153 also specifies these headers in this exact order, so order matters.
+MIME_DIGEST_KEEP_HEADERS = [
+ 'Date', 'From', 'To', 'Cc', 'Subject', 'Message-ID', 'Keywords',
+ # I believe we should also keep these headers though.
+ 'In-Reply-To', 'References', 'Content-Type', 'MIME-Version',
+ 'Content-Transfer-Encoding', 'Precedence', 'Reply-To',
+ # Mailman 2.0 adds these headers
+ 'Message',
+ ]
+
+PLAIN_DIGEST_KEEP_HEADERS = [
+ 'Message', 'Date', 'From',
+ 'Subject', 'To', 'Cc',
+ 'Message-ID', 'Keywords',
+ 'Content-Type',
+ ]
+
+
+
+#####
+# Bounce processing defaults. Same caveat applies here as with list defaults.
+#####
+
+# Should we do any bounced mail response at all?
+DEFAULT_BOUNCE_PROCESSING = Yes
+
+# How often should the bounce qrunner process queued detected bounces?
+REGISTER_BOUNCES_EVERY = minutes(15)
+
+# Bounce processing works like this: when a bounce from a member is received,
+# we look up the `bounce info' for this member. If there is no bounce info,
+# this is the first bounce we've received from this member. In that case, we
+# record today's date, and initialize the bounce score (see below for initial
+# value).
+#
+# If there is existing bounce info for this member, we look at the last bounce
+# receive date. If this date is farther away from today than the `bounce
+# expiration interval', we throw away all the old data and initialize the
+# bounce score as if this were the first bounce from the member.
+#
+# Otherwise, we increment the bounce score. If we can determine whether the
+# bounce was soft or hard (i.e. transient or fatal), then we use a score value
+# of 0.5 for soft bounces and 1.0 for hard bounces. Note that we only score
+# one bounce per day. If the bounce score is then greater than the `bounce
+# threshold' we disable the member's address.
+#
+# After disabling the address, we can send warning messages to the member,
+# providing a confirmation cookie/url for them to use to re-enable their
+# delivery. After a configurable period of time, we'll delete the address.
+# When we delete the address due to bouncing, we'll send one last message to
+# the member.
+
+# Bounce scores greater than this value get disabled.
+DEFAULT_BOUNCE_SCORE_THRESHOLD = 5.0
+
+# Bounce information older than this interval is considered stale, and is
+# discarded.
+DEFAULT_BOUNCE_INFO_STALE_AFTER = days(7)
+
+# The number of notifications to send to the disabled/removed member before we
+# remove them from the list. A value of 0 means we remove the address
+# immediately (with one last notification). Note that the first one is sent
+# upon change of status to disabled.
+DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS = 3
+
+# The interval of time between disabled warnings.
+DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS_INTERVAL = days(7)
+
+# Does the list owner get messages to the -bounces (and -admin) address that
+# failed to match by the bounce detector?
+DEFAULT_BOUNCE_UNRECOGNIZED_GOES_TO_LIST_OWNER = Yes
+
+# Notifications on bounce actions. The first specifies whether the list owner
+# should get a notification when a member is disabled due to bouncing, while
+# the second specifies whether the owner should get one when the member is
+# removed due to bouncing.
+DEFAULT_BOUNCE_NOTIFY_OWNER_ON_DISABLE = Yes
+DEFAULT_BOUNCE_NOTIFY_OWNER_ON_REMOVAL = Yes
+
+
+
+#####
+# General time limits
+#####
+
+# Default length of time a pending request is live before it is evicted from
+# the pending database.
+PENDING_REQUEST_LIFE = days(3)
+
+# How long should messages which have delivery failures continue to be
+# retried? After this period of time, a message that has failed recipients
+# will be dequeued and those recipients will never receive the message.
+DELIVERY_RETRY_PERIOD = days(5)
+
+# How long should we wait before we retry a temporary delivery failure?
+DELIVERY_RETRY_WAIT = hours(1)
+
+
+
+#####
+# Lock management defaults
+#####
+
+# These variables control certain aspects of lock acquisition and retention.
+# They should be tuned as appropriate for your environment. All variables are
+# specified in units of floating point seconds. YOU MAY NEED TO TUNE THESE
+# VARIABLES DEPENDING ON THE SIZE OF YOUR LISTS, THE PERFORMANCE OF YOUR
+# HARDWARE, NETWORK AND GENERAL MAIL HANDLING CAPABILITIES, ETC.
+
+# This variable specifies how long the lock will be retained for a specific
+# operation on a mailing list. Watch your logs/lock file and if you see a lot
+# of lock breakages, you might need to bump this up. However if you set this
+# too high, a faulty script (or incorrect use of bin/withlist) can prevent the
+# list from being used until the lifetime expires. This is probably one of
+# the most crucial tuning variables in the system.
+LIST_LOCK_LIFETIME = hours(5)
+
+# This variable specifies how long an attempt will be made to acquire a list
+# lock by the incoming qrunner process. If the lock acquisition times out,
+# the message will be re-queued for later delivery.
+LIST_LOCK_TIMEOUT = seconds(10)
+
+# Set this to On to turn on lock debugging messages for the pending requests
+# database, which will be written to logs/locks. If you think you're having
+# lock problems, or just want to tune the locks for your system, turn on lock
+# debugging.
+PENDINGDB_LOCK_DEBUGGING = Off
+
+
+
+#####
+# Nothing below here is user configurable. Most of these values are in this
+# file for internal system convenience. Don't change any of them or override
+# any of them in your mailman.cfg file!
+#####
+
+# Enumeration for Mailman cgi widget types
+Toggle = 1
+Radio = 2
+String = 3
+Text = 4
+Email = 5
+EmailList = 6
+Host = 7
+Number = 8
+FileUpload = 9
+Select = 10
+Topics = 11
+Checkbox = 12
+# An "extended email list". Contents must be an email address or a ^-prefixed
+# regular expression. Used in the sender moderation text boxes.
+EmailListEx = 13
+# Extended spam filter widget
+HeaderFilter = 14
+
+# Actions
+DEFER = 0
+APPROVE = 1
+REJECT = 2
+DISCARD = 3
+SUBSCRIBE = 4
+UNSUBSCRIBE = 5
+ACCEPT = 6
+HOLD = 7
+
+# Standard text field width
+TEXTFIELDWIDTH = 40
+
+# Bitfield for user options. See DEFAULT_NEW_MEMBER_OPTIONS above to set
+# defaults for all new lists.
+Digests = 0 # handled by other mechanism, doesn't need a flag.
+DisableDelivery = 1 # Obsolete; use set/getDeliveryStatus()
+DontReceiveOwnPosts = 2 # Non-digesters only
+AcknowledgePosts = 4
+DisableMime = 8 # Digesters only
+ConcealSubscription = 16
+SuppressPasswordReminder = 32
+ReceiveNonmatchingTopics = 64
+Moderate = 128
+DontReceiveDuplicates = 256
+
+
+# A mapping between short option tags and their flag
+OPTINFO = {'hide' : ConcealSubscription,
+ 'nomail' : DisableDelivery,
+ 'ack' : AcknowledgePosts,
+ 'notmetoo': DontReceiveOwnPosts,
+ 'digest' : 0,
+ 'plain' : DisableMime,
+ 'nodupes' : DontReceiveDuplicates
+ }
+
+# Authentication contexts.
+#
+# Mailman defines the following roles:
+
+# - User, a normal user who has no permissions except to change their personal
+# option settings
+# - List creator, someone who can create and delete lists, but cannot
+# (necessarily) configure the list.
+# - List moderator, someone who can tend to pending requests such as
+# subscription requests, or held messages
+# - List administrator, someone who has total control over a list, can
+# configure it, modify user options for members of the list, subscribe and
+# unsubscribe members, etc.
+# - Site administrator, someone who has total control over the entire site and
+# can do any of the tasks mentioned above. This person usually also has
+# command line access.
+
+UnAuthorized = 0
+AuthUser = 1 # Joe Shmoe User
+AuthCreator = 2 # List Creator / Destroyer
+AuthListAdmin = 3 # List Administrator (total control over list)
+AuthListModerator = 4 # List Moderator (can only handle held requests)
+AuthSiteAdmin = 5 # Site Administrator (total control over everything)
+
+
+
+# Vgg: Language descriptions and charsets dictionary, any new supported
+# language must have a corresponding entry here. Key is the name of the
+# directories that hold the localized texts. Data are tuples with first
+# element being the description, as described in the catalogs, and second
+# element is the language charset. I have chosen code from /usr/share/locale
+# in my GNU/Linux. :-)
+#
+# TK: Now the site admin can select languages for the installation from those
+# in the distribution tarball. We don't touch add_language() function for
+# backward compatibility. You may have to add your own language in your
+# mailman.cfg file, if it is not included in the distribution even if you had
+# put language files in source directory and configured by `--with-languages'
+# option.
+def _(s):
+ return s
+
+_DEFAULT_LANGUAGE_DATA = {
+ 'ar': (_('Arabic'), 'utf-8'),
+ 'ca': (_('Catalan'), 'iso-8859-1'),
+ 'cs': (_('Czech'), 'iso-8859-2'),
+ 'da': (_('Danish'), 'iso-8859-1'),
+ 'de': (_('German'), 'iso-8859-1'),
+ 'en': (_('English (USA)'), 'us-ascii'),
+ 'es': (_('Spanish (Spain)'), 'iso-8859-1'),
+ 'et': (_('Estonian'), 'iso-8859-15'),
+ 'eu': (_('Euskara'), 'iso-8859-15'), # Basque
+ 'fi': (_('Finnish'), 'iso-8859-1'),
+ 'fr': (_('French'), 'iso-8859-1'),
+ 'hr': (_('Croatian'), 'iso-8859-2'),
+ 'hu': (_('Hungarian'), 'iso-8859-2'),
+ 'ia': (_('Interlingua'), 'iso-8859-15'),
+ 'it': (_('Italian'), 'iso-8859-1'),
+ 'ja': (_('Japanese'), 'euc-jp'),
+ 'ko': (_('Korean'), 'euc-kr'),
+ 'lt': (_('Lithuanian'), 'iso-8859-13'),
+ 'nl': (_('Dutch'), 'iso-8859-1'),
+ 'no': (_('Norwegian'), 'iso-8859-1'),
+ 'pl': (_('Polish'), 'iso-8859-2'),
+ 'pt': (_('Portuguese'), 'iso-8859-1'),
+ 'pt_BR': (_('Portuguese (Brazil)'), 'iso-8859-1'),
+ 'ro': (_('Romanian'), 'iso-8859-2'),
+ 'ru': (_('Russian'), 'koi8-r'),
+ 'sr': (_('Serbian'), 'utf-8'),
+ 'sl': (_('Slovenian'), 'iso-8859-2'),
+ 'sv': (_('Swedish'), 'iso-8859-1'),
+ 'tr': (_('Turkish'), 'iso-8859-9'),
+ 'uk': (_('Ukrainian'), 'utf-8'),
+ 'vi': (_('Vietnamese'), 'utf-8'),
+ 'zh_CN': (_('Chinese (China)'), 'utf-8'),
+ 'zh_TW': (_('Chinese (Taiwan)'), 'utf-8'),
+}
+
+
+del _
diff --git a/src/mailman/attic/Deliverer.py b/src/mailman/attic/Deliverer.py
new file mode 100644
index 000000000..0ba3a01bb
--- /dev/null
+++ b/src/mailman/attic/Deliverer.py
@@ -0,0 +1,174 @@
+# Copyright (C) 1998-2009 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 .
+
+
+"""Mixin class with message delivery routines."""
+
+from __future__ import with_statement
+
+import logging
+
+from email.MIMEMessage import MIMEMessage
+from email.MIMEText import MIMEText
+
+from mailman import Errors
+from mailman import Message
+from mailman import Utils
+from mailman import i18n
+from mailman.configuration import config
+
+_ = i18n._
+
+log = logging.getLogger('mailman.error')
+mlog = logging.getLogger('mailman.mischief')
+
+
+
+class Deliverer:
+ def MailUserPassword(self, user):
+ listfullname = self.fqdn_listname
+ requestaddr = self.GetRequestEmail()
+ # find the lowercased version of the user's address
+ adminaddr = self.GetBouncesEmail()
+ assert self.isMember(user)
+ if not self.getMemberPassword(user):
+ # The user's password somehow got corrupted. Generate a new one
+ # for him, after logging this bogosity.
+ log.error('User %s had a false password for list %s',
+ user, self.internal_name())
+ waslocked = self.Locked()
+ if not waslocked:
+ self.Lock()
+ try:
+ self.setMemberPassword(user, Utils.MakeRandomPassword())
+ self.Save()
+ finally:
+ if not waslocked:
+ self.Unlock()
+ # Now send the user his password
+ cpuser = self.getMemberCPAddress(user)
+ recipient = self.GetMemberAdminEmail(cpuser)
+ subject = _('%(listfullname)s mailing list reminder')
+ # Get user's language and charset
+ lang = self.getMemberLanguage(user)
+ cset = Utils.GetCharSet(lang)
+ password = self.getMemberPassword(user)
+ # TK: Make unprintables to ?
+ # The list owner should allow users to set language options if they
+ # want to use non-us-ascii characters in password and send it back.
+ password = unicode(password, cset, 'replace').encode(cset, 'replace')
+ # get the text from the template
+ text = Utils.maketext(
+ 'userpass.txt',
+ {'user' : cpuser,
+ 'listname' : self.real_name,
+ 'fqdn_lname' : self.GetListEmail(),
+ 'password' : password,
+ 'options_url': self.GetOptionsURL(user, absolute=True),
+ 'requestaddr': requestaddr,
+ 'owneraddr' : self.GetOwnerEmail(),
+ }, lang=lang, mlist=self)
+ msg = Message.UserNotification(recipient, adminaddr, subject, text,
+ lang)
+ msg['X-No-Archive'] = 'yes'
+ msg.send(self, verp=config.VERP_PERSONALIZED_DELIVERIES)
+
+ def ForwardMessage(self, msg, text=None, subject=None, tomoderators=True):
+ # Wrap the message as an attachment
+ if text is None:
+ text = _('No reason given')
+ if subject is None:
+ text = _('(no subject)')
+ text = MIMEText(Utils.wrap(text),
+ _charset=Utils.GetCharSet(self.preferred_language))
+ attachment = MIMEMessage(msg)
+ notice = Message.OwnerNotification(
+ self, subject, tomoderators=tomoderators)
+ # Make it look like the message is going to the -owner address
+ notice.set_type('multipart/mixed')
+ notice.attach(text)
+ notice.attach(attachment)
+ notice.send(self)
+
+ def SendHostileSubscriptionNotice(self, listname, address):
+ # Some one was invited to one list but tried to confirm to a different
+ # list. We inform both list owners of the bogosity, but be careful
+ # not to reveal too much information.
+ selfname = self.internal_name()
+ mlog.error('%s was invited to %s but confirmed to %s',
+ address, listname, selfname)
+ # First send a notice to the attacked list
+ msg = Message.OwnerNotification(
+ self,
+ _('Hostile subscription attempt detected'),
+ Utils.wrap(_("""%(address)s was invited to a different mailing
+list, but in a deliberate malicious attempt they tried to confirm the
+invitation to your list. We just thought you'd like to know. No further
+action by you is required.""")))
+ msg.send(self)
+ # Now send a notice to the invitee list
+ try:
+ # Avoid import loops
+ from mailman.MailList import MailList
+ mlist = MailList(listname, lock=False)
+ except Errors.MMListError:
+ # Oh well
+ return
+ with i18n.using_language(mlist.preferred_language):
+ msg = Message.OwnerNotification(
+ mlist,
+ _('Hostile subscription attempt detected'),
+ Utils.wrap(_("""You invited %(address)s to your list, but in a
+deliberate malicious attempt, they tried to confirm the invitation to a
+different list. We just thought you'd like to know. No further action by you
+is required.""")))
+ msg.send(mlist)
+
+ def sendProbe(self, member, msg):
+ listname = self.real_name
+ # Put together the substitution dictionary.
+ d = {'listname': listname,
+ 'address': member,
+ 'optionsurl': self.GetOptionsURL(member, absolute=True),
+ 'owneraddr': self.GetOwnerEmail(),
+ }
+ text = Utils.maketext('probe.txt', d,
+ lang=self.getMemberLanguage(member),
+ mlist=self)
+ # Calculate the VERP'd sender address for bounce processing of the
+ # probe message.
+ token = self.pend_new(Pending.PROBE_BOUNCE, member, msg)
+ probedict = {
+ 'bounces': self.internal_name() + '-bounces',
+ 'token': token,
+ }
+ probeaddr = '%s@%s' % ((config.VERP_PROBE_FORMAT % probedict),
+ self.host_name)
+ # Calculate the Subject header, in the member's preferred language
+ ulang = self.getMemberLanguage(member)
+ with i18n.using_language(ulang):
+ subject = _('%(listname)s mailing list probe message')
+ outer = Message.UserNotification(member, probeaddr, subject,
+ lang=ulang)
+ outer.set_type('multipart/mixed')
+ text = MIMEText(text, _charset=Utils.GetCharSet(ulang))
+ outer.attach(text)
+ outer.attach(MIMEMessage(msg))
+ # Turn off further VERP'ing in the final delivery step. We set
+ # probe_token for the OutgoingRunner to more easily handling local
+ # rejects of probe messages.
+ outer.send(self, envsender=probeaddr, verp=False, probe_token=token)
diff --git a/src/mailman/attic/Digester.py b/src/mailman/attic/Digester.py
new file mode 100644
index 000000000..a88d08abc
--- /dev/null
+++ b/src/mailman/attic/Digester.py
@@ -0,0 +1,57 @@
+# Copyright (C) 1998-2009 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 .
+
+
+"""Mixin class with list-digest handling methods and settings."""
+
+import os
+import errno
+
+from mailman import Errors
+from mailman import Utils
+from mailman.Handlers import ToDigest
+from mailman.configuration import config
+from mailman.i18n import _
+
+
+
+class Digester:
+ def send_digest_now(self):
+ # Note: Handler.ToDigest.send_digests() handles bumping the digest
+ # volume and issue number.
+ digestmbox = os.path.join(self.fullpath(), 'digest.mbox')
+ try:
+ try:
+ mboxfp = None
+ # See if there's a digest pending for this mailing list
+ if os.stat(digestmbox).st_size > 0:
+ mboxfp = open(digestmbox)
+ ToDigest.send_digests(self, mboxfp)
+ os.unlink(digestmbox)
+ finally:
+ if mboxfp:
+ mboxfp.close()
+ except OSError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ # List has no outstanding digests
+ return False
+ return True
+
+ def bump_digest_volume(self):
+ self.volume += 1
+ self.next_digest_number = 1
diff --git a/src/mailman/attic/MailList.py b/src/mailman/attic/MailList.py
new file mode 100644
index 000000000..2d538f026
--- /dev/null
+++ b/src/mailman/attic/MailList.py
@@ -0,0 +1,731 @@
+# Copyright (C) 1998-2009 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 .
+
+
+"""The class representing a Mailman mailing list.
+
+Mixes in many task-specific classes.
+"""
+
+from __future__ import with_statement
+
+import os
+import re
+import sys
+import time
+import errno
+import shutil
+import socket
+import urllib
+import cPickle
+import logging
+import marshal
+import email.Iterators
+
+from UserDict import UserDict
+from cStringIO import StringIO
+from string import Template
+from types import MethodType
+from urlparse import urlparse
+from zope.interface import implements
+
+from email.Header import Header
+from email.Utils import getaddresses, formataddr, parseaddr
+
+from Mailman import Errors
+from Mailman import Utils
+from Mailman import Version
+from Mailman import database
+from Mailman.UserDesc import UserDesc
+from Mailman.configuration import config
+from Mailman.interfaces import *
+
+# Base classes
+from Mailman.Archiver import Archiver
+from Mailman.Bouncer import Bouncer
+from Mailman.Digester import Digester
+from Mailman.SecurityManager import SecurityManager
+
+# GUI components package
+from Mailman import Gui
+
+# Other useful classes
+from Mailman import i18n
+from Mailman import MemberAdaptor
+from Mailman import Message
+
+_ = i18n._
+
+DOT = '.'
+EMPTYSTRING = ''
+OR = '|'
+
+clog = logging.getLogger('mailman.config')
+elog = logging.getLogger('mailman.error')
+vlog = logging.getLogger('mailman.vette')
+slog = logging.getLogger('mailman.subscribe')
+
+
+
+# Use mixins here just to avoid having any one chunk be too large.
+class MailList(object, Archiver, Digester, SecurityManager, Bouncer):
+
+ implements(
+ IMailingList,
+ IMailingListAddresses,
+ IMailingListIdentity,
+ IMailingListRosters,
+ )
+
+ def __init__(self, data):
+ self._data = data
+ # Only one level of mixin inheritance allowed.
+ for baseclass in self.__class__.__bases__:
+ if hasattr(baseclass, '__init__'):
+ baseclass.__init__(self)
+ # Initialize the web u/i components.
+ self._gui = []
+ for component in dir(Gui):
+ if component.startswith('_'):
+ continue
+ self._gui.append(getattr(Gui, component)())
+ # Give the extension mechanism a chance to process this list.
+ try:
+ from Mailman.ext import init_mlist
+ except ImportError:
+ pass
+ else:
+ init_mlist(self)
+
+ def __getattr__(self, name):
+ missing = object()
+ if name.startswith('_'):
+ return getattr(super(MailList, self), name)
+ # Delegate to the database model object if it has the attribute.
+ obj = getattr(self._data, name, missing)
+ if obj is not missing:
+ return obj
+ # Finally, delegate to one of the gui components.
+ for guicomponent in self._gui:
+ obj = getattr(guicomponent, name, missing)
+ if obj is not missing:
+ return obj
+ # Nothing left to delegate to, so it's got to be an error.
+ raise AttributeError(name)
+
+ def __repr__(self):
+ return '' % (self.fqdn_listname, id(self))
+
+
+ def GetConfirmJoinSubject(self, listname, cookie):
+ if config.VERP_CONFIRMATIONS and cookie:
+ cset = i18n.get_translation().charset() or \
+ Utils.GetCharSet(self.preferred_language)
+ subj = Header(
+ _('Your confirmation is required to join the %(listname)s mailing list'),
+ cset, header_name='subject')
+ return subj
+ else:
+ return 'confirm ' + cookie
+
+ def GetConfirmLeaveSubject(self, listname, cookie):
+ if config.VERP_CONFIRMATIONS and cookie:
+ cset = i18n.get_translation().charset() or \
+ Utils.GetCharSet(self.preferred_language)
+ subj = Header(
+ _('Your confirmation is required to leave the %(listname)s mailing list'),
+ cset, header_name='subject')
+ return subj
+ else:
+ return 'confirm ' + cookie
+
+ def GetMemberAdminEmail(self, member):
+ """Usually the member addr, but modified for umbrella lists.
+
+ Umbrella lists have other mailing lists as members, and so admin stuff
+ like confirmation requests and passwords must not be sent to the
+ member addresses - the sublists - but rather to the administrators of
+ the sublists. This routine picks the right address, considering
+ regular member address to be their own administrative addresses.
+
+ """
+ if not self.umbrella_list:
+ return member
+ else:
+ acct, host = tuple(member.split('@'))
+ return "%s%s@%s" % (acct, self.umbrella_member_suffix, host)
+
+ def GetScriptURL(self, target, absolute=False):
+ if absolute:
+ return self.web_page_url + target + '/' + self.fqdn_listname
+ else:
+ return Utils.ScriptURL(target) + '/' + self.fqdn_listname
+
+ def GetOptionsURL(self, user, obscure=False, absolute=False):
+ url = self.GetScriptURL('options', absolute)
+ if obscure:
+ user = Utils.ObscureEmail(user)
+ return '%s/%s' % (url, urllib.quote(user.lower()))
+
+
+ #
+ # Web API support via administrative categories
+ #
+ def GetConfigCategories(self):
+ class CategoryDict(UserDict):
+ def __init__(self):
+ UserDict.__init__(self)
+ self.keysinorder = config.ADMIN_CATEGORIES[:]
+ def keys(self):
+ return self.keysinorder
+ def items(self):
+ items = []
+ for k in config.ADMIN_CATEGORIES:
+ items.append((k, self.data[k]))
+ return items
+ def values(self):
+ values = []
+ for k in config.ADMIN_CATEGORIES:
+ values.append(self.data[k])
+ return values
+
+ categories = CategoryDict()
+ # Only one level of mixin inheritance allowed
+ for gui in self._gui:
+ k, v = gui.GetConfigCategory()
+ categories[k] = (v, gui)
+ return categories
+
+ def GetConfigSubCategories(self, category):
+ for gui in self._gui:
+ if hasattr(gui, 'GetConfigSubCategories'):
+ # Return the first one that knows about the given subcategory
+ subcat = gui.GetConfigSubCategories(category)
+ if subcat is not None:
+ return subcat
+ return None
+
+ def GetConfigInfo(self, category, subcat=None):
+ for gui in self._gui:
+ if hasattr(gui, 'GetConfigInfo'):
+ value = gui.GetConfigInfo(self, category, subcat)
+ if value:
+ return value
+
+
+ #
+ # Membership management front-ends and assertion checks
+ #
+ def InviteNewMember(self, userdesc, text=''):
+ """Invite a new member to the list.
+
+ This is done by creating a subscription pending for the user, and then
+ crafting a message to the member informing them of the invitation.
+ """
+ invitee = userdesc.address
+ Utils.ValidateEmail(invitee)
+ # check for banned address
+ pattern = Utils.get_pattern(invitee, self.ban_list)
+ if pattern:
+ raise Errors.MembershipIsBanned(pattern)
+ # Hack alert! Squirrel away a flag that only invitations have, so
+ # that we can do something slightly different when an invitation
+ # subscription is confirmed. In those cases, we don't need further
+ # admin approval, even if the list is so configured. The flag is the
+ # list name to prevent invitees from cross-subscribing.
+ userdesc.invitation = self.internal_name()
+ cookie = self.pend_new(Pending.SUBSCRIPTION, userdesc)
+ requestaddr = self.getListAddress('request')
+ confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1),
+ cookie)
+ listname = self.real_name
+ text += Utils.maketext(
+ 'invite.txt',
+ {'email' : invitee,
+ 'listname' : listname,
+ 'hostname' : self.host_name,
+ 'confirmurl' : confirmurl,
+ 'requestaddr': requestaddr,
+ 'cookie' : cookie,
+ 'listowner' : self.GetOwnerEmail(),
+ }, mlist=self)
+ sender = self.GetRequestEmail(cookie)
+ msg = Message.UserNotification(
+ invitee, sender,
+ text=text, lang=self.preferred_language)
+ subj = self.GetConfirmJoinSubject(listname, cookie)
+ del msg['subject']
+ msg['Subject'] = subj
+ msg.send(self)
+
+ def AddMember(self, userdesc, remote=None):
+ """Front end to member subscription.
+
+ This method enforces subscription policy, validates values, sends
+ notifications, and any other grunt work involved in subscribing a
+ user. It eventually calls ApprovedAddMember() to do the actual work
+ of subscribing the user.
+
+ userdesc is an instance with the following public attributes:
+
+ address -- the unvalidated email address of the member
+ fullname -- the member's full name (i.e. John Smith)
+ digest -- a flag indicating whether the user wants digests or not
+ language -- the requested default language for the user
+ password -- the user's password
+
+ Other attributes may be defined later. Only address is required; the
+ others all have defaults (fullname='', digests=0, language=list's
+ preferred language, password=generated).
+
+ remote is a string which describes where this add request came from.
+ """
+ assert self.Locked()
+ # Suck values out of userdesc, apply defaults, and reset the userdesc
+ # attributes (for passing on to ApprovedAddMember()). Lowercase the
+ # addr's domain part.
+ email = Utils.LCDomain(userdesc.address)
+ name = getattr(userdesc, 'fullname', '')
+ lang = getattr(userdesc, 'language', self.preferred_language)
+ digest = getattr(userdesc, 'digest', None)
+ password = getattr(userdesc, 'password', Utils.MakeRandomPassword())
+ if digest is None:
+ if self.nondigestable:
+ digest = 0
+ else:
+ digest = 1
+ # Validate the e-mail address to some degree.
+ Utils.ValidateEmail(email)
+ if self.isMember(email):
+ raise Errors.MMAlreadyAMember, email
+ if email.lower() == self.GetListEmail().lower():
+ # Trying to subscribe the list to itself!
+ raise Errors.InvalidEmailAddress
+ realname = self.real_name
+ # Is the subscribing address banned from this list?
+ pattern = Utils.get_pattern(email, self.ban_list)
+ if pattern:
+ vlog.error('%s banned subscription: %s (matched: %s)',
+ realname, email, pattern)
+ raise Errors.MembershipIsBanned, pattern
+ # Sanity check the digest flag
+ if digest and not self.digestable:
+ raise Errors.MMCantDigestError
+ elif not digest and not self.nondigestable:
+ raise Errors.MMMustDigestError
+
+ userdesc.address = email
+ userdesc.fullname = name
+ userdesc.digest = digest
+ userdesc.language = lang
+ userdesc.password = password
+
+ # Apply the list's subscription policy. 0 means open subscriptions; 1
+ # means the user must confirm; 2 means the admin must approve; 3 means
+ # the user must confirm and then the admin must approve
+ if self.subscribe_policy == 0:
+ self.ApprovedAddMember(userdesc, whence=remote or '')
+ elif self.subscribe_policy == 1 or self.subscribe_policy == 3:
+ # User confirmation required. BAW: this should probably just
+ # accept a userdesc instance.
+ cookie = self.pend_new(Pending.SUBSCRIPTION, userdesc)
+ # Send the user the confirmation mailback
+ if remote is None:
+ by = remote = ''
+ else:
+ by = ' ' + remote
+ remote = _(' from %(remote)s')
+
+ recipient = self.GetMemberAdminEmail(email)
+ confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1),
+ cookie)
+ text = Utils.maketext(
+ 'verify.txt',
+ {'email' : email,
+ 'listaddr' : self.GetListEmail(),
+ 'listname' : realname,
+ 'cookie' : cookie,
+ 'requestaddr' : self.getListAddress('request'),
+ 'remote' : remote,
+ 'listadmin' : self.GetOwnerEmail(),
+ 'confirmurl' : confirmurl,
+ }, lang=lang, mlist=self)
+ msg = Message.UserNotification(
+ recipient, self.GetRequestEmail(cookie),
+ text=text, lang=lang)
+ # BAW: See ChangeMemberAddress() for why we do it this way...
+ del msg['subject']
+ msg['Subject'] = self.GetConfirmJoinSubject(realname, cookie)
+ msg['Reply-To'] = self.GetRequestEmail(cookie)
+ msg.send(self)
+ who = formataddr((name, email))
+ slog.info('%s: pending %s %s', self.internal_name(), who, by)
+ raise Errors.MMSubscribeNeedsConfirmation
+ elif self.HasAutoApprovedSender(email):
+ # no approval necessary:
+ self.ApprovedAddMember(userdesc)
+ else:
+ # Subscription approval is required. Add this entry to the admin
+ # requests database. BAW: this should probably take a userdesc
+ # just like above.
+ self.HoldSubscription(email, name, password, digest, lang)
+ raise Errors.MMNeedApproval, _(
+ 'subscriptions to %(realname)s require moderator approval')
+
+ def DeleteMember(self, name, whence=None, admin_notif=None, userack=True):
+ realname, email = parseaddr(name)
+ if self.unsubscribe_policy == 0:
+ self.ApprovedDeleteMember(name, whence, admin_notif, userack)
+ else:
+ self.HoldUnsubscription(email)
+ raise Errors.MMNeedApproval, _(
+ 'unsubscriptions require moderator approval')
+
+ def ChangeMemberAddress(self, oldaddr, newaddr, globally):
+ # Changing a member address consists of verifying the new address,
+ # making sure the new address isn't already a member, and optionally
+ # going through the confirmation process.
+ #
+ # Most of these checks are copied from AddMember
+ newaddr = Utils.LCDomain(newaddr)
+ Utils.ValidateEmail(newaddr)
+ # Raise an exception if this email address is already a member of the
+ # list, but only if the new address is the same case-wise as the old
+ # address and we're not doing a global change.
+ if not globally and newaddr == oldaddr and self.isMember(newaddr):
+ raise Errors.MMAlreadyAMember
+ if newaddr == self.GetListEmail().lower():
+ raise Errors.InvalidEmailAddress
+ realname = self.real_name
+ # Don't allow changing to a banned address. MAS: maybe we should
+ # unsubscribe the oldaddr too just for trying, but that's probably
+ # too harsh.
+ pattern = Utils.get_pattern(newaddr, self.ban_list)
+ if pattern:
+ vlog.error('%s banned address change: %s -> %s (matched: %s)',
+ realname, oldaddr, newaddr, pattern)
+ raise Errors.MembershipIsBanned, pattern
+ # Pend the subscription change
+ cookie = self.pend_new(Pending.CHANGE_OF_ADDRESS,
+ oldaddr, newaddr, globally)
+ confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1),
+ cookie)
+ lang = self.getMemberLanguage(oldaddr)
+ text = Utils.maketext(
+ 'verify.txt',
+ {'email' : newaddr,
+ 'listaddr' : self.GetListEmail(),
+ 'listname' : realname,
+ 'cookie' : cookie,
+ 'requestaddr': self.getListAddress('request'),
+ 'remote' : '',
+ 'listadmin' : self.GetOwnerEmail(),
+ 'confirmurl' : confirmurl,
+ }, lang=lang, mlist=self)
+ # BAW: We don't pass the Subject: into the UserNotification
+ # constructor because it will encode it in the charset of the language
+ # being used. For non-us-ascii charsets, this means it will probably
+ # quopri quote it, and thus replies will also be quopri encoded. But
+ # CommandRunner doesn't yet grok such headers. So, just set the
+ # Subject: in a separate step, although we have to delete the one
+ # UserNotification adds.
+ msg = Message.UserNotification(
+ newaddr, self.GetRequestEmail(cookie),
+ text=text, lang=lang)
+ del msg['subject']
+ msg['Subject'] = self.GetConfirmJoinSubject(realname, cookie)
+ msg['Reply-To'] = self.GetRequestEmail(cookie)
+ msg.send(self)
+
+ def ApprovedChangeMemberAddress(self, oldaddr, newaddr, globally):
+ # Check here for banned address in case address was banned after
+ # confirmation was mailed. MAS: If it's global change should we just
+ # skip this list and proceed to the others? For now we'll throw the
+ # exception.
+ pattern = Utils.get_pattern(newaddr, self.ban_list)
+ if pattern:
+ raise Errors.MembershipIsBanned, pattern
+ # It's possible they were a member of this list, but choose to change
+ # their membership globally. In that case, we simply remove the old
+ # address.
+ if self.getMemberCPAddress(oldaddr) == newaddr:
+ self.removeMember(oldaddr)
+ else:
+ self.changeMemberAddress(oldaddr, newaddr)
+ self.log_and_notify_admin(oldaddr, newaddr)
+ # If globally is true, then we also include every list for which
+ # oldaddr is a member.
+ if not globally:
+ return
+ for listname in config.list_manager.names:
+ # Don't bother with ourselves
+ if listname == self.internal_name():
+ continue
+ mlist = MailList(listname, lock=0)
+ if mlist.host_name <> self.host_name:
+ continue
+ if not mlist.isMember(oldaddr):
+ continue
+ # If new address is banned from this list, just skip it.
+ if Utils.get_pattern(newaddr, mlist.ban_list):
+ continue
+ mlist.Lock()
+ try:
+ # Same logic as above, re newaddr is already a member
+ if mlist.getMemberCPAddress(oldaddr) == newaddr:
+ mlist.removeMember(oldaddr)
+ else:
+ mlist.changeMemberAddress(oldaddr, newaddr)
+ mlist.log_and_notify_admin(oldaddr, newaddr)
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+ def log_and_notify_admin(self, oldaddr, newaddr):
+ """Log member address change and notify admin if requested."""
+ slog.info('%s: changed member address from %s to %s',
+ self.internal_name(), oldaddr, newaddr)
+ if self.admin_notify_mchanges:
+ with i18n.using_language(self.preferred_language):
+ realname = self.real_name
+ subject = _('%(realname)s address change notification')
+ name = self.getMemberName(newaddr)
+ if name is None:
+ name = ''
+ if isinstance(name, unicode):
+ name = name.encode(Utils.GetCharSet(self.preferred_language),
+ 'replace')
+ text = Utils.maketext(
+ 'adminaddrchgack.txt',
+ {'name' : name,
+ 'oldaddr' : oldaddr,
+ 'newaddr' : newaddr,
+ 'listname': self.real_name,
+ }, mlist=self)
+ msg = Message.OwnerNotification(self, subject, text)
+ msg.send(self)
+
+
+ #
+ # Confirmation processing
+ #
+ def ProcessConfirmation(self, cookie, context=None):
+ rec = self.pend_confirm(cookie)
+ if rec is None:
+ raise Errors.MMBadConfirmation, 'No cookie record for %s' % cookie
+ try:
+ op = rec[0]
+ data = rec[1:]
+ except ValueError:
+ raise Errors.MMBadConfirmation, 'op-less data %s' % (rec,)
+ if op == Pending.SUBSCRIPTION:
+ whence = 'via email confirmation'
+ try:
+ userdesc = data[0]
+ # If confirmation comes from the web, context should be a
+ # UserDesc instance which contains overrides of the original
+ # subscription information. If it comes from email, then
+ # context is a Message and isn't relevant, so ignore it.
+ if isinstance(context, UserDesc):
+ userdesc += context
+ whence = 'via web confirmation'
+ addr = userdesc.address
+ fullname = userdesc.fullname
+ password = userdesc.password
+ digest = userdesc.digest
+ lang = userdesc.language
+ except ValueError:
+ raise Errors.MMBadConfirmation, 'bad subscr data %s' % (data,)
+ # Hack alert! Was this a confirmation of an invitation?
+ invitation = getattr(userdesc, 'invitation', False)
+ # We check for both 2 (approval required) and 3 (confirm +
+ # approval) because the policy could have been changed in the
+ # middle of the confirmation dance.
+ if invitation:
+ if invitation <> self.internal_name():
+ # Not cool. The invitee was trying to subscribe to a
+ # different list than they were invited to. Alert both
+ # list administrators.
+ self.SendHostileSubscriptionNotice(invitation, addr)
+ raise Errors.HostileSubscriptionError
+ elif self.subscribe_policy in (2, 3) and \
+ not self.HasAutoApprovedSender(addr):
+ self.HoldSubscription(addr, fullname, password, digest, lang)
+ name = self.real_name
+ raise Errors.MMNeedApproval, _(
+ 'subscriptions to %(name)s require administrator approval')
+ self.ApprovedAddMember(userdesc, whence=whence)
+ return op, addr, password, digest, lang
+ elif op == Pending.UNSUBSCRIPTION:
+ addr = data[0]
+ # Log file messages don't need to be i18n'd
+ if isinstance(context, Message.Message):
+ whence = 'email confirmation'
+ else:
+ whence = 'web confirmation'
+ # Can raise NotAMemberError if they unsub'd via other means
+ self.ApprovedDeleteMember(addr, whence=whence)
+ return op, addr
+ elif op == Pending.CHANGE_OF_ADDRESS:
+ oldaddr, newaddr, globally = data
+ self.ApprovedChangeMemberAddress(oldaddr, newaddr, globally)
+ return op, oldaddr, newaddr
+ elif op == Pending.HELD_MESSAGE:
+ id = data[0]
+ approved = None
+ # Confirmation should be coming from email, where context should
+ # be the confirming message. If the message does not have an
+ # Approved: header, this is a discard. If it has an Approved:
+ # header that does not match the list password, then we'll notify
+ # the list administrator that they used the wrong password.
+ # Otherwise it's an approval.
+ if isinstance(context, Message.Message):
+ # See if it's got an Approved: header, either in the headers,
+ # or in the first text/plain section of the response. For
+ # robustness, we'll accept Approve: as well.
+ approved = context.get('Approved', context.get('Approve'))
+ if not approved:
+ try:
+ subpart = list(email.Iterators.typed_subpart_iterator(
+ context, 'text', 'plain'))[0]
+ except IndexError:
+ subpart = None
+ if subpart:
+ s = StringIO(subpart.get_payload())
+ while True:
+ line = s.readline()
+ if not line:
+ break
+ if not line.strip():
+ continue
+ i = line.find(':')
+ if i > 0:
+ if (line[:i].lower() == 'approve' or
+ line[:i].lower() == 'approved'):
+ # then
+ approved = line[i+1:].strip()
+ break
+ # Is there an approved header?
+ if approved is not None:
+ # Does it match the list password? Note that we purposefully
+ # do not allow the site password here.
+ if self.Authenticate([config.AuthListAdmin,
+ config.AuthListModerator],
+ approved) <> config.UnAuthorized:
+ action = config.APPROVE
+ else:
+ # The password didn't match. Re-pend the message and
+ # inform the list moderators about the problem.
+ self.pend_repend(cookie, rec)
+ raise Errors.MMBadPasswordError
+ else:
+ action = config.DISCARD
+ try:
+ self.HandleRequest(id, action)
+ except KeyError:
+ # Most likely because the message has already been disposed of
+ # via the admindb page.
+ elog.error('Could not process HELD_MESSAGE: %s', id)
+ return (op,)
+ elif op == Pending.RE_ENABLE:
+ member = data[1]
+ self.setDeliveryStatus(member, MemberAdaptor.ENABLED)
+ return op, member
+ else:
+ assert 0, 'Bad op: %s' % op
+
+ def ConfirmUnsubscription(self, addr, lang=None, remote=None):
+ if lang is None:
+ lang = self.getMemberLanguage(addr)
+ cookie = self.pend_new(Pending.UNSUBSCRIPTION, addr)
+ confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1),
+ cookie)
+ realname = self.real_name
+ if remote is not None:
+ by = " " + remote
+ remote = _(" from %(remote)s")
+ else:
+ by = ""
+ remote = ""
+ text = Utils.maketext(
+ 'unsub.txt',
+ {'email' : addr,
+ 'listaddr' : self.GetListEmail(),
+ 'listname' : realname,
+ 'cookie' : cookie,
+ 'requestaddr' : self.getListAddress('request'),
+ 'remote' : remote,
+ 'listadmin' : self.GetOwnerEmail(),
+ 'confirmurl' : confirmurl,
+ }, lang=lang, mlist=self)
+ msg = Message.UserNotification(
+ addr, self.GetRequestEmail(cookie),
+ text=text, lang=lang)
+ # BAW: See ChangeMemberAddress() for why we do it this way...
+ del msg['subject']
+ msg['Subject'] = self.GetConfirmLeaveSubject(realname, cookie)
+ msg['Reply-To'] = self.GetRequestEmail(cookie)
+ msg.send(self)
+
+
+ #
+ # Miscellaneous stuff
+ #
+
+ def HasAutoApprovedSender(self, sender):
+ """Returns True and logs if sender matches address or pattern
+ in subscribe_auto_approval. Otherwise returns False.
+ """
+ auto_approve = False
+ if Utils.get_pattern(sender, self.subscribe_auto_approval):
+ auto_approve = True
+ vlog.info('%s: auto approved subscribe from %s',
+ self.internal_name(), sender)
+ return auto_approve
+
+
+ #
+ # Multilingual (i18n) support
+ #
+ def set_languages(self, *language_codes):
+ # XXX FIXME not to use a database entity directly.
+ from Mailman.database.model import Language
+ # Don't use the language_codes property because that will add the
+ # default server language. The effect would be that the default
+ # server language would never get added to the list's list of
+ # languages.
+ requested_codes = set(language_codes)
+ enabled_codes = set(config.languages.enabled_codes)
+ self.available_languages = [
+ Language(code) for code in requested_codes & enabled_codes]
+
+ def add_language(self, language_code):
+ self.available_languages.append(Language(language_code))
+
+ @property
+ def language_codes(self):
+ # Callers of this method expect a list of language codes
+ available_codes = set(self.available_languages)
+ enabled_codes = set(config.languages.enabled_codes)
+ codes = available_codes & enabled_codes
+ # If we don't add this, and the site admin has never added any
+ # language support to the list, then the general admin page may have a
+ # blank field where the list owner is supposed to chose the list's
+ # preferred language.
+ if config.DEFAULT_SERVER_LANGUAGE not in codes:
+ codes.add(config.DEFAULT_SERVER_LANGUAGE)
+ return list(codes)
diff --git a/src/mailman/attic/SecurityManager.py b/src/mailman/attic/SecurityManager.py
new file mode 100644
index 000000000..8d4a30592
--- /dev/null
+++ b/src/mailman/attic/SecurityManager.py
@@ -0,0 +1,306 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Handle passwords and sanitize approved messages."""
+
+# There are current 5 roles defined in Mailman, as codified in Defaults.py:
+# user, list-creator, list-moderator, list-admin, site-admin.
+#
+# Here's how we do cookie based authentication.
+#
+# Each role (see above) has an associated password, which is currently the
+# only way to authenticate a role (in the future, we'll authenticate a
+# user and assign users to roles).
+#
+# Each cookie has the following ingredients: the authorization context's
+# secret (i.e. the password, and a timestamp. We generate an SHA1 hex
+# digest of these ingredients, which we call the 'mac'. We then marshal
+# up a tuple of the timestamp and the mac, hexlify that and return that as
+# a cookie keyed off the authcontext. Note that authenticating the user
+# also requires the user's email address to be included in the cookie.
+#
+# The verification process is done in CheckCookie() below. It extracts
+# the cookie, unhexlifies and unmarshals the tuple, extracting the
+# timestamp. Using this, and the shared secret, the mac is calculated,
+# and it must match the mac passed in the cookie. If so, they're golden,
+# otherwise, access is denied.
+#
+# It is still possible for an adversary to attempt to brute force crack
+# the password if they obtain the cookie, since they can extract the
+# timestamp and create macs based on password guesses. They never get a
+# cleartext version of the password though, so security rests on the
+# difficulty and expense of retrying the cgi dialog for each attempt. It
+# also relies on the security of SHA1.
+
+import os
+import re
+import sha
+import time
+import urllib
+import Cookie
+import logging
+import marshal
+import binascii
+
+from urlparse import urlparse
+
+from Mailman import Defaults
+from Mailman import Errors
+from Mailman import Utils
+from Mailman import passwords
+from Mailman.configuration import config
+
+log = logging.getLogger('mailman.error')
+dlog = logging.getLogger('mailman.debug')
+
+SLASH = '/'
+
+
+
+class SecurityManager:
+ def AuthContextInfo(self, authcontext, user=None):
+ # authcontext may be one of AuthUser, AuthListModerator,
+ # AuthListAdmin, AuthSiteAdmin. Not supported is the AuthCreator
+ # context.
+ #
+ # user is ignored unless authcontext is AuthUser
+ #
+ # Return the authcontext's secret and cookie key. If the authcontext
+ # doesn't exist, return the tuple (None, None). If authcontext is
+ # AuthUser, but the user isn't a member of this mailing list, a
+ # NotAMemberError will be raised. If the user's secret is None, raise
+ # a MMBadUserError.
+ key = urllib.quote(self.fqdn_listname) + '+'
+ if authcontext == Defaults.AuthUser:
+ if user is None:
+ # A bad system error
+ raise TypeError('No user supplied for AuthUser context')
+ secret = self.getMemberPassword(user)
+ userdata = urllib.quote(Utils.ObscureEmail(user), safe='')
+ key += 'user+%s' % userdata
+ elif authcontext == Defaults.AuthListModerator:
+ secret = self.mod_password
+ key += 'moderator'
+ elif authcontext == Defaults.AuthListAdmin:
+ secret = self.password
+ key += 'admin'
+ # BAW: AuthCreator
+ elif authcontext == Defaults.AuthSiteAdmin:
+ sitepass = Utils.get_global_password()
+ if config.ALLOW_SITE_ADMIN_COOKIES and sitepass:
+ secret = sitepass
+ key = 'site'
+ else:
+ # BAW: this should probably hand out a site password based
+ # cookie, but that makes me a bit nervous, so just treat site
+ # admin as a list admin since there is currently no site
+ # admin-only functionality.
+ secret = self.password
+ key += 'admin'
+ else:
+ return None, None
+ return key, secret
+
+ def Authenticate(self, authcontexts, response, user=None):
+ # Given a list of authentication contexts, check to see if the
+ # response matches one of the passwords. authcontexts must be a
+ # sequence, and if it contains the context AuthUser, then the user
+ # argument must not be None.
+ #
+ # Return the authcontext from the argument sequence that matches the
+ # response, or UnAuthorized.
+ for ac in authcontexts:
+ if ac == Defaults.AuthCreator:
+ ok = Utils.check_global_password(response, siteadmin=False)
+ if ok:
+ return Defaults.AuthCreator
+ elif ac == Defaults.AuthSiteAdmin:
+ ok = Utils.check_global_password(response)
+ if ok:
+ return Defaults.AuthSiteAdmin
+ elif ac == Defaults.AuthListAdmin:
+ # The password for the list admin and list moderator are not
+ # kept as plain text, but instead as an sha hexdigest. The
+ # response being passed in is plain text, so we need to
+ # digestify it first.
+ key, secret = self.AuthContextInfo(ac)
+ if secret is None:
+ continue
+ if passwords.check_response(secret, response):
+ return ac
+ elif ac == Defaults.AuthListModerator:
+ # The list moderator password must be sha'd
+ key, secret = self.AuthContextInfo(ac)
+ if secret and passwords.check_response(secret, response):
+ return ac
+ elif ac == Defaults.AuthUser:
+ if user is not None:
+ try:
+ if self.authenticateMember(user, response):
+ return ac
+ except Errors.NotAMemberError:
+ pass
+ else:
+ # What is this context???
+ log.error('Bad authcontext: %s', ac)
+ raise ValueError('Bad authcontext: %s' % ac)
+ return Defaults.UnAuthorized
+
+ def WebAuthenticate(self, authcontexts, response, user=None):
+ # Given a list of authentication contexts, check to see if the cookie
+ # contains a matching authorization, falling back to checking whether
+ # the response matches one of the passwords. authcontexts must be a
+ # sequence, and if it contains the context AuthUser, then the user
+ # argument should not be None.
+ #
+ # Returns a flag indicating whether authentication succeeded or not.
+ for ac in authcontexts:
+ ok = self.CheckCookie(ac, user)
+ if ok:
+ return True
+ # Check passwords
+ ac = self.Authenticate(authcontexts, response, user)
+ if ac:
+ print self.MakeCookie(ac, user)
+ return True
+ return False
+
+ def _cookie_path(self):
+ script_name = os.environ.get('SCRIPT_NAME', '')
+ return SLASH.join(script_name.split(SLASH)[:-1]) + SLASH
+
+ def MakeCookie(self, authcontext, user=None):
+ key, secret = self.AuthContextInfo(authcontext, user)
+ if key is None or secret is None or not isinstance(secret, basestring):
+ raise ValueError
+ # Timestamp
+ issued = int(time.time())
+ # Get a digest of the secret, plus other information.
+ mac = sha.new(secret + repr(issued)).hexdigest()
+ # Create the cookie object.
+ c = Cookie.SimpleCookie()
+ c[key] = binascii.hexlify(marshal.dumps((issued, mac)))
+ c[key]['path'] = self._cookie_path()
+ # We use session cookies, so don't set 'expires' or 'max-age' keys.
+ # Set the RFC 2109 required header.
+ c[key]['version'] = 1
+ return c
+
+ def ZapCookie(self, authcontext, user=None):
+ # We can throw away the secret.
+ key, secret = self.AuthContextInfo(authcontext, user)
+ # Logout of the session by zapping the cookie. For safety both set
+ # max-age=0 (as per RFC2109) and set the cookie data to the empty
+ # string.
+ c = Cookie.SimpleCookie()
+ c[key] = ''
+ c[key]['path'] = self._cookie_path()
+ c[key]['max-age'] = 0
+ # Don't set expires=0 here otherwise it'll force a persistent cookie
+ c[key]['version'] = 1
+ return c
+
+ def CheckCookie(self, authcontext, user=None):
+ # Two results can occur: we return 1 meaning the cookie authentication
+ # succeeded for the authorization context, we return 0 meaning the
+ # authentication failed.
+ #
+ # Dig out the cookie data, which better be passed on this cgi
+ # environment variable. If there's no cookie data, we reject the
+ # authentication.
+ cookiedata = os.environ.get('HTTP_COOKIE')
+ if not cookiedata:
+ return False
+ # We can't use the Cookie module here because it isn't liberal in what
+ # it accepts. Feed it a MM2.0 cookie along with a MM2.1 cookie and
+ # you get a CookieError. :(. All we care about is accessing the
+ # cookie data via getitem, so we'll use our own parser, which returns
+ # a dictionary.
+ c = parsecookie(cookiedata)
+ # If the user was not supplied, but the authcontext is AuthUser, we
+ # can try to glean the user address from the cookie key. There may be
+ # more than one matching key (if the user has multiple accounts
+ # subscribed to this list), but any are okay.
+ if authcontext == Defaults.AuthUser:
+ if user:
+ usernames = [user]
+ else:
+ usernames = []
+ prefix = urllib.quote(self.fqdn_listname) + '+user+'
+ for k in c.keys():
+ if k.startswith(prefix):
+ usernames.append(k[len(prefix):])
+ # If any check out, we're golden. Note: '@'s are no longer legal
+ # values in cookie keys.
+ for user in [Utils.UnobscureEmail(u) for u in usernames]:
+ ok = self.__checkone(c, authcontext, user)
+ if ok:
+ return True
+ return False
+ else:
+ return self.__checkone(c, authcontext, user)
+
+ def __checkone(self, c, authcontext, user):
+ # Do the guts of the cookie check, for one authcontext/user
+ # combination.
+ try:
+ key, secret = self.AuthContextInfo(authcontext, user)
+ except Errors.NotAMemberError:
+ return False
+ if key not in c or not isinstance(secret, basestring):
+ return False
+ # Undo the encoding we performed in MakeCookie() above. BAW: I
+ # believe this is safe from exploit because marshal can't be forced to
+ # load recursive data structures, and it can't be forced to execute
+ # any unexpected code. The worst that can happen is that either the
+ # client will have provided us bogus data, in which case we'll get one
+ # of the caught exceptions, or marshal format will have changed, in
+ # which case, the cookie decoding will fail. In either case, we'll
+ # simply request reauthorization, resulting in a new cookie being
+ # returned to the client.
+ try:
+ data = marshal.loads(binascii.unhexlify(c[key]))
+ issued, received_mac = data
+ except (EOFError, ValueError, TypeError, KeyError):
+ return False
+ # Make sure the issued timestamp makes sense
+ now = time.time()
+ if now < issued:
+ return False
+ # Calculate what the mac ought to be based on the cookie's timestamp
+ # and the shared secret.
+ mac = sha.new(secret + repr(issued)).hexdigest()
+ if mac <> received_mac:
+ return False
+ # Authenticated!
+ return True
+
+
+
+splitter = re.compile(';\s*')
+
+def parsecookie(s):
+ c = {}
+ for line in s.splitlines():
+ for p in splitter.split(line):
+ try:
+ k, v = p.split('=', 1)
+ except ValueError:
+ pass
+ else:
+ c[k] = v
+ return c
diff --git a/src/mailman/attic/bin/clone_member b/src/mailman/attic/bin/clone_member
new file mode 100755
index 000000000..1f2a03aca
--- /dev/null
+++ b/src/mailman/attic/bin/clone_member
@@ -0,0 +1,219 @@
+#! @PYTHON@
+#
+# Copyright (C) 1998,1999,2000,2001,2002 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.
+
+"""Clone a member address.
+
+Cloning a member address means that a new member will be added who has all the
+same options and passwords as the original member address. Note that this
+operation is fairly trusting of the user who runs it -- it does no
+verification to the new address, it does not send out a welcome message, etc.
+
+The existing member's subscription is usually not modified in any way. If you
+want to remove the old address, use the -r flag. If you also want to change
+any list admin addresses, use the -a flag.
+
+Usage:
+ clone_member [options] fromoldaddr tonewaddr
+
+Where:
+
+ --listname=listname
+ -l listname
+ Check and modify only the named mailing lists. If -l is not given,
+ then all mailing lists are scanned from the address. Multiple -l
+ options can be supplied.
+
+ --remove
+ -r
+ Remove the old address from the mailing list after it's been cloned.
+
+ --admin
+ -a
+ Scan the list admin addresses for the old address, and clone or change
+ them too.
+
+ --quiet
+ -q
+ Do the modifications quietly.
+
+ --nomodify
+ -n
+ Print what would be done, but don't actually do it. Inhibits the
+ --quiet flag.
+
+ --help
+ -h
+ Print this help message and exit.
+
+ fromoldaddr (`from old address') is the old address of the user. tonewaddr
+ (`to new address') is the new address of the user.
+
+"""
+
+import sys
+import getopt
+
+import paths
+from Mailman import MailList
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.i18n import _
+
+
+
+def usage(code, msg=''):
+ if code:
+ fd = sys.stderr
+ else:
+ fd = sys.stdout
+ print >> fd, _(__doc__)
+ if msg:
+ print >> fd, msg
+ sys.exit(code)
+
+
+
+def dolist(mlist, options):
+ SPACE = ' '
+ if not options.quiet:
+ print _('processing mailing list:'), mlist.internal_name()
+
+ # scan the list owners. TBD: mlist.owner keys should be lowercase?
+ oldowners = mlist.owner[:]
+ oldowners.sort()
+ if options.admintoo:
+ if not options.quiet:
+ print _(' scanning list owners:'), SPACE.join(oldowners)
+ newowners = {}
+ foundp = 0
+ for owner in mlist.owner:
+ if options.lfromaddr == owner.lower():
+ foundp = 1
+ if options.remove:
+ continue
+ newowners[owner] = 1
+ if foundp:
+ newowners[options.toaddr] = 1
+ newowners = newowners.keys()
+ newowners.sort()
+ if options.modify:
+ mlist.owner = newowners
+ if not options.quiet:
+ if newowners <> oldowners:
+ print
+ print _(' new list owners:'), SPACE.join(newowners)
+ else:
+ print _('(no change)')
+
+ # see if the fromaddr is a digest member or regular member
+ if options.lfromaddr in mlist.getDigestMemberKeys():
+ digest = 1
+ elif options.lfromaddr in mlist.getRegularMemberKeys():
+ digest = 0
+ else:
+ if not options.quiet:
+ print _(' address not found:'), options.fromaddr
+ return
+
+ # Now change the membership address
+ try:
+ if options.modify:
+ mlist.changeMemberAddress(options.fromaddr, options.toaddr,
+ not options.remove)
+ if not options.quiet:
+ print _(' clone address added:'), options.toaddr
+ except Errors.MMAlreadyAMember:
+ if not options.quiet:
+ print _(' clone address is already a member:'), options.toaddr
+
+ if options.remove:
+ print _(' original address removed:'), options.fromaddr
+
+
+
+def main():
+ # default options
+ class Options:
+ listnames = None
+ remove = 0
+ admintoo = 0
+ quiet = 0
+ modify = 1
+
+ # scan sysargs
+ try:
+ opts, args = getopt.getopt(
+ sys.argv[1:], 'arl:qnh',
+ ['admin', 'remove', 'listname=', 'quiet', 'nomodify', 'help'])
+ except getopt.error, msg:
+ usage(1, msg)
+
+ options = Options()
+ for opt, arg in opts:
+ if opt in ('-h', '--help'):
+ usage(0)
+ elif opt in ('-q', '--quiet'):
+ options.quiet = 1
+ elif opt in ('-n', '--nomodify'):
+ options.modify = 0
+ elif opt in ('-a', '--admin'):
+ options.admintoo = 1
+ elif opt in ('-r', '--remove'):
+ options.remove = 1
+ elif opt in ('-l', '--listname'):
+ if options.listnames is None:
+ options.listnames = []
+ options.listnames.append(arg.lower())
+
+ # further options and argument processing
+ if not options.modify:
+ options.quiet = 0
+
+ if len(args) <> 2:
+ usage(1)
+ fromaddr = args[0]
+ toaddr = args[1]
+
+ # validate and normalize the target address
+ try:
+ Utils.ValidateEmail(toaddr)
+ except Errors.EmailAddressError:
+ usage(1, _('Not a valid email address: %(toaddr)s'))
+ lfromaddr = fromaddr.lower()
+ options.toaddr = toaddr
+ options.fromaddr = fromaddr
+ options.lfromaddr = lfromaddr
+
+ if options.listnames is None:
+ options.listnames = Utils.list_names()
+
+ for listname in options.listnames:
+ try:
+ mlist = MailList.MailList(listname)
+ except Errors.MMListError, e:
+ print _('Error opening list "%(listname)s", skipping.\n%(e)s')
+ continue
+ try:
+ dolist(mlist, options)
+ finally:
+ mlist.Save()
+ mlist.Unlock()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/attic/bin/discard b/src/mailman/attic/bin/discard
new file mode 100644
index 000000000..c30198441
--- /dev/null
+++ b/src/mailman/attic/bin/discard
@@ -0,0 +1,120 @@
+#! @PYTHON@
+#
+# Copyright (C) 2003 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.
+
+"""Discard held messages.
+
+Usage:
+ discard [options] file ...
+
+Options:
+ --help / -h
+ Print this help message and exit.
+
+ --quiet / -q
+ Don't print status messages.
+"""
+
+# TODO: add command line arguments for specifying other actions than DISCARD,
+# and also for specifying other __handlepost() arguments, i.e. comment,
+# preserve, forward, addr
+
+import os
+import re
+import sys
+import getopt
+
+import paths
+from Mailman import mm_cfg
+from Mailman.MailList import MailList
+from Mailman.i18n import _
+
+try:
+ True, False
+except NameError:
+ True = 1
+ False = 0
+
+cre = re.compile(r'heldmsg-(?P.*)-(?P[0-9]+)\.(pck|txt)$')
+
+
+
+def usage(code, msg=''):
+ if code:
+ fd = sys.stderr
+ else:
+ fd = sys.stdout
+ print >> fd, _(__doc__)
+ if msg:
+ print >> fd, msg
+ sys.exit(code)
+
+
+
+def main():
+ try:
+ opts, args = getopt.getopt(sys.argv[1:], 'hq', ['help', 'quiet'])
+ except getopt.error, msg:
+ usage(1, msg)
+
+ quiet = False
+ for opt, arg in opts:
+ if opt in ('-h', '--help'):
+ usage(0)
+ elif opt in ('-q', '--quiet'):
+ quiet = True
+
+ files = args
+ if not files:
+ print _('Nothing to do.')
+
+ # Mapping from listnames to sequence of request ids
+ discards = {}
+
+ # Cruise through all the named files, collating by mailing list. We'll
+ # lock the list once, process all holds for that list and move on.
+ for f in files:
+ basename = os.path.basename(f)
+ mo = cre.match(basename)
+ if not mo:
+ print >> sys.stderr, _('Ignoring non-held message: %(f)s')
+ continue
+ listname, id = mo.group('listname', 'id')
+ try:
+ id = int(id)
+ except (ValueError, TypeError):
+ print >> sys.stderr, _('Ignoring held msg w/bad id: %(f)s')
+ continue
+ discards.setdefault(listname, []).append(id)
+
+ # Now do the discards
+ for listname, ids in discards.items():
+ mlist = MailList(listname)
+ try:
+ for id in ids:
+ # No comment, no preserve, no forward, no forwarding address
+ mlist.HandleRequest(id, mm_cfg.DISCARD, '', False, False, '')
+ if not quiet:
+ print _('Discarded held msg #%(id)s for list %(listname)s')
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/attic/bin/fix_url.py b/src/mailman/attic/bin/fix_url.py
new file mode 100644
index 000000000..30618a1a3
--- /dev/null
+++ b/src/mailman/attic/bin/fix_url.py
@@ -0,0 +1,93 @@
+#! @PYTHON@
+#
+# Copyright (C) 2001-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.
+
+"""Reset a list's web_page_url attribute to the default setting.
+
+This script is intended to be run as a bin/withlist script, i.e.
+
+% bin/withlist -l -r fix_url listname [options]
+
+Options:
+ -u urlhost
+ --urlhost=urlhost
+ Look up urlhost in the virtual host table and set the web_page_url and
+ host_name attributes of the list to the values found. This
+ essentially moves the list from one virtual domain to another.
+
+ Without this option, the default web_page_url and host_name values are
+ used.
+
+ -v / --verbose
+ Print what the script is doing.
+
+If run standalone, it prints this help text and exits.
+"""
+
+import sys
+import getopt
+
+import paths
+from Mailman.configuration import config
+from Mailman.i18n import _
+
+
+
+def usage(code, msg=''):
+ print _(__doc__.replace('%', '%%'))
+ if msg:
+ print msg
+ sys.exit(code)
+
+
+
+def fix_url(mlist, *args):
+ try:
+ opts, args = getopt.getopt(args, 'u:v', ['urlhost=', 'verbose'])
+ except getopt.error, msg:
+ usage(1, msg)
+
+ verbose = 0
+ urlhost = mailhost = None
+ for opt, arg in opts:
+ if opt in ('-u', '--urlhost'):
+ urlhost = arg
+ elif opt in ('-v', '--verbose'):
+ verbose = 1
+
+ if urlhost:
+ web_page_url = config.DEFAULT_URL_PATTERN % urlhost
+ mailhost = config.VIRTUAL_HOSTS.get(urlhost.lower(), urlhost)
+ else:
+ web_page_url = config.DEFAULT_URL_PATTERN % config.DEFAULT_URL_HOST
+ mailhost = config.DEFAULT_EMAIL_HOST
+
+ if verbose:
+ print _('Setting web_page_url to: %(web_page_url)s')
+ mlist.web_page_url = web_page_url
+ if verbose:
+ print _('Setting host_name to: %(mailhost)s')
+ mlist.host_name = mailhost
+ print _('Saving list')
+ mlist.Save()
+ mlist.Unlock()
+
+
+
+if __name__ == '__main__':
+ usage(0)
diff --git a/src/mailman/attic/bin/list_admins b/src/mailman/attic/bin/list_admins
new file mode 100644
index 000000000..c628a42dc
--- /dev/null
+++ b/src/mailman/attic/bin/list_admins
@@ -0,0 +1,101 @@
+#! @PYTHON@
+#
+# Copyright (C) 2001,2002 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.
+
+"""List all the owners of a mailing list.
+
+Usage: %(program)s [options] listname ...
+
+Where:
+
+ --all-vhost=vhost
+ -v=vhost
+ List the owners of all the mailing lists for the given virtual host.
+
+ --all
+ -a
+ List the owners of all the mailing lists on this system.
+
+ --help
+ -h
+ Print this help message and exit.
+
+`listname' is the name of the mailing list to print the owners of. You can
+have more than one named list on the command line.
+"""
+
+import sys
+import getopt
+
+import paths
+from Mailman import MailList, Utils
+from Mailman import Errors
+from Mailman.i18n import _
+
+COMMASPACE = ', '
+
+program = sys.argv[0]
+
+
+
+def usage(code, msg=''):
+ if code:
+ fd = sys.stderr
+ else:
+ fd = sys.stdout
+ print >> fd, _(__doc__)
+ if msg:
+ print >> fd, msg
+ sys.exit(code)
+
+
+
+def main():
+ try:
+ opts, args = getopt.getopt(sys.argv[1:], 'hv:a',
+ ['help', 'all-vhost=', 'all'])
+ except getopt.error, msg:
+ usage(1, msg)
+
+ listnames = args
+ vhost = None
+ for opt, arg in opts:
+ if opt in ('-h', '--help'):
+ usage(0)
+ elif opt in ('-a', '--all'):
+ listnames = Utils.list_names()
+ elif opt in ('-v', '--all-vhost'):
+ listnames = Utils.list_names()
+ vhost = arg
+
+ for listname in listnames:
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ print _('No such list: %(listname)s')
+ continue
+
+ if vhost and vhost <> mlist.host_name:
+ continue
+
+ owners = COMMASPACE.join(mlist.owner)
+ print _('List: %(listname)s, \tOwners: %(owners)s')
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/attic/bin/msgfmt.py b/src/mailman/attic/bin/msgfmt.py
new file mode 100644
index 000000000..8a2d4e66e
--- /dev/null
+++ b/src/mailman/attic/bin/msgfmt.py
@@ -0,0 +1,203 @@
+#! /usr/bin/env python
+# -*- coding: iso-8859-1 -*-
+# Written by Martin v. Löwis
+
+"""Generate binary message catalog from textual translation description.
+
+This program converts a textual Uniforum-style message catalog (.po file) into
+a binary GNU catalog (.mo file). This is essentially the same function as the
+GNU msgfmt program, however, it is a simpler implementation.
+
+Usage: msgfmt.py [OPTIONS] filename.po
+
+Options:
+ -o file
+ --output-file=file
+ Specify the output file to write to. If omitted, output will go to a
+ file named filename.mo (based off the input file name).
+
+ -h
+ --help
+ Print this message and exit.
+
+ -V
+ --version
+ Display version information and exit.
+"""
+
+import sys
+import os
+import getopt
+import struct
+import array
+
+__version__ = "1.1"
+
+MESSAGES = {}
+
+
+
+def usage(code, msg=''):
+ print >> sys.stderr, __doc__
+ if msg:
+ print >> sys.stderr, msg
+ sys.exit(code)
+
+
+
+def add(id, str, fuzzy):
+ "Add a non-fuzzy translation to the dictionary."
+ global MESSAGES
+ if not fuzzy and str:
+ MESSAGES[id] = str
+
+
+
+def generate():
+ "Return the generated output."
+ global MESSAGES
+ keys = MESSAGES.keys()
+ # the keys are sorted in the .mo file
+ keys.sort()
+ offsets = []
+ ids = strs = ''
+ for id in keys:
+ # For each string, we need size and file offset. Each string is NUL
+ # terminated; the NUL does not count into the size.
+ offsets.append((len(ids), len(id), len(strs), len(MESSAGES[id])))
+ ids += id + '\0'
+ strs += MESSAGES[id] + '\0'
+ output = ''
+ # The header is 7 32-bit unsigned integers. We don't use hash tables, so
+ # the keys start right after the index tables.
+ # translated string.
+ keystart = 7*4+16*len(keys)
+ # and the values start after the keys
+ valuestart = keystart + len(ids)
+ koffsets = []
+ voffsets = []
+ # The string table first has the list of keys, then the list of values.
+ # Each entry has first the size of the string, then the file offset.
+ for o1, l1, o2, l2 in offsets:
+ koffsets += [l1, o1+keystart]
+ voffsets += [l2, o2+valuestart]
+ offsets = koffsets + voffsets
+ output = struct.pack("Iiiiiii",
+ 0x950412deL, # Magic
+ 0, # Version
+ len(keys), # # of entries
+ 7*4, # start of key index
+ 7*4+len(keys)*8, # start of value index
+ 0, 0) # size and offset of hash table
+ output += array.array("i", offsets).tostring()
+ output += ids
+ output += strs
+ return output
+
+
+
+def make(filename, outfile):
+ ID = 1
+ STR = 2
+
+ # Compute .mo name from .po name and arguments
+ if filename.endswith('.po'):
+ infile = filename
+ else:
+ infile = filename + '.po'
+ if outfile is None:
+ outfile = os.path.splitext(infile)[0] + '.mo'
+
+ try:
+ lines = open(infile).readlines()
+ except IOError, msg:
+ print >> sys.stderr, msg
+ sys.exit(1)
+
+ section = None
+ fuzzy = 0
+
+ # Parse the catalog
+ lno = 0
+ for l in lines:
+ lno += 1
+ # If we get a comment line after a msgstr, this is a new entry
+ if l[0] == '#' and section == STR:
+ add(msgid, msgstr, fuzzy)
+ section = None
+ fuzzy = 0
+ # Record a fuzzy mark
+ if l[:2] == '#,' and l.find('fuzzy'):
+ fuzzy = 1
+ # Skip comments
+ if l[0] == '#':
+ continue
+ # Now we are in a msgid section, output previous section
+ if l.startswith('msgid'):
+ if section == STR:
+ add(msgid, msgstr, fuzzy)
+ section = ID
+ l = l[5:]
+ msgid = msgstr = ''
+ # Now we are in a msgstr section
+ elif l.startswith('msgstr'):
+ section = STR
+ l = l[6:]
+ # Skip empty lines
+ l = l.strip()
+ if not l:
+ continue
+ # XXX: Does this always follow Python escape semantics?
+ l = eval(l)
+ if section == ID:
+ msgid += l
+ elif section == STR:
+ msgstr += l
+ else:
+ print >> sys.stderr, 'Syntax error on %s:%d' % (infile, lno), \
+ 'before:'
+ print >> sys.stderr, l
+ sys.exit(1)
+ # Add last entry
+ if section == STR:
+ add(msgid, msgstr, fuzzy)
+
+ # Compute output
+ output = generate()
+
+ try:
+ open(outfile,"wb").write(output)
+ except IOError,msg:
+ print >> sys.stderr, msg
+
+
+
+def main():
+ try:
+ opts, args = getopt.getopt(sys.argv[1:], 'hVo:',
+ ['help', 'version', 'output-file='])
+ except getopt.error, msg:
+ usage(1, msg)
+
+ outfile = None
+ # parse options
+ for opt, arg in opts:
+ if opt in ('-h', '--help'):
+ usage(0)
+ elif opt in ('-V', '--version'):
+ print >> sys.stderr, "msgfmt.py", __version__
+ sys.exit(0)
+ elif opt in ('-o', '--output-file'):
+ outfile = arg
+ # do it
+ if not args:
+ print >> sys.stderr, 'No input file given'
+ print >> sys.stderr, "Try `msgfmt --help' for more information."
+ return
+
+ for filename in args:
+ make(filename, outfile)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/attic/bin/po2templ.py b/src/mailman/attic/bin/po2templ.py
new file mode 100644
index 000000000..86eae96b9
--- /dev/null
+++ b/src/mailman/attic/bin/po2templ.py
@@ -0,0 +1,90 @@
+#! @PYTHON@
+#
+# Copyright (C) 2005-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.
+
+# Author: Tokio Kikuchi
+
+
+"""po2templ.py
+
+Extract templates from language po file.
+
+Usage: po2templ.py languages
+"""
+
+import re
+import sys
+
+cre = re.compile('^#:\s*templates/en/(?P.*?):1')
+
+
+
+def do_lang(lang):
+ in_template = False
+ in_msg = False
+ msgstr = ''
+ fp = file('messages/%s/LC_MESSAGES/mailman.po' % lang)
+ try:
+ for line in fp:
+ m = cre.search(line)
+ if m:
+ in_template = True
+ in_msg = False
+ filename = m.group('filename')
+ outfilename = 'templates/%s/%s' % (lang, filename)
+ continue
+ if in_template and line.startswith('#,'):
+ if line.strip() == '#, fuzzy':
+ in_template = False
+ continue
+ if in_template and line.startswith('msgstr'):
+ line = line[7:]
+ in_msg = True
+ if in_msg:
+ if not line.strip():
+ in_template = False
+ in_msg = False
+ if len(msgstr) > 1 and outfilename:
+ # exclude no translation ... 1 is for LF only
+ outfile = file(outfilename, 'w')
+ try:
+ outfile.write(msgstr)
+ outfile.write('\n')
+ finally:
+ outfile.close()
+ outfilename = ''
+ msgstr = ''
+ continue
+ msgstr += eval(line)
+ finally:
+ fp.close()
+ if len(msgstr) > 1 and outfilename:
+ # flush remaining msgstr (last template file)
+ outfile = file(outfilename, 'w')
+ try:
+ outfile.write(msgstr)
+ outfile.write('\n')
+ finally:
+ outfile.close()
+
+
+
+if __name__ == '__main__':
+ langs = sys.argv[1:]
+ for lang in langs:
+ do_lang(lang)
diff --git a/src/mailman/attic/bin/pygettext.py b/src/mailman/attic/bin/pygettext.py
new file mode 100644
index 000000000..84421ee8c
--- /dev/null
+++ b/src/mailman/attic/bin/pygettext.py
@@ -0,0 +1,545 @@
+#! @PYTHON@
+# Originally written by Barry Warsaw
+#
+# Minimally patched to make it even more xgettext compatible
+# by Peter Funk
+
+"""pygettext -- Python equivalent of xgettext(1)
+
+Many systems (Solaris, Linux, Gnu) provide extensive tools that ease the
+internationalization of C programs. Most of these tools are independent of
+the programming language and can be used from within Python programs. Martin
+von Loewis' work[1] helps considerably in this regard.
+
+There's one problem though; xgettext is the program that scans source code
+looking for message strings, but it groks only C (or C++). Python introduces
+a few wrinkles, such as dual quoting characters, triple quoted strings, and
+raw strings. xgettext understands none of this.
+
+Enter pygettext, which uses Python's standard tokenize module to scan Python
+source code, generating .pot files identical to what GNU xgettext[2] generates
+for C and C++ code. From there, the standard GNU tools can be used.
+
+A word about marking Python strings as candidates for translation. GNU
+xgettext recognizes the following keywords: gettext, dgettext, dcgettext, and
+gettext_noop. But those can be a lot of text to include all over your code.
+C and C++ have a trick: they use the C preprocessor. Most internationalized C
+source includes a #define for gettext() to _() so that what has to be written
+in the source is much less. Thus these are both translatable strings:
+
+ gettext("Translatable String")
+ _("Translatable String")
+
+Python of course has no preprocessor so this doesn't work so well. Thus,
+pygettext searches only for _() by default, but see the -k/--keyword flag
+below for how to augment this.
+
+ [1] http://www.python.org/workshops/1997-10/proceedings/loewis.html
+ [2] http://www.gnu.org/software/gettext/gettext.html
+
+NOTE: pygettext attempts to be option and feature compatible with GNU xgettext
+where ever possible. However some options are still missing or are not fully
+implemented. Also, xgettext's use of command line switches with option
+arguments is broken, and in these cases, pygettext just defines additional
+switches.
+
+Usage: pygettext [options] inputfile ...
+
+Options:
+
+ -a
+ --extract-all
+ Extract all strings.
+
+ -d name
+ --default-domain=name
+ Rename the default output file from messages.pot to name.pot.
+
+ -E
+ --escape
+ Replace non-ASCII characters with octal escape sequences.
+
+ -D
+ --docstrings
+ Extract module, class, method, and function docstrings. These do not
+ need to be wrapped in _() markers, and in fact cannot be for Python to
+ consider them docstrings. (See also the -X option).
+
+ -h
+ --help
+ Print this help message and exit.
+
+ -k word
+ --keyword=word
+ Keywords to look for in addition to the default set, which are:
+ %(DEFAULTKEYWORDS)s
+
+ You can have multiple -k flags on the command line.
+
+ -K
+ --no-default-keywords
+ Disable the default set of keywords (see above). Any keywords
+ explicitly added with the -k/--keyword option are still recognized.
+
+ --no-location
+ Do not write filename/lineno location comments.
+
+ -n
+ --add-location
+ Write filename/lineno location comments indicating where each
+ extracted string is found in the source. These lines appear before
+ each msgid. The style of comments is controlled by the -S/--style
+ option. This is the default.
+
+ -o filename
+ --output=filename
+ Rename the default output file from messages.pot to filename. If
+ filename is `-' then the output is sent to standard out.
+
+ -p dir
+ --output-dir=dir
+ Output files will be placed in directory dir.
+
+ -S stylename
+ --style stylename
+ Specify which style to use for location comments. Two styles are
+ supported:
+
+ Solaris # File: filename, line: line-number
+ GNU #: filename:line
+
+ The style name is case insensitive. GNU style is the default.
+
+ -v
+ --verbose
+ Print the names of the files being processed.
+
+ -V
+ --version
+ Print the version of pygettext and exit.
+
+ -w columns
+ --width=columns
+ Set width of output to columns.
+
+ -x filename
+ --exclude-file=filename
+ Specify a file that contains a list of strings that are not be
+ extracted from the input files. Each string to be excluded must
+ appear on a line by itself in the file.
+
+ -X filename
+ --no-docstrings=filename
+ Specify a file that contains a list of files (one per line) that
+ should not have their docstrings extracted. This is only useful in
+ conjunction with the -D option above.
+
+If `inputfile' is -, standard input is read.
+"""
+
+import os
+import sys
+import time
+import getopt
+import tokenize
+import operator
+
+# for selftesting
+try:
+ import fintl
+ _ = fintl.gettext
+except ImportError:
+ def _(s): return s
+
+__version__ = '1.4'
+
+default_keywords = ['_']
+DEFAULTKEYWORDS = ', '.join(default_keywords)
+
+EMPTYSTRING = ''
+
+
+
+# The normal pot-file header. msgmerge and Emacs's po-mode work better if it's
+# there.
+pot_header = _('''\
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) YEAR ORGANIZATION
+# FIRST AUTHOR , YEAR.
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: PACKAGE VERSION\\n"
+"POT-Creation-Date: %(time)s\\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\\n"
+"Last-Translator: FULL NAME \\n"
+"Language-Team: LANGUAGE \\n"
+"MIME-Version: 1.0\\n"
+"Content-Type: text/plain; charset=CHARSET\\n"
+"Content-Transfer-Encoding: ENCODING\\n"
+"Generated-By: pygettext.py %(version)s\\n"
+
+''')
+
+
+def usage(code, msg=''):
+ if code:
+ fd = sys.stderr
+ else:
+ fd = sys.stdout
+ print >> fd, _(__doc__) % globals()
+ if msg:
+ print >> fd, msg
+ sys.exit(code)
+
+
+
+escapes = []
+
+def make_escapes(pass_iso8859):
+ global escapes
+ if pass_iso8859:
+ # Allow iso-8859 characters to pass through so that e.g. 'msgid
+ # "H[o-umlaut]he"' would result not result in 'msgid "H\366he"'.
+ # Otherwise we escape any character outside the 32..126 range.
+ mod = 128
+ else:
+ mod = 256
+ for i in range(256):
+ if 32 <= (i % mod) <= 126:
+ escapes.append(chr(i))
+ else:
+ escapes.append("\\%03o" % i)
+ escapes[ord('\\')] = '\\\\'
+ escapes[ord('\t')] = '\\t'
+ escapes[ord('\r')] = '\\r'
+ escapes[ord('\n')] = '\\n'
+ escapes[ord('\"')] = '\\"'
+
+
+def escape(s):
+ global escapes
+ s = list(s)
+ for i in range(len(s)):
+ s[i] = escapes[ord(s[i])]
+ return EMPTYSTRING.join(s)
+
+
+def safe_eval(s):
+ # unwrap quotes, safely
+ return eval(s, {'__builtins__':{}}, {})
+
+
+def normalize(s):
+ # This converts the various Python string types into a format that is
+ # appropriate for .po files, namely much closer to C style.
+ lines = s.split('\n')
+ if len(lines) == 1:
+ s = '"' + escape(s) + '"'
+ else:
+ if not lines[-1]:
+ del lines[-1]
+ lines[-1] = lines[-1] + '\n'
+ for i in range(len(lines)):
+ lines[i] = escape(lines[i])
+ lineterm = '\\n"\n"'
+ s = '""\n"' + lineterm.join(lines) + '"'
+ return s
+
+
+
+class TokenEater:
+ def __init__(self, options):
+ self.__options = options
+ self.__messages = {}
+ self.__state = self.__waiting
+ self.__data = []
+ self.__lineno = -1
+ self.__freshmodule = 1
+ self.__curfile = None
+
+ def __call__(self, ttype, tstring, stup, etup, line):
+ # dispatch
+## import token
+## print >> sys.stderr, 'ttype:', token.tok_name[ttype], \
+## 'tstring:', tstring
+ self.__state(ttype, tstring, stup[0])
+
+ def __waiting(self, ttype, tstring, lineno):
+ opts = self.__options
+ # Do docstring extractions, if enabled
+ if opts.docstrings and not opts.nodocstrings.get(self.__curfile):
+ # module docstring?
+ if self.__freshmodule:
+ if ttype == tokenize.STRING:
+ self.__addentry(safe_eval(tstring), lineno, isdocstring=1)
+ self.__freshmodule = 0
+ elif ttype not in (tokenize.COMMENT, tokenize.NL):
+ self.__freshmodule = 0
+ return
+ # class docstring?
+ if ttype == tokenize.NAME and tstring in ('class', 'def'):
+ self.__state = self.__suiteseen
+ return
+ if ttype == tokenize.NAME and tstring in opts.keywords:
+ self.__state = self.__keywordseen
+
+ def __suiteseen(self, ttype, tstring, lineno):
+ # ignore anything until we see the colon
+ if ttype == tokenize.OP and tstring == ':':
+ self.__state = self.__suitedocstring
+
+ def __suitedocstring(self, ttype, tstring, lineno):
+ # ignore any intervening noise
+ if ttype == tokenize.STRING:
+ self.__addentry(safe_eval(tstring), lineno, isdocstring=1)
+ self.__state = self.__waiting
+ elif ttype not in (tokenize.NEWLINE, tokenize.INDENT,
+ tokenize.COMMENT):
+ # there was no class docstring
+ self.__state = self.__waiting
+
+ def __keywordseen(self, ttype, tstring, lineno):
+ if ttype == tokenize.OP and tstring == '(':
+ self.__data = []
+ self.__lineno = lineno
+ self.__state = self.__openseen
+ else:
+ self.__state = self.__waiting
+
+ def __openseen(self, ttype, tstring, lineno):
+ if ttype == tokenize.OP and tstring == ')':
+ # We've seen the last of the translatable strings. Record the
+ # line number of the first line of the strings and update the list
+ # of messages seen. Reset state for the next batch. If there
+ # were no strings inside _(), then just ignore this entry.
+ if self.__data:
+ self.__addentry(EMPTYSTRING.join(self.__data))
+ self.__state = self.__waiting
+ elif ttype == tokenize.STRING:
+ self.__data.append(safe_eval(tstring))
+ # TBD: should we warn if we seen anything else?
+
+ def __addentry(self, msg, lineno=None, isdocstring=0):
+ if lineno is None:
+ lineno = self.__lineno
+ if not msg in self.__options.toexclude:
+ entry = (self.__curfile, lineno)
+ self.__messages.setdefault(msg, {})[entry] = isdocstring
+
+ def set_filename(self, filename):
+ self.__curfile = filename
+ self.__freshmodule = 1
+
+ def write(self, fp):
+ options = self.__options
+ timestamp = time.ctime(time.time())
+ # The time stamp in the header doesn't have the same format as that
+ # generated by xgettext...
+ print >> fp, pot_header % {'time': timestamp, 'version': __version__}
+ # Sort the entries. First sort each particular entry's keys, then
+ # sort all the entries by their first item.
+ reverse = {}
+ for k, v in self.__messages.items():
+ keys = v.keys()
+ keys.sort()
+ reverse.setdefault(tuple(keys), []).append((k, v))
+ rkeys = reverse.keys()
+ rkeys.sort()
+ for rkey in rkeys:
+ rentries = reverse[rkey]
+ rentries.sort()
+ for k, v in rentries:
+ isdocstring = 0
+ # If the entry was gleaned out of a docstring, then add a
+ # comment stating so. This is to aid translators who may wish
+ # to skip translating some unimportant docstrings.
+ if reduce(operator.__add__, v.values()):
+ isdocstring = 1
+ # k is the message string, v is a dictionary-set of (filename,
+ # lineno) tuples. We want to sort the entries in v first by
+ # file name and then by line number.
+ v = v.keys()
+ v.sort()
+ if not options.writelocations:
+ pass
+ # location comments are different b/w Solaris and GNU:
+ elif options.locationstyle == options.SOLARIS:
+ for filename, lineno in v:
+ d = {'filename': filename, 'lineno': lineno}
+ print >>fp, _(
+ '# File: %(filename)s, line: %(lineno)d') % d
+ elif options.locationstyle == options.GNU:
+ # fit as many locations on one line, as long as the
+ # resulting line length doesn't exceeds 'options.width'
+ locline = '#:'
+ for filename, lineno in v:
+ d = {'filename': filename, 'lineno': lineno}
+ s = _(' %(filename)s:%(lineno)d') % d
+ if len(locline) + len(s) <= options.width:
+ locline = locline + s
+ else:
+ print >> fp, locline
+ locline = "#:" + s
+ if len(locline) > 2:
+ print >> fp, locline
+ if isdocstring:
+ print >> fp, '#, docstring'
+ print >> fp, 'msgid', normalize(k)
+ print >> fp, 'msgstr ""\n'
+
+
+
+def main():
+ global default_keywords
+ try:
+ opts, args = getopt.getopt(
+ sys.argv[1:],
+ 'ad:DEhk:Kno:p:S:Vvw:x:X:',
+ ['extract-all', 'default-domain=', 'escape', 'help',
+ 'keyword=', 'no-default-keywords',
+ 'add-location', 'no-location', 'output=', 'output-dir=',
+ 'style=', 'verbose', 'version', 'width=', 'exclude-file=',
+ 'docstrings', 'no-docstrings',
+ ])
+ except getopt.error, msg:
+ usage(1, msg)
+
+ # for holding option values
+ class Options:
+ # constants
+ GNU = 1
+ SOLARIS = 2
+ # defaults
+ extractall = 0 # FIXME: currently this option has no effect at all.
+ escape = 0
+ keywords = []
+ outpath = ''
+ outfile = 'messages.pot'
+ writelocations = 1
+ locationstyle = GNU
+ verbose = 0
+ width = 78
+ excludefilename = ''
+ docstrings = 0
+ nodocstrings = {}
+
+ options = Options()
+ locations = {'gnu' : options.GNU,
+ 'solaris' : options.SOLARIS,
+ }
+
+ # parse options
+ for opt, arg in opts:
+ if opt in ('-h', '--help'):
+ usage(0)
+ elif opt in ('-a', '--extract-all'):
+ options.extractall = 1
+ elif opt in ('-d', '--default-domain'):
+ options.outfile = arg + '.pot'
+ elif opt in ('-E', '--escape'):
+ options.escape = 1
+ elif opt in ('-D', '--docstrings'):
+ options.docstrings = 1
+ elif opt in ('-k', '--keyword'):
+ options.keywords.append(arg)
+ elif opt in ('-K', '--no-default-keywords'):
+ default_keywords = []
+ elif opt in ('-n', '--add-location'):
+ options.writelocations = 1
+ elif opt in ('--no-location',):
+ options.writelocations = 0
+ elif opt in ('-S', '--style'):
+ options.locationstyle = locations.get(arg.lower())
+ if options.locationstyle is None:
+ usage(1, _('Invalid value for --style: %s') % arg)
+ elif opt in ('-o', '--output'):
+ options.outfile = arg
+ elif opt in ('-p', '--output-dir'):
+ options.outpath = arg
+ elif opt in ('-v', '--verbose'):
+ options.verbose = 1
+ elif opt in ('-V', '--version'):
+ print _('pygettext.py (xgettext for Python) %s') % __version__
+ sys.exit(0)
+ elif opt in ('-w', '--width'):
+ try:
+ options.width = int(arg)
+ except ValueError:
+ usage(1, _('--width argument must be an integer: %s') % arg)
+ elif opt in ('-x', '--exclude-file'):
+ options.excludefilename = arg
+ elif opt in ('-X', '--no-docstrings'):
+ fp = open(arg)
+ try:
+ while 1:
+ line = fp.readline()
+ if not line:
+ break
+ options.nodocstrings[line[:-1]] = 1
+ finally:
+ fp.close()
+
+ # calculate escapes
+ make_escapes(options.escape)
+
+ # calculate all keywords
+ options.keywords.extend(default_keywords)
+
+ # initialize list of strings to exclude
+ if options.excludefilename:
+ try:
+ fp = open(options.excludefilename)
+ options.toexclude = fp.readlines()
+ fp.close()
+ except IOError:
+ print >> sys.stderr, _(
+ "Can't read --exclude-file: %s") % options.excludefilename
+ sys.exit(1)
+ else:
+ options.toexclude = []
+
+ # slurp through all the files
+ eater = TokenEater(options)
+ for filename in args:
+ if filename == '-':
+ if options.verbose:
+ print _('Reading standard input')
+ fp = sys.stdin
+ closep = 0
+ else:
+ if options.verbose:
+ print _('Working on %s') % filename
+ fp = open(filename)
+ closep = 1
+ try:
+ eater.set_filename(filename)
+ try:
+ tokenize.tokenize(fp.readline, eater)
+ except tokenize.TokenError, e:
+ print >> sys.stderr, '%s: %s, line %d, column %d' % (
+ e[0], filename, e[1][0], e[1][1])
+ finally:
+ if closep:
+ fp.close()
+
+ # write the output
+ if options.outfile == '-':
+ fp = sys.stdout
+ closep = 0
+ else:
+ if options.outpath:
+ options.outfile = os.path.join(options.outpath, options.outfile)
+ fp = open(options.outfile, 'w')
+ closep = 1
+ try:
+ eater.write(fp)
+ finally:
+ if closep:
+ fp.close()
+
+
+if __name__ == '__main__':
+ main()
+ # some more test strings
+ _(u'a unicode string')
diff --git a/src/mailman/attic/bin/remove_members b/src/mailman/attic/bin/remove_members
new file mode 100755
index 000000000..a7b4ebb47
--- /dev/null
+++ b/src/mailman/attic/bin/remove_members
@@ -0,0 +1,186 @@
+#! @PYTHON@
+#
+# Copyright (C) 1998-2005 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.
+
+"""Remove members from a list.
+
+Usage:
+ remove_members [options] [listname] [addr1 ...]
+
+Options:
+
+ --file=file
+ -f file
+ Remove member addresses found in the given file. If file is
+ `-', read stdin.
+
+ --all
+ -a
+ Remove all members of the mailing list.
+ (mutually exclusive with --fromall)
+
+ --fromall
+ Removes the given addresses from all the lists on this system
+ regardless of virtual domains if you have any. This option cannot be
+ used -a/--all. Also, you should not specify a listname when using
+ this option.
+
+ --nouserack
+ -n
+ Don't send the user acknowledgements. If not specified, the list
+ default value is used.
+
+ --noadminack
+ -N
+ Don't send the admin acknowledgements. If not specified, the list
+ default value is used.
+
+ --help
+ -h
+ Print this help message and exit.
+
+ listname is the name of the mailing list to use.
+
+ addr1 ... are additional addresses to remove.
+"""
+
+import sys
+import getopt
+
+import paths
+from Mailman import MailList
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.i18n import _
+
+try:
+ True, False
+except NameError:
+ True = 1
+ False = 0
+
+
+
+def usage(code, msg=''):
+ if code:
+ fd = sys.stderr
+ else:
+ fd = sys.stdout
+ print >> fd, _(__doc__)
+ if msg:
+ print >> fd, msg
+ sys.exit(code)
+
+
+def ReadFile(filename):
+ lines = []
+ if filename == "-":
+ fp = sys.stdin
+ closep = False
+ else:
+ fp = open(filename)
+ closep = True
+ lines = filter(None, [line.strip() for line in fp.readlines()])
+ if closep:
+ fp.close()
+ return lines
+
+
+
+def main():
+ try:
+ opts, args = getopt.getopt(
+ sys.argv[1:], 'naf:hN',
+ ['all', 'fromall', 'file=', 'help', 'nouserack', 'noadminack'])
+ except getopt.error, msg:
+ usage(1, msg)
+
+ filename = None
+ all = False
+ alllists = False
+ # None means use list default
+ userack = None
+ admin_notif = None
+
+ for opt, arg in opts:
+ if opt in ('-h', '--help'):
+ usage(0)
+ elif opt in ('-f', '--file'):
+ filename = arg
+ elif opt in ('-a', '--all'):
+ all = True
+ elif opt == '--fromall':
+ alllists = True
+ elif opt in ('-n', '--nouserack'):
+ userack = False
+ elif opt in ('-N', '--noadminack'):
+ admin_notif = False
+
+ if len(args) < 1 and not (filename and alllists):
+ usage(1)
+
+ # You probably don't want to delete all the users of all the lists -- Marc
+ if all and alllists:
+ usage(1)
+
+ if alllists:
+ addresses = args
+ else:
+ listname = args[0].lower().strip()
+ addresses = args[1:]
+
+ if alllists:
+ listnames = Utils.list_names()
+ else:
+ listnames = [listname]
+
+ if filename:
+ try:
+ addresses = addresses + ReadFile(filename)
+ except IOError:
+ print _('Could not open file for reading: %(filename)s.')
+
+ for listname in listnames:
+ try:
+ # open locked
+ mlist = MailList.MailList(listname)
+ except Errors.MMListError:
+ print _('Error opening list %(listname)s... skipping.')
+ continue
+
+ if all:
+ addresses = mlist.getMembers()
+
+ try:
+ for addr in addresses:
+ if not mlist.isMember(addr):
+ if not alllists:
+ print _('No such member: %(addr)s')
+ continue
+ mlist.ApprovedDeleteMember(addr, 'bin/remove_members',
+ admin_notif, userack)
+ if alllists:
+ print _("User `%(addr)s' removed from list: %(listname)s.")
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/attic/bin/reset_pw.py b/src/mailman/attic/bin/reset_pw.py
new file mode 100644
index 000000000..453c8b849
--- /dev/null
+++ b/src/mailman/attic/bin/reset_pw.py
@@ -0,0 +1,83 @@
+#! @PYTHON@
+#
+# Copyright (C) 2004-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.
+
+# Inspired by Florian Weimer.
+
+"""Reset the passwords for members of a mailing list.
+
+This script resets all the passwords of a mailing list's members. It can also
+be used to reset the lists of all members of all mailing lists, but it is your
+responsibility to let the users know that their passwords have been changed.
+
+This script is intended to be run as a bin/withlist script, i.e.
+
+% bin/withlist -l -r reset_pw listname [options]
+
+Options:
+ -v / --verbose
+ Print what the script is doing.
+"""
+
+import sys
+import getopt
+
+import paths
+from Mailman import Utils
+from Mailman.i18n import _
+
+
+
+def usage(code, msg=''):
+ if code:
+ fd = sys.stderr
+ else:
+ fd = sys.stdout
+ print >> fd, _(__doc__.replace('%', '%%'))
+ if msg:
+ print >> fd, msg
+ sys.exit(code)
+
+
+
+def reset_pw(mlist, *args):
+ try:
+ opts, args = getopt.getopt(args, 'v', ['verbose'])
+ except getopt.error, msg:
+ usage(1, msg)
+
+ verbose = False
+ for opt, args in opts:
+ if opt in ('-v', '--verbose'):
+ verbose = True
+
+ listname = mlist.internal_name()
+ if verbose:
+ print _('Changing passwords for list: %(listname)s')
+
+ for member in mlist.getMembers():
+ randompw = Utils.MakeRandomPassword()
+ mlist.setMemberPassword(member, randompw)
+ if verbose:
+ print _('New password for member %(member)40s: %(randompw)s')
+
+ mlist.Save()
+
+
+
+if __name__ == '__main__':
+ usage(0)
diff --git a/src/mailman/attic/bin/sync_members b/src/mailman/attic/bin/sync_members
new file mode 100755
index 000000000..4a21624c1
--- /dev/null
+++ b/src/mailman/attic/bin/sync_members
@@ -0,0 +1,286 @@
+#! @PYTHON@
+#
+# Copyright (C) 1998-2003 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.
+
+"""Synchronize a mailing list's membership with a flat file.
+
+This script is useful if you have a Mailman mailing list and a sendmail
+:include: style list of addresses (also as is used in Majordomo). For every
+address in the file that does not appear in the mailing list, the address is
+added. For every address in the mailing list that does not appear in the
+file, the address is removed. Other options control what happens when an
+address is added or removed.
+
+Usage: %(PROGRAM)s [options] -f file listname
+
+Where `options' are:
+
+ --no-change
+ -n
+ Don't actually make the changes. Instead, print out what would be
+ done to the list.
+
+ --welcome-msg[=]
+ -w[=]
+ Sets whether or not to send the newly added members a welcome
+ message, overriding whatever the list's `send_welcome_msg' setting
+ is. With -w=yes or -w, the welcome message is sent. With -w=no, no
+ message is sent.
+
+ --goodbye-msg[=]
+ -g[=]
+ Sets whether or not to send the goodbye message to removed members,
+ overriding whatever the list's `send_goodbye_msg' setting is. With
+ -g=yes or -g, the goodbye message is sent. With -g=no, no message is
+ sent.
+
+ --digest[=]
+ -d[=]
+ Selects whether to make newly added members receive messages in
+ digests. With -d=yes or -d, they become digest members. With -d=no
+ (or if no -d option given) they are added as regular members.
+
+ --notifyadmin[=]
+ -a[=]
+ Specifies whether the admin should be notified for each subscription
+ or unsubscription. If you're adding a lot of addresses, you
+ definitely want to turn this off! With -a=yes or -a, the admin is
+ notified. With -a=no, the admin is not notified. With no -a option,
+ the default for the list is used.
+
+ --file
+ -f
+ This option is required. It specifies the flat file to synchronize
+ against. Email addresses must appear one per line. If filename is
+ `-' then stdin is used.
+
+ --help
+ -h
+ Print this message.
+
+ listname
+ Required. This specifies the list to synchronize.
+"""
+
+import sys
+
+import paths
+# Import this /after/ paths so that the sys.path is properly hacked
+import email.Utils
+
+from Mailman import MailList
+from Mailman import Errors
+from Mailman import Utils
+from Mailman.UserDesc import UserDesc
+from Mailman.i18n import _
+
+
+
+PROGRAM = sys.argv[0]
+
+def usage(code, msg=''):
+ if code:
+ fd = sys.stderr
+ else:
+ fd = sys.stdout
+ print >> fd, _(__doc__)
+ if msg:
+ print >> fd, msg
+ sys.exit(code)
+
+
+
+def yesno(opt):
+ i = opt.find('=')
+ yesno = opt[i+1:].lower()
+ if yesno in ('y', 'yes'):
+ return 1
+ elif yesno in ('n', 'no'):
+ return 0
+ else:
+ usage(1, _('Bad choice: %(yesno)s'))
+ # no return
+
+
+def main():
+ dryrun = 0
+ digest = 0
+ welcome = None
+ goodbye = None
+ filename = None
+ listname = None
+ notifyadmin = None
+
+ # TBD: can't use getopt with this command line syntax, which is broken and
+ # should be changed to be getopt compatible.
+ i = 1
+ while i < len(sys.argv):
+ opt = sys.argv[i]
+ if opt in ('-h', '--help'):
+ usage(0)
+ elif opt in ('-n', '--no-change'):
+ dryrun = 1
+ i += 1
+ print _('Dry run mode')
+ elif opt in ('-d', '--digest'):
+ digest = 1
+ i += 1
+ elif opt.startswith('-d=') or opt.startswith('--digest='):
+ digest = yesno(opt)
+ i += 1
+ elif opt in ('-w', '--welcome-msg'):
+ welcome = 1
+ i += 1
+ elif opt.startswith('-w=') or opt.startswith('--welcome-msg='):
+ welcome = yesno(opt)
+ i += 1
+ elif opt in ('-g', '--goodbye-msg'):
+ goodbye = 1
+ i += 1
+ elif opt.startswith('-g=') or opt.startswith('--goodbye-msg='):
+ goodbye = yesno(opt)
+ i += 1
+ elif opt in ('-f', '--file'):
+ if filename is not None:
+ usage(1, _('Only one -f switch allowed'))
+ try:
+ filename = sys.argv[i+1]
+ except IndexError:
+ usage(1, _('No argument to -f given'))
+ i += 2
+ elif opt in ('-a', '--notifyadmin'):
+ notifyadmin = 1
+ i += 1
+ elif opt.startswith('-a=') or opt.startswith('--notifyadmin='):
+ notifyadmin = yesno(opt)
+ i += 1
+ elif opt[0] == '-':
+ usage(1, _('Illegal option: %(opt)s'))
+ else:
+ try:
+ listname = sys.argv[i].lower()
+ i += 1
+ except IndexError:
+ usage(1, _('No listname given'))
+ break
+
+ if listname is None or filename is None:
+ usage(1, _('Must have a listname and a filename'))
+
+ # read the list of addresses to sync to from the file
+ if filename == '-':
+ filemembers = sys.stdin.readlines()
+ else:
+ try:
+ fp = open(filename)
+ except IOError, (code, msg):
+ usage(1, _('Cannot read address file: %(filename)s: %(msg)s'))
+ try:
+ filemembers = fp.readlines()
+ finally:
+ fp.close()
+
+ # strip out lines we don't care about, they are comments (# in first
+ # non-whitespace) or are blank
+ for i in range(len(filemembers)-1, -1, -1):
+ addr = filemembers[i].strip()
+ if addr == '' or addr[:1] == '#':
+ del filemembers[i]
+ print _('Ignore : %(addr)30s')
+
+ # first filter out any invalid addresses
+ filemembers = email.Utils.getaddresses(filemembers)
+ invalid = 0
+ for name, addr in filemembers:
+ try:
+ Utils.ValidateEmail(addr)
+ except Errors.EmailAddressError:
+ print _('Invalid : %(addr)30s')
+ invalid = 1
+ if invalid:
+ print _('You must fix the preceding invalid addresses first.')
+ sys.exit(1)
+
+ # get the locked list object
+ try:
+ mlist = MailList.MailList(listname)
+ except Errors.MMListError, e:
+ print _('No such list: %(listname)s')
+ sys.exit(1)
+
+ try:
+ # Get the list of addresses currently subscribed
+ addrs = {}
+ needsadding = {}
+ matches = {}
+ for addr in mlist.getMemberCPAddresses(mlist.getMembers()):
+ addrs[addr.lower()] = addr
+
+ for name, addr in filemembers:
+ # Any address found in the file that is also in the list can be
+ # ignored. If not found in the list, it must be added later.
+ laddr = addr.lower()
+ if addrs.has_key(laddr):
+ del addrs[laddr]
+ matches[laddr] = 1
+ elif not matches.has_key(laddr):
+ needsadding[laddr] = (name, addr)
+
+ if not needsadding and not addrs:
+ print _('Nothing to do.')
+ sys.exit(0)
+
+ enc = sys.getdefaultencoding()
+ # addrs contains now all the addresses that need removing
+ for laddr, (name, addr) in needsadding.items():
+ pw = Utils.MakeRandomPassword()
+ # should not already be subscribed, otherwise our test above is
+ # broken. Bogosity is if the address is listed in the file more
+ # than once. Second and subsequent ones trigger an
+ # MMAlreadyAMember error. Just catch it and go on.
+ userdesc = UserDesc(addr, name, pw, digest)
+ try:
+ if not dryrun:
+ mlist.ApprovedAddMember(userdesc, welcome, notifyadmin)
+ s = email.Utils.formataddr((name, addr)).encode(enc, 'replace')
+ print _('Added : %(s)s')
+ except Errors.MMAlreadyAMember:
+ pass
+
+ for laddr, addr in addrs.items():
+ # Should be a member, otherwise our test above is broken
+ name = mlist.getMemberName(laddr) or ''
+ if not dryrun:
+ try:
+ mlist.ApprovedDeleteMember(addr, admin_notif=notifyadmin,
+ userack=goodbye)
+ except Errors.NotAMemberError:
+ # This can happen if the address is illegal (i.e. can't be
+ # parsed by email.Utils.parseaddr()) but for legacy
+ # reasons is in the database. Use a lower level remove to
+ # get rid of this member's entry
+ mlist.removeMember(addr)
+ s = email.Utils.formataddr((name, addr)).encode(enc, 'replace')
+ print _('Removed: %(s)s')
+
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/attic/bin/templ2pot.py b/src/mailman/attic/bin/templ2pot.py
new file mode 100644
index 000000000..0253cc2cd
--- /dev/null
+++ b/src/mailman/attic/bin/templ2pot.py
@@ -0,0 +1,120 @@
+#! @PYTHON@
+# Code stolen from pygettext.py
+# by Tokio Kikuchi
+
+"""templ2pot.py -- convert mailman template (en) to pot format.
+
+Usage: templ2pot.py inputfile ...
+
+Options:
+
+ -h, --help
+
+Inputfiles are english templates. Outputs are written to stdout.
+"""
+
+import sys
+import getopt
+
+
+
+try:
+ import paths
+ from Mailman.i18n import _
+except ImportError:
+ def _(s): return s
+
+EMPTYSTRING = ''
+
+
+
+def usage(code, msg=''):
+ if code:
+ fd = sys.stderr
+ else:
+ fd = sys.stdout
+ print >> fd, _(__doc__) % globals()
+ if msg:
+ print >> fd, msg
+ sys.exit(code)
+
+
+
+escapes = []
+
+def make_escapes(pass_iso8859):
+ global escapes
+ if pass_iso8859:
+ # Allow iso-8859 characters to pass through so that e.g. 'msgid
+ # "H[o-umlaut]he"' would result not result in 'msgid "H\366he"'.
+ # Otherwise we escape any character outside the 32..126 range.
+ mod = 128
+ else:
+ mod = 256
+ for i in range(256):
+ if 32 <= (i % mod) <= 126:
+ escapes.append(chr(i))
+ else:
+ escapes.append("\\%03o" % i)
+ escapes[ord('\\')] = '\\\\'
+ escapes[ord('\t')] = '\\t'
+ escapes[ord('\r')] = '\\r'
+ escapes[ord('\n')] = '\\n'
+ escapes[ord('\"')] = '\\"'
+
+
+def escape(s):
+ global escapes
+ s = list(s)
+ for i in range(len(s)):
+ s[i] = escapes[ord(s[i])]
+ return EMPTYSTRING.join(s)
+
+
+def normalize(s):
+ # This converts the various Python string types into a format that is
+ # appropriate for .po files, namely much closer to C style.
+ lines = s.splitlines()
+ if len(lines) == 1:
+ s = '"' + escape(s) + '"'
+ else:
+ if not lines[-1]:
+ del lines[-1]
+ lines[-1] = lines[-1] + '\n'
+ for i in range(len(lines)):
+ lines[i] = escape(lines[i])
+ lineterm = '\\n"\n"'
+ s = '""\n"' + lineterm.join(lines) + '"'
+ return s
+
+
+
+def main():
+ try:
+ opts, args = getopt.getopt(
+ sys.argv[1:],
+ 'h',
+ ['help',]
+ )
+ except getopt.error, msg:
+ usage(1, msg)
+
+ # parse options
+ for opt, arg in opts:
+ if opt in ('-h', '--help'):
+ usage(0)
+
+ # calculate escapes
+ make_escapes(0)
+
+ for filename in args:
+ print '#: %s:1' % filename
+ s = file(filename).read()
+ print '#, template'
+ print 'msgid', normalize(s)
+ print 'msgstr ""\n'
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/attic/bin/transcheck b/src/mailman/attic/bin/transcheck
new file mode 100755
index 000000000..73910e771
--- /dev/null
+++ b/src/mailman/attic/bin/transcheck
@@ -0,0 +1,412 @@
+#! @PYTHON@
+#
+# transcheck - (c) 2002 by Simone Piunno
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms of the version 2.0 of the GNU General Public License as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful, but
+# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
+# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
+# for more details.
+#
+# You should have received a copy of the GNU General Public License along
+# with this program; if not, write to the Free Software Foundation, Inc.,
+# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+
+"""
+Check a given Mailman translation, making sure that variables and
+tags referenced in translation are the same variables and tags in
+the original templates and catalog.
+
+Usage:
+
+cd $MAILMAN_DIR
+%(program)s [-q]
+
+Where is your country code (e.g. 'it' for Italy) and -q is
+to ask for a brief summary.
+"""
+
+import sys
+import re
+import os
+import getopt
+
+import paths
+from Mailman.i18n import _
+
+program = sys.argv[0]
+
+
+
+def usage(code, msg=''):
+ if code:
+ fd = sys.stderr
+ else:
+ fd = sys.stdout
+ print >> fd, _(__doc__)
+ if msg:
+ print >> fd, msg
+ sys.exit(code)
+
+
+
+class TransChecker:
+ "check a translation comparing with the original string"
+ def __init__(self, regexp, escaped=None):
+ self.dict = {}
+ self.errs = []
+ self.regexp = re.compile(regexp)
+ self.escaped = None
+ if escaped:
+ self.escaped = re.compile(escaped)
+
+ def checkin(self, string):
+ "scan a string from the original file"
+ for key in self.regexp.findall(string):
+ if self.escaped and self.escaped.match(key):
+ continue
+ if self.dict.has_key(key):
+ self.dict[key] += 1
+ else:
+ self.dict[key] = 1
+
+ def checkout(self, string):
+ "scan a translated string"
+ for key in self.regexp.findall(string):
+ if self.escaped and self.escaped.match(key):
+ continue
+ if self.dict.has_key(key):
+ self.dict[key] -= 1
+ else:
+ self.errs.append(
+ "%(key)s was not found" %
+ { 'key' : key }
+ )
+
+ def computeErrors(self):
+ "check for differences between checked in and checked out"
+ for key in self.dict.keys():
+ if self.dict[key] < 0:
+ self.errs.append(
+ "Too much %(key)s" %
+ { 'key' : key }
+ )
+ if self.dict[key] > 0:
+ self.errs.append(
+ "Too few %(key)s" %
+ { 'key' : key }
+ )
+ return self.errs
+
+ def status(self):
+ if self.errs:
+ return "FAILED"
+ else:
+ return "OK"
+
+ def errorsAsString(self):
+ msg = ""
+ for err in self.errs:
+ msg += " - %(err)s" % { 'err': err }
+ return msg
+
+ def reset(self):
+ self.dict = {}
+ self.errs = []
+
+
+
+class POParser:
+ "parse a .po file extracting msgids and msgstrs"
+ def __init__(self, filename=""):
+ self.status = 0
+ self.files = []
+ self.msgid = ""
+ self.msgstr = ""
+ self.line = 1
+ self.f = None
+ self.esc = { "n": "\n", "r": "\r", "t": "\t" }
+ if filename:
+ self.f = open(filename)
+
+ def open(self, filename):
+ self.f = open(filename)
+
+ def close(self):
+ self.f.close()
+
+ def parse(self):
+ """States table for the finite-states-machine parser:
+ 0 idle
+ 1 filename-or-comment
+ 2 msgid
+ 3 msgstr
+ 4 end
+ """
+ # each time we can safely re-initialize those vars
+ self.files = []
+ self.msgid = ""
+ self.msgstr = ""
+
+
+ # can't continue if status == 4, this is a dead status
+ if self.status == 4:
+ return 0
+
+ while 1:
+ # continue scanning, char-by-char
+ c = self.f.read(1)
+ if not c:
+ # EOF -> maybe we have a msgstr to save?
+ self.status = 4
+ if self.msgstr:
+ return 1
+ else:
+ return 0
+
+ # keep the line count up-to-date
+ if c == "\n":
+ self.line += 1
+
+ # a pound was detected the previous char...
+ if self.status == 1:
+ if c == ":":
+ # was a line of filenames
+ row = self.f.readline()
+ self.files += row.split()
+ self.line += 1
+ elif c == "\n":
+ # was a single pount on the line
+ pass
+ else:
+ # was a comment... discard
+ self.f.readline()
+ self.line += 1
+ # in every case, we switch to idle status
+ self.status = 0;
+ continue
+
+ # in idle status we search for a '#' or for a 'm'
+ if self.status == 0:
+ if c == "#":
+ # this could be a comment or a filename
+ self.status = 1;
+ continue
+ elif c == "m":
+ # this should be a msgid start...
+ s = self.f.read(4)
+ assert s == "sgid"
+ # so now we search for a '"'
+ self.status = 2
+ continue
+ # in idle only those other chars are possibile
+ assert c in [ "\n", " ", "\t" ]
+
+ # searching for the msgid string
+ if self.status == 2:
+ if c == "\n":
+ # a double LF is not possible here
+ c = self.f.read(1)
+ assert c != "\n"
+ if c == "\"":
+ # ok, this is the start of the string,
+ # now search for the end
+ while 1:
+ c = self.f.read(1)
+ if not c:
+ # EOF, bailout
+ self.status = 4
+ return 0
+ if c == "\\":
+ # a quoted char...
+ c = self.f.read(1)
+ if self.esc.has_key(c):
+ self.msgid += self.esc[c]
+ else:
+ self.msgid += c
+ continue
+ if c == "\"":
+ # end of string found
+ break
+ # a normal char, add it
+ self.msgid += c
+ if c == "m":
+ # this should be a msgstr identifier
+ s = self.f.read(5)
+ assert s == "sgstr"
+ # ok, now search for the msgstr string
+ self.status = 3
+
+ # searching for the msgstr string
+ if self.status == 3:
+ if c == "\n":
+ # a double LF is the end of the msgstr!
+ c = self.f.read(1)
+ if c == "\n":
+ # ok, time to go idle and return
+ self.status = 0
+ self.line += 1
+ return 1
+ if c == "\"":
+ # start of string found
+ while 1:
+ c = self.f.read(1)
+ if not c:
+ # EOF, bail out
+ self.status = 4
+ return 1
+ if c == "\\":
+ # a quoted char...
+ c = self.f.read(1)
+ if self.esc.has_key(c):
+ self.msgid += self.esc[c]
+ else:
+ self.msgid += c
+ continue
+ if c == "\"":
+ # end of string
+ break
+ # a normal char, add it
+ self.msgstr += c
+
+
+
+
+def check_file(translatedFile, originalFile, html=0, quiet=0):
+ """check a translated template against the original one
+ search also tags if html is not zero"""
+
+ if html:
+ c = TransChecker("(%%|%\([^)]+\)[0-9]*[sd]|?MM-[^>]+>)", "^%%$")
+ else:
+ c = TransChecker("(%%|%\([^)]+\)[0-9]*[sd])", "^%%$")
+
+ try:
+ f = open(originalFile)
+ except IOError:
+ if not quiet:
+ print " - Can'open original file " + originalFile
+ return 1
+
+ while 1:
+ line = f.readline()
+ if not line: break
+ c.checkin(line)
+
+ f.close()
+
+ try:
+ f = open(translatedFile)
+ except IOError:
+ if not quiet:
+ print " - Can'open translated file " + translatedFile
+ return 1
+
+ while 1:
+ line = f.readline()
+ if not line: break
+ c.checkout(line)
+
+ f.close()
+
+ n = 0
+ msg = ""
+ for desc in c.computeErrors():
+ n +=1
+ if not quiet:
+ print " - %(desc)s" % { 'desc': desc }
+ return n
+
+
+
+def check_po(file, quiet=0):
+ "scan the po file comparing msgids with msgstrs"
+ n = 0
+ p = POParser(file)
+ c = TransChecker("(%%|%\([^)]+\)[0-9]*[sdu]|%[0-9]*[sdu])", "^%%$")
+ while p.parse():
+ if p.msgstr:
+ c.reset()
+ c.checkin(p.msgid)
+ c.checkout(p.msgstr)
+ for desc in c.computeErrors():
+ n += 1
+ if not quiet:
+ print " - near line %(line)d %(file)s: %(desc)s" % {
+ 'line': p.line,
+ 'file': p.files,
+ 'desc': desc
+ }
+ p.close()
+ return n
+
+
+def main():
+ try:
+ opts, args = getopt.getopt(sys.argv[1:], 'qh', ['quiet', 'help'])
+ except getopt.error, msg:
+ usage(1, msg)
+
+ quiet = 0
+ for opt, arg in opts:
+ if opt in ('-h', '--help'):
+ usage(0)
+ elif opt in ('-q', '--quiet'):
+ quiet = 1
+
+ if len(args) <> 1:
+ usage(1)
+
+ lang = args[0]
+
+ isHtml = re.compile("\.html$");
+ isTxt = re.compile("\.txt$");
+
+ numerrors = 0
+ numfiles = 0
+ try:
+ files = os.listdir("templates/" + lang + "/")
+ except:
+ print "can't open templates/%s/" % lang
+ for file in files:
+ fileEN = "templates/en/" + file
+ fileIT = "templates/" + lang + "/" + file
+ errlist = []
+ if isHtml.search(file):
+ if not quiet:
+ print "HTML checking " + fileIT + "... "
+ n = check_file(fileIT, fileEN, html=1, quiet=quiet)
+ if n:
+ numerrors += n
+ numfiles += 1
+ elif isTxt.search(file):
+ if not quiet:
+ print "TXT checking " + fileIT + "... "
+ n = check_file(fileIT, fileEN, html=0, quiet=quiet)
+ if n:
+ numerrors += n
+ numfiles += 1
+
+ else:
+ continue
+
+ file = "messages/" + lang + "/LC_MESSAGES/mailman.po"
+ if not quiet:
+ print "PO checking " + file + "... "
+ n = check_po(file, quiet=quiet)
+ if n:
+ numerrors += n
+ numfiles += 1
+
+ if quiet:
+ print "%(errs)u warnings in %(files)u files" % {
+ 'errs': numerrors,
+ 'files': numfiles
+ }
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/__init__.py b/src/mailman/bin/__init__.py
new file mode 100644
index 000000000..d61693c5e
--- /dev/null
+++ b/src/mailman/bin/__init__.py
@@ -0,0 +1,61 @@
+# Copyright (C) 2007-2009 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 .
+
+__all__ = [
+ 'add_members',
+ 'arch',
+ 'bounces',
+ 'bumpdigests',
+ 'check_perms',
+ 'checkdbs',
+ 'cleanarch',
+ 'config_list',
+ 'confirm',
+ 'create_list',
+ 'disabled',
+ 'dumpdb',
+ 'export',
+ 'find_member',
+ 'gate_news',
+ 'genaliases',
+ 'import',
+ 'inject',
+ 'join',
+ 'leave',
+ 'list_lists',
+ 'list_members',
+ 'list_owners',
+ 'mailmanctl',
+ 'make_instance',
+ 'master',
+ 'mmsitepass',
+ 'nightly_gzip',
+ 'owner',
+ 'post',
+ 'qrunner',
+ 'remove_list',
+ 'request',
+ 'senddigests',
+ 'set_members',
+ 'show_config',
+ 'show_qfiles',
+ 'testall',
+ 'unshunt',
+ 'update',
+ 'version',
+ 'withlist',
+ ]
diff --git a/src/mailman/bin/add_members.py b/src/mailman/bin/add_members.py
new file mode 100644
index 000000000..9c87f4af9
--- /dev/null
+++ b/src/mailman/bin/add_members.py
@@ -0,0 +1,186 @@
+# Copyright (C) 1998-2009 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 .
+
+import os
+import sys
+import codecs
+
+from cStringIO import StringIO
+from email.utils import parseaddr
+
+from mailman import Message
+from mailman import Utils
+from mailman import i18n
+from mailman.app.membership import add_member
+from mailman.config import config
+from mailman.core import errors
+from mailman.interfaces.member import AlreadySubscribedError, DeliveryMode
+from mailman.options import SingleMailingListOptions
+
+_ = i18n._
+
+
+
+class ScriptOptions(SingleMailingListOptions):
+ usage=_("""\
+%prog [options]
+
+Add members to a list. 'listname' is the name of the Mailman list you are
+adding members to; the list must already exist.
+
+You must supply at least one of -r and -d options. At most one of the
+files can be '-'.
+""")
+
+ def add_options(self):
+ super(ScriptOptions, self).add_options()
+ self.parser.add_option(
+ '-r', '--regular-members-file',
+ type='string', dest='regular', help=_("""\
+A file containing addresses of the members to be added, one address per line.
+This list of people become non-digest members. If file is '-', read addresses
+from stdin."""))
+ self.parser.add_option(
+ '-d', '--digest-members-file',
+ type='string', dest='digest', help=_("""\
+Similar to -r, but these people become digest members."""))
+ self.parser.add_option(
+ '-w', '--welcome-msg',
+ type='yesno', metavar='', help=_("""\
+Set whether or not to send the list members a welcome message, overriding
+whatever the list's 'send_welcome_msg' setting is."""))
+ self.parser.add_option(
+ '-a', '--admin-notify',
+ type='yesno', metavar='', help=_("""\
+Set whether or not to send the list administrators a notification on the
+success/failure of these subscriptions, overriding whatever the list's
+'admin_notify_mchanges' setting is."""))
+
+ def sanity_check(self):
+ if not self.options.listname:
+ self.parser.error(_('Missing listname'))
+ if len(self.arguments) > 0:
+ self.parser.print_error(_('Unexpected arguments'))
+ if self.options.regular is None and self.options.digest is None:
+ parser.error(_('At least one of -r or -d is required'))
+ if self.options.regular == '-' and self.options.digest == '-':
+ parser.error(_("-r and -d cannot both be '-'"))
+
+
+
+def readfile(filename):
+ if filename == '-':
+ fp = sys.stdin
+ else:
+ # XXX Need to specify other encodings.
+ fp = codecs.open(filename, encoding='utf-8')
+ # Strip all the lines of whitespace and discard blank lines
+ try:
+ return set(line.strip() for line in fp if line)
+ finally:
+ if fp is not sys.stdin:
+ fp.close()
+
+
+
+class Tee:
+ def __init__(self, outfp):
+ self._outfp = outfp
+
+ def write(self, msg):
+ sys.stdout.write(msg)
+ self._outfp.write(msg)
+
+
+
+def addall(mlist, subscribers, delivery_mode, ack, admin_notify, outfp):
+ tee = Tee(outfp)
+ for subscriber in subscribers:
+ try:
+ fullname, address = parseaddr(subscriber)
+ # Watch out for the empty 8-bit string.
+ if not fullname:
+ fullname = u''
+ password = Utils.MakeRandomPassword()
+ add_member(mlist, address, fullname, password, delivery_mode,
+ unicode(config.mailman.default_language))
+ # XXX Support ack and admin_notify
+ except AlreadySubscribedError:
+ print >> tee, _('Already a member: $subscriber')
+ except errors.InvalidEmailAddress:
+ if not address:
+ print >> tee, _('Bad/Invalid email address: blank line')
+ else:
+ print >> tee, _('Bad/Invalid email address: $subscriber')
+ else:
+ print >> tee, _('Subscribing: $subscriber')
+
+
+
+def main():
+ options = ScriptOptions()
+ options.initialize()
+
+ fqdn_listname = options.options.listname
+ mlist = config.db.list_manager.get(fqdn_listname)
+ if mlist is None:
+ parser.error(_('No such list: $fqdn_listname'))
+
+ # Set up defaults.
+ send_welcome_msg = (options.options.welcome_msg
+ if options.options.welcome_msg is not None
+ else mlist.send_welcome_msg)
+ admin_notify = (options.options.admin_notify
+ if options.options.admin_notify is not None
+ else mlist.admin_notify)
+
+ with i18n.using_language(mlist.preferred_language):
+ if options.options.digest:
+ dmembers = readfile(options.options.digest)
+ else:
+ dmembers = set()
+ if options.options.regular:
+ nmembers = readfile(options.options.regular)
+ else:
+ nmembers = set()
+
+ if not dmembers and not nmembers:
+ print _('Nothing to do.')
+ sys.exit(0)
+
+ outfp = StringIO()
+ if nmembers:
+ addall(mlist, nmembers, DeliveryMode.regular,
+ send_welcome_msg, admin_notify, outfp)
+
+ if dmembers:
+ addall(mlist, dmembers, DeliveryMode.mime_digests,
+ send_welcome_msg, admin_notify, outfp)
+
+ config.db.commit()
+
+ if admin_notify:
+ subject = _('$mlist.real_name subscription notification')
+ msg = Message.UserNotification(
+ mlist.owner, mlist.no_reply_address, subject,
+ outfp.getvalue(), mlist.preferred_language)
+ msg.send(mlist)
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/arch.py b/src/mailman/bin/arch.py
new file mode 100644
index 000000000..a27fa8d7f
--- /dev/null
+++ b/src/mailman/bin/arch.py
@@ -0,0 +1,151 @@
+# Copyright (C) 1998-2009 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 .
+
+import os
+import sys
+import errno
+import shutil
+import optparse
+
+from locknix.lockfile import Lock
+
+from mailman import i18n
+from mailman.Archiver.HyperArch import HyperArchive
+from mailman.Defaults import hours
+from mailman.configuration import config
+from mailman.initialize import initialize
+from mailman.version import MAILMAN_VERSION
+
+_ = i18n._
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%%prog [options] listname [mbox]
+
+Rebuild a list's archive.
+
+Use this command to rebuild the archives for a mailing list. You may want to
+do this if you edit some messages in an archive, or remove some messages from
+an archive.
+
+Where 'mbox' is the path to a list's complete mbox archive. Usually this will
+be some path in the archives/private directory. For example:
+
+% bin/arch mylist archives/private/mylist.mbox/mylist.mbox
+
+'mbox' is optional. If it is missing, it is calculated from the listname.
+"""))
+ parser.add_option('-q', '--quiet',
+ dest='verbose', default=True, action='store_false',
+ help=_('Make the archiver output less verbose'))
+ parser.add_option('--wipe',
+ default=False, action='store_true',
+ help=_("""\
+First wipe out the original archive before regenerating. You usually want to
+specify this argument unless you're generating the archive in chunks."""))
+ parser.add_option('-s', '--start',
+ default=None, type='int', metavar='N',
+ help=_("""\
+Start indexing at article N, where article 0 is the first in the mbox.
+Defaults to 0."""))
+ parser.add_option('-e', '--end',
+ default=None, type='int', metavar='M',
+ help=_("""\
+End indexing at article M. This script is not very efficient with respect to
+memory management, and for large archives, it may not be possible to index the
+mbox entirely. For that reason, you can specify the start and end article
+numbers."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if len(args) < 1:
+ parser.print_help()
+ print >> sys.stderr, _('listname is required')
+ sys.exit(1)
+ if len(args) > 2:
+ parser.print_help()
+ print >> sys.stderr, _('Unexpected arguments')
+ sys.exit(1)
+ return parser, opts, args
+
+
+
+def main():
+ parser, opts, args = parseargs()
+ initialize(opts.config)
+
+ i18n.set_language(config.DEFAULT_SERVER_LANGUAGE)
+
+ listname = args[0].lower().strip()
+ if len(args) < 2:
+ mbox = None
+ else:
+ mbox = args[1]
+
+ # Open the mailing list object
+ mlist = config.list_manager.get(listname)
+ if mlist is None:
+ parser.error(_('No such list: $listname'))
+ if mbox is None:
+ mbox = mlist.ArchiveFileName()
+
+ i18n.set_language(mlist.preferred_language)
+ # Lay claim to the archive's lock file. This is so no other post can
+ # mess up the archive while we're processing it. Try to pick a
+ # suitably long period of time for the lock lifetime even though we
+ # really don't know how long it will take.
+ #
+ # XXX processUnixMailbox() should refresh the lock.
+ lock_path = os.path.join(mlist.data_path, '.archiver.lck')
+ with Lock(lock_path, lifetime=int(hours(3))):
+ # Try to open mbox before wiping old archive.
+ try:
+ fp = open(mbox)
+ except IOError, e:
+ if e.errno == errno.ENOENT:
+ print >> sys.stderr, _('Cannot open mbox file: $mbox')
+ else:
+ print >> sys.stderr, e
+ sys.exit(1)
+ # Maybe wipe the old archives
+ if opts.wipe:
+ if mlist.scrub_nondigest:
+ # TK: save the attachments dir because they are not in mbox
+ saved = False
+ atchdir = os.path.join(mlist.archive_dir(), 'attachments')
+ savedir = os.path.join(mlist.archive_dir() + '.mbox',
+ 'attachments')
+ try:
+ os.rename(atchdir, savedir)
+ saved = True
+ except OSError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ shutil.rmtree(mlist.archive_dir())
+ if mlist.scrub_nondigest and saved:
+ os.renames(savedir, atchdir)
+
+ archiver = HyperArchive(mlist)
+ archiver.VERBOSE = opts.verbose
+ try:
+ archiver.processUnixMailbox(fp, opts.start, opts.end)
+ finally:
+ archiver.close()
+ fp.close()
diff --git a/src/mailman/bin/bumpdigests.py b/src/mailman/bin/bumpdigests.py
new file mode 100644
index 000000000..b1ed37a21
--- /dev/null
+++ b/src/mailman/bin/bumpdigests.py
@@ -0,0 +1,74 @@
+# Copyright (C) 1998-2009 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 .
+
+import sys
+import optparse
+
+from mailman import errors
+from mailman import MailList
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.version import MAILMAN_VERSION
+
+# Work around known problems with some RedHat cron daemons
+import signal
+signal.signal(signal.SIGCHLD, signal.SIG_DFL)
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options] [listname ...]
+
+Increment the digest volume number and reset the digest number to one. All
+the lists named on the command line are bumped. If no list names are given,
+all lists are bumped."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ return opts, args, parser
+
+
+
+def main():
+ opts, args, parser = parseargs()
+ config.load(opts.config)
+
+ listnames = set(args or config.list_manager.names)
+ if not listnames:
+ print _('Nothing to do.')
+ sys.exit(0)
+
+ for listname in listnames:
+ try:
+ # Be sure the list is locked
+ mlist = MailList.MailList(listname)
+ except errors.MMListError, e:
+ parser.print_help()
+ print >> sys.stderr, _('No such list: $listname')
+ sys.exit(1)
+ try:
+ mlist.bump_digest_volume()
+ finally:
+ mlist.Save()
+ mlist.Unlock()
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/check_perms.py b/src/mailman/bin/check_perms.py
new file mode 100644
index 000000000..4b75aa9f6
--- /dev/null
+++ b/src/mailman/bin/check_perms.py
@@ -0,0 +1,408 @@
+# Copyright (C) 1998-2009 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 .
+
+import os
+import sys
+import pwd
+import grp
+import errno
+import optparse
+
+from stat import *
+
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.version import MAILMAN_VERSION
+
+
+# XXX Need to check the archives/private/*/database/* files
+
+
+
+class State:
+ FIX = False
+ VERBOSE = False
+ ERRORS = 0
+
+STATE = State()
+
+DIRPERMS = S_ISGID | S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH
+QFILEPERMS = S_ISGID | S_IRWXU | S_IRWXG
+PYFILEPERMS = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH
+ARTICLEFILEPERMS = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP
+MBOXPERMS = S_IRGRP | S_IWGRP | S_IRUSR | S_IWUSR
+PRIVATEPERMS = QFILEPERMS
+
+
+
+def statmode(path):
+ return os.stat(path).st_mode
+
+
+def statgidmode(path):
+ stat = os.stat(path)
+ return stat.st_mode, stat.st_gid
+
+
+seen = {}
+
+# libc's getgrgid re-opens /etc/group each time :(
+_gidcache = {}
+
+def getgrgid(gid):
+ data = _gidcache.get(gid)
+ if data is None:
+ data = grp.getgrgid(gid)
+ _gidcache[gid] = data
+ return data
+
+
+
+def checkwalk(arg, dirname, names):
+ # Short-circuit duplicates
+ if seen.has_key(dirname):
+ return
+ seen[dirname] = True
+ for name in names:
+ path = os.path.join(dirname, name)
+ if arg.VERBOSE:
+ print _(' checking gid and mode for $path')
+ try:
+ mode, gid = statgidmode(path)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ continue
+ if gid <> MAILMAN_GID:
+ try:
+ groupname = getgrgid(gid)[0]
+ except KeyError:
+ groupname = '' % gid
+ arg.ERRORS += 1
+ print _(
+ '$path bad group (has: $groupname, expected $MAILMAN_GROUP)'),
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chown(path, -1, MAILMAN_GID)
+ else:
+ print
+ # Most directories must be at least rwxrwsr-x.
+ # The private archive directory and database directory must be at
+ # least rwxrws---. Their 'other' permissions are checked in
+ # checkarchives() and checkarchivedbs() below. Their 'user' and
+ # 'group' permissions are checked here.
+ # The directories under qfiles should be rwxrws---. Their 'user' and
+ # 'group' permissions are checked here. Their 'other' permissions
+ # aren't checked.
+ private = config.PRIVATE_ARCHIVE_FILE_DIR
+ if path == private or (
+ os.path.commonprefix((path, private)) == private
+ and os.path.split(path)[1] == 'database'):
+ # then...
+ targetperms = PRIVATEPERMS
+ elif (os.path.commonprefix((path, config.QUEUE_DIR))
+ == config.QUEUE_DIR):
+ targetperms = QFILEPERMS
+ else:
+ targetperms = DIRPERMS
+ octperms = oct(targetperms)
+ if S_ISDIR(mode) and (mode & targetperms) <> targetperms:
+ arg.ERRORS += 1
+ print _('directory permissions must be $octperms: $path'),
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(path, mode | targetperms)
+ else:
+ print
+ elif os.path.splitext(path)[1] in ('.py', '.pyc', '.pyo'):
+ octperms = oct(PYFILEPERMS)
+ if mode & PYFILEPERMS <> PYFILEPERMS:
+ print _('source perms must be $octperms: $path'),
+ arg.ERRORS += 1
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(path, mode | PYFILEPERMS)
+ else:
+ print
+ elif path.endswith('-article'):
+ # Article files must be group writeable
+ octperms = oct(ARTICLEFILEPERMS)
+ if mode & ARTICLEFILEPERMS <> ARTICLEFILEPERMS:
+ print _('article db files must be $octperms: $path'),
+ arg.ERRORS += 1
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(path, mode | ARTICLEFILEPERMS)
+ else:
+ print
+
+
+
+def checkall():
+ # first check PREFIX
+ if STATE.VERBOSE:
+ prefix = config.PREFIX
+ print _('checking mode for $prefix')
+ dirs = {}
+ for d in (config.PREFIX, config.EXEC_PREFIX, config.VAR_PREFIX,
+ config.LOG_DIR):
+ dirs[d] = True
+ for d in dirs.keys():
+ try:
+ mode = statmode(d)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ print _('WARNING: directory does not exist: $d')
+ continue
+ if (mode & DIRPERMS) <> DIRPERMS:
+ STATE.ERRORS += 1
+ print _('directory must be at least 02775: $d'),
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(d, mode | DIRPERMS)
+ else:
+ print
+ # check all subdirs
+ os.path.walk(d, checkwalk, STATE)
+
+
+
+def checkarchives():
+ private = config.PRIVATE_ARCHIVE_FILE_DIR
+ if STATE.VERBOSE:
+ print _('checking perms on $private')
+ # private archives must not be other readable
+ mode = statmode(private)
+ if mode & S_IROTH:
+ STATE.ERRORS += 1
+ print _('$private must not be other-readable'),
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(private, mode & ~S_IROTH)
+ else:
+ print
+ # In addition, on a multiuser system you may want to hide the private
+ # archives so other users can't read them.
+ if mode & S_IXOTH:
+ print _("""\
+Warning: Private archive directory is other-executable (o+x).
+ This could allow other users on your system to read private archives.
+ If you're on a shared multiuser system, you should consult the
+ installation manual on how to fix this.""")
+
+
+
+def checkmboxfile(mboxdir):
+ absdir = os.path.join(config.PRIVATE_ARCHIVE_FILE_DIR, mboxdir)
+ for f in os.listdir(absdir):
+ if not f.endswith('.mbox'):
+ continue
+ mboxfile = os.path.join(absdir, f)
+ mode = statmode(mboxfile)
+ if (mode & MBOXPERMS) <> MBOXPERMS:
+ STATE.ERRORS = STATE.ERRORS + 1
+ print _('mbox file must be at least 0660:'), mboxfile
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(mboxfile, mode | MBOXPERMS)
+ else:
+ print
+
+
+
+def checkarchivedbs():
+ # The archives/private/listname/database file must not be other readable
+ # or executable otherwise those files will be accessible when the archives
+ # are public. That may not be a horrible breach, but let's close this off
+ # anyway.
+ for dir in os.listdir(config.PRIVATE_ARCHIVE_FILE_DIR):
+ if dir.endswith('.mbox'):
+ checkmboxfile(dir)
+ dbdir = os.path.join(config.PRIVATE_ARCHIVE_FILE_DIR, dir, 'database')
+ try:
+ mode = statmode(dbdir)
+ except OSError, e:
+ if e.errno not in (errno.ENOENT, errno.ENOTDIR): raise
+ continue
+ if mode & S_IRWXO:
+ STATE.ERRORS += 1
+ print _('$dbdir "other" perms must be 000'),
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(dbdir, mode & ~S_IRWXO)
+ else:
+ print
+
+
+
+def checkcgi():
+ cgidir = os.path.join(config.EXEC_PREFIX, 'cgi-bin')
+ if STATE.VERBOSE:
+ print _('checking cgi-bin permissions')
+ exes = os.listdir(cgidir)
+ for f in exes:
+ path = os.path.join(cgidir, f)
+ if STATE.VERBOSE:
+ print _(' checking set-gid for $path')
+ mode = statmode(path)
+ if mode & S_IXGRP and not mode & S_ISGID:
+ STATE.ERRORS += 1
+ print _('$path must be set-gid'),
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(path, mode | S_ISGID)
+ else:
+ print
+
+
+
+def checkmail():
+ wrapper = os.path.join(config.WRAPPER_DIR, 'mailman')
+ if STATE.VERBOSE:
+ print _('checking set-gid for $wrapper')
+ mode = statmode(wrapper)
+ if not mode & S_ISGID:
+ STATE.ERRORS += 1
+ print _('$wrapper must be set-gid'),
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(wrapper, mode | S_ISGID)
+
+
+
+def checkadminpw():
+ for pwfile in (os.path.join(config.DATA_DIR, 'adm.pw'),
+ os.path.join(config.DATA_DIR, 'creator.pw')):
+ targetmode = S_IFREG | S_IRUSR | S_IWUSR | S_IRGRP
+ if STATE.VERBOSE:
+ print _('checking permissions on $pwfile')
+ try:
+ mode = statmode(pwfile)
+ except OSError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ return
+ if mode <> targetmode:
+ STATE.ERRORS += 1
+ octmode = oct(mode)
+ print _('$pwfile permissions must be exactly 0640 (got $octmode)'),
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(pwfile, targetmode)
+ else:
+ print
+
+
+def checkmta():
+ if config.MTA:
+ modname = 'mailman.MTA.' + config.MTA
+ __import__(modname)
+ try:
+ sys.modules[modname].checkperms(STATE)
+ except AttributeError:
+ pass
+
+
+
+def checkdata():
+ targetmode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP
+ checkfiles = ('config.pck', 'config.pck.last',
+ 'config.db', 'config.db.last',
+ 'next-digest', 'next-digest-topics',
+ 'digest.mbox', 'pending.pck',
+ 'request.db', 'request.db.tmp')
+ if STATE.VERBOSE:
+ print _('checking permissions on list data')
+ for dir in os.listdir(config.LIST_DATA_DIR):
+ for file in checkfiles:
+ path = os.path.join(config.LIST_DATA_DIR, dir, file)
+ if STATE.VERBOSE:
+ print _(' checking permissions on: $path')
+ try:
+ mode = statmode(path)
+ except OSError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ continue
+ if (mode & targetmode) <> targetmode:
+ STATE.ERRORS += 1
+ print _('file permissions must be at least 660: $path'),
+ if STATE.FIX:
+ print _('(fixing)')
+ os.chmod(path, mode | targetmode)
+ else:
+ print
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options]
+
+Check the permissions of all Mailman files. With no options, just report the
+permission and ownership problems found."""))
+ parser.add_option('-f', '--fix',
+ default=False, action='store_true', help=_("""\
+Fix all permission and ownership problems found. With this option, you must
+run check_perms as root."""))
+ parser.add_option('-v', '--verbose',
+ default=False, action='store_true',
+ help=_('Produce more verbose output'))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if args:
+ parser.print_help()
+ print >> sys.stderr, _('Unexpected arguments')
+ sys.exit(1)
+ return parser, opts, args
+
+
+
+def main():
+ global MAILMAN_USER, MAILMAN_GROUP, MAILMAN_UID, MAILMAN_GID
+
+ parser, opts, args = parseargs()
+ STATE.FIX = opts.fix
+ STATE.VERBOSE = opts.verbose
+
+ config.load(opts.config)
+
+ MAILMAN_USER = config.MAILMAN_USER
+ MAILMAN_GROUP = config.MAILMAN_GROUP
+ # Let KeyErrors percolate
+ MAILMAN_GID = grp.getgrnam(MAILMAN_GROUP).gr_gid
+ MAILMAN_UID = pwd.getpwnam(MAILMAN_USER).pw_uid
+
+ checkall()
+ checkarchives()
+ checkarchivedbs()
+ checkcgi()
+ checkmail()
+ checkdata()
+ checkadminpw()
+ checkmta()
+
+ if not STATE.ERRORS:
+ print _('No problems found')
+ else:
+ print _('Problems found:'), STATE.ERRORS
+ print _('Re-run as $MAILMAN_USER (or root) with -f flag to fix')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/checkdbs.py b/src/mailman/bin/checkdbs.py
new file mode 100644
index 000000000..2ce08aab7
--- /dev/null
+++ b/src/mailman/bin/checkdbs.py
@@ -0,0 +1,199 @@
+# Copyright (C) 1998-2009 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 .
+
+import sys
+import time
+import optparse
+
+from email.Charset import Charset
+
+from mailman import MailList
+from mailman import Message
+from mailman import Utils
+from mailman import i18n
+from mailman.app.requests import handle_request
+from mailman.configuration import config
+from mailman.version import MAILMAN_VERSION
+
+_ = i18n._
+
+# Work around known problems with some RedHat cron daemons
+import signal
+signal.signal(signal.SIGCHLD, signal.SIG_DFL)
+
+NL = u'\n'
+now = time.time()
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options]
+
+Check for pending admin requests and mail the list owners if necessary."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if args:
+ parser.print_help()
+ print >> sys.stderr, _('Unexpected arguments')
+ sys.exit(1)
+ return opts, args, parser
+
+
+
+def pending_requests(mlist):
+ # Must return a byte string
+ lcset = Utils.GetCharSet(mlist.preferred_language)
+ pending = []
+ first = True
+ requestsdb = config.db.get_list_requests(mlist)
+ for request in requestsdb.of_type(RequestType.subscription):
+ if first:
+ pending.append(_('Pending subscriptions:'))
+ first = False
+ key, data = requestsdb.get_request(request.id)
+ when = data['when']
+ addr = data['addr']
+ fullname = data['fullname']
+ passwd = data['passwd']
+ digest = data['digest']
+ lang = data['lang']
+ if fullname:
+ if isinstance(fullname, unicode):
+ fullname = fullname.encode(lcset, 'replace')
+ fullname = ' (%s)' % fullname
+ pending.append(' %s%s %s' % (addr, fullname, time.ctime(when)))
+ first = True
+ for request in requestsdb.of_type(RequestType.held_message):
+ if first:
+ pending.append(_('\nPending posts:'))
+ first = False
+ key, data = requestsdb.get_request(request.id)
+ when = data['when']
+ sender = data['sender']
+ subject = data['subject']
+ reason = data['reason']
+ text = data['text']
+ msgdata = data['msgdata']
+ subject = Utils.oneline(subject, lcset)
+ date = time.ctime(when)
+ reason = _(reason)
+ pending.append(_("""\
+From: $sender on $date
+Subject: $subject
+Cause: $reason"""))
+ pending.append('')
+ # Coerce all items in pending to a Unicode so we can join them
+ upending = []
+ charset = Utils.GetCharSet(mlist.preferred_language)
+ for s in pending:
+ if isinstance(s, unicode):
+ upending.append(s)
+ else:
+ upending.append(unicode(s, charset, 'replace'))
+ # Make sure that the text we return from here can be encoded to a byte
+ # string in the charset of the list's language. This could fail if for
+ # example, the request was pended while the list's language was French,
+ # but then it was changed to English before checkdbs ran.
+ text = NL.join(upending)
+ charset = Charset(Utils.GetCharSet(mlist.preferred_language))
+ incodec = charset.input_codec or 'ascii'
+ outcodec = charset.output_codec or 'ascii'
+ if isinstance(text, unicode):
+ return text.encode(outcodec, 'replace')
+ # Be sure this is a byte string encodeable in the list's charset
+ utext = unicode(text, incodec, 'replace')
+ return utext.encode(outcodec, 'replace')
+
+
+
+def auto_discard(mlist):
+ # Discard old held messages
+ discard_count = 0
+ expire = config.days(mlist.max_days_to_hold)
+ requestsdb = config.db.get_list_requests(mlist)
+ heldmsgs = list(requestsdb.of_type(RequestType.held_message))
+ if expire and heldmsgs:
+ for request in heldmsgs:
+ key, data = requestsdb.get_request(request.id)
+ if now - data['date'] > expire:
+ handle_request(mlist, request.id, config.DISCARD)
+ discard_count += 1
+ mlist.Save()
+ return discard_count
+
+
+
+def main():
+ opts, args, parser = parseargs()
+ config.load(opts.config)
+
+ i18n.set_language(config.DEFAULT_SERVER_LANGUAGE)
+
+ for name in config.list_manager.names:
+ # The list must be locked in order to open the requests database
+ mlist = MailList.MailList(name)
+ try:
+ count = config.db.requests.get_list_requests(mlist).count
+ # While we're at it, let's evict yesterday's autoresponse data
+ midnight_today = Utils.midnight()
+ evictions = []
+ for sender in mlist.hold_and_cmd_autoresponses.keys():
+ date, respcount = mlist.hold_and_cmd_autoresponses[sender]
+ if Utils.midnight(date) < midnight_today:
+ evictions.append(sender)
+ if evictions:
+ for sender in evictions:
+ del mlist.hold_and_cmd_autoresponses[sender]
+ # This is the only place we've changed the list's database
+ mlist.Save()
+ if count:
+ i18n.set_language(mlist.preferred_language)
+ realname = mlist.real_name
+ discarded = auto_discard(mlist)
+ if discarded:
+ count = count - discarded
+ text = _(
+ 'Notice: $discarded old request(s) automatically expired.\n\n')
+ else:
+ text = ''
+ if count:
+ text += Utils.maketext(
+ 'checkdbs.txt',
+ {'count' : count,
+ 'host_name': mlist.host_name,
+ 'adminDB' : mlist.GetScriptURL('admindb', absolute=1),
+ 'real_name': realname,
+ }, mlist=mlist)
+ text += '\n' + pending_requests(mlist)
+ subject = _('$count $realname moderator request(s) waiting')
+ else:
+ subject = _('$realname moderator request check result')
+ msg = Message.UserNotification(mlist.GetOwnerEmail(),
+ mlist.GetBouncesEmail(),
+ subject, text,
+ mlist.preferred_language)
+ msg.send(mlist, **{'tomoderators': True})
+ finally:
+ mlist.Unlock()
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/cleanarch.py b/src/mailman/bin/cleanarch.py
new file mode 100644
index 000000000..325fad91a
--- /dev/null
+++ b/src/mailman/bin/cleanarch.py
@@ -0,0 +1,133 @@
+# Copyright (C) 2001-2009 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 .
+
+"""Clean up an .mbox archive file."""
+
+import re
+import sys
+import mailbox
+import optparse
+
+from mailman.i18n import _
+from mailman.version import MAILMAN_VERSION
+
+
+cre = re.compile(mailbox.UnixMailbox._fromlinepattern)
+# From RFC 2822, a header field name must contain only characters from 33-126
+# inclusive, excluding colon. I.e. from oct 41 to oct 176 less oct 072. Must
+# use re.match() so that it's anchored at the beginning of the line.
+fre = re.compile(r'[\041-\071\073-\176]+')
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options] < inputfile > outputfile
+
+The archiver looks for Unix-From lines separating messages in an mbox archive
+file. For compatibility, it specifically looks for lines that start with
+'From ' -- i.e. the letters capital-F, lowercase-r, o, m, space, ignoring
+everything else on the line.
+
+Normally, any lines that start 'From ' in the body of a message should be
+escaped such that a > character is actually the first on a line. It is
+possible though that body lines are not actually escaped. This script
+attempts to fix these by doing a stricter test of the Unix-From lines. Any
+lines that start From ' but do not pass this stricter test are escaped with a
+'>' character."""))
+ parser.add_option('-q', '--quiet',
+ default=False, action='store_true', help=_("""\
+Don't print changed line information to standard error."""))
+ parser.add_option('-s', '--status',
+ default=-1, type='int', help=_("""\
+Print a '#' character for every n lines processed. With a number less than or
+equal to zero, suppress the '#' characters."""))
+ parser.add_option('-n', '--dry-run',
+ default=False, action='store_true', help=_("""\
+Don't actually output anything."""))
+ opts, args = parser.parser_args()
+ if args:
+ parser.print_error(_('Unexpected arguments'))
+ return parser, opts, args
+
+
+
+def escape_line(line, lineno, quiet, output):
+ if output:
+ sys.stdout.write('>' + line)
+ if not quiet:
+ print >> sys.stderr, _('Unix-From line changed: $lineno')
+ print >> sys.stderr, line[:-1]
+
+
+
+def main():
+ parser, opts, args = parseargs()
+
+ lineno = 0
+ statuscnt = 0
+ messages = 0
+ prevline = None
+ while True:
+ lineno += 1
+ line = sys.stdin.readline()
+ if not line:
+ break
+ if line.startswith('From '):
+ if cre.match(line):
+ # This is a real Unix-From line. But it could be a message
+ # /about/ Unix-From lines, so as a second order test, make
+ # sure there's at least one RFC 2822 header following
+ nextline = sys.stdin.readline()
+ lineno += 1
+ if not nextline:
+ # It was the last line of the mbox, so it couldn't have
+ # been a Unix-From
+ escape_line(line, lineno, quiet, output)
+ break
+ fieldname = nextline.split(':', 1)
+ if len(fieldname) < 2 or not fre.match(nextline):
+ # The following line was not a header, so this wasn't a
+ # valid Unix-From
+ escape_line(line, lineno, quiet, output)
+ if output:
+ sys.stdout.write(nextline)
+ else:
+ # It's a valid Unix-From line
+ messages += 1
+ if output:
+ # Before we spit out the From_ line, make sure the
+ # previous line was blank.
+ if prevline is not None and prevline <> '\n':
+ sys.stdout.write('\n')
+ sys.stdout.write(line)
+ sys.stdout.write(nextline)
+ else:
+ # This is a bogus Unix-From line
+ escape_line(line, lineno, quiet, output)
+ elif output:
+ # Any old line
+ sys.stdout.write(line)
+ if status > 0 and (lineno % status) == 0:
+ sys.stderr.write('#')
+ statuscnt += 1
+ if statuscnt > 50:
+ print >> sys.stderr
+ statuscnt = 0
+ prevline = line
+ print >> sys.stderr, _('%(messages)d messages found')
diff --git a/src/mailman/bin/config_list.py b/src/mailman/bin/config_list.py
new file mode 100644
index 000000000..a5cec9480
--- /dev/null
+++ b/src/mailman/bin/config_list.py
@@ -0,0 +1,332 @@
+# Copyright (C) 1998-2009 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 .
+
+import re
+import sys
+import time
+import optparse
+
+from mailman import errors
+from mailman import MailList
+from mailman import Utils
+from mailman import i18n
+from mailman.configuration import config
+from mailman.version import MAILMAN_VERSION
+
+_ = i18n._
+
+NL = '\n'
+nonasciipat = re.compile(r'[\x80-\xff]')
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options] listname
+
+Configure a list from a text file description, or dump a list's configuration
+settings."""))
+ parser.add_option('-i', '--inputfile',
+ metavar='FILENAME', default=None, type='string',
+ help=_("""\
+Configure the list by assigning each module-global variable in the file to an
+attribute on the mailing list object, then save the list. The named file is
+loaded with execfile() and must be legal Python code. Any variable that isn't
+already an attribute of the list object is ignored (a warning message is
+printed). See also the -c option.
+
+A special variable named 'mlist' is put into the globals during the execfile,
+which is bound to the actual MailList object. This lets you do all manner of
+bizarre thing to the list object, but BEWARE! Using this can severely (and
+possibly irreparably) damage your mailing list!
+
+The may not be used with the -o option."""))
+ parser.add_option('-o', '--outputfile',
+ metavar='FILENAME', default=None, type='string',
+ help=_("""\
+Instead of configuring the list, print out a mailing list's configuration
+variables in a format suitable for input using this script. In this way, you
+can easily capture the configuration settings for a particular list and
+imprint those settings on another list. FILENAME is the file to output the
+settings to. If FILENAME is `-', standard out is used.
+
+This may not be used with the -i option."""))
+ parser.add_option('-c', '--checkonly',
+ default=False, action='store_true', help=_("""\
+With this option, the modified list is not actually changed. This is only
+useful with the -i option."""))
+ parser.add_option('-v', '--verbose',
+ default=False, action='store_true', help=_("""\
+Print the name of each attribute as it is being changed. This is only useful
+with the -i option."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if len(args) > 1:
+ parser.print_help()
+ parser.error(_('Unexpected arguments'))
+ if not args:
+ parser.error(_('List name is required'))
+ return parser, opts, args
+
+
+
+def do_output(listname, outfile, parser):
+ closep = False
+ try:
+ if outfile == '-':
+ outfp = sys.stdout
+ else:
+ outfp = open(outfile, 'w')
+ closep = True
+ # Open the specified list unlocked, since we're only reading it.
+ try:
+ mlist = MailList.MailList(listname, lock=False)
+ except errors.MMListError:
+ parser.error(_('No such list: $listname'))
+ # Preamble for the config info. PEP 263 charset and capture time.
+ language = mlist.preferred_language
+ charset = Utils.GetCharSet(language)
+ i18n.set_language(language)
+ if not charset:
+ charset = 'us-ascii'
+ when = time.ctime(time.time())
+ print >> outfp, _('''\
+# -*- python -*-
+# -*- coding: $charset -*-
+## "$listname" mailing list configuration settings
+## captured on $when
+''')
+ # Get all the list config info. All this stuff is accessible via the
+ # web interface.
+ for k in config.ADMIN_CATEGORIES:
+ subcats = mlist.GetConfigSubCategories(k)
+ if subcats is None:
+ do_list_categories(mlist, k, None, outfp)
+ else:
+ for subcat in [t[0] for t in subcats]:
+ do_list_categories(mlist, k, subcat, outfp)
+ finally:
+ if closep:
+ outfp.close()
+
+
+
+def do_list_categories(mlist, k, subcat, outfp):
+ info = mlist.GetConfigInfo(k, subcat)
+ label, gui = mlist.GetConfigCategories()[k]
+ if info is None:
+ return
+ charset = Utils.GetCharSet(mlist.preferred_language)
+ print >> outfp, '##', k.capitalize(), _('options')
+ print >> outfp, '#'
+ # First, massage the descripton text, which could have obnoxious
+ # leading whitespace on second and subsequent lines due to
+ # triple-quoted string nonsense in the source code.
+ desc = NL.join([s.lstrip() for s in info[0].splitlines()])
+ # Print out the category description
+ desc = Utils.wrap(desc)
+ for line in desc.splitlines():
+ print >> outfp, '#', line
+ print >> outfp
+ for data in info[1:]:
+ if not isinstance(data, tuple):
+ continue
+ varname = data[0]
+ # Variable could be volatile
+ if varname[0] == '_':
+ continue
+ vtype = data[1]
+ # First, massage the descripton text, which could have
+ # obnoxious leading whitespace on second and subsequent lines
+ # due to triple-quoted string nonsense in the source code.
+ desc = NL.join([s.lstrip() for s in data[-1].splitlines()])
+ # Now strip out all HTML tags
+ desc = re.sub('<.*?>', '', desc)
+ # And convert </> to <>
+ desc = re.sub('<', '<', desc)
+ desc = re.sub('>', '>', desc)
+ # Print out the variable description.
+ desc = Utils.wrap(desc)
+ for line in desc.split('\n'):
+ print >> outfp, '#', line
+ # munge the value based on its type
+ value = None
+ if hasattr(gui, 'getValue'):
+ value = gui.getValue(mlist, vtype, varname, data[2])
+ if value is None and not varname.startswith('_'):
+ value = getattr(mlist, varname)
+ if vtype in (config.String, config.Text, config.FileUpload):
+ print >> outfp, varname, '=',
+ lines = value.splitlines()
+ if not lines:
+ print >> outfp, "''"
+ elif len(lines) == 1:
+ if charset <> 'us-ascii' and nonasciipat.search(lines[0]):
+ # This is more readable for non-english list.
+ print >> outfp, '"' + lines[0].replace('"', '\\"') + '"'
+ else:
+ print >> outfp, repr(lines[0])
+ else:
+ if charset == 'us-ascii' and nonasciipat.search(value):
+ # Normally, an english list should not have non-ascii char.
+ print >> outfp, repr(NL.join(lines))
+ else:
+ outfp.write(' """')
+ outfp.write(NL.join(lines).replace('"', '\\"'))
+ outfp.write('"""\n')
+ elif vtype in (config.Radio, config.Toggle):
+ print >> outfp, '#'
+ print >> outfp, '#', _('legal values are:')
+ # TBD: This is disgusting, but it's special cased
+ # everywhere else anyway...
+ if varname == 'subscribe_policy' and \
+ not config.ALLOW_OPEN_SUBSCRIBE:
+ i = 1
+ else:
+ i = 0
+ for choice in data[2]:
+ print >> outfp, '# ', i, '= "%s"' % choice
+ i += 1
+ print >> outfp, varname, '=', repr(value)
+ else:
+ print >> outfp, varname, '=', repr(value)
+ print >> outfp
+
+
+
+def getPropertyMap(mlist):
+ guibyprop = {}
+ categories = mlist.GetConfigCategories()
+ for category, (label, gui) in categories.items():
+ if not hasattr(gui, 'GetConfigInfo'):
+ continue
+ subcats = mlist.GetConfigSubCategories(category)
+ if subcats is None:
+ subcats = [(None, None)]
+ for subcat, sclabel in subcats:
+ for element in gui.GetConfigInfo(mlist, category, subcat):
+ if not isinstance(element, tuple):
+ continue
+ propname = element[0]
+ wtype = element[1]
+ guibyprop[propname] = (gui, wtype)
+ return guibyprop
+
+
+class FakeDoc:
+ # Fake the error reporting API for the htmlformat.Document class
+ def addError(self, s, tag=None, *args):
+ if tag:
+ print >> sys.stderr, tag
+ print >> sys.stderr, s % args
+
+ def set_language(self, val):
+ pass
+
+
+
+def do_input(listname, infile, checkonly, verbose, parser):
+ fakedoc = FakeDoc()
+ # Open the specified list locked, unless checkonly is set
+ try:
+ mlist = MailList.MailList(listname, lock=not checkonly)
+ except errors.MMListError, e:
+ parser.error(_('No such list "$listname"\n$e'))
+ savelist = False
+ guibyprop = getPropertyMap(mlist)
+ try:
+ globals = {'mlist': mlist}
+ # Any exception that occurs in execfile() will cause the list to not
+ # be saved, but any other problems are not save-fatal.
+ execfile(infile, globals)
+ savelist = True
+ for k, v in globals.items():
+ if k in ('mlist', '__builtins__'):
+ continue
+ if not hasattr(mlist, k):
+ print >> sys.stderr, _('attribute "$k" ignored')
+ continue
+ if verbose:
+ print >> sys.stderr, _('attribute "$k" changed')
+ missing = []
+ gui, wtype = guibyprop.get(k, (missing, missing))
+ if gui is missing:
+ # This isn't an official property of the list, but that's
+ # okay, we'll just restore it the old fashioned way
+ print >> sys.stderr, _('Non-standard property restored: $k')
+ setattr(mlist, k, v)
+ else:
+ # BAW: This uses non-public methods. This logic taken from
+ # the guts of GUIBase.handleForm().
+ try:
+ validval = gui._getValidValue(mlist, k, wtype, v)
+ except ValueError:
+ print >> sys.stderr, _('Invalid value for property: $k')
+ except errors.EmailAddressError:
+ print >> sys.stderr, _(
+ 'Bad email address for option $k: $v')
+ else:
+ # BAW: Horrible hack, but then this is special cased
+ # everywhere anyway. :( Privacy._setValue() knows that
+ # when ALLOW_OPEN_SUBSCRIBE is false, the web values are
+ # 0, 1, 2 but these really should be 1, 2, 3, so it adds
+ # one. But we really do provide [0..3] so we need to undo
+ # the hack that _setValue adds. :( :(
+ if k == 'subscribe_policy' and \
+ not config.ALLOW_OPEN_SUBSCRIBE:
+ validval -= 1
+ # BAW: Another horrible hack. This one is just too hard
+ # to fix in a principled way in Mailman 2.1
+ elif k == 'new_member_options':
+ # Because this is a Checkbox, _getValidValue()
+ # transforms the value into a list of one item.
+ validval = validval[0]
+ validval = [bitfield for bitfield, bitval
+ in config.OPTINFO.items()
+ if validval & bitval]
+ gui._setValue(mlist, k, validval, fakedoc)
+ # BAW: when to do gui._postValidate()???
+ finally:
+ if savelist and not checkonly:
+ mlist.Save()
+ mlist.Unlock()
+
+
+
+def main():
+ parser, opts, args = parseargs()
+ config.load(opts.config)
+ listname = args[0]
+
+ # Sanity check
+ if opts.inputfile and opts.outputfile:
+ parser.error(_('Only one of -i or -o is allowed'))
+ if not opts.inputfile and not opts.outputfile:
+ parser.error(_('One of -i or -o is required'))
+
+ if opts.outputfile:
+ do_output(listname, opts.outputfile, parser)
+ else:
+ do_input(listname, opts.inputfile, opts.checkonly,
+ opts.verbose, parser)
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/create_list.py b/src/mailman/bin/create_list.py
new file mode 100644
index 000000000..8058a7d67
--- /dev/null
+++ b/src/mailman/bin/create_list.py
@@ -0,0 +1,129 @@
+# Copyright (C) 1998-2009 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 .
+
+import sys
+
+from mailman import Message
+from mailman import Utils
+from mailman import i18n
+from mailman.app.lifecycle import create_list
+from mailman.config import config
+from mailman.core import errors
+from mailman.interfaces.listmanager import ListAlreadyExistsError
+from mailman.options import SingleMailingListOptions
+
+
+_ = i18n._
+
+
+
+class ScriptOptions(SingleMailingListOptions):
+ usage = _("""\
+%prog [options]
+
+Create a new mailing list.
+
+fqdn_listname is the 'fully qualified list name', basically the posting
+address of the list. It must be a valid email address and the domain must be
+registered with Mailman.
+
+Note that listnames are forced to lowercase.""")
+
+ def add_options(self):
+ super(ScriptOptions, self).add_options()
+ self.parser.add_option(
+ '--language',
+ type='unicode', action='store',
+ help=_("""\
+Make the list's preferred language LANGUAGE, which must be a two letter
+language code."""))
+ self.parser.add_option(
+ '-o', '--owner',
+ type='unicode', action='append', default=[],
+ dest='owners', help=_("""\
+Specific a listowner email address. If the address is not currently
+registered with Mailman, the address is registered and linked to a user.
+Mailman will send a confirmation message to the address, but it will also send
+a list creation notice to the address. More than one owner can be
+specified."""))
+ self.parser.add_option(
+ '-q', '--quiet',
+ default=False, action='store_true',
+ help=_("""\
+Normally the administrator is notified by email (after a prompt) that their
+list has been created. This option suppresses the prompt and
+notification."""))
+ self.parser.add_option(
+ '-a', '--automate',
+ default=False, action='store_true',
+ help=_("""\
+This option suppresses the prompt prior to administrator notification but
+still sends the notification. It can be used to make newlist totally
+non-interactive but still send the notification, assuming at least one list
+owner is specified with the -o option.."""))
+
+ def sanity_check(self):
+ """Set up some defaults we couldn't set up earlier."""
+ if self.options.language is None:
+ self.options.language = unicode(config.mailman.default_language)
+ # Is the language known?
+ if self.options.language not in config.languages.enabled_codes:
+ self.parser.error(_('Unknown language: $opts.language'))
+ # Handle variable number of positional arguments
+ if len(self.arguments) > 0:
+ parser.error(_('Unexpected arguments'))
+
+
+
+def main():
+ options = ScriptOptions()
+ options.initialize()
+
+ # Create the mailing list, applying styles as appropriate.
+ fqdn_listname = options.options.listname
+ if fqdn_listname is None:
+ options.parser.error(_('--listname is required'))
+ try:
+ mlist = create_list(fqdn_listname, options.options.owners)
+ mlist.preferred_language = options.options.language
+ except errors.InvalidEmailAddress:
+ options.parser.error(_('Illegal list name: $fqdn_listname'))
+ except ListAlreadyExistsError:
+ options.parser.error(_('List already exists: $fqdn_listname'))
+ except errors.BadDomainSpecificationError, domain:
+ options.parser.error(_('Undefined domain: $domain'))
+
+ config.db.commit()
+
+ if not options.options.quiet:
+ d = dict(
+ listname = mlist.fqdn_listname,
+ admin_url = mlist.script_url('admin'),
+ listinfo_url = mlist.script_url('listinfo'),
+ requestaddr = mlist.request_address,
+ siteowner = mlist.no_reply_address,
+ )
+ text = Utils.maketext('newlist.txt', d, mlist=mlist)
+ # Set the I18N language to the list's preferred language so the header
+ # will match the template language. Stashing and restoring the old
+ # translation context is just (healthy? :) paranoia.
+ with i18n.using_language(mlist.preferred_language):
+ msg = Message.UserNotification(
+ owner_mail, mlist.no_reply_address,
+ _('Your new mailing list: $fqdn_listname'),
+ text, mlist.preferred_language)
+ msg.send(mlist)
diff --git a/src/mailman/bin/disabled.py b/src/mailman/bin/disabled.py
new file mode 100644
index 000000000..cc8eb2c69
--- /dev/null
+++ b/src/mailman/bin/disabled.py
@@ -0,0 +1,201 @@
+# Copyright (C) 2001-2009 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 .
+
+import time
+import logging
+import optparse
+
+from mailman import errors
+from mailman import MailList
+from mailman import MemberAdaptor
+from mailman import Pending
+from mailman import loginit
+from mailman.Bouncer import _BounceInfo
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.version import MAILMAN_VERSION
+
+
+# Work around known problems with some RedHat cron daemons
+import signal
+signal.signal(signal.SIGCHLD, signal.SIG_DFL)
+
+ALL = (MemberAdaptor.BYBOUNCE,
+ MemberAdaptor.BYADMIN,
+ MemberAdaptor.BYUSER,
+ MemberAdaptor.UNKNOWN,
+ )
+
+
+
+def who_callback(option, opt, value, parser):
+ dest = getattr(parser.values, option.dest)
+ if opt in ('-o', '--byadmin'):
+ dest.add(MemberAdaptor.BYADMIN)
+ elif opt in ('-m', '--byuser'):
+ dest.add(MemberAdaptor.BYUSER)
+ elif opt in ('-u', '--unknown'):
+ dest.add(MemberAdaptor.UNKNOWN)
+ elif opt in ('-b', '--notbybounce'):
+ dest.discard(MemberAdaptor.BYBOUNCE)
+ elif opt in ('-a', '--all'):
+ dest.update(ALL)
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options]
+
+Process disabled members, recommended once per day.
+
+This script iterates through every mailing list looking for members whose
+delivery is disabled. If they have been disabled due to bounces, they will
+receive another notification, or they may be removed if they've received the
+maximum number of notifications.
+
+Use the --byadmin, --byuser, and --unknown flags to also send notifications to
+members whose accounts have been disabled for those reasons. Use --all to
+send the notification to all disabled members."""))
+ # This is the set of working flags for who to send notifications to. By
+ # default, we notify anybody who has been disable due to bounces.
+ parser.set_defaults(who=set([MemberAdaptor.BYBOUNCE]))
+ parser.add_option('-o', '--byadmin',
+ callback=who_callback, action='callback', dest='who',
+ help=_("""\
+Also send notifications to any member disabled by the list
+owner/administrator."""))
+ parser.add_option('-m', '--byuser',
+ callback=who_callback, action='callback', dest='who',
+ help=_("""\
+Also send notifications to any member who has disabled themself."""))
+ parser.add_option('-u', '--unknown',
+ callback=who_callback, action='callback', dest='who',
+ help=_("""\
+Also send notifications to any member disabled for unknown reasons
+(usually a legacy disabled address)."""))
+ parser.add_option('-b', '--notbybounce',
+ callback=who_callback, action='callback', dest='who',
+ help=_("""\
+Don't send notifications to members disabled because of bounces (the
+default is to notify bounce disabled members)."""))
+ parser.add_option('-a', '--all',
+ callback=who_callback, action='callback', dest='who',
+ help=_('Send notifications to all disabled members'))
+ parser.add_option('-f', '--force',
+ default=False, action='store_true',
+ help=_("""\
+Send notifications to disabled members even if they're not due a new
+notification yet."""))
+ parser.add_option('-l', '--listname',
+ dest='listnames', action='append', default=[],
+ type='string', help=_("""\
+Process only the given list, otherwise do all lists."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ return opts, args, parser
+
+
+
+def main():
+ opts, args, parser = parseargs()
+ config.load(opts.config)
+
+ loginit.initialize(propagate=True)
+ elog = logging.getLogger('mailman.error')
+ blog = logging.getLogger('mailman.bounce')
+
+ listnames = set(opts.listnames or config.list_manager.names)
+ who = tuple(opts.who)
+
+ msg = _('[disabled by periodic sweep and cull, no message available]')
+ today = time.mktime(time.localtime()[:3] + (0,) * 6)
+ for listname in listnames:
+ # List of members to notify
+ notify = []
+ mlist = MailList.MailList(listname)
+ try:
+ interval = mlist.bounce_you_are_disabled_warnings_interval
+ # Find all the members who are currently bouncing and see if
+ # they've reached the disable threshold but haven't yet been
+ # disabled. This is a sweep through the membership catching
+ # situations where they've bounced a bunch, then the list admin
+ # lowered the threshold, but we haven't (yet) seen more bounces
+ # from the member. Note: we won't worry about stale information
+ # or anything else since the normal bounce processing code will
+ # handle that.
+ disables = []
+ for member in mlist.getBouncingMembers():
+ if mlist.getDeliveryStatus(member) <> MemberAdaptor.ENABLED:
+ continue
+ info = mlist.getBounceInfo(member)
+ if info.score >= mlist.bounce_score_threshold:
+ disables.append((member, info))
+ if disables:
+ for member, info in disables:
+ mlist.disableBouncingMember(member, info, msg)
+ # Go through all the members who have delivery disabled, and find
+ # those that are due to have another notification. If they are
+ # disabled for another reason than bouncing, and we're processing
+ # them (because of the command line switch) then they won't have a
+ # bounce info record. We can piggyback on that for all disable
+ # purposes.
+ members = mlist.getDeliveryStatusMembers(who)
+ for member in members:
+ info = mlist.getBounceInfo(member)
+ if not info:
+ # See if they are bounce disabled, or disabled for some
+ # other reason.
+ status = mlist.getDeliveryStatus(member)
+ if status == MemberAdaptor.BYBOUNCE:
+ elog.error(
+ '%s disabled BYBOUNCE lacks bounce info, list: %s',
+ member, mlist.internal_name())
+ continue
+ info = _BounceInfo(
+ member, 0, today,
+ mlist.bounce_you_are_disabled_warnings,
+ mlist.pend_new(Pending.RE_ENABLE,
+ mlist.internal_name(),
+ member))
+ mlist.setBounceInfo(member, info)
+ lastnotice = time.mktime(info.lastnotice + (0,) * 6)
+ if opts.force or today >= lastnotice + interval:
+ notify.append(member)
+ # Now, send notifications to anyone who is due
+ for member in notify:
+ blog.info('Notifying disabled member %s for list: %s',
+ member, mlist.internal_name())
+ try:
+ mlist.sendNextNotification(member)
+ except errors.NotAMemberError:
+ # There must have been some problem with the data we have
+ # on this member. Most likely it's that they don't have a
+ # password assigned. Log this and delete the member.
+ blog.info(
+ 'NotAMemberError when sending disabled notice: %s',
+ member)
+ mlist.ApprovedDeleteMember(member, 'cron/disabled')
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/docs/master.txt b/src/mailman/bin/docs/master.txt
new file mode 100644
index 000000000..0d3cade77
--- /dev/null
+++ b/src/mailman/bin/docs/master.txt
@@ -0,0 +1,49 @@
+Mailman queue runner control
+============================
+
+Mailman has a number of queue runners which process messages in its queue file
+directories. In normal operation, a command line script called 'mailmanctl'
+is used to start, stop and manage the queue runners. mailmanctl actually is
+just a wrapper around the real queue runner watcher script called master.py.
+
+ >>> from mailman.testing.helpers import TestableMaster
+
+Start the master in a subthread.
+
+ >>> master = TestableMaster()
+ >>> master.start()
+
+There should be a process id for every qrunner that claims to be startable.
+
+ >>> from lazr.config import as_boolean
+ >>> startable_qrunners = [qconf for qconf in config.qrunner_configs
+ ... if as_boolean(qconf.start)]
+ >>> len(list(master.qrunner_pids)) == len(startable_qrunners)
+ True
+
+Now verify that all the qrunners are running.
+
+ >>> import os
+
+ # This should produce no output.
+ >>> for pid in master.qrunner_pids:
+ ... os.kill(pid, 0)
+
+Stop the master process, which should also kill (and not restart) the child
+queue runner processes.
+
+ >>> master.stop()
+
+None of the children are running now.
+
+ >>> import errno
+ >>> for pid in master.qrunner_pids:
+ ... try:
+ ... os.kill(pid, 0)
+ ... print 'Process did not exit:', pid
+ ... except OSError, error:
+ ... if error.errno == errno.ESRCH:
+ ... # The child process exited.
+ ... pass
+ ... else:
+ ... raise
diff --git a/src/mailman/bin/dumpdb.py b/src/mailman/bin/dumpdb.py
new file mode 100644
index 000000000..6657602e4
--- /dev/null
+++ b/src/mailman/bin/dumpdb.py
@@ -0,0 +1,88 @@
+# Copyright (C) 1998-2009 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 .
+
+import pprint
+import cPickle
+
+from mailman.config import config
+from mailman.i18n import _
+from mailman.interact import interact
+from mailman.options import Options
+
+
+COMMASPACE = ', '
+m = []
+
+
+
+class ScriptOptions(Options):
+ usage=_("""\
+%prog [options] filename
+
+Dump the contents of any Mailman queue file. The queue file is a data file
+with multiple Python pickles in it.""")
+
+ def add_options(self):
+ super(ScriptOptions, self).add_options()
+ self.parser.add_option(
+ '-n', '--noprint',
+ dest='doprint', default=True, action='store_false',
+ help=_("""\
+Don't attempt to pretty print the object. This is useful if there is some
+problem with the object and you just want to get an unpickled representation.
+Useful with 'bin/dumpdb -i '. In that case, the list of
+unpickled objects will be left in a variable called 'm'."""))
+ self.parser.add_option(
+ '-i', '--interact',
+ default=False, action='store_true',
+ help=_("""\
+Start an interactive Python session, with a variable called 'm' containing the
+list of unpickled objects."""))
+
+ def sanity_check(self):
+ if len(self.arguments) < 1:
+ self.parser.error(_('No filename given.'))
+ elif len(self.arguments) > 1:
+ self.parser.error(_('Unexpected arguments'))
+ else:
+ self.filename = self.arguments[0]
+
+
+
+def main():
+ options = ScriptOptions()
+ options.initialize()
+
+ pp = pprint.PrettyPrinter(indent=4)
+ with open(options.filename) as fp:
+ while True:
+ try:
+ m.append(cPickle.load(fp))
+ except EOFError:
+ break
+ if options.options.doprint:
+ print _('[----- start pickle -----]')
+ for i, obj in enumerate(m):
+ count = i + 1
+ print _('<----- start object $count ----->')
+ if isinstance(obj, basestring):
+ print obj
+ else:
+ pp.pprint(obj)
+ print _('[----- end pickle -----]')
+ if options.options.interact:
+ interact()
diff --git a/src/mailman/bin/export.py b/src/mailman/bin/export.py
new file mode 100644
index 000000000..d1992b4b4
--- /dev/null
+++ b/src/mailman/bin/export.py
@@ -0,0 +1,310 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Export an XML representation of a mailing list."""
+
+import sys
+import codecs
+import datetime
+import optparse
+
+from xml.sax.saxutils import escape
+
+from mailman import Defaults
+from mailman import errors
+from mailman import MemberAdaptor
+from mailman.MailList import MailList
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.initialize import initialize
+from mailman.version import MAILMAN_VERSION
+
+
+SPACE = ' '
+
+TYPES = {
+ Defaults.Toggle : 'bool',
+ Defaults.Radio : 'radio',
+ Defaults.String : 'string',
+ Defaults.Text : 'text',
+ Defaults.Email : 'email',
+ Defaults.EmailList : 'email_list',
+ Defaults.Host : 'host',
+ Defaults.Number : 'number',
+ Defaults.FileUpload : 'upload',
+ Defaults.Select : 'select',
+ Defaults.Topics : 'topics',
+ Defaults.Checkbox : 'checkbox',
+ Defaults.EmailListEx : 'email_list_ex',
+ Defaults.HeaderFilter : 'header_filter',
+ }
+
+
+
+class Indenter:
+ def __init__(self, fp, indentwidth=4):
+ self._fp = fp
+ self._indent = 0
+ self._width = indentwidth
+
+ def indent(self):
+ self._indent += 1
+
+ def dedent(self):
+ self._indent -= 1
+ assert self._indent >= 0
+
+ def write(self, s):
+ if s <> '\n':
+ self._fp.write(self._indent * self._width * ' ')
+ self._fp.write(s)
+
+
+
+class XMLDumper(object):
+ def __init__(self, fp):
+ self._fp = Indenter(fp)
+ self._tagbuffer = None
+ self._stack = []
+
+ def _makeattrs(self, tagattrs):
+ # The attribute values might contain angle brackets. They might also
+ # be None.
+ attrs = []
+ for k, v in tagattrs.items():
+ if v is None:
+ v = ''
+ else:
+ v = escape(str(v))
+ attrs.append('%s="%s"' % (k, v))
+ return SPACE.join(attrs)
+
+ def _flush(self, more=True):
+ if not self._tagbuffer:
+ return
+ name, attributes = self._tagbuffer
+ self._tagbuffer = None
+ if attributes:
+ attrstr = ' ' + self._makeattrs(attributes)
+ else:
+ attrstr = ''
+ if more:
+ print >> self._fp, '<%s%s>' % (name, attrstr)
+ self._fp.indent()
+ self._stack.append(name)
+ else:
+ print >> self._fp, '<%s%s/>' % (name, attrstr)
+
+ # Use this method when you know you have sub-elements.
+ def _push_element(self, _name, **_tagattrs):
+ self._flush()
+ self._tagbuffer = (_name, _tagattrs)
+
+ def _pop_element(self, _name):
+ buffered = bool(self._tagbuffer)
+ self._flush(more=False)
+ if not buffered:
+ name = self._stack.pop()
+ assert name == _name, 'got: %s, expected: %s' % (_name, name)
+ self._fp.dedent()
+ print >> self._fp, '%s>' % name
+
+ # Use this method when you do not have sub-elements
+ def _element(self, _name, _value=None, **_attributes):
+ self._flush()
+ if _attributes:
+ attrs = ' ' + self._makeattrs(_attributes)
+ else:
+ attrs = ''
+ if _value is None:
+ print >> self._fp, '<%s%s/>' % (_name, attrs)
+ else:
+ # The value might contain angle brackets.
+ value = escape(unicode(_value))
+ print >> self._fp, '<%s%s>%s%s>' % (_name, attrs, value, _name)
+
+ def _do_list_categories(self, mlist, k, subcat=None):
+ info = mlist.GetConfigInfo(k, subcat)
+ label, gui = mlist.GetConfigCategories()[k]
+ if info is None:
+ return
+ for data in info[1:]:
+ if not isinstance(data, tuple):
+ continue
+ varname = data[0]
+ # Variable could be volatile
+ if varname.startswith('_'):
+ continue
+ vtype = data[1]
+ # Munge the value based on its type
+ value = None
+ if hasattr(gui, 'getValue'):
+ value = gui.getValue(mlist, vtype, varname, data[2])
+ if value is None:
+ value = getattr(mlist, varname)
+ widget_type = TYPES[vtype]
+ if isinstance(value, list):
+ self._push_element('option', name=varname, type=widget_type)
+ for v in value:
+ self._element('value', v)
+ self._pop_element('option')
+ else:
+ self._element('option', value, name=varname, type=widget_type)
+
+ def _dump_list(self, mlist):
+ # Write list configuration values
+ self._push_element('list', name=mlist.fqdn_listname)
+ self._push_element('configuration')
+ self._element('option',
+ mlist.preferred_language,
+ name='preferred_language')
+ for k in config.ADMIN_CATEGORIES:
+ subcats = mlist.GetConfigSubCategories(k)
+ if subcats is None:
+ self._do_list_categories(mlist, k)
+ else:
+ for subcat in [t[0] for t in subcats]:
+ self._do_list_categories(mlist, k, subcat)
+ self._pop_element('configuration')
+ # Write membership
+ self._push_element('roster')
+ digesters = set(mlist.getDigestMemberKeys())
+ for member in sorted(mlist.getMembers()):
+ attrs = dict(id=member)
+ cased = mlist.getMemberCPAddress(member)
+ if cased <> member:
+ attrs['original'] = cased
+ self._push_element('member', **attrs)
+ self._element('realname', mlist.getMemberName(member))
+ self._element('password', mlist.getMemberPassword(member))
+ self._element('language', mlist.getMemberLanguage(member))
+ # Delivery status, combined with the type of delivery
+ attrs = {}
+ status = mlist.getDeliveryStatus(member)
+ if status == MemberAdaptor.ENABLED:
+ attrs['status'] = 'enabled'
+ else:
+ attrs['status'] = 'disabled'
+ attrs['reason'] = {MemberAdaptor.BYUSER : 'byuser',
+ MemberAdaptor.BYADMIN : 'byadmin',
+ MemberAdaptor.BYBOUNCE : 'bybounce',
+ }.get(mlist.getDeliveryStatus(member),
+ 'unknown')
+ if member in digesters:
+ if mlist.getMemberOption(member, Defaults.DisableMime):
+ attrs['delivery'] = 'plain'
+ else:
+ attrs['delivery'] = 'mime'
+ else:
+ attrs['delivery'] = 'regular'
+ changed = mlist.getDeliveryStatusChangeTime(member)
+ if changed:
+ when = datetime.datetime.fromtimestamp(changed)
+ attrs['changed'] = when.isoformat()
+ self._element('delivery', **attrs)
+ for option, flag in Defaults.OPTINFO.items():
+ # Digest/Regular delivery flag must be handled separately
+ if option in ('digest', 'plain'):
+ continue
+ value = mlist.getMemberOption(member, flag)
+ self._element(option, value)
+ topics = mlist.getMemberTopics(member)
+ if not topics:
+ self._element('topics')
+ else:
+ self._push_element('topics')
+ for topic in topics:
+ self._element('topic', topic)
+ self._pop_element('topics')
+ self._pop_element('member')
+ self._pop_element('roster')
+ self._pop_element('list')
+
+ def dump(self, listnames):
+ print >> self._fp, ''
+ self._push_element('mailman', **{
+ 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance',
+ 'xsi:noNamespaceSchemaLocation': 'ssi-1.0.xsd',
+ })
+ for listname in sorted(listnames):
+ try:
+ mlist = MailList(listname, lock=False)
+ except errors.MMUnknownListError:
+ print >> sys.stderr, _('No such list: $listname')
+ continue
+ self._dump_list(mlist)
+ self._pop_element('mailman')
+
+ def close(self):
+ while self._stack:
+ self._pop_element()
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options]
+
+Export the configuration and members of a mailing list in XML format."""))
+ parser.add_option('-o', '--outputfile',
+ metavar='FILENAME', default=None, type='string',
+ help=_("""\
+Output XML to FILENAME. If not given, or if FILENAME is '-', standard out is
+used."""))
+ parser.add_option('-l', '--listname',
+ default=[], action='append', type='string',
+ metavar='LISTNAME', dest='listnames', help=_("""\
+The list to include in the output. If not given, then all mailing lists are
+included in the XML output. Multiple -l flags may be given."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if args:
+ parser.print_help()
+ parser.error(_('Unexpected arguments'))
+ return parser, opts, args
+
+
+
+def main():
+ parser, opts, args = parseargs()
+ initialize(opts.config)
+
+ close = False
+ if opts.outputfile in (None, '-'):
+ writer = codecs.getwriter('utf-8')
+ fp = writer(sys.stdout)
+ else:
+ fp = codecs.open(opts.outputfile, 'w', 'utf-8')
+ close = True
+
+ try:
+ dumper = XMLDumper(fp)
+ if opts.listnames:
+ listnames = []
+ for listname in opts.listnames:
+ if '@' not in listname:
+ listname = '%s@%s' % (listname, config.DEFAULT_EMAIL_HOST)
+ listnames.append(listname)
+ else:
+ listnames = config.list_manager.names
+ dumper.dump(listnames)
+ dumper.close()
+ finally:
+ if close:
+ fp.close()
diff --git a/src/mailman/bin/find_member.py b/src/mailman/bin/find_member.py
new file mode 100644
index 000000000..0982724a0
--- /dev/null
+++ b/src/mailman/bin/find_member.py
@@ -0,0 +1,135 @@
+# Copyright (C) 1998-2009 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 .
+
+import re
+import sys
+import optparse
+
+from mailman import errors
+from mailman import MailList
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.version import MAILMAN_VERSION
+
+
+AS_MEMBER = 0x01
+AS_OWNER = 0x02
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options] regex [regex ...]
+
+Find all lists that a member's address is on.
+
+The interaction between -l and -x (see below) is as follows. If any -l option
+is given then only the named list will be included in the search. If any -x
+option is given but no -l option is given, then all lists will be search
+except those specifically excluded.
+
+Regular expression syntax uses the Python 're' module. Complete
+specifications are at:
+
+http://www.python.org/doc/current/lib/module-re.html
+
+Address matches are case-insensitive, but case-preserved addresses are
+displayed."""))
+ parser.add_option('-l', '--listname',
+ type='string', default=[], action='append',
+ dest='listnames',
+ help=_('Include only the named list in the search'))
+ parser.add_option('-x', '--exclude',
+ type='string', default=[], action='append',
+ dest='excludes',
+ help=_('Exclude the named list from the search'))
+ parser.add_option('-w', '--owners',
+ default=False, action='store_true',
+ help=_('Search list owners as well as members'))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if not args:
+ parser.print_help()
+ print >> sys.stderr, _('Search regular expression required')
+ sys.exit(1)
+ return parser, opts, args
+
+
+
+def main():
+ parser, opts, args = parseargs()
+ config.load(opts.config)
+
+ listnames = opts.listnames or config.list_manager.names
+ includes = set(listname.lower() for listname in listnames)
+ excludes = set(listname.lower() for listname in opts.excludes)
+ listnames = includes - excludes
+
+ if not listnames:
+ print _('No lists to search')
+ return
+
+ cres = []
+ for r in args:
+ cres.append(re.compile(r, re.IGNORECASE))
+ # dictionary of {address, (listname, ownerp)}
+ matches = {}
+ for listname in listnames:
+ try:
+ mlist = MailList.MailList(listname, lock=False)
+ except errors.MMListError:
+ print _('No such list: $listname')
+ continue
+ if opts.owners:
+ owners = mlist.owner
+ else:
+ owners = []
+ for cre in cres:
+ for member in mlist.getMembers():
+ if cre.search(member):
+ addr = mlist.getMemberCPAddress(member)
+ entries = matches.get(addr, {})
+ aswhat = entries.get(listname, 0)
+ aswhat |= AS_MEMBER
+ entries[listname] = aswhat
+ matches[addr] = entries
+ for owner in owners:
+ if cre.search(owner):
+ entries = matches.get(owner, {})
+ aswhat = entries.get(listname, 0)
+ aswhat |= AS_OWNER
+ entries[listname] = aswhat
+ matches[owner] = entries
+ addrs = matches.keys()
+ addrs.sort()
+ for k in addrs:
+ hits = matches[k]
+ lists = hits.keys()
+ print k, _('found in:')
+ for name in lists:
+ aswhat = hits[name]
+ if aswhat & AS_MEMBER:
+ print ' ', name
+ if aswhat & AS_OWNER:
+ print ' ', name, _('(as owner)')
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/gate_news.py b/src/mailman/bin/gate_news.py
new file mode 100644
index 000000000..eac30422d
--- /dev/null
+++ b/src/mailman/bin/gate_news.py
@@ -0,0 +1,243 @@
+# Copyright (C) 1998-2009 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 .
+
+import os
+import sys
+import time
+import socket
+import logging
+import nntplib
+import optparse
+import email.Errors
+
+from email.Parser import Parser
+from locknix import lockfile
+
+from mailman import MailList
+from mailman import Message
+from mailman import Utils
+from mailman import loginit
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.queue import Switchboard
+from mailman.version import MAILMAN_VERSION
+
+# Work around known problems with some RedHat cron daemons
+import signal
+signal.signal(signal.SIGCHLD, signal.SIG_DFL)
+
+NL = '\n'
+
+log = None
+
+class _ContinueLoop(Exception):
+ pass
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options]
+
+Poll the NNTP servers for messages to be gatewayed to mailing lists."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if args:
+ parser.print_help()
+ print >> sys.stderr, _('Unexpected arguments')
+ sys.exit(1)
+ return opts, args, parser
+
+
+
+_hostcache = {}
+
+def open_newsgroup(mlist):
+ # Split host:port if given
+ nntp_host, nntp_port = Utils.nntpsplit(mlist.nntp_host)
+ # Open up a "mode reader" connection to nntp server. This will be shared
+ # for all the gated lists having the same nntp_host.
+ conn = _hostcache.get(mlist.nntp_host)
+ if conn is None:
+ try:
+ conn = nntplib.NNTP(nntp_host, nntp_port,
+ readermode=True,
+ user=config.NNTP_USERNAME,
+ password=config.NNTP_PASSWORD)
+ except (socket.error, nntplib.NNTPError, IOError), e:
+ log.error('error opening connection to nntp_host: %s\n%s',
+ mlist.nntp_host, e)
+ raise
+ _hostcache[mlist.nntp_host] = conn
+ # Get the GROUP information for the list, but we're only really interested
+ # in the first article number and the last article number
+ r, c, f, l, n = conn.group(mlist.linked_newsgroup)
+ return conn, int(f), int(l)
+
+
+def clearcache():
+ for conn in set(_hostcache.values()):
+ conn.quit()
+ _hostcache.clear()
+
+
+
+# This function requires the list to be locked.
+def poll_newsgroup(mlist, conn, first, last, glock):
+ listname = mlist.internal_name()
+ # NEWNEWS is not portable and has synchronization issues.
+ for num in range(first, last):
+ glock.refresh()
+ try:
+ headers = conn.head(repr(num))[3]
+ found_to = False
+ beenthere = False
+ for header in headers:
+ i = header.find(':')
+ value = header[:i].lower()
+ if i > 0 and value == 'to':
+ found_to = True
+ if value <> 'x-beenthere':
+ continue
+ if header[i:] == ': %s' % mlist.posting_address:
+ beenthere = True
+ break
+ if not beenthere:
+ body = conn.body(repr(num))[3]
+ # Usenet originated messages will not have a Unix envelope
+ # (i.e. "From " header). This breaks Pipermail archiving, so
+ # we will synthesize one. Be sure to use the format searched
+ # for by mailbox.UnixMailbox._isrealfromline(). BAW: We use
+ # the -bounces address here in case any downstream clients use
+ # the envelope sender for bounces; I'm not sure about this,
+ # but it's the closest to the old semantics.
+ lines = ['From %s %s' % (mlist.GetBouncesEmail(),
+ time.ctime(time.time()))]
+ lines.extend(headers)
+ lines.append('')
+ lines.extend(body)
+ lines.append('')
+ p = Parser(Message.Message)
+ try:
+ msg = p.parsestr(NL.join(lines))
+ except email.Errors.MessageError, e:
+ log.error('email package exception for %s:%d\n%s',
+ mlist.linked_newsgroup, num, e)
+ raise _ContinueLoop
+ if found_to:
+ del msg['X-Originally-To']
+ msg['X-Originally-To'] = msg['To']
+ del msg['To']
+ msg['To'] = mlist.posting_address
+ # Post the message to the locked list
+ inq = Switchboard(config.INQUEUE_DIR)
+ inq.enqueue(msg,
+ listname=mlist.internal_name(),
+ fromusenet=True)
+ log.info('posted to list %s: %7d', listname, num)
+ except nntplib.NNTPError, e:
+ log.exception('NNTP error for list %s: %7d', listname, num)
+ except _ContinueLoop:
+ continue
+ # Even if we don't post the message because it was seen on the
+ # list already, update the watermark
+ mlist.usenet_watermark = num
+
+
+
+def process_lists(glock):
+ for listname in config.list_manager.names:
+ glock.refresh()
+ # Open the list unlocked just to check to see if it is gating news to
+ # mail. If not, we're done with the list. Otherwise, lock the list
+ # and gate the group.
+ mlist = MailList.MailList(listname, lock=False)
+ if not mlist.gateway_to_mail:
+ continue
+ # Get the list's watermark, i.e. the last article number that we gated
+ # from news to mail. None means that this list has never polled its
+ # newsgroup and that we should do a catch up.
+ watermark = getattr(mlist, 'usenet_watermark', None)
+ # Open the newsgroup, but let most exceptions percolate up.
+ try:
+ conn, first, last = open_newsgroup(mlist)
+ except (socket.error, nntplib.NNTPError):
+ break
+ log.info('%s: [%d..%d]', listname, first, last)
+ try:
+ try:
+ if watermark is None:
+ mlist.Lock(timeout=config.LIST_LOCK_TIMEOUT)
+ # This is the first time we've tried to gate this
+ # newsgroup. We essentially do a mass catch-up, otherwise
+ # we'd flood the mailing list.
+ mlist.usenet_watermark = last
+ log.info('%s caught up to article %d', listname, last)
+ else:
+ # The list has been polled previously, so now we simply
+ # grab all the messages on the newsgroup that have not
+ # been seen by the mailing list. The first such article
+ # is the maximum of the lowest article available in the
+ # newsgroup and the watermark. It's possible that some
+ # articles have been expired since the last time gate_news
+ # has run. Not much we can do about that.
+ start = max(watermark + 1, first)
+ if start > last:
+ log.info('nothing new for list %s', listname)
+ else:
+ mlist.Lock(timeout=config.LIST_LOCK_TIMEOUT)
+ log.info('gating %s articles [%d..%d]',
+ listname, start, last)
+ # Use last+1 because poll_newsgroup() employes a for
+ # 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:
+ log.error('Could not acquire list lock: %s', listname)
+ finally:
+ if mlist.Locked():
+ mlist.Save()
+ mlist.Unlock()
+ log.info('%s watermark: %d', listname, mlist.usenet_watermark)
+
+
+
+def main():
+ opts, args, parser = parseargs()
+ config.load(opts.config)
+
+ GATENEWS_LOCK_FILE = os.path.join(config.LOCK_DIR, 'gate_news.lock')
+ LOCK_LIFETIME = config.hours(2)
+
+ loginit.initialize(propagate=True)
+ log = logging.getLogger('mailman.fromusenet')
+
+ try:
+ with lockfile.Lock(GATENEWS_LOCK_FILE,
+ # It's okay to hijack this
+ lifetime=LOCK_LIFETIME) as lock:
+ process_lists(lock)
+ clearcache()
+ except lockfile.TimeOutError:
+ log.error('Could not acquire gate_news lock')
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/genaliases.py b/src/mailman/bin/genaliases.py
new file mode 100644
index 000000000..e8916d030
--- /dev/null
+++ b/src/mailman/bin/genaliases.py
@@ -0,0 +1,64 @@
+# Copyright (C) 2001-2009 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 .
+
+__metaclass__ = type
+__all__ = [
+ 'main',
+ ]
+
+
+import sys
+
+from mailman.config import config
+from mailman.i18n import _
+from mailman.options import Options
+
+
+
+class ScriptOptions(Options):
+ """Options for the genaliases script."""
+
+ usage = _("""\
+%prog [options]
+
+Regenerate the Mailman specific MTA aliases from scratch. The actual output
+depends on the value of the 'MTA' variable in your etc/mailman.cfg file.""")
+
+ def add_options(self):
+ super(ScriptOptions, self).add_options()
+ self.parser.add_option(
+ '-q', '--quiet',
+ default=False, action='store_true', help=_("""\
+Some MTA output can include more verbose help text. Use this to tone down the
+verbosity."""))
+
+
+
+
+def main():
+ options = ScriptOptions()
+ options.initialize()
+
+ # Get the MTA-specific module.
+ module_path, class_path = config.mta.incoming.rsplit('.', 1)
+ __import__(module_path)
+ getattr(sys.modules[module_path], class_path)().regenerate()
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/import.py b/src/mailman/bin/import.py
new file mode 100644
index 000000000..d2361e808
--- /dev/null
+++ b/src/mailman/bin/import.py
@@ -0,0 +1,315 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Import the XML representation of a mailing list."""
+
+import sys
+import codecs
+import optparse
+import traceback
+
+from xml.dom import minidom
+from xml.parsers.expat import ExpatError
+
+from mailman import Defaults
+from mailman import errors
+from mailman import MemberAdaptor
+from mailman import Utils
+from mailman import passwords
+from mailman.MailList import MailList
+from mailman.i18n import _
+from mailman.initialize import initialize
+from mailman.version import MAILMAN_VERSION
+
+
+OPTS = None
+
+
+
+def nodetext(node):
+ # Expect only one TEXT_NODE in the list of children
+ for child in node.childNodes:
+ if child.nodeType == node.TEXT_NODE:
+ return child.data
+ return u''
+
+
+def nodegen(node, *elements):
+ for child in node.childNodes:
+ if child.nodeType <> minidom.Node.ELEMENT_NODE:
+ continue
+ if elements and child.tagName not in elements:
+ print _('Ignoring unexpected element: $node.tagName')
+ else:
+ yield child
+
+
+
+def parse_config(node):
+ config = dict()
+ for child in nodegen(node, 'option'):
+ name = child.getAttribute('name')
+ if not name:
+ print _('Skipping unnamed option')
+ continue
+ vtype = child.getAttribute('type') or 'string'
+ if vtype in ('email_list', 'email_list_ex', 'checkbox'):
+ value = []
+ for subnode in nodegen(child):
+ value.append(nodetext(subnode))
+ elif vtype == 'bool':
+ value = nodetext(child)
+ try:
+ value = bool(int(value))
+ except ValueError:
+ value = {'true' : True,
+ 'false': False,
+ }.get(value.lower())
+ if value is None:
+ print _('Skipping bad boolean value: $value')
+ continue
+ elif vtype == 'radio':
+ value = nodetext(child).lower()
+ boolval = {'true' : True,
+ 'false': False,
+ }.get(value)
+ if boolval is None:
+ value = int(value)
+ else:
+ value = boolval
+ elif vtype == 'number':
+ value = nodetext(child)
+ # First try int then float
+ try:
+ value = int(value)
+ except ValueError:
+ value = float(value)
+ elif vtype in ('header_filter', 'topics'):
+ value = []
+ fakebltins = dict(__builtins__ = dict(True=True, False=False))
+ for subnode in nodegen(child):
+ reprstr = nodetext(subnode)
+ # Turn the reprs back into tuples, in a safe way
+ tupleval = eval(reprstr, fakebltins)
+ value.append(tupleval)
+ else:
+ value = nodetext(child)
+ # And now some special casing :(
+ if name == 'new_member_options':
+ value = int(nodetext(child))
+ config[name] = value
+ return config
+
+
+
+
+def parse_roster(node):
+ members = []
+ for child in nodegen(node, 'member'):
+ member = dict()
+ member['id'] = mid = child.getAttribute('id')
+ if not mid:
+ print _('Skipping member with no id')
+ continue
+ if OPTS.verbose:
+ print _('* Processing member: $mid')
+ for subnode in nodegen(child):
+ attr = subnode.tagName
+ if attr == 'delivery':
+ value = (subnode.getAttribute('status'),
+ subnode.getAttribute('delivery'))
+ elif attr in ('hide', 'ack', 'notmetoo', 'nodupes', 'nomail'):
+ value = {'true' : True,
+ 'false': False,
+ }.get(nodetext(subnode).lower(), False)
+ elif attr == 'topics':
+ value = []
+ for subsubnode in nodegen(subnode):
+ value.append(nodetext(subsubnode))
+ elif attr == 'password':
+ value = nodetext(subnode)
+ if OPTS.reset_passwords or value == '{NONE}' or not value:
+ value = passwords.make_secret(Utils.MakeRandomPassword())
+ else:
+ value = nodetext(subnode)
+ member[attr] = value
+ members.append(member)
+ return members
+
+
+
+def load(fp):
+ try:
+ doc = minidom.parse(fp)
+ except ExpatError:
+ print _('Expat error in file: $fp.name')
+ traceback.print_exc()
+ sys.exit(1)
+ doc.normalize()
+ # Make sure there's only one top-level node
+ gen = nodegen(doc, 'mailman')
+ top = gen.next()
+ try:
+ gen.next()
+ except StopIteration:
+ pass
+ else:
+ print _('Malformed XML; duplicate nodes')
+ sys.exit(1)
+ all_listdata = []
+ for listnode in nodegen(top, 'list'):
+ listdata = dict()
+ name = listnode.getAttribute('name')
+ if OPTS.verbose:
+ print _('Processing list: $name')
+ if not name:
+ print _('Ignoring malformed node')
+ continue
+ for child in nodegen(listnode, 'configuration', 'roster'):
+ if child.tagName == 'configuration':
+ list_config = parse_config(child)
+ else:
+ assert(child.tagName == 'roster')
+ list_roster = parse_roster(child)
+ all_listdata.append((name, list_config, list_roster))
+ return all_listdata
+
+
+
+def create(all_listdata):
+ for name, list_config, list_roster in all_listdata:
+ fqdn_listname = '%s@%s' % (name, list_config['host_name'])
+ if Utils.list_exists(fqdn_listname):
+ print _('Skipping already existing list: $fqdn_listname')
+ continue
+ mlist = MailList()
+ try:
+ if OPTS.verbose:
+ print _('Creating mailing list: $fqdn_listname')
+ mlist.Create(fqdn_listname, list_config['owner'][0],
+ list_config['password'])
+ except errors.BadDomainSpecificationError:
+ print _('List is not in a supported domain: $fqdn_listname')
+ continue
+ # Save the list creation, then unlock and relock the list. This is so
+ # that we use normal SQLAlchemy transactions to manage all the
+ # attribute and membership updates. Without this, no transaction will
+ # get committed in the second Save() below and we'll lose all our
+ # updates.
+ mlist.Save()
+ mlist.Unlock()
+ mlist.Lock()
+ try:
+ for option, value in list_config.items():
+ # XXX Here's what sucks. Some properties need to have
+ # _setValue() called on the gui component, because those
+ # methods do some pre-processing on the values before they're
+ # applied to the MailList instance. But we don't have a good
+ # way to find a category and sub-category that a particular
+ # property belongs to. Plus this will probably change. So
+ # for now, we'll just hard code the extra post-processing
+ # here. The good news is that not all _setValue() munging
+ # needs to be done -- for example, we've already converted
+ # everything to dollar strings.
+ if option in ('filter_mime_types', 'pass_mime_types',
+ 'filter_filename_extensions',
+ 'pass_filename_extensions'):
+ value = value.splitlines()
+ if option == 'available_languages':
+ mlist.set_languages(*value)
+ else:
+ setattr(mlist, option, value)
+ for member in list_roster:
+ mid = member['id']
+ if OPTS.verbose:
+ print _('* Adding member: $mid')
+ status, delivery = member['delivery']
+ kws = {'password' : member['password'],
+ 'language' : member['language'],
+ 'realname' : member['realname'],
+ 'digest' : delivery <> 'regular',
+ }
+ mlist.addNewMember(mid, **kws)
+ status = {'enabled' : MemberAdaptor.ENABLED,
+ 'byuser' : MemberAdaptor.BYUSER,
+ 'byadmin' : MemberAdaptor.BYADMIN,
+ 'bybounce' : MemberAdaptor.BYBOUNCE,
+ }.get(status, MemberAdaptor.UNKNOWN)
+ mlist.setDeliveryStatus(mid, status)
+ for opt in ('hide', 'ack', 'notmetoo', 'nodupes', 'nomail'):
+ mlist.setMemberOption(mid,
+ Defaults.OPTINFO[opt],
+ member[opt])
+ topics = member.get('topics')
+ if topics:
+ mlist.setMemberTopics(mid, topics)
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options]
+
+Import the configuration and/or members of a mailing list in XML format. The
+imported mailing list must not already exist. All mailing lists named in the
+XML file are imported, but those that already exist are skipped unless --error
+is given."""))
+ parser.add_option('-i', '--inputfile',
+ metavar='FILENAME', default=None, type='string',
+ help=_("""\
+Input XML from FILENAME. If not given, or if FILENAME is '-', standard input
+is used."""))
+ parser.add_option('-p', '--reset-passwords',
+ default=False, action='store_true', help=_("""\
+With this option, user passwords in the XML are ignored and are reset to a
+random password. If the generated passwords were not included in the input
+XML, they will always be randomly generated."""))
+ parser.add_option('-v', '--verbose',
+ default=False, action='store_true',
+ help=_('Produce more verbose output'))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if args:
+ parser.print_help()
+ parser.error(_('Unexpected arguments'))
+ return parser, opts, args
+
+
+
+def main():
+ global OPTS
+
+ parser, opts, args = parseargs()
+ initialize(opts.config)
+ OPTS = opts
+
+ if opts.inputfile in (None, '-'):
+ fp = sys.stdin
+ else:
+ fp = open(opts.inputfile, 'r')
+
+ try:
+ listbags = load(fp)
+ create(listbags)
+ finally:
+ if fp is not sys.stdin:
+ fp.close()
diff --git a/src/mailman/bin/inject.py b/src/mailman/bin/inject.py
new file mode 100644
index 000000000..2bc8a49e3
--- /dev/null
+++ b/src/mailman/bin/inject.py
@@ -0,0 +1,89 @@
+# Copyright (C) 2002-2009 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 .
+
+import os
+import sys
+
+from email import message_from_string
+
+from mailman import Utils
+from mailman.Message import Message
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.inject import inject_text
+from mailman.options import SingleMailingListOptions
+
+
+
+class ScriptOptions(SingleMailingListOptions):
+ usage=_("""\
+%prog [options] [filename]
+
+Inject a message from a file into Mailman's incoming queue. 'filename' is the
+name of the plaintext message file to inject. If omitted, or the string '-',
+standard input is used.
+""")
+
+ def add_options(self):
+ super(ScriptOptions, self).add_options()
+ self.parser.add_option(
+ '-q', '--queue',
+ type='string', help=_("""\
+The name of the queue to inject the message to. The queuename must be one of
+the directories inside the qfiles directory. If omitted, the incoming queue
+is used."""))
+
+ def sanity_check(self):
+ if not self.options.listname:
+ self.parser.error(_('Missing listname'))
+ if len(self.arguments) == 0:
+ self.filename = '-'
+ elif len(self.arguments) > 1:
+ self.parser.print_error(_('Unexpected arguments'))
+ else:
+ self.filename = self.arguments[0]
+
+
+
+def main():
+ options = ScriptOptions()
+ options.initialize()
+
+ if options.options.queue is None:
+ qdir = config.INQUEUE_DIR
+ else:
+ qdir = os.path.join(config.QUEUE_DIR, options.options.queue)
+ if not os.path.isdir(qdir):
+ options.parser.error(_('Bad queue directory: $qdir'))
+
+ fqdn_listname = options.options.listname
+ mlist = config.db.list_manager.get(fqdn_listname)
+ if mlist is None:
+ options.parser.error(_('No such list: $fqdn_listname'))
+
+ if options.filename == '-':
+ message_text = sys.stdin.read()
+ else:
+ with open(options.filename) as fp:
+ message_text = fp.read()
+
+ inject_text(mlist, message_text, qdir=qdir)
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/list_lists.py b/src/mailman/bin/list_lists.py
new file mode 100644
index 000000000..ea1640910
--- /dev/null
+++ b/src/mailman/bin/list_lists.py
@@ -0,0 +1,104 @@
+# Copyright (C) 1998-2009 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 .
+
+from mailman.config import config
+from mailman.i18n import _
+from mailman.options import Options
+
+
+
+class ScriptOptions(Options):
+ usage = _("""\
+%prog [options]
+
+List all mailing lists.""")
+
+ def add_options(self):
+ super(ScriptOptions, self).add_options()
+ self.parser.add_option(
+ '-a', '--advertised',
+ default=False, action='store_true',
+ help=_("""\
+List only those mailing lists that are publicly advertised"""))
+ self.parser.add_option(
+ '-b', '--bare',
+ default=False, action='store_true',
+ help=_("""\
+Displays only the list name, with no description."""))
+ self.parser.add_option(
+ '-d', '--domain',
+ default=[], type='string', action='append',
+ dest='domains', help=_("""\
+List only those mailing lists that match the given virtual domain, which may
+be either the email host or the url host name. Multiple -d options may be
+given."""))
+ self.parser.add_option(
+ '-f', '--full',
+ default=False, action='store_true',
+ help=_("""\
+Print the full list name, including the posting address."""))
+
+ def sanity_check(self):
+ if len(self.arguments) > 0:
+ self.parser.error(_('Unexpected arguments'))
+
+
+
+def main():
+ options = ScriptOptions()
+ options.initialize()
+
+ mlists = []
+ longest = 0
+
+ listmgr = config.db.list_manager
+ for fqdn_name in sorted(listmgr.names):
+ mlist = listmgr.get(fqdn_name)
+ if options.options.advertised and not mlist.advertised:
+ continue
+ if options.options.domains:
+ for domain in options.options.domains:
+ if domain in mlist.web_page_url or domain == mlist.host_name:
+ mlists.append(mlist)
+ break
+ else:
+ mlists.append(mlist)
+ if options.options.full:
+ name = mlist.fqdn_listname
+ else:
+ name = mlist.real_name
+ longest = max(len(name), longest)
+
+ if not mlists and not options.options.bare:
+ print _('No matching mailing lists found')
+ return
+
+ if not options.options.bare:
+ num_mlists = len(mlists)
+ print _('$num_mlists matching mailing lists found:')
+
+ format = '%%%ds - %%.%ds' % (longest, 77 - longest)
+ for mlist in mlists:
+ if options.options.full:
+ name = mlist.fqdn_listname
+ else:
+ name = mlist.real_name
+ if options.options.bare:
+ print name
+ else:
+ description = mlist.description or _('[no description available]')
+ print ' ', format % (name, description)
diff --git a/src/mailman/bin/list_members.py b/src/mailman/bin/list_members.py
new file mode 100644
index 000000000..443f764d6
--- /dev/null
+++ b/src/mailman/bin/list_members.py
@@ -0,0 +1,201 @@
+# Copyright (C) 1998-2009 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 .
+
+import sys
+
+from email.Utils import formataddr
+
+from mailman import Utils
+from mailman.config import config
+from mailman.core import errors
+from mailman.i18n import _
+from mailman.interfaces import DeliveryStatus
+from mailman.options import SingleMailingListOptions
+
+
+COMMASPACE = ', '
+
+WHYCHOICES = {
+ 'enabled' : DeliveryStatus.enabled,
+ 'byuser' : DeliveryStatus.by_user,
+ 'byadmin' : DeliveryStatus.by_moderator,
+ 'bybounce': DeliveryStatus.by_bounces,
+ }
+
+KINDCHOICES = set(('mime', 'plain', 'any'))
+
+
+
+class ScriptOptions(SingleMailingListOptions):
+ usage = _("""\
+%prog [options]
+
+List all the members of a mailing list. Note that with the options below, if
+neither -r or -d is supplied, regular members are printed first, followed by
+digest members, but no indication is given as to address status.
+
+listname is the name of the mailing list to use.""")
+
+ def add_options(self):
+ super(ScriptOptions, self).add_options()
+ self.parser.add_option(
+ '-o', '--output',
+ type='string', help=_("""\
+Write output to specified file instead of standard out."""))
+ self.parser.add_option(
+ '-r', '--regular',
+ default=None, action='store_true',
+ help=_('Print just the regular (non-digest) members.'))
+ self.parser.add_option(
+ '-d', '--digest',
+ default=None, type='string', metavar='KIND',
+ help=_("""\
+Print just the digest members. KIND can be 'mime', 'plain', or
+'any'. 'mime' prints just the members receiving MIME digests, while 'plain'
+prints just the members receiving plain text digests. 'any' prints all
+members receiving any kind of digest."""))
+ self.parser.add_option(
+ '-n', '--nomail',
+ type='string', metavar='WHY', help=_("""\
+Print the members that have delivery disabled. WHY selects just the subset of
+members with delivery disabled for a particular reason, where 'any' prints all
+disabled members. 'byadmin', 'byuser', 'bybounce', and 'unknown' prints just
+the users who are disabled for that particular reason. WHY can also be
+'enabled' which prints just those members for whom delivery is enabled."""))
+ self.parser.add_option(
+ '-f', '--fullnames',
+ default=False, action='store_true',
+ help=_('Include the full names in the output'))
+ self.parser.add_option(
+ '-i', '--invalid',
+ default=False, action='store_true', help=_("""\
+Print only the addresses in the membership list that are invalid. Ignores -r,
+-d, -n."""))
+
+ def sanity_check(self):
+ if not self.options.listname:
+ self.parser.error(_('Missing listname'))
+ if len(self.arguments) > 0:
+ self.parser.print_error(_('Unexpected arguments'))
+ if self.options.digest is not None:
+ self.options.kind = self.options.digest.lower()
+ if self.options.kind not in KINDCHOICES:
+ self.parser.error(
+ _('Invalid value for -d: $self.options.digest'))
+ if self.options.nomail is not None:
+ why = self.options.nomail.lower()
+ if why == 'any':
+ self.options.why = 'any'
+ elif why not in WHYCHOICES:
+ self.parser.error(
+ _('Invalid value for -n: $self.options.nomail'))
+ self.options.why = why
+ if self.options.regular is None and self.options.digest is None:
+ self.options.regular = self.options.digest = True
+ self.options.kind = 'any'
+
+
+
+def safe(string):
+ if not string:
+ return ''
+ return string.encode(sys.getdefaultencoding(), 'replace')
+
+
+def isinvalid(addr):
+ try:
+ Utils.ValidateEmail(addr)
+ return False
+ except errors.EmailAddressError:
+ return True
+
+
+
+def whymatches(mlist, addr, why):
+ # Return true if the `why' matches the reason the address is enabled, or
+ # in the case of why is None, that they are disabled for any reason
+ # (i.e. not enabled).
+ status = mlist.getDeliveryStatus(addr)
+ if why in (None, 'any'):
+ return status <> DeliveryStatus.enabled
+ return status == WHYCHOICES[why]
+
+
+
+def main():
+ options = ScriptOptions()
+ options.initialize()
+
+ fqdn_listname = options.options.listname
+ if options.options.output:
+ try:
+ fp = open(options.output, 'w')
+ except IOError:
+ options.parser.error(
+ _('Could not open file for writing: $options.options.output'))
+ else:
+ fp = sys.stdout
+
+ mlist = config.db.list_manager.get(fqdn_listname)
+ if mlist is None:
+ options.parser.error(_('No such list: $fqdn_listname'))
+
+ # The regular delivery and digest members.
+ rmembers = set(mlist.regular_members.members)
+ dmembers = set(mlist.digest_members.members)
+
+ fullnames = options.options.fullnames
+ if options.options.invalid:
+ all = sorted(member.address.address for member in rmembers + dmembers)
+ for address in all:
+ user = config.db.user_manager.get_user(address)
+ name = (user.real_name if fullnames and user else u'')
+ if options.options.invalid and isinvalid(address):
+ print >> fp, formataddr((safe(name), address))
+ return
+ if options.options.regular:
+ for address in sorted(member.address.address for member in rmembers):
+ user = config.db.user_manager.get_user(address)
+ name = (user.real_name if fullnames and user else u'')
+ # Filter out nomails
+ if (options.options.nomail and
+ not whymatches(mlist, address, options.options.why)):
+ continue
+ print >> fp, formataddr((safe(name), address))
+ if options.options.digest:
+ for address in sorted(member.address.address for member in dmembers):
+ user = config.db.user_manager.get_user(address)
+ name = (user.real_name if fullnames and user else u'')
+ # Filter out nomails
+ if (options.options.nomail and
+ not whymatches(mlist, address, options.options.why)):
+ continue
+ # Filter out digest kinds
+## if mlist.getMemberOption(addr, config.DisableMime):
+## # They're getting plain text digests
+## if opts.kind == 'mime':
+## continue
+## else:
+## # They're getting MIME digests
+## if opts.kind == 'plain':
+## continue
+ print >> fp, formataddr((safe(name), address))
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/list_owners.py b/src/mailman/bin/list_owners.py
new file mode 100644
index 000000000..953fb8941
--- /dev/null
+++ b/src/mailman/bin/list_owners.py
@@ -0,0 +1,88 @@
+# Copyright (C) 2002-2009 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 .
+
+import sys
+import optparse
+
+from mailman.MailList import MailList
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.initialize import initialize
+from mailman.version import MAILMAN_VERSION
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options] [listname ...]
+
+List the owners of a mailing list, or all mailing lists if no list names are
+given."""))
+ parser.add_option('-w', '--with-listnames',
+ default=False, action='store_true',
+ help=_("""\
+Group the owners by list names and include the list names in the output.
+Otherwise, the owners will be sorted and uniquified based on the email
+address."""))
+ parser.add_option('-m', '--moderators',
+ default=False, action='store_true',
+ help=_('Include the list moderators in the output.'))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ return parser, opts, args
+
+
+
+def main():
+ parser, opts, args = parseargs()
+ initialize(opts.config)
+
+ listmgr = config.db.list_manager
+ listnames = set(args or listmgr.names)
+ bylist = {}
+
+ for listname in listnames:
+ mlist = listmgr.get(listname)
+ addrs = [addr.address for addr in mlist.owners.addresses]
+ if opts.moderators:
+ addrs.extend([addr.address for addr in mlist.moderators.addresses])
+ bylist[listname] = addrs
+
+ if opts.with_listnames:
+ for listname in listnames:
+ unique = set()
+ for addr in bylist[listname]:
+ unique.add(addr)
+ keys = list(unique)
+ keys.sort()
+ print listname
+ for k in keys:
+ print '\t', k
+ else:
+ unique = set()
+ for listname in listnames:
+ for addr in bylist[listname]:
+ unique.add(addr)
+ for k in sorted(unique):
+ print k
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/mailmanctl.py b/src/mailman/bin/mailmanctl.py
new file mode 100644
index 000000000..667a46a70
--- /dev/null
+++ b/src/mailman/bin/mailmanctl.py
@@ -0,0 +1,232 @@
+# Copyright (C) 2001-2009 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 .
+
+"""Mailman start/stop script."""
+
+import os
+import grp
+import pwd
+import sys
+import errno
+import signal
+import logging
+
+from optparse import OptionParser
+
+from mailman.config import config
+from mailman.core.initialize import initialize
+from mailman.i18n import _
+from mailman.version import MAILMAN_VERSION
+
+
+COMMASPACE = ', '
+
+log = None
+parser = None
+
+
+
+def parseargs():
+ parser = OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+Primary start-up and shutdown script for Mailman's qrunner daemon.
+
+This script starts, stops, and restarts the main Mailman queue runners, making
+sure that the various long-running qrunners are still alive and kicking. It
+does this by forking and exec'ing the qrunners and waiting on their pids.
+When it detects a subprocess has exited, it may restart it.
+
+The qrunners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT, SIGTERM
+and SIGUSR1 all cause the qrunners to exit cleanly, but the master will only
+restart qrunners that have exited due to a SIGUSR1. SIGHUP causes the master
+and the qrunners to close their log files, and reopen then upon the next
+printed message.
+
+The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP, which it
+simply passes on to the qrunners (note that the master will close and reopen
+its own log files on receipt of a SIGHUP). The master also leaves its own
+process id in the file data/master-qrunner.pid but you normally don't need to
+use this pid directly. The `start', `stop', `restart', and `reopen' commands
+handle everything for you.
+
+Commands:
+
+ start - Start the master daemon and all qrunners. Prints a message and
+ exits if the master daemon is already running.
+
+ stop - Stops the master daemon and all qrunners. After stopping, no
+ more messages will be processed.
+
+ restart - Restarts the qrunners, but not the master process. Use this
+ whenever you upgrade or update Mailman so that the qrunners will
+ use the newly installed code.
+
+ reopen - This will close all log files, causing them to be re-opened the
+ next time a message is written to them
+
+Usage: %prog [options] [ start | stop | restart | reopen ]"""))
+ parser.add_option('-u', '--run-as-user',
+ default=True, action='store_false',
+ help=_("""\
+Normally, this script will refuse to run if the user id and group id are not
+set to the `mailman' user and group (as defined when you configured Mailman).
+If run as root, this script will change to this user and group before the
+check is made.
+
+This can be inconvenient for testing and debugging purposes, so the -u flag
+means that the step that sets and checks the uid/gid is skipped, and the
+program is run as the current user and group. This flag is not recommended
+for normal production environments.
+
+Note though, that if you run with -u and are not in the mailman group, you may
+have permission problems, such as begin unable to delete a list's archives
+through the web. Tough luck!"""))
+ parser.add_option('-f', '--force',
+ default=False, action='store_true',
+ help=_("""\
+If the master watcher finds an existing master lock, it will normally exit
+with an error message. With this option,the master will perform an extra
+level of checking. If a process matching the host/pid described in the lock
+file is running, the master will still exit, requiring you to manually clean
+up the lock. But if no matching process is found, the master will remove the
+apparently stale lock and make another attempt to claim the master lock."""))
+ parser.add_option('-q', '--quiet',
+ default=False, action='store_true',
+ help=_("""\
+Don't print status messages. Error messages are still printed to standard
+error."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ options, arguments = parser.parse_args()
+ if not arguments:
+ parser.error(_('No command given.'))
+ if len(arguments) > 1:
+ commands = COMMASPACE.join(arguments)
+ parser.error(_('Bad command: $commands'))
+ parser.options = options
+ parser.arguments = arguments
+ return parser
+
+
+
+def kill_watcher(sig):
+ try:
+ with open(config.PIDFILE) as f:
+ pid = int(f.read().strip())
+ except (IOError, ValueError), e:
+ # For i18n convenience
+ print >> sys.stderr, _('PID unreadable in: $config.PIDFILE')
+ print >> sys.stderr, e
+ print >> sys.stderr, _('Is qrunner even running?')
+ return
+ try:
+ os.kill(pid, sig)
+ except OSError, error:
+ if e.errno <> errno.ESRCH:
+ raise
+ print >> sys.stderr, _('No child with pid: $pid')
+ print >> sys.stderr, e
+ print >> sys.stderr, _('Stale pid file removed.')
+ os.unlink(config.PIDFILE)
+
+
+
+def check_privileges():
+ # If we're running as root (uid == 0), coerce the uid and gid to that
+ # which Mailman was configured for, and refuse to run if we didn't coerce
+ # the uid/gid.
+ gid = grp.getgrnam(config.MAILMAN_GROUP).gr_gid
+ uid = pwd.getpwnam(config.MAILMAN_USER).pw_uid
+ myuid = os.getuid()
+ if myuid == 0:
+ # Set the process's supplimental groups.
+ groups = [group.gr_gid for group in grp.getgrall()
+ if config.MAILMAN_USER in group.gr_mem]
+ groups.append(gid)
+ os.setgroups(groups)
+ os.setgid(gid)
+ os.setuid(uid)
+ elif myuid <> uid:
+ name = config.MAILMAN_USER
+ parser.error(
+ _('Run this program as root or as the $name user, or use -u.'))
+
+
+
+def main():
+ global log, parser
+
+ parser = parseargs()
+ initialize(parser.options.config)
+
+ log = logging.getLogger('mailman.qrunner')
+
+ if not parser.options.run_as_user:
+ check_privileges()
+ else:
+ if not parser.options.quiet:
+ print _('Warning! You may encounter permission problems.')
+
+ # Handle the commands
+ command = parser.arguments[0].lower()
+ if command == 'stop':
+ if not parser.options.quiet:
+ print _("Shutting down Mailman's master qrunner")
+ kill_watcher(signal.SIGTERM)
+ elif command == 'restart':
+ if not parser.options.quiet:
+ print _("Restarting Mailman's master qrunner")
+ kill_watcher(signal.SIGUSR1)
+ elif command == 'reopen':
+ if not parser.options.quiet:
+ print _('Re-opening all log files')
+ kill_watcher(signal.SIGHUP)
+ elif command == 'start':
+ # Start the master qrunner watcher process.
+ #
+ # Daemon process startup according to Stevens, Advanced Programming in
+ # the UNIX Environment, Chapter 13.
+ pid = os.fork()
+ if pid:
+ # parent
+ if not parser.options.quiet:
+ print _("Starting Mailman's master qrunner.")
+ return
+ # child
+ #
+ # Create a new session and become the session leader, but since we
+ # won't be opening any terminal devices, don't do the ultra-paranoid
+ # suggestion of doing a second fork after the setsid() call.
+ os.setsid()
+ # Instead of cd'ing to root, cd to the Mailman runtime directory.
+ os.chdir(config.VAR_DIR)
+ # Exec the master watcher.
+ args = [sys.executable, sys.executable,
+ os.path.join(config.BIN_DIR, 'master')]
+ if parser.options.force:
+ args.append('--force')
+ if parser.options.config:
+ args.extend(['-C', parser.options.config])
+ log.debug('starting: %s', args)
+ os.execl(*args)
+ # We should never get here.
+ raise RuntimeError('os.execl() failed')
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/master.py b/src/mailman/bin/master.py
new file mode 100644
index 000000000..d954bc865
--- /dev/null
+++ b/src/mailman/bin/master.py
@@ -0,0 +1,452 @@
+# Copyright (C) 2001-2009 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 .
+
+"""Master sub-process watcher."""
+
+__metaclass__ = type
+__all__ = [
+ 'Loop',
+ 'get_lock_data',
+ ]
+
+
+import os
+import sys
+import errno
+import signal
+import socket
+import logging
+
+from datetime import timedelta
+from lazr.config import as_boolean
+from locknix import lockfile
+from munepy import Enum
+
+from mailman.config import config
+from mailman.core.logging import reopen
+from mailman.i18n import _
+from mailman.options import Options
+
+
+DOT = '.'
+LOCK_LIFETIME = timedelta(days=1, hours=6)
+SECONDS_IN_A_DAY = 86400
+
+
+
+class ScriptOptions(Options):
+ """Options for the master watcher."""
+
+ usage = _("""\
+Master sub-process watcher.
+
+Start and watch the configured queue runners and ensure that they stay alive
+and kicking. Each are fork and exec'd in turn, with the master waiting on
+their process ids. When it detects a child queue runner has exited, it may
+restart it.
+
+The queue runners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT,
+SIGTERM and SIGUSR1 all cause the qrunners to exit cleanly. The master will
+restart qrunners that have exited due to a SIGUSR1 or some kind of other exit
+condition (say because of an exception). SIGHUP causes the master and the
+qrunners to close their log files, and reopen then upon the next printed
+message.
+
+The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP, which it
+simply passes on to the qrunners. Note that the master will close and reopen
+its own log files on receipt of a SIGHUP. The master also leaves its own
+process id in the file `data/master-qrunner.pid` but you normally don't need
+to use this pid directly.
+
+Usage: %prog [options]""")
+
+ def add_options(self):
+ self.parser.add_option(
+ '-n', '--no-restart',
+ dest='restartable', default=True, action='store_false',
+ help=_("""\
+Don't restart the qrunners when they exit because of an error or a SIGUSR1.
+Use this only for debugging."""))
+ self.parser.add_option(
+ '-f', '--force',
+ default=False, action='store_true',
+ help=_("""\
+If the master watcher finds an existing master lock, it will normally exit
+with an error message. With this option,the master will perform an extra
+level of checking. If a process matching the host/pid described in the lock
+file is running, the master will still exit, requiring you to manually clean
+up the lock. But if no matching process is found, the master will remove the
+apparently stale lock and make another attempt to claim the master lock."""))
+ self.parser.add_option(
+ '-r', '--runner',
+ dest='runners', action='append', default=[],
+ help=_("""\
+Override the default set of queue runners that the master watch will invoke
+instead of the default set. Multiple -r options may be given. The values for
+-r are passed straight through to bin/qrunner."""))
+
+ def sanity_check(self):
+ if len(self.arguments) > 0:
+ self.parser.error(_('Too many arguments'))
+
+
+
+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('.')
+ hostname = DOT.join(parts[1:-2])
+ pid = int(parts[-2])
+ return hostname, int(pid), filename
+
+
+class WatcherState(Enum):
+ # Another master watcher is running.
+ conflict = 1
+ # No conflicting process exists.
+ stale_lock = 2
+ # Hostname from lock file doesn't match.
+ host_mismatch = 3
+
+
+def master_state():
+ """Get the state of the master watcher.
+
+ :return: WatcherState describing the state of the lock file.
+ """
+
+ # 1 if proc exists on host (but is it qrunner? ;)
+ # 0 if host matches but no proc
+ # hostname if hostname doesn't match
+ hostname, pid, tempfile = get_lock_data()
+ if hostname <> socket.gethostname():
+ return WatcherState.host_mismatch
+ # Find out if the process exists by calling kill with a signal 0.
+ try:
+ os.kill(pid, 0)
+ return WatcherState.conflict
+ except OSError, e:
+ if e.errno == errno.ESRCH:
+ # No matching process id.
+ return WatcherState.stale_lock
+ # Some other error occurred.
+ raise
+
+
+def acquire_lock_1(force):
+ """Try to acquire the master queue runner lock.
+
+ :param force: Flag that controls whether to force acquisition of the lock.
+ :return: The master queue runner lock.
+ :raises: `TimeOutError` if the lock could not be acquired.
+ """
+ lock = lockfile.Lock(config.LOCK_FILE, LOCK_LIFETIME)
+ try:
+ lock.lock(timedelta(seconds=0.1))
+ return lock
+ except lockfile.TimeOutError:
+ if not force:
+ raise
+ # Force removal of lock first.
+ lock.disown()
+ hostname, pid, tempfile = get_lock_data()
+ os.unlink(config.LOCK_FILE)
+ os.unlink(os.path.join(config.LOCK_DIR, tempfile))
+ return acquire_lock_1(force=False)
+
+
+def acquire_lock(force):
+ """Acquire the master queue runner lock.
+
+ :return: The master queue runner lock or None if the lock couldn't be
+ acquired. In that case, an error messages is also printed to standard
+ error.
+ """
+ try:
+ lock = acquire_lock_1(force)
+ return lock
+ except lockfile.TimeOutError:
+ status = master_state()
+ if status == WatcherState.conflict:
+ # Hostname matches and process exists.
+ message = _("""\
+The master qrunner lock could not be acquired because it appears
+as though another master qrunner is already running.
+""")
+ elif status == WatcherState.stale_lock:
+ # Hostname matches but the process does not exist.
+ message = _("""\
+The master qrunner lock could not be acquired. It appears as though there is
+a stale master qrunner lock. Try re-running mailmanctl with the -s flag.
+""")
+ else:
+ assert status == WatcherState.host_mismatch, (
+ 'Invalid enum value: %s' % status)
+ # Hostname doesn't even match.
+ hostname, pid, tempfile = get_lock_data()
+ 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 test for stale
+locks across host boundaries, so you'll have to clean this up manually.
+
+Lock file: $config.LOCK_FILE
+Lock host: $hostname
+
+Exiting.""")
+ config.options.parser.error(message)
+
+
+
+class Loop:
+ """Main control loop class."""
+
+ def __init__(self, lock=None, restartable=None, config_file=None):
+ self._lock = lock
+ self._restartable = restartable
+ self._config_file = config_file
+ self._kids = {}
+
+ def install_signal_handlers(self):
+ """Install various signals handlers for control from mailmanctl."""
+ log = logging.getLogger('mailman.qrunner')
+ # 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.
+ def sigalrm_handler(signum, frame):
+ self._lock.refresh()
+ signal.alarm(SECONDS_IN_A_DAY)
+ signal.signal(signal.SIGALRM, sigalrm_handler)
+ signal.alarm(SECONDS_IN_A_DAY)
+ # SIGHUP tells the qrunners to close and reopen their log files.
+ def sighup_handler(signum, frame):
+ reopen()
+ for pid in self._kids:
+ os.kill(pid, signal.SIGHUP)
+ log.info('Master watcher caught SIGHUP. Re-opening log files.')
+ signal.signal(signal.SIGHUP, sighup_handler)
+ # SIGUSR1 is used by 'mailman restart'.
+ def sigusr1_handler(signum, frame):
+ for pid in self._kids:
+ os.kill(pid, signal.SIGUSR1)
+ log.info('Master watcher caught SIGUSR1. Exiting.')
+ signal.signal(signal.SIGUSR1, sigusr1_handler)
+ # SIGTERM is what init will kill this process with when changing run
+ # levels. It's also the signal 'mailmanctl stop' uses.
+ def sigterm_handler(signum, frame):
+ for pid in self._kids:
+ os.kill(pid, signal.SIGTERM)
+ log.info('Master watcher caught SIGTERM. Exiting.')
+ signal.signal(signal.SIGTERM, sigterm_handler)
+ # SIGINT is what control-C gives.
+ def sigint_handler(signum, frame):
+ for pid in self._kids:
+ os.kill(pid, signal.SIGINT)
+ log.info('Master watcher caught SIGINT. Restarting.')
+ signal.signal(signal.SIGINT, sigint_handler)
+
+ def _start_runner(self, spec):
+ """Start a queue runner.
+
+ All arguments are passed to the qrunner process.
+
+ :param spec: A queue runner spec, in a format acceptable to
+ bin/qrunner's --runner argument, e.g. name:slice:count
+ :type spec: string
+ :return: The process id of the child queue runner.
+ :rtype: int
+ """
+ pid = os.fork()
+ if pid:
+ # Parent.
+ return pid
+ # Child.
+ #
+ # Craft the command line arguments for the exec() call.
+ rswitch = '--runner=' + spec
+ # Wherever mailmanctl lives, so too must live the qrunner script.
+ exe = os.path.join(config.BIN_DIR, 'qrunner')
+ # config.PYTHON, which is the absolute path to the Python interpreter,
+ # must be given as argv[0] due to Python's library search algorithm.
+ args = [sys.executable, sys.executable, exe, rswitch, '-s']
+ if self._config_file is not None:
+ args.extend(['-C', self._config_file])
+ log = logging.getLogger('mailman.qrunner')
+ log.debug('starting: %s', args)
+ os.execl(*args)
+ # We should never get here.
+ raise RuntimeError('os.execl() failed')
+
+ def start_qrunners(self, qrunner_names=None):
+ """Start all the configured qrunners.
+
+ :param qrunners: If given, a sequence of queue runner names to start.
+ If not given, this sequence is taken from the configuration file.
+ :type qrunners: a sequence of strings
+ """
+ if not qrunner_names:
+ qrunner_names = []
+ for qrunner_config in config.qrunner_configs:
+ # Strip off the 'qrunner.' prefix.
+ assert qrunner_config.name.startswith('qrunner.'), (
+ 'Unexpected qrunner configuration section name: %s',
+ qrunner_config.name)
+ qrunner_names.append(qrunner_config.name[8:])
+ # For each qrunner we want to start, find their config section, which
+ # will tell us the name of the class to instantiate, along with the
+ # number of hash space slices to manage.
+ for name in qrunner_names:
+ section_name = 'qrunner.' + name
+ # Let AttributeError propagate.
+ qrunner_config = getattr(config, section_name)
+ if not as_boolean(qrunner_config.start):
+ continue
+ package, class_name = qrunner_config['class'].rsplit(DOT, 1)
+ __import__(package)
+ # Let AttributeError propagate.
+ class_ = getattr(sys.modules[package], class_name)
+ # Find out how many qrunners to instantiate. This must be a power
+ # of 2.
+ count = int(qrunner_config.instances)
+ assert (count & (count - 1)) == 0, (
+ 'Queue runner "%s", not a power of 2: %s', name, count)
+ for slice_number in range(count):
+ # qrunner name, slice #, # of slices, restart count
+ info = (name, slice_number, count, 0)
+ spec = '%s:%d:%d' % (name, slice_number, count)
+ pid = self._start_runner(spec)
+ log = logging.getLogger('mailman.qrunner')
+ log.debug('[%d] %s', pid, spec)
+ self._kids[pid] = info
+
+ def loop(self):
+ """Main loop.
+
+ Wait until all the qrunners have exited, restarting them if necessary
+ and configured to do so.
+ """
+ log = logging.getLogger('mailman.qrunner')
+ while True:
+ try:
+ pid, status = os.wait()
+ except OSError, error:
+ # No children? We're done.
+ if error.errno == errno.ECHILD:
+ break
+ # If the system call got interrupted, just restart it.
+ elif error.errno == errno.EINTR:
+ continue
+ else:
+ raise
+ # Find out why the subprocess exited by getting the signal
+ # received or exit status.
+ if os.WIFSIGNALED(status):
+ why = os.WTERMSIG(status)
+ elif os.WIFEXITED(status):
+ why = os.WEXITSTATUS(status)
+ else:
+ why = None
+ # We'll restart the subprocess if it exited with a SIGUSR1 or
+ # because of a failure (i.e. no exit signal), and the no-restart
+ # command line switch was not given. This lets us better handle
+ # runaway restarts (e.g. if the subprocess had a syntax error!)
+ qrname, slice_number, count, restarts = self._kids.pop(pid)
+ config_name = 'qrunner.' + qrname
+ restart = False
+ if why == signal.SIGUSR1 and self._restartable:
+ restart = True
+ # Have we hit the maximum number of restarts?
+ restarts += 1
+ max_restarts = int(getattr(config, config_name).max_restarts)
+ if restarts > max_restarts:
+ restart = False
+ # Are we permanently non-restartable?
+ log.debug("""\
+Master detected subprocess exit
+(pid: %d, why: %s, class: %s, slice: %d/%d) %s""",
+ pid, why, qrname, slice_number + 1, count,
+ ('[restarting]' if restart else ''))
+ # See if we've reached the maximum number of allowable restarts
+ if restarts > max_restarts:
+ log.info("""\
+qrunner %s reached maximum restart limit of %d, not restarting.""",
+ qrname, max_restarts)
+ # Now perhaps restart the process unless it exited with a
+ # SIGTERM or we aren't restarting.
+ if restart:
+ spec = '%s:%d:%d' % (qrname, slice_number, count)
+ newpid = self._start_runner(spec)
+ self._kids[newpid] = (qrname, slice_number, count, restarts)
+
+ def cleanup(self):
+ """Ensure that all children have exited."""
+ log = logging.getLogger('mailman.qrunner')
+ # Send SIGTERMs to all the child processes and wait for them all to
+ # exit.
+ for pid in self._kids:
+ try:
+ os.kill(pid, signal.SIGTERM)
+ except OSError, error:
+ if error.errno == errno.ESRCH:
+ # The child has already exited.
+ log.info('ESRCH on pid: %d', pid)
+ # Wait for all the children to go away.
+ while self._kids:
+ try:
+ pid, status = os.wait()
+ del self._kids[pid]
+ except OSError, e:
+ if e.errno == errno.ECHILD:
+ break
+ elif e.errno == errno.EINTR:
+ continue
+ raise
+
+
+
+def main():
+ """Main process."""
+
+ options = ScriptOptions()
+ options.initialize()
+
+ # Acquire the master lock, exiting if we can't acquire it. We'll let the
+ # caller handle any clean up or lock breaking. No with statement here
+ # because Lock's constructor doesn't support a timeout.
+ lock = acquire_lock(options.options.force)
+ try:
+ with open(config.PIDFILE, 'w') as fp:
+ print >> fp, os.getpid()
+ loop = Loop(lock, options.options.restartable, options.options.config)
+ loop.install_signal_handlers()
+ try:
+ loop.start_qrunners(options.options.runners)
+ loop.loop()
+ finally:
+ loop.cleanup()
+ os.remove(config.PIDFILE)
+ finally:
+ lock.unlock()
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/mmsitepass.py b/src/mailman/bin/mmsitepass.py
new file mode 100644
index 000000000..132803fc9
--- /dev/null
+++ b/src/mailman/bin/mmsitepass.py
@@ -0,0 +1,113 @@
+# Copyright (C) 1998-2009 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 .
+
+import sys
+import getpass
+import optparse
+
+from mailman import Utils
+from mailman import passwords
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.initialize import initialize
+from mailman.version import MAILMAN_VERSION
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options] [password]
+
+Set the site or list creator password.
+
+The site password can be used in most if not all places that the list
+administrator's password can be used, which in turn can be used in most places
+that a list user's password can be used. The list creator password is a
+separate password that can be given to non-site administrators to delegate the
+ability to create new mailing lists.
+
+If password is not given on the command line, it will be prompted for.
+"""))
+ parser.add_option('-c', '--listcreator',
+ default=False, action='store_true',
+ help=_("""\
+Set the list creator password instead of the site password. The list
+creator is authorized to create and remove lists, but does not have
+the total power of the site administrator."""))
+ parser.add_option('-p', '--password-scheme',
+ default='', type='string',
+ help=_("""\
+Specify the RFC 2307 style hashing scheme for passwords included in the
+output. Use -P to get a list of supported schemes, which are
+case-insensitive."""))
+ parser.add_option('-P', '--list-hash-schemes',
+ default=False, action='store_true', help=_("""\
+List the supported password hashing schemes and exit. The scheme labels are
+case-insensitive."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if len(args) > 1:
+ parser.error(_('Unexpected arguments'))
+ if opts.list_hash_schemes:
+ for label in passwords.Schemes:
+ print str(label).upper()
+ sys.exit(0)
+ return parser, opts, args
+
+
+def check_password_scheme(parser, password_scheme):
+ # shoule be checked after config is loaded.
+ if password_scheme == '':
+ password_scheme = config.PASSWORD_SCHEME
+ scheme = passwords.lookup_scheme(password_scheme.lower())
+ if not scheme:
+ parser.error(_('Invalid password scheme'))
+ return scheme
+
+
+
+def main():
+ parser, opts, args = parseargs()
+ initialize(opts.config)
+ opts.password_scheme = check_password_scheme(parser, opts.password_scheme)
+ if args:
+ password = args[0]
+ else:
+ # Prompt for the password
+ if opts.listcreator:
+ prompt_1 = _('New list creator password: ')
+ else:
+ prompt_1 = _('New site administrator password: ')
+ pw1 = getpass.getpass(prompt_1)
+ pw2 = getpass.getpass(_('Enter password again to confirm: '))
+ if pw1 <> pw2:
+ print _('Passwords do not match; no changes made.')
+ sys.exit(1)
+ password = pw1
+ Utils.set_global_password(password,
+ not opts.listcreator, opts.password_scheme)
+ if Utils.check_global_password(password, not opts.listcreator):
+ print _('Password changed.')
+ else:
+ print _('Password change failed.')
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/nightly_gzip.py b/src/mailman/bin/nightly_gzip.py
new file mode 100644
index 000000000..f886e5801
--- /dev/null
+++ b/src/mailman/bin/nightly_gzip.py
@@ -0,0 +1,117 @@
+# Copyright (C) 1998-2009 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 .
+
+import os
+import sys
+import optparse
+
+try:
+ import gzip
+except ImportError:
+ sys.exit(0)
+
+from mailman import MailList
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.initialize import initialize
+from mailman.version import MAILMAN_VERSION
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options] [listname ...]
+
+Re-generate the Pipermail gzip'd archive flat files."""))
+ parser.add_option('-v', '--verbose',
+ default=False, action='store_true',
+ help=_("Print each file as it's being gzip'd"))
+ parser.add_option('-z', '--level',
+ default=6, type='int',
+ help=_('Specifies the compression level'))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if opts.level < 1 or opts.level > 9:
+ parser.print_help()
+ print >> sys.stderr, _('Illegal compression level: $opts.level')
+ sys.exit(1)
+ return opts, args, parser
+
+
+
+def compress(txtfile, opts):
+ if opts.verbose:
+ print _("gzip'ing: $txtfile")
+ infp = outfp = None
+ try:
+ infp = open(txtfile)
+ outfp = gzip.open(txtfile + '.gz', 'wb', opts.level)
+ outfp.write(infp.read())
+ finally:
+ if outfp:
+ outfp.close()
+ if infp:
+ infp.close()
+
+
+
+def main():
+ opts, args, parser = parseargs()
+ initialize(opts.config)
+
+ if config.ARCHIVE_TO_MBOX not in (1, 2) or config.GZIP_ARCHIVE_TXT_FILES:
+ # We're only going to run the nightly archiver if messages are
+ # archived to the mbox, and the gzip file is not created on demand
+ # (i.e. for every individual post). This is the normal mode of
+ # operation.
+ return
+
+ # Process all the specified lists
+ for listname in set(args or config.list_manager.names):
+ mlist = MailList.MailList(listname, lock=False)
+ if not mlist.archive:
+ continue
+ dir = mlist.archive_dir()
+ try:
+ allfiles = os.listdir(dir)
+ except OSError:
+ # Has the list received any messages? If not, last_post_time will
+ # be zero, so it's not really a bogus archive dir.
+ if mlist.last_post_time > 0:
+ print _('List $listname has a bogus archive_directory: $dir')
+ continue
+ if opts.verbose:
+ print _('Processing list: $listname')
+ files = []
+ for f in allfiles:
+ if os.path.splitext(f)[1] <> '.txt':
+ continue
+ # stat both the .txt and .txt.gz files and append them only if
+ # the former is newer than the latter.
+ txtfile = os.path.join(dir, f)
+ gzpfile = txtfile + '.gz'
+ txt_mtime = os.path.getmtime(txtfile)
+ try:
+ gzp_mtime = os.path.getmtime(gzpfile)
+ except OSError:
+ gzp_mtime = -1
+ if txt_mtime > gzp_mtime:
+ files.append(txtfile)
+ for f in files:
+ compress(f, opts)
diff --git a/src/mailman/bin/qrunner.py b/src/mailman/bin/qrunner.py
new file mode 100644
index 000000000..62e943aad
--- /dev/null
+++ b/src/mailman/bin/qrunner.py
@@ -0,0 +1,269 @@
+# Copyright (C) 2001-2009 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 .
+
+import sys
+import signal
+import logging
+
+from mailman.config import config
+from mailman.core.logging import reopen
+from mailman.i18n import _
+from mailman.options import Options
+
+
+COMMASPACE = ', '
+log = None
+
+
+
+def r_callback(option, opt, value, parser):
+ dest = getattr(parser.values, option.dest)
+ parts = value.split(':')
+ if len(parts) == 1:
+ runner = parts[0]
+ rslice = rrange = 1
+ elif len(parts) == 3:
+ runner = parts[0]
+ try:
+ rslice = int(parts[1])
+ rrange = int(parts[2])
+ except ValueError:
+ parser.print_help()
+ print >> sys.stderr, _('Bad runner specification: $value')
+ sys.exit(1)
+ else:
+ parser.print_help()
+ print >> sys.stderr, _('Bad runner specification: $value')
+ sys.exit(1)
+ dest.append((runner, rslice, rrange))
+
+
+
+class ScriptOptions(Options):
+
+ usage = _("""\
+Run one or more qrunners, once or repeatedly.
+
+Each named runner class is run in round-robin fashion. In other words, the
+first named runner is run to consume all the files currently in its
+directory. When that qrunner is done, the next one is run to consume all the
+files in /its/ directory, and so on. The number of total iterations can be
+given on the command line.
+
+Usage: %prog [options]
+
+-r is required unless -l or -h is given, and its argument must be one of the
+names displayed by the -l switch.
+
+Normally, this script should be started from mailmanctl. Running it
+separately or with -o is generally useful only for debugging.
+""")
+
+ def add_options(self):
+ self.parser.add_option(
+ '-r', '--runner',
+ metavar='runner[:slice:range]', dest='runners',
+ type='string', default=[],
+ action='callback', callback=r_callback,
+ help=_("""\
+Run the named qrunner, which must be one of the strings returned by the -l
+option. Optional slice:range if given, is used to assign multiple qrunner
+processes to a queue. range is the total number of qrunners for this queue
+while slice is the number of this qrunner from [0..range).
+
+When using the slice:range form, you must ensure that each qrunner for the
+queue is given the same range value. If slice:runner is not given, then 1:1
+is used.
+
+Multiple -r options may be given, in which case each qrunner will run once in
+round-robin fashion. The special runner `All' is shorthand for a qrunner for
+each listed by the -l option."""))
+ self.parser.add_option(
+ '-o', '--once',
+ default=False, action='store_true', help=_("""\
+Run each named qrunner exactly once through its main loop. Otherwise, each
+qrunner runs indefinitely, until the process receives signal."""))
+ self.parser.add_option(
+ '-l', '--list',
+ default=False, action='store_true',
+ help=_('List the available qrunner names and exit.'))
+ self.parser.add_option(
+ '-v', '--verbose',
+ default=0, action='count', help=_("""\
+Display more debugging information to the logs/qrunner log file."""))
+ self.parser.add_option(
+ '-s', '--subproc',
+ default=False, action='store_true', help=_("""\
+This should only be used when running qrunner as a subprocess of the
+mailmanctl startup script. It changes some of the exit-on-error behavior to
+work better with that framework."""))
+
+ def sanity_check(self):
+ if self.arguments:
+ self.parser.error(_('Unexpected arguments'))
+ if not self.options.runners and not self.options.list:
+ self.parser.error(_('No runner name given.'))
+
+
+
+def make_qrunner(name, slice, range, once=False):
+ # Several conventions for specifying the runner name are supported. It
+ # could be one of the shortcut names. If the name is a full module path,
+ # use it explicitly. If the name starts with a dot, it's a class name
+ # relative to the Mailman.queue package.
+ qrunner_config = getattr(config, 'qrunner.' + name, None)
+ if qrunner_config is not None:
+ # It was a shortcut name.
+ class_path = qrunner_config['class']
+ elif name.startswith('.'):
+ class_path = 'mailman.queue' + name
+ else:
+ class_path = name
+ module_name, class_name = class_path.rsplit('.', 1)
+ try:
+ __import__(module_name)
+ except ImportError, e:
+ if config.options.options.subproc:
+ # Exit with SIGTERM exit code so the master watcher won't try to
+ # restart us.
+ print >> sys.stderr, _('Cannot import runner module: $module_name')
+ print >> sys.stderr, e
+ sys.exit(signal.SIGTERM)
+ else:
+ raise
+ qrclass = getattr(sys.modules[module_name], class_name)
+ if once:
+ # Subclass to hack in the setting of the stop flag in _do_periodic()
+ class Once(qrclass):
+ def _do_periodic(self):
+ self.stop()
+ qrunner = Once(name, slice)
+ else:
+ qrunner = qrclass(name, slice)
+ return qrunner
+
+
+
+def set_signals(loop):
+ """Set up the signal handlers.
+
+ Signals caught are: SIGTERM, SIGINT, SIGUSR1 and SIGHUP. The latter is
+ used to re-open the log files. SIGTERM and SIGINT are treated exactly the
+ same -- they cause qrunner to exit with no restart from the master.
+ SIGUSR1 also causes qrunner to exit, but the master watcher will restart
+ it in that case.
+
+ :param loop: A loop queue runner instance.
+ """
+ def sigterm_handler(signum, frame):
+ # Exit the qrunner cleanly
+ loop.stop()
+ loop.status = signal.SIGTERM
+ log.info('%s qrunner caught SIGTERM. Stopping.', loop.name())
+ signal.signal(signal.SIGTERM, sigterm_handler)
+ def sigint_handler(signum, frame):
+ # Exit the qrunner cleanly
+ loop.stop()
+ loop.status = signal.SIGINT
+ log.info('%s qrunner caught SIGINT. Stopping.', loop.name())
+ signal.signal(signal.SIGINT, sigint_handler)
+ def sigusr1_handler(signum, frame):
+ # Exit the qrunner cleanly
+ loop.stop()
+ loop.status = signal.SIGUSR1
+ log.info('%s qrunner caught SIGUSR1. Stopping.', loop.name())
+ signal.signal(signal.SIGUSR1, sigusr1_handler)
+ # SIGHUP just tells us to rotate our log files.
+ def sighup_handler(signum, frame):
+ reopen()
+ log.info('%s qrunner caught SIGHUP. Reopening logs.', loop.name())
+ signal.signal(signal.SIGHUP, sighup_handler)
+
+
+
+def main():
+ global log
+
+ options = ScriptOptions()
+ options.initialize()
+
+ if options.options.list:
+ prefixlen = max(len(shortname)
+ for shortname in config.qrunner_shortcuts)
+ for shortname in sorted(config.qrunner_shortcuts):
+ runnername = config.qrunner_shortcuts[shortname]
+ shortname = (' ' * (prefixlen - len(shortname))) + shortname
+ print _('$shortname runs $runnername')
+ sys.exit(0)
+
+ # Fast track for one infinite runner
+ if len(options.options.runners) == 1 and not options.options.once:
+ qrunner = make_qrunner(*options.options.runners[0])
+ class Loop:
+ status = 0
+ def __init__(self, qrunner):
+ self._qrunner = qrunner
+ def name(self):
+ return self._qrunner.__class__.__name__
+ def stop(self):
+ self._qrunner.stop()
+ loop = Loop(qrunner)
+ set_signals(loop)
+ # Now start up the main loop
+ log = logging.getLogger('mailman.qrunner')
+ log.info('%s qrunner started.', loop.name())
+ qrunner.run()
+ log.info('%s qrunner exiting.', loop.name())
+ else:
+ # Anything else we have to handle a bit more specially
+ qrunners = []
+ for runner, rslice, rrange in options.options.runners:
+ qrunner = make_qrunner(runner, rslice, rrange, once=True)
+ qrunners.append(qrunner)
+ # This class is used to manage the main loop
+ class Loop:
+ status = 0
+ def __init__(self):
+ self._isdone = False
+ def name(self):
+ return 'Main loop'
+ def stop(self):
+ self._isdone = True
+ def isdone(self):
+ return self._isdone
+ loop = Loop()
+ set_signals(loop)
+ log.info('Main qrunner loop started.')
+ while not loop.isdone():
+ for qrunner in qrunners:
+ # In case the SIGTERM came in the middle of this iteration
+ if loop.isdone():
+ break
+ if options.options.verbose:
+ log.info('Now doing a %s qrunner iteration',
+ qrunner.__class__.__bases__[0].__name__)
+ qrunner.run()
+ if options.options.once:
+ break
+ log.info('Main qrunner loop exiting.')
+ # All done
+ sys.exit(loop.status)
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/remove_list.py b/src/mailman/bin/remove_list.py
new file mode 100644
index 000000000..05211b200
--- /dev/null
+++ b/src/mailman/bin/remove_list.py
@@ -0,0 +1,83 @@
+# Copyright (C) 1998-2009 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 .
+
+import sys
+
+from mailman.app.lifecycle import remove_list
+from mailman.config import config
+from mailman.i18n import _
+from mailman.options import MultipleMailingListOptions
+
+
+
+class ScriptOptions(MultipleMailingListOptions):
+ usage = _("""\
+%prog [options]
+
+Remove the components of a mailing list with impunity - beware!
+
+This removes (almost) all traces of a mailing list. By default, the lists
+archives are not removed, which is very handy for retiring old lists.
+""")
+
+ def add_options(self):
+ super(ScriptOptions, self).add_options()
+ self.parser.add_option(
+ '-a', '--archives',
+ default=False, action='store_true',
+ help=_("""\
+Remove the list's archives too, or if the list has already been deleted,
+remove any residual archives."""))
+ self.parser.add_option(
+ '-q', '--quiet',
+ default=False, action='store_true',
+ help=_('Suppress status messages'))
+
+ def sanity_check(self):
+ if len(self.options.listnames) == 0:
+ self.parser.error(_('Nothing to do'))
+ if len(self.arguments) > 0:
+ self.parser.error(_('Unexpected arguments'))
+
+
+
+def main():
+ options = ScriptOptions()
+ options.initialize()
+
+ for fqdn_listname in options.options.listnames:
+ if not options.options.quiet:
+ print _('Removing list: $fqdn_listname')
+ mlist = config.db.list_manager.get(fqdn_listname)
+ if mlist is None:
+ if options.options.archives:
+ print _("""\
+No such list: ${fqdn_listname}. Removing its residual archives.""")
+ else:
+ print >> sys.stderr, _(
+ 'No such list (or list already deleted): $fqdn_listname')
+
+ if not options.options.archives:
+ print _('Not removing archives. Reinvoke with -a to remove them.')
+
+ remove_list(fqdn_listname, mlist, options.options.archives)
+ config.db.commit()
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/senddigests.py b/src/mailman/bin/senddigests.py
new file mode 100644
index 000000000..fb057d6b9
--- /dev/null
+++ b/src/mailman/bin/senddigests.py
@@ -0,0 +1,83 @@
+# Copyright (C) 1998-2009 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 .
+
+import os
+import sys
+import optparse
+
+from mailman import MailList
+from mailman.i18n import _
+from mailman.initialize import initialize
+from mailman.version import MAILMAN_VERSION
+
+# Work around known problems with some RedHat cron daemons
+import signal
+signal.signal(signal.SIGCHLD, signal.SIG_DFL)
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options]
+
+Dispatch digests for lists w/pending messages and digest_send_periodic
+set."""))
+ parser.add_option('-l', '--listname',
+ type='string', default=[], action='append',
+ dest='listnames', help=_("""\
+Send the digest for the given list only, otherwise the digests for all
+lists are sent out. Multiple -l options may be given."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if args:
+ parser.print_help()
+ print >> sys.stderr, _('Unexpected arguments')
+ sys.exit(1)
+ return opts, args, parser
+
+
+
+def main():
+ opts, args, parser = parseargs()
+ initialize(opts.config)
+
+ for listname in set(opts.listnames or config.list_manager.names):
+ mlist = MailList.MailList(listname, lock=False)
+ if mlist.digest_send_periodic:
+ mlist.Lock()
+ try:
+ try:
+ mlist.send_digest_now()
+ mlist.Save()
+ # We are unable to predict what exception may occur in digest
+ # processing and we don't want to lose the other digests, so
+ # we catch everything.
+ except Exception, errmsg:
+ print >> sys.stderr, \
+ 'List: %s: problem processing %s:\n%s' % \
+ (listname,
+ os.path.join(mlist.data_path, 'digest.mbox'),
+ errmsg)
+ finally:
+ mlist.Unlock()
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/set_members.py b/src/mailman/bin/set_members.py
new file mode 100644
index 000000000..cdd11c56f
--- /dev/null
+++ b/src/mailman/bin/set_members.py
@@ -0,0 +1,189 @@
+# Copyright (C) 2007-2009 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 .
+
+import csv
+import optparse
+
+from mailman import Message
+from mailman import Utils
+from mailman import i18n
+from mailman import passwords
+from mailman.app.membership import add_member
+from mailman.app.notifications import (
+ send_admin_subscription_notice, send_welcome_message)
+from mailman.configuration import config
+from mailman.initialize import initialize
+from mailman.interfaces import DeliveryMode
+from mailman.version import MAILMAN_VERSION
+
+
+_ = i18n._
+
+DELIVERY_MODES = {
+ 'regular': DeliveryMode.regular,
+ 'plain': DeliveryMode.plaintext_digests,
+ 'mime': DeliveryMode.mime_digests,
+ }
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options] csv-file
+
+Set the membership of a mailing list to that described in a CSV file. Each
+row of the CSV file has the following format. Only the address column is
+required.
+
+ - email address
+ - full name (default: the empty string)
+ - delivery mode (default: regular delivery) [1]
+
+[1] The delivery mode is a case insensitive string of the following values:
+
+ regular - regular, i.e. immediate delivery
+ mime - MIME digest delivery
+ plain - plain text (RFC 1153) digest delivery
+
+Any address not included in the CSV file is removed from the list membership.
+"""))
+ parser.add_option('-l', '--listname',
+ type='string', help=_("""\
+Mailng list to set the membership for."""))
+ parser.add_option('-w', '--welcome-msg',
+ type='string', metavar='', help=_("""\
+Set whether or not to send the list members a welcome message, overriding
+whatever the list's 'send_welcome_msg' setting is."""))
+ parser.add_option('-a', '--admin-notify',
+ type='string', metavar='', help=_("""\
+Set whether or not to send the list administrators a notification on the
+success/failure of these subscriptions, overriding whatever the list's
+'admin_notify_mchanges' setting is."""))
+ parser.add_option('-v', '--verbose', action='store_true',
+ help=_('Increase verbosity'))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if opts.welcome_msg is not None:
+ ch = opts.welcome_msg[0].lower()
+ if ch == 'y':
+ opts.welcome_msg = True
+ elif ch == 'n':
+ opts.welcome_msg = False
+ else:
+ parser.error(_('Illegal value for -w: $opts.welcome_msg'))
+ if opts.admin_notify is not None:
+ ch = opts.admin_notify[0].lower()
+ if ch == 'y':
+ opts.admin_notify = True
+ elif ch == 'n':
+ opts.admin_notify = False
+ else:
+ parser.error(_('Illegal value for -a: $opts.admin_notify'))
+ return parser, opts, args
+
+
+
+def parse_file(filename):
+ members = {}
+ with open(filename) as fp:
+ for row in csv.reader(fp):
+ if len(row) == 0:
+ continue
+ elif len(row) == 1:
+ address = row[0]
+ real_name = None
+ delivery_mode = DeliveryMode.regular
+ elif len(row) == 2:
+ address, real_name = row
+ delivery_mode = DeliveryMode.regular
+ else:
+ # Ignore extra columns
+ address, real_name = row[0:2]
+ delivery_mode = DELIVERY_MODES.get(row[2].lower())
+ if delivery_mode is None:
+ delivery_mode = DeliveryMode.regular
+ members[address] = real_name, delivery_mode
+ return members
+
+
+
+def main():
+ parser, opts, args = parseargs()
+ initialize(opts.config)
+
+ mlist = config.db.list_manager.get(opts.listname)
+ if mlist is None:
+ parser.error(_('No such list: $opts.listname'))
+
+ # Set up defaults.
+ if opts.welcome_msg is None:
+ send_welcome_msg = mlist.send_welcome_msg
+ else:
+ send_welcome_msg = opts.welcome_msg
+ if opts.admin_notify is None:
+ admin_notify = mlist.admin_notify_mchanges
+ else:
+ admin_notify = opts.admin_notify
+
+ # Parse the csv files.
+ member_data = {}
+ for filename in args:
+ member_data.update(parse_file(filename))
+
+ future_members = set(member_data)
+ current_members = set(obj.address for obj in mlist.members.addresses)
+ add_members = future_members - current_members
+ delete_members = current_members - future_members
+ change_members = current_members & future_members
+
+ with i18n.using_language(mlist.preferred_language):
+ # Start by removing all the delete members.
+ for address in delete_members:
+ print _('deleting address: $address')
+ member = mlist.members.get_member(address)
+ member.unsubscribe()
+ # For all members that are in both lists, update their full name and
+ # delivery mode.
+ for address in change_members:
+ print _('updating address: $address')
+ real_name, delivery_mode = member_data[address]
+ member = mlist.members.get_member(address)
+ member.preferences.delivery_mode = delivery_mode
+ user = config.db.user_manager.get_user(address)
+ user.real_name = real_name
+ for address in add_members:
+ print _('adding address: $address')
+ real_name, delivery_mode = member_data[address]
+ password = passwords.make_secret(
+ Utils.MakeRandomPassword(),
+ passwords.lookup_scheme(config.PASSWORD_SCHEME))
+ add_member(mlist, address, real_name, password, delivery_mode,
+ mlist.preferred_language, send_welcome_msg,
+ admin_notify)
+ if send_welcome_msg:
+ send_welcome_message(mlist, address, language, delivery_mode)
+ if admin_notify:
+ send_admin_subscription_notice(mlist, address, real_name)
+
+ config.db.flush()
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/show_config.py b/src/mailman/bin/show_config.py
new file mode 100644
index 000000000..8d26c5c97
--- /dev/null
+++ b/src/mailman/bin/show_config.py
@@ -0,0 +1,97 @@
+# Copyright (C) 2006-2009 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 .
+
+import re
+import sys
+import pprint
+import optparse
+
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.version import MAILMAN_VERSION
+
+
+# List of names never to show even if --verbose
+NEVER_SHOW = ['__builtins__', '__doc__']
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%%prog [options] [pattern ...]
+
+Show the values of various Defaults.py/mailman.cfg variables.
+If one or more patterns are given, show only those variables
+whose names match a pattern"""))
+ parser.add_option('-v', '--verbose',
+ default=False, action='store_true',
+ help=_(
+"Show all configuration names, not just 'settings'."))
+ parser.add_option('-i', '--ignorecase',
+ default=False, action='store_true',
+ help=_("Match patterns case-insensitively."))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ return parser, opts, args
+
+
+
+def main():
+ parser, opts, args = parseargs()
+
+ patterns = []
+ if opts.ignorecase:
+ flag = re.IGNORECASE
+ else:
+ flag = 0
+ for pattern in args:
+ patterns.append(re.compile(pattern, flag))
+
+ pp = pprint.PrettyPrinter(indent=4)
+ config.load(opts.config)
+ names = config.__dict__.keys()
+ names.sort()
+ for name in names:
+ if name in NEVER_SHOW:
+ continue
+ if not opts.verbose:
+ if name.startswith('_') or re.search('[a-z]', name):
+ continue
+ if patterns:
+ hit = False
+ for pattern in patterns:
+ if pattern.search(name):
+ hit = True
+ break
+ if not hit:
+ continue
+ value = config.__dict__[name]
+ if isinstance(value, str):
+ if re.search('\n', value):
+ print '%s = """%s"""' %(name, value)
+ else:
+ print "%s = '%s'" % (name, value)
+ else:
+ print '%s = ' % name,
+ pp.pprint(value)
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/show_qfiles.py b/src/mailman/bin/show_qfiles.py
new file mode 100644
index 000000000..e4b64e0cd
--- /dev/null
+++ b/src/mailman/bin/show_qfiles.py
@@ -0,0 +1,91 @@
+# Copyright (C) 2006-2009 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 .
+
+import os
+import sys
+
+from cPickle import load
+
+from mailman.config import config
+from mailman.i18n import _
+from mailman.options import Options
+
+
+
+class ScriptOptions(Options):
+ usage = _("""
+%%prog [options] qfiles ...
+
+Show the contents of one or more Mailman queue files.""")
+
+ def add_options(self):
+ super(ScriptOptions, self).add_options()
+ self.parser.add_option(
+ '-q', '--quiet',
+ default=False, action='store_true',
+ help=_("Don't print 'helpful' message delimiters."))
+ self.parser.add_option(
+ '-s', '--summary',
+ default=False, action='store_true',
+ help=_('Show a summary of queue files.'))
+
+
+
+def main():
+ options = ScriptOptions()
+ options.initialize()
+
+ if options.options.summary:
+ queue_totals = {}
+ files_by_queue = {}
+ for switchboard in config.switchboards.values():
+ total = 0
+ file_mappings = {}
+ for filename in os.listdir(switchboard.queue_directory):
+ base, ext = os.path.splitext(filename)
+ file_mappings[ext] = file_mappings.get(ext, 0) + 1
+ total += 1
+ files_by_queue[switchboard.queue_directory] = file_mappings
+ queue_totals[switchboard.queue_directory] = total
+ # Sort by queue name.
+ for queue_directory in sorted(files_by_queue):
+ total = queue_totals[queue_directory]
+ print queue_directory
+ print _('\tfile count: $total')
+ file_mappings = files_by_queue[queue_directory]
+ for ext in sorted(file_mappings):
+ print '\t{0}: {1}'.format(ext, file_mappings[ext])
+ return
+ # No summary.
+ for filename in options.arguments:
+ if not options.options.quiet:
+ print '====================>', filename
+ with open(filename) as fp:
+ if filename.endswith('.pck'):
+ msg = load(fp)
+ data = load(fp)
+ if data.get('_parsemsg'):
+ sys.stdout.write(msg)
+ else:
+ sys.stdout.write(msg.as_string())
+ else:
+ sys.stdout.write(fp.read())
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/unshunt.py b/src/mailman/bin/unshunt.py
new file mode 100644
index 000000000..fc889377c
--- /dev/null
+++ b/src/mailman/bin/unshunt.py
@@ -0,0 +1,51 @@
+# Copyright (C) 2002-2009 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 .
+
+__metaclass__ = type
+__all__ = [
+ 'main',
+ ]
+
+
+import sys
+
+from mailman.config import config
+from mailman.i18n import _
+from mailman.options import Options
+
+
+
+def main():
+ options = Options()
+ options.initialize()
+
+ switchboard = config.switchboards['shunt']
+ switchboard.recover_backup_files()
+
+ for filebase in switchboard.files:
+ try:
+ msg, msgdata = switchboard.dequeue(filebase)
+ whichq = msgdata.get('whichq', 'in')
+ config.switchboards[whichq].enqueue(msg, msgdata)
+ except Exception, e:
+ # If there are any unshunting errors, log them and continue trying
+ # other shunted messages.
+ print >> sys.stderr, _(
+ 'Cannot unshunt message $filebase, skipping:\n$e')
+ else:
+ # Unlink the .bak file left by dequeue()
+ switchboard.finish(filebase)
diff --git a/src/mailman/bin/update.py b/src/mailman/bin/update.py
new file mode 100644
index 000000000..34ea6cda3
--- /dev/null
+++ b/src/mailman/bin/update.py
@@ -0,0 +1,660 @@
+# Copyright (C) 1998-2009 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 .
+
+import os
+import md5
+import sys
+import time
+import email
+import errno
+import shutil
+import cPickle
+import marshal
+import optparse
+
+from locknix.lockfile import TimeOutError
+
+from mailman import MailList
+from mailman import Message
+from mailman import Pending
+from mailman import Utils
+from mailman import version
+from mailman.MemberAdaptor import BYBOUNCE, ENABLED
+from mailman.OldStyleMemberships import OldStyleMemberships
+from mailman.Queue.Switchboard import Switchboard
+from mailman.configuration import config
+from mailman.i18n import _
+from mailman.initialize import initialize
+from mailman.utilities.filesystem import makedirs
+
+
+FRESH = 0
+NOTFRESH = -1
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=version.MAILMAN_VERSION,
+ usage=_("""\
+Perform all necessary upgrades.
+
+%prog [options]"""))
+ parser.add_option('-f', '--force',
+ default=False, action='store_true', help=_("""\
+Force running the upgrade procedures. Normally, if the version number of the
+installed Mailman matches the current version number (or a 'downgrade' is
+detected), nothing will be done."""))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ if args:
+ parser.print_help()
+ print >> sys.stderr, _('Unexpected arguments')
+ sys.exit(1)
+ return parser, opts, args
+
+
+
+def calcversions():
+ # Returns a tuple of (lastversion, thisversion). If the last version
+ # could not be determined, lastversion will be FRESH or NOTFRESH,
+ # depending on whether this installation appears to be fresh or not. The
+ # determining factor is whether there are files in the $var_prefix/logs
+ # subdir or not. The version numbers are HEX_VERSIONs.
+ #
+ # See if we stored the last updated version
+ lastversion = None
+ thisversion = version.HEX_VERSION
+ try:
+ fp = open(os.path.join(config.DATA_DIR, 'last_mailman_version'))
+ data = fp.read()
+ fp.close()
+ lastversion = int(data, 16)
+ except (IOError, ValueError):
+ pass
+ #
+ # try to figure out if this is a fresh install
+ if lastversion is None:
+ lastversion = FRESH
+ try:
+ if os.listdir(config.LOG_DIR):
+ lastversion = NOTFRESH
+ except OSError:
+ pass
+ return (lastversion, thisversion)
+
+
+
+def makeabs(relpath):
+ return os.path.join(config.PREFIX, relpath)
+
+
+def make_varabs(relpath):
+ return os.path.join(config.VAR_PREFIX, relpath)
+
+
+
+def move_language_templates(mlist):
+ listname = mlist.internal_name()
+ print _('Fixing language templates: $listname')
+ # Mailman 2.1 has a new cascading search for its templates, defined and
+ # described in Utils.py:maketext(). Putting templates in the top level
+ # templates/ subdir or the lists/ subdir is deprecated and no
+ # longer searched..
+ #
+ # What this means is that most templates can live in the global templates/
+ # subdirectory, and only needs to be copied into the list-, vhost-, or
+ # site-specific language directories when needed.
+ #
+ # Also, by default all standard (i.e. English) templates must now live in
+ # the templates/en directory. This update cleans up all the templates,
+ # deleting more-specific duplicates (as calculated by md5 checksums) in
+ # favor of more-global locations.
+ #
+ # First, get rid of any lists/ template or lists//en template
+ # that is identical to the global templates/* default.
+ for gtemplate in os.listdir(os.path.join(config.TEMPLATE_DIR, 'en')):
+ # BAW: get rid of old templates, e.g. admlogin.txt and
+ # handle_opts.html
+ try:
+ fp = open(os.path.join(config.TEMPLATE_DIR, gtemplate))
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ # No global template
+ continue
+ gcksum = md5.new(fp.read()).digest()
+ fp.close()
+ # Match against the lists//* template
+ try:
+ fp = open(os.path.join(mlist.fullpath(), gtemplate))
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ else:
+ tcksum = md5.new(fp.read()).digest()
+ fp.close()
+ if gcksum == tcksum:
+ os.unlink(os.path.join(mlist.fullpath(), gtemplate))
+ # Match against the lists//*.prev template
+ try:
+ fp = open(os.path.join(mlist.fullpath(), gtemplate + '.prev'))
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ else:
+ tcksum = md5.new(fp.read()).digest()
+ fp.close()
+ if gcksum == tcksum:
+ os.unlink(os.path.join(mlist.fullpath(), gtemplate + '.prev'))
+ # Match against the lists//en/* templates
+ try:
+ fp = open(os.path.join(mlist.fullpath(), 'en', gtemplate))
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ else:
+ tcksum = md5.new(fp.read()).digest()
+ fp.close()
+ if gcksum == tcksum:
+ os.unlink(os.path.join(mlist.fullpath(), 'en', gtemplate))
+ # Match against the templates/* template
+ try:
+ fp = open(os.path.join(config.TEMPLATE_DIR, gtemplate))
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ else:
+ tcksum = md5.new(fp.read()).digest()
+ fp.close()
+ if gcksum == tcksum:
+ os.unlink(os.path.join(config.TEMPLATE_DIR, gtemplate))
+ # Match against the templates/*.prev template
+ try:
+ fp = open(os.path.join(config.TEMPLATE_DIR, gtemplate + '.prev'))
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ else:
+ tcksum = md5.new(fp.read()).digest()
+ fp.close()
+ if gcksum == tcksum:
+ os.unlink(os.path.join(config.TEMPLATE_DIR,
+ gtemplate + '.prev'))
+
+
+
+def situate_list(listname):
+ # This turns the directory called 'listname' into a directory called
+ # 'listname@domain'. Start by finding out what the domain should be.
+ # A list's domain is its email host.
+ mlist = MailList.MailList(listname, lock=False, check_version=False)
+ fullname = mlist.fqdn_listname
+ oldpath = os.path.join(config.VAR_PREFIX, 'lists', listname)
+ newpath = os.path.join(config.VAR_PREFIX, 'lists', fullname)
+ if os.path.exists(newpath):
+ print >> sys.stderr, _('WARNING: could not situate list: $listname')
+ else:
+ os.rename(oldpath, newpath)
+ print _('situated list $listname to $fullname')
+ return fullname
+
+
+
+def dolist(listname):
+ mlist = MailList.MailList(listname, lock=False)
+ try:
+ mlist.Lock(0.5)
+ except TimeOutError:
+ print >> sys.stderr, _(
+ 'WARNING: could not acquire lock for list: $listname')
+ return 1
+ # Sanity check the invariant that every BYBOUNCE disabled member must have
+ # bounce information. Some earlier betas broke this. BAW: we're
+ # submerging below the MemberAdaptor interface, so skip this if we're not
+ # using OldStyleMemberships.
+ if isinstance(mlist._memberadaptor, OldStyleMemberships):
+ noinfo = {}
+ for addr, (reason, when) in mlist.delivery_status.items():
+ if reason == BYBOUNCE and not mlist.bounce_info.has_key(addr):
+ noinfo[addr] = reason, when
+ # What to do about these folks with a BYBOUNCE delivery status and no
+ # bounce info? This number should be very small, and I think it's
+ # fine to simple re-enable them and let the bounce machinery
+ # re-disable them if necessary.
+ n = len(noinfo)
+ if n > 0:
+ print _(
+ 'Resetting $n BYBOUNCEs disabled addrs with no bounce info')
+ for addr in noinfo.keys():
+ mlist.setDeliveryStatus(addr, ENABLED)
+
+ mbox_dir = make_varabs('archives/private/%s.mbox' % (listname))
+ mbox_file = make_varabs('archives/private/%s.mbox/%s' % (listname,
+ listname))
+ o_pub_mbox_file = make_varabs('archives/public/%s' % (listname))
+ o_pri_mbox_file = make_varabs('archives/private/%s' % (listname))
+ html_dir = o_pri_mbox_file
+ o_html_dir = makeabs('public_html/archives/%s' % (listname))
+ # Make the mbox directory if it's not there.
+ if not os.path.exists(mbox_dir):
+ makedirs(mbox_dir)
+ else:
+ # This shouldn't happen, but hey, just in case
+ if not os.path.isdir(mbox_dir):
+ print _("""\
+For some reason, $mbox_dir exists as a file. This won't work with b6, so I'm
+renaming it to ${mbox_dir}.tmp and proceeding.""")
+ os.rename(mbox_dir, "%s.tmp" % (mbox_dir))
+ makedirs(mbox_dir)
+ # Move any existing mboxes around, but watch out for both a public and a
+ # private one existing
+ if os.path.isfile(o_pri_mbox_file) and os.path.isfile(o_pub_mbox_file):
+ if mlist.archive_private:
+ print _("""\
+
+$listname has both public and private mbox archives. Since this list
+currently uses private archiving, I'm installing the private mbox archive --
+$o_pri_mbox_file -- as the active archive, and renaming
+ $o_pub_mbox_file
+to
+ ${o_pub_mbox_file}.preb6
+
+You can integrate that into the archives if you want by using the 'arch'
+script.
+""") % (mlist._internal_name, o_pri_mbox_file, o_pub_mbox_file,
+ o_pub_mbox_file)
+ os.rename(o_pub_mbox_file, "%s.preb6" % (o_pub_mbox_file))
+ else:
+ print _("""\
+$mlist._internal_name has both public and private mbox archives. Since this
+list currently uses public archiving, I'm installing the public mbox file
+archive file ($o_pub_mbox_file) as the active one, and renaming
+$o_pri_mbox_file to ${o_pri_mbox_file}.preb6
+
+You can integrate that into the archives if you want by using the 'arch'
+script.
+""")
+ os.rename(o_pri_mbox_file, "%s.preb6" % (o_pri_mbox_file))
+ # Move private archive mbox there if it's around
+ # and take into account all sorts of absurdities
+ print _('- updating old private mbox file')
+ if os.path.exists(o_pri_mbox_file):
+ if os.path.isfile(o_pri_mbox_file):
+ os.rename(o_pri_mbox_file, mbox_file)
+ elif not os.path.isdir(o_pri_mbox_file):
+ newname = "%s.mm_install-dunno_what_this_was_but_its_in_the_way" \
+ % o_pri_mbox_file
+ os.rename(o_pri_mbox_file, newname)
+ print _("""\
+ unknown file in the way, moving
+ $o_pri_mbox_file
+ to
+ $newname""")
+ else:
+ # directory
+ print _("""\
+ looks like you have a really recent development installation...
+ you're either one brave soul, or you already ran me""")
+ # Move public archive mbox there if it's around
+ # and take into account all sorts of absurdities.
+ print _('- updating old public mbox file')
+ if os.path.exists(o_pub_mbox_file):
+ if os.path.isfile(o_pub_mbox_file):
+ os.rename(o_pub_mbox_file, mbox_file)
+ elif not os.path.isdir(o_pub_mbox_file):
+ newname = "%s.mm_install-dunno_what_this_was_but_its_in_the_way" \
+ % o_pub_mbox_file
+ os.rename(o_pub_mbox_file, newname)
+ print _("""\
+ unknown file in the way, moving
+ $o_pub_mbox_file
+ to
+ $newname""")
+ else: # directory
+ print _("""\
+ looks like you have a really recent development installation...
+ you're either one brave soul, or you already ran me""")
+ # Move the html archives there
+ if os.path.isdir(o_html_dir):
+ os.rename(o_html_dir, html_dir)
+ # chmod the html archives
+ os.chmod(html_dir, 02775)
+ # BAW: Is this still necessary?!
+ mlist.Save()
+ # Check to see if pre-b4 list-specific templates are around
+ # and move them to the new place if there's not already
+ # a new one there
+ tmpl_dir = os.path.join(config.PREFIX, "templates")
+ list_dir = os.path.join(config.PREFIX, "lists")
+ b4_tmpl_dir = os.path.join(tmpl_dir, mlist._internal_name)
+ new_tmpl_dir = os.path.join(list_dir, mlist._internal_name)
+ if os.path.exists(b4_tmpl_dir):
+ print _("""\
+- This list looks like it might have <= b4 list templates around""")
+ for f in os.listdir(b4_tmpl_dir):
+ o_tmpl = os.path.join(b4_tmpl_dir, f)
+ n_tmpl = os.path.join(new_tmpl_dir, f)
+ if os.path.exists(o_tmpl):
+ if not os.path.exists(n_tmpl):
+ os.rename(o_tmpl, n_tmpl)
+ print _('- moved $o_tmpl to $n_tmpl')
+ else:
+ print _("""\
+- both $o_tmpl and $n_tmpl exist, leaving untouched""")
+ else:
+ print _("""\
+- $o_tmpl doesn't exist, leaving untouched""")
+ # Move all the templates to the en language subdirectory as required for
+ # Mailman 2.1
+ move_language_templates(mlist)
+ # Avoid eating filehandles with the list lockfiles
+ mlist.Unlock()
+ return 0
+
+
+
+def archive_path_fixer(unused_arg, dir, files):
+ # Passed to os.path.walk to fix the perms on old html archives.
+ for f in files:
+ abs = os.path.join(dir, f)
+ if os.path.isdir(abs):
+ if f == "database":
+ os.chmod(abs, 02770)
+ else:
+ os.chmod(abs, 02775)
+ elif os.path.isfile(abs):
+ os.chmod(abs, 0664)
+
+
+def remove_old_sources(module):
+ # Also removes old directories.
+ src = '%s/%s' % (config.PREFIX, module)
+ pyc = src + "c"
+ if os.path.isdir(src):
+ print _('removing directory $src and everything underneath')
+ shutil.rmtree(src)
+ elif os.path.exists(src):
+ print _('removing $src')
+ try:
+ os.unlink(src)
+ except os.error, rest:
+ print _("Warning: couldn't remove $src -- $rest")
+ if module.endswith('.py') and os.path.exists(pyc):
+ try:
+ os.unlink(pyc)
+ except OSError, rest:
+ print _("couldn't remove old file $pyc -- $rest")
+
+
+
+def update_qfiles():
+ print _('updating old qfiles')
+ prefix = `time.time()` + '+'
+ # Be sure the qfiles/in directory exists (we don't really need the
+ # switchboard object, but it's convenient for creating the directory).
+ sb = Switchboard(config.INQUEUE_DIR)
+ for filename in os.listdir(config.QUEUE_DIR):
+ # Updating means just moving the .db and .msg files to qfiles/in where
+ # it should be dequeued, converted, and processed normally.
+ if os.path.splitext(filename) == '.msg':
+ oldmsgfile = os.path.join(config.QUEUE_DIR, filename)
+ newmsgfile = os.path.join(config.INQUEUE_DIR, prefix + filename)
+ os.rename(oldmsgfile, newmsgfile)
+ elif os.path.splitext(filename) == '.db':
+ olddbfile = os.path.join(config.QUEUE_DIR, filename)
+ newdbfile = os.path.join(config.INQUEUE_DIR, prefix + filename)
+ os.rename(olddbfile, newdbfile)
+ # Now update for the Mailman 2.1.5 qfile format. For every filebase in
+ # the qfiles/* directories that has both a .pck and a .db file, pull the
+ # data out and re-queue them.
+ for dirname in os.listdir(config.QUEUE_DIR):
+ dirpath = os.path.join(config.QUEUE_DIR, dirname)
+ if dirpath == config.BADQUEUE_DIR:
+ # The files in qfiles/bad can't possibly be pickles
+ continue
+ sb = Switchboard(dirpath)
+ try:
+ for filename in os.listdir(dirpath):
+ filepath = os.path.join(dirpath, filename)
+ filebase, ext = os.path.splitext(filepath)
+ # Handle the .db metadata files as part of the handling of the
+ # .pck or .msg message files.
+ if ext not in ('.pck', '.msg'):
+ continue
+ msg, data = dequeue(filebase)
+ if msg is not None and data is not None:
+ sb.enqueue(msg, data)
+ except EnvironmentError, e:
+ if e.errno <> errno.ENOTDIR:
+ raise
+ print _('Warning! Not a directory: $dirpath')
+
+
+
+# Implementations taken from the pre-2.1.5 Switchboard
+def ext_read(filename):
+ fp = open(filename)
+ d = marshal.load(fp)
+ # Update from version 2 files
+ if d.get('version', 0) == 2:
+ del d['filebase']
+ # Do the reverse conversion (repr -> float)
+ for attr in ['received_time']:
+ try:
+ sval = d[attr]
+ except KeyError:
+ pass
+ else:
+ # Do a safe eval by setting up a restricted execution
+ # environment. This may not be strictly necessary since we
+ # know they are floats, but it can't hurt.
+ d[attr] = eval(sval, {'__builtins__': {}})
+ fp.close()
+ return d
+
+
+def dequeue(filebase):
+ # Calculate the .db and .msg filenames from the given filebase.
+ msgfile = os.path.join(filebase + '.msg')
+ pckfile = os.path.join(filebase + '.pck')
+ dbfile = os.path.join(filebase + '.db')
+ # Now we are going to read the message and metadata for the given
+ # filebase. We want to read things in this order: first, the metadata
+ # file to find out whether the message is stored as a pickle or as
+ # plain text. Second, the actual message file. However, we want to
+ # first unlink the message file and then the .db file, because the
+ # qrunner only cues off of the .db file
+ msg = None
+ try:
+ data = ext_read(dbfile)
+ os.unlink(dbfile)
+ except EnvironmentError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ data = {}
+ # Between 2.1b4 and 2.1b5, the `rejection-notice' key in the metadata
+ # was renamed to `rejection_notice', since dashes in the keys are not
+ # supported in METAFMT_ASCII.
+ if data.has_key('rejection-notice'):
+ data['rejection_notice'] = data['rejection-notice']
+ del data['rejection-notice']
+ msgfp = None
+ try:
+ try:
+ msgfp = open(pckfile)
+ msg = cPickle.load(msgfp)
+ os.unlink(pckfile)
+ except EnvironmentError, e:
+ if e.errno <> errno.ENOENT: raise
+ msgfp = None
+ try:
+ msgfp = open(msgfile)
+ msg = email.message_from_file(msgfp, Message.Message)
+ os.unlink(msgfile)
+ except EnvironmentError, e:
+ if e.errno <> errno.ENOENT: raise
+ except (email.Errors.MessageParseError, ValueError), e:
+ # This message was unparsable, most likely because its
+ # MIME encapsulation was broken. For now, there's not
+ # much we can do about it.
+ print _('message is unparsable: $filebase')
+ msgfp.close()
+ msgfp = None
+ if config.QRUNNER_SAVE_BAD_MESSAGES:
+ # Cheapo way to ensure the directory exists w/ the
+ # proper permissions.
+ sb = Switchboard(config.BADQUEUE_DIR)
+ os.rename(msgfile, os.path.join(
+ config.BADQUEUE_DIR, filebase + '.txt'))
+ else:
+ os.unlink(msgfile)
+ msg = data = None
+ except EOFError:
+ # For some reason the pckfile was empty. Just delete it.
+ print _('Warning! Deleting empty .pck file: $pckfile')
+ os.unlink(pckfile)
+ finally:
+ if msgfp:
+ msgfp.close()
+ return msg, data
+
+
+
+def main():
+ parser, opts, args = parseargs()
+ initialize(opts.config)
+
+ # calculate the versions
+ lastversion, thisversion = calcversions()
+ hexlversion = hex(lastversion)
+ hextversion = hex(thisversion)
+ if lastversion == thisversion and not opts.force:
+ # nothing to do
+ print _('No updates are necessary.')
+ sys.exit(0)
+ if lastversion > thisversion and not opts.force:
+ print _("""\
+Downgrade detected, from version $hexlversion to version $hextversion
+This is probably not safe.
+Exiting.""")
+ sys.exit(1)
+ print _('Upgrading from version $hexlversion to $hextversion')
+ errors = 0
+ # get rid of old stuff
+ print _('getting rid of old source files')
+ for mod in ('mailman/Archiver.py', 'mailman/HyperArch.py',
+ 'mailman/HyperDatabase.py', 'mailman/pipermail.py',
+ 'mailman/smtplib.py', 'mailman/Cookie.py',
+ 'bin/update_to_10b6', 'scripts/mailcmd',
+ 'scripts/mailowner', 'mail/wrapper', 'mailman/pythonlib',
+ 'cgi-bin/archives', 'mailman/MailCommandHandler'):
+ remove_old_sources(mod)
+ if not config.list_manager.names:
+ print _('no lists == nothing to do, exiting')
+ return
+ # For people with web archiving, make sure the directories
+ # in the archiving are set with proper perms for b6.
+ if os.path.isdir("%s/public_html/archives" % config.PREFIX):
+ print _("""\
+fixing all the perms on your old html archives to work with b6
+If your archives are big, this could take a minute or two...""")
+ os.path.walk("%s/public_html/archives" % config.PREFIX,
+ archive_path_fixer, "")
+ print _('done')
+ for listname in config.list_manager.names:
+ # With 2.2.0a0, all list names grew an @domain suffix. If you find a
+ # list without that, move it now.
+ if not '@' in listname:
+ listname = situate_list(listname)
+ print _('Updating mailing list: $listname')
+ errors += dolist(listname)
+ print
+ print _('Updating Usenet watermarks')
+ wmfile = os.path.join(config.DATA_DIR, 'gate_watermarks')
+ try:
+ fp = open(wmfile)
+ except IOError:
+ print _('- nothing to update here')
+ else:
+ d = marshal.load(fp)
+ fp.close()
+ for listname in d.keys():
+ if listname not in listnames:
+ # this list no longer exists
+ continue
+ mlist = MailList.MailList(listname, lock=0)
+ try:
+ mlist.Lock(0.5)
+ except TimeOutError:
+ print >> sys.stderr, _(
+ 'WARNING: could not acquire lock for list: $listname')
+ errors = errors + 1
+ else:
+ # Pre 1.0b7 stored 0 in the gate_watermarks file to indicate
+ # that no gating had been done yet. Without coercing this to
+ # None, the list could now suddenly get flooded.
+ mlist.usenet_watermark = d[listname] or None
+ mlist.Save()
+ mlist.Unlock()
+ os.unlink(wmfile)
+ print _('- usenet watermarks updated and gate_watermarks removed')
+ # In Mailman 2.1, the qfiles directory has a different structure and a
+ # different content. Also, in Mailman 2.1.5 we collapsed the message
+ # files from separate .msg (pickled Message objects) and .db (marshalled
+ # dictionaries) to a shared .pck file containing two pickles.
+ update_qfiles()
+ # This warning was necessary for the upgrade from 1.0b9 to 1.0b10.
+ # There's no good way of figuring this out for releases prior to 2.0beta2
+ # :(
+ if lastversion == NOTFRESH:
+ print _("""
+
+NOTE NOTE NOTE NOTE NOTE
+
+ You are upgrading an existing Mailman installation, but I can't tell what
+ version you were previously running.
+
+ If you are upgrading from Mailman 1.0b9 or earlier you will need to
+ manually update your mailing lists. For each mailing list you need to
+ copy the file templates/options.html lists//options.html.
+
+ However, if you have edited this file via the Web interface, you will have
+ to merge your changes into this file, otherwise you will lose your
+ changes.
+
+NOTE NOTE NOTE NOTE NOTE
+
+""")
+ if not errors:
+ # Record the version we just upgraded to
+ fp = open(os.path.join(config.DATA_DIR, 'last_mailman_version'), 'w')
+ fp.write(hex(config.HEX_VERSION) + '\n')
+ fp.close()
+ else:
+ lockdir = config.LOCK_DIR
+ print _('''\
+
+ERROR:
+
+The locks for some lists could not be acquired. This means that either
+Mailman was still active when you upgraded, or there were stale locks in the
+$lockdir directory.
+
+You must put Mailman into a quiescent state and remove all stale locks, then
+re-run "make update" manually. See the INSTALL and UPGRADE files for details.
+''')
diff --git a/src/mailman/bin/version.py b/src/mailman/bin/version.py
new file mode 100644
index 000000000..0fb2c5a5b
--- /dev/null
+++ b/src/mailman/bin/version.py
@@ -0,0 +1,46 @@
+# Copyright (C) 1998-2009 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 .
+
+import optparse
+
+from mailman import version
+from mailman.i18n import _
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=version.MAILMAN_VERSION,
+ usage=_("""\
+%prog
+
+Print the Mailman version and exit."""))
+ opts, args = parser.parse_args()
+ if args:
+ parser.error(_('Unexpected arguments'))
+ return parser, opts, args
+
+
+
+def main():
+ parser, opts, args = parseargs()
+ # Yes, this is kind of silly
+ print _('Using $version.MAILMAN_VERSION ($version.CODENAME)')
+
+
+
+if __name__ == '__main__':
+ main()
diff --git a/src/mailman/bin/withlist.py b/src/mailman/bin/withlist.py
new file mode 100644
index 000000000..8f2d8a2b5
--- /dev/null
+++ b/src/mailman/bin/withlist.py
@@ -0,0 +1,220 @@
+# Copyright (C) 1998-2009 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 .
+
+import os
+import sys
+import optparse
+
+from mailman import interact
+from mailman.config import config
+from mailman.core.initialize import initialize
+from mailman.i18n import _
+from mailman.version import MAILMAN_VERSION
+
+
+LAST_MLIST = None
+VERBOSE = True
+
+
+
+def do_list(listname, args, func):
+ global LAST_MLIST
+
+ if '@' not in listname:
+ listname += '@' + config.DEFAULT_EMAIL_HOST
+
+ # XXX FIXME Remove this when this script is converted to
+ # MultipleMailingListOptions.
+ listname = listname.decode(sys.getdefaultencoding())
+ mlist = config.db.list_manager.get(listname)
+ if mlist is None:
+ print >> sys.stderr, _('Unknown list: $listname')
+ else:
+ if VERBOSE:
+ print >> sys.stderr, _('Loaded list: $listname')
+ LAST_MLIST = mlist
+ # Try to import the module and run the callable.
+ if func:
+ return func(mlist, *args)
+ return None
+
+
+
+def parseargs():
+ parser = optparse.OptionParser(version=MAILMAN_VERSION,
+ usage=_("""\
+%prog [options] listname [args ...]
+
+General framework for interacting with a mailing list object.
+
+There are two ways to use this script: interactively or programmatically.
+Using it interactively allows you to play with, examine and modify a
+IMailinglist object from Python's interactive interpreter. When running
+interactively, a IMailingList object called 'm' will be available in the
+global namespace.
+
+Programmatically, you can write a function to operate on a IMailingList
+object, and this script will take care of the housekeeping (see below for
+examples). In that case, the general usage syntax is:
+
+ % bin/withlist [options] listname [args ...]
+
+Here's an example of how to use the -r option. Say you have a file in the
+Mailman installation directory called 'listaddr.py', with the following
+two functions:
+
+ def listaddr(mlist):
+ print mlist.posting_address
+
+ def requestaddr(mlist):
+ print mlist.request_address
+
+Now, from the command line you can print the list's posting address by running
+the following from the command line:
+
+ % bin/withlist -r listaddr mylist
+ Loading list: mylist
+ Importing listaddr ...
+ Running listaddr.listaddr() ...
+ mylist@myhost.com
+
+And you can print the list's request address by running:
+
+ % bin/withlist -r listaddr.requestaddr mylist
+ Loading list: mylist
+ Importing listaddr ...
+ Running listaddr.requestaddr() ...
+ mylist-request@myhost.com
+
+As another example, say you wanted to change the password for a particular
+user on a particular list. You could put the following function in a file
+called 'changepw.py':
+
+ from mailman.errors import NotAMemberError
+
+ def changepw(mlist, addr, newpasswd):
+ try:
+ mlist.setMemberPassword(addr, newpasswd)
+ mlist.Save()
+ except NotAMemberError:
+ print 'No address matched:', addr
+
+and run this from the command line:
+
+ % bin/withlist -l -r changepw mylist somebody@somewhere.org foobar"""))
+ parser.add_option('-i', '--interactive',
+ default=None, action='store_true', help=_("""\
+Leaves you at an interactive prompt after all other processing is complete.
+This is the default unless the -r option is given."""))
+ parser.add_option('-r', '--run',
+ type='string', help=_("""\
+This can be used to run a script with the opened IMailingList object. This
+works by attempting to import'module' (which must be in the directory
+containing withlist, or already be accessible on your sys.path), and then
+calling 'callable' from the module. callable can be a class or function; it
+is called with the IMailingList object as the first argument. If additional
+args are given on the command line, they are passed as subsequent positional
+args to the callable.
+
+Note that 'module.' is optional; if it is omitted then a module with the name
+'callable' will be imported.
+
+The global variable 'r' will be set to the results of this call."""))
+ parser.add_option('-a', '--all',
+ default=False, action='store_true', help=_("""\
+This option only works with the -r option. Use this if you want to execute
+the script on all mailing lists. When you use -a you should not include a
+listname argument on the command line. The variable 'r' will be a list of all
+the results."""))
+ parser.add_option('-q', '--quiet',
+ default=False, action='store_true',
+ help=_('Suppress all status messages.'))
+ parser.add_option('-C', '--config',
+ help=_('Alternative configuration file to use'))
+ opts, args = parser.parse_args()
+ return parser, opts, args
+
+
+
+def main():
+ global VERBOSE
+
+ parser, opts, args = parseargs()
+ config_file = (os.getenv('MAILMAN_CONFIG_FILE')
+ if opts.config is None
+ else opts.config)
+ initialize(config_file, not opts.quiet)
+
+ VERBOSE = not opts.quiet
+ # The default for interact is true unless -r was given
+ if opts.interactive is None:
+ if not opts.run:
+ opts.interactive = True
+ else:
+ opts.interactive = False
+
+ dolist = True
+ if len(args) < 1 and not opts.all:
+ warning = _('No list name supplied.')
+ if opts.interactive:
+ # Let them keep going
+ print >> sys.stderr, warning
+ dolist = False
+ else:
+ parser.error(warning)
+
+ if opts.all and not opts.run:
+ parser.error(_('--all requires --run'))
+
+ # Try to import the module for the callable
+ func = None
+ if opts.run:
+ i = opts.run.rfind('.')
+ if i < 0:
+ module = opts.run
+ callable = opts.run
+ else:
+ module = opts.run[:i]
+ callable = opts.run[i+1:]
+ if VERBOSE:
+ print >> sys.stderr, _('Importing $module ...')
+ __import__(module)
+ mod = sys.modules[module]
+ if VERBOSE:
+ print >> sys.stderr, _('Running ${module}.${callable}() ...')
+ func = getattr(mod, callable)
+
+ r = None
+ if opts.all:
+ r = [do_list(listname, args, func)
+ for listname in config.list_manager.names]
+ elif dolist:
+ listname = args.pop(0).lower().strip()
+ r = do_list(listname, args, func)
+
+ # Now go to interactive mode, perhaps
+ if opts.interactive:
+ if dolist:
+ banner = _(
+ "The variable 'm' is the $listname mailing list")
+ else:
+ banner = interact.DEFAULT_BANNER
+ overrides = dict(m=LAST_MLIST, r=r,
+ commit=config.db.commit,
+ abort=config.db.abort,
+ config=config)
+ interact.interact(upframe=False, banner=banner, overrides=overrides)
diff --git a/src/mailman/chains/__init__.py b/src/mailman/chains/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mailman/chains/accept.py b/src/mailman/chains/accept.py
new file mode 100644
index 000000000..bd47f42c8
--- /dev/null
+++ b/src/mailman/chains/accept.py
@@ -0,0 +1,58 @@
+# Copyright (C) 2007-2009 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 .
+
+"""The terminal 'accept' chain."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'AcceptChain',
+ ]
+
+import logging
+
+from mailman.chains.base import TerminalChainBase
+from mailman.config import config
+from mailman.i18n import _
+
+
+log = logging.getLogger('mailman.vette')
+SEMISPACE = '; '
+
+
+
+class AcceptChain(TerminalChainBase):
+ """Accept the message for posting."""
+
+ name = 'accept'
+ description = _('Accept a message.')
+
+ def _process(self, mlist, msg, msgdata):
+ """See `TerminalChainBase`."""
+ # Start by decorating the message with a header that contains a list
+ # of all the rules that matched. These metadata could be None or an
+ # empty list.
+ rule_hits = msgdata.get('rule_hits')
+ if rule_hits:
+ msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits)
+ rule_misses = msgdata.get('rule_misses')
+ if rule_misses:
+ msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses)
+ accept_queue = config.switchboards['pipeline']
+ accept_queue.enqueue(msg, msgdata)
+ log.info('ACCEPT: %s', msg.get('message-id', 'n/a'))
diff --git a/src/mailman/chains/base.py b/src/mailman/chains/base.py
new file mode 100644
index 000000000..bcd946b40
--- /dev/null
+++ b/src/mailman/chains/base.py
@@ -0,0 +1,122 @@
+# Copyright (C) 2008-2009 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 .
+
+"""Base class for terminal chains."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Chain',
+ 'Link',
+ 'TerminalChainBase',
+ ]
+
+
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.interfaces.chain import (
+ IChain, IChainIterator, IChainLink, IMutableChain, LinkAction)
+
+
+
+class Link:
+ """A chain link."""
+ implements(IChainLink)
+
+ def __init__(self, rule, action=None, chain=None, function=None):
+ self.rule = rule
+ self.action = (LinkAction.defer if action is None else action)
+ self.chain = chain
+ self.function = function
+
+
+
+class TerminalChainBase:
+ """A base chain that always matches and executes a method.
+
+ The method is called 'process' and must be provided by the subclass.
+ """
+ implements(IChain, IChainIterator)
+
+ def _process(self, mlist, msg, msgdata):
+ """Process the message for the given mailing list.
+
+ This must be overridden by subclasses.
+ """
+ raise NotImplementedError
+
+ def get_links(self, mlist, msg, msgdata):
+ """See `IChain`."""
+ return iter(self)
+
+ def __iter__(self):
+ """See `IChainIterator`."""
+ truth = config.rules['truth']
+ # First, yield a link that always runs the process method.
+ yield Link(truth, LinkAction.run, function=self._process)
+ # Now yield a rule that stops all processing.
+ yield Link(truth, LinkAction.stop)
+
+
+
+class Chain:
+ """Generic chain base class."""
+ implements(IMutableChain)
+
+ def __init__(self, name, description):
+ assert name not in config.chains, (
+ 'Duplicate chain name: {0}'.format(name))
+ self.name = name
+ self.description = description
+ self._links = []
+ # Register the chain.
+ config.chains[name] = self
+
+ def append_link(self, link):
+ """See `IMutableChain`."""
+ self._links.append(link)
+
+ def flush(self):
+ """See `IMutableChain`."""
+ self._links = []
+
+ def get_links(self, mlist, msg, msgdata):
+ """See `IChain`."""
+ return iter(ChainIterator(self))
+
+ def get_iterator(self):
+ """Return an iterator over the links."""
+ # We do it this way in order to preserve a separation of interfaces,
+ # and allows .get_links() to be overridden.
+ for link in self._links:
+ yield link
+
+
+
+class ChainIterator:
+ """Generic chain iterator."""
+
+ implements(IChainIterator)
+
+ def __init__(self, chain):
+ self._chain = chain
+
+ def __iter__(self):
+ """See `IChainIterator`."""
+ return self._chain.get_iterator()
diff --git a/src/mailman/chains/builtin.py b/src/mailman/chains/builtin.py
new file mode 100644
index 000000000..05912a2f2
--- /dev/null
+++ b/src/mailman/chains/builtin.py
@@ -0,0 +1,86 @@
+# Copyright (C) 2007-2009 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 .
+
+"""The default built-in starting chain."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'BuiltInChain',
+ ]
+
+
+import logging
+
+from zope.interface import implements
+
+from mailman.chains.base import Link
+from mailman.config import config
+from mailman.i18n import _
+from mailman.interfaces.chain import IChain, LinkAction
+
+
+log = logging.getLogger('mailman.vette')
+
+
+
+class BuiltInChain:
+ """Default built-in chain."""
+
+ implements(IChain)
+
+ name = 'built-in'
+ description = _('The built-in moderation chain.')
+
+ _link_descriptions = (
+ ('approved', LinkAction.jump, 'accept'),
+ ('emergency', LinkAction.jump, 'hold'),
+ ('loop', LinkAction.jump, 'discard'),
+ # Do all of the following before deciding whether to hold the message
+ # for moderation.
+ ('administrivia', LinkAction.defer, None),
+ ('implicit-dest', LinkAction.defer, None),
+ ('max-recipients', LinkAction.defer, None),
+ ('max-size', LinkAction.defer, None),
+ ('news-moderation', LinkAction.defer, None),
+ ('no-subject', LinkAction.defer, None),
+ ('suspicious-header', LinkAction.defer, None),
+ # Now if any of the above hit, jump to the hold chain.
+ ('any', LinkAction.jump, 'hold'),
+ # Take a detour through the self header matching chain, which we'll
+ # create later.
+ ('truth', LinkAction.detour, 'header-match'),
+ # Finally, the builtin chain selfs to acceptance.
+ ('truth', LinkAction.jump, 'accept'),
+ )
+
+ def __init__(self):
+ self._cached_links = None
+
+ def get_links(self, mlist, msg, msgdata):
+ """See `IChain`."""
+ if self._cached_links is None:
+ self._cached_links = links = []
+ for rule_name, action, chain_name in self._link_descriptions:
+ # Get the named rule.
+ rule = config.rules[rule_name]
+ # Get the chain, if one is defined.
+ chain = (None if chain_name is None
+ else config.chains[chain_name])
+ links.append(Link(rule, action, chain))
+ return iter(self._cached_links)
diff --git a/src/mailman/chains/discard.py b/src/mailman/chains/discard.py
new file mode 100644
index 000000000..1899e0340
--- /dev/null
+++ b/src/mailman/chains/discard.py
@@ -0,0 +1,47 @@
+# Copyright (C) 2007-2009 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 .
+
+"""The terminal 'discard' chain."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'DiscardChain',
+ ]
+
+
+import logging
+
+from mailman.chains.base import TerminalChainBase
+from mailman.i18n import _
+
+
+log = logging.getLogger('mailman.vette')
+
+
+
+class DiscardChain(TerminalChainBase):
+ """Discard a message."""
+
+ name = 'discard'
+ description = _('Discard a message and stop processing.')
+
+ def _process(self, mlist, msg, msgdata):
+ """See `TerminalChainBase`."""
+ log.info('DISCARD: %s', msg.get('message-id', 'n/a'))
+ # Nothing more needs to happen.
diff --git a/src/mailman/chains/headers.py b/src/mailman/chains/headers.py
new file mode 100644
index 000000000..2f85d78d0
--- /dev/null
+++ b/src/mailman/chains/headers.py
@@ -0,0 +1,156 @@
+# Copyright (C) 2007-2009 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 .
+
+"""The header-matching chain."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'HeaderMatchChain',
+ ]
+
+
+import re
+import logging
+import itertools
+
+from zope.interface import implements
+
+from mailman.chains.base import Chain, Link
+from mailman.config import config
+from mailman.i18n import _
+from mailman.interfaces.chain import IChainIterator, LinkAction
+from mailman.interfaces.rules import IRule
+
+
+log = logging.getLogger('mailman.vette')
+
+
+
+def make_link(entry):
+ """Create a Link object.
+
+ :param entry: a 2- or 3-tuple describing a link. If a 2-tuple, it is a
+ header and a pattern, and a default chain of 'hold' will be used. If
+ a 3-tuple, the third item is the chain name to use.
+ :return: an ILink.
+ """
+ if len(entry) == 2:
+ header, pattern = entry
+ chain_name = 'hold'
+ elif len(entry) == 3:
+ header, pattern, chain_name = entry
+ # We don't assert that the chain exists here because the jump
+ # chain may not yet have been created.
+ else:
+ raise AssertionError('Bad link description: {0}'.format(entry))
+ rule = HeaderMatchRule(header, pattern)
+ chain = config.chains[chain_name]
+ return Link(rule, LinkAction.jump, chain)
+
+
+
+class HeaderMatchRule:
+ """Header matching rule used by header-match chain."""
+ implements(IRule)
+
+ # Sequential rule counter.
+ _count = 1
+
+ def __init__(self, header, pattern):
+ self._header = header
+ self._pattern = pattern
+ self.name = 'header-match-{0:02}'.format(HeaderMatchRule._count)
+ HeaderMatchRule._count += 1
+ self.description = '{0}: {1}'.format(header, pattern)
+ # XXX I think we should do better here, somehow recording that a
+ # particular header matched a particular pattern, but that gets ugly
+ # with RFC 2822 headers. It also doesn't match well with the rule
+ # name concept. For now, we just record the rather useless numeric
+ # rule name. I suppose we could do the better hit recording in the
+ # check() method, and set self.record = False.
+ self.record = True
+
+ def check(self, mlist, msg, msgdata):
+ """See `IRule`."""
+ for value in msg.get_all(self._header, []):
+ if re.search(self._pattern, value, re.IGNORECASE):
+ return True
+ return False
+
+
+
+class HeaderMatchChain(Chain):
+ """Default header matching chain.
+
+ This could be extended by header match rules in the database.
+ """
+
+ def __init__(self):
+ super(HeaderMatchChain, self).__init__(
+ 'header-match', _('The built-in header matching chain'))
+ # The header match rules are not global, so don't register them.
+ # These are the only rules that the header match chain can execute.
+ self._links = []
+ # Initialize header check rules with those from the global
+ # HEADER_MATCHES variable.
+ for entry in config.header_matches:
+ self._links.append(make_link(entry))
+ # Keep track of how many global header matching rules we've seen.
+ # This is so the flush() method will only delete those that were added
+ # via extend() or append_link().
+ self._permanent_link_count = len(self._links)
+
+ def extend(self, header, pattern, chain_name='hold'):
+ """Extend the existing header matches.
+
+ :param header: The case-insensitive header field name.
+ :param pattern: The pattern to match the header's value again. The
+ match is not anchored and is done case-insensitively.
+ :param chain: Option chain to jump to if the pattern matches any of
+ the named header values. If not given, the 'hold' chain is used.
+ """
+ self._links.append(make_link((header, pattern, chain_name)))
+
+ def flush(self):
+ """See `IMutableChain`."""
+ del self._links[self._permanent_link_count:]
+
+ def get_links(self, mlist, msg, msgdata):
+ """See `IChain`."""
+ list_iterator = HeaderMatchIterator(mlist)
+ return itertools.chain(iter(self._links), iter(list_iterator))
+
+ def __iter__(self):
+ for link in self._links:
+ yield link
+
+
+
+class HeaderMatchIterator:
+ """An iterator of both the global and list-specific chain links."""
+
+ implements(IChainIterator)
+
+ def __init__(self, mlist):
+ self._mlist = mlist
+
+ def __iter__(self):
+ """See `IChainIterator`."""
+ for entry in self._mlist.header_matches:
+ yield make_link(entry)
diff --git a/src/mailman/chains/hold.py b/src/mailman/chains/hold.py
new file mode 100644
index 000000000..16238a541
--- /dev/null
+++ b/src/mailman/chains/hold.py
@@ -0,0 +1,178 @@
+# Copyright (C) 2007-2009 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 .
+
+"""The terminal 'hold' chain."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'HoldChain',
+ ]
+
+
+import logging
+
+from email.mime.message import MIMEMessage
+from email.mime.text import MIMEText
+from email.utils import formatdate, make_msgid
+from zope.interface import implements
+
+from mailman import i18n
+from mailman.Message import UserNotification
+from mailman.Utils import maketext, oneline, wrap, GetCharSet
+from mailman.app.moderator import hold_message
+from mailman.app.replybot import autorespond_to_sender, can_acknowledge
+from mailman.chains.base import TerminalChainBase
+from mailman.config import config
+from mailman.interfaces.pending import IPendable
+
+
+log = logging.getLogger('mailman.vette')
+SEMISPACE = '; '
+_ = i18n._
+
+
+
+class HeldMessagePendable(dict):
+ implements(IPendable)
+ PEND_KEY = 'held message'
+
+
+
+class HoldChain(TerminalChainBase):
+ """Hold a message."""
+
+ name = 'hold'
+ description = _('Hold a message and stop processing.')
+
+ def _process(self, mlist, msg, msgdata):
+ """See `TerminalChainBase`."""
+ # Start by decorating the message with a header that contains a list
+ # of all the rules that matched. These metadata could be None or an
+ # empty list.
+ rule_hits = msgdata.get('rule_hits')
+ if rule_hits:
+ msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits)
+ rule_misses = msgdata.get('rule_misses')
+ if rule_misses:
+ msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses)
+ # Hold the message by adding it to the list's request database.
+ # XXX How to calculate the reason?
+ request_id = hold_message(mlist, msg, msgdata, None)
+ # Calculate a confirmation token to send to the author of the
+ # message.
+ pendable = HeldMessagePendable(type=HeldMessagePendable.PEND_KEY,
+ id=request_id)
+ token = config.db.pendings.add(pendable)
+ # Get the language to send the response in. If the sender is a
+ # member, then send it in the member's language, otherwise send it in
+ # the mailing list's preferred language.
+ sender = msg.get_sender()
+ member = mlist.members.get_member(sender)
+ language = (member.preferred_language
+ if member else mlist.preferred_language)
+ # A substitution dictionary for the email templates.
+ charset = GetCharSet(mlist.preferred_language)
+ original_subject = msg.get('subject')
+ if original_subject is None:
+ original_subject = _('(no subject)')
+ else:
+ original_subject = oneline(original_subject, charset)
+ substitutions = dict(
+ listname = mlist.fqdn_listname,
+ subject = original_subject,
+ sender = sender,
+ reason = 'XXX', #reason,
+ confirmurl = '{0}/{1}'.format(mlist.script_url('confirm'), token),
+ admindb_url = mlist.script_url('admindb'),
+ )
+ # At this point the message is held, but now we have to craft at least
+ # two responses. The first will go to the original author of the
+ # message and it will contain the token allowing them to approve or
+ # discard the message. The second one will go to the moderators of
+ # the mailing list, if the list is so configured.
+ #
+ # Start by possibly sending a response to the message author. There
+ # are several reasons why we might not go through with this. If the
+ # message was gated from NNTP, the author may not even know about this
+ # list, so don't spam them. If the author specifically requested that
+ # acknowledgments not be sent, or if the message was bulk email, then
+ # we do not send the response. It's also possible that either the
+ # mailing list, or the author (if they are a member) have been
+ # configured to not send such responses.
+ if (not msgdata.get('fromusenet') and
+ can_acknowledge(msg) and
+ mlist.respond_to_post_requests and
+ autorespond_to_sender(mlist, sender, language)):
+ # We can respond to the sender with a message indicating their
+ # posting was held.
+ subject = _(
+ 'Your message to $mlist.fqdn_listname awaits moderator approval')
+ send_language = msgdata.get('lang', language)
+ text = maketext('postheld.txt', substitutions,
+ lang=send_language, mlist=mlist)
+ adminaddr = mlist.bounces_address
+ nmsg = UserNotification(sender, adminaddr, subject, text,
+ send_language)
+ nmsg.send(mlist)
+ # Now the message for the list moderators. This one should appear to
+ # come from -owner since we really don't need to do bounce
+ # processing on it.
+ if mlist.admin_immed_notify:
+ # Now let's temporarily set the language context to that which the
+ # administrators are expecting.
+ with i18n.using_language(mlist.preferred_language):
+ language = mlist.preferred_language
+ charset = GetCharSet(language)
+ # We need to regenerate or re-translate a few values in the
+ # substitution dictionary.
+ #d['reason'] = _(reason) # XXX reason
+ substitutions['subject'] = original_subject
+ # craft the admin notification message and deliver it
+ subject = _(
+ '$mlist.fqdn_listname post from $sender requires approval')
+ nmsg = UserNotification(mlist.owner_address,
+ mlist.owner_address,
+ subject, lang=language)
+ nmsg.set_type('multipart/mixed')
+ text = MIMEText(
+ maketext('postauth.txt', substitutions,
+ raw=True, mlist=mlist),
+ _charset=charset)
+ dmsg = MIMEText(wrap(_("""\
+If you reply to this message, keeping the Subject: header intact, Mailman will
+discard the held message. Do this if the message is spam. If you reply to
+this message and include an Approved: header with the list password in it, the
+message will be approved for posting to the list. The Approved: header can
+also appear in the first line of the body of the reply.""")),
+ _charset=GetCharSet(language))
+ dmsg['Subject'] = 'confirm ' + token
+ dmsg['Sender'] = mlist.request_address
+ dmsg['From'] = mlist.request_address
+ dmsg['Date'] = formatdate(localtime=True)
+ dmsg['Message-ID'] = make_msgid()
+ nmsg.attach(text)
+ nmsg.attach(MIMEMessage(msg))
+ nmsg.attach(MIMEMessage(dmsg))
+ nmsg.send(mlist, **dict(tomoderators=True))
+ # Log the held message
+ # XXX reason
+ reason = 'n/a'
+ log.info('HOLD: %s post from %s held, message-id=%s: %s',
+ mlist.fqdn_listname, sender,
+ msg.get('message-id', 'n/a'), reason)
diff --git a/src/mailman/chains/reject.py b/src/mailman/chains/reject.py
new file mode 100644
index 000000000..3faf563da
--- /dev/null
+++ b/src/mailman/chains/reject.py
@@ -0,0 +1,59 @@
+# Copyright (C) 2007-2009 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 .
+
+"""The terminal 'reject' chain."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'RejectChain',
+ ]
+
+
+import logging
+
+from mailman.app.bounces import bounce_message
+from mailman.chains.base import TerminalChainBase
+from mailman.i18n import _
+
+
+log = logging.getLogger('mailman.vette')
+SEMISPACE = '; '
+
+
+
+class RejectChain(TerminalChainBase):
+ """Reject/bounce a message."""
+
+ name = 'reject'
+ description = _('Reject/bounce a message and stop processing.')
+
+ def _process(self, mlist, msg, msgdata):
+ """See `TerminalChainBase`."""
+ # Start by decorating the message with a header that contains a list
+ # of all the rules that matched. These metadata could be None or an
+ # empty list.
+ rule_hits = msgdata.get('rule_hits')
+ if rule_hits:
+ msg['X-Mailman-Rule-Hits'] = SEMISPACE.join(rule_hits)
+ rule_misses = msgdata.get('rule_misses')
+ if rule_misses:
+ msg['X-Mailman-Rule-Misses'] = SEMISPACE.join(rule_misses)
+ # XXX Exception/reason
+ bounce_message(mlist, msg)
+ log.info('REJECT: %s', msg.get('message-id', 'n/a'))
diff --git a/src/mailman/commands/__init__.py b/src/mailman/commands/__init__.py
new file mode 100644
index 000000000..6e89bc6da
--- /dev/null
+++ b/src/mailman/commands/__init__.py
@@ -0,0 +1,22 @@
+# Copyright (C) 2008-2009 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 .
+
+__all__ = [
+ 'echo',
+ 'end',
+ 'join',
+ ]
diff --git a/src/mailman/commands/cmd_confirm.py b/src/mailman/commands/cmd_confirm.py
new file mode 100644
index 000000000..b5e4182bd
--- /dev/null
+++ b/src/mailman/commands/cmd_confirm.py
@@ -0,0 +1,98 @@
+# Copyright (C) 2002-2009 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 .
+
+"""
+ confirm
+ Confirm an action. The confirmation-string is required and should be
+ supplied by a mailback confirmation notice.
+"""
+
+from mailman import Errors
+from mailman import Pending
+from mailman.config import config
+from mailman.i18n import _
+
+STOP = 1
+
+
+
+def gethelp(mlist):
+ return _(__doc__)
+
+
+
+def process(res, args):
+ mlist = res.mlist
+ if len(args) <> 1:
+ res.results.append(_('Usage:'))
+ res.results.append(gethelp(mlist))
+ return STOP
+ cookie = args[0]
+ try:
+ results = mlist.ProcessConfirmation(cookie, res.msg)
+ except Errors.MMBadConfirmation, e:
+ # Express in approximate days
+ days = int(config.PENDING_REQUEST_LIFE / config.days(1) + 0.5)
+ res.results.append(_("""\
+Invalid confirmation string. Note that confirmation strings expire
+approximately %(days)s days after the initial subscription request. If your
+confirmation has expired, please try to re-submit your original request or
+message."""))
+ except Errors.MMNeedApproval:
+ res.results.append(_("""\
+Your request has been forwarded to the list moderator for approval."""))
+ except Errors.MMAlreadyAMember:
+ # Some other subscription request for this address has
+ # already succeeded.
+ res.results.append(_('You are already subscribed.'))
+ except Errors.NotAMemberError:
+ # They've already been unsubscribed
+ res.results.append(_("""\
+You are not currently a member. Have you already unsubscribed or changed
+your email address?"""))
+ except Errors.MembershipIsBanned:
+ owneraddr = mlist.GetOwnerEmail()
+ res.results.append(_("""\
+You are currently banned from subscribing to this list. If you think this
+restriction is erroneous, please contact the list owners at
+%(owneraddr)s."""))
+ except Errors.HostileSubscriptionError:
+ res.results.append(_("""\
+You were not invited to this mailing list. The invitation has been discarded,
+and both list administrators have been alerted."""))
+ except Errors.MMBadPasswordError:
+ res.results.append(_("""\
+Bad approval password given. Held message is still being held."""))
+ else:
+ if ((results[0] == Pending.SUBSCRIPTION and mlist.send_welcome_msg)
+ or
+ (results[0] == Pending.UNSUBSCRIPTION and mlist.send_goodbye_msg)):
+ # We don't also need to send a confirmation succeeded message
+ res.respond = 0
+ else:
+ res.results.append(_('Confirmation succeeded'))
+ # Consume any other confirmation strings with the same cookie so
+ # the user doesn't get a misleading "unprocessed" message.
+ match = 'confirm ' + cookie
+ unprocessed = []
+ for line in res.commands:
+ if line.lstrip() == match:
+ continue
+ unprocessed.append(line)
+ res.commands = unprocessed
+ # Process just one confirmation string per message
+ return STOP
diff --git a/src/mailman/commands/cmd_help.py b/src/mailman/commands/cmd_help.py
new file mode 100644
index 000000000..eeee33ca7
--- /dev/null
+++ b/src/mailman/commands/cmd_help.py
@@ -0,0 +1,93 @@
+# Copyright (C) 2002-2009 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 .
+
+"""
+ help
+ Print this help message.
+"""
+
+import os
+import sys
+
+from mailman import Utils
+from mailman.config import config
+from mailman.i18n import _
+
+EMPTYSTRING = ''
+
+
+
+def gethelp(mlist):
+ return _(__doc__)
+
+
+
+def process(res, args):
+ # Get the help text introduction
+ mlist = res.mlist
+ # Since this message is personalized, add some useful information if the
+ # address requesting help is a member of the list.
+ msg = res.msg
+ for sender in msg.get_senders():
+ if mlist.isMember(sender):
+ memberurl = mlist.GetOptionsURL(sender, absolute=1)
+ urlhelp = _(
+ 'You can access your personal options via the following url:')
+ res.results.append(urlhelp)
+ res.results.append(memberurl)
+ # Get a blank line in the output.
+ res.results.append('')
+ break
+ # build the specific command helps from the module docstrings
+ modhelps = {}
+ import mailman.Commands
+ path = os.path.dirname(os.path.abspath(mailman.Commands.__file__))
+ for file in os.listdir(path):
+ if not file.startswith('cmd_') or not file.endswith('.py'):
+ continue
+ module = os.path.splitext(file)[0]
+ modname = 'mailman.Commands.' + module
+ try:
+ __import__(modname)
+ except ImportError:
+ continue
+ cmdname = module[4:]
+ help = None
+ if hasattr(sys.modules[modname], 'gethelp'):
+ help = sys.modules[modname].gethelp(mlist)
+ if help:
+ modhelps[cmdname] = help
+ # Now sort the command helps
+ helptext = []
+ keys = modhelps.keys()
+ keys.sort()
+ for cmd in keys:
+ helptext.append(modhelps[cmd])
+ commands = EMPTYSTRING.join(helptext)
+ # Now craft the response
+ helptext = Utils.maketext(
+ 'help.txt',
+ {'listname' : mlist.real_name,
+ 'version' : config.VERSION,
+ 'listinfo_url': mlist.GetScriptURL('listinfo', absolute=1),
+ 'requestaddr' : mlist.GetRequestEmail(),
+ 'adminaddr' : mlist.GetOwnerEmail(),
+ 'commands' : commands,
+ }, mlist=mlist, lang=res.msgdata['lang'], raw=1)
+ # Now add to the response
+ res.results.append('help')
+ res.results.append(helptext)
diff --git a/src/mailman/commands/cmd_info.py b/src/mailman/commands/cmd_info.py
new file mode 100644
index 000000000..3bdea178f
--- /dev/null
+++ b/src/mailman/commands/cmd_info.py
@@ -0,0 +1,50 @@
+# Copyright (C) 2002-2009 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 .
+
+"""
+ info
+ Get information about this mailing list.
+"""
+
+from mailman.i18n import _
+
+STOP = 1
+
+
+
+def gethelp(mlist):
+ return _(__doc__)
+
+
+
+def process(res, args):
+ mlist = res.mlist
+ if args:
+ res.results.append(gethelp(mlist))
+ return STOP
+ listname = mlist.real_name
+ description = mlist.description or _('n/a')
+ postaddr = mlist.posting_address
+ requestaddr = mlist.request_address
+ owneraddr = mlist.owner_address
+ listurl = mlist.script_url('listinfo')
+ res.results.append(_('List name: %(listname)s'))
+ res.results.append(_('Description: %(description)s'))
+ res.results.append(_('Postings to: %(postaddr)s'))
+ res.results.append(_('List Helpbot: %(requestaddr)s'))
+ res.results.append(_('List Owners: %(owneraddr)s'))
+ res.results.append(_('More information: %(listurl)s'))
diff --git a/src/mailman/commands/cmd_leave.py b/src/mailman/commands/cmd_leave.py
new file mode 100644
index 000000000..5844824f7
--- /dev/null
+++ b/src/mailman/commands/cmd_leave.py
@@ -0,0 +1,21 @@
+# Copyright (C) 2002-2009 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 .
+
+"""The `leave' command is synonymous with `unsubscribe'.
+"""
+
+from mailman.Commands.cmd_unsubscribe import process
diff --git a/src/mailman/commands/cmd_lists.py b/src/mailman/commands/cmd_lists.py
new file mode 100644
index 000000000..234ef46fc
--- /dev/null
+++ b/src/mailman/commands/cmd_lists.py
@@ -0,0 +1,65 @@
+# Copyright (C) 2002-2009 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 .
+
+"""
+ lists
+ See a list of the public mailing lists on this GNU Mailman server.
+"""
+
+from mailman.MailList import MailList
+from mailman.config import config
+from mailman.i18n import _
+
+
+STOP = 1
+
+
+
+def gethelp(mlist):
+ return _(__doc__)
+
+
+
+def process(res, args):
+ mlist = res.mlist
+ if args:
+ res.results.append(_('Usage:'))
+ res.results.append(gethelp(mlist))
+ return STOP
+ hostname = mlist.host_name
+ res.results.append(_('Public mailing lists at %(hostname)s:'))
+ i = 1
+ for listname in sorted(config.list_manager.names):
+ if listname == mlist.internal_name():
+ xlist = mlist
+ else:
+ xlist = MailList(listname, lock=0)
+ # We can mention this list if you already know about it
+ if not xlist.advertised and xlist is not mlist:
+ continue
+ # Skip the list if it isn't in the same virtual domain.
+ if xlist.host_name <> mlist.host_name:
+ continue
+ realname = xlist.real_name
+ description = xlist.description or _('n/a')
+ requestaddr = xlist.GetRequestEmail()
+ if i > 1:
+ res.results.append('')
+ res.results.append(_('%(i)3d. List name: %(realname)s'))
+ res.results.append(_(' Description: %(description)s'))
+ res.results.append(_(' Requests to: %(requestaddr)s'))
+ i += 1
diff --git a/src/mailman/commands/cmd_password.py b/src/mailman/commands/cmd_password.py
new file mode 100644
index 000000000..545da0cb5
--- /dev/null
+++ b/src/mailman/commands/cmd_password.py
@@ -0,0 +1,123 @@
+# Copyright (C) 2002-2009 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 .
+
+"""
+ password [] [address=]
+ Retrieve or change your password. With no arguments, this returns
+ your current password. With arguments and
+ you can change your password.
+
+ If you're posting from an address other than your membership address,
+ specify your membership address with `address=' (no brackets
+ around the email address, and no quotes!). Note that in this case the
+ response is always sent to the subscribed address.
+"""
+
+from email.Utils import parseaddr
+
+from mailman.config import config
+from mailman.i18n import _
+
+STOP = 1
+
+
+
+def gethelp(mlist):
+ return _(__doc__)
+
+
+
+def process(res, args):
+ mlist = res.mlist
+ address = None
+ if not args:
+ # They just want to get their existing password
+ realname, address = parseaddr(res.msg['from'])
+ if mlist.isMember(address):
+ password = mlist.getMemberPassword(address)
+ res.results.append(_('Your password is: %(password)s'))
+ # Prohibit multiple password retrievals.
+ return STOP
+ else:
+ listname = mlist.real_name
+ res.results.append(
+ _('You are not a member of the %(listname)s mailing list'))
+ return STOP
+ elif len(args) == 1 and args[0].startswith('address='):
+ # They want their password, but they're posting from a different
+ # address. We /must/ return the password to the subscribed address.
+ address = args[0][8:]
+ res.returnaddr = address
+ if mlist.isMember(address):
+ password = mlist.getMemberPassword(address)
+ res.results.append(_('Your password is: %(password)s'))
+ # Prohibit multiple password retrievals.
+ return STOP
+ else:
+ listname = mlist.real_name
+ res.results.append(
+ _('You are not a member of the %(listname)s mailing list'))
+ return STOP
+ elif len(args) == 2:
+ # They are changing their password
+ oldpasswd = args[0]
+ newpasswd = args[1]
+ realname, address = parseaddr(res.msg['from'])
+ if mlist.isMember(address):
+ if mlist.Authenticate((config.AuthUser, config.AuthListAdmin),
+ oldpasswd, address):
+ mlist.setMemberPassword(address, newpasswd)
+ res.results.append(_('Password successfully changed.'))
+ else:
+ res.results.append(_("""\
+You did not give the correct old password, so your password has not been
+changed. Use the no argument version of the password command to retrieve your
+current password, then try again."""))
+ res.results.append(_('\nUsage:'))
+ res.results.append(gethelp(mlist))
+ return STOP
+ else:
+ listname = mlist.real_name
+ res.results.append(
+ _('You are not a member of the %(listname)s mailing list'))
+ return STOP
+ elif len(args) == 3 and args[2].startswith('address='):
+ # They want to change their password, and they're sending this from a
+ # different address than what they're subscribed with. Be sure the
+ # response goes to the subscribed address.
+ oldpasswd = args[0]
+ newpasswd = args[1]
+ address = args[2][8:]
+ res.returnaddr = address
+ if mlist.isMember(address):
+ if mlist.Authenticate((config.AuthUser, config.AuthListAdmin),
+ oldpasswd, address):
+ mlist.setMemberPassword(address, newpasswd)
+ res.results.append(_('Password successfully changed.'))
+ else:
+ res.results.append(_("""\
+You did not give the correct old password, so your password has not been
+changed. Use the no argument version of the password command to retrieve your
+current password, then try again."""))
+ res.results.append(_('\nUsage:'))
+ res.results.append(gethelp(mlist))
+ return STOP
+ else:
+ listname = mlist.real_name
+ res.results.append(
+ _('You are not a member of the %(listname)s mailing list'))
+ return STOP
diff --git a/src/mailman/commands/cmd_remove.py b/src/mailman/commands/cmd_remove.py
new file mode 100644
index 000000000..8f3ce9669
--- /dev/null
+++ b/src/mailman/commands/cmd_remove.py
@@ -0,0 +1,21 @@
+# Copyright (C) 2002-2009 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 .
+
+"""The `remove' command is synonymous with `unsubscribe'.
+"""
+
+from mailman.Commands.cmd_unsubscribe import process
diff --git a/src/mailman/commands/cmd_set.py b/src/mailman/commands/cmd_set.py
new file mode 100644
index 000000000..020bc3636
--- /dev/null
+++ b/src/mailman/commands/cmd_set.py
@@ -0,0 +1,360 @@
+# Copyright (C) 2002-2009 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 .
+
+from email.Utils import parseaddr, formatdate
+
+from mailman import Errors
+from mailman import MemberAdaptor
+from mailman import i18n
+from mailman.config import config
+
+def _(s): return s
+
+OVERVIEW = _("""
+ set ...
+ Set or view your membership options.
+
+ Use `set help' (without the quotes) to get a more detailed list of the
+ options you can change.
+
+ Use `set show' (without the quotes) to view your current option
+ settings.
+""")
+
+DETAILS = _("""
+ set help
+ Show this detailed help.
+
+ set show [address=]
+ View your current option settings. If you're posting from an address
+ other than your membership address, specify your membership address
+ with `address=' (no brackets around the email address, and no
+ quotes!).
+
+ set authenticate [address=]
+ To set any of your options, you must include this command first, along
+ with your membership password. If you're posting from an address
+ other than your membership address, specify your membership address
+ with `address=' (no brackets around the email address, and no
+ quotes!).
+
+ set ack on
+ set ack off
+ When the `ack' option is turned on, you will receive an
+ acknowledgement message whenever you post a message to the list.
+
+ set digest plain
+ set digest mime
+ set digest off
+ When the `digest' option is turned off, you will receive postings
+ immediately when they are posted. Use `set digest plain' if instead
+ you want to receive postings bundled into a plain text digest
+ (i.e. RFC 1153 digest). Use `set digest mime' if instead you want to
+ receive postings bundled together into a MIME digest.
+
+ set delivery on
+ set delivery off
+ Turn delivery on or off. This does not unsubscribe you, but instead
+ tells Mailman not to deliver messages to you for now. This is useful
+ if you're going on vacation. Be sure to use `set delivery on' when
+ you return from vacation!
+
+ set myposts on
+ set myposts off
+ Use `set myposts off' to not receive copies of messages you post to
+ the list. This has no effect if you're receiving digests.
+
+ set hide on
+ set hide off
+ Use `set hide on' to conceal your email address when people request
+ the membership list.
+
+ set duplicates on
+ set duplicates off
+ Use `set duplicates off' if you want Mailman to not send you messages
+ if your address is explicitly mentioned in the To: or Cc: fields of
+ the message. This can reduce the number of duplicate postings you
+ will receive.
+
+ set reminders on
+ set reminders off
+ Use `set reminders off' if you want to disable the monthly password
+ reminder for this mailing list.
+""")
+
+_ = i18n._
+
+STOP = 1
+
+
+
+def gethelp(mlist):
+ return _(OVERVIEW)
+
+
+
+class SetCommands:
+ def __init__(self):
+ self.__address = None
+ self.__authok = 0
+
+ def process(self, res, args):
+ if not args:
+ res.results.append(_(DETAILS))
+ return STOP
+ subcmd = args.pop(0)
+ methname = 'set_' + subcmd
+ method = getattr(self, methname, None)
+ if method is None:
+ res.results.append(_('Bad set command: %(subcmd)s'))
+ res.results.append(_(DETAILS))
+ return STOP
+ return method(res, args)
+
+ def set_help(self, res, args=1):
+ res.results.append(_(DETAILS))
+ if args:
+ return STOP
+
+ def _usage(self, res):
+ res.results.append(_('Usage:'))
+ return self.set_help(res)
+
+ def set_show(self, res, args):
+ mlist = res.mlist
+ if not args:
+ realname, address = parseaddr(res.msg['from'])
+ elif len(args) == 1 and args[0].startswith('address='):
+ # Send the results to the address, not the From: dude
+ address = args[0][8:]
+ res.returnaddr = address
+ else:
+ return self._usage(res)
+ if not mlist.isMember(address):
+ listname = mlist.real_name
+ res.results.append(
+ _('You are not a member of the %(listname)s mailing list'))
+ return STOP
+ res.results.append(_('Your current option settings:'))
+ opt = mlist.getMemberOption(address, config.AcknowledgePosts)
+ onoff = opt and _('on') or _('off')
+ res.results.append(_(' ack %(onoff)s'))
+ # Digests are a special ternary value
+ digestsp = mlist.getMemberOption(address, config.Digests)
+ if digestsp:
+ plainp = mlist.getMemberOption(address, config.DisableMime)
+ if plainp:
+ res.results.append(_(' digest plain'))
+ else:
+ res.results.append(_(' digest mime'))
+ else:
+ res.results.append(_(' digest off'))
+ # If their membership is disabled, let them know why
+ status = mlist.getDeliveryStatus(address)
+ how = None
+ if status == MemberAdaptor.ENABLED:
+ status = _('delivery on')
+ elif status == MemberAdaptor.BYUSER:
+ status = _('delivery off')
+ how = _('by you')
+ elif status == MemberAdaptor.BYADMIN:
+ status = _('delivery off')
+ how = _('by the admin')
+ elif status == MemberAdaptor.BYBOUNCE:
+ status = _('delivery off')
+ how = _('due to bounces')
+ else:
+ assert status == MemberAdaptor.UNKNOWN
+ status = _('delivery off')
+ how = _('for unknown reasons')
+ changetime = mlist.getDeliveryStatusChangeTime(address)
+ if how and changetime > 0:
+ date = formatdate(changetime)
+ res.results.append(_(' %(status)s (%(how)s on %(date)s)'))
+ else:
+ res.results.append(' ' + status)
+ opt = mlist.getMemberOption(address, config.DontReceiveOwnPosts)
+ # sense is reversed
+ onoff = (not opt) and _('on') or _('off')
+ res.results.append(_(' myposts %(onoff)s'))
+ opt = mlist.getMemberOption(address, config.ConcealSubscription)
+ onoff = opt and _('on') or _('off')
+ res.results.append(_(' hide %(onoff)s'))
+ opt = mlist.getMemberOption(address, config.DontReceiveDuplicates)
+ # sense is reversed
+ onoff = (not opt) and _('on') or _('off')
+ res.results.append(_(' duplicates %(onoff)s'))
+ opt = mlist.getMemberOption(address, config.SuppressPasswordReminder)
+ # sense is reversed
+ onoff = (not opt) and _('on') or _('off')
+ res.results.append(_(' reminders %(onoff)s'))
+
+ def set_authenticate(self, res, args):
+ mlist = res.mlist
+ if len(args) == 1:
+ realname, address = parseaddr(res.msg['from'])
+ password = args[0]
+ elif len(args) == 2 and args[1].startswith('address='):
+ password = args[0]
+ address = args[1][8:]
+ else:
+ return self._usage(res)
+ # See if the password matches
+ if not mlist.isMember(address):
+ listname = mlist.real_name
+ res.results.append(
+ _('You are not a member of the %(listname)s mailing list'))
+ return STOP
+ if not mlist.Authenticate((config.AuthUser,
+ config.AuthListAdmin),
+ password, address):
+ res.results.append(_('You did not give the correct password'))
+ return STOP
+ self.__authok = 1
+ self.__address = address
+
+ def _status(self, res, arg):
+ status = arg.lower()
+ if status == 'on':
+ flag = 1
+ elif status == 'off':
+ flag = 0
+ else:
+ res.results.append(_('Bad argument: %(arg)s'))
+ self._usage(res)
+ return -1
+ # See if we're authenticated
+ if not self.__authok:
+ res.results.append(_('Not authenticated'))
+ self._usage(res)
+ return -1
+ return flag
+
+ def set_ack(self, res, args):
+ mlist = res.mlist
+ if len(args) <> 1:
+ return self._usage(res)
+ status = self._status(res, args[0])
+ if status < 0:
+ return STOP
+ mlist.setMemberOption(self.__address, config.AcknowledgePosts, status)
+ res.results.append(_('ack option set'))
+
+ def set_digest(self, res, args):
+ mlist = res.mlist
+ if len(args) <> 1:
+ return self._usage(res)
+ if not self.__authok:
+ res.results.append(_('Not authenticated'))
+ self._usage(res)
+ return STOP
+ arg = args[0].lower()
+ if arg == 'off':
+ try:
+ mlist.setMemberOption(self.__address, config.Digests, 0)
+ except Errors.AlreadyReceivingRegularDeliveries:
+ pass
+ elif arg == 'plain':
+ try:
+ mlist.setMemberOption(self.__address, config.Digests, 1)
+ except Errors.AlreadyReceivingDigests:
+ pass
+ mlist.setMemberOption(self.__address, config.DisableMime, 1)
+ elif arg == 'mime':
+ try:
+ mlist.setMemberOption(self.__address, config.Digests, 1)
+ except Errors.AlreadyReceivingDigests:
+ pass
+ mlist.setMemberOption(self.__address, config.DisableMime, 0)
+ else:
+ res.results.append(_('Bad argument: %(arg)s'))
+ self._usage(res)
+ return STOP
+ res.results.append(_('digest option set'))
+
+ def set_delivery(self, res, args):
+ mlist = res.mlist
+ if len(args) <> 1:
+ return self._usage(res)
+ status = self._status(res, args[0])
+ if status < 0:
+ return STOP
+ # Delivery status is handled differently than other options. If
+ # status is true (set delivery on), then we enable delivery.
+ # Otherwise, we have to use the setDeliveryStatus() interface to
+ # specify that delivery was disabled by the user.
+ if status:
+ mlist.setDeliveryStatus(self.__address, MemberAdaptor.ENABLED)
+ res.results.append(_('delivery enabled'))
+ else:
+ mlist.setDeliveryStatus(self.__address, MemberAdaptor.BYUSER)
+ res.results.append(_('delivery disabled by user'))
+
+ def set_myposts(self, res, args):
+ mlist = res.mlist
+ if len(args) <> 1:
+ return self._usage(res)
+ status = self._status(res, args[0])
+ if status < 0:
+ return STOP
+ # sense is reversed
+ mlist.setMemberOption(self.__address, config.DontReceiveOwnPosts,
+ not status)
+ res.results.append(_('myposts option set'))
+
+ def set_hide(self, res, args):
+ mlist = res.mlist
+ if len(args) <> 1:
+ return self._usage(res)
+ status = self._status(res, args[0])
+ if status < 0:
+ return STOP
+ mlist.setMemberOption(self.__address, config.ConcealSubscription,
+ status)
+ res.results.append(_('hide option set'))
+
+ def set_duplicates(self, res, args):
+ mlist = res.mlist
+ if len(args) <> 1:
+ return self._usage(res)
+ status = self._status(res, args[0])
+ if status < 0:
+ return STOP
+ # sense is reversed
+ mlist.setMemberOption(self.__address, config.DontReceiveDuplicates,
+ not status)
+ res.results.append(_('duplicates option set'))
+
+ def set_reminders(self, res, args):
+ mlist = res.mlist
+ if len(args) <> 1:
+ return self._usage(res)
+ status = self._status(res, args[0])
+ if status < 0:
+ return STOP
+ # sense is reversed
+ mlist.setMemberOption(self.__address, config.SuppressPasswordReminder,
+ not status)
+ res.results.append(_('reminder option set'))
+
+
+
+def process(res, args):
+ # We need to keep some state between set commands
+ if not getattr(res, 'setstate', None):
+ res.setstate = SetCommands()
+ res.setstate.process(res, args)
diff --git a/src/mailman/commands/cmd_unsubscribe.py b/src/mailman/commands/cmd_unsubscribe.py
new file mode 100644
index 000000000..456b8089d
--- /dev/null
+++ b/src/mailman/commands/cmd_unsubscribe.py
@@ -0,0 +1,88 @@
+# Copyright (C) 2002-2009 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 .
+
+"""
+ unsubscribe [password] [address=]
+ Unsubscribe from the mailing list. If given, your password must match
+ your current password. If omitted, a confirmation email will be sent
+ to the unsubscribing address. If you wish to unsubscribe an address
+ other than the address you sent this request from, you may specify
+ `address=' (no brackets around the email address, and no
+ quotes!)
+"""
+
+from email.Utils import parseaddr
+
+from mailman import Errors
+from mailman.i18n import _
+
+STOP = 1
+
+
+
+def gethelp(mlist):
+ return _(__doc__)
+
+
+
+def process(res, args):
+ mlist = res.mlist
+ password = None
+ address = None
+ argnum = 0
+ for arg in args:
+ if arg.startswith('address='):
+ address = arg[8:]
+ elif argnum == 0:
+ password = arg
+ else:
+ res.results.append(_('Usage:'))
+ res.results.append(gethelp(mlist))
+ return STOP
+ argnum += 1
+ # Fill in empty defaults
+ if address is None:
+ realname, address = parseaddr(res.msg['from'])
+ if not mlist.isMember(address):
+ listname = mlist.real_name
+ res.results.append(
+ _('%(address)s is not a member of the %(listname)s mailing list'))
+ return STOP
+ # If we're doing admin-approved unsubs, don't worry about the password
+ if mlist.unsubscribe_policy:
+ try:
+ mlist.DeleteMember(address, 'mailcmd')
+ except Errors.MMNeedApproval:
+ res.results.append(_("""\
+Your unsubscription request has been forwarded to the list administrator for
+approval."""))
+ elif password is None:
+ # No password was given, so we need to do a mailback confirmation
+ # instead of unsubscribing them here.
+ cpaddr = mlist.getMemberCPAddress(address)
+ mlist.ConfirmUnsubscription(cpaddr)
+ # We don't also need to send a confirmation to this command
+ res.respond = 0
+ else:
+ # No admin approval is necessary, so we can just delete them if the
+ # passwords match.
+ oldpw = mlist.getMemberPassword(address)
+ if oldpw <> password:
+ res.results.append(_('You gave the wrong password'))
+ return STOP
+ mlist.ApprovedDeleteMember(address, 'mailcmd')
+ res.results.append(_('Unsubscription request succeeded.'))
diff --git a/src/mailman/commands/cmd_who.py b/src/mailman/commands/cmd_who.py
new file mode 100644
index 000000000..6c66610b3
--- /dev/null
+++ b/src/mailman/commands/cmd_who.py
@@ -0,0 +1,152 @@
+# Copyright (C) 2002-2009 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 .
+
+from email.Utils import parseaddr
+
+from mailman import i18n
+from mailman.config import config
+
+STOP = 1
+
+def _(s): return s
+
+PUBLICHELP = _("""
+ who
+ See the non-hidden members of this mailing list.
+ who password
+ See everyone who is on this mailing list. The password is the
+ list's admin or moderator password.
+""")
+
+MEMBERSONLYHELP = _("""
+ who password [address=]
+ See the non-hidden members of this mailing list. The roster is
+ limited to list members only, and you must supply your membership
+ password to retrieve it. If you're posting from an address other
+ than your membership address, specify your membership address with
+ `address=' (no brackets around the email address, and no
+ quotes!). If you provide the list's admin or moderator password,
+ hidden members will be included.
+""")
+
+ADMINONLYHELP = _("""
+ who password
+ See everyone who is on this mailing list. The roster is limited to
+ list administrators and moderators only; you must supply the list
+ admin or moderator password to retrieve the roster.
+""")
+
+_ = i18n._
+
+
+
+def gethelp(mlist):
+ if mlist.private_roster == 0:
+ return _(PUBLICHELP)
+ elif mlist.private_roster == 1:
+ return _(MEMBERSONLYHELP)
+ elif mlist.private_roster == 2:
+ return _(ADMINONLYHELP)
+
+
+def usage(res):
+ res.results.append(_('Usage:'))
+ res.results.append(gethelp(res.mlist))
+
+
+
+def process(res, args):
+ mlist = res.mlist
+ address = None
+ password = None
+ ok = False
+ full = False
+ if mlist.private_roster == 0:
+ # Public rosters
+ if args:
+ if len(args) == 1:
+ if mlist.Authenticate((config.AuthListModerator,
+ config.AuthListAdmin),
+ args[0]):
+ full = True
+ else:
+ usage(res)
+ return STOP
+ else:
+ usage(res)
+ return STOP
+ ok = True
+ elif mlist.private_roster == 1:
+ # List members only
+ if len(args) == 1:
+ password = args[0]
+ realname, address = parseaddr(res.msg['from'])
+ elif len(args) == 2 and args[1].startswith('address='):
+ password = args[0]
+ address = args[1][8:]
+ else:
+ usage(res)
+ return STOP
+ if mlist.isMember(address) and mlist.Authenticate(
+ (config.AuthUser,
+ config.AuthListModerator,
+ config.AuthListAdmin),
+ password, address):
+ # Then
+ ok = True
+ if mlist.Authenticate(
+ (config.AuthListModerator,
+ config.AuthListAdmin),
+ password):
+ # Then
+ ok = full = True
+ else:
+ # Admin only
+ if len(args) <> 1:
+ usage(res)
+ return STOP
+ if mlist.Authenticate((config.AuthListModerator,
+ config.AuthListAdmin),
+ args[0]):
+ ok = full = True
+ if not ok:
+ res.results.append(
+ _('You are not allowed to retrieve the list membership.'))
+ return STOP
+ # It's okay for this person to see the list membership
+ dmembers = mlist.getDigestMemberKeys()
+ rmembers = mlist.getRegularMemberKeys()
+ if not dmembers and not rmembers:
+ res.results.append(_('This list has no members.'))
+ return
+ # Convenience function
+ def addmembers(members):
+ for member in members:
+ if not full and mlist.getMemberOption(member,
+ config.ConcealSubscription):
+ continue
+ realname = mlist.getMemberName(member)
+ if realname:
+ res.results.append(' %s (%s)' % (member, realname))
+ else:
+ res.results.append(' %s' % member)
+ if rmembers:
+ res.results.append(_('Non-digest (regular) members:'))
+ addmembers(rmembers)
+ if dmembers:
+ res.results.append(_('Digest members:'))
+ addmembers(dmembers)
diff --git a/src/mailman/commands/docs/echo.txt b/src/mailman/commands/docs/echo.txt
new file mode 100644
index 000000000..181cc58c8
--- /dev/null
+++ b/src/mailman/commands/docs/echo.txt
@@ -0,0 +1,30 @@
+The 'echo' command
+==================
+
+The mail command 'echo' simply replies with the original command and arguments
+to the sender.
+
+ >>> command = config.commands['echo']
+ >>> command.name
+ 'echo'
+ >>> command.argument_description
+ '[args]'
+ >>> command.description
+ u'Echo an acknowledgement. Arguments are return unchanged.'
+
+The original message is ignored, but the results receive the echoed command.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'test@example.com')
+
+ >>> from mailman.queue.command import Results
+ >>> results = Results()
+
+ >>> from mailman.Message import Message
+ >>> print command.process(mlist, Message(), {}, ('foo', 'bar'), results)
+ ContinueProcessing.yes
+ >>> print unicode(results)
+ The results of your email command are provided below.
+
+ echo foo bar
+
diff --git a/src/mailman/commands/docs/end.txt b/src/mailman/commands/docs/end.txt
new file mode 100644
index 000000000..4f6af26cb
--- /dev/null
+++ b/src/mailman/commands/docs/end.txt
@@ -0,0 +1,37 @@
+The 'end' command
+=================
+
+The mail command processor recognized an 'end' command which tells it to stop
+processing email messages.
+
+ >>> command = config.commands['end']
+ >>> command.name
+ 'end'
+ >>> command.description
+ u'Stop processing commands.'
+
+The 'end' command takes no arguments.
+
+ >>> command.argument_description
+ ''
+
+The command itself is fairly simple; it just stops command processing, and the
+message isn't even looked at.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'test@example.com')
+ >>> from mailman.Message import Message
+ >>> print command.process(mlist, Message(), {}, (), None)
+ ContinueProcessing.no
+
+The 'stop' command is a synonym for 'end'.
+
+ >>> command = config.commands['stop']
+ >>> command.name
+ 'stop'
+ >>> command.description
+ u'Stop processing commands.'
+ >>> command.argument_description
+ ''
+ >>> print command.process(mlist, Message(), {}, (), None)
+ ContinueProcessing.no
diff --git a/src/mailman/commands/docs/join.txt b/src/mailman/commands/docs/join.txt
new file mode 100644
index 000000000..9b85e816c
--- /dev/null
+++ b/src/mailman/commands/docs/join.txt
@@ -0,0 +1,170 @@
+The 'join' command
+==================
+
+The mail command 'join' subscribes an email address to the mailing list.
+'subscribe' is an alias for 'join'.
+
+ >>> command = config.commands['join']
+ >>> print command.name
+ join
+ >>> print command.description
+ Join this mailing list. You will be asked to confirm your subscription
+ request and you may be issued a provisional password.
+
+ By using the 'digest' option, you can specify whether you want digest
+ delivery or not. If not specified, the mailing list's default will be
+ used. You can also subscribe an alternative address by using the
+ 'address' option. For example:
+
+ join address=myotheraddress@example.com
+
+ >>> print command.argument_description
+ [digest=] [address=]
+
+
+No address to join
+------------------
+
+ >>> from mailman.Message import Message
+ >>> from mailman.app.lifecycle import create_list
+ >>> from mailman.queue.command import Results
+ >>> mlist = create_list(u'alpha@example.com')
+
+When no address argument is given, the message's From address will be used.
+If that's missing though, then an error is returned.
+
+ >>> results = Results()
+ >>> print command.process(mlist, Message(), {}, (), results)
+ ContinueProcessing.no
+ >>> print unicode(results)
+ The results of your email command are provided below.
+
+ join: No valid address found to subscribe
+
+
+The 'subscribe' command is an alias.
+
+ >>> subscribe = config.commands['subscribe']
+ >>> print subscribe.name
+ subscribe
+ >>> results = Results()
+ >>> print subscribe.process(mlist, Message(), {}, (), results)
+ ContinueProcessing.no
+ >>> print unicode(results)
+ The results of your email command are provided below.
+
+ subscribe: No valid address found to subscribe
+
+
+
+Joining the sender
+------------------
+
+When the message has a From field, that address will be subscribed.
+
+ >>> msg = message_from_string("""\
+ ... From: Anne Person
+ ...
+ ... """)
+ >>> results = Results()
+ >>> print command.process(mlist, msg, {}, (), results)
+ ContinueProcessing.yes
+ >>> print unicode(results)
+ The results of your email command are provided below.
+
+ Confirmation email sent to Anne Person
+
+
+Anne is not yet a member because she must confirm her subscription request
+first.
+
+ >>> print config.db.user_manager.get_user(u'anne@example.com')
+ None
+
+Mailman has sent her the confirmation message.
+
+ >>> virginq = config.switchboards['virgin']
+ >>> qmsg, qdata = virginq.dequeue(virginq.files[0])
+ >>> print qmsg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: confirm ...
+ From: confirm-...@example.com
+ To: anne@example.com
+ ...
+
+ Email Address Registration Confirmation
+
+ Hello, this is the GNU Mailman server at example.com.
+
+ We have received a registration request for the email address
+
+ anne@example.com
+
+ Before you can start using GNU Mailman at this site, you must first
+ confirm that this is your email address. You can do this by replying to
+ this message, keeping the Subject header intact. Or you can visit this
+ web page
+
+ http://lists.example.com/confirm/...
+
+ If you do not wish to register this email address simply disregard this
+ message. If you think you are being maliciously subscribed to the list, or
+ have any other questions, you may contact
+
+ postmaster@example.com
+
+
+Once Anne confirms her registration, she will be made a member of the mailing
+list.
+
+ >>> token = str(qmsg['subject']).split()[1].strip()
+ >>> from mailman.interfaces.registrar import IRegistrar
+ >>> registrar = IRegistrar(config.domains['example.com'])
+ >>> registrar.confirm(token)
+ True
+
+ >>> user = config.db.user_manager.get_user(u'anne@example.com')
+ >>> print user.real_name
+ Anne Person
+ >>> list(user.addresses)
+ [ [verified] at ...>]
+
+Anne is also now a member of the mailing list.
+
+ >>> mlist.members.get_member(u'anne@example.com')
+
+ on alpha@example.com as MemberRole.member>
+
+
+Joining a second list
+---------------------
+
+ >>> mlist_2 = create_list(u'baker@example.com')
+ >>> msg = message_from_string("""\
+ ... From: Anne Person
+ ...
+ ... """)
+ >>> print command.process(mlist_2, msg, {}, (), Results())
+ ContinueProcessing.yes
+
+Anne of course, is still registered.
+
+ >>> print config.db.user_manager.get_user(u'anne@example.com')
+
+
+But she is not a member of the mailing list.
+
+ >>> print mlist_2.members.get_member(u'anne@example.com')
+ None
+
+One Anne confirms this subscription, she becomes a member of the mailing list.
+
+ >>> qmsg, qdata = virginq.dequeue(virginq.files[0])
+ >>> token = str(qmsg['subject']).split()[1].strip()
+ >>> registrar.confirm(token)
+ True
+
+ >>> print mlist_2.members.get_member(u'anne@example.com')
+
+ on baker@example.com as MemberRole.member>
diff --git a/src/mailman/commands/echo.py b/src/mailman/commands/echo.py
new file mode 100644
index 000000000..30590acf8
--- /dev/null
+++ b/src/mailman/commands/echo.py
@@ -0,0 +1,48 @@
+# Copyright (C) 2002-2009 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 .
+
+"""The email command 'echo'."""
+
+__metaclass__ = type
+__all__ = [
+ 'Echo',
+ ]
+
+
+from zope.interface import implements
+
+from mailman.i18n import _
+from mailman.interfaces.command import ContinueProcessing, IEmailCommand
+
+
+SPACE = ' '
+
+
+
+class Echo:
+ """The email 'echo' command."""
+ implements(IEmailCommand)
+
+ name = 'echo'
+ argument_description = '[args]'
+ description = _(
+ 'Echo an acknowledgement. Arguments are return unchanged.')
+
+ def process(self, mlist, msg, msgdata, arguments, results):
+ """See `IEmailCommand`."""
+ print >> results, 'echo', SPACE.join(arguments)
+ return ContinueProcessing.yes
diff --git a/src/mailman/commands/end.py b/src/mailman/commands/end.py
new file mode 100644
index 000000000..a9298bc92
--- /dev/null
+++ b/src/mailman/commands/end.py
@@ -0,0 +1,51 @@
+# Copyright (C) 2002-2009 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 .
+
+"""The email commands 'end' and 'stop'."""
+
+__metaclass__ = type
+__all__ = [
+ 'End',
+ 'Stop',
+ ]
+
+
+from zope.interface import implements
+
+from mailman.i18n import _
+from mailman.interfaces.command import ContinueProcessing, IEmailCommand
+
+
+
+class End:
+ """The email 'end' command."""
+ implements(IEmailCommand)
+
+ name = 'end'
+ argument_description = ''
+ description = _('Stop processing commands.')
+
+ def process(self, mlist, msg, msgdata, arguments, results):
+ """See `IEmailCommand`."""
+ # Ignore all arguments.
+ return ContinueProcessing.no
+
+
+class Stop(End):
+ """The email 'stop' command (an alias for 'end')."""
+
+ name = 'stop'
diff --git a/src/mailman/commands/join.py b/src/mailman/commands/join.py
new file mode 100644
index 000000000..c14f3142b
--- /dev/null
+++ b/src/mailman/commands/join.py
@@ -0,0 +1,126 @@
+# Copyright (C) 2002-2009 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 .
+
+"""The email commands 'join' and 'subscribe'."""
+
+__metaclass__ = type
+__all__ = [
+ 'Join',
+ 'Subscribe',
+ ]
+
+
+from email.utils import formataddr, parseaddr
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.i18n import _
+from mailman.interfaces.command import ContinueProcessing, IEmailCommand
+from mailman.interfaces.member import DeliveryMode
+from mailman.interfaces.registrar import IRegistrar
+
+
+
+class Join:
+ """The email 'join' command."""
+ implements(IEmailCommand)
+
+ name = 'join'
+ argument_description = '[digest=] [address=]'
+ description = _("""\
+Join this mailing list. You will be asked to confirm your subscription
+request and you may be issued a provisional password.
+
+By using the 'digest' option, you can specify whether you want digest delivery
+or not. If not specified, the mailing list's default will be used. You can
+also subscribe an alternative address by using the 'address' option. For
+example:
+
+ join address=myotheraddress@example.com
+""")
+
+ def process(self, mlist, msg, msgdata, arguments, results):
+ """See `IEmailCommand`."""
+ # Parse the arguments.
+ address, delivery_mode = self._parse_arguments(arguments)
+ if address is None:
+ real_name, address = parseaddr(msg['from'])
+ # Address could be None or the empty string.
+ if not address:
+ address = msg.get_sender()
+ if not address:
+ print >> results, _(
+ '$self.name: No valid address found to subscribe')
+ return ContinueProcessing.no
+ domain = config.domains[mlist.host_name]
+ registrar = IRegistrar(domain)
+ registrar.register(address, real_name, mlist)
+ person = formataddr((real_name, address))
+ print >> results, _('Confirmation email sent to $person')
+ return ContinueProcessing.yes
+
+ def _parse_arguments(self, arguments):
+ """Parse command arguments.
+
+ :param arguments: The sequences of arguments as given to the
+ `process()` method.
+ :return: address, delivery_mode
+ """
+ address = None
+ delivery_mode = None
+ for argument in arguments:
+ parts = argument.split('=', 1)
+ if parts[0].lower() == 'digest':
+ if digest is not None:
+ print >> results, self.name, \
+ _('duplicate argument: $argument')
+ return ContinueProcessing.no
+ if len(parts) == 0:
+ # We treat just plain 'digest' as 'digest=yes'. We don't
+ # yet support the other types of digest delivery.
+ delivery_mode = DeliveryMode.mime_digests
+ else:
+ if parts[1].lower() == 'yes':
+ delivery_mode = DeliveryMode.mime_digests
+ elif parts[1].lower() == 'no':
+ delivery_mode = DeliveryMode.regular
+ else:
+ print >> results, self.name, \
+ _('bad argument: $argument')
+ return ContinueProcessing.no
+ elif parts[0].lower() == 'address':
+ if address is not None:
+ print >> results, self.name, \
+ _('duplicate argument $argument')
+ return ContinueProcessing.no
+ if len(parts) == 0:
+ print >> results, self.name, \
+ _('missing argument value: $argument')
+ return ContinueProcessing.no
+ if len(parts) > 1:
+ print >> results, self.name, \
+ _('too many argument values: $argument')
+ return ContinueProcessing.no
+ address = parts[1]
+ return address, delivery_mode
+
+
+
+class Subscribe(Join):
+ """The email 'subscribe' command (an alias for 'join')."""
+
+ name = 'subscribe'
diff --git a/src/mailman/config/__init__.py b/src/mailman/config/__init__.py
new file mode 100644
index 000000000..11f4f0c80
--- /dev/null
+++ b/src/mailman/config/__init__.py
@@ -0,0 +1,30 @@
+# Copyright (C) 2008-2009 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 .
+
+"""Mailman configuration package."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'config',
+ ]
+
+
+from mailman.config.config import Configuration
+
+config = Configuration()
diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py
new file mode 100644
index 000000000..fa359a6f5
--- /dev/null
+++ b/src/mailman/config/config.py
@@ -0,0 +1,206 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Configuration file loading and management."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Configuration',
+ ]
+
+
+import os
+import sys
+import errno
+import logging
+
+from StringIO import StringIO
+from lazr.config import ConfigSchema, as_boolean
+from pkg_resources import resource_string
+
+from mailman import version
+from mailman.core import errors
+from mailman.domain import Domain
+from mailman.languages import LanguageManager
+from mailman.styles.manager import StyleManager
+from mailman.utilities.filesystem import makedirs
+
+
+SPACE = ' '
+
+
+
+class Configuration(object):
+ """The core global configuration object."""
+
+ def __init__(self):
+ self.domains = {} # email host -> IDomain
+ self.switchboards = {}
+ self.languages = LanguageManager()
+ self.style_manager = StyleManager()
+ self.QFILE_SCHEMA_VERSION = version.QFILE_SCHEMA_VERSION
+ self._config = None
+ self.filename = None
+ # Create various registries.
+ self.chains = {}
+ self.rules = {}
+ self.handlers = {}
+ self.pipelines = {}
+ self.commands = {}
+
+ def _clear(self):
+ """Clear the cached configuration variables."""
+ self.domains.clear()
+ self.switchboards.clear()
+ self.languages = LanguageManager()
+
+ def __getattr__(self, name):
+ """Delegate to the configuration object."""
+ return getattr(self._config, name)
+
+ def load(self, filename=None):
+ """Load the configuration from the schema and config files."""
+ schema_string = resource_string('mailman.config', 'schema.cfg')
+ schema = ConfigSchema('schema.cfg', StringIO(schema_string))
+ # If a configuration file was given, load it now too. First, load the
+ # absolute minimum default configuration, then if a configuration
+ # filename was given by the user, push it.
+ config_string = resource_string('mailman.config', 'mailman.cfg')
+ self._config = schema.loadFile(StringIO(config_string), 'mailman.cfg')
+ if filename is not None:
+ self.filename = filename
+ with open(filename) as user_config:
+ self._config.push(filename, user_config.read())
+ self._post_process()
+
+ def push(self, config_name, config_string):
+ """Push a new configuration onto the stack."""
+ self._clear()
+ self._config.push(config_name, config_string)
+ self._post_process()
+
+ def pop(self, config_name):
+ """Pop a configuration from the stack."""
+ self._clear()
+ self._config.pop(config_name)
+ self._post_process()
+
+ def _post_process(self):
+ """Perform post-processing after loading the configuration files."""
+ # Set up the domains.
+ domains = self._config.getByCategory('domain', [])
+ for section in domains:
+ domain = Domain(section.email_host, section.base_url,
+ section.description, section.contact_address)
+ if domain.email_host in self.domains:
+ raise errors.BadDomainSpecificationError(
+ 'Duplicate email host: %s' % domain.email_host)
+ # Make sure there's only one mapping for the url_host
+ if domain.url_host in self.domains.values():
+ raise errors.BadDomainSpecificationError(
+ 'Duplicate url host: %s' % domain.url_host)
+ # We'll do the reverse mappings on-demand. There shouldn't be too
+ # many virtual hosts that it will really matter that much.
+ self.domains[domain.email_host] = domain
+ # Set up directories.
+ self.BIN_DIR = os.path.abspath(os.path.dirname(sys.argv[0]))
+ self.VAR_DIR = var_dir = self._config.mailman.var_dir
+ # Now that we've loaded all the configuration files we're going to
+ # load, set up some useful directories.
+ join = os.path.join
+ self.LIST_DATA_DIR = join(var_dir, 'lists')
+ self.LOG_DIR = join(var_dir, 'logs')
+ self.LOCK_DIR = lockdir = join(var_dir, 'locks')
+ self.DATA_DIR = datadir = join(var_dir, 'data')
+ self.ETC_DIR = etcdir = join(var_dir, 'etc')
+ self.SPAM_DIR = join(var_dir, 'spam')
+ self.EXT_DIR = join(var_dir, 'ext')
+ self.QUEUE_DIR = join(var_dir, 'qfiles')
+ self.MESSAGES_DIR = join(var_dir, 'messages')
+ self.PUBLIC_ARCHIVE_FILE_DIR = join(var_dir, 'archives', 'public')
+ self.PRIVATE_ARCHIVE_FILE_DIR = join(var_dir, 'archives', 'private')
+ # Other useful files
+ self.PIDFILE = join(datadir, 'master-qrunner.pid')
+ self.SITE_PW_FILE = join(datadir, 'adm.pw')
+ self.LISTCREATOR_PW_FILE = join(datadir, 'creator.pw')
+ self.CONFIG_FILE = join(etcdir, 'mailman.cfg')
+ self.LOCK_FILE = join(lockdir, 'master-qrunner')
+ # Set up the switchboards.
+ from mailman.queue import Switchboard
+ Switchboard.initialize()
+ # Set up all the languages.
+ languages = self._config.getByCategory('language', [])
+ for language in languages:
+ code = language.name.split('.')[1]
+ self.languages.add_language(code, language.description,
+ language.charset, language.enabled)
+ # Always enable the server default language, which must be defined.
+ self.languages.enable_language(self._config.mailman.default_language)
+ self.ensure_directories_exist()
+ self.style_manager.populate()
+
+ @property
+ def logger_configs(self):
+ """Return all log config sections."""
+ return self._config.getByCategory('logging', [])
+
+ @property
+ def paths(self):
+ """Return a substitution dictionary of all path variables."""
+ return dict((k, self.__dict__[k])
+ for k in self.__dict__
+ if k.endswith('_DIR'))
+
+ def ensure_directories_exist(self):
+ """Create all path directories if they do not exist."""
+ for variable, directory in self.paths.items():
+ makedirs(directory)
+
+ @property
+ def qrunner_configs(self):
+ """Iterate over all the qrunner configuration sections."""
+ for section in self._config.getByCategory('qrunner', []):
+ yield section
+
+ @property
+ def archivers(self):
+ """Iterate over all the enabled archivers."""
+ for section in self._config.getByCategory('archiver', []):
+ if not as_boolean(section.enable):
+ continue
+ class_path = section['class']
+ module_name, class_name = class_path.rsplit('.', 1)
+ __import__(module_name)
+ yield getattr(sys.modules[module_name], class_name)()
+
+ @property
+ def style_configs(self):
+ """Iterate over all the style configuration sections."""
+ for section in self._config.getByCategory('style', []):
+ yield section
+
+ @property
+ def header_matches(self):
+ """Iterate over all spam matching headers.
+
+ Values are 3-tuples of (header, pattern, chain)
+ """
+ matches = self._config.getByCategory('spam.headers', [])
+ for match in matches:
+ yield (matches.header, matches.pattern, matches.chain)
diff --git a/src/mailman/config/mailman.cfg b/src/mailman/config/mailman.cfg
new file mode 100644
index 000000000..2bf528bea
--- /dev/null
+++ b/src/mailman/config/mailman.cfg
@@ -0,0 +1,69 @@
+# Copyright (C) 2008-2009 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 .
+
+# This is the absolute bare minimum base configuration file. User supplied
+# configurations are pushed onto this.
+
+[language.en]
+
+[qrunner.archive]
+class: mailman.queue.archive.ArchiveRunner
+
+[qrunner.bad]
+class: mailman.queue.fake.BadRunner
+# The shunt runner is just a placeholder for its switchboard.
+start: no
+
+[qrunner.bounces]
+class: mailman.queue.bounce.BounceRunner
+
+[qrunner.command]
+class: mailman.queue.command.CommandRunner
+
+[qrunner.in]
+class: mailman.queue.incoming.IncomingRunner
+
+[qrunner.lmtp]
+class: mailman.queue.lmtp.LMTPRunner
+
+[qrunner.maildir]
+class: mailman.queue.maildir.MaildirRunner
+# This is still experimental.
+start: no
+
+[qrunner.news]
+class: mailman.queue.news.NewsRunner
+
+[qrunner.out]
+class: mailman.queue.outgoing.OutgoingRunner
+
+[qrunner.pipeline]
+class: mailman.queue.pipeline.PipelineRunner
+
+[qrunner.retry]
+class: mailman.queue.retry.RetryRunner
+sleep_time: 15m
+
+[qrunner.shunt]
+class: mailman.queue.fake.ShuntRunner
+# The shunt runner is just a placeholder for its switchboard.
+start: no
+
+[qrunner.virgin]
+class: mailman.queue.virgin.VirginRunner
+
+[style.default]
diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg
new file mode 100644
index 000000000..df20a7370
--- /dev/null
+++ b/src/mailman/config/schema.cfg
@@ -0,0 +1,589 @@
+# Copyright (C) 2008-2009 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 .
+
+# This is the GNU Mailman configuration schema. It defines the default
+# configuration options for the core system and plugins. It uses ini-style
+# formats under the lazr.config regime to define all system configuration
+# options. See for details.
+
+[mailman]
+# This address is the "site owner" address. Certain messages which must be
+# delivered to a human, but which can't be delivered to a list owner (e.g. a
+# bounce from a list owner), will be sent to this address. It should point to
+# a human.
+site_owner: changeme@example.com
+
+# This address is used as the from address whenever a message comes from some
+# entity to which there is no natural reply recipient. Set this to a real
+# human or to /dev/null. It will be appended with the host name of the list
+# involved. This address must not bounce and it must not point to a Mailman
+# process.
+noreply_address: noreply
+
+# Where all the runtime data will be kept. This directory must exist.
+var_dir: /tmp/mailman
+
+# The default language for this server.
+default_language: en
+
+# When allowing only members to post to a mailing list, how is the sender of
+# the message determined? If this variable is set to Yes, then first the
+# message's envelope sender is used, with a fallback to the sender if there is
+# no envelope sender. Set this variable to No to always use the sender.
+#
+# The envelope sender is set by the SMTP delivery and is thus less easily
+# spoofed than the sender, which is typically just taken from the From: header
+# and thus easily spoofed by the end-user. However, sometimes the envelope
+# sender isn't set correctly and this will manifest itself by postings being
+# held for approval even if they appear to come from a list member. If you
+# are having this problem, set this variable to No, but understand that some
+# spoofed messages may get through.
+use_envelope_sender: no
+
+# Membership tests for posting purposes are usually performed by looking at a
+# set of headers, passing the test if any of their values match a member of
+# the list. Headers are checked in the order given in this variable. The
+# value From_ means to use the envelope sender. Field names are case
+# insensitive. This is a space separate list of headers.
+sender_headers: from from_ reply-to sender
+
+# Mail command processor will ignore mail command lines after designated max.
+email_commands_max_lines: 10
+
+# Default length of time a pending request is live before it is evicted from
+# the pending database.
+pending_request_life: 3d
+
+
+[passwords]
+# When Mailman generates them, this is the default length of member passwords.
+member_password_length: 8
+
+# Specify the type of passwords to use, when Mailman generates the passwords
+# itself, as would be the case for membership requests where the user did not
+# fill in a password, or during list creation, when auto-generation of admin
+# passwords was selected.
+#
+# Set this value to 'yes' for classic Mailman user-friendly(er) passwords.
+# These generate semi-pronounceable passwords which are easier to remember.
+# Set this value to 'no' to use more cryptographically secure, but harder to
+# remember, passwords -- if your operating system and Python version support
+# the necessary feature (specifically that /dev/urandom be available).
+user_friendly_passwords: yes
+
+
+[qrunner.master]
+# Define which process queue runners, and how many of them, to start.
+
+# The full import path to the class for this queue runner.
+class: mailman.queue.runner.Runner
+
+# The directory path that this queue runner scans.
+path: $VAR_DIR/qfiles/$name
+
+# The number of parallel queue runners. This must be a power of 2.
+instances: 1
+
+# Whether to start this queue runner or not.
+start: yes
+
+# The maximum number of restarts for this queue runner. When the runner exits
+# because of an error or other unexpected problem, it is automatically
+# restarted, until the maximum number of restarts has been reached.
+max_restarts: 10
+
+# The sleep interval for the queue runner. It wakes up once every interval to
+# process the files in its slice of the queue directory.
+sleep_time: 1s
+
+[database]
+# Use this to set the Storm database engine URL. You generally have one
+# primary database connection for all of Mailman. List data and most rosters
+# will store their data in this database, although external rosters may access
+# other databases in their own way. This string supports standard
+# 'configuration' substitutions.
+url: sqlite:///$DATA_DIR/mailman.db
+debug: no
+
+[logging.template]
+# This defines various log settings. The options available are:
+#
+# - level -- Overrides the default level; this may be any of the
+# standard Python logging levels, case insensitive.
+# - format -- Overrides the default format string
+# - datefmt -- Overrides the default date format string
+# - path -- Overrides the default logger path. This may be a relative
+# path name, in which case it is relative to Mailman's LOG_DIR,
+# or it may be an absolute path name. You cannot change the
+# handler class that will be used.
+# - propagate -- Boolean specifying whether to propagate log message from this
+# logger to the root "mailman" logger. You cannot override
+# settings for the root logger.
+#
+# In this section, you can define defaults for all loggers, which will be
+# prefixed by 'mailman.'. Use subsections to override settings for specific
+# loggers. The names of the available loggers are:
+#
+# - archiver -- All archiver output
+# - bounce -- All bounce processing logs go here
+# - config -- Configuration issues
+# - debug -- Only used for development
+# - error -- All exceptions go to this log
+# - fromusenet -- Information related to the Usenet to Mailman gateway
+# - http -- Internal wsgi-based web interface
+# - locks -- Lock state changes
+# - mischief -- Various types of hostile activity
+# - post -- Information about messages posted to mailing lists
+# - qrunner -- qrunner start/stops
+# - smtp -- Successful SMTP activity
+# - smtp-failure -- Unsuccessful SMTP activity
+# - subscribe -- Information about leaves/joins
+# - vette -- Information related to admindb activity
+format: %(asctime)s (%(process)d) %(message)s
+datefmt: %b %d %H:%M:%S %Y
+propagate: no
+level: info
+path: mailman
+
+[logging.root]
+
+[logging.archiver]
+
+[logging.bounce]
+path: bounce
+
+[logging.config]
+
+[logging.debug]
+path: debug
+level: debug
+
+[logging.error]
+
+[logging.fromusenet]
+
+[logging.http]
+
+[logging.locks]
+
+[logging.mischief]
+
+[logging.qrunner]
+
+[logging.smtp]
+path: smtp
+
+# The smtp logger defines additional options for handling the logging of each
+# attempted delivery. These format strings specify what information is logged
+# for every message, every successful delivery, every refused delivery and
+# every recipient failure. To disable a status message, set the value to 'no'
+# (without the quotes).
+#
+# These template strings accept the following set of substitution
+# placeholders, if available.
+#
+# msgid -- the Message-ID of the message in question
+# listname -- the fully-qualified list name
+# sender -- the sender if available
+# recip -- the recipient address if available, or the number of
+# recipients being delivered to
+# size -- the approximate size of the message in bytes
+# seconds -- the number of seconds the operation took
+# refused -- the number of refused recipients
+# smtpcode -- the SMTP success or failure code
+# smtpmsg -- the SMTP success or failure message
+
+every: $msgid smtp to $listname for $recip recips, completed in $time seconds
+success: $msgid post to $listname from $sender, $size bytes
+refused: $msgid post to $listname from $sender, $size bytes, $refused failures
+failure: $msgid delivery to $recip failed with code $smtpcode, $smtpmsg
+
+
+[logging.subscribe]
+
+[logging.vette]
+
+
+[domain.master]
+# Site-wide domain defaults. To configure an individual
+# domain, add a [domain.example_com] section with the overrides.
+
+# This is the host name for the email interface.
+email_host: example.com
+# This is the base url for the domain's web interface. It must include the
+# url scheme.
+base_url: http://example.com
+# The contact address for this domain. This is advertised as the human to
+# contact when users have problems with the lists in this domain.
+contact_address: postmaster@example.com
+# A short description of this domain.
+description: An example domain.
+
+
+[language.master]
+# Template for language definitions. The section name must be [language.xx]
+# where xx is the 2-character ISO code for the language.
+
+# The English name for the language.
+description: English (USA)
+# And the default character set for the language.
+charset: us-ascii
+# Whether the language is enabled or not.
+enabled: yes
+
+
+[spam.headers.template]
+# This section defines basic header matching actions. Each spam.header
+# section names a header to match (case-insensitively), a pattern to match
+# against the header's value, and the chain to jump to when the match
+# succeeds.
+#
+# The header value should not include the trailing colon.
+header: X-Spam
+# The pattern is always matched with re.IGNORECASE.
+pattern: xyz
+# The chain to jump to if the pattern matches. Maybe be any existing chain
+# such as 'discard', 'reject', 'hold', or 'accept'.
+chain: hold
+
+
+[mta]
+# The class defining the interface to the incoming mail transport agent.
+incoming: mailman.mta.postfix.LMTP
+
+# The class defining the interface to the outgoing mail transport agent.
+outgoing: mailman.mta.smtp_direct.process
+
+# How to connect to the outgoing MTA.
+smtp_host: localhost
+smtp_port: 25
+
+# Where the LMTP server listens for connections.
+lmtp_host: localhost
+lmtp_port: 8025
+
+# Ceiling on the number of recipients that can be specified in a single SMTP
+# transaction. Set to 0 to submit the entire recipient list in one
+# transaction.
+max_recipients: 500
+
+# Ceiling on the number of SMTP sessions to perform on a single socket
+# connection. Some MTAs have limits. Set this to 0 to do as many as we like
+# (i.e. your MTA has no limits). Set this to some number great than 0 and
+# Mailman will close the SMTP connection and re-open it after this number of
+# consecutive sessions.
+max_sessions_per_connection: 0
+
+# Maximum number of simultaneous subthreads that will be used for SMTP
+# delivery. After the recipients list is chunked according to max_recipients,
+# each chunk is handed off to the SMTP server by a separate such thread. If
+# your Python interpreter was not built for threads, this feature is disabled.
+# You can explicitly disable it in all cases by setting max_delivery_threads
+# to 0.
+max_delivery_threads: 0
+
+# How long should messages which have delivery failures continue to be
+# retried? After this period of time, a message that has failed recipients
+# will be dequeued and those recipients will never receive the message.
+delivery_retry_period: 5d
+
+# These variables control the format and frequency of VERP-like delivery for
+# better bounce detection. VERP is Variable Envelope Return Path, defined
+# here:
+#
+# http://cr.yp.to/proto/verp.txt
+#
+# This involves encoding the address of the recipient as we (Mailman) know it
+# into the envelope sender address (i.e. the SMTP `MAIL FROM:' address).
+# Thus, no matter what kind of forwarding the recipient has in place, should
+# it eventually bounce, we will receive an unambiguous notice of the bouncing
+# address.
+#
+# However, we're technically only "VERP-like" because we're doing the envelope
+# sender encoding in Mailman, not in the MTA. We do require cooperation from
+# the MTA, so you must be sure your MTA can be configured for extended address
+# semantics.
+#
+# The first variable describes how to encode VERP envelopes. It must contain
+# these three string interpolations:
+#
+# $bounces -- the list-bounces mailbox will be set here
+# $mailbox -- the recipient's mailbox will be set here
+# $host -- the recipient's host name will be set here
+#
+# This example uses the default below.
+#
+# FQDN list address is: mylist@dom.ain
+# Recipient is: aperson@a.nother.dom
+#
+# The envelope sender will be mylist-bounces+aperson=a.nother.dom@dom.ain
+#
+# Note that your MTA /must/ be configured to deliver such an addressed message
+# to mylist-bounces!
+verp_delimiter: +
+verp_format: ${bounces}+${mailbox}=${host}
+
+# For nicer confirmation emails, use a VERP-like format which encodes the
+# confirmation cookie in the reply address. This lets us put a more user
+# friendly Subject: on the message, but requires cooperation from the MTA.
+# Format is like verp_format, but with the following substitutions:
+#
+# $address -- the list-confirm address
+# $cookie -- the confirmation cookie
+verp_confirm_format: $address+$cookie
+
+# This is analogous to verp_regexp, but for splitting apart the
+# verp_confirm_format. MUAs have been observed that mung
+#
+# From: local_part@host
+#
+# into
+#
+# To: "local_part"
+#
+# when replying, so we skip everything up to '<' if any.
+verp_confirm_regexp: ^(.*<)?(?P[^+]+?)\+(?P[^@]+)@.*$
+
+# Set this to 'yes' to enable VERP-like (more user friendly) confirmations.
+verp_confirmations: no
+
+# Another good opportunity is when regular delivery is personalized. Here
+# again, we're already incurring the performance hit for addressing each
+# individual recipient. Set this to 'yes' to enable VERPs on all personalized
+# regular deliveries (personalized digests aren't supported yet).
+verp_personalized_deliveries: no
+
+# And finally, we can VERP normal, non-personalized deliveries. However,
+# because it can be a significant performance hit, we allow you to decide how
+# often to VERP regular deliveries. This is the interval, in number of
+# messages, to do a VERP recipient address. The same variable controls both
+# regular and digest deliveries. Set to 0 to disable occasional VERPs, set to
+# 1 to VERP every delivery, or to some number > 1 for only occasional VERPs.
+verp_delivery_interval: 0
+
+# VERP format and regexp for probe messages.
+verp_probe_format: %(bounces)s+%(token)s
+verp_probe_regexp: ^(?P[^+]+?)\+(?P[^@]+)@.*$
+# Set this 'yes' to activate VERP probe for disabling by bounce.
+verp_probes: no
+
+# This is the maximum number of automatic responses sent to an address because
+# of -request messages or posting hold messages. This limit prevents response
+# loops between Mailman and misconfigured remote email robots. Mailman
+# already inhibits automatic replies to any message labeled with a header
+# "Precendence: bulk|list|junk". This is a fallback safety valve so it should
+# be set fairly high. Set to 0 for no limit (probably useful only for
+# debugging).
+max_autoresponses_per_day: 10
+
+# Some list posts and mail to the -owner address may contain DomainKey or
+# DomainKeys Identified Mail (DKIM) signature headers .
+# Various list transformations to the message such as adding a list header or
+# footer or scrubbing attachments or even reply-to munging can break these
+# signatures. It is generally felt that these signatures have value, even if
+# broken and even if the outgoing message is resigned. However, some sites
+# may wish to remove these headers by setting this to 'yes'.
+remove_dkim_headers: no
+
+# This variable describe the program to use for regenerating the transport map
+# db file, from the associated plain text files. The file being updated will
+# be appended to this string (with a separating space), so it must be
+# appropriate for os.system().
+postfix_map_cmd: /usr/sbin/postmap
+
+
+[bounces]
+# How often should the bounce qrunner process queued detected bounces?
+register_bounces_every: 15m
+
+
+[archiver.master]
+# To add new archivers, define a new section based on this one, overriding the
+# following values.
+
+# The class implementing the IArchiver interface.
+class: mailman.archiving.prototype.Prototype
+
+# Set this to 'yes' to enable the archiver.
+enable: no
+
+# The base url for the archiver. This is used to to calculate links to
+# individual messages in the archive.
+base_url: http://archive.example.com/
+
+# If the archiver works by getting a copy of the message, this is the address
+# to send the copy to.
+recipient: archive@archive.example.com
+
+# If the archiver works by calling a command on the local machine, this is the
+# command to call.
+command: /bin/echo
+
+
+[archiver.mhonarc]
+# This is the stock MHonArc archiver.
+class: mailman.archiving.mhonarc.MHonArc
+
+base_url: http://$hostname/archives/$fqdn_listname
+
+
+[archiver.mail_archive]
+# This is the stock mail-archive.com archiver.
+class: mailman.archiving.mailarchive.MailArchive
+
+[archiver.pipermail]
+# This is the stock Pipermail archiver.
+class: mailman.archiving.pipermail.Pipermail
+
+# This sets the default `clobber date' policy for the archiver. When a
+# message is to be archived either by Pipermail or an external archiver,
+# Mailman can modify the Date: header to be the date the message was received
+# instead of the Date: in the original message. This is useful if you
+# typically receive messages with outrageous dates. Set this to 0 to retain
+# the date of the original message, or to 1 to always clobber the date. Set
+# it to 2 to perform `smart overrides' on the date; when the date is outside
+# allowable_sane_date_skew (either too early or too late), then the received
+# date is substituted instead.
+clobber_date_policy: 2
+allowable_sane_date_skew: 15d
+
+# Pipermail archives contain the raw email addresses of the posting authors.
+# Some view this as a goldmine for spam harvesters. Set this to 'yes' to
+# moderately obscure email addresses, but note that this breaks mailto: URLs
+# in the archives too.
+obscure_email_addresses: yes
+
+# When the archive is public, should Pipermail also make the raw Unix mbox
+# file publically available?
+public_mbox: no
+
+
+[archiver.prototype]
+# This is a prototypical sample archiver.
+class: mailman.archiving.prototype.Prototype
+
+
+[style.master]
+# The style's priority, with 0 being the lowest priority.
+priority: 0
+
+# The class implementing the IStyle interface, which applies the style.
+class: mailman.styles.default.DefaultStyle
+
+
+[scrubber]
+# A filter module that converts from multipart messages to "flat" messages
+# (i.e. containing a single payload). This is required for Pipermail, and you
+# may want to set it to 0 for external archivers. You can also replace it
+# with your own module as long as it contains a process() function that takes
+# a MailList object and a Message object. It should raise
+# Errors.DiscardMessage if it wants to throw the message away. Otherwise it
+# should modify the Message object as necessary.
+archive_scrubber: mailman.pipeline.scrubber
+
+# This variable defines what happens to text/html subparts. They can be
+# stripped completely, escaped, or filtered through an external program. The
+# legal values are:
+# 0 - Strip out text/html parts completely, leaving a notice of the removal in
+# the message. If the outer part is text/html, the entire message is
+# discarded.
+# 1 - Remove any embedded text/html parts, leaving them as HTML-escaped
+# attachments which can be separately viewed. Outer text/html parts are
+# simply HTML-escaped.
+# 2 - Leave it inline, but HTML-escape it
+# 3 - Remove text/html as attachments but don't HTML-escape them. Note: this
+# is very dangerous because it essentially means anybody can send an HTML
+# email to your site containing evil JavaScript or web bugs, or other
+# nasty things, and folks viewing your archives will be susceptible. You
+# should only consider this option if you do heavy moderation of your list
+# postings.
+#
+# Note: given the current archiving code, it is not possible to leave
+# text/html parts inline and un-escaped. I wouldn't think it'd be a good idea
+# to do anyway.
+#
+# The value can also be a string, in which case it is the name of a command to
+# filter the HTML page through. The resulting output is left in an attachment
+# or as the entirety of the message when the outer part is text/html. The
+# format of the string must include a $filename substitution variable which
+# will contain the name of the temporary file that the program should operate
+# on. It should write the processed message to stdout. Set this to
+# HTML_TO_PLAIN_TEXT_COMMAND to specify an HTML to plain text conversion
+# program.
+archive_html_sanitizer: 1
+
+# Control parameter whether the scrubber should use the message attachment's
+# filename as is indicated by the filename parameter or use 'attachement-xxx'
+# instead. The default is set 'no' because the applications on PC and Mac
+# begin to use longer non-ascii filenames.
+use_attachment_filename: no
+
+# Use of attachment filename extension per se is may be dangerous because
+# viruses fakes it. You can set this 'yes' if you filter the attachment by
+# filename extension.
+use_attachment_filename_extension: no
+
+
+[digests]
+# Headers which should be kept in both RFC 1153 (plain) and MIME digests. RFC
+# 1153 also specifies these headers in this exact order, so order matters.
+# These are space separated and case insensitive.
+mime_digest_keep_headers:
+ Date From To Cc Subject Message-ID Keywords
+ In-Reply-To References Content-Type MIME-Version
+ Content-Transfer-Encoding Precedence Reply-To
+ Message
+
+plain_digest_keep_headers:
+ Message Date From
+ Subject To Cc
+ Message-ID Keywords
+ Content-Type
+
+
+[nntp]
+# Set these variables if you need to authenticate to your NNTP server for
+# Usenet posting or reading. If no authentication is necessary, specify None
+# for both variables.
+username:
+password:
+
+# Set this if you have an NNTP server you prefer gatewayed lists to use.
+host:
+
+# This controls how headers must be cleansed in order to be accepted by your
+# NNTP server. Some servers like INN reject messages containing prohibited
+# headers, or duplicate headers. The NNTP server may reject the message for
+# other reasons, but there's little that can be programmatically done about
+# that.
+#
+# These headers (case ignored) are removed from the original message. This is
+# a whitespace separate list of headers.
+remove_headers:
+ nntp-posting-host nntp-posting-date x-trace
+ x-complaints-to xref date-received posted
+ posting-version relay-version received
+
+# These headers are left alone, unless there are duplicates in the original
+# message. Any second and subsequent headers are rewritten to the second
+# named header (case preserved). This is a list of header pairs, one pair per
+# line.
+rewrite_duplicate_headers:
+ To X-Original-To
+ CC X-Original-CC
+ Content-Transfer-Encoding X-Original-Content-Transfer-Encoding
+ MIME-Version X-MIME-Version
diff --git a/src/mailman/constants.py b/src/mailman/constants.py
new file mode 100644
index 000000000..39c4547f8
--- /dev/null
+++ b/src/mailman/constants.py
@@ -0,0 +1,44 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Various constants and enumerations."""
+
+from __future__ import unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'SystemDefaultPreferences',
+ ]
+
+
+from zope.interface import implements
+
+from mailman.interfaces.member import DeliveryMode, DeliveryStatus
+from mailman.interfaces.preferences import IPreferences
+
+
+
+class SystemDefaultPreferences:
+ implements(IPreferences)
+
+ acknowledge_posts = False
+ hide_address = True
+ preferred_language = 'en'
+ receive_list_copy = True
+ receive_own_postings = True
+ delivery_mode = DeliveryMode.regular
+ delivery_status = DeliveryStatus.enabled
diff --git a/src/mailman/core/__init__.py b/src/mailman/core/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mailman/core/chains.py b/src/mailman/core/chains.py
new file mode 100644
index 000000000..40b8c779f
--- /dev/null
+++ b/src/mailman/core/chains.py
@@ -0,0 +1,118 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Application support for chain processing."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'initialize',
+ 'process',
+ ]
+
+
+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 mailman.config import config
+from mailman.interfaces.chain import LinkAction
+
+
+
+def process(mlist, msg, msgdata, start_chain='built-in'):
+ """Process the message through a chain.
+
+ :param mlist: the IMailingList for this message.
+ :param msg: The Message object.
+ :param msgdata: The message metadata dictionary.
+ :param start_chain: The name of the chain to start the processing with.
+ """
+ # Set up some bookkeeping.
+ chain_stack = []
+ msgdata['rule_hits'] = hits = []
+ msgdata['rule_misses'] = misses = []
+ # Find the starting chain and begin iterating through its links.
+ chain = config.chains[start_chain]
+ chain_iter = chain.get_links(mlist, msg, msgdata)
+ # Loop until we've reached the end of all processing chains.
+ while chain:
+ # Iterate over all links in the chain. Do this outside a for-loop so
+ # we can capture a chain's link iterator in mid-flight. This supports
+ # the 'detour' link action
+ try:
+ link = next(chain_iter)
+ except StopIteration:
+ # This chain is exhausted. Pop the last chain on the stack and
+ # continue iterating through it. If there's nothing left on the
+ # chain stack then we're completely finished processing.
+ if len(chain_stack) == 0:
+ return
+ chain, chain_iter = chain_stack.pop()
+ continue
+ # Process this link.
+ if link.rule.check(mlist, msg, msgdata):
+ if link.rule.record:
+ hits.append(link.rule.name)
+ # The rule matched so run its action.
+ if link.action is LinkAction.jump:
+ chain = link.chain
+ chain_iter = chain.get_links(mlist, msg, msgdata)
+ continue
+ elif link.action is LinkAction.detour:
+ # Push the current chain so that we can return to it when
+ # the next chain is finished.
+ chain_stack.append((chain, chain_iter))
+ chain = link.chain
+ chain_iter = chain.get_links(mlist, msg, msgdata)
+ continue
+ elif link.action is LinkAction.stop:
+ # Stop all processing.
+ return
+ elif link.action is LinkAction.defer:
+ # Just process the next link in the chain.
+ pass
+ elif link.action is LinkAction.run:
+ link.function(mlist, msg, msgdata)
+ else:
+ raise AssertionError(
+ 'Bad link action: {0}'.format(link.action))
+ else:
+ # The rule did not match; keep going.
+ if link.rule.record:
+ misses.append(link.rule.name)
+
+
+
+def initialize():
+ """Set up chains, both built-in and from the database."""
+ for chain_class in (DiscardChain, HoldChain, RejectChain, AcceptChain):
+ chain = chain_class()
+ assert chain.name not in config.chains, (
+ 'Duplicate chain name: {0}'.format(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/errors.py b/src/mailman/core/errors.py
new file mode 100644
index 000000000..39401127e
--- /dev/null
+++ b/src/mailman/core/errors.py
@@ -0,0 +1,172 @@
+# Copyright (C) 1998-2009 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 .
+
+"""Mailman errors."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'AlreadyReceivingDigests',
+ 'AlreadyReceivingRegularDeliveries',
+ 'BadDomainSpecificationError',
+ 'BadPasswordSchemeError',
+ 'CantDigestError',
+ 'DiscardMessage',
+ 'EmailAddressError',
+ 'HandlerError',
+ 'HoldMessage',
+ 'HostileSubscriptionError',
+ 'InvalidEmailAddress',
+ 'LostHeldMessage',
+ 'MailmanError',
+ 'MailmanException',
+ 'MemberError',
+ 'MembershipIsBanned',
+ 'MustDigestError',
+ 'NotAMemberError',
+ 'PasswordError',
+ 'RejectMessage',
+ 'SomeRecipientsFailed',
+ 'SubscriptionError',
+ ]
+
+
+
+# Base class for all exceptions raised in Mailman (XXX except legacy string
+# exceptions).
+class MailmanException(Exception):
+ pass
+
+
+
+# "New" style membership exceptions (new w/ MM2.1)
+class MemberError(MailmanException): pass
+class NotAMemberError(MemberError): pass
+class AlreadyReceivingDigests(MemberError): pass
+class AlreadyReceivingRegularDeliveries(MemberError): pass
+class CantDigestError(MemberError): pass
+class MustDigestError(MemberError): pass
+class MembershipIsBanned(MemberError): pass
+
+
+
+# New style class based exceptions. All the above errors should eventually be
+# converted.
+
+class MailmanError(MailmanException):
+ """Base class for all Mailman errors."""
+ pass
+
+class BadDomainSpecificationError(MailmanError):
+ """The specification of a virtual domain is invalid or duplicated."""
+
+
+
+# Exception hierarchy for bad email address errors that can be raised from
+# Utils.ValidateEmail()
+class EmailAddressError(MailmanError):
+ """Base class for email address validation errors."""
+
+
+class InvalidEmailAddress(EmailAddressError):
+ """Email address is invalid."""
+
+
+
+# Exceptions for admin request database
+class LostHeldMessage(MailmanError):
+ """Held message was lost."""
+ pass
+
+
+
+def _(s):
+ return s
+
+# Exceptions for the Handler subsystem
+class HandlerError(MailmanError):
+ """Base class for all handler errors."""
+
+class HoldMessage(HandlerError):
+ """Base class for all message-being-held short circuits."""
+
+ # funky spelling is necessary to break import loops
+ reason = _('For some unknown reason')
+
+ def reason_notice(self):
+ return self.reason
+
+ # funky spelling is necessary to break import loops
+ rejection = _('Your message was rejected')
+
+ def rejection_notice(self, mlist):
+ return self.rejection
+
+class DiscardMessage(HandlerError):
+ """The message can be discarded with no further action"""
+
+class SomeRecipientsFailed(HandlerError):
+ """Delivery to some or all recipients failed"""
+ def __init__(self, tempfailures, permfailures):
+ HandlerError.__init__(self)
+ self.tempfailures = tempfailures
+ self.permfailures = permfailures
+
+class RejectMessage(HandlerError):
+ """The message will be bounced back to the sender"""
+ def __init__(self, notice=None):
+ super(RejectMessage, self).__init__()
+ if notice is None:
+ notice = _('Your message was rejected')
+ if notice.endswith('\n\n'):
+ pass
+ elif notice.endswith('\n'):
+ notice += '\n'
+ else:
+ notice += '\n\n'
+ self.notice = notice
+
+
+
+# Subscription exceptions
+class SubscriptionError(MailmanError):
+ """Subscription errors base class."""
+
+
+class HostileSubscriptionError(SubscriptionError):
+ """A cross-subscription attempt was made.
+
+ This exception gets raised when an invitee attempts to use the
+ invitation to cross-subscribe to some other mailing list.
+ """
+
+
+
+class PasswordError(MailmanError):
+ """A password related error."""
+
+
+class BadPasswordSchemeError(PasswordError):
+ """A bad password scheme was given."""
+
+ def __init__(self, scheme_name='unknown'):
+ super(BadPasswordSchemeError, self).__init__()
+ self.scheme_name = scheme_name
+
+ def __str__(self):
+ return 'A bad password scheme was given: %s' % self.scheme_name
diff --git a/src/mailman/core/initialize.py b/src/mailman/core/initialize.py
new file mode 100644
index 000000000..bb16f0036
--- /dev/null
+++ b/src/mailman/core/initialize.py
@@ -0,0 +1,125 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Initialize all global state.
+
+Every entrance into the Mailman system, be it by command line, mail program,
+or cgi, must call the initialize function here in order for the system's
+global state to be set up properly. Typically this is called after command
+line argument parsing, since some of the initialization behavior is controlled
+by the command line arguments.
+"""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'initialize',
+ 'initialize_1',
+ 'initialize_2',
+ 'initialize_3',
+ ]
+
+
+import os
+
+from zope.interface.interface import adapter_hooks
+from zope.interface.verify import verifyObject
+
+import mailman.config.config
+import mailman.core.logging
+
+from mailman.core.plugins import get_plugin
+from mailman.interfaces.database import IDatabase
+
+
+
+# These initialization calls are separated for the testing framework, which
+# needs to do some internal calculations after config file loading and log
+# initialization, but before database initialization. Generally all other
+# code will just call initialize().
+
+def initialize_1(config_path=None, propagate_logs=None):
+ """First initialization step.
+
+ * The configuration system
+ * Run-time directories
+ * The logging subsystem
+
+ :param config_path: The path to the configuration file.
+ :type config_path: string
+ :param propagate_logs: Should the log output propagate to stderr?
+ :type propagate_logs: boolean or None
+ """
+ # By default, set the umask so that only owner and group can read and
+ # write our files. Specifically we must have g+rw and we probably want
+ # o-rwx although I think in most cases it doesn't hurt if other can read
+ # or write the files. Note that the Pipermail archive has more
+ # restrictive permissions in order to handle private archives, but it
+ # handles that correctly.
+ os.umask(007)
+ mailman.config.config.load(config_path)
+ # Create the queue and log directories if they don't already exist.
+ mailman.config.config.ensure_directories_exist()
+ mailman.core.logging.initialize(propagate_logs)
+
+
+def initialize_2(debug=False):
+ """Second initialization step.
+
+ * Rules
+ * Chains
+ * Pipelines
+ * Commands
+
+ :param debug: Should the database layer be put in debug mode?
+ :type debug: boolean
+ """
+ database_plugin = get_plugin('mailman.database')
+ # Instantiate the database plugin, ensure that it's of the right type, and
+ # initialize it. Then stash the object on our configuration object.
+ database = database_plugin()
+ verifyObject(IDatabase, database)
+ database.initialize(debug)
+ mailman.config.config.db = database
+ # Initialize the rules and chains. Do the imports here so as to avoid
+ # circular imports.
+ from mailman.app.commands import initialize as initialize_commands
+ from mailman.core.chains import initialize as initialize_chains
+ from mailman.core.pipelines import initialize as initialize_pipelines
+ from mailman.core.rules import initialize as initialize_rules
+ # Order here is somewhat important.
+ initialize_rules()
+ initialize_chains()
+ initialize_pipelines()
+ initialize_commands()
+
+
+def initialize_3():
+ """Third initialization step.
+
+ * Adapters
+ """
+ from mailman.app.registrar import adapt_domain_to_registrar
+ adapter_hooks.append(adapt_domain_to_registrar)
+
+
+
+def initialize(config_path=None, propagate_logs=None):
+ initialize_1(config_path, propagate_logs)
+ initialize_2()
+ initialize_3()
diff --git a/src/mailman/core/logging.py b/src/mailman/core/logging.py
new file mode 100644
index 000000000..a18065965
--- /dev/null
+++ b/src/mailman/core/logging.py
@@ -0,0 +1,163 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Logging initialization, using Python's standard logging package."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'initialize',
+ 'reopen',
+ ]
+
+
+import os
+import sys
+import codecs
+import logging
+
+from lazr.config import as_boolean, as_log_level
+
+from mailman.config import config
+
+
+_handlers = {}
+
+
+
+# XXX I would love to simplify things and use Python 2.6's WatchedFileHandler,
+# but there are two problems. First, it's more difficult to handle the test
+# suite's need to reopen the file handler to a different path. Does
+# zope.testing's logger support fix this?
+#
+# The other problem is that WatchedFileHandler doesn't really easily support
+# HUPing the process to reopen the log file. Now, maybe that's not a big deal
+# because the standard logging module would already handle things correctly if
+# the file is moved, but still that's not an interface I'm ready to give up on
+# yet. For now, keep our hack.
+
+class ReopenableFileHandler(logging.Handler):
+ """A file handler that supports reopening."""
+
+ def __init__(self, name, filename):
+ 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')
+
+ def flush(self):
+ if self._stream:
+ self._stream.flush()
+
+ def emit(self, record):
+ # It's possible for the stream to have been closed by the time we get
+ # here, due to the shut down semantics. This mostly happens in the
+ # test suite, but be defensive anyway.
+ stream = (self._stream if self._stream else sys.stderr)
+ try:
+ msg = self.format(record)
+ try:
+ stream.write('{0}'.format(msg))
+ except UnicodeError:
+ stream.write('{0}'.format(msg.encode('string-escape')))
+ self.flush()
+ except:
+ self.handleError(record)
+
+ def close(self):
+ self.flush()
+ self._stream.close()
+ self._stream = None
+ logging.Handler.close(self)
+
+ def reopen(self, filename=None):
+ """Reopen the output stream.
+
+ :param filename: If given, this reopens the output stream to a new
+ file. This is used in the test suite.
+ :type filename: string
+ """
+ if filename is not None:
+ self._filename = filename
+ self._stream.close()
+ self._stream = self._open()
+
+
+
+def initialize(propagate=None):
+ """Initialize all logs.
+
+ :param propagate: Flag specifying whether logs should propagate their
+ messages to the root logger. If omitted, propagation is determined
+ from the configuration files.
+ :type propagate: bool or None
+ """
+ # First, find the root logger and configure the logging subsystem.
+ # Initialize the root logger, then create a formatter for all the
+ # sublogs. The root logger should log to stderr.
+ logging.basicConfig(format=config.logging.root.format,
+ datefmt=config.logging.root.datefmt,
+ level=as_log_level(config.logging.root.level),
+ stream=sys.stderr)
+ # Create the subloggers.
+ for logger_config in config.logger_configs:
+ sub_name = logger_config.name.split('.')[-1]
+ if sub_name == 'root':
+ continue
+ logger_name = 'mailman.' + sub_name
+ log = logging.getLogger(logger_name)
+ # Get settings from log configuration file (or defaults).
+ log_format = logger_config.format
+ log_datefmt = logger_config.datefmt
+ # Propagation to the root logger is how we handle logging to stderr
+ # when the qrunners are not run as a subprocess of mailmanctl.
+ log.propagate = (as_boolean(logger_config.propagate)
+ if propagate is None else propagate)
+ # Set the logger's level.
+ log.setLevel(as_log_level(logger_config.level))
+ # Create a formatter for this logger, then a handler, and link the
+ # formatter to the handler.
+ formatter = logging.Formatter(fmt=log_format, datefmt=log_datefmt)
+ path_str = logger_config.path
+ path_abs = os.path.normpath(os.path.join(config.LOG_DIR, path_str))
+ handler = ReopenableFileHandler(sub_name, path_abs)
+ _handlers[sub_name] = handler
+ handler.setFormatter(formatter)
+ log.addHandler(handler)
+
+
+
+def reopen():
+ """Re-open all log files."""
+ for handler in _handlers.values():
+ handler.reopen()
+
+
+
+def get_handler(sub_name):
+ """Return the handler associated with a named logger.
+
+ :param sub_name: The logger name, sans the 'mailman.' prefix.
+ :type sub_name: string
+ :return: The file handler associated with the named logger.
+ :rtype: `ReopenableFileHandler`
+ """
+ return _handlers[sub_name]
diff --git a/src/mailman/core/pipelines.py b/src/mailman/core/pipelines.py
new file mode 100644
index 000000000..8aae5cc25
--- /dev/null
+++ b/src/mailman/core/pipelines.py
@@ -0,0 +1,125 @@
+# Copyright (C) 2008-2009 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 .
+
+"""Pipeline processor."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'initialize',
+ 'process',
+ ]
+
+
+from zope.interface import implements
+from zope.interface.verify import verifyObject
+
+from mailman.config import config
+from mailman.core.plugins import get_plugins
+from mailman.i18n import _
+from mailman.interfaces.handler import IHandler
+from mailman.interfaces.pipeline import IPipeline
+
+
+
+def process(mlist, msg, msgdata, pipeline_name='built-in'):
+ """Process the message through the given pipeline.
+
+ :param mlist: the IMailingList for this message.
+ :param msg: The Message object.
+ :param msgdata: The message metadata dictionary.
+ :param pipeline_name: The name of the pipeline to process through.
+ """
+ pipeline = config.pipelines[pipeline_name]
+ for handler in pipeline:
+ handler.process(mlist, msg, msgdata)
+
+
+
+class BasePipeline:
+ """Base pipeline implementation."""
+
+ implements(IPipeline)
+
+ _default_handlers = ()
+
+ def __init__(self):
+ self._handlers = []
+ for handler_name in self._default_handlers:
+ self._handlers.append(config.handlers[handler_name])
+
+ def __iter__(self):
+ """See `IPipeline`."""
+ for handler in self._handlers:
+ yield handler
+
+
+class BuiltInPipeline(BasePipeline):
+ """The built-in pipeline."""
+
+ name = 'built-in'
+ description = _('The built-in pipeline.')
+
+ _default_handlers = (
+ 'mime-delete',
+ 'scrubber',
+ 'tagger',
+ 'calculate-recipients',
+ 'avoid-duplicates',
+ 'cleanse',
+ 'cleanse-dkim',
+ 'cook-headers',
+ 'to-digest',
+ 'to-archive',
+ 'to-usenet',
+ 'after-delivery',
+ 'acknowledge',
+ 'to-outgoing',
+ )
+
+
+class VirginPipeline(BasePipeline):
+ """The processing pipeline for virgin messages.
+
+ Virgin messages are those that are crafted internally by Mailman.
+ """
+ name = 'virgin'
+ description = _('The virgin queue pipeline.')
+
+ _default_handlers = (
+ 'cook-headers',
+ 'to-outgoing',
+ )
+
+
+
+def initialize():
+ """Initialize the pipelines."""
+ # Find all handlers in the registered plugins.
+ for handler_finder in get_plugins('mailman.handlers'):
+ for handler_class in handler_finder():
+ handler = handler_class()
+ verifyObject(IHandler, handler)
+ assert handler.name not in config.handlers, (
+ 'Duplicate handler "{0}" found in {1}'.format(
+ handler.name, handler_finder))
+ config.handlers[handler.name] = handler
+ # Set up some pipelines.
+ for pipeline_class in (BuiltInPipeline, VirginPipeline):
+ pipeline = pipeline_class()
+ config.pipelines[pipeline.name] = pipeline
diff --git a/src/mailman/core/plugins.py b/src/mailman/core/plugins.py
new file mode 100644
index 000000000..e9ba26571
--- /dev/null
+++ b/src/mailman/core/plugins.py
@@ -0,0 +1,74 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Get a requested plugin."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ ]
+
+
+import pkg_resources
+
+
+
+def get_plugin(group):
+ """Get the named plugin.
+
+ In general, this returns exactly one plugin. If no plugins have been
+ added to the named group, the 'stock' plugin will be used. If more than
+ one plugin -- other than the stock one -- exists, an exception will be
+ raised.
+
+ :param group: The plugin group name.
+ :return: The loaded plugin.
+ :raises RuntimeError: If more than one plugin overrides the stock plugin
+ for the named group.
+ """
+ entry_points = list(pkg_resources.iter_entry_points(group))
+ if len(entry_points) == 0:
+ raise RuntimeError(
+ 'No entry points found for group: {0}'.format(group))
+ elif len(entry_points) == 1:
+ # Okay, this is the one to use.
+ return entry_points[0].load()
+ elif len(entry_points) == 2:
+ # Find the one /not/ named 'stock'.
+ entry_points = [ep for ep in entry_points if ep.name <> 'stock']
+ if len(entry_points) == 0:
+ raise RuntimeError(
+ 'No stock plugin found for group: {0}'.format(group))
+ elif len(entry_points) == 2:
+ raise RuntimeError('Too many stock plugins defined')
+ else:
+ raise AssertionError('Insanity')
+ return entry_points[0].load()
+ else:
+ raise RuntimeError('Too many plugins for group: {0}'.format(group))
+
+
+
+def get_plugins(group):
+ """Get and return all plugins in the named group.
+
+ :param group: Plugin group name.
+ :return: The loaded plugin.
+ """
+ for entry_point in pkg_resources.iter_entry_points(group):
+ yield entry_point.load()
diff --git a/src/mailman/core/rules.py b/src/mailman/core/rules.py
new file mode 100644
index 000000000..83e24dfa2
--- /dev/null
+++ b/src/mailman/core/rules.py
@@ -0,0 +1,46 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Various rule helpers"""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'initialize',
+ ]
+
+
+from zope.interface.verify import verifyObject
+
+from mailman.config import config
+from mailman.core.plugins import get_plugins
+from mailman.interfaces.rules import IRule
+
+
+
+def initialize():
+ """Find and register all rules in all plugins."""
+ # Find rules in plugins.
+ for rule_finder in get_plugins('mailman.rules'):
+ for rule_class in rule_finder():
+ rule = rule_class()
+ verifyObject(IRule, rule)
+ assert rule.name not in config.rules, (
+ 'Duplicate rule "{0}" found in {1}'.format(
+ rule.name, rule_finder))
+ config.rules[rule.name] = rule
diff --git a/src/mailman/database/__init__.py b/src/mailman/database/__init__.py
new file mode 100644
index 000000000..8b7f584c2
--- /dev/null
+++ b/src/mailman/database/__init__.py
@@ -0,0 +1,153 @@
+# Copyright (C) 2006-2009 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 .
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'StockDatabase',
+ ]
+
+import os
+import logging
+
+from locknix.lockfile import Lock
+from lazr.config import as_boolean
+from pkg_resources import resource_string
+from storm.locals import create_database, Store
+from urlparse import urlparse
+from zope.interface import implements
+
+import mailman.version
+
+from mailman.config import config
+from mailman.database.listmanager import ListManager
+from mailman.database.messagestore import MessageStore
+from mailman.database.pending import Pendings
+from mailman.database.requests import Requests
+from mailman.database.usermanager import UserManager
+from mailman.database.version import Version
+from mailman.interfaces.database import IDatabase, SchemaVersionMismatchError
+from mailman.utilities.string import expand
+
+log = logging.getLogger('mailman.config')
+
+
+
+class StockDatabase:
+ """The standard database, using Storm on top of SQLite."""
+
+ implements(IDatabase)
+
+ def __init__(self):
+ self.list_manager = None
+ self.user_manager = None
+ self.message_store = None
+ self.pendings = None
+ self.requests = None
+ self._store = None
+
+ def initialize(self, debug=None):
+ """See `IDatabase`."""
+ # Serialize this so we don't get multiple processes trying to create
+ # the database at the same time.
+ with Lock(os.path.join(config.LOCK_DIR, 'dbcreate.lck')):
+ self._create(debug)
+ self.list_manager = ListManager()
+ self.user_manager = UserManager()
+ self.message_store = MessageStore()
+ self.pendings = Pendings()
+ self.requests = Requests()
+
+ def begin(self):
+ """See `IDatabase`."""
+ # Storm takes care of this for us.
+ pass
+
+ def commit(self):
+ """See `IDatabase`."""
+ self.store.commit()
+
+ def abort(self):
+ """See `IDatabase`."""
+ self.store.rollback()
+
+ def _create(self, debug):
+ # Calculate the engine url.
+ url = expand(config.database.url, config.paths)
+ log.debug('Database url: %s', url)
+ # XXX By design of SQLite, database file creation does not honor
+ # umask. See their ticket #1193:
+ # http://www.sqlite.org/cvstrac/tktview?tn=1193,31
+ #
+ # This sucks for us because the mailman.db file /must/ be group
+ # writable, however even though we guarantee our umask is 002 here, it
+ # still gets created without the necessary g+w permission, due to
+ # SQLite's policy. This should only affect SQLite engines because its
+ # the only one that creates a little file on the local file system.
+ # This kludges around their bug by "touch"ing the database file before
+ # SQLite has any chance to create it, thus honoring the umask and
+ # ensuring the right permissions. We only try to do this for SQLite
+ # engines, and yes, we could have chmod'd the file after the fact, but
+ # half dozen and all...
+ touch(url)
+ database = create_database(url)
+ store = Store(database)
+ database.DEBUG = (as_boolean(config.database.debug)
+ if debug is None else debug)
+ # Check the sqlite master database to see if the version file exists.
+ # If so, then we assume the database schema is correctly initialized.
+ # Storm does not currently have schema creation. This is not an ideal
+ # way to handle creating the database, but it's cheap and easy for
+ # now.
+ table_names = [item[0] for item in
+ store.execute('select tbl_name from sqlite_master;')]
+ if 'version' not in table_names:
+ # Initialize the database.
+ sql = resource_string('mailman.database', 'mailman.sql')
+ for statement in sql.split(';'):
+ store.execute(statement + ';')
+ # Validate schema version.
+ v = store.find(Version, component=u'schema').one()
+ if not v:
+ # Database has not yet been initialized
+ v = Version(component='schema',
+ version=mailman.version.DATABASE_SCHEMA_VERSION)
+ store.add(v)
+ elif v.version <> mailman.version.DATABASE_SCHEMA_VERSION:
+ # XXX Update schema
+ raise SchemaVersionMismatchError(v.version)
+ self.store = store
+ store.commit()
+
+ def _reset(self):
+ """See `IDatabase`."""
+ from mailman.database.model import ModelMeta
+ self.store.rollback()
+ ModelMeta._reset(self.store)
+
+
+
+def touch(url):
+ parts = urlparse(url)
+ if parts.scheme <> 'sqlite':
+ return
+ path = os.path.normpath(parts.path)
+ fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK | os.O_CREAT, 0666)
+ # Ignore errors
+ if fd > 0:
+ os.close(fd)
diff --git a/src/mailman/database/address.py b/src/mailman/database/address.py
new file mode 100644
index 000000000..528d3af51
--- /dev/null
+++ b/src/mailman/database/address.py
@@ -0,0 +1,96 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Model for addresses."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Address',
+ ]
+
+
+from email.utils import formataddr
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.member import Member
+from mailman.database.model import Model
+from mailman.database.preferences import Preferences
+from mailman.interfaces.member import AlreadySubscribedError
+from mailman.interfaces.address import IAddress
+
+
+
+class Address(Model):
+ implements(IAddress)
+
+ id = Int(primary=True)
+ address = Unicode()
+ _original = Unicode()
+ real_name = Unicode()
+ verified_on = DateTime()
+ registered_on = DateTime()
+
+ user_id = Int()
+ user = Reference(user_id, 'User.id')
+ preferences_id = Int()
+ preferences = Reference(preferences_id, 'Preferences.id')
+
+ def __init__(self, address, real_name):
+ super(Address, self).__init__()
+ lower_case = address.lower()
+ self.address = lower_case
+ self.real_name = real_name
+ self._original = (None if lower_case == address else address)
+
+ def __str__(self):
+ addr = (self.address if self._original is None else self._original)
+ return formataddr((self.real_name, addr))
+
+ def __repr__(self):
+ verified = ('verified' if self.verified_on else 'not verified')
+ address_str = str(self)
+ if self._original is None:
+ return ''.format(
+ address_str, verified, id(self))
+ else:
+ return ''.format(
+ address_str, verified, self.address, id(self))
+
+ def subscribe(self, mailing_list, role):
+ # This member has no preferences by default.
+ member = config.db.store.find(
+ Member,
+ Member.role == role,
+ Member.mailing_list == mailing_list.fqdn_listname,
+ Member.address == self).one()
+ if member:
+ raise AlreadySubscribedError(
+ mailing_list.fqdn_listname, self.address, role)
+ member = Member(role=role,
+ mailing_list=mailing_list.fqdn_listname,
+ address=self)
+ member.preferences = Preferences()
+ config.db.store.add(member)
+ return member
+
+ @property
+ def original_address(self):
+ return (self.address if self._original is None else self._original)
diff --git a/src/mailman/database/language.py b/src/mailman/database/language.py
new file mode 100644
index 000000000..8adc5c4a5
--- /dev/null
+++ b/src/mailman/database/language.py
@@ -0,0 +1,40 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Model for languages."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Language',
+ ]
+
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.database import Model
+from mailman.interfaces import ILanguage
+
+
+
+class Language(Model):
+ implements(ILanguage)
+
+ id = Int(primary=True)
+ code = Unicode()
diff --git a/src/mailman/database/listmanager.py b/src/mailman/database/listmanager.py
new file mode 100644
index 000000000..790a2509a
--- /dev/null
+++ b/src/mailman/database/listmanager.py
@@ -0,0 +1,82 @@
+# Copyright (C) 2007-2009 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 .
+
+"""A mailing list manager."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'ListManager',
+ ]
+
+
+import datetime
+
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.mailinglist import MailingList
+from mailman.interfaces.listmanager import IListManager, ListAlreadyExistsError
+
+
+
+class ListManager(object):
+ """An implementation of the `IListManager` interface."""
+
+ implements(IListManager)
+
+ def create(self, fqdn_listname):
+ """See `IListManager`."""
+ listname, hostname = fqdn_listname.split('@', 1)
+ mlist = config.db.store.find(
+ MailingList,
+ MailingList.list_name == listname,
+ MailingList.host_name == hostname).one()
+ if mlist:
+ raise ListAlreadyExistsError(fqdn_listname)
+ mlist = MailingList(fqdn_listname)
+ mlist.created_at = datetime.datetime.now()
+ config.db.store.add(mlist)
+ return mlist
+
+ def get(self, fqdn_listname):
+ """See `IListManager`."""
+ listname, hostname = fqdn_listname.split('@', 1)
+ mlist = config.db.store.find(MailingList,
+ list_name=listname,
+ host_name=hostname).one()
+ if mlist is not None:
+ # XXX Fixme
+ mlist._restore()
+ return mlist
+
+ def delete(self, mlist):
+ """See `IListManager`."""
+ config.db.store.remove(mlist)
+
+ @property
+ def mailing_lists(self):
+ """See `IListManager`."""
+ for fqdn_listname in self.names:
+ yield self.get(fqdn_listname)
+
+ @property
+ def names(self):
+ """See `IListManager`."""
+ for mlist in config.db.store.find(MailingList):
+ yield '{0}@{1}'.format(mlist.list_name, mlist.host_name)
diff --git a/src/mailman/database/mailinglist.py b/src/mailman/database/mailinglist.py
new file mode 100644
index 000000000..8803a5fa4
--- /dev/null
+++ b/src/mailman/database/mailinglist.py
@@ -0,0 +1,272 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Model for mailing lists."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'MailingList',
+ ]
+
+
+import os
+import string
+
+from storm.locals import *
+from urlparse import urljoin
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database import roster
+from mailman.database.model import Model
+from mailman.database.types import Enum
+from mailman.interfaces.mailinglist import IMailingList, Personalization
+from mailman.utilities.filesystem import makedirs
+from mailman.utilities.string import expand
+
+
+SPACE = ' '
+UNDERSCORE = '_'
+
+
+
+class MailingList(Model):
+ implements(IMailingList)
+
+ id = Int(primary=True)
+
+ # List identity
+ list_name = Unicode()
+ host_name = Unicode()
+ # Attributes not directly modifiable via the web u/i
+ created_at = DateTime()
+ admin_member_chunksize = Int()
+ hold_and_cmd_autoresponses = Pickle()
+ # 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.
+ next_request_id = Int()
+ next_digest_number = Int()
+ admin_responses = Pickle()
+ postings_responses = Pickle()
+ request_responses = Pickle()
+ digest_last_sent_at = Float()
+ one_last_digest = Pickle()
+ volume = Int()
+ last_post_time = DateTime()
+ # 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()
+ acceptable_aliases = Pickle()
+ admin_immed_notify = Bool()
+ admin_notify_mchanges = Bool()
+ administrivia = Bool()
+ advertised = Bool()
+ anonymous_list = Bool()
+ archive = Bool()
+ archive_private = Bool()
+ archive_volume_frequency = Int()
+ autorespond_admin = Bool()
+ autorespond_postings = Bool()
+ autorespond_requests = Int()
+ autoresponse_admin_text = Unicode()
+ autoresponse_graceperiod = TimeDelta()
+ autoresponse_postings_text = Unicode()
+ autoresponse_request_text = Unicode()
+ 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()
+ collapse_alternatives = Bool()
+ convert_html_to_plaintext = Bool()
+ default_member_moderation = Bool()
+ description = Unicode()
+ digest_footer = Unicode()
+ digest_header = Unicode()
+ digest_is_default = Bool()
+ digest_send_periodic = Bool()
+ digest_size_threshold = Int()
+ digest_volume_frequency = Int()
+ digestable = Bool()
+ discard_these_nonmembers = Pickle()
+ emergency = Bool()
+ encode_ascii_prefixes = Bool()
+ filter_action = Int()
+ filter_content = Bool()
+ filter_filename_extensions = Pickle()
+ filter_mime_types = Pickle()
+ first_strip_reply_to = Bool()
+ forward_auto_discards = Bool()
+ gateway_to_mail = Bool()
+ gateway_to_news = Bool()
+ generic_nonmember_action = Int()
+ goodbye_msg = Unicode()
+ header_matches = Pickle()
+ hold_these_nonmembers = Pickle()
+ include_list_post_header = Bool()
+ include_rfc2369_headers = Bool()
+ info = Unicode()
+ linked_newsgroup = Unicode()
+ 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()
+ msg_footer = Unicode()
+ msg_header = Unicode()
+ new_member_options = Int()
+ news_moderation = Enum()
+ news_prefix_subject_too = Bool()
+ nntp_host = Unicode()
+ nondigestable = Bool()
+ nonmember_rejection_notice = Unicode()
+ obscure_addresses = Bool()
+ pass_filename_extensions = Pickle()
+ pass_mime_types = Pickle()
+ personalize = Enum()
+ pipeline = Unicode()
+ post_id = Int()
+ preferred_language = Unicode()
+ private_roster = Bool()
+ real_name = Unicode()
+ reject_these_nonmembers = Pickle()
+ reply_goes_to_list = Enum()
+ reply_to_address = Unicode()
+ require_explicit_destination = Bool()
+ respond_to_post_requests = Bool()
+ scrub_nondigest = Bool()
+ send_goodbye_msg = Bool()
+ send_reminders = Bool()
+ send_welcome_msg = Bool()
+ start_chain = Unicode()
+ subject_prefix = Unicode()
+ subscribe_auto_approval = Pickle()
+ subscribe_policy = Int()
+ topics = Pickle()
+ topics_bodylines_limit = Int()
+ topics_enabled = Bool()
+ unsubscribe_policy = Int()
+ welcome_msg = Unicode()
+
+ def __init__(self, fqdn_listname):
+ super(MailingList, self).__init__()
+ listname, hostname = fqdn_listname.split('@', 1)
+ self.list_name = listname
+ self.host_name = hostname
+ # For the pending database
+ self.next_request_id = 1
+ self._restore()
+ # Max autoresponses per day. A mapping between addresses and a
+ # 2-tuple of the date of the last autoresponse and the number of
+ # autoresponses sent on that date.
+ self.hold_and_cmd_autoresponses = {}
+ self.personalization = Personalization.none
+ self.real_name = string.capwords(
+ SPACE.join(listname.split(UNDERSCORE)))
+ makedirs(self.data_path)
+
+ # XXX FIXME
+ def _restore(self):
+ self.owners = roster.OwnerRoster(self)
+ self.moderators = roster.ModeratorRoster(self)
+ self.administrators = roster.AdministratorRoster(self)
+ self.members = roster.MemberRoster(self)
+ self.regular_members = roster.RegularMemberRoster(self)
+ self.digest_members = roster.DigestMemberRoster(self)
+ self.subscribers = roster.Subscribers(self)
+
+ @property
+ def fqdn_listname(self):
+ """See `IMailingList`."""
+ return '{0}@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def web_host(self):
+ """See `IMailingList`."""
+ return config.domains[self.host_name]
+
+ def script_url(self, target, context=None):
+ """See `IMailingList`."""
+ # Find the domain for this mailing list.
+ domain = config.domains[self.host_name]
+ # XXX Handle the case for when context is not None; those would be
+ # relative URLs.
+ return urljoin(domain.base_url, target + '/' + self.fqdn_listname)
+
+ @property
+ def data_path(self):
+ """See `IMailingList`."""
+ return os.path.join(config.LIST_DATA_DIR, self.fqdn_listname)
+
+ # IMailingListAddresses
+
+ @property
+ def posting_address(self):
+ return self.fqdn_listname
+
+ @property
+ def no_reply_address(self):
+ return '{0}@{1}'.format(config.mailman.noreply_address, self.host_name)
+
+ @property
+ def owner_address(self):
+ return '{0}-owner@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def request_address(self):
+ return '{0}-request@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def bounces_address(self):
+ return '{0}-bounces@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def join_address(self):
+ return '{0}-join@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def leave_address(self):
+ return '{0}-leave@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def subscribe_address(self):
+ return '{0}-subscribe@{1}'.format(self.list_name, self.host_name)
+
+ @property
+ def unsubscribe_address(self):
+ return '{0}-unsubscribe@{1}'.format(self.list_name, self.host_name)
+
+ def confirm_address(self, cookie):
+ local_part = expand(config.mta.verp_confirm_format, dict(
+ address = '{0}-confirm'.format(self.list_name),
+ cookie = cookie))
+ return '{0}@{1}'.format(local_part, self.host_name)
+
+ def __repr__(self):
+ return ''.format(
+ self.fqdn_listname, id(self))
diff --git a/src/mailman/database/mailman.sql b/src/mailman/database/mailman.sql
new file mode 100644
index 000000000..b098ed13b
--- /dev/null
+++ b/src/mailman/database/mailman.sql
@@ -0,0 +1,208 @@
+CREATE TABLE _request (
+ id INTEGER NOT NULL,
+ "key" TEXT,
+ request_type TEXT,
+ data_hash TEXT,
+ mailing_list_id INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT _request_mailing_list_id_fk FOREIGN KEY(mailing_list_id) REFERENCES mailinglist (id)
+);
+CREATE TABLE address (
+ id INTEGER NOT NULL,
+ address TEXT,
+ _original TEXT,
+ real_name TEXT,
+ verified_on TIMESTAMP,
+ registered_on TIMESTAMP,
+ user_id INTEGER,
+ preferences_id INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT address_user_id_fk FOREIGN KEY(user_id) REFERENCES user (id),
+ CONSTRAINT address_preferences_id_fk FOREIGN KEY(preferences_id) REFERENCES preferences (id)
+);
+CREATE TABLE language (
+ id INTEGER NOT NULL,
+ code TEXT,
+ PRIMARY KEY (id)
+);
+CREATE TABLE mailinglist (
+ id INTEGER NOT NULL,
+ list_name TEXT,
+ host_name TEXT,
+ created_at TIMESTAMP,
+ admin_member_chunksize INTEGER,
+ hold_and_cmd_autoresponses BLOB,
+ next_request_id INTEGER,
+ next_digest_number INTEGER,
+ admin_responses BLOB,
+ postings_responses BLOB,
+ request_responses BLOB,
+ digest_last_sent_at NUMERIC(10, 2),
+ one_last_digest BLOB,
+ volume INTEGER,
+ last_post_time TIMESTAMP,
+ accept_these_nonmembers BLOB,
+ acceptable_aliases BLOB,
+ admin_immed_notify BOOLEAN,
+ admin_notify_mchanges BOOLEAN,
+ administrivia BOOLEAN,
+ advertised BOOLEAN,
+ anonymous_list BOOLEAN,
+ archive BOOLEAN,
+ archive_private BOOLEAN,
+ archive_volume_frequency INTEGER,
+ autorespond_admin BOOLEAN,
+ autorespond_postings BOOLEAN,
+ autorespond_requests INTEGER,
+ autoresponse_admin_text TEXT,
+ autoresponse_graceperiod TEXT,
+ autoresponse_postings_text TEXT,
+ autoresponse_request_text TEXT,
+ ban_list BLOB,
+ bounce_info_stale_after TEXT,
+ bounce_matching_headers TEXT,
+ bounce_notify_owner_on_disable BOOLEAN,
+ bounce_notify_owner_on_removal BOOLEAN,
+ bounce_processing BOOLEAN,
+ bounce_score_threshold INTEGER,
+ bounce_unrecognized_goes_to_list_owner BOOLEAN,
+ bounce_you_are_disabled_warnings INTEGER,
+ bounce_you_are_disabled_warnings_interval TEXT,
+ collapse_alternatives BOOLEAN,
+ convert_html_to_plaintext BOOLEAN,
+ default_member_moderation BOOLEAN,
+ description TEXT,
+ digest_footer TEXT,
+ digest_header TEXT,
+ digest_is_default BOOLEAN,
+ digest_send_periodic BOOLEAN,
+ digest_size_threshold INTEGER,
+ digest_volume_frequency INTEGER,
+ digestable BOOLEAN,
+ discard_these_nonmembers BLOB,
+ emergency BOOLEAN,
+ encode_ascii_prefixes BOOLEAN,
+ filter_action INTEGER,
+ filter_content BOOLEAN,
+ filter_filename_extensions BLOB,
+ filter_mime_types BLOB,
+ first_strip_reply_to BOOLEAN,
+ forward_auto_discards BOOLEAN,
+ gateway_to_mail BOOLEAN,
+ gateway_to_news BOOLEAN,
+ generic_nonmember_action INTEGER,
+ goodbye_msg TEXT,
+ header_matches BLOB,
+ hold_these_nonmembers BLOB,
+ include_list_post_header BOOLEAN,
+ include_rfc2369_headers BOOLEAN,
+ info TEXT,
+ linked_newsgroup TEXT,
+ 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,
+ msg_footer TEXT,
+ msg_header TEXT,
+ new_member_options INTEGER,
+ news_moderation TEXT,
+ news_prefix_subject_too BOOLEAN,
+ nntp_host TEXT,
+ nondigestable BOOLEAN,
+ nonmember_rejection_notice TEXT,
+ obscure_addresses BOOLEAN,
+ pass_filename_extensions BLOB,
+ pass_mime_types BLOB,
+ personalize TEXT,
+ pipeline TEXT,
+ post_id INTEGER,
+ preferred_language TEXT,
+ private_roster BOOLEAN,
+ real_name TEXT,
+ reject_these_nonmembers BLOB,
+ reply_goes_to_list TEXT,
+ reply_to_address TEXT,
+ require_explicit_destination BOOLEAN,
+ respond_to_post_requests BOOLEAN,
+ scrub_nondigest BOOLEAN,
+ send_goodbye_msg BOOLEAN,
+ send_reminders BOOLEAN,
+ send_welcome_msg BOOLEAN,
+ start_chain TEXT,
+ subject_prefix TEXT,
+ subscribe_auto_approval BLOB,
+ subscribe_policy INTEGER,
+ topics BLOB,
+ topics_bodylines_limit INTEGER,
+ topics_enabled BOOLEAN,
+ unsubscribe_policy INTEGER,
+ welcome_msg TEXT,
+ PRIMARY KEY (id)
+);
+CREATE TABLE member (
+ id INTEGER NOT NULL,
+ role TEXT,
+ mailing_list TEXT,
+ is_moderated BOOLEAN,
+ address_id INTEGER,
+ preferences_id INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT member_address_id_fk FOREIGN KEY(address_id) REFERENCES address (id),
+ CONSTRAINT member_preferences_id_fk FOREIGN KEY(preferences_id) REFERENCES preferences (id)
+);
+CREATE TABLE message (
+ id INTEGER NOT NULL,
+ message_id_hash TEXT,
+ path TEXT,
+ message_id TEXT,
+ PRIMARY KEY (id)
+);
+CREATE TABLE pended (
+ id INTEGER NOT NULL,
+ token TEXT,
+ expiration_date TIMESTAMP,
+ PRIMARY KEY (id)
+);
+CREATE TABLE pendedkeyvalue (
+ id INTEGER NOT NULL,
+ "key" TEXT,
+ value TEXT,
+ pended_id INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT pendedkeyvalue_pended_id_fk FOREIGN KEY(pended_id) REFERENCES pended (id)
+);
+CREATE TABLE preferences (
+ id INTEGER NOT NULL,
+ acknowledge_posts BOOLEAN,
+ hide_address BOOLEAN,
+ preferred_language TEXT,
+ receive_list_copy BOOLEAN,
+ receive_own_postings BOOLEAN,
+ delivery_mode TEXT,
+ delivery_status TEXT,
+ PRIMARY KEY (id)
+);
+CREATE TABLE user (
+ id INTEGER NOT NULL,
+ real_name TEXT,
+ password TEXT,
+ preferences_id INTEGER,
+ PRIMARY KEY (id),
+ CONSTRAINT user_preferences_id_fk FOREIGN KEY(preferences_id) REFERENCES preferences (id)
+);
+CREATE TABLE version (
+ id INTEGER NOT NULL,
+ component TEXT,
+ version INTEGER,
+ PRIMARY KEY (id)
+);
+CREATE INDEX ix__request_mailing_list_id ON _request (mailing_list_id);
+CREATE INDEX ix_address_preferences_id ON address (preferences_id);
+CREATE INDEX ix_address_user_id ON address (user_id);
+CREATE INDEX ix_member_address_id ON member (address_id);
+CREATE INDEX ix_member_preferences_id ON member (preferences_id);
+CREATE INDEX ix_pendedkeyvalue_pended_id ON pendedkeyvalue (pended_id);
+CREATE INDEX ix_user_preferences_id ON user (preferences_id);
diff --git a/src/mailman/database/member.py b/src/mailman/database/member.py
new file mode 100644
index 000000000..22bf042f6
--- /dev/null
+++ b/src/mailman/database/member.py
@@ -0,0 +1,105 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Model for members."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Member',
+ ]
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.constants import SystemDefaultPreferences
+from mailman.database.model import Model
+from mailman.database.types import Enum
+from mailman.interfaces.member import IMember
+
+
+
+class Member(Model):
+ implements(IMember)
+
+ id = Int(primary=True)
+ role = Enum()
+ mailing_list = Unicode()
+ is_moderated = Bool()
+
+ address_id = Int()
+ address = Reference(address_id, 'Address.id')
+ preferences_id = Int()
+ preferences = Reference(preferences_id, 'Preferences.id')
+
+ def __init__(self, role, mailing_list, address):
+ self.role = role
+ self.mailing_list = mailing_list
+ self.address = address
+ self.is_moderated = False
+
+ def __repr__(self):
+ return ''.format(
+ self.address, self.mailing_list, self.role)
+
+ def _lookup(self, preference):
+ pref = getattr(self.preferences, preference)
+ if pref is not None:
+ return pref
+ pref = getattr(self.address.preferences, preference)
+ if pref is not None:
+ return pref
+ if self.address.user:
+ pref = getattr(self.address.user.preferences, preference)
+ if pref is not None:
+ return pref
+ return getattr(SystemDefaultPreferences, preference)
+
+ @property
+ def acknowledge_posts(self):
+ return self._lookup('acknowledge_posts')
+
+ @property
+ def preferred_language(self):
+ return self._lookup('preferred_language')
+
+ @property
+ def receive_list_copy(self):
+ return self._lookup('receive_list_copy')
+
+ @property
+ def receive_own_postings(self):
+ return self._lookup('receive_own_postings')
+
+ @property
+ def delivery_mode(self):
+ return self._lookup('delivery_mode')
+
+ @property
+ def delivery_status(self):
+ return self._lookup('delivery_status')
+
+ @property
+ def options_url(self):
+ # XXX Um, this is definitely wrong
+ return 'http://example.com/' + self.address.address
+
+ def unsubscribe(self):
+ config.db.store.remove(self.preferences)
+ config.db.store.remove(self)
diff --git a/src/mailman/database/message.py b/src/mailman/database/message.py
new file mode 100644
index 000000000..e77e11429
--- /dev/null
+++ b/src/mailman/database/message.py
@@ -0,0 +1,53 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Model for messages."""
+
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Message',
+ ]
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.model import Model
+from mailman.interfaces.messages import IMessage
+
+
+
+class Message(Model):
+ """A message in the message store."""
+
+ implements(IMessage)
+
+ id = Int(primary=True, default=AutoReload)
+ message_id = Unicode()
+ message_id_hash = RawStr()
+ path = RawStr()
+ # This is a Messge-ID field representation, not a database row id.
+
+ def __init__(self, message_id, message_id_hash, path):
+ super(Message, self).__init__()
+ self.message_id = message_id
+ self.message_id_hash = message_id_hash
+ self.path = path
+ config.db.store.add(self)
diff --git a/src/mailman/database/messagestore.py b/src/mailman/database/messagestore.py
new file mode 100644
index 000000000..a129f47ec
--- /dev/null
+++ b/src/mailman/database/messagestore.py
@@ -0,0 +1,137 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Model for message stores."""
+
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'MessageStore',
+ ]
+
+import os
+import errno
+import base64
+import hashlib
+import cPickle as pickle
+
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.message import Message
+from mailman.interfaces.messages import IMessageStore
+from mailman.utilities.filesystem import makedirs
+
+
+# It could be very bad if you have already stored files and you change this
+# value. We'd need a script to reshuffle and resplit.
+MAX_SPLITS = 2
+EMPTYSTRING = ''
+
+
+
+class MessageStore:
+ implements(IMessageStore)
+
+ def add(self, message):
+ # Ensure that the message has the requisite headers.
+ message_ids = message.get_all('message-id', [])
+ if len(message_ids) <> 1:
+ raise ValueError('Exactly one Message-ID header required')
+ # Calculate and insert the X-Message-ID-Hash.
+ message_id = message_ids[0]
+ # Complain if the Message-ID already exists in the storage.
+ existing = config.db.store.find(Message,
+ Message.message_id == message_id).one()
+ if existing is not None:
+ raise ValueError(
+ 'Message ID already exists in message store: {0}'.format(
+ message_id))
+ shaobj = hashlib.sha1(message_id)
+ hash32 = base64.b32encode(shaobj.digest())
+ del message['X-Message-ID-Hash']
+ message['X-Message-ID-Hash'] = hash32
+ # Calculate the path on disk where we're going to store this message
+ # object, in pickled format.
+ parts = []
+ split = list(hash32)
+ while split and len(parts) < MAX_SPLITS:
+ parts.append(split.pop(0) + split.pop(0))
+ parts.append(hash32)
+ relpath = os.path.join(*parts)
+ # Store the message in the database. This relies on the database
+ # providing a unique serial number, but to get this information, we
+ # have to use a straight insert instead of relying on Elixir to create
+ # the object.
+ row = Message(message_id=message_id,
+ message_id_hash=hash32,
+ path=relpath)
+ # Now calculate the full file system path.
+ path = os.path.join(config.MESSAGES_DIR, relpath)
+ # Write the file to the path, but catch the appropriate exception in
+ # case the parent directories don't yet exist. In that case, create
+ # them and try again.
+ while True:
+ try:
+ with open(path, 'w') as fp:
+ # -1 says to use the highest protocol available.
+ pickle.dump(message, fp, -1)
+ break
+ except IOError as error:
+ if error.errno <> errno.ENOENT:
+ raise
+ makedirs(os.path.dirname(path))
+ return hash32
+
+ def _get_message(self, row):
+ path = os.path.join(config.MESSAGES_DIR, row.path)
+ with open(path) as fp:
+ return pickle.load(fp)
+
+ def get_message_by_id(self, message_id):
+ row = config.db.store.find(Message, message_id=message_id).one()
+ if row is None:
+ return None
+ return self._get_message(row)
+
+ def get_message_by_hash(self, message_id_hash):
+ # It's possible the hash came from a message header, in which case it
+ # will be a Unicode. However when coming from source code, it may be
+ # an 8-string. Coerce to the latter if necessary; it must be
+ # US-ASCII.
+ if isinstance(message_id_hash, unicode):
+ message_id_hash = message_id_hash.encode('ascii')
+ row = config.db.store.find(Message,
+ message_id_hash=message_id_hash).one()
+ if row is None:
+ return None
+ return self._get_message(row)
+
+ @property
+ def messages(self):
+ for row in config.db.store.find(Message):
+ yield self._get_message(row)
+
+ def delete_message(self, message_id):
+ row = config.db.store.find(Message, message_id=message_id).one()
+ if row is None:
+ raise LookupError(message_id)
+ path = os.path.join(config.MESSAGES_DIR, row.path)
+ os.remove(path)
+ config.db.store.remove(row)
diff --git a/src/mailman/database/model.py b/src/mailman/database/model.py
new file mode 100644
index 000000000..85fa033c7
--- /dev/null
+++ b/src/mailman/database/model.py
@@ -0,0 +1,56 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Base class for all database classes."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Model',
+ ]
+
+from storm.properties import PropertyPublisherMeta
+
+
+
+class ModelMeta(PropertyPublisherMeta):
+ """Do more magic on table classes."""
+
+ _class_registry = set()
+
+ def __init__(self, name, bases, dict):
+ # Before we let the base class do it's thing, force an __storm_table__
+ # property to enforce our table naming convention.
+ self.__storm_table__ = name.lower()
+ super(ModelMeta, self).__init__(name, bases, dict)
+ # Register the model class so that it can be more easily cleared.
+ # This is required by the test framework.
+ if name == 'Model':
+ return
+ ModelMeta._class_registry.add(self)
+
+ @staticmethod
+ def _reset(store):
+ for model_class in ModelMeta._class_registry:
+ store.find(model_class).remove()
+
+
+
+class Model:
+ """Like Storm's `Storm` subclass, but with a bit extra."""
+ __metaclass__ = ModelMeta
diff --git a/src/mailman/database/pending.py b/src/mailman/database/pending.py
new file mode 100644
index 000000000..f4c2057e0
--- /dev/null
+++ b/src/mailman/database/pending.py
@@ -0,0 +1,177 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Implementations of the IPendable and IPending interfaces."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Pended',
+ 'Pendings',
+ ]
+
+import sys
+import time
+import random
+import hashlib
+import datetime
+
+from lazr.config import as_timedelta
+from storm.locals import *
+from zope.interface import implements
+from zope.interface.verify import verifyObject
+
+from mailman.config import config
+from mailman.database.model import Model
+from mailman.interfaces.pending import (
+ IPendable, IPended, IPendedKeyValue, IPendings)
+
+
+
+class PendedKeyValue(Model):
+ """A pended key/value pair, tied to a token."""
+
+ implements(IPendedKeyValue)
+
+ def __init__(self, key, value):
+ self.key = key
+ self.value = value
+
+ id = Int(primary=True)
+ key = Unicode()
+ value = Unicode()
+ pended_id = Int()
+
+
+class Pended(Model):
+ """A pended event, tied to a token."""
+
+ implements(IPended)
+
+ def __init__(self, token, expiration_date):
+ super(Pended, self).__init__()
+ self.token = token
+ self.expiration_date = expiration_date
+
+ id = Int(primary=True)
+ token = RawStr()
+ expiration_date = DateTime()
+ key_values = ReferenceSet(id, PendedKeyValue.pended_id)
+
+
+
+class UnpendedPendable(dict):
+ implements(IPendable)
+
+
+
+class Pendings:
+ """Implementation of the IPending interface."""
+
+ implements(IPendings)
+
+ def add(self, pendable, lifetime=None):
+ verifyObject(IPendable, pendable)
+ # Calculate the token and the lifetime.
+ if lifetime is None:
+ lifetime = as_timedelta(config.mailman.pending_request_life)
+ # Calculate a unique token. Algorithm vetted by the Timbot. time()
+ # has high resolution on Linux, clock() on Windows. random gives us
+ # about 45 bits in Python 2.2, 53 bits on Python 2.3. The time and
+ # clock values basically help obscure the random number generator, as
+ # does the hash calculation. The integral parts of the time values
+ # are discarded because they're the most predictable bits.
+ for attempts in range(3):
+ now = time.time()
+ x = random.random() + now % 1.0 + time.clock() % 1.0
+ # Use sha1 because it produces shorter strings.
+ token = hashlib.sha1(repr(x)).hexdigest()
+ # In practice, we'll never get a duplicate, but we'll be anal
+ # about checking anyway.
+ if config.db.store.find(Pended, token=token).count() == 0:
+ break
+ else:
+ raise AssertionError('Could not find a valid pendings token')
+ # Create the record, and then the individual key/value pairs.
+ pending = Pended(
+ token=token,
+ expiration_date=datetime.datetime.now() + lifetime)
+ for key, value in pendable.items():
+ if isinstance(key, str):
+ key = unicode(key, 'utf-8')
+ if isinstance(value, str):
+ value = unicode(value, 'utf-8')
+ elif type(value) is int:
+ value = '__builtin__.int\1%s' % value
+ elif type(value) is float:
+ value = '__builtin__.float\1%s' % value
+ elif type(value) is bool:
+ value = '__builtin__.bool\1%s' % value
+ elif type(value) is list:
+ # We expect this to be a list of strings.
+ value = ('mailman.database.pending.unpack_list\1' +
+ '\2'.join(value))
+ keyval = PendedKeyValue(key=key, value=value)
+ pending.key_values.add(keyval)
+ config.db.store.add(pending)
+ return token
+
+ def confirm(self, token, expunge=True):
+ store = config.db.store
+ pendings = store.find(Pended, token=token)
+ if pendings.count() == 0:
+ return None
+ assert pendings.count() == 1, (
+ 'Unexpected token count: {0}'.format(pendings.count()))
+ pending = pendings[0]
+ pendable = UnpendedPendable()
+ # Find all PendedKeyValue entries that are associated with the pending
+ # object's ID. Watch out for type conversions.
+ for keyvalue in store.find(PendedKeyValue,
+ PendedKeyValue.pended_id == pending.id):
+ if keyvalue.value is not None and '\1' in keyvalue.value:
+ typename, value = keyvalue.value.split('\1', 1)
+ package, classname = typename.rsplit('.', 1)
+ __import__(package)
+ module = sys.modules[package]
+ pendable[keyvalue.key] = getattr(module, classname)(value)
+ else:
+ pendable[keyvalue.key] = keyvalue.value
+ if expunge:
+ store.remove(keyvalue)
+ if expunge:
+ store.remove(pending)
+ return pendable
+
+ def evict(self):
+ store = config.db.store
+ now = datetime.datetime.now()
+ for pending in store.find(Pended):
+ if pending.expiration_date < now:
+ # Find all PendedKeyValue entries that are associated with the
+ # pending object's ID.
+ q = store.find(PendedKeyValue,
+ PendedKeyValue.pended_id == pending.id)
+ for keyvalue in q:
+ store.remove(keyvalue)
+ store.remove(pending)
+
+
+
+def unpack_list(value):
+ return value.split('\2')
diff --git a/src/mailman/database/preferences.py b/src/mailman/database/preferences.py
new file mode 100644
index 000000000..f3ee55673
--- /dev/null
+++ b/src/mailman/database/preferences.py
@@ -0,0 +1,50 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Model for preferences."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Preferences',
+ ]
+
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.database.model import Model
+from mailman.database.types import Enum
+from mailman.interfaces.preferences import IPreferences
+
+
+
+class Preferences(Model):
+ implements(IPreferences)
+
+ id = Int(primary=True)
+ acknowledge_posts = Bool()
+ hide_address = Bool()
+ preferred_language = Unicode()
+ receive_list_copy = Bool()
+ receive_own_postings = Bool()
+ delivery_mode = Enum()
+ delivery_status = Enum()
+
+ def __repr__(self):
+ return ''.format(id(self))
diff --git a/src/mailman/database/requests.py b/src/mailman/database/requests.py
new file mode 100644
index 000000000..249feb6b6
--- /dev/null
+++ b/src/mailman/database/requests.py
@@ -0,0 +1,138 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Implementations of the IRequests and IListRequests interfaces."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Requests',
+ ]
+
+
+from datetime import timedelta
+from storm.locals import *
+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.pending import IPendable
+from mailman.interfaces.requests import IListRequests, IRequests, RequestType
+
+
+
+class DataPendable(dict):
+ implements(IPendable)
+
+
+
+class ListRequests:
+ implements(IListRequests)
+
+ def __init__(self, mailing_list):
+ self.mailing_list = mailing_list
+
+ @property
+ def count(self):
+ return config.db.store.find(
+ _Request, mailing_list=self.mailing_list).count()
+
+ def count_of(self, request_type):
+ return config.db.store.find(
+ _Request,
+ mailing_list=self.mailing_list, request_type=request_type).count()
+
+ @property
+ def held_requests(self):
+ results = config.db.store.find(
+ _Request, mailing_list=self.mailing_list)
+ for request in results:
+ yield request
+
+ def of_type(self, request_type):
+ results = config.db.store.find(
+ _Request,
+ mailing_list=self.mailing_list, request_type=request_type)
+ for request in results:
+ yield request
+
+ def hold_request(self, request_type, key, data=None):
+ if request_type not in RequestType:
+ raise TypeError(request_type)
+ if data is None:
+ data_hash = None
+ else:
+ # We're abusing the pending database as a way of storing arbitrary
+ # key/value pairs, where both are strings. This isn't ideal but
+ # it lets us get auxiliary data almost for free. We may need to
+ # lock this down more later.
+ pendable = DataPendable()
+ pendable.update(data)
+ token = config.db.pendings.add(pendable, timedelta(days=5000))
+ data_hash = token
+ request = _Request(key, request_type, self.mailing_list, data_hash)
+ config.db.store.add(request)
+ return request.id
+
+ def get_request(self, request_id):
+ result = config.db.store.get(_Request, request_id)
+ if result is None:
+ return None
+ if result.data_hash is None:
+ return result.key, result.data_hash
+ pendable = config.db.pendings.confirm(result.data_hash, expunge=False)
+ data = dict()
+ data.update(pendable)
+ return result.key, data
+
+ def delete_request(self, request_id):
+ request = config.db.store.get(_Request, request_id)
+ if request is None:
+ raise KeyError(request_id)
+ # Throw away the pended data.
+ config.db.pendings.confirm(request.data_hash)
+ config.db.store.remove(request)
+
+
+
+class Requests:
+ implements(IRequests)
+
+ def get_list_requests(self, mailing_list):
+ return ListRequests(mailing_list)
+
+
+
+class _Request(Model):
+ """Table for mailing list hold requests."""
+
+ id = Int(primary=True, default=AutoReload)
+ key = Unicode()
+ request_type = Enum()
+ data_hash = RawStr()
+
+ mailing_list_id = Int()
+ mailing_list = Reference(mailing_list_id, 'MailingList.id')
+
+ def __init__(self, key, request_type, mailing_list, data_hash):
+ super(_Request, self).__init__()
+ self.key = key
+ self.request_type = request_type
+ self.mailing_list = mailing_list
+ self.data_hash = data_hash
diff --git a/src/mailman/database/roster.py b/src/mailman/database/roster.py
new file mode 100644
index 000000000..fc0a24c7d
--- /dev/null
+++ b/src/mailman/database/roster.py
@@ -0,0 +1,270 @@
+# Copyright (C) 2007-2009 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 .
+
+"""An implementation of an IRoster.
+
+These are hard-coded rosters which know how to filter a set of members to find
+the ones that fit a particular role. These are used as the member, owner,
+moderator, and administrator roster filters.
+"""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'AdministratorRoster',
+ 'DigestMemberRoster',
+ 'MemberRoster',
+ 'Memberships',
+ 'ModeratorRoster',
+ 'OwnerRoster',
+ 'RegularMemberRoster',
+ 'Subscribers',
+ ]
+
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.address import Address
+from mailman.database.member import Member
+from mailman.interfaces.member import DeliveryMode, MemberRole
+from mailman.interfaces.roster import IRoster
+
+
+
+class AbstractRoster:
+ """An abstract IRoster class.
+
+ This class takes the simple approach of implemented the 'users' and
+ 'addresses' properties in terms of the 'members' property. This may not
+ be the most efficient way, but it works.
+
+ This requires that subclasses implement the 'members' property.
+ """
+ implements(IRoster)
+
+ role = None
+
+ def __init__(self, mlist):
+ self._mlist = mlist
+
+ @property
+ def members(self):
+ for member in config.db.store.find(
+ Member,
+ mailing_list=self._mlist.fqdn_listname,
+ role=self.role):
+ yield member
+
+ @property
+ def users(self):
+ # Members are linked to addresses, which in turn are linked to users.
+ # So while the 'members' attribute does most of the work, we have to
+ # keep a set of unique users. It's possible for the same user to be
+ # subscribed to a mailing list multiple times with different
+ # addresses.
+ users = set(member.address.user for member in self.members)
+ for user in users:
+ yield user
+
+ @property
+ def addresses(self):
+ # Every Member is linked to exactly one address so the 'members'
+ # attribute does most of the work.
+ for member in self.members:
+ yield member.address
+
+ def get_member(self, address):
+ results = config.db.store.find(
+ Member,
+ Member.mailing_list == self._mlist.fqdn_listname,
+ Member.role == self.role,
+ Address.address == address,
+ Member.address_id == Address.id)
+ if results.count() == 0:
+ return None
+ elif results.count() == 1:
+ return results[0]
+ else:
+ raise AssertionError(
+ 'Too many matching member results: {0}'.format(
+ results.count()))
+
+
+
+class MemberRoster(AbstractRoster):
+ """Return all the members of a list."""
+
+ name = 'member'
+ role = MemberRole.member
+
+
+
+class OwnerRoster(AbstractRoster):
+ """Return all the owners of a list."""
+
+ name = 'owner'
+ role = MemberRole.owner
+
+
+
+class ModeratorRoster(AbstractRoster):
+ """Return all the owners of a list."""
+
+ name = 'moderator'
+ role = MemberRole.moderator
+
+
+
+class AdministratorRoster(AbstractRoster):
+ """Return all the administrators of a list."""
+
+ name = 'administrator'
+
+ @property
+ def members(self):
+ # Administrators are defined as the union of the owners and the
+ # moderators.
+ members = config.db.store.find(
+ Member,
+ Member.mailing_list == self._mlist.fqdn_listname,
+ Or(Member.role == MemberRole.owner,
+ Member.role == MemberRole.moderator))
+ for member in members:
+ yield member
+
+ def get_member(self, address):
+ results = config.db.store.find(
+ Member,
+ Member.mailing_list == self._mlist.fqdn_listname,
+ Or(Member.role == MemberRole.moderator,
+ Member.role == MemberRole.owner),
+ Address.address == address,
+ Member.address_id == Address.id)
+ if results.count() == 0:
+ return None
+ elif results.count() == 1:
+ return results[0]
+ else:
+ raise AssertionError(
+ 'Too many matching member results: {0}'.format(results))
+
+
+
+class RegularMemberRoster(AbstractRoster):
+ """Return all the regular delivery members of a list."""
+
+ name = 'regular_members'
+
+ @property
+ def members(self):
+ # Query for all the Members which have a role of MemberRole.member and
+ # are subscribed to this mailing list. Then return only those members
+ # that have a regular delivery mode.
+ for member in config.db.store.find(
+ Member,
+ mailing_list=self._mlist.fqdn_listname,
+ role=MemberRole.member):
+ if member.delivery_mode == DeliveryMode.regular:
+ yield member
+
+
+
+_digest_modes = (
+ DeliveryMode.mime_digests,
+ DeliveryMode.plaintext_digests,
+ DeliveryMode.summary_digests,
+ )
+
+
+
+class DigestMemberRoster(AbstractRoster):
+ """Return all the regular delivery members of a list."""
+
+ name = 'digest_members'
+
+ @property
+ def members(self):
+ # Query for all the Members which have a role of MemberRole.member and
+ # are subscribed to this mailing list. Then return only those members
+ # that have one of the digest delivery modes.
+ for member in config.db.store.find(
+ Member,
+ mailing_list=self._mlist.fqdn_listname,
+ role=MemberRole.member):
+ if member.delivery_mode in _digest_modes:
+ yield member
+
+
+
+class Subscribers(AbstractRoster):
+ """Return all subscribed members regardless of their role."""
+
+ name = 'subscribers'
+
+ @property
+ def members(self):
+ for member in config.db.store.find(
+ Member,
+ mailing_list=self._mlist.fqdn_listname):
+ yield member
+
+
+
+class Memberships:
+ """A roster of a single user's memberships."""
+
+ implements(IRoster)
+
+ name = 'memberships'
+
+ def __init__(self, user):
+ self._user = user
+
+ @property
+ def members(self):
+ results = config.db.store.find(
+ Member,
+ Address.user_id == self._user.id,
+ Member.address_id == Address.id)
+ for member in results:
+ yield member
+
+ @property
+ def users(self):
+ yield self._user
+
+ @property
+ def addresses(self):
+ for address in self._user.addresses:
+ yield address
+
+ def get_member(self, address):
+ results = config.db.store.find(
+ Member,
+ Member.address_id == Address.id,
+ Address.user_id == self._user.id)
+ if results.count() == 0:
+ return None
+ elif results.count() == 1:
+ return results[0]
+ else:
+ raise AssertionError(
+ 'Too many matching member results: {0}'.format(
+ results.count()))
diff --git a/src/mailman/database/transaction.py b/src/mailman/database/transaction.py
new file mode 100644
index 000000000..d42562389
--- /dev/null
+++ b/src/mailman/database/transaction.py
@@ -0,0 +1,53 @@
+# Copyright (C) 2006-2009 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 .
+
+"""Transactional support."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'txn',
+ ]
+
+
+from mailman.config import config
+
+
+
+class txn(object):
+ """Decorator for transactional support.
+
+ When the function this decorator wraps exits cleanly, the current
+ transaction is committed. When it exits uncleanly (i.e. because of an
+ exception, the transaction is aborted.
+
+ Either way, the current transaction is completed.
+ """
+ def __init__(self, function):
+ self._function = function
+
+ def __get__(self, obj, type=None):
+ def wrapper(*args, **kws):
+ try:
+ rtn = self._function(obj, *args, **kws)
+ config.db.commit()
+ return rtn
+ except:
+ config.db.abort()
+ raise
+ return wrapper
diff --git a/src/mailman/database/types.py b/src/mailman/database/types.py
new file mode 100644
index 000000000..2f901fe49
--- /dev/null
+++ b/src/mailman/database/types.py
@@ -0,0 +1,64 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Storm type conversions."""
+
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Enum',
+ ]
+
+
+import sys
+
+from storm.properties import SimpleProperty
+from storm.variables import Variable
+
+
+
+class _EnumVariable(Variable):
+ """Storm variable."""
+
+ def parse_set(self, value, from_db):
+ if value is None:
+ return None
+ if not from_db:
+ return value
+ path, intvalue = value.rsplit(':', 1)
+ modulename, classname = path.rsplit('.', 1)
+ __import__(modulename)
+ cls = getattr(sys.modules[modulename], classname)
+ return cls[int(intvalue)]
+
+ def parse_get(self, value, to_db):
+ if value is None:
+ return None
+ if not to_db:
+ return value
+ return '{0}.{1}:{2}'.format(
+ value.enumclass.__module__,
+ value.enumclass.__name__,
+ int(value))
+
+
+class Enum(SimpleProperty):
+ """Custom munepy.Enum type for Storm."""
+
+ variable_class = _EnumVariable
diff --git a/src/mailman/database/user.py b/src/mailman/database/user.py
new file mode 100644
index 000000000..23701686b
--- /dev/null
+++ b/src/mailman/database/user.py
@@ -0,0 +1,94 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Model for users."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'User',
+ ]
+
+from storm.locals import *
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.model import Model
+from mailman.database.address import Address
+from mailman.database.preferences import Preferences
+from mailman.database.roster import Memberships
+from mailman.interfaces.address import (
+ AddressAlreadyLinkedError, AddressNotLinkedError)
+from mailman.interfaces.user import IUser
+
+
+
+class User(Model):
+ """Mailman users."""
+
+ implements(IUser)
+
+ id = Int(primary=True)
+ real_name = Unicode()
+ password = Unicode()
+
+ addresses = ReferenceSet(id, 'Address.user_id')
+ preferences_id = Int()
+ preferences = Reference(preferences_id, 'Preferences.id')
+
+ def __repr__(self):
+ return ''.format(self.real_name, id(self))
+
+ def link(self, address):
+ """See `IUser`."""
+ if address.user is not None:
+ raise AddressAlreadyLinkedError(address)
+ address.user = self
+
+ def unlink(self, address):
+ """See `IUser`."""
+ if address.user is None:
+ raise AddressNotLinkedError(address)
+ address.user = None
+
+ def controls(self, address):
+ """See `IUser`."""
+ found = config.db.store.find(Address, address=address)
+ 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):
+ """See `IUser`."""
+ # First, see if the address already exists
+ addrobj = config.db.store.find(Address, address=address).one()
+ if addrobj is None:
+ if real_name is None:
+ real_name = ''
+ addrobj = Address(address=address, real_name=real_name)
+ addrobj.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
+
+ @property
+ def memberships(self):
+ return Memberships(self)
diff --git a/src/mailman/database/usermanager.py b/src/mailman/database/usermanager.py
new file mode 100644
index 000000000..3b0c8b534
--- /dev/null
+++ b/src/mailman/database/usermanager.py
@@ -0,0 +1,103 @@
+# Copyright (C) 2007-2009 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 .
+
+"""A user manager."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'UserManager',
+ ]
+
+from zope.interface import implements
+
+from mailman.config import config
+from mailman.database.address import Address
+from mailman.database.preferences import Preferences
+from mailman.database.user import User
+from mailman.interfaces.address import ExistingAddressError
+from mailman.interfaces.usermanager import IUserManager
+
+
+
+class UserManager(object):
+ implements(IUserManager)
+
+ def create_user(self, address=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)
+ user.preferences = Preferences()
+ config.db.store.add(user)
+ return user
+
+ 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())
+ if addresses.count() == 0:
+ return None
+ elif addresses.count() == 1:
+ return addresses[0].user
+ else:
+ raise AssertionError('Unexpected query count')
+
+ def create_address(self, address, real_name=None):
+ addresses = config.db.store.find(Address, address=address.lower())
+ if addresses.count() == 1:
+ found = addresses[0]
+ raise ExistingAddressError(found.original_address)
+ 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
+ # constructor will do the right thing.
+ address = Address(address, real_name)
+ address.preferences = Preferences()
+ config.db.store.add(address)
+ return address
+
+ def delete_address(self, address):
+ # If there's a user controlling this address, it has to first be
+ # unlinked before the address can be deleted.
+ if address.user:
+ address.user.unlink(address)
+ config.db.store.remove(address)
+
+ def get_address(self, address):
+ addresses = config.db.store.find(Address, address=address.lower())
+ if addresses.count() == 0:
+ return None
+ elif addresses.count() == 1:
+ return addresses[0]
+ else:
+ raise AssertionError('Unexpected query count')
+
+ @property
+ def addresses(self):
+ for address in config.db.store.find(Address):
+ yield address
diff --git a/src/mailman/database/version.py b/src/mailman/database/version.py
new file mode 100644
index 000000000..d15065395
--- /dev/null
+++ b/src/mailman/database/version.py
@@ -0,0 +1,40 @@
+# Copyright (C) 2007-2009 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 .
+
+"""Model class for version numbers."""
+
+from __future__ import absolute_import, unicode_literals
+
+__metaclass__ = type
+__all__ = [
+ 'Version',
+ ]
+
+from storm.locals import *
+from mailman.database.model import Model
+
+
+
+class Version(Model):
+ id = Int(primary=True)
+ component = Unicode()
+ version = Int()
+
+ def __init__(self, component, version):
+ super(Version, self).__init__()
+ self.component = component
+ self.version = version
diff --git a/src/mailman/docs/__init__.py b/src/mailman/docs/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/mailman/docs/addresses.txt b/src/mailman/docs/addresses.txt
new file mode 100644
index 000000000..9eccb2673
--- /dev/null
+++ b/src/mailman/docs/addresses.txt
@@ -0,0 +1,231 @@
+Email addresses
+===============
+
+Addresses represent a text email address, along with some meta data about
+those addresses, such as their registration date, and whether and when they've
+been validated. Addresses may be linked to the users that Mailman knows
+about. Addresses are subscribed to mailing lists though members.
+
+ >>> usermgr = config.db.user_manager
+
+
+Creating addresses
+------------------
+
+Addresses are created directly through the user manager, which starts out with
+no addresses.
+
+ >>> sorted(address.address for address in usermgr.addresses)
+ []
+
+Creating an unlinked email address is straightforward.
+
+ >>> address_1 = usermgr.create_address(u'aperson@example.com')
+ >>> sorted(address.address for address in usermgr.addresses)
+ [u'aperson@example.com']
+
+However, such addresses have no real name.
+
+ >>> address_1.real_name
+ u''
+
+You can also create an email address object with a real name.
+
+ >>> address_2 = usermgr.create_address(
+ ... u'bperson@example.com', u'Ben Person')
+ >>> sorted(address.address for address in usermgr.addresses)
+ [u'aperson@example.com', u'bperson@example.com']
+ >>> sorted(address.real_name for address in usermgr.addresses)
+ [u'', u'Ben Person']
+
+The str() of the address is the RFC 2822 preferred originator format, while
+the repr() carries more information.
+
+ >>> str(address_2)
+ 'Ben Person '
+ >>> repr(address_2)
+ ' [not verified] at 0x...>'
+
+You can assign real names to existing addresses.
+
+ >>> address_1.real_name = u'Anne Person'
+ >>> sorted(address.real_name for address in usermgr.addresses)
+ [u'Anne Person', u'Ben Person']
+
+These addresses are not linked to users, and can be seen by searching the user
+manager for an associated user.
+
+ >>> print usermgr.get_user(u'aperson@example.com')
+ None
+ >>> print usermgr.get_user(u'bperson@example.com')
+ None
+
+You can create email addresses that are linked to users by using a different
+interface.
+
+ >>> user_1 = usermgr.create_user(u'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 usermgr.addresses)
+ [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com']
+ >>> sorted(address.real_name for address in usermgr.addresses)
+ [u'Anne Person', u'Ben Person', u'Claire Person']
+
+And now you can find the associated user.
+
+ >>> print usermgr.get_user(u'aperson@example.com')
+ None
+ >>> print usermgr.get_user(u'bperson@example.com')
+ None
+ >>> usermgr.get_user(u'cperson@example.com')
+
+
+
+Deleting addresses
+------------------
+
+You can remove an unlinked address from the user manager.
+
+ >>> usermgr.delete_address(address_1)
+ >>> sorted(address.address for address in usermgr.addresses)
+ [u'bperson@example.com', u'cperson@example.com']
+ >>> sorted(address.real_name for address in usermgr.addresses)
+ [u'Ben Person', u'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']
+ >>> user_1.controls(u'cperson@example.com')
+ True
+ >>> address_3 = list(user_1.addresses)[0]
+ >>> usermgr.delete_address(address_3)
+ >>> sorted(address.address for address in user_1.addresses)
+ []
+ >>> user_1.controls(u'cperson@example.com')
+ False
+ >>> sorted(address.address for address in usermgr.addresses)
+ [u'bperson@example.com']
+
+
+Registration and validation
+---------------------------
+
+Addresses have two dates, the date the address was registered on and the date
+the address was validated on. Neither date is set by default.
+
+ >>> address_4 = usermgr.create_address(
+ ... u'dperson@example.com', u'Dan Person')
+ >>> print address_4.registered_on
+ None
+ >>> print address_4.verified_on
+ None
+
+The registered date takes a Python datetime object.
+
+ >>> from datetime import datetime
+ >>> address_4.registered_on = datetime(2007, 5, 8, 22, 54, 1)
+ >>> print address_4.registered_on
+ 2007-05-08 22:54:01
+ >>> print address_4.verified_on
+ None
+
+And of course, you can also set the validation date.
+
+ >>> address_4.verified_on = datetime(2007, 5, 13, 22, 54, 1)
+ >>> print address_4.registered_on
+ 2007-05-08 22:54:01
+ >>> print address_4.verified_on
+ 2007-05-13 22:54:01
+
+
+Subscriptions
+-------------
+
+Addresses get subscribed to mailing lists, not users. When the address is
+subscribed, a role is specified.
+
+ >>> address_5 = usermgr.create_address(
+ ... u'eperson@example.com', u'Elly Person')
+ >>> mlist = config.db.list_manager.create(u'_xtext@example.com')
+ >>> from mailman.interfaces.member import MemberRole
+ >>> address_5.subscribe(mlist, MemberRole.owner)
+ on
+ _xtext@example.com as MemberRole.owner>
+ >>> address_5.subscribe(mlist, MemberRole.member)
+ on
+ _xtext@example.com as MemberRole.member>
+
+Now Elly is both an owner and a member of the mailing list.
+
+ >>> sorted(mlist.owners.members)
+ [ on
+ _xtext@example.com as MemberRole.owner>]
+ >>> sorted(mlist.moderators.members)
+ []
+ >>> sorted(mlist.administrators.members)
+ [ on
+ _xtext@example.com as MemberRole.owner>]
+ >>> sorted(mlist.members.members)
+ [ on
+ _xtext@example.com as MemberRole.member>]
+ >>> sorted(mlist.regular_members.members)
+ [ on
+ _xtext@example.com as MemberRole.member>]
+ >>> sorted(mlist.digest_members.members)
+ []
+
+
+Case-preserved addresses
+------------------------
+
+Technically speaking, email addresses are case sensitive in the local part.
+Mailman preserves the case of addresses and uses the case preserved version
+when sending the user a message, but it treats addresses that are different in
+case equivalently in all other situations.
+
+ >>> address_6 = usermgr.create_address(
+ ... u'FPERSON@example.com', u'Frank Person')
+
+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 '
+ >>> repr(address_6)
+ ' [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.
+
+ >>> address_6.address
+ u'fperson@example.com'
+ >>> address_6.original_address
+ u'FPERSON@example.com'
+
+Because addresses are case-insensitive for all other purposes, you cannot
+create an address that differs only in case.
+
+ >>> usermgr.create_address(u'fperson@example.com')
+ Traceback (most recent call last):
+ ...
+ ExistingAddressError: FPERSON@example.com
+ >>> usermgr.create_address(u'fperson@EXAMPLE.COM')
+ Traceback (most recent call last):
+ ...
+ ExistingAddressError: FPERSON@example.com
+ >>> usermgr.create_address(u'FPERSON@example.com')
+ Traceback (most recent call last):
+ ...
+ ExistingAddressError: FPERSON@example.com
+
+You can get the address using either the lower cased version or case-preserved
+version. In fact, searching for an address is case insensitive.
+
+ >>> usermgr.get_address(u'fperson@example.com').address
+ u'fperson@example.com'
+ >>> usermgr.get_address(u'FPERSON@example.com').address
+ u'fperson@example.com'
diff --git a/src/mailman/docs/archivers.txt b/src/mailman/docs/archivers.txt
new file mode 100644
index 000000000..ef36a25ac
--- /dev/null
+++ b/src/mailman/docs/archivers.txt
@@ -0,0 +1,184 @@
+Archivers
+=========
+
+Mailman supports pluggable archivers, and it comes with several default
+archivers.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'test@example.com')
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.org
+ ... To: test@example.com
+ ... Subject: An archived message
+ ... Message-ID: <12345>
+ ...
+ ... 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
+the archive. This latter is appropriate for the message footer or for the RFC
+5064 Archived-At header.
+
+Pipermail does not support a permalink, so that interface returns None.
+Mailman defines a draft spec for how list servers and archivers can
+interoperate.
+
+ >>> archivers = {}
+ >>> from operator import attrgetter
+ >>> for archiver in sorted(config.archivers, key=attrgetter('name')):
+ ... print archiver.name
+ ... print ' ', archiver.list_url(mlist)
+ ... print ' ', archiver.permalink(mlist, msg)
+ ... archivers[archiver.name] = archiver
+ mail-archive
+ http://go.mail-archive.dev/test%40example.com
+ http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc=
+ mhonarc
+ http://lists.example.com/.../test@example.com
+ http://lists.example.com/.../RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
+ pipermail
+ http://www.example.com/pipermail/test@example.com
+ None
+ prototype
+ http://lists.example.com
+ http://lists.example.com/RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
+
+
+Sending the message to the archiver
+-----------------------------------
+
+The archiver is also able to archive the message.
+
+ >>> archivers['pipermail'].archive_message(mlist, msg)
+
+ >>> import os
+ >>> from mailman.interfaces.archiver import IPipermailMailingList
+ >>> pckpath = os.path.join(
+ ... IPipermailMailingList(mlist).archive_dir(),
+ ... 'pipermail.pck')
+ >>> os.path.exists(pckpath)
+ True
+
+Note however that the prototype archiver can't archive messages.
+
+ >>> archivers['prototype'].archive_message(mlist, msg)
+ Traceback (most recent call last):
+ ...
+ NotImplementedError
+
+
+The Mail-Archive.com
+--------------------
+
+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)
+ http://go.mail-archive.dev/test%40example.com
+ >>> print archiver.permalink(mlist, msg)
+ 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.
+
+ >>> archiver.archive_message(mlist, msg)
+
+ >>> from mailman.queue.outgoing import OutgoingRunner
+ >>> from mailman.testing.helpers import make_testable_runner
+ >>> outgoing = make_testable_runner(OutgoingRunner, 'out')
+ >>> outgoing.run()
+
+ >>> from operator import itemgetter
+ >>> messages = list(smtpd.messages)
+ >>> len(messages)
+ 1
+
+ >>> print messages[0].as_string()
+ From: aperson@example.org
+ To: test@example.com
+ Subject: An archived message
+ Message-ID: <12345>
+ X-Message-ID-Hash: ZaXPPxRMM9_hFZL4vTRlQlBx8pc=
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Sender: test-bounces@example.com
+ Errors-To: test-bounces@example.com
+ X-Peer: 127.0.0.1:...
+ X-MailFrom: test-bounces@example.com
+ X-RcptTo: archive@mail-archive.dev
+
+ Here is an archived message.
+ _______________________________________________
+ Test mailing list
+ test@example.com
+ http://lists.example.com/listinfo/test@example.com
+
+ >>> smtpd.clear()
+
+However, if the mailing list is not public, the message will never be archived
+at this service.
+
+ >>> mlist.archive_private = True
+ >>> print archiver.list_url(mlist)
+ None
+ >>> print archiver.permalink(mlist, msg)
+ None
+ >>> archiver.archive_message(mlist, msg)
+ >>> list(smtpd.messages)
+ []
+
+Additionally, this archiver can handle malformed Message-IDs.
+
+ >>> mlist.archive_private = False
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = '12345>'
+ >>> print archiver.permalink(mlist, msg)
+ http://go.mail-archive.dev/bXvG32YzcDEIVDaDLaUSVQekfo8=
+
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = '<12345'
+ >>> print archiver.permalink(mlist, msg)
+ http://go.mail-archive.dev/9rockPrT1Mm-jOsLWS6_hseR_OY=
+
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = '12345'
+ >>> print archiver.permalink(mlist, msg)
+ http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc=
+
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = ' 12345 '
+ >>> print archiver.permalink(mlist, msg)
+ http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc=
+
+
+MHonArc
+-------
+
+The MHonArc archiver is also available.
+
+ >>> archiver = archivers['mhonarc']
+ >>> print archiver.name
+ mhonarc
+
+Messages sent to a local MHonArc instance are added to its archive via a
+subprocess call.
+
+ >>> archiver.archive_message(mlist, msg)
+ >>> archive_log = open(os.path.join(config.LOG_DIR, 'archiver'))
+ >>> try:
+ ... contents = archive_log.read()
+ ... finally:
+ ... archive_log.close()
+ >>> print 'LOG:', contents
+ LOG: ... /usr/bin/mhonarc -add
+ -dbfile /.../private/test@example.com.mbox/mhonarc.db
+ -outdir /.../mhonarc/test@example.com
+ -stderr /.../logs/mhonarc
+ -stdout /.../logs/mhonarc
+ -spammode -umask 022
+ ...
diff --git a/src/mailman/docs/bounces.txt b/src/mailman/docs/bounces.txt
new file mode 100644
index 000000000..9e8bcd23b
--- /dev/null
+++ b/src/mailman/docs/bounces.txt
@@ -0,0 +1,107 @@
+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.
+
+ >>> mlist = config.db.list_manager.create(u'_xtest@example.com')
+ >>> mlist.preferred_language = u'en'
+
+Any message can be bounced.
+
+ >>> msg = message_from_string("""\
+ ... To: _xtest@example.com
+ ... From: aperson@example.com
+ ... Subject: Something important
+ ...
+ ... I sometimes say something important.
+ ... """)
+
+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.
+
+ >>> switchboard = config.switchboards['virgin']
+ >>> from mailman.app.bounces import bounce_message
+ >>> bounce_message(mlist, msg)
+ >>> len(switchboard.files)
+ 1
+ >>> filebase = switchboard.files[0]
+ >>> qmsg, qmsgdata = switchboard.dequeue(filebase)
+ >>> switchboard.finish(filebase)
+ >>> print qmsg.as_string()
+ Subject: Something important
+ From: _xtest-owner@example.com
+ To: aperson@example.com
+ MIME-Version: 1.0
+ Content-Type: multipart/mixed; boundary="..."
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+
+ --...
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+
+ [No bounce details are available]
+ --...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+
+ To: _xtest@example.com
+ From: aperson@example.com
+ Subject: Something important
+
+ I sometimes say something important.
+
+ --...--
+
+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.
+
+ >>> from mailman.core.errors import RejectMessage
+ >>> error = RejectMessage("This wasn't very important after all.")
+ >>> bounce_message(mlist, msg, error)
+ >>> len(switchboard.files)
+ 1
+ >>> filebase = switchboard.files[0]
+ >>> qmsg, qmsgdata = switchboard.dequeue(filebase)
+ >>> switchboard.finish(filebase)
+ >>> print qmsg.as_string()
+ Subject: Something important
+ From: _xtest-owner@example.com
+ To: aperson@example.com
+ MIME-Version: 1.0
+ Content-Type: multipart/mixed; boundary="..."
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+
+ --...
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+
+ This wasn't very important after all.
+ --...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+
+ To: _xtest@example.com
+ From: aperson@example.com
+ Subject: Something important
+
+ I sometimes say something important.
+
+ --...--
diff --git a/src/mailman/docs/chains.txt b/src/mailman/docs/chains.txt
new file mode 100644
index 000000000..b6e75e6e1
--- /dev/null
+++ b/src/mailman/docs/chains.txt
@@ -0,0 +1,345 @@
+Chains
+======
+
+When a new message comes into the system, Mailman uses a set of rule chains to
+decide whether the message gets posted to the list, rejected, discarded, or
+held for moderator approval.
+
+There are a number of built-in chains available that act as end-points in the
+processing of messages.
+
+
+The Discard chain
+-----------------
+
+The Discard chain simply throws the message away.
+
+ >>> from zope.interface.verify import verifyObject
+ >>> from mailman.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.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'_xtest@example.com')
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ... Subject: My first post
+ ... Message-ID:
+ ...
+ ... An important message.
+ ... """)
+
+ >>> from mailman.core.chains import process
+
+ # XXX This checks the vette log file because there is no other evidence
+ # that this chain has done anything.
+ >>> import os
+ >>> fp = open(os.path.join(config.LOG_DIR, 'vette'))
+ >>> file_pos = fp.tell()
+ >>> process(mlist, msg, {}, 'discard')
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... DISCARD:
+
+
+
+The Reject chain
+----------------
+
+The Reject chain bounces the message back to the original sender, and logs
+this action.
+
+ >>> chain = config.chains['reject']
+ >>> verifyObject(IChain, chain)
+ True
+ >>> print chain.name
+ reject
+ >>> print chain.description
+ Reject/bounce a message and stop processing.
+ >>> file_pos = fp.tell()
+ >>> process(mlist, msg, {}, 'reject')
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... REJECT:
+
+The bounce message is now sitting in the Virgin queue.
+
+ >>> virginq = config.switchboards['virgin']
+ >>> len(virginq.files)
+ 1
+ >>> qmsg, qdata = virginq.dequeue(virginq.files[0])
+ >>> print qmsg.as_string()
+ Subject: My first post
+ From: _xtest-owner@example.com
+ To: aperson@example.com
+ ...
+ [No bounce details are available]
+ ...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID:
+
+ An important message.
+
+ ...
+
+
+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.
+
+ >>> chain = config.chains['hold']
+ >>> verifyObject(IChain, chain)
+ True
+ >>> print chain.name
+ hold
+ >>> print chain.description
+ Hold a message and stop processing.
+
+ >>> file_pos = fp.tell()
+ >>> process(mlist, msg, {}, 'hold')
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... HOLD: _xtest@example.com post from aperson@example.com held,
+ message-id=: n/a
+
+
+There are now two messages in the Virgin queue, one to the list moderators and
+one to the original author.
+
+ >>> len(virginq.files)
+ 2
+ >>> qfiles = []
+ >>> for filebase in virginq.files:
+ ... qmsg, qdata = virginq.dequeue(filebase)
+ ... virginq.finish(filebase)
+ ... qfiles.append(qmsg)
+ >>> from operator import itemgetter
+ >>> qfiles.sort(key=itemgetter('to'))
+
+This message is addressed to the mailing list moderators.
+
+ >>> print qfiles[0].as_string()
+ Subject: _xtest@example.com post from aperson@example.com requires approval
+ From: _xtest-owner@example.com
+ To: _xtest-owner@example.com
+ MIME-Version: 1.0
+ ...
+ As list administrator, your authorization is requested for the
+ following mailing list posting:
+
+ List: _xtest@example.com
+ From: aperson@example.com
+ Subject: My first post
+ Reason: XXX
+
+ At your convenience, visit:
+
+ http://lists.example.com/admindb/_xtest@example.com
+
+ to approve or deny the request.
+
+ ...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID:
+ X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW
+
+ An important message.
+
+ ...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Subject: confirm ...
+ Sender: _xtest-request@example.com
+ From: _xtest-request@example.com
+ ...
+
+ If you reply to this message, keeping the Subject: header intact,
+ Mailman will discard the held message. Do this if the message is
+ spam. If you reply to this message and include an Approved: header
+ with the list password in it, the message will be approved for posting
+ to the list. The Approved: header can also appear in the first line
+ of the body of the reply.
+ ...
+
+This message is addressed to the sender of the message.
+
+ >>> print qfiles[1].as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: Your message to _xtest@example.com awaits moderator approval
+ From: _xtest-bounces@example.com
+ To: aperson@example.com
+ ...
+ Your mail to '_xtest@example.com' with the subject
+
+ My first post
+
+ Is being held until the list moderator can review it for approval.
+
+ The reason it is being held:
+
+ XXX
+
+ Either the message will get posted to the list, or you will receive
+ notification of the moderator's decision. If you would like to cancel
+ this posting, please visit the following URL:
+
+ http://lists.example.com/confirm/_xtest@example.com/...
+
+
+
+In addition, the pending database is holding the original messages, waiting
+for them to be disposed of by the original author or the list moderators. The
+database is essentially a dictionary, with the keys being the randomly
+selected tokens included in the urls and the values being a 2-tuple where the
+first item is a type code and the second item is a message id.
+
+ >>> import re
+ >>> cookie = None
+ >>> for line in qfiles[1].get_payload().splitlines():
+ ... mo = re.search('confirm/[^/]+/(?P.*)$', line)
+ ... if mo:
+ ... cookie = mo.group('cookie')
+ ... break
+ >>> assert cookie is not None, 'No confirmation token found'
+ >>> data = config.db.pendings.confirm(cookie)
+ >>> sorted(data.items())
+ [(u'id', ...), (u'type', u'held message')]
+
+The message itself is held in the message store.
+
+ >>> rkey, rdata = config.db.requests.get_list_requests(mlist).get_request(
+ ... data['id'])
+ >>> msg = config.db.message_store.get_message_by_id(
+ ... rdata['_mod_message_id'])
+ >>> print msg.as_string()
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID:
+ X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW
+
+ An important message.
+
+
+
+The Accept chain
+----------------
+
+The Accept chain sends the message on the 'prep' queue, where it will be
+processed and sent on to the list membership.
+
+ >>> chain = config.chains['accept']
+ >>> verifyObject(IChain, chain)
+ True
+ >>> print chain.name
+ accept
+ >>> print chain.description
+ Accept a message.
+ >>> file_pos = fp.tell()
+ >>> process(mlist, msg, {}, 'accept')
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... ACCEPT:
+
+ >>> pipelineq = config.switchboards['pipeline']
+ >>> len(pipelineq.files)
+ 1
+ >>> qmsg, qdata = pipelineq.dequeue(pipelineq.files[0])
+ >>> print qmsg.as_string()
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID:
+ X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW
+
+ An important message.
+