aboutsummaryrefslogtreecommitdiffstats
path: root/Mailman
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--Mailman/.cvsignore3
-rw-r--r--Mailman/Archiver/.cvsignore1
-rw-r--r--Mailman/Archiver/Archiver.py232
-rw-r--r--Mailman/Archiver/HyperArch.py1224
-rw-r--r--Mailman/Archiver/HyperDatabase.py338
-rw-r--r--Mailman/Archiver/Makefile.in72
-rw-r--r--Mailman/Archiver/__init__.py17
-rw-r--r--Mailman/Archiver/pipermail.py854
-rw-r--r--Mailman/Autoresponder.py43
-rw-r--r--Mailman/Bouncer.py281
-rw-r--r--Mailman/Bouncers/.cvsignore1
-rw-r--r--Mailman/Bouncers/BouncerAPI.py71
-rw-r--r--Mailman/Bouncers/Caiwireless.py45
-rw-r--r--Mailman/Bouncers/Compuserve.py45
-rw-r--r--Mailman/Bouncers/DSN.py79
-rw-r--r--Mailman/Bouncers/Exchange.py47
-rw-r--r--Mailman/Bouncers/Exim.py30
-rw-r--r--Mailman/Bouncers/GroupWise.py70
-rw-r--r--Mailman/Bouncers/LLNL.py31
-rw-r--r--Mailman/Bouncers/Makefile.in74
-rw-r--r--Mailman/Bouncers/Microsoft.py48
-rw-r--r--Mailman/Bouncers/Netscape.py88
-rw-r--r--Mailman/Bouncers/Postfix.py86
-rw-r--r--Mailman/Bouncers/Qmail.py61
-rw-r--r--Mailman/Bouncers/SMTP32.py57
-rw-r--r--Mailman/Bouncers/SimpleMatch.py100
-rw-r--r--Mailman/Bouncers/SimpleWarning.py44
-rw-r--r--Mailman/Bouncers/Sina.py47
-rw-r--r--Mailman/Bouncers/Yahoo.py53
-rw-r--r--Mailman/Bouncers/Yale.py79
-rw-r--r--Mailman/Bouncers/__init__.py15
-rw-r--r--Mailman/Cgi/.cvsignore1
-rw-r--r--Mailman/Cgi/Auth.py59
-rw-r--r--Mailman/Cgi/Makefile.in71
-rw-r--r--Mailman/Cgi/__init__.py15
-rw-r--r--Mailman/Cgi/admin.py1407
-rw-r--r--Mailman/Cgi/admindb.py769
-rw-r--r--Mailman/Cgi/confirm.py791
-rw-r--r--Mailman/Cgi/create.py410
-rw-r--r--Mailman/Cgi/edithtml.py170
-rw-r--r--Mailman/Cgi/listinfo.py206
-rw-r--r--Mailman/Cgi/options.py950
-rw-r--r--Mailman/Cgi/private.py162
-rw-r--r--Mailman/Cgi/rmlist.py242
-rw-r--r--Mailman/Cgi/roster.py129
-rw-r--r--Mailman/Cgi/subscribe.py276
-rw-r--r--Mailman/Commands/.cvsignore1
-rw-r--r--Mailman/Commands/Makefile.in69
-rw-r--r--Mailman/Commands/__init__.py15
-rw-r--r--Mailman/Commands/cmd_confirm.py84
-rw-r--r--Mailman/Commands/cmd_echo.py26
-rw-r--r--Mailman/Commands/cmd_end.py33
-rw-r--r--Mailman/Commands/cmd_help.py92
-rw-r--r--Mailman/Commands/cmd_info.py49
-rw-r--r--Mailman/Commands/cmd_join.py20
-rw-r--r--Mailman/Commands/cmd_leave.py20
-rw-r--r--Mailman/Commands/cmd_lists.py69
-rw-r--r--Mailman/Commands/cmd_password.py118
-rw-r--r--Mailman/Commands/cmd_remove.py20
-rw-r--r--Mailman/Commands/cmd_set.py353
-rw-r--r--Mailman/Commands/cmd_stop.py20
-rw-r--r--Mailman/Commands/cmd_subscribe.py136
-rw-r--r--Mailman/Commands/cmd_unsubscribe.py87
-rw-r--r--Mailman/Commands/cmd_who.py133
-rw-r--r--Mailman/Defaults.py.in1224
-rw-r--r--Mailman/Deliverer.py136
-rw-r--r--Mailman/Digester.py73
-rw-r--r--Mailman/Errors.py147
-rw-r--r--Mailman/GatewayManager.py38
-rw-r--r--Mailman/Gui/.cvsignore1
-rw-r--r--Mailman/Gui/Archive.py44
-rw-r--r--Mailman/Gui/Autoresponse.py98
-rw-r--r--Mailman/Gui/Bounce.py183
-rw-r--r--Mailman/Gui/ContentFilter.py169
-rw-r--r--Mailman/Gui/Digest.py160
-rw-r--r--Mailman/Gui/GUIBase.py200
-rw-r--r--Mailman/Gui/General.py446
-rw-r--r--Mailman/Gui/Language.py122
-rw-r--r--Mailman/Gui/Makefile.in69
-rw-r--r--Mailman/Gui/Membership.py34
-rw-r--r--Mailman/Gui/NonDigest.py130
-rw-r--r--Mailman/Gui/Passwords.py31
-rw-r--r--Mailman/Gui/Privacy.py398
-rw-r--r--Mailman/Gui/Topics.py160
-rw-r--r--Mailman/Gui/Usenet.py137
-rw-r--r--Mailman/Gui/__init__.py32
-rw-r--r--Mailman/HTMLFormatter.py433
-rw-r--r--Mailman/Handlers/.cvsignore1
-rw-r--r--Mailman/Handlers/Acknowledge.py62
-rw-r--r--Mailman/Handlers/AfterDelivery.py28
-rw-r--r--Mailman/Handlers/Approve.py82
-rw-r--r--Mailman/Handlers/AvoidDuplicates.py88
-rw-r--r--Mailman/Handlers/CalcRecips.py133
-rw-r--r--Mailman/Handlers/Cleanse.py39
-rw-r--r--Mailman/Handlers/CookHeaders.py254
-rw-r--r--Mailman/Handlers/Decorate.py183
-rw-r--r--Mailman/Handlers/Emergency.py37
-rw-r--r--Mailman/Handlers/FileRecips.py49
-rw-r--r--Mailman/Handlers/Hold.py280
-rw-r--r--Mailman/Handlers/Makefile.in69
-rw-r--r--Mailman/Handlers/MimeDel.py220
-rw-r--r--Mailman/Handlers/Moderate.py164
-rw-r--r--Mailman/Handlers/OwnerRecips.py27
-rw-r--r--Mailman/Handlers/Replybot.py120
-rw-r--r--Mailman/Handlers/SMTPDirect.py349
-rw-r--r--Mailman/Handlers/Scrubber.py400
-rw-r--r--Mailman/Handlers/Sendmail.py116
-rw-r--r--Mailman/Handlers/SpamDetect.py50
-rw-r--r--Mailman/Handlers/Tagger.py156
-rw-r--r--Mailman/Handlers/ToArchive.py39
-rw-r--r--Mailman/Handlers/ToDigest.py351
-rw-r--r--Mailman/Handlers/ToOutgoing.py55
-rw-r--r--Mailman/Handlers/ToUsenet.py44
-rw-r--r--Mailman/Handlers/__init__.py15
-rw-r--r--Mailman/ListAdmin.py579
-rw-r--r--Mailman/LockFile.py596
-rw-r--r--Mailman/Logging/.cvsignore1
-rw-r--r--Mailman/Logging/Logger.py103
-rw-r--r--Mailman/Logging/Makefile.in69
-rw-r--r--Mailman/Logging/MultiLogger.py76
-rw-r--r--Mailman/Logging/StampedLogger.py89
-rw-r--r--Mailman/Logging/Syslog.py69
-rw-r--r--Mailman/Logging/Utils.py52
-rw-r--r--Mailman/Logging/__init__.py15
-rw-r--r--Mailman/MTA/.cvsignore1
-rw-r--r--Mailman/MTA/Makefile.in69
-rw-r--r--Mailman/MTA/Manual.py135
-rw-r--r--Mailman/MTA/Postfix.py344
-rw-r--r--Mailman/MTA/Utils.py79
-rw-r--r--Mailman/MTA/__init__.py15
-rw-r--r--Mailman/MailList.py1346
-rw-r--r--Mailman/Mailbox.py101
-rw-r--r--Mailman/Makefile.in99
-rw-r--r--Mailman/MemberAdaptor.py350
-rw-r--r--Mailman/Message.py274
-rw-r--r--Mailman/OldStyleMemberships.py353
-rw-r--r--Mailman/Pending.py204
-rw-r--r--Mailman/Post.py61
-rw-r--r--Mailman/Queue/.cvsignore1
-rw-r--r--Mailman/Queue/ArchRunner.py76
-rw-r--r--Mailman/Queue/BounceRunner.py195
-rw-r--r--Mailman/Queue/CommandRunner.py220
-rw-r--r--Mailman/Queue/IncomingRunner.py170
-rw-r--r--Mailman/Queue/MaildirRunner.py184
-rw-r--r--Mailman/Queue/Makefile.in69
-rw-r--r--Mailman/Queue/NewsRunner.py158
-rw-r--r--Mailman/Queue/OutgoingRunner.py139
-rw-r--r--Mailman/Queue/Runner.py245
-rw-r--r--Mailman/Queue/Switchboard.py340
-rw-r--r--Mailman/Queue/VirginRunner.py43
-rw-r--r--Mailman/Queue/__init__.py15
-rw-r--r--Mailman/Queue/sbcache.py26
-rw-r--r--Mailman/SafeDict.py70
-rw-r--r--Mailman/SecurityManager.py333
-rw-r--r--Mailman/Site.py107
-rw-r--r--Mailman/TopicMgr.py61
-rw-r--r--Mailman/UserDesc.py57
-rw-r--r--Mailman/Utils.py773
-rw-r--r--Mailman/Version.py48
-rw-r--r--Mailman/__init__.py15
-rw-r--r--Mailman/htmlformat.py678
-rw-r--r--Mailman/i18n.py129
-rw-r--r--Mailman/mm_cfg.py.dist.in44
-rw-r--r--Mailman/versions.py495
164 files changed, 28886 insertions, 0 deletions
diff --git a/Mailman/.cvsignore b/Mailman/.cvsignore
new file mode 100644
index 00000000..4ef7207b
--- /dev/null
+++ b/Mailman/.cvsignore
@@ -0,0 +1,3 @@
+Makefile
+mm_cfg.py.dist
+Defaults.py
diff --git a/Mailman/Archiver/.cvsignore b/Mailman/Archiver/.cvsignore
new file mode 100644
index 00000000..f3c7a7c5
--- /dev/null
+++ b/Mailman/Archiver/.cvsignore
@@ -0,0 +1 @@
+Makefile
diff --git a/Mailman/Archiver/Archiver.py b/Mailman/Archiver/Archiver.py
new file mode 100644
index 00000000..903031cd
--- /dev/null
+++ b/Mailman/Archiver/Archiver.py
@@ -0,0 +1,232 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+"""Mixin class for putting new messages in the right place for archival.
+
+Public archives are separated from private ones. An external archival
+mechanism (eg, pipermail) should be pointed to the right places, to do the
+archival.
+"""
+
+import os
+import errno
+import traceback
+from cStringIO import StringIO
+
+from Mailman import mm_cfg
+from Mailman import Mailbox
+from Mailman import Utils
+from Mailman import Site
+from Mailman.SafeDict import SafeDict
+from Mailman.Logging.Syslog import syslog
+from Mailman.i18n import _
+
+
+
+def makelink(old, new):
+ try:
+ os.symlink(old, new)
+ except os.error, e:
+ code, msg = e
+ if code <> errno.EEXIST:
+ raise
+
+def breaklink(link):
+ try:
+ os.unlink(link)
+ except os.error, e:
+ code, msg = e
+ if code <> errno.ENOENT:
+ raise
+
+
+
+class Archiver:
+ #
+ # Interface to Pipermail. HyperArch.py uses this method to get the
+ # archive directory for the mailing list
+ #
+ def InitVars(self):
+ # Configurable
+ self.archive = mm_cfg.DEFAULT_ARCHIVE
+ # 0=public, 1=private:
+ self.archive_private = mm_cfg.DEFAULT_ARCHIVE_PRIVATE
+ self.archive_volume_frequency = \
+ mm_cfg.DEFAULT_ARCHIVE_VOLUME_FREQUENCY
+ # The archive file structure by default is:
+ #
+ # archives/
+ # private/
+ # listname.mbox/
+ # listname.mbox
+ # listname/
+ # lots-of-pipermail-stuff
+ # public/
+ # listname.mbox@ -> ../private/listname.mbox
+ # listname@ -> ../private/listname
+ #
+ # IOW, the mbox and pipermail archives are always stored in the
+ # private archive for the list. This is safe because archives/private
+ # is always set to o-rx. Public archives have a symlink to get around
+ # the private directory, pointing directly to the private/listname
+ # which has o+rx permissions. Private archives do not have the
+ # symbolic links.
+ omask = os.umask(0)
+ try:
+ try:
+ os.mkdir(self.archive_dir()+'.mbox', 02775)
+ except OSError, e:
+ if e.errno <> errno.EEXIST: raise
+ # We also create an empty pipermail archive directory into
+ # which we'll drop an empty index.html file into. This is so
+ # that lists that have not yet received a posting have
+ # /something/ as their index.html, and don't just get a 404.
+ try:
+ os.mkdir(self.archive_dir(), 02775)
+ except OSError, e:
+ if e.errno <> errno.EEXIST: raise
+ # See if there's an index.html file there already and if not,
+ # write in the empty archive notice.
+ indexfile = os.path.join(self.archive_dir(), 'index.html')
+ fp = None
+ try:
+ fp = open(indexfile)
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ else:
+ fp = open(indexfile, 'w')
+ fp.write(Utils.maketext(
+ 'emptyarchive.html',
+ {'listname': self.real_name,
+ 'listinfo': self.GetScriptURL('listinfo', absolute=1),
+ }, mlist=self))
+ if fp:
+ fp.close()
+ finally:
+ os.umask(omask)
+
+ def archive_dir(self):
+ return Site.get_archpath(self.internal_name())
+
+ def ArchiveFileName(self):
+ """The mbox name where messages are left for archive construction."""
+ return os.path.join(self.archive_dir() + '.mbox',
+ self.internal_name() + '.mbox')
+
+ def GetBaseArchiveURL(self):
+ if self.archive_private:
+ return self.GetScriptURL('private', absolute=1) + '/'
+ else:
+ inv = {}
+ for k, v in mm_cfg.VIRTUAL_HOSTS.items():
+ inv[v] = k
+ url = mm_cfg.PUBLIC_ARCHIVE_URL % {
+ 'listname': self.internal_name(),
+ 'hostname': inv.get(self.host_name, mm_cfg.DEFAULT_URL_HOST),
+ }
+ if not url.endswith('/'):
+ url += '/'
+ return url
+
+ def __archive_file(self, afn):
+ """Open (creating, if necessary) the named archive file."""
+ omask = os.umask(002)
+ try:
+ return Mailbox.Mailbox(open(afn, 'a+'))
+ finally:
+ os.umask(omask)
+
+ #
+ # old ArchiveMail function, retained under a new name
+ # for optional archiving to an mbox
+ #
+ def __archive_to_mbox(self, post):
+ """Retain a text copy of the message in an mbox file."""
+ try:
+ afn = self.ArchiveFileName()
+ mbox = self.__archive_file(afn)
+ mbox.AppendMessage(post)
+ mbox.fp.close()
+ except IOError, msg:
+ syslog('error', 'Archive file access failure:\n\t%s %s', afn, msg)
+ raise
+
+ def ExternalArchive(self, ar, txt):
+ d = SafeDict({'listname': self.internal_name()})
+ cmd = ar % d
+ extarch = os.popen(cmd, 'w')
+ extarch.write(txt)
+ status = extarch.close()
+ if status:
+ syslog('error', 'external archiver non-zero exit status: %d\n',
+ (status & 0xff00) >> 8)
+
+ #
+ # archiving in real time this is called from list.post(msg)
+ #
+ def ArchiveMail(self, msg):
+ """Store postings in mbox and/or pipermail archive, depending."""
+ # Fork so archival errors won't disrupt normal list delivery
+ if mm_cfg.ARCHIVE_TO_MBOX == -1:
+ return
+ #
+ # We don't need an extra archiver lock here because we know the list
+ # itself must be locked.
+ if mm_cfg.ARCHIVE_TO_MBOX in (1, 2):
+ self.__archive_to_mbox(msg)
+ if mm_cfg.ARCHIVE_TO_MBOX == 1:
+ # Archive to mbox only.
+ return
+ txt = str(msg)
+ # should we use the internal or external archiver?
+ private_p = self.archive_private
+ if mm_cfg.PUBLIC_EXTERNAL_ARCHIVER and not private_p:
+ self.ExternalArchive(mm_cfg.PUBLIC_EXTERNAL_ARCHIVER, txt)
+ elif mm_cfg.PRIVATE_EXTERNAL_ARCHIVER and private_p:
+ self.ExternalArchive(mm_cfg.PRIVATE_EXTERNAL_ARCHIVER, txt)
+ else:
+ # use the internal archiver
+ f = StringIO(txt)
+ import HyperArch
+ h = HyperArch.HyperArchive(self)
+ h.processUnixMailbox(f)
+ h.close()
+ f.close()
+
+ #
+ # called from MailList.MailList.Save()
+ #
+ def CheckHTMLArchiveDir(self):
+ # We need to make sure that the archive directory has the right perms
+ # for public vs private. If it doesn't exist, or some weird
+ # permissions errors prevent us from stating the directory, it's
+ # pointless to try to fix the perms, so we just return -scott
+ if mm_cfg.ARCHIVE_TO_MBOX == -1:
+ # Archiving is completely disabled, don't require the skeleton.
+ return
+ pubdir = Site.get_archpath(self.internal_name(), public=1)
+ privdir = self.archive_dir()
+ pubmbox = pubdir + '.mbox'
+ privmbox = privdir + '.mbox'
+ if self.archive_private:
+ breaklink(pubdir)
+ breaklink(pubmbox)
+ else:
+ # BAW: privdir or privmbox could be nonexistant. We'd get an
+ # OSError, ENOENT which should be caught and reported properly.
+ makelink(privdir, pubdir)
+ makelink(privmbox, pubmbox)
diff --git a/Mailman/Archiver/HyperArch.py b/Mailman/Archiver/HyperArch.py
new file mode 100644
index 00000000..98fb5738
--- /dev/null
+++ b/Mailman/Archiver/HyperArch.py
@@ -0,0 +1,1224 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""HyperArch: Pipermail archiving for Mailman
+
+ - The Dragon De Monsyne <dragondm@integral.org>
+
+ TODO:
+ - Should be able to force all HTML to be regenerated next time the
+ archive is run, in case a template is changed.
+ - Run a command to generate tarball of html archives for downloading
+ (probably in the 'update_dirty_archives' method).
+"""
+
+from __future__ import nested_scopes
+
+import sys
+import re
+import errno
+import urllib
+import time
+import os
+import types
+import HyperDatabase
+import pipermail
+import weakref
+import binascii
+
+from email.Header import decode_header, make_header
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import LockFile
+from Mailman import MailList
+from Mailman import i18n
+from Mailman.SafeDict import SafeDict
+from Mailman.Logging.Syslog import syslog
+from Mailman.Mailbox import ArchiverMailbox
+
+# Set up i18n. Assume the current language has already been set in the caller.
+_ = i18n._
+
+gzip = None
+if mm_cfg.GZIP_ARCHIVE_TXT_FILES:
+ try:
+ import gzip
+ except ImportError:
+ pass
+
+EMPTYSTRING = ''
+NL = '\n'
+
+# MacOSX has a default stack size that is too small for deeply recursive
+# regular expressions. We see this as crashes in the Python test suite when
+# running test_re.py and test_sre.py. The fix is to set the stack limit to
+# 2048; the general recommendation is to do in the shell before running the
+# test suite. But that's inconvenient for a daemon like the qrunner.
+#
+# AFAIK, this problem only affects the archiver, so we're adding this work
+# around to this file (it'll get imported by the bundled pipermail or by the
+# bin/arch script. We also only do this on darwin, a.k.a. MacOSX.
+if sys.platform == 'darwin':
+ try:
+ import resource
+ except ImportError:
+ pass
+ else:
+ soft, hard = resource.getrlimit(resource.RLIMIT_STACK)
+ newsoft = min(hard, max(soft, 1024*2048))
+ resource.setrlimit(resource.RLIMIT_STACK, (newsoft, hard))
+
+
+
+def html_quote(s, lang=None):
+ repls = ( ('&', '&amp;'),
+ ("<", '&lt;'),
+ (">", '&gt;'),
+ ('"', '&quot;'))
+ for thing, repl in repls:
+ s = s.replace(thing, repl)
+ return Utils.uncanonstr(s, lang)
+
+
+def url_quote(s):
+ return urllib.quote(s)
+
+
+def null_to_space(s):
+ return s.replace('\000', ' ')
+
+
+def sizeof(filename, lang):
+ try:
+ size = os.path.getsize(filename)
+ except OSError, e:
+ # ENOENT can happen if the .mbox file was moved away or deleted, and
+ # an explicit mbox file name was given to bin/arch.
+ if e.errno <> errno.ENOENT: raise
+ return _('size not available')
+ if size < 1000:
+ # Avoid i18n side-effects
+ otrans = i18n.get_translation()
+ try:
+ i18n.set_language(lang)
+ out = _(' %(size)i bytes ')
+ finally:
+ i18n.set_translation(otrans)
+ return out
+ elif size < 1000000:
+ return ' %d KB ' % (size / 1000)
+ # GB?? :-)
+ return ' %d MB ' % (size / 1000000)
+
+
+html_charset = '<META http-equiv="Content-Type" ' \
+ 'content="text/html; charset=%s">'
+
+def CGIescape(arg, lang=None):
+ if isinstance(arg, types.UnicodeType):
+ s = Utils.websafe(arg)
+ else:
+ s = Utils.websafe(str(arg))
+ return Utils.uncanonstr(s.replace('"', '&quot;'), lang)
+
+# Parenthesized human name
+paren_name_pat = re.compile(r'([(].*[)])')
+
+# Subject lines preceded with 'Re:'
+REpat = re.compile( r"\s*RE\s*(\[\d+\]\s*)?:\s*", re.IGNORECASE)
+
+# E-mail addresses and URLs in text
+emailpat = re.compile(r'([-+,.\w]+@[-+.\w]+)')
+
+# Argh! This pattern is buggy, and will choke on URLs with GET parameters.
+urlpat = re.compile(r'(\w+://[^>)\s]+)') # URLs in text
+
+# Blank lines
+blankpat = re.compile(r'^\s*$')
+
+# Starting <html> directive
+htmlpat = re.compile(r'^\s*<HTML>\s*$', re.IGNORECASE)
+# Ending </html> directive
+nohtmlpat = re.compile(r'^\s*</HTML>\s*$', re.IGNORECASE)
+# Match quoted text
+quotedpat = re.compile(r'^([>|:]|&gt;)+')
+
+
+
+# This doesn't need to be a weakref instance because it's just storing
+# strings. Keys are (templatefile, lang) tuples.
+_templatecache = {}
+
+def quick_maketext(templatefile, dict=None, lang=None, mlist=None):
+ if lang is None:
+ if mlist is None:
+ lang = mm_cfg.DEFAULT_SERVER_LANGUAGE
+ else:
+ lang = mlist.preferred_language
+ template = _templatecache.get((templatefile, lang))
+ if template is None:
+ # Use the basic maketext, with defaults to get the raw template
+ template = Utils.maketext(templatefile, lang=lang, raw=1)
+ _templatecache[(templatefile, lang)] = template
+ # Copied from Utils.maketext()
+ text = template
+ if dict is not None:
+ try:
+ sdict = SafeDict(dict)
+ try:
+ text = sdict.interpolate(template)
+ except UnicodeError:
+ # Try again after coercing the template to unicode
+ utemplate = unicode(template,
+ Utils.GetCharSet(lang),
+ 'replace')
+ text = sdict.interpolate(utemplate)
+ except (TypeError, ValueError):
+ # The template is really screwed up
+ pass
+ # Make sure the text is in the given character set, or html-ify any bogus
+ # characters.
+ return Utils.uncanonstr(text, lang)
+
+
+
+# Note: I'm overriding most, if not all of the pipermail Article class
+# here -ddm
+# 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
+# fromdate : The posting date, in `unixfrom' 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(pipermail.Article):
+ __super_init = pipermail.Article.__init__
+ __super_set_date = pipermail.Article._set_date
+
+ _last_article_time = time.time()
+
+ def __init__(self, message=None, sequence=0, keepHeaders=[],
+ lang=mm_cfg.DEFAULT_SERVER_LANGUAGE, mlist=None):
+ self.__super_init(message, sequence, keepHeaders)
+ self.prev = None
+ self.next = None
+ # Trim Re: from the subject line
+ i = 0
+ while i != -1:
+ result = REpat.match(self.subject)
+ if result:
+ i = result.end(0)
+ self.subject = self.subject[i:]
+ else:
+ i = -1
+ # Useful to keep around
+ self._lang = lang
+ self._mlist = mlist
+
+ if mm_cfg.ARCHIVER_OBSCURES_EMAILADDRS:
+ # Avoid i18n side-effects. Note that the language for this
+ # article (for this list) could be different from the site-wide
+ # preferred language, so we need to ensure no side-effects will
+ # occur. Think what happens when executing bin/arch.
+ otrans = i18n.get_translation()
+ try:
+ i18n.set_language(lang)
+ self.email = re.sub('@', _(' at '), self.email)
+ finally:
+ i18n.set_translation(otrans)
+
+ # Snag the content-* headers. RFC 1521 states that their values are
+ # case insensitive.
+ ctype = message.get('Content-Type', 'text/plain')
+ cenc = message.get('Content-Transfer-Encoding', '')
+ self.ctype = ctype.lower()
+ self.cenc = cenc.lower()
+ self.decoded = {}
+ charset = message.get_param('charset')
+ if charset:
+ charset = charset.lower().strip()
+ if charset[0]=='"' and charset[-1]=='"':
+ charset = charset[1:-1]
+ if charset[0]=="'" and charset[-1]=="'":
+ charset = charset[1:-1]
+ try:
+ body = message.get_payload(decode=1)
+ except binascii.Error:
+ body = None
+ if body and charset != Utils.GetCharSet(self._lang):
+ # decode body
+ try:
+ body = unicode(body, charset)
+ except (UnicodeError, LookupError):
+ body = None
+ if body:
+ self.body = [l + "\n" for l in body.splitlines()]
+
+ self.decode_headers()
+
+ # Mapping of listnames to MailList instances as a weak value dictionary.
+ # This code is copied from Runner.py but there's one important operational
+ # difference. In Runner.py, we always .Load() the MailList object for
+ # each _dispose() run, otherwise the object retrieved from the cache won't
+ # be up-to-date. Since we're creating a new HyperArchive instance for
+ # each message being archived, we don't need to worry about that -- but it
+ # does mean there are additional opportunities for optimization.
+ _listcache = weakref.WeakValueDictionary()
+
+ def _open_list(self, listname):
+ # Cache the open list so that any use of the list within this process
+ # uses the same object. We use a WeakValueDictionary so that when the
+ # list is no longer necessary, its memory is freed.
+ mlist = self._listcache.get(listname)
+ if not mlist:
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ syslog('error', 'error opening list: %s\n%s', listname, e)
+ return None
+ else:
+ self._listcache[listname] = mlist
+ return mlist
+
+ def __getstate__(self):
+ d = self.__dict__.copy()
+ # We definitely don't want to pickle the MailList instance, so just
+ # pickle a reference to it.
+ if d.has_key('_mlist'):
+ mlist = d['_mlist']
+ del d['_mlist']
+ else:
+ mlist = None
+ if mlist:
+ d['__listname'] = self._mlist.internal_name()
+ else:
+ d['__listname'] = None
+ # Delete a few other things we don't want in the pickle
+ for attr in ('prev', 'next', 'body'):
+ if d.has_key(attr):
+ del d[attr]
+ d['body'] = []
+ return d
+
+ def __setstate__(self, d):
+ # For loading older Articles via pickle. All this stuff was added
+ # when Simone Piunni and Tokio Kikuchi i18n'ified Pipermail. See SF
+ # patch #594771.
+ self.__dict__ = d
+ listname = d.get('__listname')
+ if listname:
+ del d['__listname']
+ d['_mlist'] = self._open_list(listname)
+ if not d.has_key('_lang'):
+ if hasattr(self, '_mlist'):
+ self._lang = self._mlist.preferred_language
+ else:
+ self._lang = mm_cfg.DEFAULT_SERVER_LANGUAGE
+ if not d.has_key('cenc'):
+ self.cenc = None
+ if not d.has_key('decoded'):
+ self.decoded = {}
+
+ def setListIfUnset(self, mlist):
+ if getattr(self, '_mlist', None) is None:
+ self._mlist = mlist
+
+ def quote(self, buf):
+ return html_quote(buf, self._lang)
+
+ def decode_headers(self):
+ """MIME-decode headers.
+
+ If the email, subject, or author attributes contain non-ASCII
+ characters using the encoded-word syntax of RFC 2047, decoded versions
+ of those attributes are placed in the self.decoded (a dictionary).
+
+ If the list's charset differs from the header charset, an attempt is
+ made to decode the headers as Unicode. If that fails, they are left
+ undecoded.
+ """
+ author = self.decode_charset(self.author)
+ subject = self.decode_charset(self.subject)
+ if author:
+ self.decoded['author'] = author
+ email = self.decode_charset(self.email)
+ if email:
+ self.decoded['email'] = email
+ if subject:
+ self.decoded['subject'] = subject
+
+ def decode_charset(self, field):
+ if field.find("=?") == -1:
+ return None
+ # Get the decoded header as a list of (s, charset) tuples
+ pairs = decode_header(field)
+ # Use __unicode__() until we can guarantee Python 2.2
+ try:
+ # Use a large number for maxlinelen so it won't get wrapped
+ h = make_header(pairs, 99999)
+ return h.__unicode__()
+ except (UnicodeError, LookupError):
+ # Unknown encoding
+ return None
+ # The last value for c will have the proper charset in it
+ return EMPTYSTRING.join([s for s, c in pairs])
+
+ def as_html(self):
+ d = self.__dict__.copy()
+ # avoid i18n side-effects
+ otrans = i18n.get_translation()
+ i18n.set_language(self._lang)
+ try:
+ d["prev"], d["prev_wsubj"] = self._get_prev()
+ d["next"], d["next_wsubj"] = self._get_next()
+
+ d["email_html"] = self.quote(self.email)
+ d["title"] = self.quote(self.subject)
+ d["subject_html"] = self.quote(self.subject)
+ d["subject_url"] = url_quote(self.subject)
+ d["in_reply_to_url"] = url_quote(self.in_reply_to)
+ if mm_cfg.ARCHIVER_OBSCURES_EMAILADDRS:
+ # Point the mailto url back to the list
+ author = re.sub('@', _(' at '), self.author)
+ emailurl = self._mlist.GetListEmail()
+ else:
+ author = self.author
+ emailurl = self.email
+ d["author_html"] = self.quote(author)
+ d["email_url"] = url_quote(emailurl)
+ d["datestr_html"] = self.quote(i18n.ctime(int(self.date)))
+ d["body"] = self._get_body()
+ d['listurl'] = self._mlist.GetScriptURL('listinfo', absolute=1)
+ d['listname'] = self._mlist.real_name
+ d['encoding'] = ''
+ finally:
+ i18n.set_translation(otrans)
+
+ charset = Utils.GetCharSet(self._lang)
+ d["encoding"] = html_charset % charset
+
+ self._add_decoded(d)
+ return quick_maketext(
+ 'article.html', d,
+ lang=self._lang, mlist=self._mlist)
+
+ def _get_prev(self):
+ """Return the href and subject for the previous message"""
+ if self.prev:
+ subject = self._get_subject_enc(self.prev)
+ prev = ('<LINK REL="Previous" HREF="%s">'
+ % (url_quote(self.prev.filename)))
+ prev_wsubj = ('<LI>' + _('Previous message:') +
+ ' <A HREF="%s">%s\n</A></li>'
+ % (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 = ('<LINK REL="Next" HREF="%s">'
+ % (url_quote(self.next.filename)))
+ next_wsubj = ('<LI>' + _('Next message:') +
+ ' <A HREF="%s">%s\n</A></li>'
+ % (url_quote(self.next.filename),
+ self.quote(subject)))
+ else:
+ next = next_wsubj = ""
+ return next, next_wsubj
+
+ _rx_quote = re.compile('=([A-F0-9][A-F0-9])')
+ _rx_softline = re.compile('=[ \t]*$')
+
+ def _get_body(self):
+ """Return the message body ready for HTML, decoded if necessary"""
+ try:
+ body = self.html_body
+ except AttributeError:
+ body = self.body
+ return null_to_space(EMPTYSTRING.join(body))
+
+ def _add_decoded(self, d):
+ """Add encoded-word keys to HTML output"""
+ for src, dst in (('author', 'author_html'),
+ ('email', 'email_html'),
+ ('subject', 'subject_html'),
+ ('subject', 'title')):
+ if self.decoded.has_key(src):
+ d[dst] = self.quote(self.decoded[src])
+
+ def as_text(self):
+ d = self.__dict__.copy()
+ # We need to guarantee a valid From_ line, even if there are
+ # bososities in the headers.
+ if not d.get('fromdate', '').strip():
+ d['fromdate'] = time.ctime(time.time())
+ if not d.get('email', '').strip():
+ d['email'] = 'bogus@does.not.exist.com'
+ if not d.get('datestr', '').strip():
+ d['datestr'] = time.ctime(time.time())
+ #
+ headers = ['From %(email)s %(fromdate)s',
+ 'From: %(email)s (%(author)s)',
+ 'Date: %(datestr)s',
+ 'Subject: %(subject)s']
+ if d['_in_reply_to']:
+ headers.append('In-Reply-To: %(_in_reply_to)s')
+ if d['_references']:
+ headers.append('References: %(_references)s')
+ if d['_message_id']:
+ headers.append('Message-ID: %(_message_id)s')
+ body = EMPTYSTRING.join(self.body)
+ if isinstance(body, types.UnicodeType):
+ body = body.encode(Utils.GetCharSet(self._lang), 'replace')
+ return NL.join(headers) % d + '\n\n' + body
+
+ def _set_date(self, message):
+ self.__super_set_date(message)
+ self.fromdate = time.ctime(int(self.date))
+
+ def loadbody_fromHTML(self,fileobj):
+ self.body = []
+ begin = 0
+ while 1:
+ line = fileobj.readline()
+ if not line:
+ break
+ if not begin:
+ if line.strip() == '<!--beginarticle-->':
+ begin = 1
+ continue
+ if line.strip() == '<!--endarticle-->':
+ break
+ self.body.append(line)
+
+
+
+class HyperArchive(pipermail.T):
+ __super_init = pipermail.T.__init__
+ __super_update_archive = pipermail.T.update_archive
+ __super_update_dirty_archives = pipermail.T.update_dirty_archives
+ __super_add_article = pipermail.T.add_article
+
+ # some defaults
+ DIRMODE = 02775
+ FILEMODE = 0660
+
+ VERBOSE = 0
+ DEFAULTINDEX = 'thread'
+ ARCHIVE_PERIOD = 'month'
+
+ THREADLAZY = 0
+ THREADLEVELS = 3
+
+ ALLOWHTML = 1 # "Lines between <html></html>" handled as is.
+ SHOWHTML = 0 # Eg, nuke leading whitespace in html manner.
+ IQUOTES = 1 # Italicize quoted text.
+ SHOWBR = 0 # Add <br> onto every line
+
+ def __init__(self, maillist):
+ # can't init the database while other processes are writing to it!
+ # XXX TODO- implement native locking
+ # with mailman's LockFile module for HyperDatabase.HyperDatabase
+ #
+ dir = maillist.archive_dir()
+ db = HyperDatabase.HyperDatabase(dir, maillist)
+ self.__super_init(dir, reload=1, database=db)
+
+ self.maillist = maillist
+ self._lock_file = None
+ self.lang = maillist.preferred_language
+ self.charset = Utils.GetCharSet(maillist.preferred_language)
+
+ if hasattr(self.maillist,'archive_volume_frequency'):
+ if self.maillist.archive_volume_frequency == 0:
+ self.ARCHIVE_PERIOD='year'
+ elif self.maillist.archive_volume_frequency == 2:
+ self.ARCHIVE_PERIOD='quarter'
+ elif self.maillist.archive_volume_frequency == 3:
+ self.ARCHIVE_PERIOD='week'
+ elif self.maillist.archive_volume_frequency == 4:
+ self.ARCHIVE_PERIOD='day'
+ else:
+ self.ARCHIVE_PERIOD='month'
+
+ yre = r'(?P<year>[0-9]{4,4})'
+ mre = r'(?P<month>[01][0-9])'
+ dre = r'(?P<day>[0123][0-9])'
+ self._volre = {
+ 'year': '^' + yre + '$',
+ 'quarter': '^' + yre + r'q(?P<quarter>[1234])$',
+ 'month': '^' + yre + r'-(?P<month>[a-zA-Z]+)$',
+ 'week': r'^Week-of-Mon-' + yre + mre + dre,
+ 'day': '^' + yre + mre + dre + '$'
+ }
+
+ def _makeArticle(self, msg, sequence):
+ return Article(msg, sequence,
+ lang=self.maillist.preferred_language,
+ mlist=self.maillist)
+
+ def html_foot(self):
+ # avoid i18n side-effects
+ mlist = self.maillist
+ otrans = i18n.get_translation()
+ i18n.set_language(mlist.preferred_language)
+ # Convenience
+ def quotetime(s):
+ return html_quote(i18n.ctime(s), self.lang)
+ try:
+ d = {"lastdate": quotetime(self.lastdate),
+ "archivedate": quotetime(self.archivedate),
+ "listinfo": mlist.GetScriptURL('listinfo', absolute=1),
+ "version": self.version,
+ }
+ i = {"thread": _("thread"),
+ "subject": _("subject"),
+ "author": _("author"),
+ "date": _("date")
+ }
+ finally:
+ i18n.set_translation(otrans)
+
+ for t in i.keys():
+ cap = t[0].upper() + t[1:]
+ if self.type == cap:
+ d["%s_ref" % (t)] = ""
+ else:
+ d["%s_ref" % (t)] = ('<a href="%s.html#start">[ %s ]</a>'
+ % (t, i[t]))
+ return quick_maketext(
+ 'archidxfoot.html', d,
+ mlist=mlist)
+
+ def html_head(self):
+ # avoid i18n side-effects
+ mlist = self.maillist
+ otrans = i18n.get_translation()
+ i18n.set_language(mlist.preferred_language)
+ # Convenience
+ def quotetime(s):
+ return html_quote(i18n.ctime(s), self.lang)
+ try:
+ d = {"listname": html_quote(mlist.real_name, self.lang),
+ "archtype": self.type,
+ "archive": self.volNameToDesc(self.archive),
+ "listinfo": mlist.GetScriptURL('listinfo', absolute=1),
+ "firstdate": quotetime(self.firstdate),
+ "lastdate": quotetime(self.lastdate),
+ "size": self.size,
+ }
+ i = {"thread": _("thread"),
+ "subject": _("subject"),
+ "author": _("author"),
+ "date": _("date"),
+ }
+ finally:
+ i18n.set_translation(otrans)
+
+ for t in i.keys():
+ cap = t[0].upper() + t[1:]
+ if self.type == cap:
+ d["%s_ref" % (t)] = ""
+ d["archtype"] = i[t]
+ else:
+ d["%s_ref" % (t)] = ('<a href="%s.html#start">[ %s ]</a>'
+ % (t, i[t]))
+ if self.charset:
+ d["encoding"] = html_charset % self.charset
+ else:
+ d["encoding"] = ""
+ return quick_maketext(
+ 'archidxhead.html', d,
+ mlist=mlist)
+
+ def html_TOC(self):
+ mlist = self.maillist
+ listname = mlist.internal_name()
+ mbox = os.path.join(mlist.archive_dir()+'.mbox', listname+'.mbox')
+ d = {"listname": mlist.real_name,
+ "listinfo": mlist.GetScriptURL('listinfo', absolute=1),
+ "fullarch": '../%s.mbox/%s.mbox' % (listname, listname),
+ "size": sizeof(mbox, mlist.preferred_language),
+ 'meta': '',
+ }
+ # Avoid i18n side-effects
+ otrans = i18n.get_translation()
+ i18n.set_language(mlist.preferred_language)
+ try:
+ if not self.archives:
+ d["noarchive_msg"] = _(
+ '<P>Currently, there are no archives. </P>')
+ d["archive_listing_start"] = ""
+ d["archive_listing_end"] = ""
+ d["archive_listing"] = ""
+ else:
+ d["noarchive_msg"] = ""
+ d["archive_listing_start"] = quick_maketext(
+ 'archliststart.html',
+ lang=mlist.preferred_language,
+ mlist=mlist)
+ d["archive_listing_end"] = quick_maketext(
+ 'archlistend.html',
+ mlist=mlist)
+
+ accum = []
+ for a in self.archives:
+ accum.append(self.html_TOC_entry(a))
+ d["archive_listing"] = EMPTYSTRING.join(accum)
+ finally:
+ i18n.set_translation(otrans)
+
+ # The TOC is always in the charset of the list's preferred language
+ d['meta'] += html_charset % Utils.GetCharSet(mlist.preferred_language)
+
+ return quick_maketext(
+ 'archtoc.html', d,
+ mlist=mlist)
+
+ def html_TOC_entry(self, arch):
+ # Check to see if the archive is gzip'd or not
+ txtfile = os.path.join(self.maillist.archive_dir(), arch + '.txt')
+ gzfile = txtfile + '.gz'
+ # which exists? .txt.gz first, then .txt
+ if os.path.exists(gzfile):
+ file = gzfile
+ url = arch + '.txt.gz'
+ templ = '<td><A href="%(url)s">[ ' + _('Gzip\'d Text%(sz)s') \
+ + ']</a></td>'
+ elif os.path.exists(txtfile):
+ file = txtfile
+ url = arch + '.txt'
+ templ = '<td><A href="%(url)s">[ ' + _('Text%(sz)s') + ']</a></td>'
+ else:
+ # neither found?
+ file = None
+ # in Python 1.5.2 we have an easy way to get the size
+ if file:
+ textlink = templ % {
+ 'url': url,
+ 'sz' : sizeof(file, self.maillist.preferred_language)
+ }
+ else:
+ # there's no archive file at all... hmmm.
+ textlink = ''
+ return quick_maketext(
+ 'archtocentry.html',
+ {'archive': arch,
+ 'archivelabel': self.volNameToDesc(arch),
+ 'textlink': textlink
+ },
+ mlist=self.maillist)
+
+ def GetArchLock(self):
+ if self._lock_file:
+ return 1
+ self._lock_file = LockFile.LockFile(
+ os.path.join(mm_cfg.LOCK_DIR,
+ self.maillist.internal_name() + '-arch.lock'))
+ try:
+ self._lock_file.lock(timeout=0.5)
+ except LockFile.TimeOutError:
+ return 0
+ return 1
+
+ def DropArchLock(self):
+ if self._lock_file:
+ self._lock_file.unlock(unconditionally=1)
+ self._lock_file = None
+
+ def processListArch(self):
+ name = self.maillist.ArchiveFileName()
+ wname= name+'.working'
+ ename= name+'.err_unarchived'
+ try:
+ os.stat(name)
+ except (IOError,os.error):
+ #no archive file, nothin to do -ddm
+ return
+
+ #see if arch is locked here -ddm
+ if not self.GetArchLock():
+ #another archiver is running, nothing to do. -ddm
+ return
+
+ #if the working file is still here, the archiver may have
+ # crashed during archiving. Save it, log an error, and move on.
+ try:
+ wf = open(wname)
+ syslog('error',
+ 'Archive working file %s present. '
+ 'Check %s for possibly unarchived msgs',
+ wname, ename)
+ omask = os.umask(007)
+ try:
+ ef = open(ename, 'a+')
+ finally:
+ os.umask(omask)
+ ef.seek(1,2)
+ if ef.read(1) <> '\n':
+ ef.write('\n')
+ ef.write(wf.read())
+ ef.close()
+ wf.close()
+ os.unlink(wname)
+ except IOError:
+ pass
+ os.rename(name,wname)
+ archfile = open(wname)
+ self.processUnixMailbox(archfile)
+ archfile.close()
+ os.unlink(wname)
+ self.DropArchLock()
+
+ def get_filename(self, article):
+ return '%06i.html' % (article.sequence,)
+
+ 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."""
+ res = self.dateToVolName(float(article.date))
+ self.message(_("figuring article archives\n"))
+ self.message(res + "\n")
+ return res
+
+ def volNameToDesc(self, volname):
+ volname = volname.strip()
+ # Don't make these module global constants since we have to runtime
+ # translate them anyway.
+ monthdict = [
+ '',
+ _('January'), _('February'), _('March'), _('April'),
+ _('May'), _('June'), _('July'), _('August'),
+ _('September'), _('October'), _('November'), _('December')
+ ]
+ for each in self._volre.keys():
+ match = re.match(self._volre[each], volname)
+ # Let ValueErrors percolate up
+ if match:
+ year = int(match.group('year'))
+ if each == 'quarter':
+ d =["", _("First"), _("Second"), _("Third"), _("Fourth") ]
+ ord = d[int(match.group('quarter'))]
+ return _("%(ord)s quarter %(year)i")
+ elif each == 'month':
+ monthstr = match.group('month').lower()
+ for i in range(1, 13):
+ monthname = time.strftime("%B", (1999,i,1,0,0,0,0,1,0))
+ if monthstr.lower() == monthname.lower():
+ month = monthdict[i]
+ return _("%(month)s %(year)i")
+ raise ValueError, "%s is not a month!" % monthstr
+ elif each == 'week':
+ month = monthdict[int(match.group("month"))]
+ day = int(match.group("day"))
+ return _("The Week Of Monday %(day)i %(month)s %(year)i")
+ elif each == 'day':
+ month = monthdict[int(match.group("month"))]
+ day = int(match.group("day"))
+ return _("%(day)i %(month)s %(year)i")
+ else:
+ return match.group('year')
+ raise ValueError, "%s is not a valid volname" % volname
+
+# The following two methods should be inverses of each other. -ddm
+
+ def dateToVolName(self,date):
+ datetuple=time.localtime(date)
+ if self.ARCHIVE_PERIOD=='year':
+ return time.strftime("%Y",datetuple)
+ elif self.ARCHIVE_PERIOD=='quarter':
+ if datetuple[1] in [1,2,3]:
+ return time.strftime("%Yq1",datetuple)
+ elif datetuple[1] in [4,5,6]:
+ return time.strftime("%Yq2",datetuple)
+ elif datetuple[1] in [7,8,9]:
+ return time.strftime("%Yq3",datetuple)
+ else:
+ return time.strftime("%Yq4",datetuple)
+ elif self.ARCHIVE_PERIOD == 'day':
+ return time.strftime("%Y%m%d", datetuple)
+ elif self.ARCHIVE_PERIOD == 'week':
+ # Reconstruct "seconds since epoch", and subtract weekday
+ # multiplied by the number of seconds in a day.
+ monday = time.mktime(datetuple) - datetuple[6] * 24 * 60 * 60
+ # Build a new datetuple from this "seconds since epoch" value
+ datetuple = time.localtime(monday)
+ return time.strftime("Week-of-Mon-%Y%m%d", datetuple)
+ # month. -ddm
+ else:
+ return time.strftime("%Y-%B",datetuple)
+
+
+ def volNameToDate(self,volname):
+ volname = volname.strip()
+ for each in self._volre.keys():
+ match=re.match(self._volre[each],volname)
+ if match:
+ year=int(match.group('year'))
+ month=1
+ day = 1
+ if each == 'quarter':
+ q=int(match.group('quarter'))
+ month=(q*3)-2
+ elif each == 'month':
+ monthstr=match.group('month').lower()
+ m=[]
+ for i in range(1,13):
+ m.append(
+ time.strftime("%B",(1999,i,1,0,0,0,0,1,0)).lower())
+ try:
+ month=m.index(monthstr)+1
+ except ValueError:
+ pass
+ elif each == 'week' or each == 'day':
+ month = int(match.group("month"))
+ day = int(match.group("day"))
+ return time.mktime((year,month,1,0,0,0,0,1,-1))
+ return 0.0
+
+ def sortarchives(self):
+ def sf(a,b,s=self):
+ al=s.volNameToDate(a)
+ bl=s.volNameToDate(b)
+ if al>bl:
+ return 1
+ elif al<bl:
+ return -1
+ else:
+ return 0
+ if self.ARCHIVE_PERIOD in ('month','year','quarter'):
+ self.archives.sort(sf)
+ else:
+ self.archives.sort()
+ self.archives.reverse()
+
+ def message(self, msg):
+ if self.VERBOSE:
+ f = sys.stderr
+ f.write(msg)
+ if msg[-1:] != '\n':
+ f.write('\n')
+ f.flush()
+
+ def open_new_archive(self, archive, archivedir):
+ index_html = os.path.join(archivedir, 'index.html')
+ try:
+ os.unlink(index_html)
+ except:
+ pass
+ os.symlink(self.DEFAULTINDEX+'.html',index_html)
+
+ def write_index_header(self):
+ self.depth=0
+ print self.html_head()
+ if not self.THREADLAZY and self.type=='Thread':
+ self.message(_("Computing threaded index\n"))
+ self.updateThreadedIndex()
+
+ def write_index_footer(self):
+ for i in range(self.depth):
+ print '</UL>'
+ print self.html_foot()
+
+ def write_index_entry(self, article):
+ subject = self.get_header("subject", article)
+ author = self.get_header("author", article)
+ if mm_cfg.ARCHIVER_OBSCURES_EMAILADDRS:
+ author = re.sub('@', _(' at '), author)
+ subject = CGIescape(subject, self.lang)
+ author = CGIescape(author, self.lang)
+
+ d = {
+ 'filename': urllib.quote(article.filename),
+ 'subject': subject,
+ 'sequence': article.sequence,
+ 'author': author
+ }
+ print quick_maketext(
+ 'archidxentry.html', d,
+ mlist=self.maillist)
+
+ def get_header(self, field, article):
+ # if we have no decoded header, return the encoded one
+ result = article.decoded.get(field)
+ if result is None:
+ return getattr(article, field)
+ # otherwise, the decoded one will be Unicode
+ return result
+
+ def write_threadindex_entry(self, article, depth):
+ if depth < 0:
+ self.message('depth<0')
+ depth = 0
+ if depth > self.THREADLEVELS:
+ depth = self.THREADLEVELS
+ if depth < self.depth:
+ for i in range(self.depth-depth):
+ print '</UL>'
+ elif depth > self.depth:
+ for i in range(depth-self.depth):
+ print '<UL>'
+ print '<!--%i %s -->' % (depth, article.threadKey)
+ self.depth = depth
+ self.write_index_entry(article)
+
+ def write_TOC(self):
+ self.sortarchives()
+ omask = os.umask(002)
+ try:
+ toc = open(os.path.join(self.basedir, 'index.html'), 'w')
+ finally:
+ os.umask(omask)
+ toc.write(self.html_TOC())
+ toc.close()
+
+ def write_article(self, index, article, path):
+ # called by add_article
+ omask = os.umask(002)
+ try:
+ f = open(path, 'w')
+ finally:
+ os.umask(omask)
+ f.write(article.as_html())
+ f.close()
+
+ # Write the text article to the text archive.
+ path = os.path.join(self.basedir, "%s.txt" % index)
+ omask = os.umask(002)
+ try:
+ f = open(path, 'a+')
+ finally:
+ os.umask(omask)
+ f.write(article.as_text())
+ f.close()
+
+ def update_archive(self, archive):
+ self.__super_update_archive(archive)
+ # only do this if the gzip module was imported globally, and
+ # gzip'ing was enabled via mm_cfg.GZIP_ARCHIVE_TXT_FILES. See
+ # above.
+ if gzip:
+ archz = None
+ archt = None
+ txtfile = os.path.join(self.basedir, '%s.txt' % archive)
+ gzipfile = os.path.join(self.basedir, '%s.txt.gz' % archive)
+ oldgzip = os.path.join(self.basedir, '%s.old.txt.gz' % archive)
+ try:
+ # open the plain text file
+ archt = open(txtfile)
+ except IOError:
+ return
+ try:
+ os.rename(gzipfile, oldgzip)
+ archz = gzip.open(oldgzip)
+ except (IOError, RuntimeError, os.error):
+ pass
+ try:
+ ou = os.umask(002)
+ newz = gzip.open(gzipfile, 'w')
+ finally:
+ # XXX why is this a finally?
+ os.umask(ou)
+ if archz:
+ newz.write(archz.read())
+ archz.close()
+ os.unlink(oldgzip)
+ # XXX do we really need all this in a try/except?
+ try:
+ newz.write(archt.read())
+ newz.close()
+ archt.close()
+ except IOError:
+ pass
+ os.unlink(txtfile)
+
+ _skip_attrs = ('maillist', '_lock_file', 'charset')
+
+ def getstate(self):
+ d={}
+ for each in self.__dict__.keys():
+ if not (each in self._skip_attrs
+ or each.upper() == each):
+ d[each] = self.__dict__[each]
+ return d
+
+ # Add <A HREF="..."> tags around URLs and e-mail addresses.
+
+ def __processbody_URLquote(self, lines):
+ # XXX a lot to do here:
+ # 1. use lines directly, rather than source and dest
+ # 2. make it clearer
+ # 3. make it faster
+ source = lines[:]
+ dest = lines
+ last_line_was_quoted = 0
+ for i in xrange(0, len(source)):
+ Lorig = L = source[i]
+ prefix = suffix = ""
+ if L is None:
+ continue
+ # Italicise quoted text
+ if self.IQUOTES:
+ quoted = quotedpat.match(L)
+ if quoted is None:
+ last_line_was_quoted = 0
+ else:
+ quoted = quoted.end(0)
+ prefix = CGIescape(L[:quoted], self.lang) + '<i>'
+ suffix = '</I>'
+ if self.SHOWHTML:
+ suffix += '<BR>'
+ if not last_line_was_quoted:
+ prefix = '<BR>' + prefix
+ L = L[quoted:]
+ last_line_was_quoted = 1
+ # Check for an e-mail address
+ L2 = ""
+ jr = emailpat.search(L)
+ kr = urlpat.search(L)
+ while jr is not None or kr is not None:
+ if jr == None:
+ j = -1
+ else:
+ j = jr.start(0)
+ if kr is None:
+ k = -1
+ else:
+ k = kr.start(0)
+ if j != -1 and (j < k or k == -1):
+ text = jr.group(1)
+ length = len(text)
+ if mm_cfg.ARCHIVER_OBSCURES_EMAILADDRS:
+ text = re.sub('@', _(' at '), text)
+ URL = self.maillist.GetScriptURL(
+ 'listinfo', absolute=1)
+ else:
+ URL = 'mailto:' + text
+ pos = j
+ elif k != -1 and (j > k or j == -1):
+ text = URL = kr.group(1)
+ length = len(text)
+ pos = k
+ else: # j==k
+ raise ValueError, "j==k: This can't happen!"
+ #length = len(text)
+ #self.message("URL: %s %s %s \n"
+ # % (CGIescape(L[:pos]), URL, CGIescape(text)))
+ L2 += '%s<A HREF="%s">%s</A>' % (
+ CGIescape(L[:pos], self.lang),
+ html_quote(URL), CGIescape(text, self.lang))
+ L = L[pos+length:]
+ jr = emailpat.search(L)
+ kr = urlpat.search(L)
+ if jr is None and kr is None:
+ L = CGIescape(L, self.lang)
+ L = prefix + L2 + L + suffix
+ source[i] = None
+ dest[i] = L
+
+ # Perform Hypermail-style processing of <HTML></HTML> directives
+ # in message bodies. Lines between <HTML> and </HTML> will be written
+ # out precisely as they are; other lines will be passed to func2
+ # for further processing .
+
+ def __processbody_HTML(self, lines):
+ # XXX need to make this method modify in place
+ source = lines[:]
+ dest = lines
+ l = len(source)
+ i = 0
+ while i < l:
+ while i < l and htmlpat.match(source[i]) is None:
+ i = i + 1
+ if i < l:
+ source[i] = None
+ i = i + 1
+ while i < l and nohtmlpat.match(source[i]) is None:
+ dest[i], source[i] = source[i], None
+ i = i + 1
+ if i < l:
+ source[i] = None
+ i = i + 1
+
+ def format_article(self, article):
+ # called from add_article
+ # TBD: Why do the HTML formatting here and keep it in the
+ # pipermail database? It makes more sense to do the html
+ # formatting as the article is being written as html and toss
+ # the data after it has been written to the archive file.
+ lines = filter(None, article.body)
+ # Handle <HTML> </HTML> directives
+ if self.ALLOWHTML:
+ self.__processbody_HTML(lines)
+ self.__processbody_URLquote(lines)
+ if not self.SHOWHTML and lines:
+ lines.insert(0, '<PRE>')
+ lines.append('</PRE>')
+ else:
+ # Do fancy formatting here
+ if self.SHOWBR:
+ lines = map(lambda x:x + "<BR>", lines)
+ else:
+ for i in range(0, len(lines)):
+ s = lines[i]
+ if s[0:1] in ' \t\n':
+ lines[i] = '<P>' + 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/Mailman/Archiver/HyperDatabase.py b/Mailman/Archiver/HyperDatabase.py
new file mode 100644
index 00000000..ab41b824
--- /dev/null
+++ b/Mailman/Archiver/HyperDatabase.py
@@ -0,0 +1,338 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+#
+# site modules
+#
+import os
+import marshal
+import time
+import errno
+
+#
+# package/project modules
+#
+import pipermail
+from Mailman 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.LockFile(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/Mailman/Archiver/Makefile.in b/Mailman/Archiver/Makefile.in
new file mode 100644
index 00000000..fe56149d
--- /dev/null
+++ b/Mailman/Archiver/Makefile.in
@@ -0,0 +1,72 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman/Archiver
+SHELL= /bin/sh
+
+MODULES= __init__.py Archiver.py HyperArch.py HyperDatabase.py \
+pipermail.py
+
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+
+install:
+ for f in $(MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \
+ done
+
+finish:
+
+clean:
+
+distclean:
+ -rm *.pyc
+ -rm Makefile
+
diff --git a/Mailman/Archiver/__init__.py b/Mailman/Archiver/__init__.py
new file mode 100644
index 00000000..65ad7be7
--- /dev/null
+++ b/Mailman/Archiver/__init__.py
@@ -0,0 +1,17 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+from Archiver import *
diff --git a/Mailman/Archiver/pipermail.py b/Mailman/Archiver/pipermail.py
new file mode 100644
index 00000000..2e1b226d
--- /dev/null
+++ b/Mailman/Archiver/pipermail.py
@@ -0,0 +1,854 @@
+#! /usr/bin/env python
+
+from __future__ import nested_scopes
+
+import mailbox
+import os
+import re
+import sys
+import time
+from email.Utils import parseaddr, parsedate_tz
+import cPickle as pickle
+from cStringIO import StringIO
+from string import lowercase
+
+__version__ = '0.09 (Mailman edition)'
+VERSION = __version__
+CACHESIZE = 100 # Number of slots in the cache
+
+from Mailman import Errors
+from Mailman.Mailbox import ArchiverMailbox
+from Mailman.Logging.Syslog import syslog
+from Mailman.i18n import _
+
+SPACE = ' '
+
+
+
+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
+ article.body = []
+ self.articleIndex[article.msgid] = pickle.dumps(article)
+ article.body = temp
+
+# 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())
+ 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 time.mktime(date[:9])
+ except (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
+
+ def __repr__(self):
+ return '<Article ID = '+repr(self.msgid)+'>'
+
+# 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]
+ 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 <input>,
+ # 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:
+ syslog('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))
+
+ author = fixAuthor(article.author)
+ 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/Mailman/Autoresponder.py b/Mailman/Autoresponder.py
new file mode 100644
index 00000000..c568ec06
--- /dev/null
+++ b/Mailman/Autoresponder.py
@@ -0,0 +1,43 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""MailList mixin class managing the autoresponder.
+"""
+
+from Mailman import mm_cfg
+from Mailman.i18n import _
+
+
+
+class Autoresponder:
+ def InitVars(self):
+ # configurable
+ self.autorespond_postings = 0
+ self.autorespond_admin = 0
+ # this value can be
+ # 0 - no autoresponse on the -request line
+ # 1 - autorespond, but discard the original message
+ # 2 - autorespond, and forward the message on to be processed
+ self.autorespond_requests = 0
+ self.autoresponse_postings_text = ''
+ self.autoresponse_admin_text = ''
+ self.autoresponse_request_text = ''
+ self.autoresponse_graceperiod = 90 # days
+ # non-configurable
+ self.postings_responses = {}
+ self.admin_responses = {}
+ self.request_responses = {}
+
diff --git a/Mailman/Bouncer.py b/Mailman/Bouncer.py
new file mode 100644
index 00000000..34bd21d4
--- /dev/null
+++ b/Mailman/Bouncer.py
@@ -0,0 +1,281 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Handle delivery bounces.
+"""
+
+import sys
+import time
+from types import StringType
+
+from email.MIMEText import MIMEText
+from email.MIMEMessage import MIMEMessage
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman import MemberAdaptor
+from Mailman import Pending
+from Mailman.Logging.Syslog import syslog
+from Mailman import i18n
+
+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(mm_cfg.days(1))[:3]
+
+def _(s): return s
+
+REASONS = {MemberAdaptor.BYBOUNCE: _('due to excessive bounces'),
+ MemberAdaptor.BYUSER: _('by yourself'),
+ MemberAdaptor.BYADMIN: _('by the list administrator'),
+ MemberAdaptor.UNKNOWN: _('for unknown reasons'),
+ }
+
+_ = i18n._
+
+
+
+class _BounceInfo:
+ def __init__(self, member, score, date, noticesleft, cookie):
+ self.member = member
+ self.cookie = cookie
+ 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 """\
+<bounce info for member %(member)s
+ current score: %(score)s
+ last bounce date: %(date)s
+ email notices left: %(noticesleft)s
+ last notice date: %(lastnotice)s
+ confirmation cookie: %(cookie)s
+ >""" % self.__dict__
+
+
+
+class Bouncer:
+ def InitVars(self):
+ # Configurable...
+ self.bounce_processing = mm_cfg.DEFAULT_BOUNCE_PROCESSING
+ self.bounce_score_threshold = mm_cfg.DEFAULT_BOUNCE_SCORE_THRESHOLD
+ self.bounce_info_stale_after = mm_cfg.DEFAULT_BOUNCE_INFO_STALE_AFTER
+ self.bounce_you_are_disabled_warnings = \
+ mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS
+ self.bounce_you_are_disabled_warnings_interval = \
+ mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS_INTERVAL
+ self.bounce_unrecognized_goes_to_list_owner = \
+ mm_cfg.DEFAULT_BOUNCE_UNRECOGNIZED_GOES_TO_LIST_OWNER
+ self.bounce_notify_owner_on_disable = \
+ mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_DISABLE
+ self.bounce_notify_owner_on_removal = \
+ mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_REMOVAL
+ # Not configurable...
+ #
+ # This holds legacy member related information. It's keyed by the
+ # member address, and the value is an object containing the bounce
+ # score, the date of the last received bounce, and a count of the
+ # notifications left to send.
+ self.bounce_info = {}
+ # New style delivery status
+ self.delivery_status = {}
+
+ def registerBounce(self, member, msg, weight=1.0):
+ if not self.isMember(member):
+ return
+ info = self.getBounceInfo(member)
+ today = time.localtime()[:3]
+ if not isinstance(info, _BounceInfo):
+ # This is the first bounce we've seen from this member
+ cookie = Pending.new(Pending.RE_ENABLE, self.internal_name(),
+ member)
+ info = _BounceInfo(member, weight, today,
+ self.bounce_you_are_disabled_warnings,
+ cookie)
+ self.setBounceInfo(member, info)
+ syslog('bounce', '%s: %s bounce score: %s', self.internal_name(),
+ member, info.score)
+ # Continue to the check phase below
+ elif self.getDeliveryStatus(member) <> MemberAdaptor.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.
+ syslog('bounce', '%s: %s residual bounce received',
+ self.internal_name(), member)
+ return
+ elif info.date == today:
+ # We've already scored any bounces for today, so ignore this one.
+ syslog('bounce', '%s: %s already scored a bounce for today',
+ self.internal_name(), member)
+ # Continue to check phase below
+ else:
+ # See if this member's bounce information is stale.
+ now = Utils.midnight(today)
+ lastbounce = Utils.midnight(info.date)
+ if lastbounce + self.bounce_info_stale_after < now:
+ # Information is stale, so simply reset it
+ info.reset(weight, today,
+ self.bounce_you_are_disabled_warnings)
+ syslog('bounce', '%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 = today
+ syslog('bounce', '%s: %s current bounce score: %s',
+ member, self.internal_name(), 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:
+ self.disableBouncingMember(member, info, msg)
+
+ def disableBouncingMember(self, member, info, msg):
+ # Disable them
+ syslog('bounce', '%s: %s disabling due to bounce score %s >= %s',
+ self.internal_name(), member,
+ info.score, self.bounce_score_threshold)
+ self.setDeliveryStatus(member, MemberAdaptor.BYBOUNCE)
+ 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.
+ siteowner = Utils.get_site_email(self.host_name)
+ text = Utils.maketext(
+ 'bounce.txt',
+ {'listname' : self.real_name,
+ 'addr' : member,
+ 'negative' : '',
+ 'did' : _('disabled'),
+ 'but' : '',
+ 'reenable' : '',
+ 'owneraddr': siteowner,
+ }, mlist=self)
+ subject = _('Bounce action notification')
+ umsg = Message.UserNotification(self.GetOwnerEmail(),
+ siteowner, 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, StringType):
+ 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.
+ Pending.confirm(info.cookie)
+ if reason == MemberAdaptor.BYBOUNCE:
+ syslog('bounce', '%s: %s deleted after exhausting notices',
+ self.internal_name(), member)
+ syslog('subscribe', '%s: %s auto-unsubscribed [reason: %s]',
+ self.internal_name(), member,
+ {MemberAdaptor.BYBOUNCE: 'BYBOUNCE',
+ MemberAdaptor.BYUSER: 'BYUSER',
+ MemberAdaptor.BYADMIN: 'BYADMIN',
+ MemberAdaptor.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 == MemberAdaptor.BYBOUNCE:
+ 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]
+
+ def BounceMessage(self, msg, msgdata, 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)'))
+ if e is None:
+ notice = _('[No bounce details are available]')
+ else:
+ notice = _(e.notice())
+ # Currently we always craft bounces as MIME messages.
+ bmsg = Message.UserNotification(msg.get_sender(),
+ self.GetOwnerEmail(),
+ subject,
+ lang=self.preferred_language)
+ # BAW: Be sure you set the type before trying to attach, or you'll get
+ # a MultipartConversionError.
+ bmsg.set_type('multipart/mixed')
+ txt = MIMEText(notice,
+ _charset=Utils.GetCharSet(self.preferred_language))
+ bmsg.attach(txt)
+ bmsg.attach(MIMEMessage(msg))
+ bmsg.send(self)
diff --git a/Mailman/Bouncers/.cvsignore b/Mailman/Bouncers/.cvsignore
new file mode 100644
index 00000000..f3c7a7c5
--- /dev/null
+++ b/Mailman/Bouncers/.cvsignore
@@ -0,0 +1 @@
+Makefile
diff --git a/Mailman/Bouncers/BouncerAPI.py b/Mailman/Bouncers/BouncerAPI.py
new file mode 100644
index 00000000..e8994145
--- /dev/null
+++ b/Mailman/Bouncers/BouncerAPI.py
@@ -0,0 +1,71 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""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
+
+from Mailman.Logging.Syslog import syslog
+
+# 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.
+class _Stop:
+ pass
+Stop = _Stop()
+
+
+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 is Stop:
+ # One of the detectors recognized the bounce, but there were no
+ # addresses to extract. Return the empty list.
+ return []
+ elif addrs:
+ return addrs
+ return []
diff --git a/Mailman/Bouncers/Caiwireless.py b/Mailman/Bouncers/Caiwireless.py
new file mode 100644
index 00000000..0e3e71fc
--- /dev/null
+++ b/Mailman/Bouncers/Caiwireless.py
@@ -0,0 +1,45 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Parse mystery style generated by MTA at caiwireless.net."""
+
+import re
+import email
+from cStringIO import StringIO
+
+tcre = re.compile(r'the following recipients did not receive this message:',
+ re.IGNORECASE)
+acre = re.compile(r'<(?P<addr>[^>]*)>')
+
+
+
+def process(msg):
+ if msg.get_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/Mailman/Bouncers/Compuserve.py b/Mailman/Bouncers/Compuserve.py
new file mode 100644
index 00000000..516c2237
--- /dev/null
+++ b/Mailman/Bouncers/Compuserve.py
@@ -0,0 +1,45 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""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<addr>.*)')
+
+
+
+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/Mailman/Bouncers/DSN.py b/Mailman/Bouncers/DSN.py
new file mode 100644
index 00000000..3e040bef
--- /dev/null
+++ b/Mailman/Bouncers/DSN.py
@@ -0,0 +1,79 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Parse RFC 1894 (i.e. DSN) bounce formats."""
+
+from email.Iterators import typed_subpart_iterator
+from email.Utils import parseaddr
+from cStringIO import StringIO
+
+
+
+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', '')
+ # BAW: Should we treat delayed bounces the same? Yes, because if
+ # the transient problem clears up, they should get unbounced. The
+ # other problem is what to do about a DSN that has both delayed
+ # and failed actions in multiple header blocks? We're not
+ # architected to handle that. ;/
+ if action.lower() not in ('failed', 'failure', 'delayed'):
+ # Some non-permanent failure, so ignore this block
+ continue
+ params = []
+ foundp = 0
+ for header in ('original-recipient', 'final-recipient'):
+ for k, v in msgblock.get_params([], header):
+ if k.lower() == 'rfc822':
+ foundp = 1
+ else:
+ params.append(k)
+ if foundp:
+ # Note that params should already be unquoted.
+ addrs.extend(params)
+ break
+ # Uniquify
+ rtnaddrs = {}
+ for a in addrs:
+ if a is not None:
+ realname, a = parseaddr(a)
+ rtnaddrs[a] = 1
+ return rtnaddrs.keys()
+
+
+
+def process(msg):
+ # 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_subtype() <> 'report':
+ return None
+ return check(msg)
diff --git a/Mailman/Bouncers/Exchange.py b/Mailman/Bouncers/Exchange.py
new file mode 100644
index 00000000..1f73aeb1
--- /dev/null
+++ b/Mailman/Bouncers/Exchange.py
@@ -0,0 +1,47 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Recognizes (some) Microsoft Exchange formats."""
+
+import re
+import email.Iterators
+
+scre = re.compile('did not reach the following recipient')
+ecre = re.compile('MSEXCH:')
+a1cre = re.compile('SMTP=(?P<addr>[^;]+); on ')
+a2cre = re.compile('(?P<addr>[^ ]+) on ')
+
+
+
+def process(msg):
+ addrs = {}
+ it = email.Iterators.body_line_iterator(msg)
+ # Find the start line
+ for line in it:
+ if scre.search(line):
+ break
+ else:
+ return []
+ # Search each line until we hit the end line
+ for line in it:
+ if ecre.search(line):
+ break
+ mo = a1cre.search(line)
+ if not mo:
+ mo = a2cre.search(line)
+ if mo:
+ addrs[mo.group('addr')] = 1
+ return addrs.keys()
diff --git a/Mailman/Bouncers/Exim.py b/Mailman/Bouncers/Exim.py
new file mode 100644
index 00000000..1f03df2d
--- /dev/null
+++ b/Mailman/Bouncers/Exim.py
@@ -0,0 +1,30 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""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/Mailman/Bouncers/GroupWise.py b/Mailman/Bouncers/GroupWise.py
new file mode 100644
index 00000000..8bde4405
--- /dev/null
+++ b/Mailman/Bouncers/GroupWise.py
@@ -0,0 +1,70 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""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<addr>[^>]*)>')
+
+
+
+def find_textplain(msg):
+ if msg.get_type(msg.get_default_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_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/Mailman/Bouncers/LLNL.py b/Mailman/Bouncers/LLNL.py
new file mode 100644
index 00000000..faadb0b9
--- /dev/null
+++ b/Mailman/Bouncers/LLNL.py
@@ -0,0 +1,31 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""LLNL's custom Sendmail bounce message."""
+
+import re
+import email
+
+acre = re.compile(r',\s*(?P<addr>\S+@[^,]+),', re.IGNORECASE)
+
+
+
+def process(msg):
+ for line in email.Iterators.body_line_iterator(msg):
+ mo = acre.search(line)
+ if mo:
+ return [mo.group('addr')]
+ return []
diff --git a/Mailman/Bouncers/Makefile.in b/Mailman/Bouncers/Makefile.in
new file mode 100644
index 00000000..d4c9dfca
--- /dev/null
+++ b/Mailman/Bouncers/Makefile.in
@@ -0,0 +1,74 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman/Bouncers
+SHELL= /bin/sh
+
+MODULES= *.py
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+
+install:
+ for f in $(MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \
+ done
+
+finish:
+
+clean:
+
+distclean:
+ -rm *.pyc
+ -rm Makefile
+
+
+# Local Variables:
+# indent-tabs-mode: t
+# End:
diff --git a/Mailman/Bouncers/Microsoft.py b/Mailman/Bouncers/Microsoft.py
new file mode 100644
index 00000000..65d49cc1
--- /dev/null
+++ b/Mailman/Bouncers/Microsoft.py
@@ -0,0 +1,48 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""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_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
+ body = StringIO(subpart.get_payload())
+ 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/Mailman/Bouncers/Netscape.py b/Mailman/Bouncers/Netscape.py
new file mode 100644
index 00000000..21aea7c5
--- /dev/null
+++ b/Mailman/Bouncers/Netscape.py
@@ -0,0 +1,88 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Netscape Messaging Server bounce formats.
+
+I've seen at least one NMS server version 3.6 (envy.gmp.usyd.edu.au) bounce
+messages of this format. Bounces come in DSN MIME format, but don't include
+any -Recipient: headers. Gotta just parse the text :(
+
+NMS 4.1 (dfw-smtpin1.email.verio.net) seems even worse, but we'll try to
+decipher the format here too.
+
+"""
+
+import re
+from cStringIO import StringIO
+
+pcre = re.compile(
+ r'This Message was undeliverable due to the following reason:',
+ re.IGNORECASE)
+
+acre = re.compile(
+ r'(?P<reply>please reply to)?.*<(?P<addr>[^>]*)>',
+ re.IGNORECASE)
+
+
+
+def flatten(msg, leaves):
+ # give us all the leaf (non-multipart) subparts
+ if msg.is_multipart():
+ for part in msg.get_payload():
+ flatten(part, leaves)
+ else:
+ leaves.append(msg)
+
+
+
+def process(msg):
+ # Sigh. Some show NMS 3.6's show
+ # multipart/report; report-type=delivery-status
+ # and some show
+ # multipart/mixed;
+ if not msg.is_multipart():
+ return None
+ # We're looking for a text/plain subpart occuring before a
+ # message/delivery-status subpart.
+ plainmsg = None
+ leaves = []
+ flatten(msg, leaves)
+ for i, subpart in zip(range(len(leaves)-1), leaves):
+ if subpart.get_type() == 'text/plain':
+ plainmsg = subpart
+ break
+ if not plainmsg:
+ return None
+ # Total guesswork, based on captured examples...
+ body = StringIO(plainmsg.get_payload())
+ addrs = []
+ while 1:
+ line = body.readline()
+ if not line:
+ break
+ mo = pcre.search(line)
+ if mo:
+ # We found a bounce section, but I have no idea what the official
+ # format inside here is. :( We'll just search for <addr>
+ # strings.
+ while 1:
+ line = body.readline()
+ if not line:
+ break
+ mo = acre.search(line)
+ if mo and not mo.group('reply'):
+ addrs.append(mo.group('addr'))
+ return addrs
diff --git a/Mailman/Bouncers/Postfix.py b/Mailman/Bouncers/Postfix.py
new file mode 100644
index 00000000..fb1a1233
--- /dev/null
+++ b/Mailman/Bouncers/Postfix.py
@@ -0,0 +1,86 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""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'.
+/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)', re.IGNORECASE)
+rcre = re.compile(r'failure reason:$', re.IGNORECASE)
+acre = re.compile(r'<(?P<addr>[^>]*)>:')
+
+def findaddr(msg):
+ addrs = []
+ body = StringIO(msg.get_payload())
+ # simple state machine
+ # 0 == nothing found
+ # 1 == salutation found
+ state = 0
+ while 1:
+ line = body.readline()
+ if not line:
+ break
+ # preserve leading whitespace
+ line = line.rstrip()
+ # yes use match to match at beginning of string
+ if state == 0 and (pcre.match(line) or rcre.match(line)):
+ state = 1
+ elif state == 1 and line:
+ mo = acre.search(line)
+ if mo:
+ addrs.append(mo.group('addr'))
+ # probably a continuation line
+ return addrs
+
+
+
+def process(msg):
+ if msg.get_type() <> 'multipart/mixed':
+ 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_type() == 'text/plain' and \
+ subpart.get('content-description', '').lower() == 'notification':
+ # then...
+ return findaddr(subpart)
+ return None
diff --git a/Mailman/Bouncers/Qmail.py b/Mailman/Bouncers/Qmail.py
new file mode 100644
index 00000000..d6a3e3c3
--- /dev/null
+++ b/Mailman/Bouncers/Qmail.py
@@ -0,0 +1,61 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""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
+
+introtag = 'Hi. This is the'
+acre = re.compile(r'<(?P<addr>[^>]*)>:')
+
+
+
+def process(msg):
+ addrs = []
+ # simple state machine
+ # 0 = nothing seen yet
+ # 1 = intro paragraph seen
+ # 2 = recip paragraphs seen
+ state = 0
+ for line in email.Iterators.body_line_iterator(msg):
+ line = line.strip()
+ if state == 0 and line.startswith(introtag):
+ state = 1
+ 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/Mailman/Bouncers/SMTP32.py b/Mailman/Bouncers/SMTP32.py
new file mode 100644
index 00000000..62982461
--- /dev/null
+++ b/Mailman/Bouncers/SMTP32.py
@@ -0,0 +1,57 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Something which claims
+X-Mailer: <SMTP32 vXXXXXX>
+
+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...
+ |undeliverable\ to
+ )
+ \s* # space separator
+ (?P<addr>.*) # and finally, the address
+ ''', re.IGNORECASE | re.VERBOSE)
+
+
+
+def process(msg):
+ mailer = msg.get('x-mailer', '')
+ if not mailer.startswith('<SMTP32 v'):
+ return
+ addrs = {}
+ for line in email.Iterators.body_line_iterator(msg):
+ if ecre.search(line):
+ break
+ mo = acre.search(line)
+ if mo:
+ addrs[mo.group('addr')] = 1
+ return addrs.keys()
diff --git a/Mailman/Bouncers/SimpleMatch.py b/Mailman/Bouncers/SimpleMatch.py
new file mode 100644
index 00000000..ccc8d6ed
--- /dev/null
+++ b/Mailman/Bouncers/SimpleMatch.py
@@ -0,0 +1,100 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""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<addr>[^>]*)>')),
+ # sz-sb.de, corridor.com, nfg.nl
+ (_c('the following addresses had'),
+ _c('transcript of session follows'),
+ _c(r'<(?P<fulladdr>[^>]*)>|\(expanded from: <?(?P<addr>[^>)]*)>?\)')),
+ # robanal.demon.co.uk
+ (_c('this message was created automatically by mail delivery software'),
+ _c('original message follows'),
+ _c('rcpt to:\s*<(?P<addr>[^>]*)>')),
+ # s1.com (InterScan E-Mail VirusWall NT ???)
+ (_c('message from interscan e-mail viruswall nt'),
+ _c('end of message'),
+ _c('rcpt to:\s*<(?P<addr>[^>]*)>')),
+ # Smail
+ (_c('failed addresses follow:'),
+ _c('message text follows:'),
+ _c(r'\s*(?P<addr>\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<addr>[^>]*)>')),
+ # 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<addr>.*)$')),
+ # usa.net
+ (_c('Intended recipient:\s*(?P<addr>.*)$'),
+ _c('--------RETURNED MAIL FOLLOWS--------'),
+ _c('Intended recipient:\s*(?P<addr>.*)$')),
+ # hotpop.com
+ (_c('Undeliverable Address:\s*(?P<addr>.*)$'),
+ _c('Original message attached'),
+ _c('Undeliverable Address:\s*(?P<addr>.*)$')),
+ # 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 = {}
+ state = 0
+ for line in email.Iterators.body_line_iterator(msg):
+ if state == 0:
+ for scre, ecre, acre in patterns:
+ if scre.search(line):
+ state = 1
+ break
+ 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
+ return addrs.keys()
diff --git a/Mailman/Bouncers/SimpleWarning.py b/Mailman/Bouncers/SimpleWarning.py
new file mode 100644
index 00000000..bc515515
--- /dev/null
+++ b/Mailman/Bouncers/SimpleWarning.py
@@ -0,0 +1,44 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Recognizes simple heuristically delimited warnings."""
+
+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<addr>\S+@\S+)\s*')),
+ # Next one goes here...
+ ]
+
+
+
+def process(msg):
+ return _process(msg, patterns)
diff --git a/Mailman/Bouncers/Sina.py b/Mailman/Bouncers/Sina.py
new file mode 100644
index 00000000..2cc2e69b
--- /dev/null
+++ b/Mailman/Bouncers/Sina.py
@@ -0,0 +1,47 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""sina.com bounces"""
+
+import re
+from email import Iterators
+
+acre = re.compile(r'<(?P<addr>[^>]*)>')
+
+
+
+def process(msg):
+ if msg.get('from', '').lower() <> 'mailer-daemon@sina.com':
+ print 'out 1'
+ return []
+ if not msg.is_multipart():
+ print 'out 2'
+ return []
+ # The interesting bits are in the first text/plain multipart
+ part = None
+ try:
+ part = msg.get_payload(0)
+ except IndexError:
+ pass
+ if not part:
+ print 'out 3'
+ return []
+ addrs = {}
+ for line in Iterators.body_line_iterator(part):
+ mo = acre.match(line)
+ if mo:
+ addrs[mo.group('addr')] = 1
+ return addrs.keys()
diff --git a/Mailman/Bouncers/Yahoo.py b/Mailman/Bouncers/Yahoo.py
new file mode 100644
index 00000000..fd952915
--- /dev/null
+++ b/Mailman/Bouncers/Yahoo.py
@@ -0,0 +1,53 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Yahoo! has its own weird format for bounces."""
+
+import re
+import email
+from email.Utils import parseaddr
+
+tcre = re.compile(r'message\s+from\s+yahoo\.\S+', re.IGNORECASE)
+acre = re.compile(r'<(?P<addr>[^>]*)>:')
+ecre = re.compile(r'--- Original message follows')
+
+
+
+def process(msg):
+ # Yahoo! bounces seem to have a known subject value and something called
+ # an x-uidl: header, the value of which seems unimportant.
+ sender = parseaddr(msg.get('from', '').lower())[1] or ''
+ if not sender.startswith('mailer-daemon@yahoo'):
+ return None
+ addrs = []
+ # simple state machine
+ # 0 == nothing seen
+ # 1 == tag line seen
+ state = 0
+ for line in email.Iterators.body_line_iterator(msg):
+ line = line.strip()
+ if state == 0 and tcre.match(line):
+ state = 1
+ elif state == 1:
+ mo = acre.match(line)
+ if mo:
+ addrs.append(mo.group('addr'))
+ continue
+ mo = ecre.match(line)
+ if mo:
+ # we're at the end of the error response
+ break
+ return addrs
diff --git a/Mailman/Bouncers/Yale.py b/Mailman/Bouncers/Yale.py
new file mode 100644
index 00000000..6afc4d97
--- /dev/null
+++ b/Mailman/Bouncers/Yale.py
@@ -0,0 +1,79 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Yale's mail server is pretty dumb.
+
+Its reports include the end user's name, but not the full domain. I think we
+can usually guess it right anyway. This is completely based on examination of
+the corpse, and is subject to failure whenever Yale even slightly changes
+their MTA. :(
+
+"""
+
+import re
+from cStringIO import StringIO
+from email.Utils import getaddresses
+
+scre = re.compile(r'Message not delivered to the following', re.IGNORECASE)
+ecre = re.compile(r'Error Detail', re.IGNORECASE)
+acre = re.compile(r'\s+(?P<addr>\S+)\s+')
+
+
+
+def process(msg):
+ if msg.is_multipart():
+ return None
+ try:
+ whofrom = getaddresses([msg.get('from', '')])[0][1]
+ if not whofrom:
+ return None
+ username, domain = whofrom.split('@', 1)
+ except (IndexError, ValueError):
+ return None
+ if username.lower() <> 'mailer-daemon':
+ return None
+ parts = domain.split('.')
+ parts.reverse()
+ for part1, part2 in zip(parts, ('edu', 'yale')):
+ if part1 <> part2:
+ return None
+ # Okay, we've established that the bounce came from the mailer-daemon at
+ # yale.edu. Let's look for a name, and then guess the relevant domains.
+ names = {}
+ body = StringIO(msg.get_payload())
+ state = 0
+ # simple state machine
+ # 0 == init
+ # 1 == intro found
+ while 1:
+ line = body.readline()
+ if not line:
+ break
+ if state == 0 and scre.search(line):
+ state = 1
+ elif state == 1 and ecre.search(line):
+ break
+ elif state == 1:
+ mo = acre.search(line)
+ if mo:
+ names[mo.group('addr')] = 1
+ # Now we have a bunch of names, these are either @yale.edu or
+ # @cs.yale.edu. Add them both.
+ addrs = []
+ for name in names.keys():
+ addrs.append(name + '@yale.edu')
+ addrs.append(name + '@cs.yale.edu')
+ return addrs
diff --git a/Mailman/Bouncers/__init__.py b/Mailman/Bouncers/__init__.py
new file mode 100644
index 00000000..2cbbabb1
--- /dev/null
+++ b/Mailman/Bouncers/__init__.py
@@ -0,0 +1,15 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
diff --git a/Mailman/Cgi/.cvsignore b/Mailman/Cgi/.cvsignore
new file mode 100644
index 00000000..f3c7a7c5
--- /dev/null
+++ b/Mailman/Cgi/.cvsignore
@@ -0,0 +1 @@
+Makefile
diff --git a/Mailman/Cgi/Auth.py b/Mailman/Cgi/Auth.py
new file mode 100644
index 00000000..58640663
--- /dev/null
+++ b/Mailman/Cgi/Auth.py
@@ -0,0 +1,59 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Common routines for logging in and logging out of the list administrator
+and list moderator interface.
+"""
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.htmlformat import FontAttr
+from Mailman.i18n import _
+
+
+
+class NotLoggedInError(Exception):
+ """Exception raised when no matching admin cookie was found."""
+ def __init__(self, message):
+ Exception.__init__(self, message)
+ self.message = message
+
+
+
+def loginpage(mlist, scriptname, msg='', frontpage=None):
+ url = mlist.GetScriptURL(scriptname)
+ if frontpage:
+ actionurl = url
+ else:
+ actionurl = Utils.GetRequestURI(url)
+ if msg:
+ msg = FontAttr(msg, color='#ff0000', size='+1').Format()
+ if scriptname == 'admindb':
+ who = _('Moderator')
+ else:
+ who = _('Administrator')
+ # Language stuff
+ charset = Utils.GetCharSet(mlist.preferred_language)
+ print 'Content-type: text/html; charset=' + charset + '\n\n'
+ print Utils.maketext(
+ 'admlogin.html',
+ {'listname': mlist.real_name,
+ 'path' : actionurl,
+ 'message' : msg,
+ 'who' : who,
+ }, mlist=mlist)
+ print mlist.GetMailmanFooter()
diff --git a/Mailman/Cgi/Makefile.in b/Mailman/Cgi/Makefile.in
new file mode 100644
index 00000000..a613c2b0
--- /dev/null
+++ b/Mailman/Cgi/Makefile.in
@@ -0,0 +1,71 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman
+CGIDIR= $(PACKAGEDIR)/Cgi
+SHELL= /bin/sh
+
+CGI_MODULES= *.py
+
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+
+install:
+ for f in $(CGI_MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(CGIDIR); \
+ done
+
+finish:
+
+clean:
+
+distclean:
+ -rm *.pyc
+ -rm Makefile
diff --git a/Mailman/Cgi/__init__.py b/Mailman/Cgi/__init__.py
new file mode 100644
index 00000000..2cbbabb1
--- /dev/null
+++ b/Mailman/Cgi/__init__.py
@@ -0,0 +1,15 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
diff --git a/Mailman/Cgi/admin.py b/Mailman/Cgi/admin.py
new file mode 100644
index 00000000..49c6efbf
--- /dev/null
+++ b/Mailman/Cgi/admin.py
@@ -0,0 +1,1407 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Process and produce the list-administration options forms.
+
+"""
+
+# For Python 2.1.x compatibility
+from __future__ import nested_scopes
+
+import sys
+import os
+import re
+import cgi
+import sha
+import urllib
+import signal
+from types import *
+from string import lowercase, digits
+
+from email.Utils import unquote, parseaddr, formataddr
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import MailList
+from Mailman import Errors
+from Mailman import MemberAdaptor
+from Mailman import i18n
+from Mailman.UserDesc import UserDesc
+from Mailman.htmlformat import *
+from Mailman.Cgi import Auth
+from Mailman.Logging.Syslog import syslog
+
+# Set up i18n
+_ = i18n._
+i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+NL = '\n'
+OPTCOLUMNS = 11
+
+
+
+def main():
+ # Try to find out which list is being administered
+ parts = Utils.GetPathPieces()
+ if not parts:
+ # None, so just do the admin overview and be done with it
+ admin_overview()
+ return
+ # Get the list object
+ listname = parts[0].lower()
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ # Avoid cross-site scripting attacks
+ safelistname = Utils.websafe(listname)
+ admin_overview(_('No such list <em>%(safelistname)s</em>'))
+ syslog('error', 'admin.py access for non-existent list: %s',
+ listname)
+ return
+ # Now that we know what list has been requested, all subsequent admin
+ # pages are shown in that list's preferred language.
+ i18n.set_language(mlist.preferred_language)
+ # If the user is not authenticated, we're done.
+ cgidata = cgi.FieldStorage(keep_blank_values=1)
+
+ if not mlist.WebAuthenticate((mm_cfg.AuthListAdmin,
+ mm_cfg.AuthSiteAdmin),
+ cgidata.getvalue('adminpw', '')):
+ if cgidata.has_key('adminpw'):
+ # This is a re-authorization attempt
+ msg = Bold(FontSize('+1', _('Authorization failed.'))).Format()
+ else:
+ msg = ''
+ Auth.loginpage(mlist, 'admin', msg=msg)
+ return
+
+ # Which subcategory was requested? Default is `general'
+ if len(parts) == 1:
+ category = 'general'
+ subcat = None
+ elif len(parts) == 2:
+ category = parts[1]
+ subcat = None
+ else:
+ category = parts[1]
+ subcat = parts[2]
+
+ # Is this a log-out request?
+ if category == 'logout':
+ print mlist.ZapCookie(mm_cfg.AuthListAdmin)
+ Auth.loginpage(mlist, 'admin', frontpage=1)
+ return
+
+ # Sanity check
+ if category not in mlist.GetConfigCategories().keys():
+ category = 'general'
+
+ # Is the request for variable details?
+ varhelp = None
+ qsenviron = os.environ.get('QUERY_STRING')
+ parsedqs = None
+ if qsenviron:
+ parsedqs = cgi.parse_qs(qsenviron)
+ if cgidata.has_key('VARHELP'):
+ varhelp = cgidata.getvalue('VARHELP')
+ elif parsedqs:
+ # POST methods, even if their actions have a query string, don't get
+ # put into FieldStorage's keys :-(
+ qs = parsedqs.get('VARHELP')
+ if qs and isinstance(qs, ListType):
+ varhelp = qs[0]
+ if varhelp:
+ option_help(mlist, varhelp)
+ return
+
+ # The html page document
+ doc = Document()
+ doc.set_language(mlist.preferred_language)
+
+ # From this point on, the MailList object must be locked. However, we
+ # must release the lock no matter how we exit. try/finally isn't enough,
+ # because of this scenario: user hits the admin page which may take a long
+ # time to render; user gets bored and hits the browser's STOP button;
+ # browser shuts down socket; server tries to write to broken socket and
+ # gets a SIGPIPE. Under Apache 1.3/mod_cgi, Apache catches this SIGPIPE
+ # (I presume it is buffering output from the cgi script), then turns
+ # around and SIGTERMs the cgi process. Apache waits three seconds and
+ # then SIGKILLs the cgi process. We /must/ catch the SIGTERM and do the
+ # most reasonable thing we can in as short a time period as possible. If
+ # we get the SIGKILL we're screwed (because it's uncatchable and we'll
+ # have no opportunity to clean up after ourselves).
+ #
+ # This signal handler catches the SIGTERM, unlocks the list, and then
+ # exits the process. The effect of this is that the changes made to the
+ # MailList object will be aborted, which seems like the only sensible
+ # semantics.
+ #
+ # BAW: This may not be portable to other web servers or cgi execution
+ # models.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ # Make sure the list gets unlocked...
+ mlist.Unlock()
+ # ...and ensure we exit, otherwise race conditions could cause us to
+ # enter MailList.Save() while we're in the unlocked state, and that
+ # could be bad!
+ sys.exit(0)
+
+ mlist.Lock()
+ try:
+ # Install the emergency shutdown signal handler
+ signal.signal(signal.SIGTERM, sigterm_handler)
+
+ if cgidata.keys():
+ # There are options to change
+ change_options(mlist, category, subcat, cgidata, doc)
+ # Let the list sanity check the changed values
+ mlist.CheckValues()
+ # Additional sanity checks
+ if not mlist.digestable and not mlist.nondigestable:
+ doc.addError(
+ _('''You have turned off delivery of both digest and
+ non-digest messages. This is an incompatible state of
+ affairs. You must turn on either digest delivery or
+ non-digest delivery or your mailing list will basically be
+ unusable.'''), tag=_('Warning: '))
+
+ if not mlist.digestable and mlist.getDigestMemberKeys():
+ doc.addError(
+ _('''You have digest members, but digests are turned
+ off. Those people will not receive mail.'''),
+ tag=_('Warning: '))
+ if not mlist.nondigestable and mlist.getRegularMemberKeys():
+ doc.addError(
+ _('''You have regular list members but non-digestified mail is
+ turned off. They will receive mail until you fix this
+ problem.'''), tag=_('Warning: '))
+ # Glom up the results page and print it out
+ show_results(mlist, doc, category, subcat, cgidata)
+ print doc.Format()
+ mlist.Save()
+ finally:
+ # Now be sure to unlock the list. It's okay if we get a signal here
+ # because essentially, the signal handler will do the same thing. And
+ # unlocking is unconditional, so it's not an error if we unlock while
+ # we're already unlocked.
+ mlist.Unlock()
+
+
+
+def admin_overview(msg=''):
+ # Show the administrative overview page, with the list of all the lists on
+ # this host. msg is an optional error message to display at the top of
+ # the page.
+ #
+ # This page should be displayed in the server's default language, which
+ # should have already been set.
+ hostname = Utils.get_domain()
+ legend = _('%(hostname)s mailing lists - Admin Links')
+ # The html `document'
+ doc = Document()
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+ doc.SetTitle(legend)
+ # The table that will hold everything
+ table = Table(border=0, width="100%")
+ table.AddRow([Center(Header(2, legend))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ # Skip any mailing list that isn't advertised.
+ advertised = []
+ listnames = Utils.list_names()
+ listnames.sort()
+
+ for name in listnames:
+ mlist = MailList.MailList(name, lock=0)
+ if mlist.advertised:
+ if mm_cfg.VIRTUAL_HOST_OVERVIEW and \
+ mlist.web_page_url.find(hostname) == -1:
+ # List is for different identity of this host - skip it.
+ continue
+ else:
+ advertised.append(mlist)
+
+ # Greeting depends on whether there was an error or not
+ if msg:
+ greeting = FontAttr(msg, color="ff5060", size="+1")
+ else:
+ greeting = _("Welcome!")
+
+ welcome = []
+ mailmanlink = Link(mm_cfg.MAILMAN_URL, _('Mailman')).Format()
+ if not advertised:
+ welcome.extend([
+ greeting,
+ _('''<p>There currently are no publicly-advertised %(mailmanlink)s
+ mailing lists on %(hostname)s.'''),
+ ])
+ else:
+ welcome.extend([
+ greeting,
+ _('''<p>Below is the collection of publicly-advertised
+ %(mailmanlink)s mailing lists on %(hostname)s. Click on a list
+ name to visit the configuration pages for that list.'''),
+ ])
+
+ creatorurl = Utils.ScriptURL('create')
+ mailman_owner = Utils.get_site_email()
+ extra = msg and _('right ') or ''
+ welcome.extend([
+ _('''To visit the administrators configuration page for an
+ unadvertised list, open a URL similar to this one, but with a '/' and
+ the %(extra)slist name appended. If you have the proper authority,
+ you can also <a href="%(creatorurl)s">create a new mailing list</a>.
+
+ <p>General list information can be found at '''),
+ Link(Utils.ScriptURL('listinfo'),
+ _('the mailing list overview page')),
+ '.',
+ _('<p>(Send questions and comments to '),
+ Link('mailto:%s' % mailman_owner, mailman_owner),
+ '.)<p>',
+ ])
+
+ table.AddRow([Container(*welcome)])
+ table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0, colspan=2)
+
+ if advertised:
+ table.AddRow(['&nbsp;', '&nbsp;'])
+ table.AddRow([Bold(FontAttr(_('List'), size='+2')),
+ Bold(FontAttr(_('Description'), size='+2'))
+ ])
+ highlight = 1
+ for mlist in advertised:
+ table.AddRow(
+ [Link(mlist.GetScriptURL('admin'), Bold(mlist.real_name)),
+ mlist.description or Italic(_('[no description available]'))])
+ if highlight and mm_cfg.WEB_HIGHLIGHT_COLOR:
+ table.AddRowInfo(table.GetCurrentRowIndex(),
+ bgcolor=mm_cfg.WEB_HIGHLIGHT_COLOR)
+ highlight = not highlight
+
+ doc.AddItem(table)
+ doc.AddItem('<hr>')
+ doc.AddItem(MailmanLogo())
+ print doc.Format()
+
+
+
+def option_help(mlist, varhelp):
+ # The html page document
+ doc = Document()
+ doc.set_language(mlist.preferred_language)
+ # Find out which category and variable help is being requested for.
+ item = None
+ reflist = varhelp.split('/')
+ if len(reflist) >= 2:
+ category = subcat = None
+ if len(reflist) == 2:
+ category, varname = reflist
+ elif len(reflist) == 3:
+ category, subcat, varname = reflist
+ options = mlist.GetConfigInfo(category, subcat)
+ for i in options:
+ if i and i[0] == varname:
+ item = i
+ break
+ # Print an error message if we couldn't find a valid one
+ if not item:
+ bad = _('No valid variable name found.')
+ doc.addError(bad)
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+ return
+ # Get the details about the variable
+ varname, kind, params, dependancies, description, elaboration = \
+ get_item_characteristics(item)
+ # Set up the document
+ realname = mlist.real_name
+ legend = _("""%(realname)s Mailing list Configuration Help
+ <br><em>%(varname)s</em> Option""")
+
+ header = Table(width='100%')
+ header.AddRow([Center(Header(3, legend))])
+ header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ doc.SetTitle(_("Mailman %(varname)s List Option Help"))
+ doc.AddItem(header)
+ doc.AddItem("<b>%s</b> (%s): %s<p>" % (varname, category, description))
+ if elaboration:
+ doc.AddItem("%s<p>" % elaboration)
+
+ if subcat:
+ url = '%s/%s/%s' % (mlist.GetScriptURL('admin'), category, subcat)
+ else:
+ url = '%s/%s' % (mlist.GetScriptURL('admin'), category)
+ form = Form(url)
+ valtab = Table(cellspacing=3, cellpadding=4, width='100%')
+ add_options_table_item(mlist, category, subcat, valtab, item, detailsp=0)
+ form.AddItem(valtab)
+ form.AddItem('<p>')
+ form.AddItem(Center(submit_button()))
+ doc.AddItem(Center(form))
+
+ doc.AddItem(_("""<em><strong>Warning:</strong> changing this option here
+ could cause other screens to be out-of-sync. Be sure to reload any other
+ pages that are displaying this option for this mailing list. You can also
+ """))
+
+ adminurl = mlist.GetScriptURL('admin')
+ if subcat:
+ url = '%s/%s/%s' % (adminurl, category, subcat)
+ else:
+ url = '%s/%s' % (adminurl, category)
+ categoryname = mlist.GetConfigCategories()[category][0]
+ doc.AddItem(Link(url, _('return to the %(categoryname)s options page.')))
+ doc.AddItem('</em>')
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+
+
+
+def show_results(mlist, doc, category, subcat, cgidata):
+ # Produce the results page
+ adminurl = mlist.GetScriptURL('admin')
+ categories = mlist.GetConfigCategories()
+ label = _(categories[category][0])
+
+ # Set up the document's headers
+ realname = mlist.real_name
+ doc.SetTitle(_('%(realname)s Administration (%(label)s)'))
+ doc.AddItem(Center(Header(2, _(
+ '%(realname)s mailing list administration<br>%(label)s Section'))))
+ doc.AddItem('<hr>')
+ # Now we need to craft the form that will be submitted, which will contain
+ # all the variable settings, etc. This is a bit of a kludge because we
+ # know that the autoreply and members categories supports file uploads.
+ encoding = None
+ if category in ('autoreply', 'members'):
+ encoding = 'multipart/form-data'
+ if subcat:
+ form = Form('%s/%s/%s' % (adminurl, category, subcat),
+ encoding=encoding)
+ else:
+ form = Form('%s/%s' % (adminurl, category), encoding=encoding)
+ # This holds the two columns of links
+ linktable = Table(valign='top', width='100%')
+ linktable.AddRow([Center(Bold(_("Configuration Categories"))),
+ Center(Bold(_("Other Administrative Activities")))])
+ # The `other links' are stuff in the right column.
+ otherlinks = UnorderedList()
+ otherlinks.AddItem(Link(mlist.GetScriptURL('admindb'),
+ _('Tend to pending moderator requests')))
+ otherlinks.AddItem(Link(mlist.GetScriptURL('listinfo'),
+ _('Go to the general list information page')))
+ otherlinks.AddItem(Link(mlist.GetScriptURL('edithtml'),
+ _('Edit the public HTML pages')))
+ otherlinks.AddItem(Link(mlist.GetBaseArchiveURL(),
+ _('Go to list archives')).Format() +
+ '<br>&nbsp;<br>')
+ # We do not allow through-the-web deletion of the site list!
+ if mm_cfg.OWNERS_CAN_DELETE_THEIR_OWN_LISTS and \
+ mlist.internal_name() <> mm_cfg.MAILMAN_SITE_LIST:
+ otherlinks.AddItem(Link(mlist.GetScriptURL('rmlist'),
+ _('Delete this mailing list')).Format() +
+ _(' (requires confirmation)<br>&nbsp;<br>'))
+ otherlinks.AddItem(Link('%s/logout' % adminurl,
+ # BAW: What I really want is a blank line, but
+ # adding an &nbsp; won't do it because of the
+ # bullet added to the list item.
+ '<FONT SIZE="+2"><b>%s</b></FONT>' %
+ _('Logout')))
+ # These are links to other categories and live in the left column
+ categorylinks_1 = categorylinks = UnorderedList()
+ categorylinks_2 = ''
+ categorykeys = categories.keys()
+ half = len(categorykeys) / 2
+ counter = 0
+ subcat = None
+ for k in categorykeys:
+ label = _(categories[k][0])
+ url = '%s/%s' % (adminurl, k)
+ if k == category:
+ # Handle subcategories
+ subcats = mlist.GetConfigSubCategories(k)
+ if subcats:
+ subcat = Utils.GetPathPieces()[-1]
+ for k, v in subcats:
+ if k == subcat:
+ break
+ else:
+ # The first subcategory in the list is the default
+ subcat = subcats[0][0]
+ subcat_items = []
+ for sub, text in subcats:
+ if sub == subcat:
+ text = Bold('[%s]' % text).Format()
+ subcat_items.append(Link(url + '/' + sub, text))
+ categorylinks.AddItem(
+ Bold(label).Format() +
+ UnorderedList(*subcat_items).Format())
+ else:
+ categorylinks.AddItem(Link(url, Bold('[%s]' % label)))
+ else:
+ categorylinks.AddItem(Link(url, label))
+ counter += 1
+ if counter >= half:
+ categorylinks_2 = categorylinks = UnorderedList()
+ counter = -len(categorykeys)
+ # Make the emergency stop switch a rude solo light
+ etable = Table()
+ # Add all the links to the links table...
+ etable.AddRow([categorylinks_1, categorylinks_2])
+ etable.AddRowInfo(etable.GetCurrentRowIndex(), valign='top')
+ if mlist.emergency:
+ label = _('Emergency moderation of all list traffic is enabled')
+ etable.AddRow([Center(
+ Link('?VARHELP=general/emergency', Bold(label)))])
+ color = mm_cfg.WEB_ERROR_COLOR
+ etable.AddCellInfo(etable.GetCurrentRowIndex(), 0,
+ colspan=2, bgcolor=color)
+ linktable.AddRow([etable, otherlinks])
+ # ...and add the links table to the document.
+ form.AddItem(linktable)
+ form.AddItem('<hr>')
+ form.AddItem(
+ _('''Make your changes in the following section, then submit them
+ using the <em>Submit Your Changes</em> button below.''')
+ + '<p>')
+
+ # The members and passwords categories are special in that they aren't
+ # defined in terms of gui elements. Create those pages here.
+ if category == 'members':
+ # Figure out which subcategory we should display
+ subcat = Utils.GetPathPieces()[-1]
+ if subcat not in ('list', 'add', 'remove'):
+ subcat = 'list'
+ # Add member category specific tables
+ form.AddItem(membership_options(mlist, subcat, cgidata, doc, form))
+ form.AddItem(Center(submit_button('setmemberopts_btn')))
+ # In "list" subcategory, we can also search for members
+ if subcat == 'list':
+ form.AddItem('<hr>\n')
+ table = Table(width='100%')
+ table.AddRow([Center(Header(2, _('Additional Member Tasks')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ # Add a blank separator row
+ table.AddRow(['&nbsp;', '&nbsp;'])
+ # Add a section to set the moderation bit for all members
+ table.AddRow([_("""<li>Set everyone's moderation bit, including
+ those members not currently visible""")])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([RadioButtonArray('allmodbit_val',
+ (_('Off'), _('On')),
+ mlist.default_member_moderation),
+ SubmitButton('allmodbit_btn', _('Set'))])
+ form.AddItem(table)
+ elif category == 'passwords':
+ form.AddItem(Center(password_inputs(mlist)))
+ form.AddItem(Center(submit_button()))
+ else:
+ form.AddItem(show_variables(mlist, category, subcat, cgidata, doc))
+ form.AddItem(Center(submit_button()))
+ # And add the form
+ doc.AddItem(form)
+ doc.AddItem(mlist.GetMailmanFooter())
+
+
+
+def show_variables(mlist, category, subcat, cgidata, doc):
+ options = mlist.GetConfigInfo(category, subcat)
+
+ # The table containing the results
+ table = Table(cellspacing=3, cellpadding=4, width='100%')
+
+ # Get and portray the text label for the category.
+ categories = mlist.GetConfigCategories()
+ label = _(categories[category][0])
+
+ table.AddRow([Center(Header(2, label))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+
+ # The very first item in the config info will be treated as a general
+ # description if it is a string
+ description = options[0]
+ if isinstance(description, StringType):
+ table.AddRow([description])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ options = options[1:]
+
+ if not options:
+ return table
+
+ # Add the global column headers
+ table.AddRow([Center(Bold(_('Description'))),
+ Center(Bold(_('Value')))])
+ table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0,
+ width='15%')
+ table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 1,
+ width='85%')
+
+ for item in options:
+ if type(item) == StringType:
+ # The very first banner option (string in an options list) is
+ # treated as a general description, while any others are
+ # treated as section headers - centered and italicized...
+ table.AddRow([Center(Italic(item))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ else:
+ add_options_table_item(mlist, category, subcat, table, item)
+ table.AddRow(['<br>'])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ return table
+
+
+
+def add_options_table_item(mlist, category, subcat, table, item, detailsp=1):
+ # Add a row to an options table with the item description and value.
+ varname, kind, params, extra, descr, elaboration = \
+ get_item_characteristics(item)
+ if elaboration is None:
+ elaboration = descr
+ descr = get_item_gui_description(mlist, category, subcat,
+ varname, descr, elaboration, detailsp)
+ val = get_item_gui_value(mlist, category, kind, varname, params, extra)
+ table.AddRow([descr, val])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
+ table.AddCellInfo(table.GetCurrentRowIndex(), 1,
+ bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
+
+
+
+def get_item_characteristics(record):
+ # Break out the components of an item description from its description
+ # record:
+ #
+ # 0 -- option-var name
+ # 1 -- type
+ # 2 -- entry size
+ # 3 -- ?dependancies?
+ # 4 -- Brief description
+ # 5 -- Optional description elaboration
+ if len(record) == 5:
+ elaboration = None
+ varname, kind, params, dependancies, descr = record
+ elif len(record) == 6:
+ varname, kind, params, dependancies, descr, elaboration = record
+ else:
+ raise ValueError, _('Badly formed options entry:\n %(record)s')
+ return varname, kind, params, dependancies, descr, elaboration
+
+
+
+def get_item_gui_value(mlist, category, kind, varname, params, extra):
+ """Return a representation of an item's settings."""
+ # Give the category a chance to return the value for the variable
+ value = None
+ label, gui = mlist.GetConfigCategories()[category]
+ if hasattr(gui, 'getValue'):
+ value = gui.getValue(mlist, kind, varname, params)
+ # Filter out None, and volatile attributes
+ if value is None and not varname.startswith('_'):
+ value = getattr(mlist, varname)
+ # Now create the widget for this value
+ if kind == mm_cfg.Radio or kind == mm_cfg.Toggle:
+ # If we are returning the option for subscribe policy and this site
+ # doesn't allow open subscribes, then we have to alter the value of
+ # mlist.subscribe_policy as passed to RadioButtonArray in order to
+ # compensate for the fact that there is one fewer option.
+ # Correspondingly, we alter the value back in the change options
+ # function -scott
+ #
+ # TBD: this is an ugly ugly hack.
+ if varname.startswith('_'):
+ checked = 0
+ else:
+ checked = value
+ if varname == 'subscribe_policy' and not mm_cfg.ALLOW_OPEN_SUBSCRIBE:
+ checked = checked - 1
+ # For Radio buttons, we're going to interpret the extra stuff as a
+ # horizontal/vertical flag. For backwards compatibility, the value 0
+ # means horizontal, so we use "not extra" to get the parity right.
+ return RadioButtonArray(varname, params, checked, not extra)
+ elif (kind == mm_cfg.String or kind == mm_cfg.Email or
+ kind == mm_cfg.Host or kind == mm_cfg.Number):
+ return TextBox(varname, value, params)
+ elif kind == mm_cfg.Text:
+ if params:
+ r, c = params
+ else:
+ r, c = None, None
+ return TextArea(varname, value or '', r, c)
+ elif kind in (mm_cfg.EmailList, mm_cfg.EmailListEx):
+ if params:
+ r, c = params
+ else:
+ r, c = None, None
+ res = NL.join(value)
+ return TextArea(varname, res, r, c, wrap='off')
+ elif kind == mm_cfg.FileUpload:
+ # like a text area, but also with uploading
+ if params:
+ r, c = params
+ else:
+ r, c = None, None
+ container = Container()
+ container.AddItem(_('<em>Enter the text below, or...</em><br>'))
+ container.AddItem(TextArea(varname, value or '', r, c))
+ container.AddItem(_('<br><em>...specify a file to upload</em><br>'))
+ container.AddItem(FileUpload(varname+'_upload', r, c))
+ return container
+ elif kind == mm_cfg.Select:
+ if params:
+ values, legend, selected = params
+ else:
+ values = mlist.GetAvailableLanguages()
+ legend = map(_, map(Utils.GetLanguageDescr, values))
+ selected = values.index(mlist.preferred_language)
+ return SelectOptions(varname, values, legend, selected)
+ elif kind == mm_cfg.Topics:
+ # A complex and specialized widget type that allows for setting of a
+ # topic name, a mark button, a regexp text box, an "add after mark",
+ # and a delete button. Yeesh! params are ignored.
+ table = Table(border=0)
+ # This adds the html for the entry widget
+ def makebox(i, name, pattern, desc, empty=0, table=table):
+ deltag = 'topic_delete_%02d' % i
+ boxtag = 'topic_box_%02d' % i
+ reboxtag = 'topic_rebox_%02d' % i
+ desctag = 'topic_desc_%02d' % i
+ wheretag = 'topic_where_%02d' % i
+ addtag = 'topic_add_%02d' % i
+ newtag = 'topic_new_%02d' % i
+ if empty:
+ table.AddRow([Center(Bold(_('Topic %(i)d'))),
+ Hidden(newtag)])
+ else:
+ table.AddRow([Center(Bold(_('Topic %(i)d'))),
+ SubmitButton(deltag, _('Delete'))])
+ table.AddRow([Label(_('Topic name:')),
+ TextBox(boxtag, value=name, size=30)])
+ table.AddRow([Label(_('Regexp:')),
+ TextArea(reboxtag, text=pattern,
+ rows=4, cols=30, wrap='off')])
+ table.AddRow([Label(_('Description:')),
+ TextArea(desctag, text=desc,
+ rows=4, cols=30, wrap='soft')])
+ if not empty:
+ table.AddRow([SubmitButton(addtag, _('Add new item...')),
+ SelectOptions(wheretag, ('before', 'after'),
+ (_('...before this one.'),
+ _('...after this one.')),
+ selected=1),
+ ])
+ table.AddRow(['<hr>'])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ # Now for each element in the existing data, create a widget
+ i = 1
+ data = getattr(mlist, varname)
+ for name, pattern, desc, empty in data:
+ makebox(i, name, pattern, desc, empty)
+ i += 1
+ # Add one more non-deleteable widget as the first blank entry, but
+ # only if there are no real entries.
+ if i == 1:
+ makebox(i, '', '', '', empty=1)
+ return table
+ elif kind == mm_cfg.Checkbox:
+ return CheckBoxArray(varname, *params)
+ else:
+ assert 0, 'Bad gui widget type: %s' % kind
+
+
+
+def get_item_gui_description(mlist, category, subcat,
+ varname, descr, elaboration, detailsp):
+ # Return the item's description, with link to details.
+ #
+ # Details are not included if this is a VARHELP page, because that /is/
+ # the details page!
+ if detailsp:
+ if subcat:
+ varhelp = '/?VARHELP=%s/%s/%s' % (category, subcat, varname)
+ else:
+ varhelp = '/?VARHELP=%s/%s' % (category, varname)
+ if descr == elaboration:
+ linktext = _('<br>(Edit <b>%(varname)s</b>)')
+ else:
+ linktext = _('<br>(Details for <b>%(varname)s</b>)')
+ link = Link(mlist.GetScriptURL('admin') + varhelp,
+ linktext).Format()
+ text = Label('%s %s' % (descr, link)).Format()
+ else:
+ text = Label(descr).Format()
+ if varname[0] == '_':
+ text += Label(_('''<br><em><strong>Note:</strong>
+ setting this value performs an immediate action but does not modify
+ permanent state.</em>''')).Format()
+ return text
+
+
+
+def membership_options(mlist, subcat, cgidata, doc, form):
+ # Show the main stuff
+ adminurl = mlist.GetScriptURL('admin', absolute=1)
+ container = Container()
+ header = Table(width="100%")
+ # If we're in the list subcategory, show the membership list
+ if subcat == 'add':
+ header.AddRow([Center(Header(2, _('Mass Subscriptions')))])
+ header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ container.AddItem(header)
+ mass_subscribe(mlist, container)
+ return container
+ if subcat == 'remove':
+ header.AddRow([Center(Header(2, _('Mass Removals')))])
+ header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ container.AddItem(header)
+ mass_remove(mlist, container)
+ return container
+ # Otherwise...
+ header.AddRow([Center(Header(2, _('Membership List')))])
+ header.AddCellInfo(header.GetCurrentRowIndex(), 0, colspan=2,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ container.AddItem(header)
+ # Add a "search for member" button
+ table = Table(width='100%')
+ link = Link('http://www.python.org/doc/current/lib/re-syntax.html',
+ _('(help)')).Format()
+ table.AddRow([Label(_('Find member %(link)s:')),
+ TextBox('findmember',
+ value=cgidata.getvalue('findmember', '')),
+ SubmitButton('findmember_btn', _('Search...'))])
+ container.AddItem(table)
+ container.AddItem('<hr><p>')
+ usertable = Table(width="90%", border='2')
+ # If there are more members than allowed by chunksize, then we split the
+ # membership up alphabetically. Otherwise just display them all.
+ chunksz = mlist.admin_member_chunksize
+ all = mlist.getMembers()
+ all.sort(lambda x, y: cmp(x.lower(), y.lower()))
+ # See if the query has a regular expression
+ regexp = cgidata.getvalue('findmember', '').strip()
+ if regexp:
+ try:
+ cre = re.compile(regexp, re.IGNORECASE)
+ except re.error:
+ doc.addError(_('Bad regular expression: ') + regexp)
+ else:
+ # BAW: There's got to be a more efficient way of doing this!
+ names = [mlist.getMemberName(s) or '' for s in all]
+ all = [a for n, a in zip(names, all)
+ if cre.search(n) or cre.search(a)]
+ chunkindex = None
+ bucket = None
+ actionurl = None
+ if len(all) < chunksz:
+ members = all
+ else:
+ # Split them up alphabetically, and then split the alphabetical
+ # listing by chunks
+ buckets = {}
+ for addr in all:
+ members = buckets.setdefault(addr[0].lower(), [])
+ members.append(addr)
+ # Now figure out which bucket we want
+ bucket = None
+ qs = {}
+ # POST methods, even if their actions have a query string, don't get
+ # put into FieldStorage's keys :-(
+ qsenviron = os.environ.get('QUERY_STRING')
+ if qsenviron:
+ qs = cgi.parse_qs(qsenviron)
+ bucket = qs.get('letter', 'a')[0].lower()
+ if bucket not in digits + lowercase:
+ bucket = None
+ if not bucket or not buckets.has_key(bucket):
+ keys = buckets.keys()
+ keys.sort()
+ bucket = keys[0]
+ members = buckets[bucket]
+ action = adminurl + '/members?letter=%s' % bucket
+ if len(members) <= chunksz:
+ form.set_action(action)
+ else:
+ i, r = divmod(len(members), chunksz)
+ numchunks = i + (not not r * 1)
+ # Now chunk them up
+ chunkindex = 0
+ if qs.has_key('chunk'):
+ try:
+ chunkindex = int(qs['chunk'][0])
+ except ValueError:
+ chunkindex = 0
+ if chunkindex < 0 or chunkindex > numchunks:
+ chunkindex = 0
+ members = members[chunkindex*chunksz:(chunkindex+1)*chunksz]
+ # And set the action URL
+ form.set_action(action + '&chunk=%s' % chunkindex)
+ # So now members holds all the addresses we're going to display
+ allcnt = len(all)
+ if bucket:
+ membercnt = len(members)
+ usertable.AddRow([Center(Italic(_(
+ '%(allcnt)s members total, %(membercnt)s shown')))])
+ else:
+ usertable.AddRow([Center(Italic(_('%(allcnt)s members total')))])
+ usertable.AddCellInfo(usertable.GetCurrentRowIndex(),
+ usertable.GetCurrentCellIndex(),
+ colspan=OPTCOLUMNS,
+ bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
+ # Add the alphabetical links
+ if bucket:
+ cells = []
+ for letter in digits + lowercase:
+ if not buckets.get(letter):
+ continue
+ url = adminurl + '/members?letter=%s' % letter
+ if letter == bucket:
+ show = Bold('[%s]' % letter.upper()).Format()
+ else:
+ show = letter.upper()
+ cells.append(Link(url, show).Format())
+ joiner = '&nbsp;'*2 + '\n'
+ usertable.AddRow([Center(joiner.join(cells))])
+ usertable.AddCellInfo(usertable.GetCurrentRowIndex(),
+ usertable.GetCurrentCellIndex(),
+ colspan=OPTCOLUMNS,
+ bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
+ usertable.AddRow([Center(h) for h in (_('unsub'),
+ _('member address<br>member name'),
+ _('mod'), _('hide'),
+ _('nomail<br>[reason]'),
+ _('ack'), _('not metoo'),
+ _('nodupes'),
+ _('digest'), _('plain'),
+ _('language'))])
+ rowindex = usertable.GetCurrentRowIndex()
+ for i in range(OPTCOLUMNS):
+ usertable.AddCellInfo(rowindex, i, bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
+ # Find the longest name in the list
+ longest = 0
+ if members:
+ names = filter(None, [mlist.getMemberName(s) for s in members])
+ # Make the name field at least as long as the longest email address
+ longest = max([len(s) for s in names + members])
+ # Abbreviations for delivery status details
+ ds_abbrevs = {MemberAdaptor.UNKNOWN : _('?'),
+ MemberAdaptor.BYUSER : _('U'),
+ MemberAdaptor.BYADMIN : _('A'),
+ MemberAdaptor.BYBOUNCE: _('B'),
+ }
+ # Now populate the rows
+ for addr in members:
+ link = Link(mlist.GetOptionsURL(addr, obscure=1),
+ mlist.getMemberCPAddress(addr))
+ fullname = Utils.uncanonstr(mlist.getMemberName(addr),
+ mlist.preferred_language)
+ name = TextBox(addr + '_realname', fullname, size=longest).Format()
+ cells = [Center(CheckBox(addr + '_unsub', 'off', 0).Format()),
+ link.Format() + '<br>' +
+ name +
+ Hidden('user', urllib.quote(addr)).Format(),
+ ]
+ # Do the `mod' option
+ if mlist.getMemberOption(addr, mm_cfg.Moderate):
+ value = 'on'
+ checked = 1
+ else:
+ value = 'off'
+ checked = 0
+ box = CheckBox('%s_mod' % addr, value, checked)
+ cells.append(Center(box).Format())
+ for opt in ('hide', 'nomail', 'ack', 'notmetoo', 'nodupes'):
+ extra = ''
+ if opt == 'nomail':
+ status = mlist.getDeliveryStatus(addr)
+ if status == MemberAdaptor.ENABLED:
+ value = 'off'
+ checked = 0
+ else:
+ value = 'on'
+ checked = 1
+ extra = '[%s]' % ds_abbrevs[status]
+ elif mlist.getMemberOption(addr, mm_cfg.OPTINFO[opt]):
+ value = 'on'
+ checked = 1
+ else:
+ value = 'off'
+ checked = 0
+ box = CheckBox('%s_%s' % (addr, opt), value, checked)
+ cells.append(Center(box.Format() + extra))
+ # This code is less efficient than the original which did a has_key on
+ # the underlying dictionary attribute. This version is slower and
+ # less memory efficient. It points to a new MemberAdaptor interface
+ # method.
+ if addr in mlist.getRegularMemberKeys():
+ cells.append(Center(CheckBox(addr + '_digest', 'off', 0).Format()))
+ else:
+ cells.append(Center(CheckBox(addr + '_digest', 'on', 1).Format()))
+ if mlist.getMemberOption(addr, mm_cfg.OPTINFO['plain']):
+ value = 'on'
+ checked = 1
+ else:
+ value = 'off'
+ checked = 0
+ cells.append(Center(CheckBox('%s_plain' % addr, value, checked)))
+ # User's preferred language
+ langpref = mlist.getMemberLanguage(addr)
+ langs = mlist.GetAvailableLanguages()
+ langdescs = [_(Utils.GetLanguageDescr(lang)) for lang in langs]
+ try:
+ selected = langs.index(langpref)
+ except ValueError:
+ selected = 0
+ cells.append(Center(SelectOptions(addr + '_language', langs,
+ langdescs, selected)).Format())
+ usertable.AddRow(cells)
+ # Add the usertable and a legend
+ legend = UnorderedList()
+ legend.AddItem(
+ _('<b>unsub</b> -- Click on this to unsubscribe the member.'))
+ legend.AddItem(
+ _("""<b>mod</b> -- The user's personal moderation flag. If this is
+ set, postings from them will be moderated, otherwise they will be
+ approved."""))
+ legend.AddItem(
+ _("""<b>hide</b> -- Is the member's address concealed on
+ the list of subscribers?"""))
+ legend.AddItem(_(
+ """<b>nomail</b> -- Is delivery to the member disabled? If so, an
+ abbreviation will be given describing the reason for the disabled
+ delivery:
+ <ul><li><b>U</b> -- Delivery was disabled by the user via their
+ personal options page.
+ <li><b>A</b> -- Delivery was disabled by the list
+ administrators.
+ <li><b>B</b> -- Delivery was disabled by the system due to
+ excessive bouncing from the member's address.
+ <li><b>?</b> -- The reason for disabled delivery isn't known.
+ This is the case for all memberships which were disabled
+ in older versions of Mailman.
+ </ul>"""))
+ legend.AddItem(
+ _('''<b>ack</b> -- Does the member get acknowledgements of their
+ posts?'''))
+ legend.AddItem(
+ _('''<b>not metoo</b> -- Does the member want to avoid copies of their
+ own postings?'''))
+ legend.AddItem(
+ _('''<b>nodupes</b> -- Does the member want to avoid duplicates of the
+ same message?'''))
+ legend.AddItem(
+ _('''<b>digest</b> -- Does the member get messages in digests?
+ (otherwise, individual messages)'''))
+ legend.AddItem(
+ _('''<b>plain</b> -- If getting digests, does the member get plain
+ text digests? (otherwise, MIME)'''))
+ legend.AddItem(_("<b>language</b> -- Language preferred by the user"))
+ addlegend = ''
+ parsedqs = 0
+ qsenviron = os.environ.get('QUERY_STRING')
+ if qsenviron:
+ qs = cgi.parse_qs(qsenviron).get('legend')
+ if qs and isinstance(qs, ListType):
+ qs = qs[0]
+ if qs == 'yes':
+ addlegend = 'legend=yes&'
+ if addlegend:
+ container.AddItem(legend.Format() + '<p>')
+ container.AddItem(
+ Link(adminurl + '/members/list',
+ _('Click here to hide the legend for this table.')))
+ else:
+ container.AddItem(
+ Link(adminurl + '/members/list?legend=yes',
+ _('Click here to include the legend for this table.')))
+ container.AddItem(Center(usertable))
+
+ # There may be additional chunks
+ if chunkindex is not None:
+ buttons = []
+ url = adminurl + '/members?%sletter=%s&' % (addlegend, bucket)
+ footer = _('''<p><em>To view more members, click on the appropriate
+ range listed below:</em>''')
+ chunkmembers = buckets[bucket]
+ last = len(chunkmembers)
+ for i in range(numchunks):
+ if i == chunkindex:
+ continue
+ start = chunkmembers[i*chunksz]
+ end = chunkmembers[min((i+1)*chunksz, last)-1]
+ link = Link(url + 'chunk=%d' % i, _('from %(start)s to %(end)s'))
+ buttons.append(link)
+ buttons = UnorderedList(*buttons)
+ container.AddItem(footer + buttons.Format() + '<p>')
+ return container
+
+
+
+def mass_subscribe(mlist, container):
+ # MASS SUBSCRIBE
+ GREY = mm_cfg.WEB_ADMINITEM_COLOR
+ table = Table(width='90%')
+ table.AddRow([
+ Label(_('Subscribe these users now or invite them?')),
+ RadioButtonArray('subscribe_or_invite',
+ (_('Subscribe'), _('Invite')),
+ 0, values=(0, 1))
+ ])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
+ table.AddRow([
+ Label(_('Send welcome messages to new subscribees?')),
+ RadioButtonArray('send_welcome_msg_to_this_batch',
+ (_('No'), _('Yes')),
+ mlist.send_welcome_msg,
+ values=(0, 1))
+ ])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
+ table.AddRow([
+ Label(_('Send notifications of new subscriptions to the list owner?')),
+ RadioButtonArray('send_notifications_to_list_owner',
+ (_('No'), _('Yes')),
+ mlist.admin_notify_mchanges,
+ values=(0,1))
+ ])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
+ table.AddRow([Italic(_('Enter one address per line below...'))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Center(TextArea(name='subscribees',
+ rows=10, cols='70%', wrap=None))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Italic(Label(_('...or specify a file to upload:'))),
+ FileUpload('subscribees_upload', cols='50')])
+ container.AddItem(Center(table))
+ # Invitation text
+ table.AddRow(['&nbsp;', '&nbsp;'])
+ table.AddRow([Italic(_("""Below, enter additional text to be added to the
+ top of your invitation or the subscription notification. Include at least
+ one blank line at the end..."""))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Center(TextArea(name='invitation',
+ rows=10, cols='70%', wrap=None))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+
+
+
+def mass_remove(mlist, container):
+ # MASS UNSUBSCRIBE
+ GREY = mm_cfg.WEB_ADMINITEM_COLOR
+ table = Table(width='90%')
+ table.AddRow([
+ Label(_('Send unsubscription acknowledgement to the user?')),
+ RadioButtonArray('send_unsub_ack_to_this_batch',
+ (_('No'), _('Yes')),
+ 0, values=(0, 1))
+ ])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
+ table.AddRow([
+ Label(_('Send notifications to the list owner?')),
+ RadioButtonArray('send_unsub_notifications_to_list_owner',
+ (_('No'), _('Yes')),
+ mlist.admin_notify_mchanges,
+ values=(0, 1))
+ ])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ table.AddCellInfo(table.GetCurrentRowIndex(), 1, bgcolor=GREY)
+ table.AddRow([Italic(_('Enter one address per line below...'))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Center(TextArea(name='unsubscribees',
+ rows=10, cols='70%', wrap=None))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Italic(Label(_('...or specify a file to upload:'))),
+ FileUpload('unsubscribees_upload', cols='50')])
+ container.AddItem(Center(table))
+
+
+
+def password_inputs(mlist):
+ adminurl = mlist.GetScriptURL('admin', absolute=1)
+ table = Table(cellspacing=3, cellpadding=4)
+ table.AddRow([Center(Header(2, _('Change list ownership passwords')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ table.AddRow([_("""\
+The <em>list administrators</em> are the people who have ultimate control over
+all parameters of this mailing list. They are able to change any list
+configuration variable available through these administration web pages.
+
+<p>The <em>list moderators</em> have more limited permissions; they are not
+able to change any list configuration variable, but they are allowed to tend
+to pending administration requests, including approving or rejecting held
+subscription requests, and disposing of held postings. Of course, the
+<em>list administrators</em> can also tend to pending requests.
+
+<p>In order to split the list ownership duties into administrators and
+moderators, you must set a separate moderator password in the fields below,
+and also provide the email addresses of the list moderators in the
+<a href="%(adminurl)s/general">general options section</a>.""")])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ # Set up the admin password table on the left
+ atable = Table(border=0, cellspacing=3, cellpadding=4,
+ bgcolor=mm_cfg.WEB_ADMINPW_COLOR)
+ atable.AddRow([Label(_('Enter new administrator password:')),
+ PasswordBox('newpw', size=20)])
+ atable.AddRow([Label(_('Confirm administrator password:')),
+ PasswordBox('confirmpw', size=20)])
+ # Set up the moderator password table on the right
+ mtable = Table(border=0, cellspacing=3, cellpadding=4,
+ bgcolor=mm_cfg.WEB_ADMINPW_COLOR)
+ mtable.AddRow([Label(_('Enter new moderator password:')),
+ PasswordBox('newmodpw', size=20)])
+ mtable.AddRow([Label(_('Confirm moderator password:')),
+ PasswordBox('confirmmodpw', size=20)])
+ # Add these tables to the overall password table
+ table.AddRow([atable, mtable])
+ return table
+
+
+
+def submit_button(name='submit'):
+ table = Table(border=0, cellspacing=0, cellpadding=2)
+ table.AddRow([Bold(SubmitButton(name, _('Submit Your Changes')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, align='middle')
+ return table
+
+
+
+def change_options(mlist, category, subcat, cgidata, doc):
+ def safeint(formvar, defaultval=None):
+ try:
+ return int(cgidata.getvalue(formvar))
+ except (ValueError, TypeError):
+ return defaultval
+ confirmed = 0
+ # Handle changes to the list moderator password. Do this before checking
+ # the new admin password, since the latter will force a reauthentication.
+ new = cgidata.getvalue('newmodpw', '').strip()
+ confirm = cgidata.getvalue('confirmmodpw', '').strip()
+ if new or confirm:
+ if new == confirm:
+ mlist.mod_password = sha.new(new).hexdigest()
+ # No re-authentication necessary because the moderator's
+ # password doesn't get you into these pages.
+ else:
+ doc.addError(_('Moderator passwords did not match'))
+ # Handle changes to the list administrator password
+ new = cgidata.getvalue('newpw', '').strip()
+ confirm = cgidata.getvalue('confirmpw', '').strip()
+ if new or confirm:
+ if new == confirm:
+ mlist.password = sha.new(new).hexdigest()
+ # Set new cookie
+ print mlist.MakeCookie(mm_cfg.AuthListAdmin)
+ else:
+ doc.addError(_('Administrator passwords did not match'))
+ # Give the individual gui item a chance to process the form data
+ categories = mlist.GetConfigCategories()
+ label, gui = categories[category]
+ # BAW: We handle the membership page special... for now.
+ if category <> 'members':
+ gui.handleForm(mlist, category, subcat, cgidata, doc)
+ # mass subscription, removal processing for members category
+ subscribers = ''
+ subscribers += cgidata.getvalue('subscribees', '')
+ subscribers += cgidata.getvalue('subscribees_upload', '')
+ if subscribers:
+ entries = filter(None, [n.strip() for n in subscribers.splitlines()])
+ send_welcome_msg = safeint('send_welcome_msg_to_this_batch',
+ mlist.send_welcome_msg)
+ send_admin_notif = safeint('send_notifications_to_list_owner',
+ mlist.admin_notify_mchanges)
+ # Default is to subscribe
+ subscribe_or_invite = safeint('subscribe_or_invite', 0)
+ invitation = cgidata.getvalue('invitation', '')
+ digest = 0
+ if not mlist.digestable:
+ digest = 0
+ if not mlist.nondigestable:
+ digest = 1
+ subscribe_errors = []
+ subscribe_success = []
+ # Now cruise through all the subscribees and do the deed. BAW: we
+ # should limit the number of "Successfully subscribed" status messages
+ # we display. Try uploading a file with 10k names -- it takes a while
+ # to render the status page.
+ for entry in entries:
+ fullname, address = parseaddr(entry)
+ # Canonicalize the full name
+ fullname = Utils.canonstr(fullname, mlist.preferred_language)
+ userdesc = UserDesc(address, fullname,
+ Utils.MakeRandomPassword(),
+ digest, mlist.preferred_language)
+ try:
+ if subscribe_or_invite:
+ if mlist.isMember(address):
+ raise Errors.MMAlreadyAMember
+ else:
+ mlist.InviteNewMember(userdesc, invitation)
+ else:
+ mlist.ApprovedAddMember(userdesc, send_welcome_msg,
+ send_admin_notif, invitation)
+ except Errors.MMAlreadyAMember:
+ subscribe_errors.append((entry, _('Already a member')))
+ except Errors.MMBadEmailError:
+ if userdesc.address == '':
+ subscribe_errors.append((_('&lt;blank line&gt;'),
+ _('Bad/Invalid email address')))
+ else:
+ subscribe_errors.append((entry,
+ _('Bad/Invalid email address')))
+ except Errors.MMHostileAddress:
+ subscribe_errors.append(
+ (entry, _('Hostile address (illegal characters)')))
+ else:
+ member = Utils.uncanonstr(formataddr((fullname, address)))
+ subscribe_success.append(Utils.websafe(member))
+ if subscribe_success:
+ if subscribe_or_invite:
+ doc.AddItem(Header(5, _('Successfully invited:')))
+ else:
+ doc.AddItem(Header(5, _('Successfully subscribed:')))
+ doc.AddItem(UnorderedList(*subscribe_success))
+ doc.AddItem('<p>')
+ if subscribe_errors:
+ if subscribe_or_invite:
+ doc.AddItem(Header(5, _('Error inviting:')))
+ else:
+ doc.AddItem(Header(5, _('Error subscribing:')))
+ items = ['%s -- %s' % (x0, x1) for x0, x1 in subscribe_errors]
+ doc.AddItem(UnorderedList(*items))
+ doc.AddItem('<p>')
+ # Unsubscriptions
+ removals = ''
+ if cgidata.has_key('unsubscribees'):
+ removals += cgidata['unsubscribees'].value
+ if cgidata.has_key('unsubscribees_upload') and \
+ cgidata['unsubscribees_upload'].value:
+ removals += cgidata['unsubscribees_upload'].value
+ if removals:
+ names = filter(None, [n.strip() for n in removals.splitlines()])
+ send_unsub_notifications = int(
+ cgidata['send_unsub_notifications_to_list_owner'].value)
+ userack = int(
+ cgidata['send_unsub_ack_to_this_batch'].value)
+ unsubscribe_errors = []
+ unsubscribe_success = []
+ for addr in names:
+ try:
+ mlist.ApprovedDeleteMember(
+ addr, whence='admin mass unsub',
+ admin_notif=send_unsub_notifications,
+ userack=userack)
+ unsubscribe_success.append(addr)
+ except Errors.NotAMemberError:
+ unsubscribe_errors.append(addr)
+ if unsubscribe_success:
+ doc.AddItem(Header(5, _('Successfully Unsubscribed:')))
+ doc.AddItem(UnorderedList(*unsubscribe_success))
+ doc.AddItem('<p>')
+ if unsubscribe_errors:
+ doc.AddItem(Header(3, Bold(FontAttr(
+ _('Cannot unsubscribe non-members:'),
+ color='#ff0000', size='+2')).Format()))
+ doc.AddItem(UnorderedList(*unsubscribe_errors))
+ doc.AddItem('<p>')
+ # See if this was a moderation bit operation
+ if cgidata.has_key('allmodbit_btn'):
+ val = cgidata.getvalue('allmodbit_val')
+ try:
+ val = int(val)
+ except VallueError:
+ val = None
+ if val not in (0, 1):
+ doc.addError(_('Bad moderation flag value'))
+ else:
+ for member in mlist.getMembers():
+ mlist.setMemberOption(member, mm_cfg.Moderate, val)
+ # do the user options for members category
+ if cgidata.has_key('setmemberopts_btn') and cgidata.has_key('user'):
+ user = cgidata['user']
+ if type(user) is ListType:
+ users = []
+ for ui in range(len(user)):
+ users.append(urllib.unquote(user[ui].value))
+ else:
+ users = [urllib.unquote(user.value)]
+ errors = []
+ removes = []
+ for user in users:
+ if cgidata.has_key('%s_unsub' % user):
+ try:
+ mlist.ApprovedDeleteMember(user)
+ removes.append(user)
+ except Errors.NotAMemberError:
+ errors.append((user, _('Not subscribed')))
+ continue
+ if not mlist.isMember(user):
+ doc.addError(_('Ignoring changes to deleted member: %(user)s'),
+ tag=_('Warning: '))
+ continue
+ value = cgidata.has_key('%s_digest' % user)
+ try:
+ mlist.setMemberOption(user, mm_cfg.Digests, value)
+ except (Errors.AlreadyReceivingDigests,
+ Errors.AlreadyReceivingRegularDeliveries,
+ Errors.CantDigestError,
+ Errors.MustDigestError):
+ # BAW: Hmm...
+ pass
+
+ newname = cgidata.getvalue(user+'_realname', '')
+ newname = Utils.canonstr(newname, mlist.preferred_language)
+ mlist.setMemberName(user, newname)
+
+ newlang = cgidata.getvalue(user+'_language')
+ oldlang = mlist.getMemberLanguage(user)
+ if newlang and newlang <> oldlang:
+ mlist.setMemberLanguage(user, newlang)
+
+ moderate = not not cgidata.getvalue(user+'_mod')
+ mlist.setMemberOption(user, mm_cfg.Moderate, moderate)
+
+ # Set the `nomail' flag, but only if the user isn't already
+ # disabled (otherwise we might change BYUSER into BYADMIN).
+ if cgidata.has_key('%s_nomail' % user):
+ if mlist.getDeliveryStatus(user) == MemberAdaptor.ENABLED:
+ mlist.setDeliveryStatus(user, MemberAdaptor.BYADMIN)
+ else:
+ mlist.setDeliveryStatus(user, MemberAdaptor.ENABLED)
+ for opt in ('hide', 'ack', 'notmetoo', 'nodupes', 'plain'):
+ opt_code = mm_cfg.OPTINFO[opt]
+ if cgidata.has_key('%s_%s' % (user, opt)):
+ mlist.setMemberOption(user, opt_code, 1)
+ else:
+ mlist.setMemberOption(user, opt_code, 0)
+ # Give some feedback on who's been removed
+ if removes:
+ doc.AddItem(Header(5, _('Successfully Removed:')))
+ doc.AddItem(UnorderedList(*removes))
+ doc.AddItem('<p>')
+ if errors:
+ doc.AddItem(Header(5, _("Error Unsubscribing:")))
+ items = ['%s -- %s' % (x[0], x[1]) for x in errors]
+ doc.AddItem(apply(UnorderedList, tuple((items))))
+ doc.AddItem("<p>")
diff --git a/Mailman/Cgi/admindb.py b/Mailman/Cgi/admindb.py
new file mode 100644
index 00000000..e6b71cda
--- /dev/null
+++ b/Mailman/Cgi/admindb.py
@@ -0,0 +1,769 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Produce and process the pending-approval items for a list."""
+
+import sys
+import os
+import cgi
+import errno
+import signal
+import email
+import time
+from types import ListType
+from urllib import quote_plus, unquote_plus
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import MailList
+from Mailman import Errors
+from Mailman import Message
+from Mailman import i18n
+from Mailman.Handlers.Moderate import ModeratedMemberPost
+from Mailman.ListAdmin import readMessage
+from Mailman.Cgi import Auth
+from Mailman.htmlformat import *
+from Mailman.Logging.Syslog import syslog
+
+EMPTYSTRING = ''
+NL = '\n'
+
+# Set up i18n. Until we know which list is being requested, we use the
+# server's default.
+_ = i18n._
+i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+EXCERPT_HEIGHT = 10
+EXCERPT_WIDTH = 76
+
+
+
+def helds_by_sender(mlist):
+ heldmsgs = mlist.GetHeldMessageIds()
+ bysender = {}
+ for id in heldmsgs:
+ sender = mlist.GetRecord(id)[1]
+ bysender.setdefault(sender, []).append(id)
+ return bysender
+
+
+def hacky_radio_buttons(btnname, labels, values, defaults, spacing=3):
+ # We can't use a RadioButtonArray here because horizontal placement can be
+ # confusing to the user and vertical placement takes up too much
+ # real-estate. This is a hack!
+ space = '&nbsp;' * spacing
+ btns = Table(cellspacing='5', cellpadding='0')
+ btns.AddRow([space + text + space for text in labels])
+ btns.AddRow([Center(RadioButton(btnname, value, default))
+ for value, default in zip(values, defaults)])
+ return btns
+
+
+
+def main():
+ # Figure out which list is being requested
+ parts = Utils.GetPathPieces()
+ if not parts:
+ handle_no_list()
+ return
+
+ listname = parts[0].lower()
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ # Avoid cross-site scripting attacks
+ safelistname = Utils.websafe(listname)
+ handle_no_list(_('No such list <em>%(safelistname)s</em>'))
+ syslog('error', 'No such list "%s": %s\n', listname, e)
+ return
+
+ # Now that we know which list to use, set the system's language to it.
+ i18n.set_language(mlist.preferred_language)
+
+ # Make sure the user is authorized to see this page.
+ cgidata = cgi.FieldStorage(keep_blank_values=1)
+
+ if not mlist.WebAuthenticate((mm_cfg.AuthListAdmin,
+ mm_cfg.AuthListModerator,
+ mm_cfg.AuthSiteAdmin),
+ cgidata.getvalue('adminpw', '')):
+ if cgidata.has_key('adminpw'):
+ # This is a re-authorization attempt
+ msg = Bold(FontSize('+1', _('Authorization failed.'))).Format()
+ else:
+ msg = ''
+ Auth.loginpage(mlist, 'admindb', msg=msg)
+ return
+
+ # Set up the results document
+ doc = Document()
+ doc.set_language(mlist.preferred_language)
+
+ # See if we're requesting all the messages for a particular sender, or if
+ # we want a specific held message.
+ sender = None
+ msgid = None
+ details = None
+ envar = os.environ.get('QUERY_STRING')
+ if envar:
+ # POST methods, even if their actions have a query string, don't get
+ # put into FieldStorage's keys :-(
+ qs = cgi.parse_qs(envar).get('sender')
+ if qs and type(qs) == ListType:
+ sender = qs[0]
+ qs = cgi.parse_qs(envar).get('msgid')
+ if qs and type(qs) == ListType:
+ msgid = qs[0]
+ qs = cgi.parse_qs(envar).get('details')
+ if qs and type(qs) == ListType:
+ details = qs[0]
+
+ # We need a signal handler to catch the SIGTERM that can come from Apache
+ # when the user hits the browser's STOP button. See the comment in
+ # admin.py for details.
+ #
+ # BAW: Strictly speaking, the list should not need to be locked just to
+ # read the request database. However the request database asserts that
+ # the list is locked in order to load it and it's not worth complicating
+ # that logic.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ # Make sure the list gets unlocked...
+ mlist.Unlock()
+ # ...and ensure we exit, otherwise race conditions could cause us to
+ # enter MailList.Save() while we're in the unlocked state, and that
+ # could be bad!
+ sys.exit(0)
+
+ mlist.Lock()
+ try:
+ # Install the emergency shutdown signal handler
+ signal.signal(signal.SIGTERM, sigterm_handler)
+
+ realname = mlist.real_name
+ if not cgidata.keys():
+ # If this is not a form submission (i.e. there are no keys in the
+ # form), then all we don't need to do much special.
+ doc.SetTitle(_('%(realname)s Administrative Database'))
+ elif not details:
+ # This is a form submission
+ doc.SetTitle(_('%(realname)s Administrative Database Results'))
+ process_form(mlist, doc, cgidata)
+ # Now print the results and we're done. Short circuit for when there
+ # are no pending requests, but be sure to save the results!
+ if not mlist.NumRequestsPending():
+ title = _('%(realname)s Administrative Database')
+ doc.SetTitle(title)
+ doc.AddItem(Header(2, title))
+ doc.AddItem(_('There are no pending requests.'))
+ doc.AddItem(' ')
+ doc.AddItem(Link(mlist.GetScriptURL('admindb', absolute=1),
+ _('Click here to reload this page.')))
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+ mlist.Save()
+ return
+
+ admindburl = mlist.GetScriptURL('admindb', absolute=1)
+ form = Form(admindburl)
+ # Add the instructions template
+ if details:
+ doc.AddItem(Header(
+ 2, _('Detailed instructions for the administrative database')))
+ else:
+ doc.AddItem(Header(
+ 2,
+ _('Administrative requests for mailing list:')
+ + ' <em>%s</em>' % mlist.real_name))
+ if not details:
+ form.AddItem(Center(SubmitButton('submit', _('Submit All Data'))))
+ # Add a link back to the overview, if we're not viewing the overview!
+ adminurl = mlist.GetScriptURL('admin', absolute=1)
+ d = {'listname' : mlist.real_name,
+ 'detailsurl': admindburl + '?details=instructions',
+ 'summaryurl': admindburl,
+ 'viewallurl': admindburl + '?details=all',
+ 'adminurl' : adminurl,
+ 'filterurl' : adminurl + '/privacy/sender',
+ }
+ addform = 1
+ if sender:
+ esender = Utils.websafe(sender)
+ d['description'] = _("all of %(esender)s's held messages.")
+ doc.AddItem(Utils.maketext('admindbpreamble.html', d,
+ raw=1, mlist=mlist))
+ show_sender_requests(mlist, form, sender)
+ elif msgid:
+ d['description'] = _('a single held message.')
+ doc.AddItem(Utils.maketext('admindbpreamble.html', d,
+ raw=1, mlist=mlist))
+ show_message_requests(mlist, form, msgid)
+ elif details == 'all':
+ d['description'] = _('all held messages.')
+ doc.AddItem(Utils.maketext('admindbpreamble.html', d,
+ raw=1, mlist=mlist))
+ show_detailed_requests(mlist, form)
+ elif details == 'instructions':
+ doc.AddItem(Utils.maketext('admindbdetails.html', d,
+ raw=1, mlist=mlist))
+ addform = 0
+ else:
+ # Show a summary of all requests
+ doc.AddItem(Utils.maketext('admindbsummary.html', d,
+ raw=1, mlist=mlist))
+ num = show_pending_subs(mlist, form)
+ num += show_pending_unsubs(mlist, form)
+ num += show_helds_overview(mlist, form)
+ addform = num > 0
+ # Finish up the document, adding buttons to the form
+ if addform:
+ doc.AddItem(form)
+ form.AddItem('<hr>')
+ form.AddItem(Center(SubmitButton('submit', _('Submit All Data'))))
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+ # Commit all changes
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+def handle_no_list(msg=''):
+ # Print something useful if no list was given.
+ doc = Document()
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+ header = _('Mailman Administrative Database Error')
+ doc.SetTitle(header)
+ doc.AddItem(Header(2, header))
+ doc.AddItem(msg)
+ url = Utils.ScriptURL('admin', absolute=1)
+ link = Link(url, _('list of available mailing lists.')).Format()
+ doc.AddItem(_('You must specify a list name. Here is the %(link)s'))
+ doc.AddItem('<hr>')
+ doc.AddItem(MailmanLogo())
+ print doc.Format()
+
+
+
+def show_pending_subs(mlist, form):
+ # Add the subscription request section
+ pendingsubs = mlist.GetSubscriptionIds()
+ if not pendingsubs:
+ return 0
+ form.AddItem('<hr>')
+ form.AddItem(Center(Header(2, _('Subscription Requests'))))
+ table = Table(border=2)
+ table.AddRow([Center(Bold(_('Address/name'))),
+ Center(Bold(_('Your decision'))),
+ Center(Bold(_('Reason for refusal')))
+ ])
+ # Alphabetical order by email address
+ byaddrs = {}
+ for id in pendingsubs:
+ addr = mlist.GetRecord(id)[1]
+ byaddrs.setdefault(addr, []).append(id)
+ addrs = byaddrs.keys()
+ addrs.sort()
+ num = 0
+ for addr, ids in byaddrs.items():
+ # Eliminate duplicates
+ for id in ids[1:]:
+ mlist.HandleRequest(id, mm_cfg.DISCARD)
+ id = ids[0]
+ time, addr, fullname, passwd, digest, lang = mlist.GetRecord(id)
+ fullname = Utils.uncanonstr(fullname, mlist.preferred_language)
+ radio = RadioButtonArray(id, (_('Defer'),
+ _('Approve'),
+ _('Reject'),
+ _('Discard')),
+ values=(mm_cfg.DEFER,
+ mm_cfg.SUBSCRIBE,
+ mm_cfg.REJECT,
+ mm_cfg.DISCARD),
+ checked=0).Format()
+ if addr not in mlist.ban_list:
+ radio += '<br>' + CheckBox('ban-%d' % id, 1).Format() + \
+ '&nbsp;' + _('Permanently ban from this list')
+ table.AddRow(['%s<br><em>%s</em>' % (addr, fullname),
+ radio,
+ TextBox('comment-%d' % id, size=40)
+ ])
+ num += 1
+ if num > 0:
+ form.AddItem(table)
+ return num
+
+
+
+def show_pending_unsubs(mlist, form):
+ # Add the pending unsubscription request section
+ lang = mlist.preferred_language
+ pendingunsubs = mlist.GetUnsubscriptionIds()
+ if not pendingunsubs:
+ return 0
+ table = Table(border=2)
+ table.AddRow([Center(Bold(_('User address/name'))),
+ Center(Bold(_('Your decision'))),
+ Center(Bold(_('Reason for refusal')))
+ ])
+ # Alphabetical order by email address
+ byaddrs = {}
+ for id in pendingunsubs:
+ addr = mlist.GetRecord(id)[1]
+ byaddrs.setdefault(addr, []).append(id)
+ addrs = byaddrs.keys()
+ addrs.sort()
+ num = 0
+ for addr, ids in byaddrs.items():
+ # Eliminate duplicates
+ for id in ids[1:]:
+ mlist.HandleRequest(id, mm_cfg.DISCARD)
+ id = ids[0]
+ addr = mlist.GetRecord(id)
+ try:
+ fullname = Utils.uncanonstr(mlist.getMemberName(addr), lang)
+ except Errors.NotAMemberError:
+ # They must have been unsubscribed elsewhere, so we can just
+ # discard this record.
+ mlist.HandleRequest(id, mm_cfg.DISCARD)
+ continue
+ num += 1
+ table.AddRow(['%s<br><em>%s</em>' % (addr, fullname),
+ RadioButtonArray(id, (_('Defer'),
+ _('Approve'),
+ _('Reject'),
+ _('Discard')),
+ values=(mm_cfg.DEFER,
+ mm_cfg.UNSUBSCRIBE,
+ mm_cfg.REJECT,
+ mm_cfg.DISCARD),
+ checked=0),
+ TextBox('comment-%d' % id, size=45)
+ ])
+ if num > 0:
+ form.AddItem('<hr>')
+ form.AddItem(Center(Header(2, _('Unsubscription Requests'))))
+ form.AddItem(table)
+ return num
+
+
+
+def show_helds_overview(mlist, form):
+ # Sort the held messages by sender
+ bysender = helds_by_sender(mlist)
+ if not bysender:
+ return 0
+ # Add the by-sender overview tables
+ admindburl = mlist.GetScriptURL('admindb', absolute=1)
+ table = Table(border=0)
+ form.AddItem(table)
+ senders = bysender.keys()
+ senders.sort()
+ for sender in senders:
+ qsender = quote_plus(sender)
+ esender = Utils.websafe(sender)
+ senderurl = admindburl + '?sender=' + qsender
+ # The encompassing sender table
+ stable = Table(border=1)
+ stable.AddRow([Center(Bold(_('From:')).Format() + esender)])
+ stable.AddCellInfo(stable.GetCurrentRowIndex(), 0, colspan=2)
+ left = Table(border=0)
+ left.AddRow([_('Action to take on all these held messages:')])
+ left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2)
+ btns = hacky_radio_buttons(
+ 'senderaction-' + qsender,
+ (_('Defer'), _('Accept'), _('Reject'), _('Discard')),
+ (mm_cfg.DEFER, mm_cfg.APPROVE, mm_cfg.REJECT, mm_cfg.DISCARD),
+ (1, 0, 0, 0))
+ left.AddRow([btns])
+ left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2)
+ left.AddRow([
+ CheckBox('senderpreserve-' + qsender, 1).Format() +
+ '&nbsp;' +
+ _('Preserve messages for the site administrator')
+ ])
+ left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2)
+ left.AddRow([
+ CheckBox('senderforward-' + qsender, 1).Format() +
+ '&nbsp;' +
+ _('Forward messages (individually) to:')
+ ])
+ left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2)
+ left.AddRow([
+ TextBox('senderforwardto-' + qsender,
+ value=mlist.GetOwnerEmail())
+ ])
+ left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2)
+ # If the sender is a member and the message is being held due to a
+ # moderation bit, give the admin a chance to clear the member's mod
+ # bit. If this sender is not a member and is not already on one of
+ # the sender filters, then give the admin a chance to add this sender
+ # to one of the filters.
+ if mlist.isMember(sender):
+ if mlist.getMemberOption(sender, mm_cfg.Moderate):
+ left.AddRow([
+ CheckBox('senderclearmodp-' + qsender, 1).Format() +
+ '&nbsp;' +
+ _("Clear this member's <em>moderate</em> flag")
+ ])
+ else:
+ left.AddRow(
+ [_('<em>The sender is now a member of this list</em>')])
+ left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2)
+ elif sender not in (mlist.accept_these_nonmembers +
+ mlist.hold_these_nonmembers +
+ mlist.reject_these_nonmembers +
+ mlist.discard_these_nonmembers):
+ left.AddRow([
+ CheckBox('senderfilterp-' + qsender, 1).Format() +
+ '&nbsp;' +
+ _('Add <b>%(esender)s</b> to a sender filter')
+ ])
+ left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2)
+ btns = hacky_radio_buttons(
+ 'senderfilter-' + qsender,
+ (_('Accepts'), _('Holds'), _('Rejects'), _('Discards')),
+ (mm_cfg.ACCEPT, mm_cfg.HOLD, mm_cfg.REJECT, mm_cfg.DISCARD),
+ (0, 0, 0, 1))
+ left.AddRow([btns])
+ left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2)
+ if sender not in mlist.ban_list:
+ left.AddRow([
+ CheckBox('senderbanp-' + qsender, 1).Format() +
+ '&nbsp;' +
+ _("""Ban <b>%(esender)s</b> from ever subscribing to this
+ mailing list""")])
+ left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2)
+ right = Table(border=0)
+ right.AddRow([
+ _("""Click on the message number to view the individual
+ message, or you can """) +
+ Link(senderurl, _('view all messages from %(esender)s')).Format()
+ ])
+ right.AddCellInfo(right.GetCurrentRowIndex(), 0, colspan=2)
+ right.AddRow(['&nbsp;', '&nbsp;'])
+ counter = 1
+ for id in bysender[sender]:
+ info = mlist.GetRecord(id)
+ ptime, sender, subject, reason, filename, msgdata = info
+ # BAW: This is really the size of the message pickle, which should
+ # be close, but won't be exact. Sigh, good enough.
+ try:
+ size = os.path.getsize(os.path.join(mm_cfg.DATA_DIR, filename))
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ # This message must have gotten lost, i.e. it's already been
+ # handled by the time we got here.
+ mlist.HandleRequest(id, mm_cfg.DISCARD)
+ continue
+ t = Table(border=0)
+ t.AddRow([Link(admindburl + '?msgid=%d' % id, '[%d]' % counter),
+ Bold(_('Subject:')),
+ Utils.websafe(subject)
+ ])
+ t.AddRow(['&nbsp;', Bold(_('Size:')), str(size) + _(' bytes')])
+ if reason:
+ reason = _(reason)
+ else:
+ reason = _('not available')
+ t.AddRow(['&nbsp;', Bold(_('Reason:')), reason])
+ # Include the date we received the message, if available
+ when = msgdata.get('received_time')
+ if when:
+ t.AddRow(['&nbsp;', Bold(_('Received:')),
+ time.ctime(when)])
+ counter += 1
+ right.AddRow([t])
+ stable.AddRow([left, right])
+ table.AddRow([stable])
+ return 1
+
+
+
+def show_sender_requests(mlist, form, sender):
+ bysender = helds_by_sender(mlist)
+ if not bysender:
+ return
+ sender_ids = bysender.get(sender)
+ if sender_ids is None:
+ # BAW: should we print an error message?
+ return
+ total = len(sender_ids)
+ count = 1
+ for id in sender_ids:
+ info = mlist.GetRecord(id)
+ show_post_requests(mlist, id, info, total, count, form)
+ count += 1
+
+
+
+def show_message_requests(mlist, form, id):
+ try:
+ id = int(id)
+ info = mlist.GetRecord(id)
+ except (ValueError, KeyError):
+ # BAW: print an error message?
+ return
+ show_post_requests(mlist, id, info, 1, 1, form)
+
+
+
+def show_detailed_requests(mlist, form):
+ all = mlist.GetHeldMessageIds()
+ total = len(all)
+ count = 1
+ for id in mlist.GetHeldMessageIds():
+ info = mlist.GetRecord(id)
+ show_post_requests(mlist, id, info, total, count, form)
+ count += 1
+
+
+
+def show_post_requests(mlist, id, info, total, count, form):
+ # For backwards compatibility with pre 2.0beta3
+ if len(info) == 5:
+ ptime, sender, subject, reason, filename = info
+ msgdata = {}
+ else:
+ ptime, sender, subject, reason, filename, msgdata = info
+ form.AddItem('<hr>')
+ # Header shown on each held posting (including count of total)
+ msg = _('Posting Held for Approval')
+ if total <> 1:
+ msg += _(' (%(count)d of %(total)d)')
+ form.AddItem(Center(Header(2, msg)))
+ # We need to get the headers and part of the textual body of the message
+ # being held. The best way to do this is to use the email Parser to get
+ # an actual object, which will be easier to deal with. We probably could
+ # just do raw reads on the file.
+ try:
+ msg = readMessage(os.path.join(mm_cfg.DATA_DIR, filename))
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ form.AddItem(_('<em>Message with id #%(id)d was lost.'))
+ form.AddItem('<p>')
+ # BAW: kludge to remove id from requests.db.
+ try:
+ mlist.HandleRequest(id, mm_cfg.DISCARD)
+ except Errors.LostHeldMessage:
+ pass
+ return
+ except email.Errors.MessageParseError:
+ form.AddItem(_('<em>Message with id #%(id)d is corrupted.'))
+ # BAW: Should we really delete this, or shuttle it off for site admin
+ # to look more closely at?
+ form.AddItem('<p>')
+ # BAW: kludge to remove id from requests.db.
+ try:
+ mlist.HandleRequest(id, mm_cfg.DISCARD)
+ except Errors.LostHeldMessage:
+ pass
+ return
+ # Get the header text and the message body excerpt
+ lines = []
+ chars = 0
+ # A negative value means, include the entire message regardless of size
+ limit = mm_cfg.ADMINDB_PAGE_TEXT_LIMIT
+ for line in email.Iterators.body_line_iterator(msg):
+ lines.append(line)
+ chars += len(line)
+ if chars > limit > 0:
+ break
+ # Negative values mean display the entire message, regardless of size
+ if limit > 0:
+ body = EMPTYSTRING.join(lines)[:mm_cfg.ADMINDB_PAGE_TEXT_LIMIT]
+ else:
+ body = EMPTYSTRING.join(lines)
+ hdrtxt = NL.join(['%s: %s' % (k, v) for k, v in msg.items()])
+ hdrtxt = Utils.websafe(hdrtxt)
+ # Okay, we've reconstituted the message just fine. Now for the fun part!
+ t = Table(cellspacing=0, cellpadding=0, width='100%')
+ t.AddRow([Bold(_('From:')), sender])
+ row, col = t.GetCurrentRowIndex(), t.GetCurrentCellIndex()
+ t.AddCellInfo(row, col-1, align='right')
+ t.AddRow([Bold(_('Subject:')), Utils.websafe(subject)])
+ t.AddCellInfo(row+1, col-1, align='right')
+ t.AddRow([Bold(_('Reason:')), _(reason)])
+ t.AddCellInfo(row+2, col-1, align='right')
+ when = msgdata.get('received_time')
+ if when:
+ t.AddRow([Bold(_('Received:')), time.ctime(when)])
+ t.AddCellInfo(row+2, col-1, align='right')
+ # We can't use a RadioButtonArray here because horizontal placement can be
+ # confusing to the user and vertical placement takes up too much
+ # real-estate. This is a hack!
+ buttons = Table(cellspacing="5", cellpadding="0")
+ buttons.AddRow(map(lambda x, s='&nbsp;'*5: s+x+s,
+ (_('Defer'), _('Approve'), _('Reject'), _('Discard'))))
+ buttons.AddRow([Center(RadioButton(id, mm_cfg.DEFER, 1)),
+ Center(RadioButton(id, mm_cfg.APPROVE, 0)),
+ Center(RadioButton(id, mm_cfg.REJECT, 0)),
+ Center(RadioButton(id, mm_cfg.DISCARD, 0)),
+ ])
+ t.AddRow([Bold(_('Action:')), buttons])
+ t.AddCellInfo(row+3, col-1, align='right')
+ t.AddRow(['&nbsp;',
+ CheckBox('preserve-%d' % id, 'on', 0).Format() +
+ '&nbsp;' + _('Preserve message for site administrator')
+ ])
+ t.AddRow(['&nbsp;',
+ CheckBox('forward-%d' % id, 'on', 0).Format() +
+ '&nbsp;' + _('Additionally, forward this message to: ') +
+ TextBox('forward-addr-%d' % id, size=47,
+ value=mlist.GetOwnerEmail()).Format()
+ ])
+ notice = msgdata.get('rejection_notice', _('[No explanation given]'))
+ t.AddRow([
+ Bold(_('If you reject this post,<br>please explain (optional):')),
+ TextArea('comment-%d' % id, rows=4, cols=EXCERPT_WIDTH,
+ text = Utils.wrap(_(notice), column=80))
+ ])
+ row, col = t.GetCurrentRowIndex(), t.GetCurrentCellIndex()
+ t.AddCellInfo(row, col-1, align='right')
+ t.AddRow([Bold(_('Message Headers:')),
+ TextArea('headers-%d' % id, hdrtxt,
+ rows=EXCERPT_HEIGHT, cols=EXCERPT_WIDTH, readonly=1)])
+ row, col = t.GetCurrentRowIndex(), t.GetCurrentCellIndex()
+ t.AddCellInfo(row, col-1, align='right')
+ t.AddRow([Bold(_('Message Excerpt:')),
+ TextArea('fulltext-%d' % id, Utils.websafe(body),
+ rows=EXCERPT_HEIGHT, cols=EXCERPT_WIDTH, readonly=1)])
+ t.AddCellInfo(row+1, col-1, align='right')
+ form.AddItem(t)
+ form.AddItem('<p>')
+
+
+
+def process_form(mlist, doc, cgidata):
+ senderactions = {}
+ # Sender-centric actions
+ for k in cgidata.keys():
+ for prefix in ('senderaction-', 'senderpreserve-', 'senderforward-',
+ 'senderforwardto-', 'senderfilterp-', 'senderfilter-',
+ 'senderclearmodp-', 'senderbanp-'):
+ if k.startswith(prefix):
+ action = k[:len(prefix)-1]
+ sender = unquote_plus(k[len(prefix):])
+ value = cgidata.getvalue(k)
+ senderactions.setdefault(sender, {})[action] = value
+ for sender in senderactions.keys():
+ actions = senderactions[sender]
+ # Handle what to do about all this sender's held messages
+ try:
+ action = int(actions.get('senderaction', mm_cfg.DEFER))
+ except ValueError:
+ action = mm_cfg.DEFER
+ if action in (mm_cfg.DEFER, mm_cfg.APPROVE,
+ mm_cfg.REJECT, mm_cfg.DISCARD):
+ preserve = actions.get('senderpreserve', 0)
+ forward = actions.get('senderforward', 0)
+ forwardaddr = actions.get('senderforwardto', '')
+ comment = _('No reason given')
+ bysender = helds_by_sender(mlist)
+ for id in bysender.get(sender, []):
+ try:
+ mlist.HandleRequest(id, action, comment, preserve,
+ forward, forwardaddr)
+ except (KeyError, Errors.LostHeldMessage):
+ # That's okay, it just means someone else has already
+ # updated the database while we were staring at the page,
+ # so just ignore it
+ continue
+ # Now see if this sender should be added to one of the nonmember
+ # sender filters.
+ if actions.get('senderfilterp', 0):
+ try:
+ which = int(actions.get('senderfilter'))
+ except ValueError:
+ # Bogus form
+ which = 'ignore'
+ if which == mm_cfg.ACCEPT:
+ mlist.accept_these_nonmembers.append(sender)
+ elif which == mm_cfg.HOLD:
+ mlist.hold_these_nonmembers.append(sender)
+ elif which == mm_cfg.REJECT:
+ mlist.reject_these_nonmembers.append(sender)
+ elif which == mm_cfg.DISCARD:
+ mlist.discard_these_nonmembers.append(sender)
+ # Otherwise, it's a bogus form, so ignore it
+ # And now see if we're to clear the member's moderation flag.
+ if actions.get('senderclearmodp', 0):
+ try:
+ mlist.setMemberOption(sender, mm_cfg.Moderate, 0)
+ except Errors.NotAMemberError:
+ # This person's not a member any more. Oh well.
+ pass
+ # And should this address be banned?
+ if actions.get('senderbanp', 0):
+ if sender not in mlist.ban_list:
+ mlist.ban_list.append(sender)
+ # Now, do message specific actions
+ erroraddrs = []
+ for k in cgidata.keys():
+ formv = cgidata[k]
+ if type(formv) == ListType:
+ continue
+ try:
+ v = int(formv.value)
+ request_id = int(k)
+ except ValueError:
+ continue
+ if v not in (mm_cfg.DEFER, mm_cfg.APPROVE, mm_cfg.REJECT,
+ mm_cfg.DISCARD, mm_cfg.SUBSCRIBE, mm_cfg.UNSUBSCRIBE,
+ mm_cfg.ACCEPT, mm_cfg.HOLD):
+ continue
+ # Get the action comment and reasons if present.
+ commentkey = 'comment-%d' % request_id
+ preservekey = 'preserve-%d' % request_id
+ forwardkey = 'forward-%d' % request_id
+ forwardaddrkey = 'forward-addr-%d' % request_id
+ bankey = 'ban-%d' % request_id
+ # Defaults
+ comment = _('[No reason given]')
+ preserve = 0
+ forward = 0
+ forwardaddr = ''
+ if cgidata.has_key(commentkey):
+ comment = cgidata[commentkey].value
+ if cgidata.has_key(preservekey):
+ preserve = cgidata[preservekey].value
+ if cgidata.has_key(forwardkey):
+ forward = cgidata[forwardkey].value
+ if cgidata.has_key(forwardaddrkey):
+ forwardaddr = cgidata[forwardaddrkey].value
+ # Should we ban this address? Do this check before handling the
+ # request id because that will evict the record.
+ if cgidata.getvalue(bankey):
+ sender = mlist.GetRecord(request_id)[1]
+ if sender not in mlist.ban_list:
+ mlist.ban_list.append(sender)
+ # Handle the request id
+ try:
+ mlist.HandleRequest(request_id, v, comment,
+ preserve, forward, forwardaddr)
+ except (KeyError, Errors.LostHeldMessage):
+ # That's okay, it just means someone else has already updated the
+ # database while we were staring at the page, so just ignore it
+ continue
+ except Errors.MMAlreadyAMember, v:
+ erroraddrs.append(v)
+ # save the list and print the results
+ doc.AddItem(Header(2, _('Database Updated...')))
+ if erroraddrs:
+ for addr in erroraddrs:
+ doc.AddItem(`addr` + _(' is already a member') + '<br>')
diff --git a/Mailman/Cgi/confirm.py b/Mailman/Cgi/confirm.py
new file mode 100644
index 00000000..2348b0b6
--- /dev/null
+++ b/Mailman/Cgi/confirm.py
@@ -0,0 +1,791 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Confirm a pending action via URL."""
+
+import signal
+import cgi
+import time
+
+from Mailman import mm_cfg
+from Mailman import Errors
+from Mailman import i18n
+from Mailman import MailList
+from Mailman import Pending
+from Mailman.UserDesc import UserDesc
+from Mailman.htmlformat import *
+from Mailman.Logging.Syslog import syslog
+
+# Set up i18n
+_ = i18n._
+i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+
+
+def main():
+ doc = Document()
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+ parts = Utils.GetPathPieces()
+ if not parts or len(parts) < 1:
+ bad_confirmation(doc)
+ doc.AddItem(MailmanLogo())
+ print doc.Format()
+ return
+
+ listname = parts[0].lower()
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ # Avoid cross-site scripting attacks
+ safelistname = Utils.websafe(listname)
+ bad_confirmation(doc, _('No such list <em>%(safelistname)s</em>'))
+ doc.AddItem(MailmanLogo())
+ print doc.Format()
+ syslog('error', 'No such list "%s": %s', listname, e)
+ return
+
+ # Set the language for the list
+ i18n.set_language(mlist.preferred_language)
+ doc.set_language(mlist.preferred_language)
+
+ # Get the form data to see if this is a second-step confirmation
+ cgidata = cgi.FieldStorage(keep_blank_values=1)
+ cookie = cgidata.getvalue('cookie')
+ if cookie == '':
+ ask_for_cookie(mlist, doc, _('Confirmation string was empty.'))
+ return
+
+ if not cookie and len(parts) == 2:
+ cookie = parts[1]
+
+ if len(parts) > 2:
+ bad_confirmation(doc)
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+ return
+
+ if not cookie:
+ ask_for_cookie(mlist, doc)
+ return
+
+ days = int(mm_cfg.PENDING_REQUEST_LIFE / mm_cfg.days(1) + 0.5)
+ confirmurl = mlist.GetScriptURL('confirm', absolute=1)
+ # Avoid cross-site scripting attacks
+ safecookie = Utils.websafe(cookie)
+ badconfirmstr = _('''<b>Invalid confirmation string:</b>
+ %(safecookie)s.
+
+ <p>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 subscription.
+ Otherwise, <a href="%(confirmurl)s">re-enter</a> your confirmation
+ string.''')
+
+ content = Pending.confirm(cookie, expunge=0)
+ if content is None:
+ bad_confirmation(doc, badconfirmstr)
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+ return
+
+ try:
+ if content[0] == Pending.SUBSCRIPTION:
+ if cgidata.getvalue('cancel'):
+ subscription_cancel(mlist, doc, cookie)
+ elif cgidata.getvalue('submit'):
+ subscription_confirm(mlist, doc, cookie, cgidata)
+ else:
+ subscription_prompt(mlist, doc, cookie, content[1])
+ elif content[0] == Pending.UNSUBSCRIPTION:
+ try:
+ if cgidata.getvalue('cancel'):
+ unsubscription_cancel(mlist, doc, cookie)
+ elif cgidata.getvalue('submit'):
+ unsubscription_confirm(mlist, doc, cookie)
+ else:
+ unsubscription_prompt(mlist, doc, cookie, *content[1:])
+ except Errors.NotAMemberError:
+ doc.addError(_("""The address requesting unsubscription is not
+ a member of the mailing list. Perhaps you have already been
+ unsubscribed, e.g. by the list administrator?"""))
+ # And get rid of this confirmation cookie
+ Pending.confirm(cookie)
+ elif content[0] == Pending.CHANGE_OF_ADDRESS:
+ if cgidata.getvalue('cancel'):
+ addrchange_cancel(mlist, doc, cookie)
+ elif cgidata.getvalue('submit'):
+ addrchange_confirm(mlist, doc, cookie)
+ else:
+ # Watch out for users who have unsubscribed themselves in the
+ # meantime!
+ try:
+ addrchange_prompt(mlist, doc, cookie, *content[1:])
+ except Errors.NotAMemberError:
+ doc.addError(_("""The address requesting to be changed has
+ been subsequently unsubscribed. This request has been
+ cancelled."""))
+ Pending.confirm(cookie, expunge=1)
+ elif content[0] == Pending.HELD_MESSAGE:
+ if cgidata.getvalue('cancel'):
+ heldmsg_cancel(mlist, doc, cookie)
+ elif cgidata.getvalue('submit'):
+ heldmsg_confirm(mlist, doc, cookie)
+ else:
+ heldmsg_prompt(mlist, doc, cookie, *content[1:])
+ elif content[0] == Pending.RE_ENABLE:
+ if cgidata.getvalue('cancel'):
+ reenable_cancel(mlist, doc, cookie)
+ elif cgidata.getvalue('submit'):
+ reenable_confirm(mlist, doc, cookie)
+ else:
+ reenable_prompt(mlist, doc, cookie, *content[1:])
+ else:
+ bad_confirmation(doc, _('System error, bad content: %(content)s'))
+ except Errors.MMBadConfirmation:
+ bad_confirmation(doc, badconfirmstr)
+
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+
+
+
+def bad_confirmation(doc, extra=''):
+ title = _('Bad confirmation string')
+ doc.SetTitle(title)
+ doc.AddItem(Header(3, Bold(FontAttr(title, color='#ff0000', size='+2'))))
+ doc.AddItem(extra)
+
+
+
+def ask_for_cookie(mlist, doc, extra=''):
+ title = _('Enter confirmation cookie')
+ doc.SetTitle(title)
+ form = Form(mlist.GetScriptURL('confirm', 1))
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR)
+
+ if extra:
+ table.AddRow([Bold(FontAttr(extra, size='+1'))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+
+ # Add cookie entry box
+ table.AddRow([_("""Please enter the confirmation string
+ (i.e. <em>cookie</em>) that you received in your email message, in the box
+ below. Then hit the <em>Submit</em> button to proceed to the next
+ confirmation step.""")])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Label(_('Confirmation string:')),
+ TextBox('cookie')])
+ table.AddRow([Center(SubmitButton('submit_cookie', _('Submit')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ form.AddItem(table)
+ doc.AddItem(form)
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+
+
+
+def subscription_prompt(mlist, doc, cookie, userdesc):
+ email = userdesc.address
+ password = userdesc.password
+ digest = userdesc.digest
+ lang = userdesc.language
+ name = Utils.uncanonstr(userdesc.fullname, lang)
+ i18n.set_language(lang)
+ doc.set_language(lang)
+ title = _('Confirm subscription request')
+ doc.SetTitle(title)
+
+ form = Form(mlist.GetScriptURL('confirm', 1))
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR)
+
+ listname = mlist.real_name
+ # This is the normal, no-confirmation required results text.
+ #
+ # We do things this way so we don't have to reformat this paragraph, which
+ # would mess up translations. If you modify this text for other reasons,
+ # please refill the paragraph, and clean up the logic.
+ result = _("""Your confirmation is required in order to complete the
+ subscription request to the mailing list <em>%(listname)s</em>. Your
+ subscription settings are shown below; make any necessary changes and hit
+ <em>Subscribe</em> to complete the confirmation process. Once you've
+ confirmed your subscription request, you will be shown your account
+ options page which you can use to further customize your membership
+ options.
+
+ <p>Note: your password will be emailed to you once your subscription is
+ confirmed. You can change it by visiting your personal options page.
+
+ <p>Or hit <em>Cancel and discard</em> to cancel this subscription
+ request.""") + '<p><hr>'
+ if mlist.subscribe_policy in (2, 3):
+ # Confirmation is required
+ result = _("""Your confirmation is required in order to continue with
+ the subscription request to the mailing list <em>%(listname)s</em>.
+ Your subscription settings are shown below; make any necessary changes
+ and hit <em>Subscribe to list ...</em> to complete the confirmation
+ process. Once you've confirmed your subscription request, the
+ moderator must approve or reject your membership request. You will
+ receive notice of their decision.
+
+ <p>Note: your password will be emailed to you once your subscription
+ is confirmed. You can change it by visiting your personal options
+ page.
+
+ <p>Or, if you've changed your mind and do not want to subscribe to
+ this mailing list, you can hit <em>Cancel my subscription
+ request</em>.""") + '<p><hr>'
+ table.AddRow([result])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+
+ table.AddRow([Label(_('Your email address:')), email])
+ table.AddRow([Label(_('Your real name:')),
+ TextBox('realname', name)])
+## table.AddRow([Label(_('Password:')),
+## PasswordBox('password', password)])
+## table.AddRow([Label(_('Password (confirm):')),
+## PasswordBox('pwconfirm', password)])
+ # Only give them a choice to receive digests if they actually have a
+ # choice <wink>.
+ if mlist.nondigestable and mlist.digestable:
+ table.AddRow([Label(_('Receive digests?')),
+ RadioButtonArray('digests', (_('No'), _('Yes')),
+ checked=digest, values=(0, 1))])
+ langs = mlist.GetAvailableLanguages()
+ values = [_(Utils.GetLanguageDescr(l)) for l in langs]
+ try:
+ selected = langs.index(lang)
+ except ValueError:
+ selected = lang.index(mlist.preferred_language)
+ table.AddRow([Label(_('Preferred language:')),
+ SelectOptions('language', langs, values, selected)])
+ table.AddRow([Hidden('cookie', cookie)])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([
+ Label(SubmitButton('cancel', _('Cancel my subscription request'))),
+ SubmitButton('submit', _('Subscribe to list %(listname)s'))
+ ])
+ form.AddItem(table)
+ doc.AddItem(form)
+
+
+
+def subscription_cancel(mlist, doc, cookie):
+ # Discard this cookie
+ userdesc = Pending.confirm(cookie, expunge=1)[1]
+ lang = userdesc.language
+ i18n.set_language(lang)
+ doc.set_language(lang)
+ doc.AddItem(_('You have canceled your subscription request.'))
+
+
+
+def subscription_confirm(mlist, doc, cookie, cgidata):
+ # See the comment in admin.py about the need for the signal
+ # handler.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ mlist.Unlock()
+ sys.exit(0)
+
+ listname = mlist.real_name
+ mlist.Lock()
+ try:
+ try:
+ # Some pending values may be overridden in the form. email of
+ # course is hardcoded. ;)
+ lang = cgidata.getvalue('language')
+ i18n.set_language(lang)
+ doc.set_language(lang)
+ if cgidata.has_key('digests'):
+ try:
+ digest = int(cgidata.getvalue('digests'))
+ except ValueError:
+ digest = None
+ else:
+ digest = None
+ userdesc = Pending.confirm(cookie, expunge=0)[1]
+ fullname = cgidata.getvalue('realname', None)
+ if fullname is not None:
+ fullname = Utils.canonstr(fullname, lang)
+ overrides = UserDesc(fullname=fullname, digest=digest, lang=lang)
+ userdesc += overrides
+ op, addr, pw, digest, lang = mlist.ProcessConfirmation(
+ cookie, userdesc)
+ except Errors.MMNeedApproval:
+ title = _('Awaiting moderator approval')
+ doc.SetTitle(title)
+ doc.AddItem(Header(3, Bold(FontAttr(title, size='+2'))))
+ doc.AddItem(_("""\
+ You have successfully confirmed your subscription request to the
+ mailing list %(listname)s, however final approval is required from
+ the list moderator before you will be subscribed. Your request
+ has been forwarded to the list moderator, and you will be notified
+ of the moderator's decision."""))
+ except Errors.NotAMemberError:
+ bad_confirmation(doc, _('''Invalid confirmation string. It is
+ possible that you are attempting to confirm a request for an
+ address that has already been unsubscribed.'''))
+ except Errors.MMAlreadyAMember:
+ doc.addError(_("You are already a member of this mailing list!"))
+ else:
+ # Use the user's preferred language
+ i18n.set_language(lang)
+ doc.set_language(lang)
+ # The response
+ listname = mlist.real_name
+ title = _('Subscription request confirmed')
+ optionsurl = mlist.GetOptionsURL(addr, absolute=1)
+ doc.SetTitle(title)
+ doc.AddItem(Header(3, Bold(FontAttr(title, size='+2'))))
+ doc.AddItem(_('''\
+ You have successfully confirmed your subscription request for
+ "%(addr)s" to the %(listname)s mailing list. A separate
+ confirmation message will be sent to your email address, along
+ with your password, and other useful information and links.
+
+ <p>You can now
+ <a href="%(optionsurl)s">proceed to your membership login
+ page</a>.'''))
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+def unsubscription_cancel(mlist, doc, cookie):
+ # Discard this cookie
+ Pending.confirm(cookie, expunge=1)
+ doc.AddItem(_('You have canceled your unsubscription request.'))
+
+
+
+def unsubscription_confirm(mlist, doc, cookie):
+ # See the comment in admin.py about the need for the signal
+ # handler.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ mlist.Unlock()
+ sys.exit(0)
+
+ mlist.Lock()
+ try:
+ try:
+ # Do this in two steps so we can get the preferred language for
+ # the user who is unsubscribing.
+ op, addr = Pending.confirm(cookie, expunge=0)
+ lang = mlist.getMemberLanguage(addr)
+ i18n.set_language(lang)
+ doc.set_language(lang)
+ op, addr = mlist.ProcessConfirmation(cookie)
+ except Errors.NotAMemberError:
+ bad_confirmation(doc, _('''Invalid confirmation string. It is
+ possible that you are attempting to confirm a request for an
+ address that has already been unsubscribed.'''))
+ else:
+ # The response
+ listname = mlist.real_name
+ title = _('Unsubscription request confirmed')
+ listinfourl = mlist.GetScriptURL('listinfo', absolute=1)
+ doc.SetTitle(title)
+ doc.AddItem(Header(3, Bold(FontAttr(title, size='+2'))))
+ doc.AddItem(_("""\
+ You have successfully unsubscribed from the %(listname)s mailing
+ list. You can now <a href="%(listinfourl)s">visit the list's main
+ information page</a>."""))
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+def unsubscription_prompt(mlist, doc, cookie, addr):
+ title = _('Confirm unsubscription request')
+ doc.SetTitle(title)
+ lang = mlist.getMemberLanguage(addr)
+ i18n.set_language(lang)
+ doc.set_language(lang)
+
+ form = Form(mlist.GetScriptURL('confirm', 1))
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR)
+
+ listname = mlist.real_name
+ fullname = mlist.getMemberName(addr)
+ if fullname is None:
+ fullname = _('<em>Not available</em>')
+ else:
+ fullname = Utils.uncanonstr(fullname, lang)
+ table.AddRow([_("""Your confirmation is required in order to complete the
+ unsubscription request from the mailing list <em>%(listname)s</em>. You
+ are currently subscribed with
+
+ <ul><li><b>Real name:</b> %(fullname)s
+ <li><b>Email address:</b> %(addr)s
+ </ul>
+
+ Hit the <em>Unsubscribe</em> button below to complete the confirmation
+ process.
+
+ <p>Or hit <em>Cancel and discard</em> to cancel this unsubscription
+ request.""") + '<p><hr>'])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Hidden('cookie', cookie)])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([SubmitButton('submit', _('Unsubscribe')),
+ SubmitButton('cancel', _('Cancel and discard'))])
+
+ form.AddItem(table)
+ doc.AddItem(form)
+
+
+
+def addrchange_cancel(mlist, doc, cookie):
+ # Discard this cookie
+ Pending.confirm(cookie, expunge=1)
+ doc.AddItem(_('You have canceled your change of address request.'))
+
+
+
+def addrchange_confirm(mlist, doc, cookie):
+ # See the comment in admin.py about the need for the signal
+ # handler.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ mlist.Unlock()
+ sys.exit(0)
+
+ mlist.Lock()
+ try:
+ try:
+ # Do this in two steps so we can get the preferred language for
+ # the user who is unsubscribing.
+ op, oldaddr, newaddr, globally = Pending.confirm(cookie, expunge=0)
+ lang = mlist.getMemberLanguage(oldaddr)
+ i18n.set_language(lang)
+ doc.set_language(lang)
+ op, oldaddr, newaddr = mlist.ProcessConfirmation(cookie)
+ except Errors.NotAMemberError:
+ bad_confirmation(doc, _('''Invalid confirmation string. It is
+ possible that you are attempting to confirm a request for an
+ address that has already been unsubscribed.'''))
+ else:
+ # The response
+ listname = mlist.real_name
+ title = _('Change of address request confirmed')
+ optionsurl = mlist.GetOptionsURL(newaddr, absolute=1)
+ doc.SetTitle(title)
+ doc.AddItem(Header(3, Bold(FontAttr(title, size='+2'))))
+ doc.AddItem(_("""\
+ You have successfully changed your address on the %(listname)s
+ mailing list from <b>%(oldaddr)s</b> to <b>%(newaddr)s</b>. You
+ can now <a href="%(optionsurl)s">proceed to your membership
+ login page</a>."""))
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+def addrchange_prompt(mlist, doc, cookie, oldaddr, newaddr, globally):
+ title = _('Confirm change of address request')
+ doc.SetTitle(title)
+ lang = mlist.getMemberLanguage(oldaddr)
+ i18n.set_language(lang)
+ doc.set_language(lang)
+
+ form = Form(mlist.GetScriptURL('confirm', 1))
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR)
+
+ listname = mlist.real_name
+ fullname = mlist.getMemberName(oldaddr)
+ if fullname is None:
+ fullname = _('<em>Not available</em>')
+ else:
+ fullname = Utils.uncanonstr(fullname, lang)
+ if globally:
+ globallys = _('globally')
+ else:
+ globallys = ''
+ table.AddRow([_("""Your confirmation is required in order to complete the
+ change of address request for the mailing list <em>%(listname)s</em>. You
+ are currently subscribed with
+
+ <ul><li><b>Real name:</b> %(fullname)s
+ <li><b>Old email address:</b> %(oldaddr)s
+ </ul>
+
+ and you have requested to %(globallys)s change your email address to
+
+ <ul><li><b>New email address:</b> %(newaddr)s
+ </ul>
+
+ Hit the <em>Change address</em> button below to complete the confirmation
+ process.
+
+ <p>Or hit <em>Cancel and discard</em> to cancel this change of address
+ request.""") + '<p><hr>'])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Hidden('cookie', cookie)])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([SubmitButton('submit', _('Change address')),
+ SubmitButton('cancel', _('Cancel and discard'))])
+
+ form.AddItem(table)
+ doc.AddItem(form)
+
+
+
+def heldmsg_cancel(mlist, doc, cookie):
+ # Discard this cookie
+ title = _('Continue awaiting approval')
+ doc.SetTitle(title)
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ Pending.confirm(cookie, expunge=1)
+ table.AddRow([_('''Okay, the list moderator will still have the
+ opportunity to approve or reject this message.''')])
+ doc.AddItem(table)
+
+
+
+def heldmsg_confirm(mlist, doc, cookie):
+ # See the comment in admin.py about the need for the signal
+ # handler.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ mlist.Unlock()
+ sys.exit(0)
+
+ mlist.Lock()
+ try:
+ try:
+ # Do this in two steps so we can get the preferred language for
+ # the user who posted the message.
+ op, id = Pending.confirm(cookie, expunge=1)
+ ign, sender, msgsubject, ign, ign, ign = mlist.GetRecord(id)
+ subject = Utils.websafe(msgsubject)
+ lang = mlist.getMemberLanguage(sender)
+ i18n.set_language(lang)
+ doc.set_language(lang)
+ # Discard the message
+ mlist.HandleRequest(id, mm_cfg.DISCARD,
+ _('Sender discarded message via web.'))
+ except Errors.LostHeldMessage:
+ bad_confirmation(doc, _('''The held message with the Subject:
+ header <em>%(subject)s</em> could not be found. The most likely
+ reason for this is that the list moderator has already approved or
+ rejected the message. You were not able to cancel it in
+ time.'''))
+ else:
+ # The response
+ listname = mlist.real_name
+ title = _('Posted message canceled')
+ doc.SetTitle(title)
+ doc.AddItem(Header(3, Bold(FontAttr(title, size='+2'))))
+ doc.AddItem(_('''\
+ You have successfully canceled the posting of your message with
+ the Subject: header <em>%(subject)s</em> to the mailing list
+ %(listname)s.'''))
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+def heldmsg_prompt(mlist, doc, cookie, id):
+ title = _('Cancel held message posting')
+ doc.SetTitle(title)
+ form = Form(mlist.GetScriptURL('confirm', 1))
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ # Blarg. The list must be locked in order to interact with the ListAdmin
+ # database, even for read-only. See the comment in admin.py about the
+ # need for the signal handler.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ mlist.Unlock()
+ sys.exit(0)
+ # Get the record, but watch for KeyErrors which mean the admin has already
+ # disposed of this message.
+ mlist.Lock()
+ try:
+ try:
+ data = mlist.GetRecord(id)
+ except KeyError:
+ data = None
+ finally:
+ mlist.Unlock()
+
+ if data is None:
+ bad_confirmation(doc, _("""The held message you were referred to has
+ already been handled by the list administrator."""))
+ return
+
+ # Unpack the data and present the confirmation message
+ ign, sender, msgsubject, givenreason, ign, ign = data
+ # Now set the language to the sender's preferred.
+ lang = mlist.getMemberLanguage(sender)
+ i18n.set_language(lang)
+ doc.set_language(lang)
+
+ subject = Utils.websafe(msgsubject)
+ reason = Utils.websafe(_(givenreason))
+ listname = mlist.real_name
+ table.AddRow([_('''Your confirmation is required in order to cancel the
+ posting of your message to the mailing list <em>%(listname)s</em>:
+
+ <ul><li><b>Sender:</b> %(sender)s
+ <li><b>Subject:</b> %(subject)s
+ <li><b>Reason:</b> %(reason)s
+ </ul>
+
+ Hit the <em>Cancel posting</em> button to discard the posting.
+
+ <p>Or hit the <em>Continue awaiting approval</em> button to continue to
+ allow the list moderator to approve or reject the message.''')
+ + '<p><hr>'])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Hidden('cookie', cookie)])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([SubmitButton('submit', _('Cancel posting')),
+ SubmitButton('cancel', _('Continue awaiting approval'))])
+
+ form.AddItem(table)
+ doc.AddItem(form)
+
+
+
+def reenable_cancel(mlist, doc, cookie):
+ # Don't actually discard this cookie, since the user may decide to
+ # re-enable their membership at a future time, and we may be sending out
+ # future notifications with this cookie value.
+ doc.AddItem(_("""You have canceled the re-enabling of your membership. If
+ we continue to receive bounces from your address, it could be deleted from
+ this mailing list."""))
+
+
+
+def reenable_confirm(mlist, doc, cookie):
+ # See the comment in admin.py about the need for the signal
+ # handler.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ mlist.Unlock()
+ sys.exit(0)
+
+ mlist.Lock()
+ try:
+ try:
+ # Do this in two steps so we can get the preferred language for
+ # the user who is unsubscribing.
+ op, listname, addr = Pending.confirm(cookie, expunge=0)
+ lang = mlist.getMemberLanguage(addr)
+ i18n.set_language(lang)
+ doc.set_language(lang)
+ op, addr = mlist.ProcessConfirmation(cookie)
+ except Errors.NotAMemberError:
+ bad_confirmation(doc, _('''Invalid confirmation string. It is
+ possible that you are attempting to confirm a request for an
+ address that has already been unsubscribed.'''))
+ else:
+ # The response
+ listname = mlist.real_name
+ title = _('Membership re-enabled.')
+ optionsurl = mlist.GetOptionsURL(addr, absolute=1)
+ doc.SetTitle(title)
+ doc.AddItem(Header(3, Bold(FontAttr(title, size='+2'))))
+ doc.AddItem(_("""\
+ You have successfully re-enabled your membership in the
+ %(listname)s mailing list. You can now <a
+ href="%(optionsurl)s">visit your member options page</a>.
+ """))
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+def reenable_prompt(mlist, doc, cookie, list, member):
+ title = _('Re-enable mailing list membership')
+ doc.SetTitle(title)
+ form = Form(mlist.GetScriptURL('confirm', 1))
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ colspan=2, bgcolor=mm_cfg.WEB_HEADER_COLOR)
+
+ lang = mlist.getMemberLanguage(member)
+ i18n.set_language(lang)
+ doc.set_language(lang)
+
+ realname = mlist.real_name
+ info = mlist.getBounceInfo(member)
+ if not info:
+ listinfourl = mlist.GetScriptURL('listinfo', absolute=1)
+ # They've already be unsubscribed
+ table.AddRow([_("""We're sorry, but you have already been unsubscribed
+ from this mailing list. To re-subscribe, please visit the
+ <a href="%(listinfourl)s">list information page</a>.""")])
+ return
+
+ date = time.strftime('%A, %B %d, %Y', info.date + (0,) * 6)
+ daysleft = int(info.noticesleft *
+ mlist.bounce_you_are_disabled_warnings_interval /
+ mm_cfg.days(1))
+ # BAW: for consistency this should be changed to 'fullname' or the above
+ # 'fullname's should be changed to 'username'. Don't want to muck with
+ # the i18n catalogs though.
+ username = mlist.getMemberName(member)
+ if username is None:
+ username = _('<em>not available</em>')
+ else:
+ username = Utils.uncanonstr(username, lang)
+
+ table.AddRow([_("""Your membership in the %(realname)s mailing list is
+ currently disabled due to excessive bounces. Your confirmation is
+ required in order to re-enable delivery to your address. We have the
+ following information on file:
+
+ <ul><li><b>Member address:</b> %(member)s
+ <li><b>Member name:</b> %(username)s
+ <li><b>Last bounce received on:</b> %(date)s
+ <li><b>Approximate number of days before you are permanently removed
+ from this list:</b> %(daysleft)s
+ </ul>
+
+ Hit the <em>Re-enable membership</em> button to resume receiving postings
+ from the mailing list. Or hit the <em>Cancel</em> button to defer
+ re-enabling your membership.
+ """)])
+
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Hidden('cookie', cookie)])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([SubmitButton('submit', _('Re-enable membership')),
+ SubmitButton('cancel', _('Cancel'))])
+
+ form.AddItem(table)
+ doc.AddItem(form)
diff --git a/Mailman/Cgi/create.py b/Mailman/Cgi/create.py
new file mode 100644
index 00000000..31e16269
--- /dev/null
+++ b/Mailman/Cgi/create.py
@@ -0,0 +1,410 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Create mailing lists through the web."""
+
+import sys
+import os
+import signal
+import cgi
+import sha
+from types import ListType
+
+from Mailman import mm_cfg
+from Mailman import MailList
+from Mailman import Message
+from Mailman import Errors
+from Mailman import i18n
+from Mailman.htmlformat import *
+from Mailman.Logging.Syslog import syslog
+
+# Set up i18n
+_ = i18n._
+i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+
+
+def main():
+ doc = Document()
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+ cgidata = cgi.FieldStorage()
+ parts = Utils.GetPathPieces()
+ if parts:
+ # Bad URL specification
+ title = _('Bad URL specification')
+ doc.SetTitle(title)
+ doc.AddItem(
+ Header(3, Bold(FontAttr(title, color='#ff0000', size='+2'))))
+ syslog('error', 'Bad URL specification: %s', parts)
+ elif cgidata.has_key('doit'):
+ # We must be processing the list creation request
+ process_request(doc, cgidata)
+ elif cgidata.has_key('clear'):
+ request_creation(doc)
+ else:
+ # Put up the list creation request form
+ request_creation(doc)
+ doc.AddItem('<hr>')
+ # Always add the footer and print the document
+ doc.AddItem(_('Return to the ') +
+ Link(Utils.ScriptURL('listinfo'),
+ _('general list overview')).Format())
+ doc.AddItem(_('<br>Return to the ') +
+ Link(Utils.ScriptURL('admin'),
+ _('administrative list overview')).Format())
+ doc.AddItem(MailmanLogo())
+ print doc.Format()
+
+
+
+def process_request(doc, cgidata):
+ # Lowercase the listname since this is treated as the "internal" name.
+ listname = cgidata.getvalue('listname', '').strip().lower()
+ owner = cgidata.getvalue('owner', '').strip()
+ try:
+ autogen = int(cgidata.getvalue('autogen', '0'))
+ except ValueError:
+ autogen = 0
+ try:
+ notify = int(cgidata.getvalue('notify', '0'))
+ except ValueError:
+ notify = 0
+ try:
+ moderate = int(cgidata.getvalue('moderate', '0'))
+ except ValueError:
+ moderate = mm_cfg.DEFAULT_DEFAULT_MEMBER_MODERATION
+
+ password = cgidata.getvalue('password', '').strip()
+ confirm = cgidata.getvalue('confirm', '').strip()
+ auth = cgidata.getvalue('auth', '').strip()
+ langs = cgidata.getvalue('langs', [mm_cfg.DEFAULT_SERVER_LANGUAGE])
+
+ if type(langs) <> ListType:
+ langs = [langs]
+ # Sanity check
+ if '@' in listname:
+ request_creation(doc, cgidata,
+ _('List name must not include "@": %(listname)s'))
+ return
+ if Utils.list_exists(listname):
+ # BAW: should we tell them the list already exists? This could be
+ # used to mine/guess the existance of non-advertised lists. Then
+ # again, that can be done in other ways already, so oh well.
+ request_creation(doc, cgidata, _('List already exists: %(listname)s'))
+ return
+ if not listname:
+ request_creation(doc, cgidata,
+ _('You forgot to enter the list name'))
+ return
+ if not owner:
+ request_creation(doc, cgidata,
+ _('You forgot to specify the list owner'))
+ return
+
+ if autogen:
+ if password or confirm:
+ request_creation(
+ doc, cgidata,
+ _('''Leave the initial password (and confirmation) fields
+ blank if you want Mailman to autogenerate the list
+ passwords.'''))
+ return
+ password = confirm = Utils.MakeRandomPassword(length=8)
+ else:
+ if password <> confirm:
+ request_creation(doc, cgidata,
+ _('Initial list passwords do not match'))
+ return
+ if not password:
+ request_creation(
+ doc, cgidata,
+ # The little <!-- ignore --> tag is used so that this string
+ # differs from the one in bin/newlist. The former is destined
+ # for the web while the latter is destined for email, so they
+ # must be different entries in the message catalog.
+ _('The list password cannot be empty<!-- ignore -->'))
+ return
+ # The authorization password must be non-empty, and it must match either
+ # the list creation password or the site admin password
+ ok = 0
+ if auth:
+ ok = Utils.check_global_password(auth, 0)
+ if not ok:
+ ok = Utils.check_global_password(auth)
+ if not ok:
+ request_creation(
+ doc, cgidata,
+ _('You are not authorized to create new mailing lists'))
+ return
+ # We've got all the data we need, so go ahead and try to create the list
+ # See admin.py for why we need to set up the signal handler.
+ mlist = MailList.MailList()
+
+ def sigterm_handler(signum, frame, mlist=mlist):
+ # Make sure the list gets unlocked...
+ mlist.Unlock()
+ # ...and ensure we exit, otherwise race conditions could cause us to
+ # enter MailList.Save() while we're in the unlocked state, and that
+ # could be bad!
+ sys.exit(0)
+
+ try:
+ # Install the emergency shutdown signal handler
+ signal.signal(signal.SIGTERM, sigterm_handler)
+
+ pw = sha.new(password).hexdigest()
+ # Guarantee that all newly created files have the proper permission.
+ # proper group ownership should be assured by the autoconf script
+ # enforcing that all directories have the group sticky bit set
+ oldmask = os.umask(002)
+ try:
+ try:
+ mlist.Create(listname, owner, pw, langs)
+ finally:
+ os.umask(oldmask)
+ except Errors.MMBadEmailError, s:
+ request_creation(doc, cgidata,
+ _('Bad owner email address: %(s)s'))
+ return
+ except Errors.MMListAlreadyExistsError:
+ request_creation(doc, cgidata,
+ _('List already exists: %(listname)s'))
+ return
+ except Errors.BadListNameError, s:
+ request_creation(doc, cgidata,
+ _('Illegal list name: %(s)s'))
+ return
+ except Errors.MMListError:
+ request_creation(
+ doc, cgidata,
+ _('''Some unknown error occurred while creating the list.
+ Please contact the site administrator for assistance.'''))
+ return
+
+ # Initialize the host_name and web_page_url attributes, based on
+ # virtual hosting settings and the request environment variables.
+ hostname = Utils.get_domain()
+ mlist.default_member_moderation = moderate
+ mlist.web_page_url = mm_cfg.DEFAULT_URL_PATTERN % hostname
+ mlist.host_name = mm_cfg.VIRTUAL_HOSTS.get(
+ hostname, mm_cfg.DEFAULT_EMAIL_HOST)
+ mlist.Save()
+ finally:
+ # Now be sure to unlock the list. It's okay if we get a signal here
+ # because essentially, the signal handler will do the same thing. And
+ # unlocking is unconditional, so it's not an error if we unlock while
+ # we're already unlocked.
+ mlist.Unlock()
+
+ # Now do the MTA-specific list creation tasks
+ if mm_cfg.MTA:
+ modname = 'Mailman.MTA.' + mm_cfg.MTA
+ __import__(modname)
+ sys.modules[modname].create(mlist, cgi=1)
+
+ # And send the notice to the list owner.
+ if notify:
+ siteadmin = Utils.get_site_email(mlist.host_name, 'admin')
+ text = Utils.maketext(
+ 'newlist.txt',
+ {'listname' : listname,
+ 'password' : password,
+ 'admin_url' : mlist.GetScriptURL('admin', absolute=1),
+ 'listinfo_url': mlist.GetScriptURL('listinfo', absolute=1),
+ 'requestaddr' : mlist.GetRequestEmail(),
+ 'siteowner' : siteadmin,
+ }, mlist=mlist)
+ msg = Message.UserNotification(
+ owner, siteadmin,
+ _('Your new mailing list: %(listname)s'),
+ text, mlist.preferred_language)
+ msg.send(mlist)
+
+ # Success!
+ listinfo_url = mlist.GetScriptURL('listinfo', absolute=1)
+ admin_url = mlist.GetScriptURL('admin', absolute=1)
+ create_url = Utils.ScriptURL('create')
+
+ title = _('Mailing list creation results')
+ doc.SetTitle(title)
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ table.AddRow([_('''You have successfully created the mailing list
+ <b>%(listname)s</b> and notification has been sent to the list owner
+ <b>%(owner)s</b>. You can now:''')])
+ ullist = UnorderedList()
+ ullist.AddItem(Link(listinfo_url, _("Visit the list's info page")))
+ ullist.AddItem(Link(admin_url, _("Visit the list's admin page")))
+ ullist.AddItem(Link(create_url, _('Create another list')))
+ table.AddRow([ullist])
+ doc.AddItem(table)
+
+
+
+# Because the cgi module blows
+class Dummy:
+ def getvalue(self, name, default):
+ return default
+dummy = Dummy()
+
+
+
+def request_creation(doc, cgidata=dummy, errmsg=None):
+ # What virtual domain are we using?
+ hostname = Utils.get_domain()
+ # Set up the document
+ title = _('Create a %(hostname)s Mailing List')
+ doc.SetTitle(title)
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ # Add any error message
+ if errmsg:
+ table.AddRow([Header(3, Bold(
+ FontAttr(_('Error: '), color='#ff0000', size='+2').Format() +
+ Italic(errmsg).Format()))])
+ table.AddRow([_("""You can create a new mailing list by entering the
+ relevant information into the form below. The name of the mailing list
+ will be used as the primary address for posting messages to the list, so
+ it should be lowercased. You will not be able to change this once the
+ list is created.
+
+ <p>You also need to enter the email address of the initial list owner.
+ Once the list is created, the list owner will be given notification, along
+ with the initial list password. The list owner will then be able to
+ modify the password and add or remove additional list owners.
+
+ <p>If you want Mailman to automatically generate the initial list admin
+ password, click on `Yes' in the autogenerate field below, and leave the
+ initial list password fields empty.
+
+ <p>You must have the proper authorization to create new mailing lists.
+ Each site should have a <em>list creator's</em> password, which you can
+ enter in the field at the bottom. Note that the site administrator's
+ password can also be used for authentication.
+ """)])
+ # Build the form for the necessary input
+ GREY = mm_cfg.WEB_ADMINITEM_COLOR
+ form = Form(Utils.ScriptURL('create'))
+ ftable = Table(border=0, cols='2', width='100%',
+ cellspacing=3, cellpadding=4)
+
+ ftable.AddRow([Center(Italic(_('List Identity')))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2)
+
+ ftable.AddRow([Label(_('Name of list:')),
+ TextBox('listname', cgidata.getvalue('listname', ''))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+
+ ftable.AddRow([Label(_('Initial list owner address:')),
+ TextBox('owner', cgidata.getvalue('owner', ''))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+
+ try:
+ autogen = int(cgidata.getvalue('autogen', '0'))
+ except ValueError:
+ autogen = 0
+ ftable.AddRow([Label(_('Auto-generate initial list password?')),
+ RadioButtonArray('autogen', (_('No'), _('Yes')),
+ checked=autogen,
+ values=(0, 1))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+
+ ftable.AddRow([Label(_('Initial list password:')),
+ PasswordBox('password', cgidata.getvalue('password', ''))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+
+ ftable.AddRow([Label(_('Confirm initial password:')),
+ PasswordBox('confirm', cgidata.getvalue('confirm', ''))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+
+ try:
+ notify = int(cgidata.getvalue('notify', '1'))
+ except ValueError:
+ notify = 1
+
+ ftable.AddRow([Center(Italic(_('List Characteristics')))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2)
+
+ ftable.AddRow([
+ Label(_("""Should new members be quarantined before they
+ are allowed to post unmoderated to this list? Answer <em>Yes</em> to hold
+ new member postings for moderator approval by default.""")),
+ RadioButtonArray('moderate', (_('No'), _('Yes')),
+ checked=mm_cfg.DEFAULT_DEFAULT_MEMBER_MODERATION,
+ values=(0,1))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+ # Create the table of initially supported languages, sorted on the long
+ # name of the language.
+ revmap = {}
+ for key, (name, charset) in mm_cfg.LC_DESCRIPTIONS.items():
+ revmap[_(name)] = key
+ langnames = revmap.keys()
+ langnames.sort()
+ langs = []
+ for name in langnames:
+ langs.append(revmap[name])
+ try:
+ langi = langs.index(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+ except ValueError:
+ # Someone must have deleted the servers's preferred language. Could
+ # be other trouble lurking!
+ langi = 0
+ # BAW: we should preserve the list of checked languages across form
+ # invocations.
+ checked = [0] * len(langs)
+ checked[langi] = 1
+ deflang = _(Utils.GetLanguageDescr(mm_cfg.DEFAULT_SERVER_LANGUAGE))
+ ftable.AddRow([Label(_(
+ '''Initial list of supported languages. <p>Note that if you do not
+ select at least one initial language, the list will use the server
+ default language of %(deflang)s''')),
+ CheckBoxArray('langs',
+ [_(Utils.GetLanguageDescr(L)) for L in langs],
+ checked=checked,
+ values=langs)])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+
+ ftable.AddRow([Label(_('Send "list created" email to list owner?')),
+ RadioButtonArray('notify', (_('No'), _('Yes')),
+ checked=notify,
+ values=(0, 1))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+
+ ftable.AddRow(['<hr>'])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2)
+ ftable.AddRow([Label(_("List creator's (authentication) password:")),
+ PasswordBox('auth')])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+
+ ftable.AddRow([Center(SubmitButton('doit', _('Create List'))),
+ Center(SubmitButton('clear', _('Clear Form')))])
+ form.AddItem(ftable)
+ table.AddRow([form])
+ doc.AddItem(table)
diff --git a/Mailman/Cgi/edithtml.py b/Mailman/Cgi/edithtml.py
new file mode 100644
index 00000000..cd235162
--- /dev/null
+++ b/Mailman/Cgi/edithtml.py
@@ -0,0 +1,170 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Script which implements admin editing of the list's html templates."""
+
+import os
+import cgi
+import errno
+
+from Mailman import Utils
+from Mailman import MailList
+from Mailman.htmlformat import *
+from Mailman.HTMLFormatter import HTMLFormatter
+from Mailman import Errors
+from Mailman.Cgi import Auth
+from Mailman.Logging.Syslog import syslog
+from Mailman import i18n
+
+_ = i18n._
+
+
+
+def main():
+ # Trick out pygettext since we want to mark template_data as translatable,
+ # but we don't want to actually translate it here.
+ def _(s):
+ return s
+
+ template_data = (
+ ('listinfo.html', _('General list information page')),
+ ('subscribe.html', _('Subscribe results page')),
+ ('options.html', _('User specific options page')),
+ )
+
+ _ = i18n._
+ doc = Document()
+
+ # Set up the system default language
+ i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+ parts = Utils.GetPathPieces()
+ if not parts:
+ doc.AddItem(Header(2, _("List name is required.")))
+ print doc.Format()
+ return
+
+ listname = parts[0].lower()
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ # Avoid cross-site scripting attacks
+ safelistname = Utils.websafe(listname)
+ doc.AddItem(Header(2, _('No such list <em>%(safelistname)s</em>')))
+ print doc.Format()
+ syslog('error', 'No such list "%s": %s', listname, e)
+ return
+
+ # Now that we have a valid list, set the language to its default
+ i18n.set_language(mlist.preferred_language)
+ doc.set_language(mlist.preferred_language)
+
+ # Must be authenticated to get any farther
+ cgidata = cgi.FieldStorage()
+
+ # Editing the html for a list is limited to the list admin and site admin.
+ if not mlist.WebAuthenticate((mm_cfg.AuthListAdmin,
+ mm_cfg.AuthSiteAdmin),
+ cgidata.getvalue('adminpw', '')):
+ if cgidata.has_key('admlogin'):
+ # This is a re-authorization attempt
+ msg = Bold(FontSize('+1', _('Authorization failed.'))).Format()
+ else:
+ msg = ''
+ Auth.loginpage(mlist, 'admin', msg=msg)
+ return
+
+ realname = mlist.real_name
+ if len(parts) > 1:
+ template_name = parts[1]
+ for (template, info) in template_data:
+ if template == template_name:
+ template_info = _(info)
+ doc.SetTitle(_(
+ '%(realname)s -- Edit html for %(template_info)s'))
+ break
+ else:
+ # Avoid cross-site scripting attacks
+ safetemplatename = Utils.websafe(template_name)
+ doc.SetTitle(_('Edit HTML : Error'))
+ doc.AddItem(Header(2, _("%(safetemplatename)s: Invalid template")))
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+ return
+ else:
+ doc.SetTitle(_('%(realname)s -- HTML Page Editing'))
+ doc.AddItem(Header(1, _('%(realname)s -- HTML Page Editing')))
+ doc.AddItem(Header(2, _('Select page to edit:')))
+ template_list = UnorderedList()
+ for (template, info) in template_data:
+ l = Link(mlist.GetScriptURL('edithtml') + '/' + template, _(info))
+ template_list.AddItem(l)
+ doc.AddItem(FontSize("+2", template_list))
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+ return
+
+ try:
+ if cgidata.keys():
+ ChangeHTML(mlist, cgidata, template_name, doc)
+ FormatHTML(mlist, doc, template_name, template_info)
+ finally:
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+
+
+
+def FormatHTML(mlist, doc, template_name, template_info):
+ doc.AddItem(Header(1,'%s:' % mlist.real_name))
+ doc.AddItem(Header(1, template_info))
+ doc.AddItem('<hr>')
+
+ link = Link(mlist.GetScriptURL('admin'),
+ _('View or edit the list configuration information.'))
+
+ doc.AddItem(FontSize("+1", link))
+ doc.AddItem('<p>')
+ doc.AddItem('<hr>')
+ form = Form(mlist.GetScriptURL('edithtml') + '/' + template_name)
+ text = Utils.websafe(Utils.maketext(template_name, raw=1, mlist=mlist))
+ form.AddItem(TextArea('html_code', text, rows=40, cols=75))
+ form.AddItem('<p>' + _('When you are done making changes...'))
+ form.AddItem(SubmitButton('submit', _('Submit Changes')))
+ doc.AddItem(form)
+
+
+
+def ChangeHTML(mlist, cgi_info, template_name, doc):
+ if not cgi_info.has_key('html_code'):
+ doc.AddItem(Header(3,_("Can't have empty html page.")))
+ doc.AddItem(Header(3,_("HTML Unchanged.")))
+ doc.AddItem('<hr>')
+ return
+ code = cgi_info['html_code'].value
+ langdir = os.path.join(mlist.fullpath(), mlist.preferred_language)
+ # Make sure the directory exists
+ try:
+ os.mkdir(langdir, 02775)
+ except OSError, e:
+ if e.errno <> errno.EEXIST: raise
+ fp = open(os.path.join(langdir, template_name), 'w')
+ try:
+ fp.write(code)
+ finally:
+ fp.close()
+ doc.AddItem(Header(3, _('HTML successfully updated.')))
+ doc.AddItem('<hr>')
diff --git a/Mailman/Cgi/listinfo.py b/Mailman/Cgi/listinfo.py
new file mode 100644
index 00000000..d9e4d266
--- /dev/null
+++ b/Mailman/Cgi/listinfo.py
@@ -0,0 +1,206 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Produce listinfo page, primary web entry-point to mailing lists.
+"""
+
+# No lock needed in this script, because we don't change data.
+
+import os
+import cgi
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import MailList
+from Mailman import Errors
+from Mailman import i18n
+from Mailman.htmlformat import *
+from Mailman.Logging.Syslog import syslog
+
+# Set up i18n
+_ = i18n._
+i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+
+
+def main():
+ parts = Utils.GetPathPieces()
+ if not parts:
+ listinfo_overview()
+ return
+
+ listname = parts[0].lower()
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ # Avoid cross-site scripting attacks
+ safelistname = Utils.websafe(listname)
+ listinfo_overview(_('No such list <em>%(safelistname)s</em>'))
+ syslog('error', 'No such list "%s": %s', listname, e)
+ return
+
+ # See if the user want to see this page in other language
+ cgidata = cgi.FieldStorage()
+ language = cgidata.getvalue('language', mlist.preferred_language)
+ i18n.set_language(language)
+ list_listinfo(mlist, language)
+
+
+
+def listinfo_overview(msg=''):
+ # Present the general listinfo overview
+ hostname = Utils.get_domain()
+ # Set up the document and assign it the correct language. The only one we
+ # know about at the moment is the server's default.
+ doc = Document()
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+ legend = _("%(hostname)s Mailing Lists")
+ doc.SetTitle(legend)
+
+ table = Table(border=0, width="100%")
+ table.AddRow([Center(Header(2, legend))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+
+ # Skip any mailing lists that isn't advertised.
+ advertised = []
+ listnames = Utils.list_names()
+ listnames.sort()
+
+ for name in listnames:
+ mlist = MailList.MailList(name, lock=0)
+ if mlist.advertised:
+ if mm_cfg.VIRTUAL_HOST_OVERVIEW and \
+ mlist.web_page_url.find(hostname) == -1:
+ # List is for different identity of this host - skip it.
+ continue
+ else:
+ advertised.append(mlist)
+
+ if msg:
+ greeting = FontAttr(msg, color="ff5060", size="+1")
+ else:
+ greeting = FontAttr(_('Welcome!'), size='+2')
+
+ welcome = [greeting]
+ mailmanlink = Link(mm_cfg.MAILMAN_URL, _('Mailman')).Format()
+ if not advertised:
+ welcome.extend(
+ _('''<p>There currently are no publicly-advertised
+ %(mailmanlink)s mailing lists on %(hostname)s.'''))
+ else:
+ welcome.append(
+ _('''<p>Below is a listing of all the public mailing lists on
+ %(hostname)s. Click on a list name to get more information about
+ the list, or to subscribe, unsubscribe, and change the preferences
+ on your subscription.'''))
+
+ # set up some local variables
+ adj = msg and _('right') or ''
+ siteowner = Utils.get_site_email()
+ welcome.extend(
+ (_(''' To visit the general information page for an unadvertised list,
+ open a URL similar to this one, but with a '/' and the %(adj)s
+ list name appended.
+ <p>List administrators, you can visit '''),
+ Link(Utils.ScriptURL('admin'),
+ _('the list admin overview page')),
+ _(''' to find the management interface for your list.
+ <p>Send questions or comments to '''),
+ Link('mailto:' + siteowner, siteowner),
+ '.<p>'))
+
+ table.AddRow([apply(Container, welcome)])
+ table.AddCellInfo(max(table.GetCurrentRowIndex(), 0), 0, colspan=2)
+
+ if advertised:
+ table.AddRow(['&nbsp;', '&nbsp;'])
+ table.AddRow([Bold(FontAttr(_('List'), size='+2')),
+ Bold(FontAttr(_('Description'), size='+2'))
+ ])
+ highlight = 1
+ for mlist in advertised:
+ table.AddRow(
+ [Link(mlist.GetScriptURL('listinfo'), Bold(mlist.real_name)),
+ mlist.description or Italic(_('[no description available]'))])
+ if highlight and mm_cfg.WEB_HIGHLIGHT_COLOR:
+ table.AddRowInfo(table.GetCurrentRowIndex(),
+ bgcolor=mm_cfg.WEB_HIGHLIGHT_COLOR)
+ highlight = not highlight
+
+ doc.AddItem(table)
+ doc.AddItem('<hr>')
+ doc.AddItem(MailmanLogo())
+ print doc.Format()
+
+
+
+def list_listinfo(mlist, lang):
+ # Generate list specific listinfo
+ doc = HeadlessDocument()
+ doc.set_language(lang)
+
+ replacements = mlist.GetStandardReplacements(lang)
+
+ if not mlist.digestable or not mlist.nondigestable:
+ replacements['<mm-digest-radio-button>'] = ""
+ replacements['<mm-undigest-radio-button>'] = ""
+ replacements['<mm-digest-question-start>'] = '<!-- '
+ replacements['<mm-digest-question-end>'] = ' -->'
+ else:
+ replacements['<mm-digest-radio-button>'] = mlist.FormatDigestButton()
+ replacements['<mm-undigest-radio-button>'] = \
+ mlist.FormatUndigestButton()
+ replacements['<mm-digest-question-start>'] = ''
+ replacements['<mm-digest-question-end>'] = ''
+ replacements['<mm-plain-digests-button>'] = \
+ mlist.FormatPlainDigestsButton()
+ replacements['<mm-mime-digests-button>'] = mlist.FormatMimeDigestsButton()
+ replacements['<mm-subscribe-box>'] = mlist.FormatBox('email', size=30)
+ replacements['<mm-subscribe-button>'] = mlist.FormatButton(
+ 'email-button', text=_('Subscribe'))
+ replacements['<mm-new-password-box>'] = mlist.FormatSecureBox('pw')
+ replacements['<mm-confirm-password>'] = mlist.FormatSecureBox('pw-conf')
+ replacements['<mm-subscribe-form-start>'] = mlist.FormatFormStart(
+ 'subscribe')
+ # Roster form substitutions
+ replacements['<mm-roster-form-start>'] = mlist.FormatFormStart('roster')
+ replacements['<mm-roster-option>'] = mlist.FormatRosterOptionForUser(lang)
+ # Options form substitutions
+ replacements['<mm-options-form-start>'] = mlist.FormatFormStart('options')
+ replacements['<mm-editing-options>'] = mlist.FormatEditingOption(lang)
+ replacements['<mm-info-button>'] = SubmitButton('UserOptions',
+ _('Edit Options')).Format()
+ # If only one language is enabled for this mailing list, omit the choice
+ # buttons.
+ if len(mlist.GetAvailableLanguages()) == 1:
+ displang = ''
+ else:
+ displang = mlist.FormatButton('displang-button',
+ text = _("View this page in"))
+ replacements['<mm-displang-box>'] = displang
+ replacements['<mm-lang-form-start>'] = mlist.FormatFormStart('listinfo')
+ replacements['<mm-fullname-box>'] = mlist.FormatBox('fullname', size=30)
+
+ # Do the expansion.
+ doc.AddItem(mlist.ParseTags('listinfo.html', replacements, lang))
+ print doc.Format()
+
+
+
+if __name__ == "__main__":
+ main()
diff --git a/Mailman/Cgi/options.py b/Mailman/Cgi/options.py
new file mode 100644
index 00000000..da4562f7
--- /dev/null
+++ b/Mailman/Cgi/options.py
@@ -0,0 +1,950 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Produce and handle the member options."""
+
+import sys
+import os
+import cgi
+import signal
+import urllib
+from types import ListType
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import MailList
+from Mailman import Errors
+from Mailman import MemberAdaptor
+from Mailman import i18n
+from Mailman.htmlformat import *
+from Mailman.Logging.Syslog import syslog
+
+SLASH = '/'
+SETLANGUAGE = -1
+
+# Set up i18n
+_ = i18n._
+i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+
+
+def main():
+ doc = Document()
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+ parts = Utils.GetPathPieces()
+ lenparts = parts and len(parts)
+ if not parts or lenparts < 1:
+ title = _('CGI script error')
+ doc.SetTitle(title)
+ doc.AddItem(Header(2, title))
+ doc.addError(_('Invalid options to CGI script.'))
+ doc.AddItem('<hr>')
+ doc.AddItem(MailmanLogo())
+ print doc.Format()
+ return
+
+ # get the list and user's name
+ listname = parts[0].lower()
+ # open list
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ # Avoid cross-site scripting attacks
+ safelistname = Utils.websafe(listname)
+ title = _('CGI script error')
+ doc.SetTitle(title)
+ doc.AddItem(Header(2, title))
+ doc.addError(_('No such list <em>%(safelistname)s</em>'))
+ doc.AddItem('<hr>')
+ doc.AddItem(MailmanLogo())
+ print doc.Format()
+ syslog('error', 'No such list "%s": %s\n', listname, e)
+ return
+
+ # The total contents of the user's response
+ cgidata = cgi.FieldStorage(keep_blank_values=1)
+
+ # Set the language for the page. If we're coming from the listinfo cgi,
+ # we might have a 'language' key in the cgi data. That was an explicit
+ # preference to view the page in, so we should honor that here. If that's
+ # not available, use the list's default language.
+ language = cgidata.getvalue('language', mlist.preferred_language)
+ i18n.set_language(language)
+ doc.set_language(language)
+
+ if lenparts < 2:
+ user = cgidata.getvalue('email')
+ if not user:
+ # If we're coming from the listinfo page and we left the email
+ # address field blank, it's not an error. listinfo.html names the
+ # button UserOptions; we can use that as the descriminator.
+ if not cgidata.getvalue('UserOptions'):
+ doc.addError(_('No address given'))
+ loginpage(mlist, doc, None, cgidata)
+ print doc.Format()
+ return
+ else:
+ user = Utils.LCDomain(Utils.UnobscureEmail(SLASH.join(parts[1:])))
+
+ # Avoid cross-site scripting attacks
+ safeuser = Utils.websafe(user)
+ # Sanity check the user, but be careful about leaking membership
+ # information when we're using private rosters.
+ if not mlist.isMember(user) and mlist.private_roster == 0:
+ doc.addError(_('No such member: %(safeuser)s.'))
+ loginpage(mlist, doc, None, cgidata)
+ print doc.Format()
+ return
+
+ # Find the case preserved email address (the one the user subscribed with)
+ lcuser = user.lower()
+ try:
+ cpuser = mlist.getMemberCPAddress(lcuser)
+ except Errors.NotAMemberError:
+ # This happens if the user isn't a member but we've got private rosters
+ cpuser = None
+ if lcuser == cpuser:
+ cpuser = None
+
+ # And now we know the user making the request, so set things up to for the
+ # user's stored preferred language, overridden by any form settings for
+ # their new language preference.
+ userlang = cgidata.getvalue('language', mlist.getMemberLanguage(user))
+ doc.set_language(userlang)
+ i18n.set_language(userlang)
+
+ # See if this is VARHELP on topics.
+ varhelp = None
+ if cgidata.has_key('VARHELP'):
+ varhelp = cgidata['VARHELP'].value
+ elif os.environ.get('QUERY_STRING'):
+ # POST methods, even if their actions have a query string, don't get
+ # put into FieldStorage's keys :-(
+ qs = cgi.parse_qs(os.environ['QUERY_STRING']).get('VARHELP')
+ if qs and type(qs) == types.ListType:
+ varhelp = qs[0]
+ if varhelp:
+ topic_details(mlist, doc, user, cpuser, userlang, varhelp)
+ return
+
+ # Are we processing an unsubscription request from the login screen?
+ if cgidata.has_key('login-unsub'):
+ # Because they can't supply a password for unsubscribing, we'll need
+ # to do the confirmation dance.
+ if mlist.isMember(user):
+ mlist.ConfirmUnsubscription(user, userlang)
+ doc.addError(_('The confirmation email has been sent.'), tag='')
+ else:
+ # Not a member
+ if mlist.private_roster == 0:
+ # Public rosters
+ doc.addError(_('No such member: %(safeuser)s.'))
+ else:
+ syslog('mischief',
+ 'Unsub attempt of non-member w/ private rosters: %s',
+ user)
+ doc.addError(_('The confirmation email has been sent.'),
+ tag='')
+ loginpage(mlist, doc, user, cgidata)
+ print doc.Format()
+ return
+
+ # Are we processing a password reminder from the login screen?
+ if cgidata.has_key('login-remind'):
+ if mlist.isMember(user):
+ mlist.MailUserPassword(user)
+ doc.addError(
+ _('A reminder of your password has been emailed to you.'),
+ tag='')
+ else:
+ # Not a member
+ if mlist.private_roster == 0:
+ # Public rosters
+ doc.addError(_('No such member: %(safeuser)s.'))
+ else:
+ syslog('mischief',
+ 'Reminder attempt of non-member w/ private rosters: %s',
+ user)
+ doc.addError(
+ _('A reminder of your password has been emailed to you.'),
+ tag='')
+ loginpage(mlist, doc, user, cgidata)
+ print doc.Format()
+ return
+
+ # Authenticate, possibly using the password supplied in the login page
+ password = cgidata.getvalue('password', '').strip()
+
+ if not mlist.WebAuthenticate((mm_cfg.AuthUser,
+ mm_cfg.AuthListAdmin,
+ mm_cfg.AuthSiteAdmin),
+ password, user):
+ # Not authenticated, so throw up the login page again. If they tried
+ # to authenticate via cgi (instead of cookie), then print an error
+ # message.
+ if cgidata.has_key('password'):
+ doc.addError(_('Authentication failed.'))
+ # So as not to allow membership leakage, prompt for the email
+ # address and the password here.
+ if mlist.private_roster <> 0:
+ syslog('mischief',
+ 'Login failure with private rosters: %s',
+ user)
+ user = None
+ loginpage(mlist, doc, user, cgidata)
+ print doc.Format()
+ return
+
+ # From here on out, the user is okay to view and modify their membership
+ # options. The first set of checks does not require the list to be
+ # locked.
+
+ if cgidata.has_key('logout'):
+ print mlist.ZapCookie(mm_cfg.AuthUser, user)
+ loginpage(mlist, doc, user, cgidata)
+ print doc.Format()
+ return
+
+ if cgidata.has_key('emailpw'):
+ mlist.MailUserPassword(user)
+ options_page(
+ mlist, doc, user, cpuser, userlang,
+ _('A reminder of your password has been emailed to you.'))
+ print doc.Format()
+ return
+
+ if cgidata.has_key('othersubs'):
+ hostname = mlist.host_name
+ title = _('List subscriptions for %(user)s on %(hostname)s')
+ doc.SetTitle(title)
+ doc.AddItem(Header(2, title))
+ doc.AddItem(_('''Click on a link to visit your options page for the
+ requested mailing list.'''))
+
+ # Troll through all the mailing lists that match host_name and see if
+ # the user is a member. If so, add it to the list.
+ onlists = []
+ for gmlist in lists_of_member(mlist, user) + [mlist]:
+ url = gmlist.GetOptionsURL(user)
+ link = Link(url, gmlist.real_name)
+ onlists.append((gmlist.real_name, link))
+ onlists.sort()
+ items = OrderedList(*[link for name, link in onlists])
+ doc.AddItem(items)
+ print doc.Format()
+ return
+
+ if cgidata.has_key('change-of-address'):
+ # We could be changing the user's full name, email address, or both.
+ # Watch out for non-ASCII characters in the member's name.
+ membername = cgidata.getvalue('fullname')
+ # Canonicalize the member's name
+ membername = Utils.canonstr(membername, language)
+ newaddr = cgidata.getvalue('new-address')
+ confirmaddr = cgidata.getvalue('confirm-address')
+
+ oldname = mlist.getMemberName(user)
+ set_address = set_membername = 0
+
+ # See if the user wants to change their email address globally
+ globally = cgidata.getvalue('changeaddr-globally')
+
+ # We will change the member's name under the following conditions:
+ # - membername has a value
+ # - membername has no value, but they /used/ to have a membername
+ if membername and membername <> oldname:
+ # Setting it to a new value
+ set_membername = 1
+ if not membername and oldname:
+ # Unsetting it
+ set_membername = 1
+ # We will change the user's address if both newaddr and confirmaddr
+ # are non-blank, have the same value, and aren't the currently
+ # subscribed email address (when compared case-sensitively). If both
+ # are blank, but membername is set, we ignore it, otherwise we print
+ # an error.
+ msg = ''
+ if newaddr and confirmaddr:
+ if newaddr <> confirmaddr:
+ options_page(mlist, doc, user, cpuser, userlang,
+ _('Addresses did not match!'))
+ print doc.Format()
+ return
+ if newaddr == user:
+ options_page(mlist, doc, user, cpuser, userlang,
+ _('You are already using that email address'))
+ print doc.Format()
+ return
+ # If they're requesting to subscribe an address which is already a
+ # member, and they're /not/ doing it globally, then refuse.
+ # Otherwise, we'll agree to do it globally (with a warning
+ # message) and let ApprovedChangeMemberAddress() handle already a
+ # member issues.
+ if mlist.isMember(newaddr):
+ safenewaddr = Utils.websafe(newaddr)
+ if globally:
+ listname = mlist.real_name
+ msg += _("""\
+The new address you requested %(newaddr)s is already a member of the
+%(listname)s mailing list, however you have also requested a global change of
+address. Upon confirmation, any other mailing list containing the address
+%(user)s will be changed. """)
+ # Don't return
+ else:
+ options_page(
+ mlist, doc, user, cpuser, userlang,
+ _('The new address is already a member: %(newaddr)s'))
+ print doc.Format()
+ return
+ set_address = 1
+ elif (newaddr or confirmaddr) and not set_membername:
+ options_page(mlist, doc, user, cpuser, userlang,
+ _('Addresses may not be blank'))
+ print doc.Format()
+ return
+
+ # Standard sigterm handler.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ mlist.Unlock()
+ sys.exit(0)
+
+ signal.signal(signal.SIGTERM, sigterm_handler)
+ if set_address:
+ # Register the pending change after the list is locked
+ msg += _('A confirmation message has been sent to %(newaddr)s. ')
+ mlist.Lock()
+ try:
+ try:
+ mlist.ChangeMemberAddress(user, newaddr, globally)
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+ except Errors.MMBadEmailError:
+ msg = _('Bad email address provided')
+ except Errors.MMHostileAddress:
+ msg = _('Illegal email address provided')
+ except Errors.MMAlreadyAMember:
+ msg = _('%(newaddr)s is already a member of the list.')
+
+ if set_membername:
+ mlist.Lock()
+ try:
+ mlist.ChangeMemberName(user, membername, globally)
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+ msg += _('Member name successfully changed. ')
+
+ options_page(mlist, doc, user, cpuser, userlang, msg)
+ print doc.Format()
+ return
+
+ if cgidata.has_key('changepw'):
+ newpw = cgidata.getvalue('newpw')
+ confirmpw = cgidata.getvalue('confpw')
+ if not newpw or not confirmpw:
+ options_page(mlist, doc, user, cpuser, userlang,
+ _('Passwords may not be blank'))
+ print doc.Format()
+ return
+ if newpw <> confirmpw:
+ options_page(mlist, doc, user, cpuser, userlang,
+ _('Passwords did not match!'))
+ print doc.Format()
+ return
+
+ # See if the user wants to change their passwords globally
+ mlists = [mlist]
+ if cgidata.getvalue('pw-globally'):
+ mlists.extend(lists_of_member(mlist, user))
+
+ for gmlist in mlists:
+ change_password(gmlist, user, newpw, confirmpw)
+
+ # Regenerate the cookie so a re-authorization isn't necessary
+ print mlist.MakeCookie(mm_cfg.AuthUser, user)
+ options_page(mlist, doc, user, cpuser, userlang,
+ _('Password successfully changed.'))
+ print doc.Format()
+ return
+
+ if cgidata.has_key('unsub'):
+ # Was the confirming check box turned on?
+ if not cgidata.getvalue('unsubconfirm'):
+ options_page(
+ mlist, doc, user, cpuser, userlang,
+ _('''You must confirm your unsubscription request by turning
+ on the checkbox below the <em>Unsubscribe</em> button. You
+ have not been unsubscribed!'''))
+ print doc.Format()
+ return
+
+ # Standard signal handler
+ def sigterm_handler(signum, frame, mlist=mlist):
+ mlist.Unlock()
+ sys.exit(0)
+
+ # Okay, zap them. Leave them sitting at the list's listinfo page. We
+ # must own the list lock, and we want to make sure the user (BAW: and
+ # list admin?) is informed of the removal.
+ signal.signal(signal.SIGTERM, sigterm_handler)
+ mlist.Lock()
+ needapproval = 0
+ try:
+ try:
+ mlist.DeleteMember(
+ user, 'via the member options page', userack=1)
+ except Errors.MMNeedApproval:
+ needapproval = 1
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+ # Now throw up some results page, with appropriate links. We can't
+ # drop them back into their options page, because that's gone now!
+ fqdn_listname = mlist.GetListEmail()
+ owneraddr = mlist.GetOwnerEmail()
+ url = mlist.GetScriptURL('listinfo', absolute=1)
+
+ title = _('Unsubscription results')
+ doc.SetTitle(title)
+ doc.AddItem(Header(2, title))
+ if needapproval:
+ doc.AddItem(_("""Your unsubscription request has been received and
+ forwarded on to the list moderators for approval. You will
+ receive notification once the list moderators have made their
+ decision."""))
+ else:
+ doc.AddItem(_("""You have been successfully unsubscribed from the
+ mailing list %(fqdn_listname)s. If you were receiving digest
+ deliveries you may get one more digest. If you have any questions
+ about your unsubscription, please contact the list owners at
+ %(owneraddr)s."""))
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+ return
+
+ if cgidata.has_key('options-submit'):
+ # Digest action flags
+ digestwarn = 0
+ cantdigest = 0
+ mustdigest = 0
+
+ newvals = []
+ # First figure out which options have changed. The item names come
+ # from FormatOptionButton() in HTMLFormatter.py
+ for item, flag in (('digest', mm_cfg.Digests),
+ ('mime', mm_cfg.DisableMime),
+ ('dontreceive', mm_cfg.DontReceiveOwnPosts),
+ ('ackposts', mm_cfg.AcknowledgePosts),
+ ('disablemail', mm_cfg.DisableDelivery),
+ ('conceal', mm_cfg.ConcealSubscription),
+ ('remind', mm_cfg.SuppressPasswordReminder),
+ ('rcvtopic', mm_cfg.ReceiveNonmatchingTopics),
+ ('nodupes', mm_cfg.DontReceiveDuplicates),
+ ):
+ try:
+ newval = int(cgidata.getvalue(item))
+ except (TypeError, ValueError):
+ newval = None
+
+ # Skip this option if there was a problem or it wasn't changed.
+ # Note that delivery status is handled separate from the options
+ # flags.
+ if newval is None:
+ continue
+ elif flag == mm_cfg.DisableDelivery:
+ status = mlist.getDeliveryStatus(user)
+ # Here, newval == 0 means enable, newval == 1 means disable
+ if not newval and status <> MemberAdaptor.ENABLED:
+ newval = MemberAdaptor.ENABLED
+ elif newval and status == MemberAdaptor.ENABLED:
+ newval = MemberAdaptor.BYUSER
+ else:
+ continue
+ elif newval == mlist.getMemberOption(user, flag):
+ continue
+ # Should we warn about one more digest?
+ if flag == mm_cfg.Digests and \
+ newval == 0 and mlist.getMemberOption(user, flag):
+ digestwarn = 1
+
+ newvals.append((flag, newval))
+
+ # The user language is handled a little differently
+ if userlang not in mlist.GetAvailableLanguages():
+ newvals.append((SETLANGUAGE, mlist.preferred_language))
+ else:
+ newvals.append((SETLANGUAGE, userlang))
+
+ # Process user selected topics, but don't make the changes to the
+ # MailList object; we must do that down below when the list is
+ # locked.
+ topicnames = cgidata.getvalue('usertopic')
+ if topicnames:
+ # Some topics were selected. topicnames can actually be a string
+ # or a list of strings depending on whether more than one topic
+ # was selected or not.
+ if not isinstance(topicnames, ListType):
+ # Assume it was a bare string, so listify it
+ topicnames = [topicnames]
+ # unquote the topic names
+ topicnames = [urllib.unquote_plus(n) for n in topicnames]
+
+ # The standard sigterm handler (see above)
+ def sigterm_handler(signum, frame, mlist=mlist):
+ mlist.Unlock()
+ sys.exit(0)
+
+ # Now, lock the list and perform the changes
+ mlist.Lock()
+ try:
+ signal.signal(signal.SIGTERM, sigterm_handler)
+ # `values' is a tuple of flags and the web values
+ for flag, newval in newvals:
+ # Handle language settings differently
+ if flag == SETLANGUAGE:
+ mlist.setMemberLanguage(user, newval)
+ # Handle delivery status separately
+ elif flag == mm_cfg.DisableDelivery:
+ mlist.setDeliveryStatus(user, newval)
+ else:
+ try:
+ mlist.setMemberOption(user, flag, newval)
+ except Errors.CantDigestError:
+ cantdigest = 1
+ except Errors.MustDigestError:
+ mustdigest = 1
+ # Set the topics information.
+ mlist.setMemberTopics(user, topicnames)
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+ # A bag of attributes for the global options
+ class Global:
+ enable = None
+ remind = None
+ nodupes = None
+ mime = None
+ def __nonzero__(self):
+ return len(self.__dict__.keys()) > 0
+
+ globalopts = Global()
+
+ # The enable/disable option and the password remind option may have
+ # their global flags sets.
+ if cgidata.getvalue('deliver-globally'):
+ # Yes, this is inefficient, but the list is so small it shouldn't
+ # make much of a difference.
+ for flag, newval in newvals:
+ if flag == mm_cfg.DisableDelivery:
+ globalopts.enable = newval
+ break
+
+ if cgidata.getvalue('remind-globally'):
+ for flag, newval in newvals:
+ if flag == mm_cfg.SuppressPasswordReminder:
+ globalopts.remind = newval
+ break
+
+ if cgidata.getvalue('nodupes-globally'):
+ for flag, newval in newvals:
+ if flag == mm_cfg.DontReceiveDuplicates:
+ globalopts.nodupes = newval
+ break
+
+ if cgidata.getvalue('mime-globally'):
+ for flag, newval in newvals:
+ if flag == mm_cfg.DisableMime:
+ globalopts.mime = newval
+ break
+
+ if globalopts:
+ for gmlist in lists_of_member(mlist, user):
+ global_options(gmlist, user, globalopts)
+
+ # Now print the results
+ if cantdigest:
+ msg = _('''The list administrator has disabled digest delivery for
+ this list, so your delivery option has not been set. However your
+ other options have been set successfully.''')
+ elif mustdigest:
+ msg = _('''The list administrator has disabled non-digest delivery
+ for this list, so your delivery option has not been set. However
+ your other options have been set successfully.''')
+ else:
+ msg = _('You have successfully set your options.')
+
+ if digestwarn:
+ msg += _('You may get one last digest.')
+
+ options_page(mlist, doc, user, cpuser, userlang, msg)
+ print doc.Format()
+ return
+
+ options_page(mlist, doc, user, cpuser, userlang)
+ print doc.Format()
+
+
+
+def options_page(mlist, doc, user, cpuser, userlang, message=''):
+ # The bulk of the document will come from the options.html template, which
+ # includes it's own html armor (head tags, etc.). Suppress the head that
+ # Document() derived pages get automatically.
+ doc.suppress_head = 1
+
+ if mlist.obscure_addresses:
+ presentable_user = Utils.ObscureEmail(user, for_text=1)
+ if cpuser is not None:
+ cpuser = Utils.ObscureEmail(cpuser, for_text=1)
+ else:
+ presentable_user = user
+
+ fullname = Utils.uncanonstr(mlist.getMemberName(user), userlang)
+ if fullname:
+ presentable_user += ', %s' % fullname
+
+ # Do replacements
+ replacements = mlist.GetStandardReplacements(userlang)
+ replacements['<mm-results>'] = Bold(FontSize('+1', message)).Format()
+ replacements['<mm-digest-radio-button>'] = mlist.FormatOptionButton(
+ mm_cfg.Digests, 1, user)
+ replacements['<mm-undigest-radio-button>'] = mlist.FormatOptionButton(
+ mm_cfg.Digests, 0, user)
+ replacements['<mm-plain-digests-button>'] = mlist.FormatOptionButton(
+ mm_cfg.DisableMime, 1, user)
+ replacements['<mm-mime-digests-button>'] = mlist.FormatOptionButton(
+ mm_cfg.DisableMime, 0, user)
+ replacements['<mm-global-mime-button>'] = (
+ CheckBox('mime-globally', 1, checked=0).Format())
+ replacements['<mm-delivery-enable-button>'] = mlist.FormatOptionButton(
+ mm_cfg.DisableDelivery, 0, user)
+ replacements['<mm-delivery-disable-button>'] = mlist.FormatOptionButton(
+ mm_cfg.DisableDelivery, 1, user)
+ replacements['<mm-disabled-notice>'] = mlist.FormatDisabledNotice(user)
+ replacements['<mm-dont-ack-posts-button>'] = mlist.FormatOptionButton(
+ mm_cfg.AcknowledgePosts, 0, user)
+ replacements['<mm-ack-posts-button>'] = mlist.FormatOptionButton(
+ mm_cfg.AcknowledgePosts, 1, user)
+ replacements['<mm-receive-own-mail-button>'] = mlist.FormatOptionButton(
+ mm_cfg.DontReceiveOwnPosts, 0, user)
+ replacements['<mm-dont-receive-own-mail-button>'] = (
+ mlist.FormatOptionButton(mm_cfg.DontReceiveOwnPosts, 1, user))
+ replacements['<mm-dont-get-password-reminder-button>'] = (
+ mlist.FormatOptionButton(mm_cfg.SuppressPasswordReminder, 1, user))
+ replacements['<mm-get-password-reminder-button>'] = (
+ mlist.FormatOptionButton(mm_cfg.SuppressPasswordReminder, 0, user))
+ replacements['<mm-public-subscription-button>'] = (
+ mlist.FormatOptionButton(mm_cfg.ConcealSubscription, 0, user))
+ replacements['<mm-hide-subscription-button>'] = mlist.FormatOptionButton(
+ mm_cfg.ConcealSubscription, 1, user)
+ replacements['<mm-dont-receive-duplicates-button>'] = (
+ mlist.FormatOptionButton(mm_cfg.DontReceiveDuplicates, 1, user))
+ replacements['<mm-receive-duplicates-button>'] = (
+ mlist.FormatOptionButton(mm_cfg.DontReceiveDuplicates, 0, user))
+ replacements['<mm-unsubscribe-button>'] = (
+ mlist.FormatButton('unsub', _('Unsubscribe')) + '<br>' +
+ CheckBox('unsubconfirm', 1, checked=0).Format() +
+ _('<em>Yes, I really want to unsubscribe</em>'))
+ replacements['<mm-new-pass-box>'] = mlist.FormatSecureBox('newpw')
+ replacements['<mm-confirm-pass-box>'] = mlist.FormatSecureBox('confpw')
+ replacements['<mm-change-pass-button>'] = (
+ mlist.FormatButton('changepw', _("Change My Password")))
+ replacements['<mm-other-subscriptions-submit>'] = (
+ mlist.FormatButton('othersubs',
+ _('List my other subscriptions')))
+ replacements['<mm-form-start>'] = (
+ mlist.FormatFormStart('options', user))
+ replacements['<mm-user>'] = user
+ replacements['<mm-presentable-user>'] = presentable_user
+ replacements['<mm-email-my-pw>'] = mlist.FormatButton(
+ 'emailpw', (_('Email My Password To Me')))
+ replacements['<mm-umbrella-notice>'] = (
+ mlist.FormatUmbrellaNotice(user, _("password")))
+ replacements['<mm-logout-button>'] = (
+ mlist.FormatButton('logout', _('Log out')))
+ replacements['<mm-options-submit-button>'] = mlist.FormatButton(
+ 'options-submit', _('Submit My Changes'))
+ replacements['<mm-global-pw-changes-button>'] = (
+ CheckBox('pw-globally', 1, checked=0).Format())
+ replacements['<mm-global-deliver-button>'] = (
+ CheckBox('deliver-globally', 1, checked=0).Format())
+ replacements['<mm-global-remind-button>'] = (
+ CheckBox('remind-globally', 1, checked=0).Format())
+ replacements['<mm-global-nodupes-button>'] = (
+ CheckBox('nodupes-globally', 1, checked=0).Format())
+
+ days = int(mm_cfg.PENDING_REQUEST_LIFE / mm_cfg.days(1))
+ if days > 1:
+ units = _('days')
+ else:
+ units = _('day')
+ replacements['<mm-pending-days>'] = _('%(days)d %(units)s')
+
+ replacements['<mm-new-address-box>'] = mlist.FormatBox('new-address')
+ replacements['<mm-confirm-address-box>'] = mlist.FormatBox(
+ 'confirm-address')
+ replacements['<mm-change-address-button>'] = mlist.FormatButton(
+ 'change-of-address', _('Change My Address and Name'))
+ replacements['<mm-global-change-of-address>'] = CheckBox(
+ 'changeaddr-globally', 1, checked=0).Format()
+ replacements['<mm-fullname-box>'] = mlist.FormatBox(
+ 'fullname', value=fullname)
+
+ # Create the topics radios. BAW: what if the list admin deletes a topic,
+ # but the user still wants to get that topic message?
+ usertopics = mlist.getMemberTopics(user)
+ if mlist.topics:
+ table = Table(border="0")
+ for name, pattern, description, emptyflag in mlist.topics:
+ quotedname = urllib.quote_plus(name)
+ details = Link(mlist.GetScriptURL('options') +
+ '/%s/?VARHELP=%s' % (user, quotedname),
+ ' (Details)')
+ if name in usertopics:
+ checked = 1
+ else:
+ checked = 0
+ table.AddRow([CheckBox('usertopic', quotedname, checked=checked),
+ name + details.Format()])
+ topicsfield = table.Format()
+ else:
+ topicsfield = _('<em>No topics defined</em>')
+ replacements['<mm-topics>'] = topicsfield
+ replacements['<mm-suppress-nonmatching-topics>'] = (
+ mlist.FormatOptionButton(mm_cfg.ReceiveNonmatchingTopics, 0, user))
+ replacements['<mm-receive-nonmatching-topics>'] = (
+ mlist.FormatOptionButton(mm_cfg.ReceiveNonmatchingTopics, 1, user))
+
+ if cpuser is not None:
+ replacements['<mm-case-preserved-user>'] = _('''
+You are subscribed to this list with the case-preserved address
+<em>%(cpuser)s</em>.''')
+ else:
+ replacements['<mm-case-preserved-user>'] = ''
+
+ doc.AddItem(mlist.ParseTags('options.html', replacements, userlang))
+
+
+
+def loginpage(mlist, doc, user, cgidata):
+ realname = mlist.real_name
+ actionurl = mlist.GetScriptURL('options')
+ if user is None:
+ title = _('%(realname)s list: member options login page')
+ extra = _('email address and ')
+ else:
+ title = _('%(realname)s list: member options for user %(user)s')
+ obuser = Utils.ObscureEmail(user)
+ extra = ''
+ # Set up the title
+ doc.SetTitle(title)
+ # We use a subtable here so we can put a language selection box in
+ lang = cgidata.getvalue('language', mlist.preferred_language)
+ table = Table(width='100%', border=0, cellspacing=4, cellpadding=5)
+ # If only one language is enabled for this mailing list, omit the choice
+ # buttons.
+ table.AddRow([Center(Header(2, title))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ if len(mlist.GetAvailableLanguages()) > 1:
+ langform = Form(actionurl)
+ langform.AddItem(SubmitButton('displang-button',
+ _('View this page in')))
+ langform.AddItem(mlist.GetLangSelectBox(lang))
+ if user:
+ langform.AddItem(Hidden('email', user))
+ table.AddRow([Center(langform)])
+ doc.AddItem(table)
+ # Preamble
+ # Set up the login page
+ form = Form(actionurl)
+ table = Table(width='100%', border=0, cellspacing=4, cellpadding=5)
+ table.AddRow([_("""In order to change your membership option, you must
+ first log in by giving your %(extra)smembership password in the section
+ below. If you don't remember your membership password, you can have it
+ emailed to you by clicking on the button below. If you just want to
+ unsubscribe from this list, click on the <em>Unsubscribe</em> button and a
+ confirmation message will be sent to you.
+
+ <p><strong><em>Important:</em></strong> From this point on, you must have
+ cookies enabled in your browser, otherwise none of your changes will take
+ effect.
+ """)])
+ # Password and login button
+ ptable = Table(width='50%', border=0, cellspacing=4, cellpadding=5)
+ if user is None:
+ ptable.AddRow([Label(_('Email address:')),
+ TextBox('email', size=20)])
+ else:
+ ptable.AddRow([Hidden('email', user)])
+ ptable.AddRow([Label(_('Password:')),
+ PasswordBox('password', size=20)])
+ ptable.AddRow([Center(SubmitButton('login', _('Log in')))])
+ ptable.AddCellInfo(ptable.GetCurrentRowIndex(), 0, colspan=2)
+ table.AddRow([Center(ptable)])
+ # Unsubscribe section
+ table.AddRow([Center(Header(2, _('Unsubscribe')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+
+ table.AddRow([_("""By clicking on the <em>Unsubscribe</em> button, a
+ confirmation message will be emailed to you. This message will have a
+ link that you should click on to complete the removal process (you can
+ also confirm by email; see the instructions in the confirmation
+ message).""")])
+
+ table.AddRow([Center(SubmitButton('login-unsub', _('Unsubscribe')))])
+ # Password reminder section
+ table.AddRow([Center(Header(2, _('Password reminder')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+
+ table.AddRow([_("""By clicking on the <em>Remind</em> button, your
+ password will be emailed to you.""")])
+
+ table.AddRow([Center(SubmitButton('login-remind', _('Remind')))])
+ # Finish up glomming together the login page
+ form.AddItem(table)
+ doc.AddItem(form)
+ doc.AddItem(mlist.GetMailmanFooter())
+
+
+
+def lists_of_member(mlist, user):
+ hostname = mlist.host_name
+ onlists = []
+ for listname in Utils.list_names():
+ # The current list will always handle things in the mainline
+ if listname == mlist.internal_name():
+ continue
+ glist = MailList.MailList(listname, lock=0)
+ if glist.host_name <> hostname:
+ continue
+ if not glist.isMember(user):
+ continue
+ onlists.append(glist)
+ return onlists
+
+
+
+def change_password(mlist, user, newpw, confirmpw):
+ # This operation requires the list lock, so let's set up the signal
+ # handling so the list lock will get released when the user hits the
+ # browser stop button.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ # Make sure the list gets unlocked...
+ mlist.Unlock()
+ # ...and ensure we exit, otherwise race conditions could cause us to
+ # enter MailList.Save() while we're in the unlocked state, and that
+ # could be bad!
+ sys.exit(0)
+
+ # Must own the list lock!
+ mlist.Lock()
+ try:
+ # Install the emergency shutdown signal handler
+ signal.signal(signal.SIGTERM, sigterm_handler)
+ # change the user's password. The password must already have been
+ # compared to the confirmpw and otherwise been vetted for
+ # acceptability.
+ mlist.setMemberPassword(user, newpw)
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+def global_options(mlist, user, globalopts):
+ # Is there anything to do?
+ for attr in dir(globalopts):
+ if attr.startswith('_'):
+ continue
+ if getattr(globalopts, attr) is not None:
+ break
+ else:
+ return
+
+ def sigterm_handler(signum, frame, mlist=mlist):
+ # Make sure the list gets unlocked...
+ mlist.Unlock()
+ # ...and ensure we exit, otherwise race conditions could cause us to
+ # enter MailList.Save() while we're in the unlocked state, and that
+ # could be bad!
+ sys.exit(0)
+
+ # Must own the list lock!
+ mlist.Lock()
+ try:
+ # Install the emergency shutdown signal handler
+ signal.signal(signal.SIGTERM, sigterm_handler)
+
+ if globalopts.enable is not None:
+ mlist.setDeliveryStatus(user, globalopts.enable)
+
+ if globalopts.remind is not None:
+ mlist.setMemberOption(user, mm_cfg.SuppressPasswordReminder,
+ globalopts.remind)
+
+ if globalopts.nodupes is not None:
+ mlist.setMemberOption(user, mm_cfg.DontReceiveDuplicates,
+ globalopts.nodupes)
+
+ if globalopts.mime is not None:
+ mlist.setMemberOption(user, mm_cfg.DisableMime, globalopts.mime)
+
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+def topic_details(mlist, doc, user, cpuser, userlang, varhelp):
+ # Find out which topic the user wants to get details of
+ reflist = varhelp.split('/')
+ name = None
+ topicname = _('<missing>')
+ if len(reflist) == 1:
+ topicname = urllib.unquote_plus(reflist[0])
+ for name, pattern, description, emptyflag in mlist.topics:
+ if name == topicname:
+ break
+ else:
+ name = None
+
+ if not name:
+ options_page(mlist, doc, user, cpuser, userlang,
+ _('Requested topic is not valid: %(topicname)s'))
+ print doc.Format()
+ return
+
+ table = Table(border=3, width='100%')
+ table.AddRow([Center(Bold(_('Topic filter details')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0, colspan=2,
+ bgcolor=mm_cfg.WEB_SUBHEADER_COLOR)
+ table.AddRow([Bold(Label(_('Name:'))),
+ Utils.websafe(name)])
+ table.AddRow([Bold(Label(_('Pattern (as regexp):'))),
+ '<pre>' + Utils.websafe(pattern) + '</pre>'])
+ table.AddRow([Bold(Label(_('Description:'))),
+ Utils.websafe(description)])
+ # Make colors look nice
+ for row in range(1, 4):
+ table.AddCellInfo(row, 0, bgcolor=mm_cfg.WEB_ADMINITEM_COLOR)
+
+ options_page(mlist, doc, user, cpuser, userlang, table.Format())
+ print doc.Format()
diff --git a/Mailman/Cgi/private.py b/Mailman/Cgi/private.py
new file mode 100644
index 00000000..6b7af70a
--- /dev/null
+++ b/Mailman/Cgi/private.py
@@ -0,0 +1,162 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Provide a password-interface wrapper around private archives.
+"""
+
+import sys
+import os
+import cgi
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import MailList
+from Mailman import Errors
+from Mailman import i18n
+from Mailman.htmlformat import *
+from Mailman.Logging.Syslog import syslog
+
+# Set up i18n. Until we know which list is being requested, we use the
+# server's default.
+_ = i18n._
+i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+
+
+def true_path(path):
+ "Ensure that the path is safe by removing .."
+ path = path.replace('../', '')
+ path = path.replace('./', '')
+ return path[1:]
+
+
+def content_type(path):
+ if path[-3:] == '.gz':
+ path = path[:-3]
+ if path[-4:] == '.txt':
+ return 'text/plain'
+ return 'text/html'
+
+
+
+def main():
+ doc = Document()
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+ parts = Utils.GetPathPieces()
+ if not parts:
+ doc.SetTitle(_("Private Archive Error"))
+ doc.AddItem(Header(3, _("You must specify a list.")))
+ print doc.Format()
+ return
+
+ path = os.environ.get('PATH_INFO')
+ # BAW: This needs to be converted to the Site module abstraction
+ true_filename = os.path.join(
+ mm_cfg.PRIVATE_ARCHIVE_FILE_DIR,
+ true_path(path))
+
+ listname = parts[0].lower()
+ mboxfile = ''
+ if len(parts) > 1:
+ mboxfile = parts[1]
+
+ # See if it's the list's mbox file is being requested
+ if listname.endswith('.mbox') and mboxfile.endswith('.mbox') and \
+ listname[:-5] == mboxfile[:-5]:
+ listname = listname[:-5]
+ else:
+ mboxfile = ''
+
+ # If it's a directory, we have to append index.html in this script. We
+ # must also check for a gzipped file, because the text archives are
+ # usually stored in compressed form.
+ if os.path.isdir(true_filename):
+ true_filename = true_filename + '/index.html'
+ if not os.path.exists(true_filename) and \
+ os.path.exists(true_filename + '.gz'):
+ true_filename = true_filename + '.gz'
+
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ # Avoid cross-site scripting attacks
+ safelistname = Utils.websafe(listname)
+ msg = _('No such list <em>%(safelistname)s</em>')
+ doc.SetTitle(_("Private Archive Error - %(msg)s"))
+ doc.AddItem(Header(2, msg))
+ print doc.Format()
+ syslog('error', 'No such list "%s": %s\n', listname, e)
+ return
+
+ i18n.set_language(mlist.preferred_language)
+ doc.set_language(mlist.preferred_language)
+
+ cgidata = cgi.FieldStorage()
+ username = cgidata.getvalue('username', '')
+ password = cgidata.getvalue('password', '')
+
+ is_auth = 0
+ realname = mlist.real_name
+ message = ''
+
+ if not mlist.WebAuthenticate((mm_cfg.AuthUser,
+ mm_cfg.AuthListModerator,
+ mm_cfg.AuthListAdmin,
+ mm_cfg.AuthSiteAdmin),
+ password, username):
+ if cgidata.has_key('submit'):
+ # This is a re-authorization attempt
+ message = Bold(FontSize('+1', _('Authorization failed.'))).Format()
+ # Output the password form
+ charset = Utils.GetCharSet(mlist.preferred_language)
+ print 'Content-type: text/html; charset=' + charset + '\n\n'
+ while path and path[0] == '/':
+ path=path[1:] # Remove leading /'s
+ print Utils.maketext(
+ 'private.html',
+ {'action' : mlist.GetScriptURL('private', absolute=1),
+ 'realname': mlist.real_name,
+ 'message' : message,
+ }, mlist=mlist)
+ return
+
+ lang = mlist.getMemberLanguage(username)
+ i18n.set_language(lang)
+ doc.set_language(lang)
+
+ # Authorization confirmed... output the desired file
+ try:
+ ctype = content_type(path)
+ if mboxfile:
+ f = open(os.path.join(mlist.archive_dir() + '.mbox',
+ mlist.internal_name() + '.mbox'))
+ ctype = 'text/plain'
+ elif true_filename[-3:] == '.gz':
+ import gzip
+ f = gzip.open(true_filename, 'r')
+ else:
+ f = open(true_filename, 'r')
+ except IOError:
+ msg = _('Private archive file not found')
+ doc.SetTitle(msg)
+ doc.AddItem(Header(2, msg))
+ print doc.Format()
+ syslog('error', 'Private archive file not found: %s', true_filename)
+ else:
+ print 'Content-type: %s\n' % ctype
+ sys.stdout.write(f.read())
+ f.close()
diff --git a/Mailman/Cgi/rmlist.py b/Mailman/Cgi/rmlist.py
new file mode 100644
index 00000000..fab57edd
--- /dev/null
+++ b/Mailman/Cgi/rmlist.py
@@ -0,0 +1,242 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Remove/delete mailing lists through the web."""
+
+import os
+import cgi
+import sys
+import errno
+import shutil
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import MailList
+from Mailman import Errors
+from Mailman import i18n
+from Mailman.htmlformat import *
+from Mailman.Logging.Syslog import syslog
+
+# Set up i18n
+_ = i18n._
+i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+
+
+def main():
+ doc = Document()
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+ cgidata = cgi.FieldStorage()
+ parts = Utils.GetPathPieces()
+
+ if not parts:
+ # Bad URL specification
+ title = _('Bad URL specification')
+ doc.SetTitle(title)
+ doc.AddItem(
+ Header(3, Bold(FontAttr(title, color='#ff0000', size='+2'))))
+ doc.AddItem('<hr>')
+ doc.AddItem(MailmanLogo())
+ print doc.Format()
+ syslog('error', 'Bad URL specification: %s', parts)
+ return
+
+ listname = parts[0].lower()
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ # Avoid cross-site scripting attacks
+ safelistname = Utils.websafe(listname)
+ title = _('No such list <em>%(safelistname)s</em>')
+ doc.SetTitle(title)
+ doc.AddItem(
+ Header(3,
+ Bold(FontAttr(title, color='#ff0000', size='+2'))))
+ doc.AddItem('<hr>')
+ doc.AddItem(MailmanLogo())
+ print doc.Format()
+ syslog('error', 'No such list "%s": %s\n', listname, e)
+ return
+
+ # Now that we have a valid mailing list, set the language
+ i18n.set_language(mlist.preferred_language)
+ doc.set_language(mlist.preferred_language)
+
+ # Be sure the list owners are not sneaking around!
+ if not mm_cfg.OWNERS_CAN_DELETE_THEIR_OWN_LISTS:
+ title = _("You're being a sneaky list owner!")
+ doc.SetTitle(title)
+ doc.AddItem(
+ Header(3, Bold(FontAttr(title, color='#ff0000', size='+2'))))
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+ syslog('mischief', 'Attempt to sneakily delete a list: %s', listname)
+ return
+
+ if cgidata.has_key('doit'):
+ process_request(doc, cgidata, mlist)
+ print doc.Format()
+ return
+
+ request_deletion(doc, mlist)
+ # Always add the footer and print the document
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+
+
+
+def process_request(doc, cgidata, mlist):
+ password = cgidata.getvalue('password', '').strip()
+ try:
+ delarchives = int(cgidata.getvalue('delarchives', '0'))
+ except ValueError:
+ delarchives = 0
+
+ # Removing a list is limited to the list-creator (a.k.a. list-destroyer),
+ # the list-admin, or the site-admin. Don't use WebAuthenticate here
+ # because we want to be sure the actual typed password is valid, not some
+ # password sitting in a cookie.
+ if mlist.Authenticate((mm_cfg.AuthCreator,
+ mm_cfg.AuthListAdmin,
+ mm_cfg.AuthSiteAdmin),
+ password) == mm_cfg.UnAuthorized:
+ request_deletion(
+ doc, mlist,
+ _('You are not authorized to delete this mailing list'))
+ return
+
+ # Do the MTA-specific list deletion tasks
+ if mm_cfg.MTA:
+ modname = 'Mailman.MTA.' + mm_cfg.MTA
+ __import__(modname)
+ sys.modules[modname].remove(mlist, cgi=1)
+
+ REMOVABLES = ['lists/%s']
+
+ if delarchives:
+ REMOVABLES.extend(['archives/private/%s',
+ 'archives/private/%s.mbox',
+ 'archives/public/%s',
+ 'archives/public/%s.mbox',
+ ])
+
+ problems = 0
+ listname = mlist.internal_name()
+ for dirtmpl in REMOVABLES:
+ dir = os.path.join(mm_cfg.VAR_PREFIX, dirtmpl % listname)
+ if os.path.islink(dir):
+ try:
+ os.unlink(dir)
+ except OSError, e:
+ if e.errno not in (errno.EACCES, errno.EPERM): raise
+ problems += 1
+ syslog('error',
+ 'link %s not deleted due to permission problems',
+ dir)
+ elif os.path.isdir(dir):
+ try:
+ shutil.rmtree(dir)
+ except OSError, e:
+ if e.errno not in (errno.EACCES, errno.EPERM): raise
+ problems += 1
+ syslog('error',
+ 'directory %s not deleted due to permission problems',
+ dir)
+
+ title = _('Mailing list deletion results')
+ doc.SetTitle(title)
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+ if not problems:
+ table.AddRow([_('''You have successfully deleted the mailing list
+ <b>%(listname)s</b>.''')])
+ else:
+ sitelist = Utils.get_site_email(mlist.host_name)
+ table.AddRow([_('''There were some problems deleting the mailing list
+ <b>%(listname)s</b>. Contact your site administrator at %(sitelist)s
+ for details.''')])
+ doc.AddItem(table)
+ doc.AddItem('<hr>')
+ doc.AddItem(_('Return to the ') +
+ Link(Utils.ScriptURL('listinfo'),
+ _('general list overview')).Format())
+ doc.AddItem(_('<br>Return to the ') +
+ Link(Utils.ScriptURL('admin'),
+ _('administrative list overview')).Format())
+ doc.AddItem(MailmanLogo())
+
+
+
+def request_deletion(doc, mlist, errmsg=None):
+ realname = mlist.real_name
+ title = _('Permanently remove mailing list <em>%(realname)s</em>')
+ doc.SetTitle(title)
+
+ table = Table(border=0, width='100%')
+ table.AddRow([Center(Bold(FontAttr(title, size='+1')))])
+ table.AddCellInfo(table.GetCurrentRowIndex(), 0,
+ bgcolor=mm_cfg.WEB_HEADER_COLOR)
+
+ # Add any error message
+ if errmsg:
+ table.AddRow([Header(3, Bold(
+ FontAttr(_('Error: '), color='#ff0000', size='+2').Format() +
+ Italic(errmsg).Format()))])
+
+ table.AddRow([_("""This page allows you as the list owner, to permanent
+ remove this mailing list from the system. <strong>This action is not
+ undoable</strong> so you should undertake it only if you are absolutely
+ sure this mailing list has served its purpose and is no longer necessary.
+
+ <p>Note that no warning will be sent to your list members and after this
+ action, any subsequent messages sent to the mailing list, or any of its
+ administrative addreses will bounce.
+
+ <p>You also have the option of removing the archives for this mailing list
+ at this time. It is almost always recommended that you do
+ <strong>not</strong> remove the archives, since they serve as the
+ historical record of your mailing list.
+
+ <p>For your safety, you will be asked to reconfirm the list password.
+ """)])
+ GREY = mm_cfg.WEB_ADMINITEM_COLOR
+ form = Form(mlist.GetScriptURL('rmlist'))
+ ftable = Table(border=0, cols='2', width='100%',
+ cellspacing=3, cellpadding=4)
+
+ ftable.AddRow([Label(_('List password:')), PasswordBox('password')])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+
+ ftable.AddRow([Label(_('Also delete archives?')),
+ RadioButtonArray('delarchives', (_('No'), _('Yes')),
+ checked=0, values=(0, 1))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, bgcolor=GREY)
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 1, bgcolor=GREY)
+
+ ftable.AddRow([Center(Link(
+ mlist.GetScriptURL('admin'),
+ _('<b>Cancel</b> and return to list administration')))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2)
+
+ ftable.AddRow([Center(SubmitButton('doit', _('Delete this list')))])
+ ftable.AddCellInfo(ftable.GetCurrentRowIndex(), 0, colspan=2)
+ form.AddItem(ftable)
+ table.AddRow([form])
+ doc.AddItem(table)
diff --git a/Mailman/Cgi/roster.py b/Mailman/Cgi/roster.py
new file mode 100644
index 00000000..71c06240
--- /dev/null
+++ b/Mailman/Cgi/roster.py
@@ -0,0 +1,129 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Produce subscriber roster, using listinfo form data, roster.html template.
+
+Takes listname in PATH_INFO.
+"""
+
+
+# We don't need to lock in this script, because we're never going to change
+# data.
+
+import sys
+import os
+import cgi
+import urllib
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import MailList
+from Mailman import Errors
+from Mailman import i18n
+from Mailman.htmlformat import *
+from Mailman.Logging.Syslog import syslog
+
+# Set up i18n
+_ = i18n._
+i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+
+
+def main():
+ parts = Utils.GetPathPieces()
+ if not parts:
+ error_page(_('Invalid options to CGI script'))
+ return
+
+ listname = parts[0].lower()
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ # Avoid cross-site scripting attacks
+ safelistname = Utils.websafe(listname)
+ error_page(_('No such list <em>%(safelistname)s</em>'))
+ syslog('error', 'roster: no such list "%s": %s', listname, e)
+ return
+
+ cgidata = cgi.FieldStorage()
+
+ # messages in form should go in selected language (if any...)
+ if cgidata.has_key('language'):
+ lang = cgidata['language'].value
+ else:
+ lang = mlist.preferred_language
+
+ i18n.set_language(lang)
+
+ # Perform authentication for protected rosters. If the roster isn't
+ # protected, then anybody can see the pages. If members-only or
+ # "admin"-only, then we try to cookie authenticate the user, and failing
+ # that, we check roster-email and roster-pw fields for a valid password.
+ # (also allowed: the list moderator, the list admin, and the site admin).
+ if mlist.private_roster == 0:
+ # No privacy
+ ok = 1
+ elif mlist.private_roster == 1:
+ # Members only
+ addr = cgidata.getvalue('roster-email', '')
+ password = cgidata.getvalue('roster-pw', '')
+ ok = mlist.WebAuthenticate((mm_cfg.AuthUser,
+ mm_cfg.AuthListModerator,
+ mm_cfg.AuthListAdmin,
+ mm_cfg.AuthSiteAdmin),
+ password, addr)
+ else:
+ # Admin only, so we can ignore the address field
+ password = cgidata.getvalue('roster-pw', '')
+ ok = mlist.WebAuthenticate((mm_cfg.AuthListModerator,
+ mm_cfg.AuthListAdmin,
+ mm_cfg.AuthSiteAdmin),
+ password)
+ if not ok:
+ realname = mlist.real_name
+ doc = Document()
+ doc.set_language(lang)
+ error_page_doc(doc, _('%(realname)s roster authentication failed.'))
+ doc.AddItem(mlist.GetMailmanFooter())
+ print doc.Format()
+ return
+
+ # The document and its language
+ doc = HeadlessDocument()
+ doc.set_language(lang)
+
+ replacements = mlist.GetAllReplacements(lang)
+ replacements['<mm-displang-box>'] = mlist.FormatButton(
+ 'displang-button',
+ text = _('View this page in'))
+ replacements['<mm-lang-form-start>'] = mlist.FormatFormStart('roster')
+ doc.AddItem(mlist.ParseTags('roster.html', replacements, lang))
+ print doc.Format()
+
+
+
+def error_page(errmsg):
+ doc = Document()
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+ error_page_doc(doc, errmsg)
+ print doc.Format()
+
+
+def error_page_doc(doc, errmsg, *args):
+ # Produce a simple error-message page on stdout and exit.
+ doc.SetTitle(_("Error"))
+ doc.AddItem(Header(2, _("Error")))
+ doc.AddItem(Bold(errmsg % args))
diff --git a/Mailman/Cgi/subscribe.py b/Mailman/Cgi/subscribe.py
new file mode 100644
index 00000000..c2dfe5cd
--- /dev/null
+++ b/Mailman/Cgi/subscribe.py
@@ -0,0 +1,276 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Process subscription or roster requests from listinfo form."""
+
+import sys
+import os
+import cgi
+import signal
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import MailList
+from Mailman import Errors
+from Mailman import i18n
+from Mailman import Message
+from Mailman.UserDesc import UserDesc
+from Mailman.htmlformat import *
+from Mailman.Logging.Syslog import syslog
+
+SLASH = '/'
+ERRORSEP = '\n\n<p>'
+
+# Set up i18n
+_ = i18n._
+i18n.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+
+
+def main():
+ doc = Document()
+ doc.set_language(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+
+ parts = Utils.GetPathPieces()
+ if not parts:
+ doc.AddItem(Header(2, _("Error")))
+ doc.AddItem(Bold(_('Invalid options to CGI script')))
+ print doc.Format()
+ return
+
+ listname = parts[0].lower()
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ # Avoid cross-site scripting attacks
+ safelistname = Utils.websafe(listname)
+ doc.AddItem(Header(2, _("Error")))
+ doc.AddItem(Bold(_('No such list <em>%(safelistname)s</em>')))
+ print doc.Format()
+ syslog('error', 'No such list "%s": %s\n', listname, e)
+ return
+
+ # See if the form data has a preferred language set, in which case, use it
+ # for the results. If not, use the list's preferred language.
+ cgidata = cgi.FieldStorage()
+ language = cgidata.getvalue('language', mlist.preferred_language)
+ i18n.set_language(language)
+ doc.set_language(language)
+
+ # We need a signal handler to catch the SIGTERM that can come from Apache
+ # when the user hits the browser's STOP button. See the comment in
+ # admin.py for details.
+ #
+ # BAW: Strictly speaking, the list should not need to be locked just to
+ # read the request database. However the request database asserts that
+ # the list is locked in order to load it and it's not worth complicating
+ # that logic.
+ def sigterm_handler(signum, frame, mlist=mlist):
+ # Make sure the list gets unlocked...
+ mlist.Unlock()
+ # ...and ensure we exit, otherwise race conditions could cause us to
+ # enter MailList.Save() while we're in the unlocked state, and that
+ # could be bad!
+ sys.exit(0)
+
+ mlist.Lock()
+ try:
+ # Install the emergency shutdown signal handler
+ signal.signal(signal.SIGTERM, sigterm_handler)
+
+ process_form(mlist, doc, cgidata, language)
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+
+def process_form(mlist, doc, cgidata, lang):
+ listowner = mlist.GetOwnerEmail()
+ realname = mlist.real_name
+ results = []
+
+ # The email address being subscribed, required
+ email = cgidata.getvalue('email', '')
+ if not email:
+ results.append(_('You must supply a valid email address.'))
+
+ fullname = cgidata.getvalue('fullname', '')
+ # Canonicalize the full name
+ fullname = Utils.canonstr(fullname, lang)
+ # Who was doing the subscribing?
+ remote = os.environ.get('REMOTE_HOST',
+ os.environ.get('REMOTE_ADDR',
+ 'unidentified origin'))
+
+ # Was an attempt made to subscribe the list to itself?
+ if email == mlist.GetListEmail():
+ syslog('mischief', 'Attempt to self subscribe %s: %s', email, remote)
+ results.append(_('You may not subscribe a list to itself!'))
+
+ # If the user did not supply a password, generate one for him
+ password = cgidata.getvalue('pw')
+ confirmed = cgidata.getvalue('pw-conf')
+
+ if password is None and confirmed is None:
+ password = Utils.MakeRandomPassword()
+ elif password is None or confirmed is None:
+ results.append(_('If you supply a password, you must confirm it.'))
+ elif password <> confirmed:
+ results.append(_('Your passwords did not match.'))
+
+ # Get the digest option for the subscription.
+ digestflag = cgidata.getvalue('digest')
+ if digestflag:
+ try:
+ digest = int(digestflag)
+ except ValueError:
+ digest = 0
+ else:
+ digest = mlist.digest_is_default
+
+ # Sanity check based on list configuration. BAW: It's actually bogus that
+ # the page allows you to set the digest flag if you don't really get the
+ # choice. :/
+ if not mlist.digestable:
+ digest = 0
+ elif not mlist.nondigestable:
+ digest = 1
+
+ if results:
+ print_results(mlist, ERRORSEP.join(results), doc, lang)
+ return
+
+ # If this list has private rosters, we have to be careful about the
+ # message that gets printed, otherwise the subscription process can be
+ # used to mine for list members. It may be inefficient, but it's still
+ # possible, and that kind of defeats the purpose of private rosters.
+ # We'll use this string for all successful or unsuccessful subscription
+ # results.
+ if mlist.private_roster == 0:
+ # Public rosters
+ privacy_results = ''
+ else:
+ privacy_results = _("""\
+Your subscription request has been received, and will soon be acted upon.
+Depending on the configuration of this mailing list, your subscription request
+may have to be first confirmed by you via email, or approved by the list
+moderator. If confirmation is required, you will soon get a confirmation
+email which contains further instructions.""")
+
+ try:
+ userdesc = UserDesc(email, fullname, password, digest, lang)
+ mlist.AddMember(userdesc, remote)
+ results = ''
+ # Check for all the errors that mlist.AddMember can throw options on the
+ # web page for this cgi
+ except Errors.MembershipIsBanned:
+ results = _("""The email address you supplied is banned from this
+ mailing list. If you think this restriction is erroneous, please
+ contact the list owners at %(listowner)s.""")
+ except Errors.MMBadEmailError:
+ results = _("""\
+The email address you supplied is not valid. (E.g. it must contain an
+`@'.)""")
+ except Errors.MMHostileAddress:
+ results = _("""\
+Your subscription is not allowed because the email address you gave is
+insecure.""")
+ except Errors.MMSubscribeNeedsConfirmation:
+ # Results string depends on whether we have private rosters or not
+ if privacy_results:
+ results = privacy_results
+ else:
+ results = _("""\
+Confirmation from your email address is required, to prevent anyone from
+subscribing you without permission. Instructions are being sent to you at
+%(email)s. Please note your subscription will not start until you confirm
+your subscription.""")
+ except Errors.MMNeedApproval, x:
+ # Results string depends on whether we have private rosters or not
+ if privacy_results:
+ results = privacy_results
+ else:
+ # We need to interpolate into x
+ x = _(x)
+ results = _("""\
+Your subscription request was deferred because %(x)s. Your request has been
+forwarded to the list moderator. You will receive email informing you of the
+moderator's decision when they get to your request.""")
+ except Errors.MMAlreadyAMember:
+ # Results string depends on whether we have private rosters or not
+ if not privacy_results:
+ results = _('You are already subscribed.')
+ else:
+ results = privacy_results
+ # This could be a membership probe. For safety, let the user know
+ # a probe occurred. BAW: should we inform the list moderator?
+ listaddr = mlist.GetListEmail()
+ # Set the language for this email message to the member's language.
+ mlang = mlist.getMemberLanguage(email)
+ otrans = i18n.get_translation()
+ i18n.set_language(mlang)
+ try:
+ msg = Message.UserNotification(
+ mlist.getMemberCPAddress(email),
+ mlist.GetBouncesEmail(),
+ _('Mailman privacy alert'),
+ _("""\
+An attempt was made to subscribe your address to the mailing list
+%(listaddr)s. You are already subscribed to this mailing list.
+
+Note that the list membership is not public, so it is possible that a bad
+person was trying to probe the list for its membership. This would be a
+privacy violation if we let them do this, but we didn't.
+
+If you submitted the subscription request and forgot that you were already
+subscribed to the list, then you can ignore this message. If you suspect that
+an attempt is being made to covertly discover whether you are a member of this
+list, and you are worried about your privacy, then feel free to send a message
+to the list administrator at %(listowner)s.
+"""), lang=mlang)
+ finally:
+ i18n.set_translation(otrans)
+ msg.send(mlist)
+ # These shouldn't happen unless someone's tampering with the form
+ except Errors.MMCantDigestError:
+ results = _('This list does not support digest delivery.')
+ except Errors.MMMustDigestError:
+ results = _('This list only supports digest delivery.')
+ else:
+ # Everything's cool. Our return string actually depends on whether
+ # this list has private rosters or not
+ if privacy_results:
+ results = privacy_results
+ else:
+ results = _("""\
+You have been successfully subscribed to the %(realname)s mailing list.""")
+ # Show the results
+ print_results(mlist, results, doc, lang)
+
+
+
+def print_results(mlist, results, doc, lang):
+ # The bulk of the document will come from the options.html template, which
+ # includes its own html armor (head tags, etc.). Suppress the head that
+ # Document() derived pages get automatically.
+ doc.suppress_head = 1
+
+ replacements = mlist.GetStandardReplacements(lang)
+ replacements['<mm-results>'] = results
+ output = mlist.ParseTags('subscribe.html', replacements, lang)
+ doc.AddItem(output)
+ print doc.Format()
diff --git a/Mailman/Commands/.cvsignore b/Mailman/Commands/.cvsignore
new file mode 100644
index 00000000..f3c7a7c5
--- /dev/null
+++ b/Mailman/Commands/.cvsignore
@@ -0,0 +1 @@
+Makefile
diff --git a/Mailman/Commands/Makefile.in b/Mailman/Commands/Makefile.in
new file mode 100644
index 00000000..bacd9629
--- /dev/null
+++ b/Mailman/Commands/Makefile.in
@@ -0,0 +1,69 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman/Commands
+SHELL= /bin/sh
+
+MODULES= *.py
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+
+install:
+ for f in $(MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \
+ done
+
+finish:
+
+clean:
+
+distclean:
+ -rm *.pyc
+ -rm Makefile
diff --git a/Mailman/Commands/__init__.py b/Mailman/Commands/__init__.py
new file mode 100644
index 00000000..ac6d2391
--- /dev/null
+++ b/Mailman/Commands/__init__.py
@@ -0,0 +1,15 @@
+# Copyright (C) 2001 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
diff --git a/Mailman/Commands/cmd_confirm.py b/Mailman/Commands/cmd_confirm.py
new file mode 100644
index 00000000..5e4fc701
--- /dev/null
+++ b/Mailman/Commands/cmd_confirm.py
@@ -0,0 +1,84 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""
+ confirm <confirmation-string>
+ Confirm an action. The confirmation-string is required and should be
+ supplied with in mailback confirmation notice.
+"""
+
+from Mailman import mm_cfg
+from Mailman import Errors
+from Mailman import Pending
+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(mm_cfg.PENDING_REQUEST_LIFE / mm_cfg.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 current a member. Have you already unsubscribed or changed
+your email address?"""))
+ 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/Mailman/Commands/cmd_echo.py b/Mailman/Commands/cmd_echo.py
new file mode 100644
index 00000000..1f8b5979
--- /dev/null
+++ b/Mailman/Commands/cmd_echo.py
@@ -0,0 +1,26 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""
+ echo [args]
+ Simply echo an acknowledgement. Args are echoed back unchanged.
+"""
+
+SPACE = ' '
+
+def process(res, args):
+ res.results.append('echo %s' % SPACE.join(args))
+ return 1
diff --git a/Mailman/Commands/cmd_end.py b/Mailman/Commands/cmd_end.py
new file mode 100644
index 00000000..aeec7936
--- /dev/null
+++ b/Mailman/Commands/cmd_end.py
@@ -0,0 +1,33 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""
+ end
+ Stop processing commands. Use this if your mail program automatically
+ adds a signature file.
+"""
+
+from Mailman.i18n import _
+
+
+
+def gethelp(mlist):
+ return _(__doc__)
+
+
+
+def process(res, args):
+ return 1 # STOP
diff --git a/Mailman/Commands/cmd_help.py b/Mailman/Commands/cmd_help.py
new file mode 100644
index 00000000..e2d865e8
--- /dev/null
+++ b/Mailman/Commands/cmd_help.py
@@ -0,0 +1,92 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""
+ help
+ Print this help message.
+"""
+
+import sys
+import os
+
+from Mailman import mm_cfg
+from Mailman import Utils
+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' : mm_cfg.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/Mailman/Commands/cmd_info.py b/Mailman/Commands/cmd_info.py
new file mode 100644
index 00000000..1c28da70
--- /dev/null
+++ b/Mailman/Commands/cmd_info.py
@@ -0,0 +1,49 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""
+ 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.GetListEmail()
+ requestaddr = mlist.GetRequestEmail()
+ owneraddr = mlist.GetOwnerEmail()
+ listurl = mlist.GetScriptURL('listinfo', absolute=1)
+ 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/Mailman/Commands/cmd_join.py b/Mailman/Commands/cmd_join.py
new file mode 100644
index 00000000..4daccc1a
--- /dev/null
+++ b/Mailman/Commands/cmd_join.py
@@ -0,0 +1,20 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""The `join' command is synonymous with `subscribe'.
+"""
+
+from Mailman.Commands.cmd_subscribe import process
diff --git a/Mailman/Commands/cmd_leave.py b/Mailman/Commands/cmd_leave.py
new file mode 100644
index 00000000..ed5ccc7b
--- /dev/null
+++ b/Mailman/Commands/cmd_leave.py
@@ -0,0 +1,20 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""The `leave' command is synonymous with `unsubscribe'.
+"""
+
+from Mailman.Commands.cmd_unsubscribe import process
diff --git a/Mailman/Commands/cmd_lists.py b/Mailman/Commands/cmd_lists.py
new file mode 100644
index 00000000..81b60e60
--- /dev/null
+++ b/Mailman/Commands/cmd_lists.py
@@ -0,0 +1,69 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""
+ lists
+ See a list of the public mailing lists on this GNU Mailman server.
+"""
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman.MailList import MailList
+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:'))
+ lists = Utils.list_names()
+ lists.sort()
+ i = 1
+ for listname in lists:
+ 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. BAW: should a
+ # message to the site list include everything regardless of domain?
+ if mm_cfg.VIRTUAL_HOST_OVERVIEW and \
+ 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/Mailman/Commands/cmd_password.py b/Mailman/Commands/cmd_password.py
new file mode 100644
index 00000000..c2347be9
--- /dev/null
+++ b/Mailman/Commands/cmd_password.py
@@ -0,0 +1,118 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""
+ password [<oldpassword> <newpassword>] [address=<address>]
+ Retrieve or change your password. With no arguments, this returns
+ your current password. With arguments <oldpassword> and <newpassword>
+ you can change your password.
+
+ If you're posting from an address other than your membership address,
+ specify your membership address with `address=<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 import mm_cfg
+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'))
+ 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'))
+ 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((mm_cfg.AuthUser, mm_cfg.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((mm_cfg.AuthUser, mm_cfg.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/Mailman/Commands/cmd_remove.py b/Mailman/Commands/cmd_remove.py
new file mode 100644
index 00000000..55be1f3e
--- /dev/null
+++ b/Mailman/Commands/cmd_remove.py
@@ -0,0 +1,20 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""The `remove' command is synonymous with `unsubscribe'.
+"""
+
+from Mailman.Commands.cmd_unsubscribe import process
diff --git a/Mailman/Commands/cmd_set.py b/Mailman/Commands/cmd_set.py
new file mode 100644
index 00000000..c3eaa9a6
--- /dev/null
+++ b/Mailman/Commands/cmd_set.py
@@ -0,0 +1,353 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+from email.Utils import parseaddr, formatdate
+
+from Mailman import mm_cfg
+from Mailman import Errors
+from Mailman import MemberAdaptor
+from Mailman import i18n
+
+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=<address>]
+ View your current option settings. If you're posting from an address
+ other than your membership address, specify your membership address
+ with `address=<address>' (no brackets around the email address, and no
+ quotes!).
+
+ set authenticate <password> [address=<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=<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, mm_cfg.AcknowledgePosts)
+ onoff = opt and _('on') or _('off')
+ res.results.append(_(' ack %(onoff)s'))
+ # Digests are a special ternary value
+ digestsp = mlist.getMemberOption(address, mm_cfg.Digests)
+ if digestsp:
+ plainp = mlist.getMemberOption(address, mm_cfg.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, mm_cfg.DontReceiveOwnPosts)
+ # sense is reversed
+ onoff = (not opt) and _('on') or _('off')
+ res.results.append(_(' myposts %(onoff)s'))
+ opt = mlist.getMemberOption(address, mm_cfg.ConcealSubscription)
+ onoff = opt and _('on') or _('off')
+ res.results.append(_(' hide %(onoff)s'))
+ opt = mlist.getMemberOption(address, mm_cfg.DontReceiveDuplicates)
+ # sense is reversed
+ onoff = (not opt) and _('on') or _('off')
+ res.results.append(_(' duplicates %(onoff)s'))
+ opt = mlist.getMemberOption(address, mm_cfg.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((mm_cfg.AuthUser,
+ mm_cfg.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, mm_cfg.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, mm_cfg.Digests, 0)
+ except Errors.AlreadyReceivingRegularDeliveries:
+ pass
+ elif arg == 'plain':
+ try:
+ mlist.setMemberOption(self.__address, mm_cfg.Digests, 1)
+ except Errors.AlreadyReceivingDigests:
+ pass
+ mlist.setMemberOption(self.__address, mm_cfg.DisableMime, 1)
+ elif arg == 'mime':
+ try:
+ mlist.setMemberOption(self.__address, mm_cfg.Digests, 1)
+ except Errors.AlreadyReceivingDigests:
+ pass
+ mlist.setMemberOption(self.__address, mm_cfg.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
+ # sense is reversed
+ mlist.setMemberOption(self.__address, mm_cfg.DisableDelivery,
+ not status)
+ res.results.append(_('delivery option set'))
+
+ 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, mm_cfg.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, mm_cfg.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, mm_cfg.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, mm_cfg.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/Mailman/Commands/cmd_stop.py b/Mailman/Commands/cmd_stop.py
new file mode 100644
index 00000000..defcf64d
--- /dev/null
+++ b/Mailman/Commands/cmd_stop.py
@@ -0,0 +1,20 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""stop is synonymous with the end command.
+"""
+
+from Mailman.Commands.cmd_end import process
diff --git a/Mailman/Commands/cmd_subscribe.py b/Mailman/Commands/cmd_subscribe.py
new file mode 100644
index 00000000..1a5048d6
--- /dev/null
+++ b/Mailman/Commands/cmd_subscribe.py
@@ -0,0 +1,136 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""
+ subscribe [password] [digest|nodigest] [address=<address>]
+ Subscribe to this mailing list. Your password must be given to
+ unsubscribe or change your options, but if you omit the password, one
+ will be generated for you. You may be periodically reminded of your
+ password.
+
+ The next argument may be either: `nodigest' or `digest' (no quotes!).
+ If you wish to subscribe an address other than the address you sent
+ this request from, you may specify `address=<address>' (no brackets
+ around the email address, and no quotes!)
+"""
+
+from email.Utils import parseaddr
+from email.Header import decode_header, make_header
+
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.UserDesc import UserDesc
+from Mailman.i18n import _
+
+STOP = 1
+
+
+
+def gethelp(mlist):
+ return _(__doc__)
+
+
+
+def process(res, args):
+ mlist = res.mlist
+ digest = None
+ password = None
+ address = None
+ realname = None
+ # Parse the args
+ argnum = 0
+ for arg in args:
+ if arg.startswith('address='):
+ address = arg[8:]
+ elif argnum == 0:
+ password = arg
+ elif argnum == 1:
+ if arg.lower() not in ('digest', 'nodigest'):
+ res.results.append(_('Bad digest specifier: %(arg)s'))
+ return STOP
+ if arg.lower() == 'digest':
+ digest = 1
+ else:
+ digest = 0
+ else:
+ res.results.append(_('Usage:'))
+ res.results.append(gethelp(mlist))
+ return STOP
+ argnum += 1
+ # Fill in empty defaults
+ if digest is None:
+ digest = mlist.digest_is_default
+ if password is None:
+ password = Utils.MakeRandomPassword()
+ if address is None:
+ realname, address = parseaddr(res.msg['from'])
+ if not address:
+ # Fall back to the sender address
+ address = res.msg.get_sender()
+ if not address:
+ res.results.append(_('No valid address found to subscribe'))
+ return STOP
+ # Watch for encoded names
+ h = make_header(decode_header(realname))
+ # BAW: in Python 2.2, use just unicode(h)
+ realname = h.__unicode__()
+ # Coerce to byte string if uh contains only ascii
+ try:
+ realname = realname.encode('us-ascii')
+ except UnicodeError:
+ pass
+ # Create the UserDesc record and do a non-approved subscription
+ listowner = mlist.GetOwnerEmail()
+ userdesc = UserDesc(address, realname, password, digest)
+ remote = res.msg.get_sender()
+ try:
+ mlist.AddMember(userdesc, remote)
+ except Errors.MembershipIsBanned:
+ res.results.append(_("""\
+The email address you supplied is banned from this mailing list.
+If you think this restriction is erroneous, please contact the list
+owners at %(listowner)s."""))
+ return STOP
+ except Errors.MMBadEmailError:
+ res.results.append(_("""\
+Mailman won't accept the given email address as a valid address.
+(E.g. it must have an @ in it.)"""))
+ return STOP
+ except Errors.MMHostileAddress:
+ res.results.append(_("""\
+Your subscription is not allowed because
+the email address you gave is insecure."""))
+ return STOP
+ except Errors.MMAlreadyAMember:
+ res.results.append(_('You are already subscribed!'))
+ return STOP
+ except Errors.MMCantDigestError:
+ res.results.append(
+ _('No one can subscribe to the digest of this list!'))
+ return STOP
+ except Errors.MMMustDigestError:
+ res.results.append(_('This list only supports digest subscriptions!'))
+ return STOP
+ except Errors.MMSubscribeNeedsConfirmation:
+ # We don't need to respond /and/ send a confirmation message.
+ res.respond = 0
+ except Errors.MMNeedApproval:
+ res.results.append(_("""\
+Your subscription request has been forwarded to the list administrator
+at %(listowner)s for review."""))
+ else:
+ # Everything is a-ok
+ res.results.append(_('Subscription request succeeded.'))
diff --git a/Mailman/Commands/cmd_unsubscribe.py b/Mailman/Commands/cmd_unsubscribe.py
new file mode 100644
index 00000000..c574a80f
--- /dev/null
+++ b/Mailman/Commands/cmd_unsubscribe.py
@@ -0,0 +1,87 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""
+ unsubscribe [password] [address=<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=<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/Mailman/Commands/cmd_who.py b/Mailman/Commands/cmd_who.py
new file mode 100644
index 00000000..62505b3d
--- /dev/null
+++ b/Mailman/Commands/cmd_who.py
@@ -0,0 +1,133 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# Remove this when base minimal compatibility is Python 2.2
+from __future__ import nested_scopes
+
+from email.Utils import parseaddr
+
+from Mailman import mm_cfg
+from Mailman import i18n
+
+STOP = 1
+
+def _(s): return s
+
+PUBLICHELP = _("""
+ who
+ See everyone who is on this mailing list.
+""")
+
+MEMBERSONLYHELP = _("""
+ who password [address=<address>]
+ See everyone who is on 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=<address>' (no brackets around the email address, and no
+ quotes!)
+""")
+
+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 = 0
+ if mlist.private_roster == 0:
+ # Public rosters
+ if args:
+ usage(res)
+ return STOP
+ ok = 1
+ 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(
+ (mm_cfg.AuthUser,
+ mm_cfg.AuthListModerator,
+ mm_cfg.AuthListAdmin),
+ password, address):
+ # Then
+ ok = 1
+ else:
+ # Admin only
+ if len(args) <> 1:
+ usage(res)
+ return STOP
+ if mlist.Authenticate((mm_cfg.AuthListModerator,
+ mm_cfg.AuthListAdmin),
+ args[0]):
+ ok = 1
+ 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 mlist.getMemberOption(member, mm_cfg.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/Mailman/Defaults.py.in b/Mailman/Defaults.py.in
new file mode 100644
index 00000000..a9d11f63
--- /dev/null
+++ b/Mailman/Defaults.py.in
@@ -0,0 +1,1224 @@
+# -*- 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Distributed default settings for significant Mailman config variables.
+"""
+
+# NEVER make site configuration changes to this file. ALWAYS make them in
+# mm_cfg.py instead, in the designated area. See the comments in that file
+# for details.
+
+
+import os
+
+def seconds(s): return s
+def minutes(m): return m * 60
+def hours(h): return h * 60 * 60
+def days(d): return d * 60 * 60 * 24
+
+
+
+#####
+# 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/'
+
+# Mailman needs to know about (at least) two fully-qualified domain names
+# (fqdn); 1) the hostname used in your urls, and 2) the hostname used in email
+# addresses for your domain. For example, if people visit your Mailman system
+# with "http://www.dom.ain/mailman" then your url fqdn is "www.dom.ain", and
+# if people send mail to your system via "yourlist@dom.ain" then your email
+# fqdn is "dom.ain". DEFAULT_URL_HOST controls the former, and
+# DEFAULT_EMAIL_HOST controls the latter. Mailman also needs to know how to
+# map from one to the other (this is especially important if you're running
+# with virtual domains). You use "add_virtualhost(urlfqdn, emailfqdn)" to add
+# new mappings.
+#
+# If you don't need to change DEFAULT_EMAIL_HOST and DEFAULT_URL_HOST in your
+# mm_cfg.py, then you're done; the default mapping is added automatically. If
+# however you change either variable in your mm_cfg.py, then be sure to also
+# include the following:
+#
+# add_virtualhost(DEFAULT_URL_HOST, DEFAULT_EMAIL_HOST)
+#
+# because otherwise the default mappings won't be correct.
+DEFAULT_EMAIL_HOST = '@MAILHOST@'
+DEFAULT_URL_HOST = '@URLHOST@'
+DEFAULT_URL_PATTERN = 'http://%s/mailman/'
+
+# DEFAULT_HOST_NAME has been replaced with DEFAULT_EMAIL_HOST, however some
+# sites may have the former in their mm_cfg.py files. If so, we'll believe
+# that, otherwise we'll believe DEFAULT_EMAIL_HOST. Same for DEFAULT_URL.
+DEFAULT_HOST_NAME = None
+DEFAULT_URL = None
+
+HOME_PAGE = 'index.html'
+MAILMAN_SITE_LIST = 'mailman'
+
+# 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 1 to allow site admin cookies.
+ALLOW_SITE_ADMIN_COOKIES = 0
+
+# 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'
+
+
+
+#####
+# Virtual domains
+#####
+
+# Set up your virtual host mappings here. This is primarily used for the
+# thru-the-web list creation, so its effects are currently fairly limited.
+# Use add_virtualhost() call to add new mappings. The keys are strings as
+# determined by Utils.get_domain(), the values are as appropriate for
+# DEFAULT_HOST_NAME.
+VIRTUAL_HOSTS = {}
+
+# When set, the listinfo and admin overviews of lists on the machine will be
+# confined to only those lists whose web_page_url configuration option host is
+# included within the URL by which the page is visited - only those "on the
+# virtual host". If unset, then all advertised (i.e. public) lists are
+# included in the overview.
+VIRTUAL_HOST_OVERVIEW = 1
+
+
+# Helper function; use this in your mm_cfg.py files. If optional emailhost is
+# omitted it defaults to urlhost with the first name stripped off, e.g.
+#
+# add_virtualhost('www.dom.ain')
+# VIRTUAL_HOST['www.dom.ain']
+# ==> 'dom.ain'
+#
+def add_virtualhost(urlhost, emailhost=None):
+ DOT = '.'
+ if emailhost is None:
+ emailhost = DOT.join(urlhost.split(DOT)[1:])
+ VIRTUAL_HOSTS[urlhost.lower()] = emailhost.lower()
+
+# And set the default
+add_virtualhost(DEFAULT_URL_HOST, DEFAULT_EMAIL_HOST)
+
+
+
+#####
+# Spam avoidance defaults
+#####
+
+# This variable contains a list of 2-tuple of the format (header, regex) which
+# the Mailman/Handlers/SpamDetect.py module uses to match against the current
+# message. If the regex matches the given header in the current message, then
+# it is flagged as spam. header is case-insensitive and should not include
+# the trailing colon. regex is always matched with re.IGNORECASE.
+#
+# Note that the more searching done, the slower the whole process gets. Spam
+# detection is run against all messages coming to either the list, or the
+# -owners address, unless the message is explicitly approved.
+KNOWN_SPAMMERS = []
+
+
+
+#####
+# 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
+
+
+#####
+# 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 should be a string with "%(listname)s" somewhere in it. Mailman will
+# interpolate the name of the list into this. You can also include a
+# "%(hostname)s" in the string, into which Mailman will interpolate
+# the host name (usually DEFAULT_URL_HOST).
+PUBLIC_ARCHIVE_URL = 'http://%(hostname)s/pipermail/%(listname)s'
+
+# Are archives on or off by default?
+DEFAULT_ARCHIVE = 1 # 0=Off, 1=On
+
+# Are archives public or private by default?
+DEFAULT_ARCHIVE_PRIVATE = 0 # 0=public, 1=private
+
+# ARCHIVE_TO_MBOX
+#-1 - do not do any archiving
+# 0 - do not archive to mbox, use builtin mailman html archiving only
+# 1 - archive to mbox to use an external archiving mechanism only
+# 2 - archive to both mbox and builtin mailman html archiving -
+# use this to make both external archiving mechanism work and
+# mailman's builtin html archiving. the flat mail file can be
+# useful for searching, 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 false. When either is set, the value
+# should be a shell command string which will get passed to os.popen(). This
+# string can contain %(listname)s for dictionary interpolation. The name of
+# the list being archived will be substituted for this.
+#
+# 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 = 0
+PRIVATE_EXTERNAL_ARCHIVER = 0
+
+# 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.Handlers.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)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 1 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 = 0
+
+# 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 true to
+# moderately obscure email addresses, but note that this breaks mailto: URLs
+# in the archives too.
+ARCHIVER_OBSCURES_EMAILADDRS = 1
+
+# 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']
+
+
+
+#####
+# 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 a module in the Mailman.Handlers package.
+#
+# WARNING: Sendmail has security holes and should be avoided. In fact, you
+# must read the Mailman/Handlers/Sendmail.py file before it will work for
+# you.
+#
+#DELIVERY_MODULE = 'Sendmail'
+DELIVERY_MODULE = 'SMTPDirect'
+
+# 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 flag to false 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 README.POSTFIX for details.
+POSTFIX_STYLE_VIRTUAL_DOMAINS = []
+
+# 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 = ''
+
+# 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'),
+ ]
+
+# All `normal' messages which are delivered to the entire list membership go
+# through this pipeline of handler modules. Lists themselves can override the
+# global pipeline by defining a `pipeline' attribute.
+GLOBAL_PIPELINE = [
+ # These are the modules that do tasks common to all delivery paths.
+ 'SpamDetect',
+ 'Approve',
+ 'Replybot',
+ 'Moderate',
+ 'Hold',
+ 'MimeDel',
+ 'Emergency',
+ 'Tagger',
+ 'CalcRecips',
+ 'AvoidDuplicates',
+ 'Cleanse',
+ 'CookHeaders',
+ # And now we send the message to the digest mbox file, and to the arch and
+ # news queues. Runners will provide further processing of the message,
+ # specific to those delivery paths.
+ 'ToDigest',
+ 'ToArchive',
+ 'ToUsenet',
+ # Now we'll do a few extra things specific to the member delivery
+ # (outgoing) path, finally leaving the message in the outgoing queue.
+ 'AfterDelivery',
+ 'Acknowledge',
+ 'ToOutgoing',
+ ]
+
+# This is the pipeline which messages sent to the -owner address go through
+OWNER_PIPELINE = [
+ 'SpamDetect',
+ 'Replybot',
+ 'OwnerRecips',
+ 'ToOutgoing',
+ ]
+
+
+# This defines syslog() 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_<header> -- 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_<header> - Same as msg_<header> 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
+# file in logs/ 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/<name> 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',
+ '%(msg_message-id)s smtp for %(#recips)d recips, completed in %(time).3f seconds')
+
+# This will only be printed if there were no immediate smtp failures.
+# Mutually exclusive with SMTP_LOG_REFUSED.
+SMTP_LOG_SUCCESS = (
+ 'post',
+ 'post to %(listname)s from %(sender)s, size=%(size)d, 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',
+ 'post to %(listname)s from %(sender)s, size=%(size)d, %(#refused)d 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',
+ 'delivery to %(recipient)s failed with code %(failcode)d: %(failmsg)s')
+
+# 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_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<bounces>[^+]+?)\+(?P<mailbox>[^=]+)=(?P<host>[^@]+)@.*$'
+
+# A perfect opportunity for doing VERP is the password reminders, which are
+# already addressed individually to each recipient. This flag, if true,
+# enables VERPs on all password reminders.
+VERP_PASSWORD_REMINDERS = 0
+
+# 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 true to enable VERPs on all personalized
+# regular deliveries (personalized digests aren't supported yet).
+VERP_PERSONALIZED_DELIVERIES = 0
+
+# 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:
+#
+# %(confirm)s -- the list-confirm mailbox will be set here
+# %(cookie)s -- the confirmation cookie will be set here
+VERP_CONFIRM_FORMAT = '%(addr)s+%(cookie)s'
+
+# This is analogous to VERP_REGEXP, but for splitting apart the
+# VERP_CONFIRM_FORMAT.
+VERP_CONFIRM_REGEXP = r'^(?P<addr>[^+]+?)\+(?P<cookie>[^@]+)@.*$'
+
+# Set this to true to enable VERP-like (more user friendly) confirmations
+VERP_CONFIRMATIONS = 0
+
+# 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? This is a list of
+# 2-tuples containing the name of the qrunner class (which must live in a
+# module of the same name within the Mailman.Queue package), and 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.
+
+# BAW: Eventually we may support weighted hash spaces.
+# BAW: Although not enforced, the # of slices must be a power of 2
+
+QRUNNERS = [
+ ('ArchRunner', 1), # messages for the archiver
+ ('BounceRunner', 1), # for processing the qfile/bounces directory
+ ('CommandRunner', 1), # commands and bounces from the outside world
+ ('IncomingRunner', 1), # posts from the outside world
+ ('NewsRunner', 1), # outgoing messages to the nntpd
+ ('OutgoingRunner', 1), # outgoing messages to the smtpd
+ ('VirginRunner', 1), # internally crafted (virgin birth) messages
+ ]
+
+# Set this to true 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. Also, the line after USE_MAILDIR to your mm_cfg.py file.
+#
+# WARNING: If you want to use Maildir delivery, you /must/ start Mailman's
+# qrunner as root, or you will get permission problems.
+#
+# NOTE: Maildir delivery is experimental for Mailman 2.1.
+USE_MAILDIR = 0
+# QRUNNERS.append(('MaildirRunner', 1))
+
+# 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 0 to discard such messages, or to 1 to store
+# them in qfiles/bad subdirectory.
+QRUNNER_SAVE_BAD_MESSAGES = 1
+
+
+
+#####
+# 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. See
+# LC_DESCRIPTIONS below for legal values.
+DEFAULT_SERVER_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 1, 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 0 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 0, but understand that some
+# spoofed messages may get through.
+USE_ENVELOPE_SENDER = 0
+
+# 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 1 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 0 instead requires list removal to be done by the site
+# administrator, via the command line script bin/rmlist.
+OWNERS_CAN_DELETE_THEIR_OWN_LISTS = 0
+
+# Set this variable to 1 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 = 0
+
+# 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 = 1
+
+# These define the available types of external message metadata formats, and
+# the one to use by default. MARSHAL format uses Python's built-in marshal
+# module. BSDDB_NATIVE uses the bsddb module compiled into Python, which
+# links with whatever version of Berkeley db you've got on your system (in
+# Python 2.0 this is included by default if configure can find it). ASCII
+# format is a dumb repr()-based format with "key = value" Python assignments.
+# It is human readable and editable (as Python source code) and is appropriate
+# for execfile() food.
+#
+# Note! Make sure your queues are empty before you change this.
+METAFMT_MARSHAL = 1
+METAFMT_BSDDB_NATIVE = 2
+METAFMT_ASCII = 3
+
+METADATA_FORMAT = METAFMT_MARSHAL
+
+# 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
+
+
+
+#####
+# List defaults
+#####
+
+# 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 = 1
+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 = "[%(real_name)s] "
+DEFAULT_MSG_HEADER = ""
+DEFAULT_MSG_FOOTER = """_______________________________________________
+%(real_name)s mailing list
+%(real_name)s@%(host_name)s
+%(web_page_url)slistinfo%(cgiext)s/%(_internal_name)s
+"""
+
+# Mail command processor will ignore mail command lines after designated max.
+DEFAULT_MAIL_COMMANDS_MAX_LINES = 25
+
+# Is the list owner notified of admin requests immediately by mail, as well as
+# by daily pending-request reminder?
+DEFAULT_ADMIN_IMMED_NOTIFY = 1
+
+# Is the list owner notified of subscribes/unsubscribes?
+DEFAULT_ADMIN_NOTIFY_MCHANGES = 0
+
+# Should list members, by default, have their posts be moderated?
+DEFAULT_DEFAULT_MEMBER_MODERATION = 0
+
+# Should non-member posts which are auto-discarded also be forwarded to the
+# moderators?
+DEFAULT_FORWARD_AUTO_DISCARDS = 1
+
+# 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 = 1
+
+# 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 = 0
+
+# 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 = 1
+
+# Send welcome messages to new users? Probably should keep this set to 1.
+DEFAULT_SEND_WELCOME_MSG = 1
+
+# Send goodbye messages to unsubscribed members? Probably should keep this
+# set to 1.
+DEFAULT_SEND_GOODBYE_MSG = 1
+
+# Wipe sender information, and make it look like the list-admin
+# address sends all messages
+DEFAULT_ANONYMOUS_LIST = 0
+
+# {header-name: regexp} spam filtering - we include some for example sake.
+DEFAULT_BOUNCE_MATCHING_HEADERS = """
+# 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 = 0
+
+# 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. This is
+# a boolean variable.
+DEFAULT_FIRST_STRIP_REPLY_TO = 0
+
+# 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 = 0
+
+# 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 = 1
+
+# 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 1, list owners will
+# be given the option to suppress these headers. By setting it to 0, 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 = 1
+
+# 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 = 0
+
+# 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']
+
+# 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 = 1
+
+# 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 = 1
+
+# Check for administrivia in messages sent to the main list?
+DEFAULT_ADMINISTRIVIA = 1
+
+
+
+#####
+# Digestification defaults
+#####
+
+# Will list be available in non-digested form?
+DEFAULT_NONDIGESTABLE = 1
+
+# Will list be available in digested form?
+DEFAULT_DIGESTABLE = 1
+DEFAULT_DIGEST_HEADER = ""
+DEFAULT_DIGEST_FOOTER = DEFAULT_MSG_FOOTER
+
+DEFAULT_DIGEST_IS_DEFAULT = 0
+DEFAULT_MIME_IS_DEFAULT_DIGEST = 0
+DEFAULT_DIGEST_SIZE_THRESHHOLD = 30 # KB
+DEFAULT_DIGEST_SEND_PERIODIC = 1
+DEFAULT_PLAIN_DIGEST_KEEP_HEADERS = ['message', 'date', 'from',
+ 'subject', 'to', 'cc',
+ 'reply-to', 'organization']
+
+
+
+#####
+# Bounce processing defaults
+#####
+
+# Should we do any bounced mail response at all?
+DEFAULT_BOUNCE_PROCESSING = 1
+
+# 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 = 1
+
+# 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 = 1
+DEFAULT_BOUNCE_NOTIFY_OWNER_ON_REMOVAL = 1
+
+
+
+#####
+# General time limits
+#####
+
+# How long should subscriptions requests await confirmation before being
+# dropped?
+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)
+
+
+
+#####
+# 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.
+
+# Set this to true to turn on MailList object lock debugging messages, 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.
+LIST_LOCK_DEBUGGING = 0
+
+# 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)
+
+
+
+#####
+# Nothing below here is user configurable. Most of these values are in this
+# file for convenience. Don't change any of them or override any of them in
+# your mm_cfg.py file!
+#####
+
+# These directories are used to find various important files in the Mailman
+# installation. PREFIX and EXEC_PREFIX are set by configure and should point
+# to the installation directory of the Mailman package.
+PYTHON = '@PYTHON@'
+PREFIX = '@prefix@'
+EXEC_PREFIX = '@exec_prefix@'
+VAR_PREFIX = '@VAR_PREFIX@'
+
+# Work around a bogus autoconf 2.12 bug
+if EXEC_PREFIX == '${prefix}':
+ EXEC_PREFIX = PREFIX
+
+# CGI extension, change using configure script
+CGIEXT = '@CGIEXT@'
+
+# Group id that group-owns the Mailman installation
+MAILMAN_USER = '@MAILMAN_USER@'
+MAILMAN_GROUP = '@MAILMAN_GROUP@'
+
+# 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
+
+# Held message disposition actions, for use between admindb.py and
+# ListAdmin.py.
+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)
+
+# Useful directories
+LIST_DATA_DIR = os.path.join(VAR_PREFIX, 'lists')
+LOG_DIR = os.path.join(VAR_PREFIX, 'logs')
+LOCK_DIR = os.path.join(VAR_PREFIX, 'locks')
+DATA_DIR = os.path.join(VAR_PREFIX, 'data')
+SPAM_DIR = os.path.join(VAR_PREFIX, 'spam')
+WRAPPER_DIR = os.path.join(EXEC_PREFIX, 'mail')
+BIN_DIR = os.path.join(PREFIX, 'bin')
+SCRIPTS_DIR = os.path.join(PREFIX, 'scripts')
+TEMPLATE_DIR = os.path.join(PREFIX, 'templates')
+MESSAGES_DIR = os.path.join(PREFIX, 'messages')
+PUBLIC_ARCHIVE_FILE_DIR = os.path.join(VAR_PREFIX, 'archives', 'public')
+PRIVATE_ARCHIVE_FILE_DIR = os.path.join(VAR_PREFIX, 'archives', 'private')
+
+# Directories used by the qrunner subsystem
+QUEUE_DIR = os.path.join(VAR_PREFIX, 'qfiles')
+INQUEUE_DIR = os.path.join(QUEUE_DIR, 'in')
+OUTQUEUE_DIR = os.path.join(QUEUE_DIR, 'out')
+CMDQUEUE_DIR = os.path.join(QUEUE_DIR, 'commands')
+BOUNCEQUEUE_DIR = os.path.join(QUEUE_DIR, 'bounces')
+NEWSQUEUE_DIR = os.path.join(QUEUE_DIR, 'news')
+ARCHQUEUE_DIR = os.path.join(QUEUE_DIR, 'archive')
+SHUNTQUEUE_DIR = os.path.join(QUEUE_DIR, 'shunt')
+VIRGINQUEUE_DIR = os.path.join(QUEUE_DIR, 'virgin')
+BADQUEUE_DIR = os.path.join(QUEUE_DIR, 'bad')
+MAILDIR_DIR = os.path.join(QUEUE_DIR, 'maildir')
+
+# Other useful files
+PIDFILE = os.path.join(DATA_DIR, 'master-qrunner.pid')
+SITE_PW_FILE = os.path.join(DATA_DIR, 'adm.pw')
+LISTCREATOR_PW_FILE = os.path.join(DATA_DIR, 'creator.pw')
+
+# Import a bunch of version numbers
+from Version import *
+
+# 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. :-)
+def _(s):
+ return s
+
+LC_DESCRIPTIONS = {}
+
+def add_language(code, description, charset):
+ LC_DESCRIPTIONS[code] = (description, charset)
+
+add_language('big5', _('Traditional Chinese'), 'big5')
+add_language('cs', _('Czech'), 'iso-8859-2')
+add_language('de', _('German'), 'iso-8859-1')
+add_language('en', _('English (USA)'), 'us-ascii')
+add_language('es', _('Spanish (Spain)'), 'iso-8859-1')
+add_language('et', _('Estonian'), 'iso-8859-15')
+add_language('fi', _('Finnish'), 'iso-8859-1')
+add_language('fr', _('French'), 'iso-8859-1')
+add_language('gb', _('Simplified Chinese'), 'gb2312')
+add_language('hu', _('Hungarian'), 'iso-8859-2')
+add_language('it', _('Italian'), 'iso-8859-1')
+add_language('ja', _('Japanese'), 'euc-jp')
+add_language('ko', _('Korean'), 'euc-kr')
+add_language('lt', _('Lithuanian'), 'iso-8859-13')
+add_language('nl', _('Dutch'), 'iso-8859-1')
+add_language('no', _('Norwegian'), 'iso-8859-1')
+add_language('pt_BR', _('Portuguese (Brazil)'), 'iso-8859-1')
+add_language('ru', _('Russian'), 'koi8-r')
+add_language('sv', _('Swedish'), 'iso-8859-1')
+
+del _
diff --git a/Mailman/Deliverer.py b/Mailman/Deliverer.py
new file mode 100644
index 00000000..983a67d5
--- /dev/null
+++ b/Mailman/Deliverer.py
@@ -0,0 +1,136 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+"""Mixin class with message delivery routines."""
+
+from email.MIMEText import MIMEText
+from email.MIMEMessage import MIMEMessage
+
+from Mailman import mm_cfg
+from Mailman import Errors
+from Mailman import Utils
+from Mailman import Message
+from Mailman.i18n import _
+from Mailman.Logging.Syslog import syslog
+
+
+
+class Deliverer:
+ def SendSubscribeAck(self, name, password, digest, text=''):
+ pluser = self.getMemberLanguage(name)
+ if not self.send_welcome_msg:
+ return
+ if self.welcome_msg:
+ welcome = Utils.wrap(self.welcome_msg) + '\n'
+ else:
+ welcome = ''
+ if self.umbrella_list:
+ addr = self.GetMemberAdminEmail(name)
+ umbrella = Utils.wrap(_('''\
+Note: Since this is a list of mailing lists, administrative
+notices like the password reminder will be sent to
+your membership administrative address, %(addr)s.'''))
+ else:
+ umbrella = ''
+ # get the text from the template
+ text += Utils.maketext(
+ 'subscribeack.txt',
+ {'real_name' : self.real_name,
+ 'host_name' : self.host_name,
+ 'welcome' : welcome,
+ 'umbrella' : umbrella,
+ 'emailaddr' : self.GetListEmail(),
+ 'listinfo_url': self.GetScriptURL('listinfo', absolute=1),
+ 'optionsurl' : self.GetOptionsURL(name, absolute=1),
+ 'password' : password,
+ }, lang=pluser, mlist=self)
+ if digest:
+ digmode = _(' (Digest mode)')
+ else:
+ digmode = ''
+ realname = self.real_name
+ msg = Message.UserNotification(
+ self.GetMemberAdminEmail(name), self.GetRequestEmail(),
+ _('Welcome to the "%(realname)s" mailing list%(digmode)s'),
+ text, pluser)
+ msg['X-No-Archive'] = 'yes'
+ msg.send(self, verp=mm_cfg.VERP_PERSONALIZED_DELIVERIES)
+
+ def SendUnsubscribeAck(self, addr, lang):
+ realname = self.real_name
+ msg = Message.UserNotification(
+ self.GetMemberAdminEmail(addr), self.GetBouncesEmail(),
+ _('You have been unsubscribed from the %(realname)s mailing list'),
+ Utils.wrap(self.goodbye_msg), lang)
+ msg.send(self, verp=mm_cfg.VERP_PERSONALIZED_DELIVERIES)
+
+ def MailUserPassword(self, user):
+ listfullname = '%s@%s' % (self.real_name, self.host_name)
+ 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.
+ syslog('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 the text from the template
+ text = Utils.maketext(
+ 'userpass.txt',
+ {'user' : cpuser,
+ 'listname' : self.real_name,
+ 'fqdn_lname' : self.GetListEmail(),
+ 'password' : self.getMemberPassword(user),
+ 'options_url': self.GetOptionsURL(user, absolute=1),
+ 'requestaddr': requestaddr,
+ 'owneraddr' : self.GetOwnerEmail(),
+ }, lang=self.getMemberLanguage(user), mlist=self)
+ msg = Message.UserNotification(recipient, adminaddr, subject, text,
+ self.getMemberLanguage(user))
+ msg['X-No-Archive'] = 'yes'
+ msg.send(self, verp=mm_cfg.VERP_PERSONALIZED_DELIVERIES)
+
+ def ForwardMessage(self, msg, text=None, subject=None, tomoderators=1):
+ # 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)
diff --git a/Mailman/Digester.py b/Mailman/Digester.py
new file mode 100644
index 00000000..94b3dfd5
--- /dev/null
+++ b/Mailman/Digester.py
@@ -0,0 +1,73 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+"""Mixin class with list-digest handling methods and settings."""
+
+import os
+from stat import ST_SIZE
+import errno
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.Handlers import ToDigest
+from Mailman.i18n import _
+
+
+
+class Digester:
+ def InitVars(self):
+ # Configurable
+ self.digestable = mm_cfg.DEFAULT_DIGESTABLE
+ self.digest_is_default = mm_cfg.DEFAULT_DIGEST_IS_DEFAULT
+ self.mime_is_default_digest = mm_cfg.DEFAULT_MIME_IS_DEFAULT_DIGEST
+ self.digest_size_threshhold = mm_cfg.DEFAULT_DIGEST_SIZE_THRESHHOLD
+ self.digest_send_periodic = mm_cfg.DEFAULT_DIGEST_SEND_PERIODIC
+ self.next_post_number = 1
+ self.digest_header = mm_cfg.DEFAULT_DIGEST_HEADER
+ self.digest_footer = mm_cfg.DEFAULT_DIGEST_FOOTER
+ self.digest_volume_frequency = mm_cfg.DEFAULT_DIGEST_VOLUME_FREQUENCY
+ # Non-configurable.
+ self.one_last_digest = {}
+ self.digest_members = {}
+ self.next_digest_number = 1
+ self.digest_last_sent_at = 0
+
+ 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 0
+ return 1
+
+ def bump_digest_volume(self):
+ self.volume += 1
+ self.next_digest_number = 1
diff --git a/Mailman/Errors.py b/Mailman/Errors.py
new file mode 100644
index 00000000..ce1868cc
--- /dev/null
+++ b/Mailman/Errors.py
@@ -0,0 +1,147 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+"""Shared mailman errors and messages."""
+
+
+# exceptions for problems related to opening a list
+class MMListError(Exception): pass
+class MMUnknownListError(MMListError): pass
+class MMCorruptListDatabaseError(MMListError): pass
+class MMListNotReadyError(MMListError): pass
+class MMListAlreadyExistsError(MMListError): pass
+class BadListNameError(MMListError): pass
+
+# Membership exceptions
+class MMMemberError(Exception): pass
+class MMBadUserError(MMMemberError): pass
+class MMAlreadyAMember(MMMemberError): pass
+
+# "New" style membership exceptions (new w/ MM2.1)
+class MemberError(Exception): 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
+
+# Exception hierarchy for various authentication failures, can be
+# raised from functions in SecurityManager.py
+class MMAuthenticationError(Exception): pass
+class MMBadPasswordError(MMAuthenticationError): pass
+class MMPasswordsMustMatch(MMAuthenticationError): pass
+class MMCookieError(MMAuthenticationError): pass
+class MMExpiredCookieError(MMCookieError): pass
+class MMInvalidCookieError(MMCookieError): pass
+
+# BAW: these still need to be converted to classes.
+MMMustDigestError = "MMMustDigestError"
+MMCantDigestError = "MMCantDigestError"
+MMNeedApproval = "MMNeedApproval"
+MMSubscribeNeedsConfirmation = "MMSubscribeNeedsConfirmation"
+MMBadConfirmation = "MMBadConfirmation"
+MMAlreadyDigested = "MMAlreadyDigested"
+MMAlreadyUndigested = "MMAlreadyUndigested"
+
+MODERATED_LIST_MSG = "Moderated list"
+IMPLICIT_DEST_MSG = "Implicit destination"
+SUSPICIOUS_HEADER_MSG = "Suspicious header"
+FORBIDDEN_SENDER_MSG = "Forbidden sender"
+
+
+
+# New style class based exceptions. All the above errors should eventually be
+# converted.
+
+class MailmanError(Exception):
+ """Base class for all Mailman exceptions."""
+ pass
+
+
+class MMLoopingPost(MailmanError):
+ """Post already went through this list!"""
+ pass
+
+
+# Exception hierarchy for bad email address errors that can be raised from
+# Utils.ValidateEmail()
+class EmailAddressError(MailmanError):
+ """Base class for email address validation errors."""
+ pass
+
+class MMBadEmailError(EmailAddressError):
+ """Email address is invalid (empty string or not fully qualified)."""
+ pass
+
+class MMHostileAddress(EmailAddressError):
+ """Email address has potentially hostile characters in it."""
+ pass
+
+
+# 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
+
+# multiple inheritance for backwards compatibility
+class LoopError(DiscardMessage, MMLoopingPost):
+ """We've seen this message before"""
+
+class RejectMessage(HandlerError):
+ """The message will be bounced back to the sender"""
+ def __init__(self, notice=None):
+ if notice is None:
+ notice = _('Your message was rejected')
+ self.__notice = notice
+
+ def notice(self):
+ return self.__notice
diff --git a/Mailman/GatewayManager.py b/Mailman/GatewayManager.py
new file mode 100644
index 00000000..fa338cd4
--- /dev/null
+++ b/Mailman/GatewayManager.py
@@ -0,0 +1,38 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Mixin class for configuring Usenet gateway.
+
+All the actual functionality is in Handlers/ToUsenet.py for the mail->news
+gateway and cron/gate_news for the news->mail gateway.
+
+"""
+
+from Mailman import mm_cfg
+from Mailman.i18n import _
+
+
+class GatewayManager:
+ def InitVars(self):
+ # Configurable
+ self.nntp_host = mm_cfg.DEFAULT_NNTP_HOST
+ self.linked_newsgroup = ''
+ self.gateway_to_news = 0
+ self.gateway_to_mail = 0
+ self.news_prefix_subject_too = 1
+ # In patch #401270, this was called newsgroup_is_moderated, but the
+ # semantics weren't quite the same.
+ self.news_moderation = 0
diff --git a/Mailman/Gui/.cvsignore b/Mailman/Gui/.cvsignore
new file mode 100644
index 00000000..f3c7a7c5
--- /dev/null
+++ b/Mailman/Gui/.cvsignore
@@ -0,0 +1 @@
+Makefile
diff --git a/Mailman/Gui/Archive.py b/Mailman/Gui/Archive.py
new file mode 100644
index 00000000..59c2fd10
--- /dev/null
+++ b/Mailman/Gui/Archive.py
@@ -0,0 +1,44 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+from Mailman import mm_cfg
+from Mailman.i18n import _
+from Mailman.Gui.GUIBase import GUIBase
+
+
+
+class Archive(GUIBase):
+ def GetConfigCategory(self):
+ return 'archive', _('Archiving Options')
+
+ def GetConfigInfo(self, mlist, category, subcat=None):
+ if category <> 'archive':
+ return None
+ return [
+ _("List traffic archival policies."),
+
+ ('archive', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('Archive messages?')),
+
+ ('archive_private', mm_cfg.Radio, (_('public'), _('private')), 0,
+ _('Is archive file source for public or private archival?')),
+
+ ('archive_volume_frequency', mm_cfg.Radio,
+ (_('Yearly'), _('Monthly'), _('Quarterly'),
+ _('Weekly'), _('Daily')),
+ 0,
+ _('How often should a new archive volume be started?')),
+ ]
diff --git a/Mailman/Gui/Autoresponse.py b/Mailman/Gui/Autoresponse.py
new file mode 100644
index 00000000..3c8a71e0
--- /dev/null
+++ b/Mailman/Gui/Autoresponse.py
@@ -0,0 +1,98 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Administrative GUI for the autoresponder."""
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman.i18n import _
+from Mailman.Gui.GUIBase import GUIBase
+
+# These are the allowable string substitution variables
+ALLOWEDS = ('listname', 'listurl', 'requestemail', 'adminemail', 'owneremail')
+
+
+
+class Autoresponse(GUIBase):
+ def GetConfigCategory(self):
+ return 'autoreply', _('Auto-responder')
+
+ def GetConfigInfo(self, mlist, category, subcat=None):
+ if category <> 'autoreply':
+ return None
+ WIDTH = mm_cfg.TEXTFIELDWIDTH
+
+ return [
+ _("""\
+Auto-responder characteristics.<p>
+
+In the text fields below, string interpolation is performed with
+the following key/value substitutions:
+<p><ul>
+ <li><b>listname</b> - <em>gets the name of the mailing list</em>
+ <li><b>listurl</b> - <em>gets the list's listinfo URL</em>
+ <li><b>requestemail</b> - <em>gets the list's -request address</em>
+ <li><b>owneremail</b> - <em>gets the list's -owner address</em>
+</ul>
+
+<p>For each text field, you can either enter the text directly into the text
+box, or you can specify a file on your local system to upload as the text."""),
+
+ ('autorespond_postings', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('''Should Mailman send an auto-response to mailing list
+ posters?''')),
+
+ ('autoresponse_postings_text', mm_cfg.FileUpload,
+ (6, WIDTH), 0,
+ _('Auto-response text to send to mailing list posters.')),
+
+ ('autorespond_admin', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('''Should Mailman send an auto-response to emails sent to the
+ -owner address?''')),
+
+ ('autoresponse_admin_text', mm_cfg.FileUpload,
+ (6, WIDTH), 0,
+ _('Auto-response text to send to -owner emails.')),
+
+ ('autorespond_requests', mm_cfg.Radio,
+ (_('No'), _('Yes, w/discard'), _('Yes, w/forward')), 0,
+ _('''Should Mailman send an auto-response to emails sent to the
+ -request address? If you choose yes, decide whether you want
+ Mailman to discard the original email, or forward it on to the
+ system as a normal mail command.''')),
+
+ ('autoresponse_request_text', mm_cfg.FileUpload,
+ (6, WIDTH), 0,
+ _('Auto-response text to send to -request emails.')),
+
+ ('autoresponse_graceperiod', mm_cfg.Number, 3, 0,
+ _('''Number of days between auto-responses to either the mailing
+ list or -request/-owner address from the same poster. Set to
+ zero (or negative) for no grace period (i.e. auto-respond to
+ every message).''')),
+ ]
+
+ def _setValue(self, mlist, property, val, doc):
+ # Handle these specially because we may need to convert to/from
+ # external $-string representation.
+ if property in ('autoresponse_postings_text',
+ 'autoresponse_admin_text',
+ 'autoresponse_request_text'):
+ val = self._convertString(mlist, property, ALLOWEDS, val, doc)
+ if val is None:
+ # There was a problem, so don't set it
+ return
+ GUIBase._setValue(self, mlist, property, val, doc)
diff --git a/Mailman/Gui/Bounce.py b/Mailman/Gui/Bounce.py
new file mode 100644
index 00000000..4986cf28
--- /dev/null
+++ b/Mailman/Gui/Bounce.py
@@ -0,0 +1,183 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+from Mailman import mm_cfg
+from Mailman.i18n import _
+from Mailman.mm_cfg import days
+from Mailman.Gui.GUIBase import GUIBase
+
+
+
+class Bounce(GUIBase):
+ def GetConfigCategory(self):
+ return 'bounce', _('Bounce processing')
+
+ def GetConfigInfo(self, mlist, category, subcat=None):
+ if category <> 'bounce':
+ return None
+ return [
+ _("""These policies control the automatic bounce processing system
+ in Mailman. Here's an overview of how it works.
+
+ <p>When a bounce is received, Mailman tries to extract two pieces
+ of information from the message: the address of the member the
+ message was intended for, and the severity of the problem causing
+ the bounce. The severity can be either <em>hard</em> or
+ <em>soft</em> meaning either a fatal error occurred, or a
+ transient error occurred. When in doubt, a hard severity is used.
+
+ <p>If no member address can be extracted from the bounce, then the
+ bounce is usually discarded. Otherwise, each member is assigned a
+ <em>bounce score</em> and every time we encounter a bounce from
+ this member we increment the score. Hard bounces increment by 1
+ while soft bounces increment by 0.5. We only increment the bounce
+ score once per day, so even if we receive ten hard bounces from a
+ member per day, their score will increase by only 1 for that day.
+
+ <p>When a member's bounce score is greater than the
+ <a href="?VARHELP=bounce/bounce_score_threshold">bounce score
+ threshold</a>, the subscription is disabled. Once disabled, the
+ member will not receive any postings from the list until their
+ membership is explicitly re-enabled (either by the list
+ administrator or the user). However, they will receive occasional
+ reminders that their membership has been disabled, and these
+ reminders will include information about how to re-enable their
+ membership.
+
+ <p>You can control both the
+ <a href="?VARHELP=bounce/bounce_you_are_disabled_warnings">number
+ of reminders</a> the member will receive and the
+ <a href="?VARHELP=bounce/bounce_you_are_disabled_warnings_interval"
+ >frequency</a> with which these reminders are sent.
+
+ <p>There is one other important configuration variable; after a
+ certain period of time -- during which no bounces from the member
+ are received -- the bounce information is
+ <a href="?VARHELP=bounce/bounce_info_stale_after">considered
+ stale</a> and discarded. Thus by adjusting this value, and the
+ score threshold, you can control how quickly bouncing members are
+ disabled. You should tune both of these to the frequency and
+ traffic volume of your list."""),
+
+ _('Bounce detection sensitivity'),
+
+ ('bounce_processing', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('Should Mailman perform automatic bounce processing?'),
+ _("""By setting this value to <em>No</em>, you disable all
+ automatic bounce processing for this list, however bounce
+ messages will still be discarded so that the list administrator
+ isn't inundated with them.""")),
+
+ ('bounce_score_threshold', mm_cfg.Number, 5, 0,
+ _("""The maximum member bounce score before the member's
+ subscription is disabled. This value can be a floating point
+ number.""")),
+
+ ('bounce_info_stale_after', mm_cfg.Number, 5, 0,
+ _("""The number of days after which a member's bounce information
+ is discarded, if no new bounces have been received in the
+ interim. This value must be an integer.""")),
+
+ ('bounce_you_are_disabled_warnings', mm_cfg.Number, 5, 0,
+ _("""How many <em>Your Membership Is Disabled</em> warnings a
+ disabled member should get before their address is removed from
+ the mailing list. Set to 0 to immediately remove an address from
+ the list once their bounce score exceeds the threshold. This
+ value must be an integer.""")),
+
+ ('bounce_you_are_disabled_warnings_interval', mm_cfg.Number, 5, 0,
+ _("""The number of days between sending the <em>Your Membership
+ Is Disabled</em> warnings. This value must be an integer.""")),
+
+ _('Notifications'),
+
+ ('bounce_unrecognized_goes_to_list_owner', mm_cfg.Toggle,
+ (_('No'), _('Yes')), 0,
+ _('''Should Mailman send you, the list owner, any bounce messages
+ that failed to be detected by the bounce processor? <em>Yes</em>
+ is recommended.'''),
+ _("""While Mailman's bounce detector is fairly robust, it's
+ impossible to detect every bounce format in the world. You
+ should keep this variable set to <em>Yes</em> for two reasons: 1)
+ If this really is a permanent bounce from one of your members,
+ you should probably manually remove them from your list, and 2)
+ you might want to send the message on to the Mailman developers
+ so that this new format can be added to its known set.
+
+ <p>If you really can't be bothered, then set this variable to
+ <em>No</em> and all non-detected bounces will be discarded
+ without further processing.
+
+ <p><b>Note:</b> This setting will also affect all messages sent
+ to your list's -admin address. This address is deprecated and
+ should never be used, but some people may still send mail to this
+ address. If this happens, and this variable is set to
+ <em>No</em> those messages too will get discarded. You may want
+ to set up an
+ <a href="?VARHELP=autoreply/autoresponse_admin_text">autoresponse
+ message</a> for email to the -owner and -admin address.""")),
+
+ ('bounce_notify_owner_on_disable', mm_cfg.Toggle,
+ (_('No'), _('Yes')), 0,
+ _("""Should Mailman notify you, the list owner, when bounces
+ cause a member's subscription to be disabled?"""),
+ _("""By setting this value to <em>No</em>, you turn off
+ notification messages that are normally sent to the list owners
+ when a member's delivery is disabled due to excessive bounces.
+ An attempt to notify the member will always be made.""")),
+
+ ('bounce_notify_owner_on_removal', mm_cfg.Toggle,
+ (_('No'), _('Yes')), 0,
+ _("""Should Mailman notify you, the list owner, when bounces
+ cause a member to be unsubscribed?"""),
+ _("""By setting this value to <em>No</em>, you turn off
+ notification messages that are normally sent to the list owners
+ when a member is unsubscribed due to excessive bounces. An
+ attempt to notify the member will always be made.""")),
+
+ ]
+
+ def _setValue(self, mlist, property, val, doc):
+ # Do value conversion from web representation to internal
+ # representation.
+ try:
+ if property == 'bounce_processing':
+ val = int(val)
+ elif property == 'bounce_score_threshold':
+ val = float(val)
+ elif property == 'bounce_info_stale_after':
+ val = days(int(val))
+ elif property == 'bounce_you_are_disabled_warnings':
+ val = int(val)
+ elif property == 'bounce_you_are_disabled_warnings_interval':
+ val = days(int(val))
+ elif property == 'bounce_notify_owner_on_disable':
+ val = int(val)
+ elif property == 'bounce_notify_owner_on_removal':
+ val = int(val)
+ except ValueError:
+ doc.addError(
+ _("""Bad value for <a href="?VARHELP=bounce/%(property)s"
+ >%(property)s</a>: %(val)s"""),
+ tag = _('Error: '))
+ return
+ GUIBase._setValue(self, mlist, property, val, doc)
+
+ def getValue(self, mlist, kind, varname, params):
+ if varname not in ('bounce_info_stale_after',
+ 'bounce_you_are_disabled_warnings_interval'):
+ return None
+ return int(getattr(mlist, varname) / days(1))
diff --git a/Mailman/Gui/ContentFilter.py b/Mailman/Gui/ContentFilter.py
new file mode 100644
index 00000000..cb7ed95c
--- /dev/null
+++ b/Mailman/Gui/ContentFilter.py
@@ -0,0 +1,169 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""GUI component managing the content filtering options.
+"""
+
+from Mailman import mm_cfg
+from Mailman.i18n import _
+from Mailman.Gui.GUIBase import GUIBase
+
+NL = '\n'
+
+
+
+class ContentFilter(GUIBase):
+ def GetConfigCategory(self):
+ return 'contentfilter', _('Content&nbsp;filtering')
+
+ def GetConfigInfo(self, mlist, category, subcat=None):
+ if category <> 'contentfilter':
+ return None
+ WIDTH = mm_cfg.TEXTFIELDWIDTH
+
+ actions = [_('Discard'), _('Reject'), _('Forward to List Owner')]
+ if mm_cfg.OWNERS_CAN_PRESERVE_FILTERED_MESSAGES:
+ actions.append(_('Preserve'))
+
+ return [
+ _("""Policies concerning the content of list traffic.
+
+ <p>Content filtering works like this: when a message is
+ received by the list and you have enabled content filtering, the
+ individual attachments are first compared to the
+ <a href="?VARHELP=contentfilter/filter_mime_types">filter
+ types</a>. If the attachment type matches an entry in the filter
+ types, it is discarded.
+
+ <p>Then, if there are <a
+ href="?VARHELP=contentfilter/pass_mime_types">pass types</a>
+ defined, any attachment type that does <em>not</em> match a
+ pass type is also discarded. If there are no pass types defined,
+ this check is skipped.
+
+ <p>After this initial filtering, any <tt>multipart</tt>
+ attachments that are empty are removed. If the outer message is
+ left empty after this filtering, then the whole message is
+ discarded. Then, each <tt>multipart/alternative</tt> section will
+ be replaced by just the first alternative that is non-empty after
+ filtering.
+
+ <p>Finally, any <tt>text/html</tt> parts that are left in the
+ message may be converted to <tt>text/plain</tt> if
+ <a href="?VARHELP=contentfilter/convert_html_to_plaintext"
+ >convert_html_to_plaintext</a> is enabled and the site is
+ configured to allow these conversions."""),
+
+ ('filter_content', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _("""Should Mailman filter the content of list traffic according
+ to the settings below?""")),
+
+ ('filter_mime_types', mm_cfg.Text, (10, WIDTH), 0,
+ _("""Remove message attachments that have a matching content
+ type."""),
+
+ _("""Use this option to remove each message attachment that
+ matches one of these content types. Each line should contain a
+ string naming a MIME <tt>type/subtype</tt>,
+ e.g. <tt>image/gif</tt>. Leave off the subtype to remove all
+ parts with a matching major content type, e.g. <tt>image</tt>.
+
+ <p>Blank lines are ignored.
+
+ <p>See also <a href="?VARHELP=contentfilter/pass_mime_types"
+ >pass_mime_types</a> for a content type whitelist.""")),
+
+ ('pass_mime_types', mm_cfg.Text, (10, WIDTH), 0,
+ _("""Remove message attachments that don't have a matching
+ content type. Leave this field blank to skip this filter
+ test."""),
+
+ _("""Use this option to remove each message attachment that does
+ not have a matching content type. Requirements and formats are
+ exactly like <a href="?VARHELP=contentfilter/filter_mime_types"
+ >filter_mime_types</a>.
+
+ <p><b>Note:</b> if you add entries to this list but don't add
+ <tt>multipart</tt> to this list, any messages with attachments
+ will be rejected by the pass filter.""")),
+
+ ('convert_html_to_plaintext', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _("""Should Mailman convert <tt>text/html</tt> parts to plain
+ text? This conversion happens after MIME attachments have been
+ stripped.""")),
+
+ ('filter_action', mm_cfg.Radio, tuple(actions), 0,
+
+ _("""Action to take when a message matches the content filtering
+ rules."""),
+
+ _("""One of these actions is take when the message matches one of
+ the content filtering rules, meaning, the top-level
+ content type matches one of the <a
+ href="?VARHELP=contentfilter/filter_mime_types"
+ >filter_mime_types</a>, or the top-level content type does
+ <strong>not</strong> match one of the
+ <a href="?VARHELP=contentfilter/pass_mime_types"
+ >pass_mime_types</a>, or if after filtering the subparts of the
+ message, the message ends up empty.
+
+ <p>Note this action is not taken if after filtering the message
+ still contains content. In that case the message is always
+ forwarded on to the list membership.
+
+ <p>When messages are discarded, a log entry is written
+ containing the Message-ID of the discarded message. When
+ messages are rejected or forwarded to the list owner, a reason
+ for the rejection is included in the bounce message to the
+ original author. When messages are preserved, they are saved in
+ a special queue directory on disk for the site administrator to
+ view (and possibly rescue) but otherwise discarded. This last
+ option is only available if enabled by the site
+ administrator.""")),
+ ]
+
+ def _setValue(self, mlist, property, val, doc):
+ if property in ('filter_mime_types', 'pass_mime_types'):
+ types = []
+ for spectype in [s.strip() for s in val.splitlines()]:
+ ok = 1
+ slashes = spectype.count('/')
+ if slashes == 0 and not spectype:
+ ok = 0
+ elif slashes == 1:
+ maintype, subtype = [s.strip().lower()
+ for s in spectype.split('/')]
+ if not maintype or not subtype:
+ ok = 0
+ elif slashes > 1:
+ ok = 0
+ if not ok:
+ doc.addError(_('Bad MIME type ignored: %(spectype)s'))
+ else:
+ types.append(spectype.strip().lower())
+ if property == 'filter_mime_types':
+ mlist.filter_mime_types = types
+ elif property == 'pass_mime_types':
+ mlist.pass_mime_types = types
+ else:
+ GUIBase._setValue(self, mlist, property, val, doc)
+
+ def getValue(self, mlist, kind, property, params):
+ if property == 'filter_mime_types':
+ return NL.join(mlist.filter_mime_types)
+ if property == 'pass_mime_types':
+ return NL.join(mlist.pass_mime_types)
+ return None
diff --git a/Mailman/Gui/Digest.py b/Mailman/Gui/Digest.py
new file mode 100644
index 00000000..7eb486c7
--- /dev/null
+++ b/Mailman/Gui/Digest.py
@@ -0,0 +1,160 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Administrative GUI for digest deliveries."""
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman.i18n import _
+
+# Intra-package import
+from Mailman.Gui.GUIBase import GUIBase
+
+# Common b/w nondigest and digest headers & footers. Personalizations may add
+# to this.
+ALLOWEDS = ('real_name', 'list_name', 'host_name', 'web_page_url',
+ 'description', 'info', 'cgiext', '_internal_name',
+ )
+
+
+
+class Digest(GUIBase):
+ def GetConfigCategory(self):
+ return 'digest', _('Digest options')
+
+ def GetConfigInfo(self, mlist, category, subcat=None):
+ if category <> 'digest':
+ return None
+ WIDTH = mm_cfg.TEXTFIELDWIDTH
+
+ info = [
+ _("Batched-delivery digest characteristics."),
+
+ ('digestable', mm_cfg.Toggle, (_('No'), _('Yes')), 1,
+ _('Can list members choose to receive list traffic '
+ 'bunched in digests?')),
+
+ ('digest_is_default', mm_cfg.Radio,
+ (_('Regular'), _('Digest')), 0,
+ _('Which delivery mode is the default for new users?')),
+
+ ('mime_is_default_digest', mm_cfg.Radio,
+ (_('Plain'), _('MIME')), 0,
+ _('When receiving digests, which format is default?')),
+
+ ('digest_size_threshhold', mm_cfg.Number, 3, 0,
+ _('How big in Kb should a digest be before it gets sent out?')),
+ # Should offer a 'set to 0' for no size threshhold.
+
+ ('digest_send_periodic', mm_cfg.Radio, (_('No'), _('Yes')), 1,
+ _('Should a digest be dispatched daily when the size threshold '
+ "isn't reached?")),
+
+ ('digest_header', mm_cfg.Text, (4, WIDTH), 0,
+ _('Header added to every digest'),
+ _("Text attached (as an initial message, before the table"
+ " of contents) to the top of digests. ")
+ + Utils.maketext('headfoot.html', raw=1, mlist=mlist)),
+
+ ('digest_footer', mm_cfg.Text, (4, WIDTH), 0,
+ _('Footer added to every digest'),
+ _("Text attached (as a final message) to the bottom of digests. ")
+ + Utils.maketext('headfoot.html', raw=1, mlist=mlist)),
+
+ ('digest_volume_frequency', mm_cfg.Radio,
+ (_('Yearly'), _('Monthly'), _('Quarterly'),
+ _('Weekly'), _('Daily')), 0,
+ _('How often should a new digest volume be started?'),
+ _('''When a new digest volume is started, the volume number is
+ incremented and the issue number is reset to 1.''')),
+
+ ('_new_volume', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('Should Mailman start a new digest volume?'),
+ _('''Setting this option instructs Mailman to start a new volume
+ with the next digest sent out.''')),
+
+ ('_send_digest_now', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('''Should Mailman send the next digest right now, if it is not
+ empty?''')),
+ ]
+
+## if mm_cfg.OWNERS_CAN_ENABLE_PERSONALIZATION:
+## info.extend([
+## ('digest_personalize', mm_cfg.Toggle, (_('No'), _('Yes')), 1,
+
+## _('''Should Mailman personalize each digest delivery?
+## This is often useful for announce-only lists, but <a
+## href="?VARHELP=digest/digest_personalize">read the details</a>
+## section for a discussion of important performance
+## issues.'''),
+
+## _("""Normally, Mailman sends the digest messages to
+## the mail server in batches. This is much more efficent
+## because it reduces the amount of traffic between Mailman and
+## the mail server.
+
+## <p>However, some lists can benefit from a more personalized
+## approach. In this case, Mailman crafts a new message for
+## each member on the digest delivery list. Turning this on
+## adds a few more expansion variables that can be included in
+## the <a href="?VARHELP=digest/digest_header">message header</a>
+## and <a href="?VARHELP=digest/digest_footer">message footer</a>
+## but it may degrade the performance of your site as
+## a whole.
+
+## <p>You need to carefully consider whether the trade-off is
+## worth it, or whether there are other ways to accomplish what
+## you want. You should also carefully monitor your system load
+## to make sure it is acceptable.
+
+## <p>These additional substitution variables will be available
+## for your headers and footers, when this feature is enabled:
+
+## <ul><li><b>user_address</b> - The address of the user,
+## coerced to lower case.
+## <li><b>user_delivered_to</b> - The case-preserved address
+## that the user is subscribed with.
+## <li><b>user_password</b> - The user's password.
+## <li><b>user_name</b> - The user's full name.
+## <li><b>user_optionsurl</b> - The url to the user's option
+## page.
+## """))
+## ])
+
+ return info
+
+ def _setValue(self, mlist, property, val, doc):
+ # Watch for the special, immediate action attributes
+ if property == '_new_volume' and val:
+ mlist.bump_digest_volume()
+ volume = mlist.volume
+ number = mlist.next_digest_number
+ doc.AddItem(_("""The next digest will be sent as volume
+ %(volume)s, number %(number)s"""))
+ elif property == '_send_digest_now' and val:
+ status = mlist.send_digest_now()
+ if status:
+ doc.AddItem(_("""A digest has been sent."""))
+ else:
+ doc.AddItem(_("""There was no digest to send."""))
+ else:
+ # Everything else...
+ if property in ('digest_header', 'digest_footer'):
+ val = self._convertString(mlist, property, ALLOWEDS, val, doc)
+ if val is None:
+ # There was a problem, so don't set it
+ return
+ GUIBase._setValue(self, mlist, property, val, doc)
diff --git a/Mailman/Gui/GUIBase.py b/Mailman/Gui/GUIBase.py
new file mode 100644
index 00000000..7062235e
--- /dev/null
+++ b/Mailman/Gui/GUIBase.py
@@ -0,0 +1,200 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Base class for all web GUI components."""
+
+import re
+from types import TupleType, ListType
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.i18n import _
+
+NL = '\n'
+BADJOINER = '</code>, <code>'
+
+
+
+class GUIBase:
+ # Providing a common interface for GUI component form processing. Most
+ # GUI components won't need to override anything, but some may want to
+ # override _setValue() to provide some specialized processing for some
+ # attributes.
+ def _getValidValue(self, mlist, property, wtype, val):
+ # Coerce and validate the new value.
+ #
+ # Radio buttons and boolean toggles both have integral type
+ if wtype in (mm_cfg.Radio, mm_cfg.Toggle):
+ # Let ValueErrors propagate
+ return int(val)
+ # String and Text widgets both just return their values verbatim
+ if wtype in (mm_cfg.String, mm_cfg.Text):
+ return val
+ # This widget contains a single email address
+ if wtype == mm_cfg.Email:
+ # BAW: We must allow blank values otherwise reply_to_address can't
+ # be cleared. This is currently the only mm_cfg.Email type widget
+ # in the interface, so watch out if we ever add any new ones.
+ if val:
+ # Let MMBadEmailError and MMHostileAddress propagate
+ Utils.ValidateEmail(val)
+ return val
+ # These widget types contain lists of email addresses, one per line.
+ # The EmailListEx allows each line to contain either an email address
+ # or a regular expression
+ if wtype in (mm_cfg.EmailList, mm_cfg.EmailListEx):
+ # BAW: value might already be a list, if this is coming from
+ # config_list input. Sigh.
+ if isinstance(val, ListType):
+ return val
+ addrs = []
+ for addr in [s.strip() for s in val.split(NL)]:
+ # Discard empty lines
+ if not addr:
+ continue
+ try:
+ # This throws an exception if the address is invalid
+ Utils.ValidateEmail(addr)
+ except Errors.EmailAddressError:
+ # See if this is a context that accepts regular
+ # expressions, and that the re is legal
+ if wtype == mm_cfg.EmailListEx and addr.startswith('^'):
+ try:
+ re.compile(addr)
+ except re.error:
+ raise ValueError
+ else:
+ raise
+ addrs.append(addr)
+ return addrs
+ # This is a host name, i.e. verbatim
+ if wtype == mm_cfg.Host:
+ return val
+ # This is a number, either a float or an integer
+ if wtype == mm_cfg.Number:
+ num = -1
+ try:
+ num = int(val)
+ except ValueError:
+ # Let ValueErrors percolate up
+ num = float(val)
+ if num < 0:
+ return getattr(mlist, property)
+ return num
+ # This widget is a select box, i.e. verbatim
+ if wtype == mm_cfg.Select:
+ return val
+ # Checkboxes return a list of the selected items, even if only one is
+ # selected.
+ if wtype == mm_cfg.Checkbox:
+ if isinstance(val, ListType):
+ return val
+ return [val]
+ if wtype == mm_cfg.FileUpload:
+ return val
+ if wtype == mm_cfg.Topics:
+ return val
+ # Should never get here
+ assert 0, 'Bad gui widget type: %s' % wtype
+
+ def _setValue(self, mlist, property, val, doc):
+ # Set the value, or override to take special action on the property
+ if not property.startswith('_') and getattr(mlist, property) <> val:
+ setattr(mlist, property, val)
+
+ def _postValidate(self, mlist, doc):
+ # Validate all the attributes for this category
+ pass
+
+ def handleForm(self, mlist, category, subcat, cgidata, doc):
+ for item in self.GetConfigInfo(mlist, category, subcat):
+ # Skip descriptions and legacy non-attributes
+ if not isinstance(item, TupleType) or len(item) < 5:
+ continue
+ # Unpack the gui item description
+ property, wtype, args, deps, desc = item[0:5]
+ # BAW: I know this code is a little crufty but I wanted to
+ # reproduce the semantics of the original code in admin.py as
+ # closely as possible, for now. We can clean it up later.
+ #
+ # The property may be uploadable...
+ uploadprop = property + '_upload'
+ if cgidata.has_key(uploadprop) and cgidata[uploadprop].value:
+ val = cgidata[uploadprop].value
+ elif not cgidata.has_key(property):
+ continue
+ elif isinstance(cgidata[property], ListType):
+ val = [x.value for x in cgidata[property]]
+ else:
+ val = cgidata[property].value
+ # Coerce the value to the expected type, raising exceptions if the
+ # value is invalid
+ try:
+ val = self._getValidValue(mlist, property, wtype, val)
+ except ValueError:
+ doc.addError(_('Invalid value for variable: %(property)s'))
+ # This is the parent of MMBadEmailError and MMHostileAddress
+ except Errors.EmailAddressError:
+ doc.addError(
+ _('Bad email address for option %(property)s: %(val)s'))
+ else:
+ # Set the attribute, which will normally delegate to the mlist
+ self._setValue(mlist, property, val, doc)
+ # Do a final sweep once all the attributes have been set. This is how
+ # we can do cross-attribute assertions
+ self._postValidate(mlist, doc)
+
+ # Convenience method for handling $-string attributes
+ def _convertString(self, mlist, property, alloweds, val, doc):
+ # Is the list using $-strings?
+ dollarp = getattr(mlist, 'use_dollar_strings', 0)
+ if dollarp:
+ ids = Utils.dollar_identifiers(val)
+ else:
+ # %-strings
+ ids = Utils.percent_identifiers(val)
+ # Here's the list of allowable interpolations
+ for allowed in alloweds:
+ if ids.has_key(allowed):
+ del ids[allowed]
+ if ids:
+ # What's left are not allowed
+ badkeys = ids.keys()
+ badkeys.sort()
+ bad = BADJOINER.join(badkeys)
+ doc.addError(_(
+ """The following illegal substitution variables were
+ found in the <code>%(property)s</code> string:
+ <code>%(bad)s</code>
+ <p>Your list may not operate properly until you correct this
+ problem."""), tag=_('Warning: '))
+ return val
+ # Now if we're still using %-strings, do a roundtrip conversion and
+ # see if the converted value is the same as the new value. If not,
+ # then they probably left off a trailing `s'. We'll warn them and use
+ # the corrected string.
+ if not dollarp:
+ fixed = Utils.to_percent(Utils.to_dollar(val))
+ if fixed <> val:
+ doc.addError(_(
+ """Your <code>%(property)s</code> string appeared to
+ have some correctable problems in its new value.
+ The fixed value will be used instead. Please
+ double check that this is what you intended.
+ """))
+ return fixed
+ return val
diff --git a/Mailman/Gui/General.py b/Mailman/Gui/General.py
new file mode 100644
index 00000000..a33d1004
--- /dev/null
+++ b/Mailman/Gui/General.py
@@ -0,0 +1,446 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""MailList mixin class managing the general options.
+"""
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.i18n import _
+from Mailman.Gui.GUIBase import GUIBase
+
+OPTIONS = ('hide', 'ack', 'notmetoo', 'nodupes')
+
+
+
+class General(GUIBase):
+ def GetConfigCategory(self):
+ return 'general', _('General Options')
+
+ def GetConfigInfo(self, mlist, category, subcat):
+ if category <> 'general':
+ return None
+ WIDTH = mm_cfg.TEXTFIELDWIDTH
+
+ # These are for the default_options checkboxes below.
+ bitfields = {'hide' : mm_cfg.ConcealSubscription,
+ 'ack' : mm_cfg.AcknowledgePosts,
+ 'notmetoo' : mm_cfg.DontReceiveOwnPosts,
+ 'nodupes' : mm_cfg.DontReceiveDuplicates
+ }
+ bitdescrs = {
+ 'hide' : _("Conceal the member's address"),
+ 'ack' : _("Acknowledge the member's posting"),
+ 'notmetoo' : _("Do not send a copy of a member's own post"),
+ 'nodupes' :
+ _('Filter out duplicate messages to list members (if possible)'),
+ }
+
+ optvals = [mlist.new_member_options & bitfields[o] for o in OPTIONS]
+ opttext = [bitdescrs[o] for o in OPTIONS]
+
+ rtn = [
+ _('''Fundamental list characteristics, including descriptive
+ info and basic behaviors.'''),
+
+ _('General list personality'),
+
+ ('real_name', mm_cfg.String, WIDTH, 0,
+ _('The public name of this list (make case-changes only).'),
+ _('''The capitalization of this name can be changed to make it
+ presentable in polite company as a proper noun, or to make an
+ acronym part all upper case, etc. However, the name will be
+ advertised as the email address (e.g., in subscribe confirmation
+ notices), so it should <em>not</em> be otherwise altered. (Email
+ addresses are not case sensitive, but they are sensitive to
+ almost everything else :-)''')),
+
+ ('owner', mm_cfg.EmailList, (3, WIDTH), 0,
+ _("""The list administrator email addresses. Multiple
+ administrator addresses, each on separate line is okay."""),
+
+ _('''There are two ownership roles associated with each mailing
+ list. The <em>list administrators</em> are the people who have
+ ultimate control over all parameters of this mailing list. They
+ are able to change any list configuration variable available
+ through these administration web pages.
+
+ <p>The <em>list moderators</em> have more limited permissions;
+ they are not able to change any list configuration variable, but
+ they are allowed to tend to pending administration requests,
+ including approving or rejecting held subscription requests, and
+ disposing of held postings. Of course, the <em>list
+ administrators</em> can also tend to pending requests.
+
+ <p>In order to split the list ownership duties into
+ administrators and moderators, you must
+ <a href="passwords">set a separate moderator password</a>,
+ and also provide the <a href="?VARHELP=general/moderator">email
+ addresses of the list moderators</a>. Note that the field you
+ are changing here specifies the list administrators.''')),
+
+ ('moderator', mm_cfg.EmailList, (3, WIDTH), 0,
+ _("""The list moderator email addresses. Multiple
+ moderator addresses, each on separate line is okay."""),
+
+ _('''There are two ownership roles associated with each mailing
+ list. The <em>list administrators</em> are the people who have
+ ultimate control over all parameters of this mailing list. They
+ are able to change any list configuration variable available
+ through these administration web pages.
+
+ <p>The <em>list moderators</em> have more limited permissions;
+ they are not able to change any list configuration variable, but
+ they are allowed to tend to pending administration requests,
+ including approving or rejecting held subscription requests, and
+ disposing of held postings. Of course, the <em>list
+ administrators</em> can also tend to pending requests.
+
+ <p>In order to split the list ownership duties into
+ administrators and moderators, you must
+ <a href="passwords">set a separate moderator password</a>,
+ and also provide the email addresses of the list moderators in
+ this section. Note that the field you are changing here
+ specifies the list moderators.''')),
+
+ ('description', mm_cfg.String, WIDTH, 0,
+ _('A terse phrase identifying this list.'),
+
+ _('''This description is used when the mailing list is listed with
+ other mailing lists, or in headers, and so forth. It should
+ be as succinct as you can get it, while still identifying what
+ the list is.''')),
+
+ ('info', mm_cfg.Text, (7, WIDTH), 0,
+ _('''An introductory description - a few paragraphs - about the
+ list. It will be included, as html, at the top of the listinfo
+ page. Carriage returns will end a paragraph - see the details
+ for more info.'''),
+ _("""The text will be treated as html <em>except</em> that
+ newlines will be translated to &lt;br&gt; - so you can use links,
+ preformatted text, etc, but don't put in carriage returns except
+ where you mean to separate paragraphs. And review your changes -
+ bad html (like some unterminated HTML constructs) can prevent
+ display of the entire listinfo page.""")),
+
+ ('subject_prefix', mm_cfg.String, WIDTH, 0,
+ _('Prefix for subject line of list postings.'),
+ _("""This text will be prepended to subject lines of messages
+ posted to the list, to distinguish mailing list messages in in
+ mailbox summaries. Brevity is premium here, it's ok to shorten
+ long mailing list names to something more concise, as long as it
+ still identifies the mailing list.""")),
+
+ ('anonymous_list', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _("""Hide the sender of a message, replacing it with the list
+ address (Removes From, Sender and Reply-To fields)""")),
+
+ _('''<tt>Reply-To:</tt> header munging'''),
+
+ ('first_strip_reply_to', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _('''Should any existing <tt>Reply-To:</tt> header found in the
+ original message be stripped? If so, this will be done
+ regardless of whether an explict <tt>Reply-To:</tt> header is
+ added by Mailman or not.''')),
+
+ ('reply_goes_to_list', mm_cfg.Radio,
+ (_('Poster'), _('This list'), _('Explicit address')), 0,
+ _('''Where are replies to list messages directed?
+ <tt>Poster</tt> is <em>strongly</em> recommended for most mailing
+ lists.'''),
+
+ # Details for reply_goes_to_list
+ _("""This option controls what Mailman does to the
+ <tt>Reply-To:</tt> header in messages flowing through this
+ mailing list. When set to <em>Poster</em>, no <tt>Reply-To:</tt>
+ header is added by Mailman, although if one is present in the
+ original message, it is not stripped. Setting this value to
+ either <em>This list</em> or <em>Explicit address</em> causes
+ Mailman to insert a specific <tt>Reply-To:</tt> header in all
+ messages, overriding the header in the original message if
+ necessary (<em>Explicit address</em> inserts the value of <a
+ href="?VARHELP=general/reply_to_address">reply_to_address</a>).
+
+ <p>There are many reasons not to introduce or override the
+ <tt>Reply-To:</tt> header. One is that some posters depend on
+ their own <tt>Reply-To:</tt> settings to convey their valid
+ return address. Another is that modifying <tt>Reply-To:</tt>
+ makes it much more difficult to send private replies. See <a
+ href="http://www.unicom.com/pw/reply-to-harmful.html">`Reply-To'
+ Munging Considered Harmful</a> for a general discussion of this
+ issue. See <a
+ href="http://www.metasystema.org/essays/reply-to-useful.mhtml">Reply-To
+ Munging Considered Useful</a> for a dissenting opinion.
+
+ <p>Some mailing lists have restricted posting privileges, with a
+ parallel list devoted to discussions. Examples are `patches' or
+ `checkin' lists, where software changes are posted by a revision
+ control system, but discussion about the changes occurs on a
+ developers mailing list. To support these types of mailing
+ lists, select <tt>Explicit address</tt> and set the
+ <tt>Reply-To:</tt> address below to point to the parallel
+ list.""")),
+
+ ('reply_to_address', mm_cfg.Email, WIDTH, 0,
+ _('Explicit <tt>Reply-To:</tt> header.'),
+ # Details for reply_to_address
+ _("""This is the address set in the <tt>Reply-To:</tt> header
+ when the <a
+ href="?VARHELP=general/reply_goes_to_list">reply_goes_to_list</a>
+ option is set to <em>Explicit address</em>.
+
+ <p>There are many reasons not to introduce or override the
+ <tt>Reply-To:</tt> header. One is that some posters depend on
+ their own <tt>Reply-To:</tt> settings to convey their valid
+ return address. Another is that modifying <tt>Reply-To:</tt>
+ makes it much more difficult to send private replies. See <a
+ href="http://www.unicom.com/pw/reply-to-harmful.html">`Reply-To'
+ Munging Considered Harmful</a> for a general discussion of this
+ issue. See <a
+ href="http://www.metasystema.org/essays/reply-to-useful.mhtml">Reply-To
+ Munging Considered Useful</a> for a dissenting opinion.
+
+ <p>Some mailing lists have restricted posting privileges, with a
+ parallel list devoted to discussions. Examples are `patches' or
+ `checkin' lists, where software changes are posted by a revision
+ control system, but discussion about the changes occurs on a
+ developers mailing list. To support these types of mailing
+ lists, specify the explicit <tt>Reply-To:</tt> address here. You
+ must also specify <tt>Explicit address</tt> in the
+ <tt>reply_goes_to_list</tt>
+ variable.
+
+ <p>Note that if the original message contains a
+ <tt>Reply-To:</tt> header, it will not be changed.""")),
+
+ _('Umbrella list settings'),
+
+ ('umbrella_list', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _('''Send password reminders to, eg, "-owner" address instead of
+ directly to user.'''),
+
+ _("""Set this to yes when this list is intended to cascade only
+ to other mailing lists. When set, meta notices like
+ confirmations and password reminders will be directed to an
+ address derived from the member\'s address - it will have the
+ value of "umbrella_member_suffix" appended to the member's
+ account name.""")),
+
+ ('umbrella_member_suffix', mm_cfg.String, WIDTH, 0,
+ _('''Suffix for use when this list is an umbrella for other
+ lists, according to setting of previous "umbrella_list"
+ setting.'''),
+
+ _("""When "umbrella_list" is set to indicate that this list has
+ other mailing lists as members, then administrative notices like
+ confirmations and password reminders need to not be sent to the
+ member list addresses, but rather to the owner of those member
+ lists. In that case, the value of this setting is appended to
+ the member's account name for such notices. `-owner' is the
+ typical choice. This setting has no effect when "umbrella_list"
+ is "No".""")),
+
+ _('Notifications'),
+
+ ('send_reminders', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _('''Send monthly password reminders?'''),
+
+ _('''Turn this on if you want password reminders to be sent once
+ per month to your members. Note that members may disable their
+ own individual password reminders.''')),
+
+ ('welcome_msg', mm_cfg.Text, (4, WIDTH), 0,
+ _('''List-specific text prepended to new-subscriber welcome
+ message'''),
+
+ _("""This value, if any, will be added to the front of the
+ new-subscriber welcome message. The rest of the welcome message
+ already describes the important addresses and URLs for the
+ mailing list, so you don't need to include any of that kind of
+ stuff here. This should just contain mission-specific kinds of
+ things, like etiquette policies or team orientation, or that kind
+ of thing.
+
+ <p>Note that this text will be wrapped, according to the
+ following rules:
+ <ul><li>Each paragraph is filled so that no line is longer than
+ 70 characters.
+ <li>Any line that begins with whitespace is not filled.
+ <li>A blank line separates paragraphs.
+ </ul>""")),
+
+ ('send_welcome_msg', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _('Send welcome message to newly subscribed members?'),
+ _("""Turn this off only if you plan on subscribing people manually
+ and don't want them to know that you did so. This option is most
+ useful for transparently migrating lists from some other mailing
+ list manager to Mailman.""")),
+
+ ('goodbye_msg', mm_cfg.Text, (4, WIDTH), 0,
+ _('''Text sent to people leaving the list. If empty, no special
+ text will be added to the unsubscribe message.''')),
+
+ ('send_goodbye_msg', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _('Send goodbye message to members when they are unsubscribed?')),
+
+ ('admin_immed_notify', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _('''Should the list moderators get immediate notice of new
+ requests, as well as daily notices about collected ones?'''),
+
+ _('''List moderators (and list administrators) are sent daily
+ reminders of requests pending approval, like subscriptions to a
+ moderated list, or postings that are being held for one reason or
+ another. Setting this option causes notices to be sent
+ immediately on the arrival of new requests as well.''')),
+
+ ('admin_notify_mchanges', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _('''Should administrator get notices of subscribes and
+ unsubscribes?''')),
+
+ ('respond_to_post_requests', mm_cfg.Radio,
+ (_('No'), _('Yes')), 0,
+ _('Send mail to poster when their posting is held for approval?'),
+
+ _("""Approval notices are sent when mail triggers certain of the
+ limits <em>except</em> routine list moderation and spam filters,
+ for which notices are <em>not</em> sent. This option overrides
+ ever sending the notice.""")),
+
+ _('Additional settings'),
+
+ ('emergency', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('Emergency moderation of all list traffic.'),
+ _("""When this option is enabled, all list traffic is emergency
+ moderated, i.e. held for moderation. Turn this option on when
+ your list is experiencing a flamewar and you want a cooling off
+ period.""")),
+
+ ('new_member_options', mm_cfg.Checkbox,
+ (opttext, optvals, 0, OPTIONS),
+ # The description for new_member_options includes a kludge where
+ # we add a hidden field so that even when all the checkboxes are
+ # deselected, the form data will still have a new_member_options
+ # key (it will always be a list). Otherwise, we'd never be able
+ # to tell if all were deselected!
+ 0, _('''Default options for new members joining this list.<input
+ type="hidden" name="new_member_options" value="ignore">'''),
+
+ _("""When a new member is subscribed to this list, their initial
+ set of options is taken from the this variable's setting.""")),
+
+ ('administrivia', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _('''(Administrivia filter) Check postings and intercept ones
+ that seem to be administrative requests?'''),
+
+ _("""Administrivia tests will check postings to see whether it's
+ really meant as an administrative request (like subscribe,
+ unsubscribe, etc), and will add it to the the administrative
+ requests queue, notifying the administrator of the new request,
+ in the process.""")),
+
+ ('max_message_size', mm_cfg.Number, 7, 0,
+ _('''Maximum length in kilobytes (KB) of a message body. Use 0
+ for no limit.''')),
+
+ ('host_name', mm_cfg.Host, WIDTH, 0,
+ _('Host name this list prefers for email.'),
+
+ _("""The "host_name" is the preferred name for email to
+ mailman-related addresses on this host, and generally should be
+ the mail host's exchanger address, if any. This setting can be
+ useful for selecting among alternative names of a host that has
+ multiple addresses.""")),
+
+ ]
+
+ if mm_cfg.ALLOW_RFC2369_OVERRIDES:
+ rtn.append(
+ ('include_rfc2369_headers', mm_cfg.Radio,
+ (_('No'), _('Yes')), 0,
+ _("""Should messages from this mailing list include the
+ <a href="http://www.faqs.org/rfcs/rfc2369.html">RFC 2369</a>
+ (i.e. <tt>List-*</tt>) headers? <em>Yes</em> is highly
+ recommended."""),
+
+ _("""RFC 2369 defines a set of List-* headers that are
+ normally added to every message sent to the list membership.
+ These greatly aid end-users who are using standards compliant
+ mail readers. They should normally always be enabled.
+
+ <p>However, not all mail readers are standards compliant yet,
+ and if you have a large number of members who are using
+ non-compliant mail readers, they may be annoyed at these
+ headers. You should first try to educate your members as to
+ why these headers exist, and how to hide them in their mail
+ clients. As a last resort you can disable these headers, but
+ this is not recommended (and in fact, your ability to disable
+ these headers may eventually go away)."""))
+ )
+ # Suppression of List-Post: headers
+ rtn.append(
+ ('include_list_post_header', mm_cfg.Radio,
+ (_('No'), _('Yes')), 0,
+ _('Should postings include the <tt>List-Post:</tt> header?'),
+ _("""The <tt>List-Post:</tt> header is one of the headers
+ recommended by
+ <a href="http://www.faqs.org/rfcs/rfc2369.html">RFC 2369</a>.
+ However for some <em>announce-only</em> mailing lists, only a
+ very select group of people are allowed to post to the list; the
+ general membership is usually not allowed to post. For lists of
+ this nature, the <tt>List-Post:</tt> header is misleading.
+ Select <em>No</em> to disable the inclusion of this header. (This
+ does not affect the inclusion of the other <tt>List-*:</tt>
+ headers.)"""))
+ )
+
+ return rtn
+
+ def _setValue(self, mlist, property, val, doc):
+ if property == 'real_name' and \
+ val.lower() <> mlist.internal_name().lower():
+ # These values can't differ by other than case
+ doc.addError(_("""<b>real_name</b> attribute not
+ changed! It must differ from the list's name by case
+ only."""))
+ elif property == 'new_member_options':
+ newopts = 0
+ for opt in OPTIONS:
+ bitfield = mm_cfg.OPTINFO[opt]
+ if opt in val:
+ newopts |= bitfield
+ mlist.new_member_options = newopts
+ elif property == 'subject_prefix':
+ # Convert any html entities to Unicode
+ mlist.subject_prefix = Utils.canonstr(
+ val, mlist.preferred_language)
+ else:
+ GUIBase._setValue(self, mlist, property, val, doc)
+
+ def _postValidate(self, mlist, doc):
+ if not mlist.reply_to_address.strip() and \
+ mlist.reply_goes_to_list == 2:
+ # You can't go to an explicit address that is blank
+ doc.addError(_("""You cannot add a Reply-To: to an explicit
+ address if that address is blank. Resetting these values."""))
+ mlist.reply_to_address = ''
+ mlist.reply_goes_to_list = 0
+
+ def getValue(self, mlist, kind, varname, params):
+ if varname <> 'subject_prefix':
+ return None
+ # The subject_prefix may be Unicode
+ return Utils.uncanonstr(mlist.subject_prefix, mlist.preferred_language)
diff --git a/Mailman/Gui/Language.py b/Mailman/Gui/Language.py
new file mode 100644
index 00000000..bfa5185f
--- /dev/null
+++ b/Mailman/Gui/Language.py
@@ -0,0 +1,122 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""MailList mixin class managing the language options.
+"""
+
+import codecs
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import i18n
+from Mailman.Logging.Syslog import syslog
+from Mailman.Gui.GUIBase import GUIBase
+
+_ = i18n._
+
+
+
+class Language(GUIBase):
+ def GetConfigCategory(self):
+ return 'language', _('Language&nbsp;options')
+
+ def GetConfigInfo(self, mlist, category, subcat=None):
+ if category <> 'language':
+ return None
+
+ # Set things up for the language choices
+ langs = mlist.GetAvailableLanguages()
+ langnames = [_(Utils.GetLanguageDescr(L)) for L in langs]
+ try:
+ langi = langs.index(mlist.preferred_language)
+ except ValueError:
+ # Someone must have deleted the list's preferred language. Could
+ # be other trouble lurking!
+ langi = 0
+
+ # Only allow the admin to choose a language if the system has a
+ # charset for it. I think this is the best way to test for that.
+ def checkcodec(charset):
+ try:
+ codecs.lookup(charset)
+ return 1
+ except LookupError:
+ return 0
+
+ all = [key for key in mm_cfg.LC_DESCRIPTIONS.keys()
+ if checkcodec(Utils.GetCharSet(key))]
+ all.sort()
+ checked = [L in langs for L in all]
+ allnames = [_(Utils.GetLanguageDescr(L)) for L in all]
+
+ return [
+ _('Natural language (internationalization) options.'),
+
+ ('preferred_language', mm_cfg.Select,
+ (langs, langnames, langi),
+ 0,
+ _('Default language for this list.'),
+ _('''This is the default natural language for this mailing list.
+ If <a href="?VARHELP=language/available_languages">more than one
+ language</a> is supported then users will be able to select their
+ own preferences for when they interact with the list. All other
+ interactions will be conducted in the default language. This
+ applies to both web-based and email-based messages, but not to
+ email posted by list members.''')),
+
+ ('available_languages', mm_cfg.Checkbox,
+ (allnames, checked, 0, all), 0,
+ _('Languages supported by this list.'),
+
+ _('''These are all the natural languages supported by this list.
+ Note that the
+ <a href="?VARHELP=language/preferred_language">default
+ language</a> must be included.''')),
+
+ ('encode_ascii_prefixes', mm_cfg.Radio,
+ (_('Never'), _('Always'), _('As needed')), 0,
+ _("""Encode the
+ <a href="?VARHELP=general/subject_prefix">subject
+ prefix</a> even when it consists of only ASCII characters?"""),
+
+ _("""If your mailing list's default language uses a non-ASCII
+ character set and the prefix contains non-ASCII characters, the
+ prefix will always be encoded according to the relevant
+ standards. However, if your prefix contains only ASCII
+ characters, you may want to set this option to <em>Never</em> to
+ disable prefix encoding. This can make the subject headers
+ slightly more readable for users with mail readers that don't
+ properly handle non-ASCII encodings.
+
+ <p>Note however, that if your mailing list receives both encoded
+ and unencoded subject headers, you might want to choose <em>As
+ needed</em>. Using this setting, Mailman will not encode ASCII
+ prefixes when the rest of the header contains only ASCII
+ characters, but if the original header contains non-ASCII
+ characters, it will encode the prefix. This avoids an ambiguity
+ in the standards which could cause some mail readers to display
+ extra, or missing spaces between the prefix and the original
+ header.""")),
+
+ ]
+
+ def _setValue(self, mlist, property, val, doc):
+ # If we're changing the list's preferred language, change the I18N
+ # context as well
+ if property == 'preferred_language':
+ i18n.set_language(val)
+ doc.set_language(val)
+ GUIBase._setValue(self, mlist, property, val, doc)
diff --git a/Mailman/Gui/Makefile.in b/Mailman/Gui/Makefile.in
new file mode 100644
index 00000000..ea219772
--- /dev/null
+++ b/Mailman/Gui/Makefile.in
@@ -0,0 +1,69 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman/Gui
+SHELL= /bin/sh
+
+MODULES= *.py
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+
+install:
+ for f in $(MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \
+ done
+
+finish:
+
+clean:
+
+distclean:
+ -rm *.pyc
+ -rm Makefile
diff --git a/Mailman/Gui/Membership.py b/Mailman/Gui/Membership.py
new file mode 100644
index 00000000..99e44a57
--- /dev/null
+++ b/Mailman/Gui/Membership.py
@@ -0,0 +1,34 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""MailList mixin class managing the membership pseudo-options.
+"""
+
+from Mailman.i18n import _
+
+
+
+class Membership:
+ def GetConfigCategory(self):
+ return 'members', _('Membership&nbsp;Management')
+
+ def GetConfigSubCategories(self, category):
+ if category == 'members':
+ return [('list', _('Membership&nbsp;List')),
+ ('add', _('Mass&nbsp;Subscription')),
+ ('remove', _('Mass&nbsp;Removal')),
+ ]
+ return None
diff --git a/Mailman/Gui/NonDigest.py b/Mailman/Gui/NonDigest.py
new file mode 100644
index 00000000..900f865f
--- /dev/null
+++ b/Mailman/Gui/NonDigest.py
@@ -0,0 +1,130 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""GUI component for managing the non-digest delivery options.
+"""
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman.i18n import _
+from Mailman.Gui.GUIBase import GUIBase
+
+from Mailman.Gui.Digest import ALLOWEDS
+PERSONALIZED_ALLOWEDS = ('user_address', 'user_delivered_to', 'user_password',
+ 'user_name', 'user_optionsurl',
+ )
+
+
+
+class NonDigest(GUIBase):
+ def GetConfigCategory(self):
+ return 'nondigest', _('Non-digest&nbsp;options')
+
+ def GetConfigInfo(self, mlist, category, subcat=None):
+ if category <> 'nondigest':
+ return None
+ WIDTH = mm_cfg.TEXTFIELDWIDTH
+
+ info = [
+ _("Policies concerning immediately delivered list traffic."),
+
+ ('nondigestable', mm_cfg.Toggle, (_('No'), _('Yes')), 1,
+ _("""Can subscribers choose to receive mail immediately, rather
+ than in batched digests?""")),
+ ]
+
+ if mm_cfg.OWNERS_CAN_ENABLE_PERSONALIZATION:
+ info.extend([
+ ('personalize', mm_cfg.Radio,
+ (_('No'), _('Yes'), _('Full Personalization')), 1,
+
+ _('''Should Mailman personalize each non-digest delivery?
+ This is often useful for announce-only lists, but <a
+ href="?VARHELP=nondigest/personalize">read the details</a>
+ section for a discussion of important performance
+ issues.'''),
+
+ _("""Normally, Mailman sends the regular delivery messages to
+ the mail server in batches. This is much more efficent
+ because it reduces the amount of traffic between Mailman and
+ the mail server.
+
+ <p>However, some lists can benefit from a more personalized
+ approach. In this case, Mailman crafts a new message for
+ each member on the regular delivery list. Turning this
+ feature on may degrade the performance of your site, so you
+ need to carefully consider whether the trade-off is worth it,
+ or whether there are other ways to accomplish what you want.
+ You should also carefully monitor your system load to make
+ sure it is acceptable.
+
+ <p>Select <em>No</em> to disable personalization and send
+ messages to the members in batches. Select <em>Yes</em> to
+ personalize deliveries and allow additional substitution
+ variables in message headers and footers (see below). In
+ addition, by selecting <em>Full Personalization</em>, the
+ <code>To</code> header of posted messages will be modified to
+ include the member's address instead of the list's posting
+ address.
+
+ <p>When personalization is enabled, a few more expansion
+ variables that can be included in the <a
+ href="?VARHELP=nondigest/msg_header">message header</a> and
+ <a href="?VARHELP=nondigest/msg_footer">message footer</a>.
+
+ <p>These additional substitution variables will be available
+ for your headers and footers, when this feature is enabled:
+
+ <ul><li><b>user_address</b> - The address of the user,
+ coerced to lower case.
+ <li><b>user_delivered_to</b> - The case-preserved address
+ that the user is subscribed with.
+ <li><b>user_password</b> - The user's password.
+ <li><b>user_name</b> - The user's full name.
+ <li><b>user_optionsurl</b> - The url to the user's option
+ page.
+ </ul>
+ """))
+ ])
+ # BAW: for very dumb reasons, we want the `personalize' attribute to
+ # show up before the msg_header and msg_footer attrs, otherwise we'll
+ # get a bogus warning if the header/footer contains a personalization
+ # substitution variable, and we're transitioning from no
+ # personalization to personalization enabled.
+ info.extend([('msg_header', mm_cfg.Text, (10, WIDTH), 0,
+ _('Header added to mail sent to regular list members'),
+ _('''Text prepended to the top of every immediately-delivery
+ message. ''') + Utils.maketext('headfoot.html',
+ mlist=mlist, raw=1)),
+
+ ('msg_footer', mm_cfg.Text, (10, WIDTH), 0,
+ _('Footer added to mail sent to regular list members'),
+ _('''Text appended to the bottom of every immediately-delivery
+ message. ''') + Utils.maketext('headfoot.html',
+ mlist=mlist, raw=1)),
+ ])
+ return info
+
+ def _setValue(self, mlist, property, val, doc):
+ alloweds = list(ALLOWEDS)
+ if mlist.personalize:
+ alloweds.extend(PERSONALIZED_ALLOWEDS)
+ if property in ('msg_header', 'msg_footer'):
+ val = self._convertString(mlist, property, alloweds, val, doc)
+ if val is None:
+ # There was a problem, so don't set it
+ return
+ GUIBase._setValue(self, mlist, property, val, doc)
diff --git a/Mailman/Gui/Passwords.py b/Mailman/Gui/Passwords.py
new file mode 100644
index 00000000..a3cf6b8e
--- /dev/null
+++ b/Mailman/Gui/Passwords.py
@@ -0,0 +1,31 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""MailList mixin class managing the password pseudo-options.
+"""
+
+from Mailman.i18n import _
+from Mailman.Gui.GUIBase import GUIBase
+
+
+
+class Passwords(GUIBase):
+ def GetConfigCategory(self):
+ return 'passwords', _('Passwords')
+
+ def handleForm(self, mlist, category, subcat, cgidata, doc):
+ # Nothing more needs to be done
+ pass
diff --git a/Mailman/Gui/Privacy.py b/Mailman/Gui/Privacy.py
new file mode 100644
index 00000000..7ef50375
--- /dev/null
+++ b/Mailman/Gui/Privacy.py
@@ -0,0 +1,398 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""MailList mixin class managing the privacy options.
+"""
+
+from Mailman import mm_cfg
+from Mailman.i18n import _
+from Mailman.Gui.GUIBase import GUIBase
+
+
+
+class Privacy(GUIBase):
+ def GetConfigCategory(self):
+ return 'privacy', _('Privacy options')
+
+ def GetConfigSubCategories(self, category):
+ if category == 'privacy':
+ return [('subscribing', _('Subscription&nbsp;rules')),
+ ('sender', _('Sender&nbsp;filters')),
+ ('recipient', _('Recipient&nbsp;filters')),
+ ('spam', _('Spam&nbsp;filters')),
+ ]
+ return None
+
+ def GetConfigInfo(self, mlist, category, subcat=None):
+ if category <> 'privacy':
+ return None
+ # Pre-calculate some stuff. Technically, we shouldn't do the
+ # sub_cfentry calculation here, but it's too ugly to indent it any
+ # further, and besides, that'll mess up i18n catalogs.
+ WIDTH = mm_cfg.TEXTFIELDWIDTH
+ if mm_cfg.ALLOW_OPEN_SUBSCRIBE:
+ sub_cfentry = ('subscribe_policy', mm_cfg.Radio,
+ # choices
+ (_('None'),
+ _('Confirm'),
+ _('Require approval'),
+ _('Confirm and approve')),
+ 0,
+ _('What steps are required for subscription?<br>'),
+ _('''None - no verification steps (<em>Not
+ Recommended </em>)<br>
+ Confirm (*) - email confirmation step required <br>
+ Require approval - require list administrator
+ Approval for subscriptions <br>
+ Confirm and approve - both confirm and approve
+
+ <p>(*) when someone requests a subscription,
+ Mailman sends them a notice with a unique
+ subscription request number that they must reply to
+ in order to subscribe.<br>
+
+ This prevents mischievous (or malicious) people
+ from creating subscriptions for others without
+ their consent.'''))
+ else:
+ sub_cfentry = ('subscribe_policy', mm_cfg.Radio,
+ # choices
+ (_('Confirm'),
+ _('Require approval'),
+ _('Confirm and approve')),
+ 1,
+ _('What steps are required for subscription?<br>'),
+ _('''Confirm (*) - email confirmation required <br>
+ Require approval - require list administrator
+ approval for subscriptions <br>
+ Confirm and approve - both confirm and approve
+
+ <p>(*) when someone requests a subscription,
+ Mailman sends them a notice with a unique
+ subscription request number that they must reply to
+ in order to subscribe.<br> This prevents
+ mischievous (or malicious) people from creating
+ subscriptions for others without their consent.'''))
+
+ # some helpful values
+ admin = mlist.GetScriptURL('admin')
+
+ subscribing_rtn = [
+ _("""This section allows you to configure subscription and
+ membership exposure policy. You can also control whether this
+ list is public or not. See also the
+ <a href="%(admin)s/archive">Archival Options</a> section for
+ separate archive-related privacy settings."""),
+
+ _('Subscribing'),
+ ('advertised', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _('''Advertise this list when people ask what lists are on this
+ machine?''')),
+
+ sub_cfentry,
+
+ ('unsubscribe_policy', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _("""Is the list moderator's approval required for unsubscription
+ requests? (<em>No</em> is recommended)"""),
+
+ _("""When members want to leave a list, they will make an
+ unsubscription request, either via the web or via email.
+ Normally it is best for you to allow open unsubscriptions so that
+ users can easily remove themselves from mailing lists (they get
+ really upset if they can't get off lists!).
+
+ <p>For some lists though, you may want to impose moderator
+ approval before an unsubscription request is processed. Examples
+ of such lists include a corporate mailing list that all employees
+ are required to be members of.""")),
+
+ _('Ban list'),
+ ('ban_list', mm_cfg.EmailListEx, (10, WIDTH), 1,
+ _("""List of addresses which are banned from membership in this
+ mailing list."""),
+
+ _("""Addresses in this list are banned outright from subscribing
+ to this mailing list, with no further moderation required. Add
+ addresses one per line; start the line with a ^ character to
+ designate a regular expression match.""")),
+
+ _("Membership exposure"),
+ ('private_roster', mm_cfg.Radio,
+ (_('Anyone'), _('List members'), _('List admin only')), 0,
+ _('Who can view subscription list?'),
+
+ _('''When set, the list of subscribers is protected by member or
+ admin password authentication.''')),
+
+ ('obscure_addresses', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _("""Show member addresses so they're not directly recognizable
+ as email addresses?"""),
+ _("""Setting this option causes member email addresses to be
+ transformed when they are presented on list web pages (both in
+ text and as links), so they're not trivially recognizable as
+ email addresses. The intention is to prevent the addresses
+ from being snarfed up by automated web scanners for use by
+ spammers.""")),
+ ]
+
+ adminurl = mlist.GetScriptURL('admin', absolute=1)
+ sender_rtn = [
+ _("""When a message is posted to the list, a series of
+ moderation steps are take to decide whether the a moderator must
+ first approve the message or not. This section contains the
+ controls for moderation of both member and non-member postings.
+
+ <p>Member postings are held for moderation if their
+ <b>moderation flag</b> is turned on. You can control whether
+ member postings are moderated by default or not.
+
+ <p>Non-member postings can be automatically
+ <a href="?VARHELP=privacy/sender/accept_these_nonmembers"
+ >accepted</a>,
+ <a href="?VARHELP=privacy/sender/hold_these_nonmembers">held for
+ moderation</a>,
+ <a href="?VARHELP=privacy/sender/reject_these_nonmembers"
+ >rejected</a> (bounced), or
+ <a href="?VARHELP=privacy/sender/discard_these_nonmembers"
+ >discarded</a>,
+ either individually or as a group. Any
+ posting from a non-member who is not explicitly accepted,
+ rejected, or discarded, will have their posting filtered by the
+ <a href="?VARHELP=privacy/sender/generic_nonmember_action">general
+ non-member rules</a>.
+
+ <p>In the text boxes below, add one address per line; start the
+ line with a ^ character to designate a <a href=
+ "http://www.python.org/doc/current/lib/module-re.html"
+ >Python regular expression</a>. When entering backslashes, do so
+ as if you were using Python raw strings (i.e. you generally just
+ use a single backslash).
+
+ <p>Note that non-regexp matches are always done first."""),
+
+ _('Member filters'),
+
+ ('default_member_moderation', mm_cfg.Radio, (_('No'), _('Yes')),
+ 0, _('By default, should new list member postings be moderated?'),
+
+ _("""Each list member has a <em>moderation flag</em> which says
+ whether messages from the list member can be posted directly to
+ the list, or must first be approved by the list moderator. When
+ the moderation flag is turned on, list member postings must be
+ approved first. You, the list administrator can decide whether a
+ specific individual's postings will be moderated or not.
+
+ <p>When a new member is subscribed, their initial moderation flag
+ takes its value from this option. Turn this option off to accept
+ member postings by default. Turn this option on to, by default,
+ moderate member postings first. You can always manually set an
+ individual member's moderation bit by using the
+ <a href="%(adminurl)s/members">membership management
+ screens</a>.""")),
+
+ ('member_moderation_action', mm_cfg.Radio,
+ (_('Hold'), _('Reject'), _('Discard')), 0,
+ _("""Action to take when a moderated member posts to the
+ list."""),
+ _("""<ul><li><b>Hold</b> -- this holds the message for approval
+ by the list moderators.
+
+ <p><li><b>Reject</b> -- this automatically rejects the message by
+ sending a bounce notice to the post's author. The text of the
+ bounce notice can be <a
+ href="?VARHELP=privacy/sender/member_moderation_notice"
+ >configured by you</a>.
+
+ <p><li><b>Discard</b> -- this simply discards the message, with
+ no notice sent to the post's author.
+ </ul>""")),
+
+ ('member_moderation_notice', mm_cfg.Text, (10, WIDTH), 1,
+ _("""Text to include in any
+ <a href="?VARHELP/privacy/sender/member_moderation_action"
+ >rejection notice</a> to
+ be sent to moderated members who post to this list.""")),
+
+ _('Non-member filters'),
+
+ ('accept_these_nonmembers', mm_cfg.EmailListEx, (10, WIDTH), 1,
+ _("""List of non-member addresses whose postings should be
+ automatically accepted."""),
+
+ _("""Postings from any of these non-members will be automatically
+ accepted with no further moderation applied. Add member
+ addresses one per line; start the line with a ^ character to
+ designate a regular expression match.""")),
+
+ ('hold_these_nonmembers', mm_cfg.EmailListEx, (10, WIDTH), 1,
+ _("""List of non-member addresses whose postings will be
+ immediately held for moderation."""),
+
+ _("""Postings from any of these non-members will be immediately
+ and automatically held for moderation by the list moderators.
+ The sender will receive a notification message which will allow
+ them to cancel their held message. Add member addresses one per
+ line; start the line with a ^ character to designate a regular
+ expression match.""")),
+
+ ('reject_these_nonmembers', mm_cfg.EmailListEx, (10, WIDTH), 1,
+ _("""List of non-member addresses whose postings will be
+ automatically rejected."""),
+
+ _("""Postings from any of these non-members will be automatically
+ rejected. In other words, their messages will be bounced back to
+ the sender with a notification of automatic rejection. This
+ option is not appropriate for known spam senders; their messages
+ should be
+ <a href="?VARHELP=privacy/sender/discard_these_nonmembers"
+ >automatically discarded</a>.
+
+ <p>Add member addresses one per line; start the line with a ^
+ character to designate a regular expression match.""")),
+
+ ('discard_these_nonmembers', mm_cfg.EmailListEx, (10, WIDTH), 1,
+ _("""List of non-member addresses whose postings will be
+ automatically discarded."""),
+
+ _("""Postings from any of these non-members will be automatically
+ discarded. That is, the message will be thrown away with no
+ further processing or notification. The sender will not receive
+ a notification or a bounce, however the list moderators can
+ optionally <a href="?VARHELP=privacy/sender/forward_auto_discards"
+ >receive copies of auto-discarded messages.</a>.
+
+ <p>Add member addresses one per line; start the line with a ^
+ character to designate a regular expression match.""")),
+
+ ('generic_nonmember_action', mm_cfg.Radio,
+ (_('Accept'), _('Hold'), _('Reject'), _('Discard')), 0,
+ _("""Action to take for postings from non-members for which no
+ explicit action is defined."""),
+
+ _("""When a post from a non-member is received, the message's
+ sender is matched against the list of explicitly
+ <a href="?VARHELP=privacy/sender/accept_these_nonmembers"
+ >accepted</a>,
+ <a href="?VARHELP=privacy/sender/hold_these_nonmembers">held</a>,
+ <a href="?VARHELP=privacy/sender/reject_these_nonmembers"
+ >rejected</a> (bounced), and
+ <a href="?VARHELP=privacy/sender/discard_these_nonmembers"
+ >discarded</a> addresses. If no match is found, then this action
+ is taken.""")),
+
+ ('forward_auto_discards', mm_cfg.Radio, (_('No'), _('Yes')), 0,
+ _("""Should messages from non-members, which are automatically
+ discarded, be forwarded to the list moderator?""")),
+
+ ]
+
+ recip_rtn = [
+ _("""This section allows you to configure various filters based on
+ the recipient of the message."""),
+
+ _('Recipient filters'),
+
+ ('require_explicit_destination', mm_cfg.Radio,
+ (_('No'), _('Yes')), 0,
+ _("""Must posts have list named in destination (to, cc) field
+ (or be among the acceptable alias names, specified below)?"""),
+
+ _("""Many (in fact, most) spams do not explicitly name their
+ myriad destinations in the explicit destination addresses - in
+ fact often the To: field has a totally bogus address for
+ obfuscation. The constraint applies only to the stuff in the
+ address before the '@' sign, but still catches all such spams.
+
+ <p>The cost is that the list will not accept unhindered any
+ postings relayed from other addresses, unless
+
+ <ol>
+ <li>The relaying address has the same name, or
+
+ <li>The relaying address name is included on the options that
+ specifies acceptable aliases for the list.
+
+ </ol>""")),
+
+ ('acceptable_aliases', mm_cfg.Text, (4, WIDTH), 0,
+ _("""Alias names (regexps) which qualify as explicit to or cc
+ destination names for this list."""),
+
+ _("""Alternate addresses that are acceptable when
+ `require_explicit_destination' is enabled. This option takes a
+ list of regular expressions, one per line, which is matched
+ against every recipient address in the message. The matching is
+ performed with Python's re.match() function, meaning they are
+ anchored to the start of the string.
+
+ <p>For backwards compatibility with Mailman 1.1, if the regexp
+ does not contain an `@', then the pattern is matched against just
+ the local part of the recipient address. If that match fails, or
+ if the pattern does contain an `@', then the pattern is matched
+ against the entire recipient address.
+
+ <p>Matching against the local part is deprecated; in a future
+ release, the pattern will always be matched against the entire
+ recipient address.""")),
+
+ ('max_num_recipients', mm_cfg.Number, 5, 0,
+ _('Ceiling on acceptable number of recipients for a posting.'),
+
+ _('''If a posting has this number, or more, of recipients, it is
+ held for admin approval. Use 0 for no ceiling.''')),
+ ]
+
+ spam_rtn = [
+ _("""This section allows you to configure various anti-spam
+ filters posting filters, which can help reduce the amount of spam
+ your list members end up receiving.
+ """),
+
+ _("Anti-Spam filters"),
+
+ ('bounce_matching_headers', mm_cfg.Text, (6, WIDTH), 0,
+ _('Hold posts with header value matching a specified regexp.'),
+ _("""Use this option to prohibit posts according to specific
+ header values. The target value is a regular-expression for
+ matching against the specified header. The match is done
+ disregarding letter case. Lines beginning with '#' are ignored
+ as comments.
+
+ <p>For example:<pre>to: .*@public.com </pre> says to hold all
+ postings with a <em>To:</em> mail header containing '@public.com'
+ anywhere among the addresses.
+
+ <p>Note that leading whitespace is trimmed from the regexp. This
+ can be circumvented in a number of ways, e.g. by escaping or
+ bracketing it.""")),
+ ]
+
+ if subcat == 'sender':
+ return sender_rtn
+ elif subcat == 'recipient':
+ return recip_rtn
+ elif subcat == 'spam':
+ return spam_rtn
+ else:
+ return subscribing_rtn
+
+ def _setValue(self, mlist, property, val, doc):
+ # For subscribe_policy when ALLOW_OPEN_SUBSCRIBE is true, we need to
+ # add one to the value because the page didn't present an open list as
+ # an option.
+ if property == 'subscribe_policy' and not mm_cfg.ALLOW_OPEN_SUBSCRIBE:
+ val += 1
+ setattr(mlist, property, val)
diff --git a/Mailman/Gui/Topics.py b/Mailman/Gui/Topics.py
new file mode 100644
index 00000000..310d876f
--- /dev/null
+++ b/Mailman/Gui/Topics.py
@@ -0,0 +1,160 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+import re
+
+from Mailman import mm_cfg
+from Mailman.i18n import _
+from Mailman.Logging.Syslog import syslog
+from Mailman.Gui.GUIBase import GUIBase
+
+
+
+class Topics(GUIBase):
+ def GetConfigCategory(self):
+ return 'topics', _('Topics')
+
+ def GetConfigInfo(self, mlist, category, subcat=None):
+ if category <> 'topics':
+ return None
+ WIDTH = mm_cfg.TEXTFIELDWIDTH
+
+ return [
+ _('List topic keywords'),
+
+ ('topics_enabled', mm_cfg.Radio, (_('Disabled'), _('Enabled')), 0,
+ _('''Should the topic filter be enabled or disabled?'''),
+
+ _("""The topic filter categorizes each incoming email message
+ according to <a
+ href="http://www.python.org/doc/current/lib/module-re.html">regular
+ expression filters</a> you specify below. If the message's
+ <code>Subject:</code> or <code>Keywords:</code> header contains a
+ match against a topic filter, the message is logically placed
+ into a topic <em>bucket</em>. Each user can then choose to only
+ receive messages from the mailing list for a particular topic
+ bucket (or buckets). Any message not categorized in a topic
+ bucket registered with the user is not delivered to the list.
+
+ <p>Note that this feature only works with regular delivery, not
+ digest delivery.
+
+ <p>The body of the message can also be optionally scanned for
+ <code>Subject:</code> and <code>Keywords:</code> headers, as
+ specified by the <a
+ href="?VARHELP=topics/topics_bodylines_limit">topics_bodylines_limit</a>
+ configuration variable.""")),
+
+ ('topics_bodylines_limit', mm_cfg.Number, 5, 0,
+ _('How many body lines should the topic matcher scan?'),
+
+ _("""The topic matcher will scan this many lines of the message
+ body looking for topic keyword matches. Body scanning stops when
+ either this many lines have been looked at, or a non-header-like
+ body line is encountered. By setting this value to zero, no body
+ lines will be scanned (i.e. only the <code>Keywords:</code> and
+ <code>Subject:</code> headers will be scanned). By setting this
+ value to a negative number, then all body lines will be scanned
+ until a non-header-like line is encountered.
+ """)),
+
+ ('topics', mm_cfg.Topics, 0, 0,
+ _('Topic keywords, one per line, to match against each message.'),
+
+ _("""Each topic keyword is actually a regular expression, which is
+ matched against certain parts of a mail message, specifically the
+ <code>Keywords:</code> and <code>Subject:</code> message headers.
+ Note that the first few lines of the body of the message can also
+ contain a <code>Keywords:</code> and <code>Subject:</code>
+ "header" on which matching is also performed.""")),
+
+ ]
+
+ def handleForm(self, mlist, category, subcat, cgidata, doc):
+ topics = []
+ # We start i at 1 and keep going until we no longer find items keyed
+ # with the marked tags.
+ i = 1
+ while 1:
+ deltag = 'topic_delete_%02d' % i
+ boxtag = 'topic_box_%02d' % i
+ reboxtag = 'topic_rebox_%02d' % i
+ desctag = 'topic_desc_%02d' % i
+ wheretag = 'topic_where_%02d' % i
+ addtag = 'topic_add_%02d' % i
+ newtag = 'topic_new_%02d' % i
+
+ i += 1
+ # Was this a delete? If so, we can just ignore this entry
+ if cgidata.has_key(deltag):
+ continue
+
+ # Get the data for the current box
+ name = cgidata.getvalue(boxtag)
+ pattern = cgidata.getvalue(reboxtag)
+ desc = cgidata.getvalue(desctag)
+
+ if name is None:
+ # We came to the end of the boxes
+ break
+
+ if cgidata.has_key(newtag) and (not name or not pattern):
+ # This new entry is incomplete.
+ doc.addError(_("""Topic specifications require both a name and
+ a pattern. Incomplete topics will be ignored."""))
+ continue
+
+ # Make sure the pattern was a legal regular expression
+ try:
+ re.compile(pattern)
+ except (re.error, TypeError):
+ doc.addError(_("""The topic pattern `%(pattern)s' is not a
+ legal regular expression. It will be discarded."""))
+ continue
+
+ # Was this an add item?
+ if cgidata.has_key(addtag):
+ # Where should the new one be added?
+ where = cgidata.getvalue(wheretag)
+ if where == 'before':
+ # Add a new empty topics box before the current one
+ topics.append(('', '', '', 1))
+ topics.append((name, pattern, desc, 0))
+ # Default is to add it after...
+ else:
+ topics.append((name, pattern, desc, 0))
+ topics.append(('', '', '', 1))
+ # Otherwise, just retain this one in the list
+ else:
+ topics.append((name, pattern, desc, 0))
+
+ # Add these topics to the mailing list object, and deal with other
+ # options.
+ mlist.topics = topics
+ try:
+ mlist.topics_enabled = int(cgidata.getvalue(
+ 'topics_enabled',
+ mlist.topics_enabled))
+ except ValueError:
+ # BAW: should really print a warning
+ pass
+ try:
+ mlist.topics_bodylines_limit = int(cgidata.getvalue(
+ 'topics_bodylines_limit',
+ mlist.topics_bodylines_limit))
+ except ValueError:
+ # BAW: should really print a warning
+ pass
diff --git a/Mailman/Gui/Usenet.py b/Mailman/Gui/Usenet.py
new file mode 100644
index 00000000..9d6b65f4
--- /dev/null
+++ b/Mailman/Gui/Usenet.py
@@ -0,0 +1,137 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+from Mailman import mm_cfg
+from Mailman.i18n import _
+from Mailman.Gui.GUIBase import GUIBase
+
+
+
+class Usenet(GUIBase):
+ def GetConfigCategory(self):
+ return 'gateway', _('Mail&lt;-&gt;News&nbsp;gateways')
+
+ def GetConfigInfo(self, mlist, category, subcat=None):
+ if category <> 'gateway':
+ return None
+
+ WIDTH = mm_cfg.TEXTFIELDWIDTH
+ VERTICAL = 1
+
+ return [
+ _('Mail-to-News and News-to-Mail gateway services.'),
+
+ _('News server settings'),
+
+ ('nntp_host', mm_cfg.String, WIDTH, 0,
+ _('''The Internet address of the machine your News server is
+ running on.'''),
+ _('''The News server is not part of Mailman proper. You have to
+ already have access to a NNTP server, and that NNTP server has to
+ recognize the machine this mailing list runs on as a machine
+ capable of reading and posting news.''')),
+
+ ('linked_newsgroup', mm_cfg.String, WIDTH, 0,
+ _('The name of the Usenet group to gateway to and/or from.')),
+
+ ('gateway_to_news', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('''Should new posts to the mailing list be sent to the
+ newsgroup?''')),
+
+ ('gateway_to_mail', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('''Should new posts to the newsgroup be sent to the mailing
+ list?''')),
+
+ _('Forwarding options'),
+
+ ('news_moderation', mm_cfg.Radio,
+ (_('None'), _('Open list, moderated group'), _('Moderated')),
+ VERTICAL,
+
+ _("""The moderation policy of the newsgroup."""),
+
+ _("""This setting determines the moderation policy of the
+ newsgroup and its interaction with the moderation policy of the
+ mailing list. This only applies to the newsgroup that you are
+ gatewaying <em>to</em>, so if you are only gatewaying from
+ Usenet, or the newsgroup you are gatewaying to is not moderated,
+ set this option to <em>None</em>.
+
+ <p>If the newsgroup is moderated, you can set this mailing list
+ up to be the moderation address for the newsgroup. By selecting
+ <em>Moderated</em>, an additional posting hold will be placed in
+ the approval process. All messages posted to the mailing list
+ will have to be approved before being sent on to the newsgroup,
+ or to the mailing list membership.
+
+ <p><em>Note that if the message has an <tt>Approved</tt> header
+ with the list's administrative password in it, this hold test
+ will be bypassed, allowing privileged posters to send messages
+ directly to the list and the newsgroup.</em>
+
+ <p>Finally, if the newsgroup is moderated, but you want to have
+ an open posting policy anyway, you should select <em>Open list,
+ moderated group</em>. The effect of this is to use the normal
+ Mailman moderation facilities, but to add an <tt>Approved</tt>
+ header to all messages that are gatewayed to Usenet.""")),
+
+ ('news_prefix_subject_too', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('Prefix <tt>Subject:</tt> headers on postings gated to news?'),
+ _("""Mailman prefixes <tt>Subject:</tt> headers with
+ <a href="?VARHELP=general/subject_prefix">text you can
+ customize</a> and normally, this prefix shows up in messages
+ gatewayed to Usenet. You can set this option to <em>No</em> to
+ disable the prefix on gated messages. Of course, if you turn off
+ normal <tt>Subject:</tt> prefixes, they won't be prefixed for
+ gated messages either.""")),
+
+ _('Mass catch up'),
+
+ ('_mass_catchup', mm_cfg.Toggle, (_('No'), _('Yes')), 0,
+ _('Should Mailman perform a <em>catchup</em> on the newsgroup?'),
+ _('''When you tell Mailman to perform a catchup on the newsgroup,
+ this means that you want to start gating messages to the mailing
+ list with the next new message found. All earlier messages on
+ the newsgroup will be ignored. This is as if you were reading
+ the newsgroup yourself, and you marked all current messages as
+ <em>read</em>. By catching up, your mailing list members will
+ not see any of the earlier messages.''')),
+
+ ]
+
+ def _setValue(self, mlist, property, val, doc):
+ # Watch for the special, immediate action attributes
+ if property == '_mass_catchup' and val:
+ mlist.usenet_watermark = None
+ doc.AddItem(_('Mass catchup completed'))
+ else:
+ GUIBase._setValue(self, mlist, property, val, doc)
+
+ def _postValidate(self, mlist, doc):
+ # Make sure that if we're gating, that the newsgroups and host
+ # information are not blank.
+ if mlist.gateway_to_news or mlist.gateway_to_mail:
+ # BAW: It's too expensive and annoying to ensure that both the
+ # host is valid and that the newsgroup is a valid n.g. on the
+ # server. This should be good enough.
+ if not mlist.nntp_host or not mlist.linked_newsgroup:
+ doc.addError(_("""You cannot enable gatewaying unless both the
+ <a href="?VARHELP=gateway/nntp_host">news server field</a> and
+ the <a href="?VARHELP=gateway/linked_newsgroup">linked
+ newsgroup</a> fields are filled in."""))
+ # And reset these values
+ mlist.gateway_to_news = 0
+ mlist.gateway_to_mail = 0
diff --git a/Mailman/Gui/__init__.py b/Mailman/Gui/__init__.py
new file mode 100644
index 00000000..1e79b34a
--- /dev/null
+++ b/Mailman/Gui/__init__.py
@@ -0,0 +1,32 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+from Archive import Archive
+from Autoresponse import Autoresponse
+from Bounce import Bounce
+from Digest import Digest
+from General import General
+from Membership import Membership
+from NonDigest import NonDigest
+from Passwords import Passwords
+from Privacy import Privacy
+from Topics import Topics
+from Usenet import Usenet
+from Language import Language
+from ContentFilter import ContentFilter
+
+# Don't export this symbol outside the package
+del GUIBase
diff --git a/Mailman/HTMLFormatter.py b/Mailman/HTMLFormatter.py
new file mode 100644
index 00000000..397eb475
--- /dev/null
+++ b/Mailman/HTMLFormatter.py
@@ -0,0 +1,433 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+"""Routines for presentation of list-specific HTML text."""
+
+import time
+import re
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import MemberAdaptor
+from Mailman.htmlformat import *
+
+from Mailman.i18n import _
+
+
+EMPTYSTRING = ''
+BR = '<br>'
+NL = '\n'
+COMMASPACE = ', '
+
+
+
+class HTMLFormatter:
+ def GetMailmanFooter(self):
+ ownertext = COMMASPACE.join([Utils.ObscureEmail(a, 1)
+ for a in self.owner])
+ # Remove the .Format() when htmlformat conversion is done.
+ realname = self.real_name
+ hostname = self.host_name
+ listinfo_link = Link(self.GetScriptURL('listinfo'), realname).Format()
+ owner_link = Link('mailto:' + self.GetOwnerEmail(), ownertext).Format()
+ innertext = _('%(listinfo_link)s list run by %(owner_link)s')
+ return Container(
+ '<hr>',
+ Address(
+ Container(
+ innertext,
+ '<br>',
+ Link(self.GetScriptURL('admin'),
+ _('%(realname)s administrative interface')),
+ _(' (requires authorization)'),
+ '<br>',
+ Link(Utils.ScriptURL('listinfo'),
+ _('Overview of all %(hostname)s mailing lists')),
+ '<p>', MailmanLogo()))).Format()
+
+ def FormatUsers(self, digest, lang=None):
+ if lang is None:
+ lang = self.preferred_language
+ conceal_sub = mm_cfg.ConcealSubscription
+ people = []
+ if digest:
+ digestmembers = self.getDigestMemberKeys()
+ for dm in digestmembers:
+ if not self.getMemberOption(dm, conceal_sub):
+ people.append(dm)
+ num_concealed = len(digestmembers) - len(people)
+ else:
+ members = self.getRegularMemberKeys()
+ for m in members:
+ if not self.getMemberOption(m, conceal_sub):
+ people.append(m)
+ num_concealed = len(members) - len(people)
+ if num_concealed == 1:
+ concealed = _('<em>(1 private member not shown)</em>')
+ elif num_concealed > 1:
+ concealed = _(
+ '<em>(%(num_concealed)d private members not shown)</em>')
+ else:
+ concealed = ''
+ items = []
+ people.sort()
+ for person in people:
+ id = Utils.ObscureEmail(person)
+ url = self.GetOptionsURL(person)
+ if self.obscure_addresses:
+ showing = Utils.ObscureEmail(person, for_text=1)
+ else:
+ showing = person
+ got = Link(url, showing)
+ if self.getDeliveryStatus(person) <> MemberAdaptor.ENABLED:
+ got = Italic('(', got, ')')
+ items.append(got)
+ # Just return the .Format() so this works until I finish
+ # converting everything to htmlformat...
+ return concealed + UnorderedList(*tuple(items)).Format()
+
+ def FormatOptionButton(self, option, value, user):
+ if option == mm_cfg.DisableDelivery:
+ optval = self.getDeliveryStatus(user) <> MemberAdaptor.ENABLED
+ else:
+ optval = self.getMemberOption(user, option)
+ if optval == value:
+ checked = ' CHECKED'
+ else:
+ checked = ''
+ name = {mm_cfg.DontReceiveOwnPosts : 'dontreceive',
+ mm_cfg.DisableDelivery : 'disablemail',
+ mm_cfg.DisableMime : 'mime',
+ mm_cfg.AcknowledgePosts : 'ackposts',
+ mm_cfg.Digests : 'digest',
+ mm_cfg.ConcealSubscription : 'conceal',
+ mm_cfg.SuppressPasswordReminder : 'remind',
+ mm_cfg.ReceiveNonmatchingTopics : 'rcvtopic',
+ mm_cfg.DontReceiveDuplicates : 'nodupes',
+ }[option]
+ return '<input type=radio name="%s" value="%d"%s>' % (
+ name, value, checked)
+
+ def FormatDigestButton(self):
+ if self.digest_is_default:
+ checked = ' CHECKED'
+ else:
+ checked = ''
+ return '<input type=radio name="digest" value="1"%s>' % checked
+
+ def FormatDisabledNotice(self, user):
+ status = self.getDeliveryStatus(user)
+ reason = None
+ info = self.getBounceInfo(user)
+ if status == MemberAdaptor.BYUSER:
+ reason = _('; it was disabled by you')
+ elif status == MemberAdaptor.BYADMIN:
+ reason = _('; it was disabled by the list administrator')
+ elif status == MemberAdaptor.BYBOUNCE:
+ date = time.strftime('%d-%b-%Y',
+ time.localtime(Utils.midnight(info.date)))
+ reason = _('''; it was disabled due to excessive bounces. The
+ last bounce was received on %(date)s''')
+ elif status == MemberAdaptor.UNKNOWN:
+ reason = _('; it was disabled for unknown reasons')
+ if reason:
+ note = FontSize('+1', _(
+ 'Note: your list delivery is currently disabled%(reason)s.'
+ )).Format()
+ link = Link('#disable', _('Mail delivery')).Format()
+ mailto = Link('mailto:' + self.GetOwnerEmail(),
+ _('the list administrator')).Format()
+ return _('''<p>%(note)s
+
+ <p>You may have disabled list delivery intentionally,
+ or it may have been triggered by bounces from your email
+ address. In either case, to re-enable delivery, change the
+ %(link)s option below. Contact %(mailto)s if you have any
+ questions or need assistance.''')
+ elif info and info.score > 0:
+ # Provide information about their current bounce score. We know
+ # their membership is currently enabled.
+ score = info.score
+ total = self.bounce_score_threshold
+ return _('''<p>We have received some recent bounces from your
+ address. Your current <em>bounce score</em> is %(score)s out of a
+ maximum of %(total)s. Please double check that your subscribed
+ address is correct and that there are no problems with delivery to
+ this address. Your bounce score will be automatically reset if
+ the problems are corrected soon.''')
+ else:
+ return ''
+
+ def FormatUmbrellaNotice(self, user, type):
+ addr = self.GetMemberAdminEmail(user)
+ if self.umbrella_list:
+ return _("(Note - you are subscribing to a list of mailing lists, "
+ "so the %(type)s notice will be sent to the admin address"
+ " for your membership, %(addr)s.)<p>")
+ else:
+ return ""
+
+ def FormatSubscriptionMsg(self):
+ msg = ''
+ also = ''
+ if self.subscribe_policy == 1:
+ msg += _('''You will be sent email requesting confirmation, to
+ prevent others from gratuitously subscribing you.''')
+ elif self.subscribe_policy == 2:
+ msg += _("""This is a closed list, which means your subscription
+ will be held for approval. You will be notified of the list
+ moderator's decision by email.""")
+ also = _('also ')
+ elif self.subscribe_policy == 3:
+ msg += _("""You will be sent email requesting confirmation, to
+ prevent others from gratuitously subscribing you. Once
+ confirmation is received, your request will be held for approval
+ by the list moderator. You will be notified of the moderator's
+ decision by email.""")
+ also = _("also ")
+ if msg:
+ msg += ' '
+ if self.private_roster == 1:
+ msg += _('''This is %(also)sa private list, which means that the
+ list of members is not available to non-members.''')
+ elif self.private_roster:
+ msg += _('''This is %(also)sa hidden list, which means that the
+ list of members is available only to the list administrator.''')
+ else:
+ msg += _('''This is %(also)sa public list, which means that the
+ list of members list is available to everyone.''')
+ if self.obscure_addresses:
+ msg += _(''' (but we obscure the addresses so they are not
+ easily recognizable by spammers).''')
+
+ if self.umbrella_list:
+ sfx = self.umbrella_member_suffix
+ msg += _("""<p>(Note that this is an umbrella list, intended to
+ have only other mailing lists as members. Among other things,
+ this means that your confirmation request will be sent to the
+ `%(sfx)s' account for your address.)""")
+ return msg
+
+ def FormatUndigestButton(self):
+ if self.digest_is_default:
+ checked = ''
+ else:
+ checked = ' CHECKED'
+ return '<input type=radio name="digest" value="0"%s>' % checked
+
+ def FormatMimeDigestsButton(self):
+ if self.mime_is_default_digest:
+ checked = ' CHECKED'
+ else:
+ checked = ''
+ return '<input type=radio name="mime" value="1"%s>' % checked
+
+ def FormatPlainDigestsButton(self):
+ if self.mime_is_default_digest:
+ checked = ''
+ else:
+ checked = ' CHECKED'
+ return '<input type=radio name="plain" value="1"%s>' % checked
+
+ def FormatEditingOption(self, lang):
+ if self.private_roster == 0:
+ either = _('<b><i>either</i></b> ')
+ else:
+ either = ''
+ realname = self.real_name
+
+ text = (_('''To unsubscribe from %(realname)s, get a password reminder,
+ or change your subscription options %(either)senter your subscription
+ email address:
+ <p><center> ''')
+ + TextBox('email', size=30).Format()
+ + ' '
+ + SubmitButton('UserOptions',
+ _('Unsubscribe or edit options')).Format()
+ + Hidden('language', lang).Format()
+ + '</center>')
+ if self.private_roster == 0:
+ text += _('''<p>... <b><i>or</i></b> select your entry from
+ the subscribers list (see above).''')
+ text += _(''' If you leave the field blank, you will be prompted for
+ your email address''')
+ return text
+
+ def RestrictedListMessage(self, which, restriction):
+ if not restriction:
+ return ''
+ elif restriction == 1:
+ return _(
+ '''(<i>%(which)s is only available to the list
+ members.</i>)''')
+ else:
+ return _('''(<i>%(which)s is only available to the list
+ administrator.</i>)''')
+
+ def FormatRosterOptionForUser(self, lang):
+ return self.RosterOption(lang).Format()
+
+ def RosterOption(self, lang):
+ container = Container()
+ container.AddItem(Hidden('language', lang))
+ if not self.private_roster:
+ container.AddItem(_("Click here for the list of ")
+ + self.real_name
+ + _(" subscribers: "))
+ container.AddItem(SubmitButton('SubscriberRoster',
+ _("Visit Subscriber list")))
+ else:
+ if self.private_roster == 1:
+ only = _('members')
+ whom = _('Address:')
+ else:
+ only = _('the list administrator')
+ whom = _('Admin address:')
+ # Solicit the user and password.
+ container.AddItem(
+ self.RestrictedListMessage(_('The subscribers list'),
+ self.private_roster)
+ + _(" <p>Enter your ")
+ + whom[:-1].lower()
+ + _(" and password to visit"
+ " the subscribers list: <p><center> ")
+ + whom
+ + " ")
+ container.AddItem(self.FormatBox('roster-email'))
+ container.AddItem(_("Password: ")
+ + self.FormatSecureBox('roster-pw')
+ + "&nbsp;&nbsp;")
+ container.AddItem(SubmitButton('SubscriberRoster',
+ _('Visit Subscriber List')))
+ container.AddItem("</center>")
+ return container
+
+ def FormatFormStart(self, name, extra=''):
+ base_url = self.GetScriptURL(name)
+ if extra:
+ full_url = "%s/%s" % (base_url, extra)
+ else:
+ full_url = base_url
+ return ('<FORM Method=POST ACTION="%s">' % full_url)
+
+ def FormatArchiveAnchor(self):
+ return '<a href="%s">' % self.GetBaseArchiveURL()
+
+ def FormatFormEnd(self):
+ return '</FORM>'
+
+ def FormatBox(self, name, size=20, value=''):
+ return '<INPUT type="Text" name="%s" size="%d" value="%s">' % (
+ name, size, value)
+
+ def FormatSecureBox(self, name):
+ return '<INPUT type="Password" name="%s" size="15">' % name
+
+ def FormatButton(self, name, text='Submit'):
+ return '<INPUT type="Submit" name="%s" value="%s">' % (name, text)
+
+ def FormatReminder(self, lang):
+ if self.send_reminders:
+ return _('Once a month, your password will be emailed to you as'
+ ' a reminder.')
+ return ''
+
+ def ParseTags(self, template, replacements, lang=None):
+ if lang is None:
+ charset = 'us-ascii'
+ else:
+ charset = Utils.GetCharSet(lang)
+ text = Utils.maketext(template, raw=1, lang=lang, mlist=self)
+ parts = re.split('(</?[Mm][Mm]-[^>]*>)', text)
+ i = 1
+ while i < len(parts):
+ tag = parts[i].lower()
+ if replacements.has_key(tag):
+ repl = replacements[tag]
+ if isinstance(repl, type(u'')):
+ repl = repl.encode(charset, 'replace')
+ parts[i] = repl
+ else:
+ parts[i] = ''
+ i = i + 2
+ return EMPTYSTRING.join(parts)
+
+ # This needs to wait until after the list is inited, so let's build it
+ # when it's needed only.
+ def GetStandardReplacements(self, lang=None):
+ dmember_len = len(self.getDigestMemberKeys())
+ member_len = len(self.getRegularMemberKeys())
+ # If only one language is enabled for this mailing list, omit the
+ # language choice buttons.
+ if len(self.GetAvailableLanguages()) == 1:
+ listlangs = _(Utils.GetLanguageDescr(self.preferred_language))
+ else:
+ listlangs = self.GetLangSelectBox(lang).Format()
+ d = {
+ '<mm-mailman-footer>' : self.GetMailmanFooter(),
+ '<mm-list-name>' : self.real_name,
+ '<mm-email-user>' : self._internal_name,
+ '<mm-list-description>' : self.description,
+ '<mm-list-info>' : BR.join(self.info.split(NL)),
+ '<mm-form-end>' : self.FormatFormEnd(),
+ '<mm-archive>' : self.FormatArchiveAnchor(),
+ '</mm-archive>' : '</a>',
+ '<mm-list-subscription-msg>' : self.FormatSubscriptionMsg(),
+ '<mm-restricted-list-message>' : \
+ self.RestrictedListMessage(_('The current archive'),
+ self.archive_private),
+ '<mm-num-reg-users>' : `member_len`,
+ '<mm-num-digesters>' : `dmember_len`,
+ '<mm-num-members>' : (`member_len + dmember_len`),
+ '<mm-posting-addr>' : '%s' % self.GetListEmail(),
+ '<mm-request-addr>' : '%s' % self.GetRequestEmail(),
+ '<mm-owner>' : self.GetOwnerEmail(),
+ '<mm-reminder>' : self.FormatReminder(self.preferred_language),
+ '<mm-host>' : self.host_name,
+ '<mm-list-langs>' : listlangs,
+ }
+ if mm_cfg.IMAGE_LOGOS:
+ d['<mm-favicon>'] = mm_cfg.IMAGE_LOGOS + mm_cfg.SHORTCUT_ICON
+ return d
+
+ def GetAllReplacements(self, lang=None):
+ """
+ returns standard replaces plus formatted user lists in
+ a dict just like GetStandardReplacements.
+ """
+ if lang is None:
+ lang = self.preferred_language
+ d = self.GetStandardReplacements(lang)
+ d.update({"<mm-regular-users>": self.FormatUsers(0, lang),
+ "<mm-digest-users>": self.FormatUsers(1, lang)})
+ return d
+
+ def GetLangSelectBox(self, lang=None, varname='language'):
+ if lang is None:
+ lang = self.preferred_language
+ # Figure out the available languages
+ values = self.GetAvailableLanguages()
+ legend = map(_, map(Utils.GetLanguageDescr, values))
+ try:
+ selected = values.index(lang)
+ except ValueError:
+ try:
+ selected = values.index(self.preferred_language)
+ except ValueError:
+ selected = mm_cfg.DEFAULT_SERVER_LANGUAGE
+ # Return the widget
+ return SelectOptions(varname, values, legend, selected)
diff --git a/Mailman/Handlers/.cvsignore b/Mailman/Handlers/.cvsignore
new file mode 100644
index 00000000..f3c7a7c5
--- /dev/null
+++ b/Mailman/Handlers/.cvsignore
@@ -0,0 +1 @@
+Makefile
diff --git a/Mailman/Handlers/Acknowledge.py b/Mailman/Handlers/Acknowledge.py
new file mode 100644
index 00000000..103448e1
--- /dev/null
+++ b/Mailman/Handlers/Acknowledge.py
@@ -0,0 +1,62 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Send an acknowledgement of the successful post to the sender.
+
+This only happens if the sender has set their AcknowledgePosts attribute.
+This module must appear after the deliverer in the message pipeline in order
+to send acks only after successful delivery.
+
+"""
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman import Errors
+from Mailman.i18n import _
+
+
+
+def process(mlist, msg, msgdata):
+ # Extract the sender's address and find them in the user database
+ sender = msgdata.get('original_sender', msg.get_sender())
+ try:
+ ack = mlist.getMemberOption(sender, mm_cfg.AcknowledgePosts)
+ if not ack:
+ return
+ except Errors.NotAMemberError:
+ return
+ # Okay, they want acknowledgement of their post. Give them their original
+ # subject. BAW: do we want to use the decoded header?
+ origsubj = msgdata.get('origsubj', msg.get('subject', _('(no subject)')))
+ # Get the user's preferred language
+ lang = msgdata.get('lang', mlist.getMemberLanguage(sender))
+ # Now get the acknowledgement template
+ realname = mlist.real_name
+ text = Utils.maketext(
+ 'postack.txt',
+ {'subject' : origsubj,
+ 'listname' : realname,
+ 'listinfo_url': mlist.GetScriptURL('listinfo', absolute=1),
+ 'optionsurl' : mlist.GetOptionsURL(sender, absolute=1),
+ }, lang=lang, mlist=mlist, raw=1)
+ # Craft the outgoing message, with all headers and attributes
+ # necessary for general delivery. Then enqueue it to the outgoing
+ # queue.
+ subject = _('%(realname)s post acknowledgement')
+ usermsg = Message.UserNotification(sender, mlist.GetBouncesEmail(),
+ subject, text, lang)
+ usermsg.send(mlist)
diff --git a/Mailman/Handlers/AfterDelivery.py b/Mailman/Handlers/AfterDelivery.py
new file mode 100644
index 00000000..b6bb96c2
--- /dev/null
+++ b/Mailman/Handlers/AfterDelivery.py
@@ -0,0 +1,28 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Perform some bookkeeping after a successful post.
+
+This module must appear after the delivery module in the message pipeline.
+"""
+
+import time
+
+
+
+def process(mlist, msg, msgdata):
+ mlist.last_post_time = time.time()
+ mlist.post_id += 1
diff --git a/Mailman/Handlers/Approve.py b/Mailman/Handlers/Approve.py
new file mode 100644
index 00000000..d339a9b1
--- /dev/null
+++ b/Mailman/Handlers/Approve.py
@@ -0,0 +1,82 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Determine whether the message is approved for delivery.
+
+This module only tests for definitive approvals. IOW, this module only
+determines whether the message is definitively approved or definitively
+denied. Situations that could hold a message for approval or confirmation are
+not tested by this module.
+
+"""
+
+from email.Iterators import typed_subpart_iterator
+
+from Mailman import mm_cfg
+from Mailman import Errors
+
+NL = '\n'
+
+
+
+def process(mlist, msg, msgdata):
+ # Short circuits
+ if msgdata.get('approved'):
+ # Digests, Usenet postings, and some other messages come pre-approved.
+ # TBD: we may want to further filter Usenet messages, so the test
+ # above may not be entirely correct.
+ return
+ # See if the message has an Approved or Approve header with a valid
+ # list-moderator, list-admin. Also look at the first non-whitespace line
+ # in the file to see if it looks like an Approved header. We are
+ # specifically /not/ allowing the site admins password to work here
+ # because we want to discourage the practice of sending the site admin
+ # password through email in the clear.
+ missing = []
+ passwd = msg.get('approved', msg.get('approve', missing))
+ if passwd is missing:
+ # Find the first text/plain part in the message
+ part = None
+ for part in typed_subpart_iterator(msg, 'text', 'plain'):
+ break
+ if part is not None:
+ lines = part.get_payload().splitlines()
+ line = ''
+ for lineno, line in zip(range(len(lines)), lines):
+ if line.strip():
+ break
+ i = line.find(':')
+ if i >= 0:
+ name = line[:i]
+ value = line[i+1:]
+ if name.lower() in ('approve', 'approved'):
+ passwd = value.lstrip()
+ # Now strip the first line from the payload so the
+ # password doesn't leak.
+ del lines[lineno]
+ part.set_payload(NL.join(lines[1:]))
+ if passwd is not missing and mlist.Authenticate((mm_cfg.AuthListModerator,
+ mm_cfg.AuthListAdmin),
+ passwd):
+ # BAW: should we definitely deny if the password exists but does not
+ # match? For now we'll let it percolate up for further determination.
+ msgdata['approved'] = 1
+ # Used by the Emergency module
+ msgdata['adminapproved'] = 1
+ # has this message already been posted to this list?
+ beentheres = [s.strip().lower() for s in msg.get_all('x-beenthere', [])]
+ if mlist.GetListEmail().lower() in beentheres:
+ raise Errors.LoopError
diff --git a/Mailman/Handlers/AvoidDuplicates.py b/Mailman/Handlers/AvoidDuplicates.py
new file mode 100644
index 00000000..af740da2
--- /dev/null
+++ b/Mailman/Handlers/AvoidDuplicates.py
@@ -0,0 +1,88 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""If the user wishes it, do not send duplicates of the same message.
+
+This module keeps an in-memory dictionary of Message-ID: and recipient pairs.
+If a message with an identical Message-ID: is about to be sent to someone who
+has already received a copy, we either drop the message, add a duplicate
+warning header, or pass it through, depending on the user's preferences.
+"""
+
+from Mailman import mm_cfg
+
+from email.Utils import getaddresses, formataddr
+
+
+
+def process(mlist, msg, msgdata):
+ recips = msgdata['recips']
+ # Short circuit
+ if not recips:
+ return
+ # Seed this set with addresses we don't care about dup avoiding
+ explicit_recips = {}
+ listaddrs = [mlist.GetListEmail(), mlist.GetBouncesEmail(),
+ mlist.GetOwnerEmail(), mlist.GetRequestEmail()]
+ for addr in listaddrs:
+ explicit_recips[addr] = 1
+ # Figure out the set of explicit recipients
+ ccaddrs = {}
+ for header in ('to', 'cc', 'resent-to', 'resent-cc'):
+ addrs = getaddresses(msg.get_all(header, []))
+ if header == 'cc':
+ for name, addr in addrs:
+ ccaddrs[addr] = name, addr
+ for name, addr in addrs:
+ if not addr:
+ continue
+ # Ignore the list addresses for purposes of dup avoidance
+ explicit_recips[addr] = 1
+ # Now strip out the list addresses
+ for addr in listaddrs:
+ del explicit_recips[addr]
+ if not explicit_recips:
+ # No one was explicitly addressed, so we can't do any dup collapsing
+ return
+ newrecips = []
+ for r in recips:
+ # If this recipient is explicitly addressed...
+ if explicit_recips.has_key(r):
+ send_duplicate = 1
+ # If the member wants to receive duplicates, or if the recipient
+ # is not a member at all, just flag the X-Mailman-Duplicate: yes
+ # header.
+ if mlist.isMember(r) and \
+ mlist.getMemberOption(r, mm_cfg.DontReceiveDuplicates):
+ send_duplicate = 0
+ # We'll send a duplicate unless the user doesn't wish it. If
+ # personalization is enabled, the add-dupe-header flag will add a
+ # X-Mailman-Duplicate: yes header for this user's message.
+ if send_duplicate:
+ msgdata.setdefault('add-dup-header', {})[r] = 1
+ newrecips.append(r)
+ elif ccaddrs.has_key(r):
+ del ccaddrs[r]
+ else:
+ # Otherwise, this is the first time they've been in the recips
+ # list. Add them to the newrecips list and flag them as having
+ # received this message.
+ newrecips.append(r)
+ # Set the new list of recipients
+ msgdata['recips'] = newrecips
+ del msg['cc']
+ for item in ccaddrs.values():
+ msg['cc'] = formataddr(item)
diff --git a/Mailman/Handlers/CalcRecips.py b/Mailman/Handlers/CalcRecips.py
new file mode 100644
index 00000000..1b2a600b
--- /dev/null
+++ b/Mailman/Handlers/CalcRecips.py
@@ -0,0 +1,133 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Calculate the regular (i.e. non-digest) recipients of the message.
+
+This module calculates the non-digest recipients for the message based on the
+list's membership and configuration options. It places the list of recipients
+on the `recips' attribute of the message. This attribute is used by the
+SendmailDeliver and BulkDeliver modules.
+"""
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman import Errors
+from Mailman.MemberAdaptor import ENABLED
+from Mailman.i18n import _
+from Mailman.Logging.Syslog import syslog
+
+
+
+def process(mlist, msg, msgdata):
+ # Short circuit if we've already calculated the recipients list,
+ # regardless of whether the list is empty or not.
+ if msgdata.has_key('recips'):
+ return
+ # Should the original sender should be included in the recipients list?
+ include_sender = 1
+ sender = msg.get_sender()
+ try:
+ if mlist.getMemberOption(sender, mm_cfg.DontReceiveOwnPosts):
+ include_sender = 0
+ except Errors.NotAMemberError:
+ pass
+ # Support for urgent messages, which bypasses digests and disabled
+ # delivery and forces an immediate delivery to all members Right Now. We
+ # are specifically /not/ allowing the site admins password to work here
+ # because we want to discourage the practice of sending the site admin
+ # password through email in the clear. (see also Approve.py)
+ missing = []
+ password = msg.get('urgent', missing)
+ if password is not missing:
+ if mlist.Authenticate((mm_cfg.AuthListModerator,
+ mm_cfg.AuthListAdmin),
+ password):
+ recips = mlist.getMemberCPAddresses(mlist.getRegularMemberKeys() +
+ mlist.getDigestMemberKeys())
+ msgdata['recips'] = recips
+ return
+ else:
+ # Bad Urgent: password, so reject it instead of passing it on. I
+ # think it's better that the sender know they screwed up than to
+ # deliver it normally.
+ realname = mlist.real_name
+ text = _("""\
+Your urgent message to the %(realname)s mailing list was not authorized for
+delivery. The original message as received by Mailman is attached.
+""")
+ raise Errors.RejectMessage, Utils.wrap(text)
+ # Calculate the regular recipients of the message
+ recips = [mlist.getMemberCPAddress(m)
+ for m in mlist.getRegularMemberKeys()
+ if mlist.getDeliveryStatus(m) == ENABLED]
+ # Remove the sender if they don't want to receive their own posts
+ if not include_sender:
+ try:
+ recips.remove(mlist.getMemberCPAddress(sender))
+ except (Errors.NotAMemberError, ValueError):
+ # Sender does not want to get copies of their own messages (not
+ # metoo), but delivery to their address is disabled (nomail). Or
+ # the sender is not a member of the mailing list.
+ pass
+ # Handle topic classifications
+ do_topic_filters(mlist, msg, msgdata, recips)
+ # Bookkeeping
+ msgdata['recips'] = recips
+
+
+
+def do_topic_filters(mlist, msg, msgdata, recips):
+ hits = msgdata.get('topichits')
+ zaprecips = []
+ if hits:
+ # The message hit some topics, so only deliver this message to those
+ # who are interested in one of the hit topics.
+ for user in recips:
+ utopics = mlist.getMemberTopics(user)
+ if not utopics:
+ # This user is not interested in any topics, so they get all
+ # postings.
+ continue
+ # BAW: Slow, first-match, set intersection!
+ for topic in utopics:
+ if topic in hits:
+ # The user wants this message
+ break
+ else:
+ # The user was interested in topics, but not any of the ones
+ # this message matched, so zap him.
+ zaprecips.append(user)
+ else:
+ # The semantics for a message that did not hit any of the pre-canned
+ # topics is to troll through the membership list, looking for users
+ # who selected at least one topic of interest, but turned on
+ # ReceiveNonmatchingTopics.
+ for user in recips:
+ if not mlist.getMemberTopics(user):
+ # The user did not select any topics of interest, so he gets
+ # this message by default.
+ continue
+ if not mlist.getMemberOption(user,
+ mm_cfg.ReceiveNonmatchingTopics):
+ # The user has interest in some topics, but elects not to
+ # receive message that match no topics, so zap him.
+ zaprecips.append(user)
+ # Otherwise, the user wants non-matching messages.
+ # Prune out the non-receiving users
+ for user in zaprecips:
+ recips.remove(user)
+
diff --git a/Mailman/Handlers/Cleanse.py b/Mailman/Handlers/Cleanse.py
new file mode 100644
index 00000000..143f9500
--- /dev/null
+++ b/Mailman/Handlers/Cleanse.py
@@ -0,0 +1,39 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Cleanse certain headers from all messages."""
+
+
+def process(mlist, msg, msgdata):
+ # Always remove this header from any outgoing messages. Be sure to do
+ # this after the information on the header is actually used, but before a
+ # permanent record of the header is saved.
+ del msg['approved']
+ # Also remove this header since it can contain a password
+ del msg['urgent']
+ # We remove other headers from anonymous lists
+ if mlist.anonymous_list:
+ del msg['from']
+ del msg['reply-to']
+ del msg['sender']
+ msg['From'] = mlist.GetListEmail()
+ msg['Reply-To'] = mlist.GetListEmail()
+ # Some headers can be used to fish for membership
+ del msg['return-receipt-to']
+ del msg['disposition-notification-to']
+ del msg['x-confirm-reading-to']
+ # Pegasus mail uses this one... sigh
+ del msg['x-pmrqc']
diff --git a/Mailman/Handlers/CookHeaders.py b/Mailman/Handlers/CookHeaders.py
new file mode 100644
index 00000000..40eddd66
--- /dev/null
+++ b/Mailman/Handlers/CookHeaders.py
@@ -0,0 +1,254 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Cook a message's Subject header.
+"""
+
+from __future__ import nested_scopes
+import re
+from types import UnicodeType
+
+from email.Charset import Charset
+from email.Header import Header, decode_header
+from email.Utils import parseaddr, formataddr, getaddresses
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman.i18n import _
+from Mailman.Logging.Syslog import syslog
+
+CONTINUATION = ',\n\t'
+COMMASPACE = ', '
+MAXLINELEN = 78
+
+
+
+def _isunicode(s):
+ return isinstance(s, UnicodeType)
+
+def uheader(mlist, s, header_name=None):
+ # Get the charset to encode the string in. If this is us-ascii, we'll use
+ # iso-8859-1 instead, just to get a little extra coverage, and because the
+ # Header class tries us-ascii first anyway.
+ charset = Utils.GetCharSet(mlist.preferred_language)
+ if charset == 'us-ascii':
+ charset = 'iso-8859-1'
+ charset = Charset(charset)
+ # Convert the string to unicode so Header will do the 3-charset encoding.
+ # If s is a byte string and there are funky characters in it that don't
+ # match the charset, we might as well replace them now.
+ if not _isunicode(s):
+ codec = charset.input_codec or 'ascii'
+ s = unicode(s, codec, 'replace')
+ # We purposefully leave no space b/w prefix and subject!
+ return Header(s, charset, header_name=header_name)
+
+
+
+def process(mlist, msg, msgdata):
+ # Set the "X-Ack: no" header if noack flag is set.
+ if msgdata.get('noack'):
+ del msg['x-ack']
+ msg['X-Ack'] = 'no'
+ # Because we're going to modify various important headers in the email
+ # message, we want to save some of the information in the msgdata
+ # dictionary for later. Specifically, the sender header will get waxed,
+ # but we need it for the Acknowledge module later.
+ msgdata['original_sender'] = msg.get_sender()
+ # VirginRunner sets _fasttrack for internally crafted messages.
+ fasttrack = msgdata.get('_fasttrack')
+ if not msgdata.get('isdigest') and not fasttrack:
+ prefix_subject(mlist, msg, msgdata)
+ # Mark message so we know we've been here, but leave any existing
+ # X-BeenThere's intact.
+ msg['X-BeenThere'] = mlist.GetListEmail()
+ # Add Precedence: and other useful headers. None of these are standard
+ # and finding information on some of them are fairly difficult. Some are
+ # just common practice, and we'll add more here as they become necessary.
+ # Good places to look are:
+ #
+ # http://www.dsv.su.se/~jpalme/ietf/jp-ietf-home.html
+ # http://www.faqs.org/rfcs/rfc2076.html
+ #
+ # None of these headers are added if they already exist. BAW: some
+ # consider the advertising of this a security breach. I.e. if there are
+ # known exploits in a particular version of Mailman and we know a site is
+ # using such an old version, they may be vulnerable. It's too easy to
+ # edit the code to add a configuration variable to handle this.
+ if not msg.has_key('x-mailman-version'):
+ msg['X-Mailman-Version'] = mm_cfg.VERSION
+ # We set "Precedence: list" because this is the recommendation from the
+ # sendmail docs, the most authoritative source of this header's semantics.
+ if not msg.has_key('precedence'):
+ msg['Precedence'] = 'list'
+ # Reply-To: munging. Do not do this if the message is "fast tracked",
+ # meaning it is internally crafted and delivered to a specific user. BAW:
+ # Yuck, I really hate this feature but I've caved under the sheer pressure
+ # of the (very vocal) folks want it. OTOH, RFC 2822 allows Reply-To: to
+ # be a list of addresses, so instead of replacing the original, simply
+ # augment it. RFC 2822 allows max one Reply-To: header so collapse them
+ # if we're adding a value, otherwise don't touch it. (Should we collapse
+ # in all cases?)
+ if not fasttrack:
+ # A convenience function, requires nested scopes. pair is (name, addr)
+ new = []
+ d = {}
+ def add(pair):
+ lcaddr = pair[1].lower()
+ if d.has_key(lcaddr):
+ return
+ d[lcaddr] = pair
+ new.append(pair)
+ # List admin wants an explicit Reply-To: added
+ if mlist.reply_goes_to_list == 2:
+ add(parseaddr(mlist.reply_to_address))
+ # If we're not first stripping existing Reply-To: then we need to add
+ # the original Reply-To:'s to the list we're building up. In both
+ # cases we'll zap the existing field because RFC 2822 says max one is
+ # allowed.
+ if not mlist.first_strip_reply_to:
+ orig = msg.get_all('reply-to', [])
+ for pair in getaddresses(orig):
+ add(pair)
+ # Set Reply-To: header to point back to this list. Add this last
+ # because some folks think that some MUAs make it easier to delete
+ # addresses from the right than from the left.
+ if mlist.reply_goes_to_list == 1:
+ i18ndesc = uheader(mlist, mlist.description)
+ add((str(i18ndesc), mlist.GetListEmail()))
+ del msg['reply-to']
+ # Don't put Reply-To: back if there's nothing to add!
+ if new:
+ # Preserve order
+ msg['Reply-To'] = COMMASPACE.join(
+ [formataddr(pair) for pair in new])
+ # The To field normally contains the list posting address. However
+ # when messages are fully personalized, that header will get
+ # overwritten with the address of the recipient. We need to get the
+ # posting address in one of the recipient headers or they won't be
+ # able to reply back to the list. It's possible the posting address
+ # was munged into the Reply-To header, but if not, we'll add it to a
+ # Cc header. BAW: should we force it into a Reply-To header in the
+ # above code?
+ if mlist.personalize == 2 and mlist.reply_goes_to_list <> 1:
+ # Watch out for existing Cc headers, merge, and remove dups. Note
+ # that RFC 2822 says only zero or one Cc header is allowed.
+ new = []
+ d = {}
+ for pair in getaddresses(msg.get_all('cc', [])):
+ add(pair)
+ i18ndesc = uheader(mlist, mlist.description)
+ add((str(i18ndesc), mlist.GetListEmail()))
+ del msg['Cc']
+ msg['Cc'] = COMMASPACE.join([formataddr(pair) for pair in new])
+ # Add list-specific headers as defined in RFC 2369 and RFC 2919, but only
+ # if the message is being crafted for a specific list (e.g. not for the
+ # password reminders).
+ #
+ # BAW: Some people really hate the List-* headers. It seems that the free
+ # version of Eudora (possibly on for some platforms) does not hide these
+ # headers by default, pissing off their users. Too bad. Fix the MUAs.
+ if msgdata.get('_nolist') or not mlist.include_rfc2369_headers:
+ return
+ # Pre-calculate
+ listid = '<%s.%s>' % (mlist.internal_name(), mlist.host_name)
+ if mlist.description:
+ # Make sure description is properly i18n'd
+ listid_h = uheader(mlist, mlist.description, 'List-Id')
+ listid_h.append(' ' + listid, 'us-ascii')
+ else:
+ # For wrapping
+ listid_h = Header(listid, 'us-ascii', header_name='List-Id')
+ # We always add a List-ID: header.
+ del msg['list-id']
+ msg['List-Id'] = listid_h
+ # For internally crafted messages, we
+ # also add a (nonstandard), "X-List-Administrivia: yes" header. For all
+ # others (i.e. those coming from list posts), we adda a bunch of other RFC
+ # 2369 headers.
+ requestaddr = mlist.GetRequestEmail()
+ subfieldfmt = '<%s>, <mailto:%s?subject=%ssubscribe>'
+ listinfo = mlist.GetScriptURL('listinfo', absolute=1)
+ headers = {}
+ if msgdata.get('reduced_list_headers'):
+ headers['X-List-Administrivia'] = 'yes'
+ else:
+ headers.update({
+ 'List-Help' : '<mailto:%s?subject=help>' % requestaddr,
+ 'List-Unsubscribe': subfieldfmt % (listinfo, requestaddr, 'un'),
+ 'List-Subscribe' : subfieldfmt % (listinfo, requestaddr, ''),
+ })
+ # List-Post: is controlled by a separate attribute
+ if mlist.include_list_post_header:
+ headers['List-Post'] = '<mailto:%s>' % mlist.GetListEmail()
+ # Add this header if we're archiving
+ if mlist.archive:
+ archiveurl = mlist.GetBaseArchiveURL()
+ if archiveurl.endswith('/'):
+ archiveurl = archiveurl[:-1]
+ headers['List-Archive'] = '<%s>' % archiveurl
+ # First we delete any pre-existing headers because the RFC permits only
+ # one copy of each, and we want to be sure it's ours.
+ for h, v in headers.items():
+ del msg[h]
+ # Wrap these lines if they are too long. 78 character width probably
+ # shouldn't be hardcoded, but is at least text-MUA friendly. The
+ # adding of 2 is for the colon-space separator.
+ if len(h) + 2 + len(v) > 78:
+ v = CONTINUATION.join(v.split(', '))
+ msg[h] = v
+
+
+
+def prefix_subject(mlist, msg, msgdata):
+ # Add the subject prefix unless the message is a digest or is being fast
+ # tracked (e.g. internally crafted, delivered to a single user such as the
+ # list admin).
+ prefix = mlist.subject_prefix
+ subject = msg['subject']
+ msgdata['origsubj'] = subject
+ # The header may be multilingual; decode it from base64/quopri and search
+ # each chunk for the prefix. BAW: Note that if the prefix contains spaces
+ # and each word of the prefix is encoded in a different chunk in the
+ # header, we won't find it. I think in practice that's unlikely though.
+ headerbits = decode_header(subject)
+ if prefix and subject:
+ pattern = re.escape(prefix.strip())
+ for decodedsubj, charset in headerbits:
+ if re.search(pattern, decodedsubj, re.IGNORECASE):
+ # The subject's already got the prefix, so don't change it
+ return
+ del msg['subject']
+ if not subject:
+ subject = _('(no subject)')
+ # Get the header as a Header instance, with proper unicode conversion
+ h = uheader(mlist, prefix, 'Subject')
+ for s, c in headerbits:
+ # Once again, convert the string to unicode.
+ if c is None:
+ c = Charset('iso-8859-1')
+ if not isinstance(c, Charset):
+ c = Charset(c)
+ if not _isunicode(s):
+ codec = c.input_codec or 'ascii'
+ try:
+ s = unicode(s, codec, 'replace')
+ except LookupError:
+ # Unknown codec, is this default reasonable?
+ s = unicode(s, Utils.GetCharSet(mlist.preferred_language),
+ 'replace')
+ h.append(s, c)
+ msg['Subject'] = h
diff --git a/Mailman/Handlers/Decorate.py b/Mailman/Handlers/Decorate.py
new file mode 100644
index 00000000..5605e321
--- /dev/null
+++ b/Mailman/Handlers/Decorate.py
@@ -0,0 +1,183 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Decorate a message by sticking the header and footer around it.
+"""
+
+from types import ListType
+from email.MIMEText import MIMEText
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.Message import Message
+from Mailman.i18n import _
+from Mailman.SafeDict import SafeDict
+from Mailman.Logging.Syslog import syslog
+
+
+
+def process(mlist, msg, msgdata):
+ # Digests and Mailman-craft messages should not get additional headers
+ if msgdata.get('isdigest') or msgdata.get('nodecorate'):
+ return
+ d = {}
+ if msgdata.get('personalize'):
+ # Calculate the extra personalization dictionary. Note that the
+ # length of the recips list better be exactly 1.
+ recips = msgdata.get('recips')
+ assert type(recips) == ListType and len(recips) == 1
+ member = recips[0].lower()
+ d['user_address'] = member
+ try:
+ d['user_delivered_to'] = mlist.getMemberCPAddress(member)
+ # BAW: Hmm, should we allow this?
+ d['user_password'] = mlist.getMemberPassword(member)
+ d['user_language'] = mlist.getMemberLanguage(member)
+ d['user_name'] = mlist.getMemberName(member) or _('not available')
+ d['user_optionsurl'] = mlist.GetOptionsURL(member)
+ except Errors.NotAMemberError:
+ pass
+ # These strings are descriptive for the log file and shouldn't be i18n'd
+ header = decorate(mlist, mlist.msg_header, 'non-digest header', d)
+ footer = decorate(mlist, mlist.msg_footer, 'non-digest footer', d)
+ # Escape hatch if both the footer and header are empty
+ if not header and not footer:
+ return
+ # Be MIME smart here. We only attach the header and footer by
+ # concatenation when the message is a non-multipart of type text/plain.
+ # Otherwise, if it is not a multipart, we make it a multipart, and then we
+ # add the header and footer as text/plain parts.
+ #
+ # BJG: In addition, only add the footer if the message's character set
+ # matches the charset of the list's preferred language. This is a
+ # suboptimal solution, and should be solved by allowing a list to have
+ # multiple headers/footers, for each language the list supports.
+ #
+ # Also, if the list's preferred charset is us-ascii, we can always
+ # safely add the header/footer to a plain text message since all
+ # charsets Mailman supports are strict supersets of us-ascii --
+ # no, UTF-16 emails are not supported yet.
+ mcset = msg.get_param('charset', 'us-ascii').lower()
+ lcset = Utils.GetCharSet(mlist.preferred_language)
+ msgtype = msg.get_type('text/plain')
+ # BAW: If the charsets don't match, should we add the header and footer by
+ # MIME multipart chroming the message?
+ wrap = 1
+ if not msg.is_multipart() and msgtype == 'text/plain' and \
+ msg.get('content-transfer-encoding', '').lower() <> 'base64' and \
+ (lcset == 'us-ascii' or mcset == lcset):
+ oldpayload = msg.get_payload()
+ frontsep = endsep = ''
+ if header and not header.endswith('\n'):
+ frontsep = '\n'
+ if footer and not oldpayload.endswith('\n'):
+ endsep = '\n'
+ payload = header + frontsep + oldpayload + endsep + footer
+ msg.set_payload(payload)
+ wrap = 0
+ elif msg.get_type() == 'multipart/mixed':
+ # The next easiest thing to do is just prepend the header and append
+ # the footer as additional subparts
+ mimehdr = MIMEText(header, 'plain', lcset)
+ mimeftr = MIMEText(footer, 'plain', lcset)
+ payload = msg.get_payload()
+ if not isinstance(payload, ListType):
+ payload = [payload]
+ if footer:
+ payload.append(mimeftr)
+ if header:
+ payload.insert(0, mimehdr)
+ msg.set_payload(payload)
+ wrap = 0
+ # If we couldn't add the header or footer in a less intrusive way, we can
+ # at least do it by MIME encapsulation. We want to keep as much of the
+ # outer chrome as possible.
+ if not wrap:
+ return
+ # Because of the way Message objects are passed around to process(), we
+ # need to play tricks with the outer message -- i.e. the outer one must
+ # remain the same instance. So we're going to create a clone of the outer
+ # message, with all the header chrome intact, then copy the payload to it.
+ # This will give us a clone of the original message, and it will form the
+ # basis of the interior, wrapped Message.
+ inner = Message()
+ # Which headers to copy? Let's just do the Content-* headers
+ for h, v in msg.items():
+ if h.lower().startswith('content-'):
+ inner[h] = v
+ inner.set_payload(msg.get_payload())
+ # For completeness
+ inner.set_unixfrom(msg.get_unixfrom())
+ inner.preamble = msg.preamble
+ inner.epilogue = msg.epilogue
+ # Don't copy get_charset, as this might be None, even if
+ # get_content_charset isn't. However, do make sure there is a default
+ # content-type, even if the original message was not MIME.
+ inner.set_default_type(msg.get_default_type())
+ # BAW: HACK ALERT.
+ if hasattr(msg, '__version__'):
+ inner.__version__ = msg.__version__
+ # Now, play games with the outer message to make it contain three
+ # subparts: the header (if any), the wrapped message, and the footer (if
+ # any).
+ payload = [inner]
+ if header:
+ mimehdr = MIMEText(header, 'plain', lcset)
+ payload.insert(0, mimehdr)
+ if footer:
+ mimeftr = MIMEText(footer, 'plain', lcset)
+ payload.append(mimeftr)
+ msg.set_payload(payload)
+ del msg['content-type']
+ del msg['content-transfer-encoding']
+ del msg['content-disposition']
+ msg['Content-Type'] = 'multipart/mixed'
+
+
+
+def decorate(mlist, template, what, extradict={}):
+ # `what' is just a descriptive phrase used in the log message
+ #
+ # BAW: We've found too many situations where Python can be fooled into
+ # interpolating too much revealing data into a format string. For
+ # example, a footer of "% silly %(real_name)s" would give a header
+ # containing all list attributes. While we've previously removed such
+ # really bad ones like `password' and `passwords', it's much better to
+ # provide a whitelist of known good attributes, then to try to remove a
+ # blacklist of known bad ones.
+ d = SafeDict({'real_name' : mlist.real_name,
+ 'list_name' : mlist.internal_name(),
+ # For backwards compatibility
+ '_internal_name': mlist.internal_name(),
+ 'host_name' : mlist.host_name,
+ 'web_page_url' : mlist.web_page_url,
+ 'description' : mlist.description,
+ 'info' : mlist.info,
+ 'cgiext' : mm_cfg.CGIEXT,
+ })
+ d.update(extradict)
+ # Using $-strings?
+ if getattr(mlist, 'use_dollar_strings', 0):
+ template = Utils.to_percent(template)
+ # Interpolate into the template
+ try:
+ text = (template % d).replace('\r\n', '\n')
+ except (ValueError, TypeError), e:
+ syslog('error', 'Exception while calculating %s:\n%s', what, e)
+ what = what.upper()
+ text = template
+ return text
diff --git a/Mailman/Handlers/Emergency.py b/Mailman/Handlers/Emergency.py
new file mode 100644
index 00000000..1833c9f7
--- /dev/null
+++ b/Mailman/Handlers/Emergency.py
@@ -0,0 +1,37 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Put an emergency hold on all messages otherwise approved.
+
+No notices are sent to either the sender or the list owner for emergency
+holds. I think they'd be too obnoxious.
+"""
+
+from Mailman import Errors
+from Mailman.i18n import _
+
+
+
+class EmergencyHold(Errors.HoldMessage):
+ reason = _('Emergency hold on all list traffic is in effect')
+ rejection = _('Your message was deemed inappropriate by the moderator.')
+
+
+
+def process(mlist, msg, msgdata):
+ if mlist.emergency and not msgdata.get('adminapproved'):
+ mlist.HoldMessage(msg, _(EmergencyHold.reason), msgdata)
+ raise EmergencyHold
diff --git a/Mailman/Handlers/FileRecips.py b/Mailman/Handlers/FileRecips.py
new file mode 100644
index 00000000..4ca4582e
--- /dev/null
+++ b/Mailman/Handlers/FileRecips.py
@@ -0,0 +1,49 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Get the normal delivery recipients from a Sendmail style :include: file.
+"""
+
+import os
+import errno
+
+from Mailman import Errors
+
+
+
+def process(mlist, msg, msgdata):
+ if msgdata.has_key('recips'):
+ return
+ filename = os.path.join(mlist.fullpath(), 'members.txt')
+ try:
+ fp = open(filename)
+ except IOError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ # If the file didn't exist, just set an empty recipients list
+ msgdata['recips'] = []
+ return
+ # Read all the lines out of the file, and strip them of the trailing nl
+ addrs = [line.strip() for line in fp.readlines()]
+ # If the sender is in that list, remove him
+ sender = msg.get_sender()
+ if mlist.isMember(sender):
+ try:
+ addrs.remove(mlist.getMemberCPAddress(sender))
+ except ValueError:
+ # Don't worry if the sender isn't in the list
+ pass
+ msgdata['recips'] = addrs
diff --git a/Mailman/Handlers/Hold.py b/Mailman/Handlers/Hold.py
new file mode 100644
index 00000000..15223959
--- /dev/null
+++ b/Mailman/Handlers/Hold.py
@@ -0,0 +1,280 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Determine whether this message should be held for approval.
+
+This modules tests only for hold situations, such as messages that are too
+large, messages that have potential administrivia, etc. Definitive approvals
+or denials are handled by a different module.
+
+If no determination can be made (i.e. none of the hold criteria matches), then
+we do nothing. If the message must be held for approval, then the hold
+database is updated and any administrator notification messages are sent.
+Finally an exception is raised to let the pipeline machinery know that further
+message handling should stop.
+
+"""
+
+import email
+from email.MIMEText import MIMEText
+from email.MIMEMessage import MIMEMessage
+import email.Utils
+from types import ClassType
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman import Message
+from Mailman import i18n
+from Mailman import Pending
+from Mailman.Logging.Syslog import syslog
+
+# First, play footsie with _ so that the following are marked as translated,
+# but aren't actually translated until we need the text later on.
+def _(s):
+ return s
+
+
+
+class ForbiddenPoster(Errors.HoldMessage):
+ reason = _('Sender is explicitly forbidden')
+ rejection = _('You are forbidden from posting messages to this list.')
+
+class ModeratedPost(Errors.HoldMessage):
+ reason = _('Post to moderated list')
+ rejection = _('Your message was deemed inappropriate by the moderator.')
+
+class NonMemberPost(Errors.HoldMessage):
+ reason = _('Post by non-member to a members-only list')
+ rejection = _('Non-members are not allowed to post messages to this list.')
+
+class NotExplicitlyAllowed(Errors.HoldMessage):
+ reason = _('Posting to a restricted list by sender requires approval')
+ rejection = _('This list is restricted; your message was not approved.')
+
+class TooManyRecipients(Errors.HoldMessage):
+ reason = _('Too many recipients to the message')
+ rejection = _('Please trim the recipient list; it is too long.')
+
+class ImplicitDestination(Errors.HoldMessage):
+ reason = _('Message has implicit destination')
+ rejection = _('''Blind carbon copies or other implicit destinations are
+not allowed. Try reposting your message by explicitly including the list
+address in the To: or Cc: fields.''')
+
+class Administrivia(Errors.HoldMessage):
+ reason = _('Message may contain administrivia')
+
+ def rejection_notice(self, mlist):
+ listurl = mlist.GetScriptURL('listinfo', absolute=1)
+ request = mlist.GetRequestEmail()
+ return _("""Please do *not* post administrative requests to the mailing
+list. If you wish to subscribe, visit %(listurl)s or send a message with the
+word `help' in it to the request address, %(request)s, for further
+instructions.""")
+
+class SuspiciousHeaders(Errors.HoldMessage):
+ reason = _('Message has a suspicious header')
+ rejection = _('Your message had a suspicious header.')
+
+class MessageTooBig(Errors.HoldMessage):
+ def __init__(self, msgsize, limit):
+ self.__msgsize = msgsize
+ self.__limit = limit
+
+ def reason_notice(self):
+ size = self.__msgsize
+ limit = self.__limit
+ return _('''Message body is too big: %(size)d bytes with a limit of
+%(limit)d KB''')
+
+ def rejection_notice(self, mlist):
+ kb = self.__limit
+ return _('''Your message was too big; please trim it to less than
+%(kb)d KB in size.''')
+
+class ModeratedNewsgroup(ModeratedPost):
+ reason = _('Posting to a moderated newsgroup')
+
+
+
+# And reset the translator
+_ = i18n._
+
+
+
+def ackp(msg):
+ ack = msg.get('x-ack', '').lower()
+ precedence = msg.get('precedence', '').lower()
+ if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'):
+ return 0
+ return 1
+
+
+
+def process(mlist, msg, msgdata):
+ if msgdata.get('approved'):
+ return
+ # Get the sender of the message
+ listname = mlist.internal_name()
+ adminaddr = listname + '-admin'
+ sender = msg.get_sender()
+ # Special case an ugly sendmail feature: If there exists an alias of the
+ # form "owner-foo: bar" and sendmail receives mail for address "foo",
+ # sendmail will change the envelope sender of the message to "bar" before
+ # delivering. This feature does not appear to be configurable. *Boggle*.
+ if not sender or sender[:len(listname)+6] == adminaddr:
+ sender = msg.get_sender(use_envelope=0)
+ #
+ # Possible administrivia?
+ if mlist.administrivia and Utils.is_administrivia(msg):
+ hold_for_approval(mlist, msg, msgdata, Administrivia)
+ # no return
+ #
+ # Are there too many recipients to the message?
+ if mlist.max_num_recipients > 0:
+ # figure out how many recipients there are
+ recips = email.Utils.getaddresses(msg.get_all('to', []) +
+ msg.get_all('cc', []))
+ if len(recips) >= mlist.max_num_recipients:
+ hold_for_approval(mlist, msg, msgdata, TooManyRecipients)
+ # no return
+ #
+ # Implicit destination? Note that message originating from the Usenet
+ # side of the world should never be checked for implicit destination.
+ if mlist.require_explicit_destination and \
+ not mlist.HasExplicitDest(msg) and \
+ not msgdata.get('fromusenet'):
+ # then
+ hold_for_approval(mlist, msg, msgdata, ImplicitDestination)
+ # no return
+ #
+ # Suspicious headers?
+ if mlist.bounce_matching_headers:
+ triggered = mlist.hasMatchingHeader(msg)
+ if triggered:
+ # TBD: Darn - can't include the matching line for the admin
+ # message because the info would also go to the sender
+ hold_for_approval(mlist, msg, msgdata, SuspiciousHeaders)
+ # no return
+ #
+ # Is the message too big?
+ if mlist.max_message_size > 0:
+ bodylen = 0
+ for line in email.Iterators.body_line_iterator(msg):
+ bodylen += len(line)
+ if bodylen/1024.0 > mlist.max_message_size:
+ hold_for_approval(mlist, msg, msgdata,
+ MessageTooBig(bodylen, mlist.max_message_size))
+ # no return
+ #
+ # Are we gatewaying to a moderated newsgroup and is this list the
+ # moderator's address for the group?
+ if mlist.news_moderation == 2:
+ hold_for_approval(mlist, msg, msgdata, ModeratedNewsgroup)
+
+
+
+def hold_for_approval(mlist, msg, msgdata, exc):
+ # BAW: This should really be tied into the email confirmation system so
+ # that the message can be approved or denied via email as well as the
+ # web.
+ if type(exc) is ClassType:
+ # Go ahead and instantiate it now.
+ exc = exc()
+ listname = mlist.real_name
+ sender = msgdata.get('sender', msg.get_sender())
+ owneraddr = mlist.GetOwnerEmail()
+ adminaddr = mlist.GetBouncesEmail()
+ requestaddr = mlist.GetRequestEmail()
+ # We need to send both the reason and the rejection notice through the
+ # translator again, because of the games we play above
+ reason = Utils.wrap(exc.reason_notice())
+ msgdata['rejection_notice'] = Utils.wrap(exc.rejection_notice(mlist))
+ id = mlist.HoldMessage(msg, reason, msgdata)
+ # Now we need to craft and send a message to the list admin so they can
+ # deal with the held message.
+ d = {'listname' : listname,
+ 'hostname' : mlist.host_name,
+ 'reason' : _(reason),
+ 'sender' : sender,
+ 'subject' : msg.get('subject', _('(no subject)')),
+ 'admindb_url': mlist.GetScriptURL('admindb', absolute=1),
+ }
+ # We may want to send a notification to the original sender too
+ fromusenet = msgdata.get('fromusenet')
+ # Since we're sending two messages, which may potentially be in different
+ # languages (the user's preferred and the list's preferred for the admin),
+ # we need to play some i18n games here. Since the current language
+ # context ought to be set up for the user, let's craft his message first.
+ #
+ # This message should appear to come from <list>-admin so as to handle any
+ # bounce processing that might be needed.
+ cookie = Pending.new(Pending.HELD_MESSAGE, id)
+ if not fromusenet and ackp(msg) and mlist.respond_to_post_requests and \
+ mlist.autorespondToSender(sender):
+ # Get a confirmation cookie
+ d['confirmurl'] = '%s/%s' % (mlist.GetScriptURL('confirm', absolute=1),
+ cookie)
+ lang = msgdata.get('lang', mlist.getMemberLanguage(sender))
+ subject = _('Your message to %(listname)s awaits moderator approval')
+ text = Utils.maketext('postheld.txt', d, lang=lang, mlist=mlist)
+ nmsg = Message.UserNotification(sender, adminaddr, subject, text, lang)
+ nmsg.send(mlist)
+ # Now the message for the list owners. Be sure to include the list
+ # moderators in this message. This one should appear to come from
+ # <list>-owner since we really don't need to do bounce processing on it.
+ if mlist.admin_immed_notify:
+ # Now let's temporarily set the language context to that which the
+ # admin is expecting.
+ otranslation = i18n.get_translation()
+ i18n.set_language(mlist.preferred_language)
+ try:
+ lang = mlist.preferred_language
+ charset = Utils.GetCharSet(lang)
+ # We need to regenerate or re-translate a few values in d
+ usersubject = msg.get('subject', _('(no subject)'))
+ d['reason'] = _(reason)
+ d['subject'] = usersubject
+ # craft the admin notification message and deliver it
+ subject = _('%(listname)s post from %(sender)s requires approval')
+ nmsg = Message.UserNotification(owneraddr, owneraddr, subject,
+ lang=lang)
+ nmsg.set_type('multipart/mixed')
+ text = MIMEText(
+ Utils.maketext('postauth.txt', d, raw=1, mlist=mlist),
+ _charset=charset)
+ dmsg = MIMEText(Utils.wrap(_("""\
+If you reply to this message, keeping the Subject: header intact, Mailman will
+discard the held message. Do this if the message is spam. If you reply to
+this message and include an Approved: header with the list password in it, the
+message will be approved for posting to the list. The Approved: header can
+also appear in the first line of the body of the reply.""")),
+ _charset=Utils.GetCharSet(lang))
+ dmsg['Subject'] = 'confirm ' + cookie
+ dmsg['Sender'] = requestaddr
+ dmsg['From'] = requestaddr
+ nmsg.attach(text)
+ nmsg.attach(MIMEMessage(msg))
+ nmsg.attach(MIMEMessage(dmsg))
+ nmsg.send(mlist, **{'tomoderators': 1})
+ finally:
+ i18n.set_translation(otranslation)
+ # Log the held message
+ syslog('vette', '%s post from %s held: %s', listname, sender, reason)
+ # raise the specific MessageHeld exception to exit out of the message
+ # delivery pipeline
+ raise exc
diff --git a/Mailman/Handlers/Makefile.in b/Mailman/Handlers/Makefile.in
new file mode 100644
index 00000000..6123bdfb
--- /dev/null
+++ b/Mailman/Handlers/Makefile.in
@@ -0,0 +1,69 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman/Handlers
+SHELL= /bin/sh
+
+MODULES= *.py
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+
+install:
+ for f in $(MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \
+ done
+
+finish:
+
+clean:
+
+distclean:
+ -rm *.pyc
+ -rm Makefile
diff --git a/Mailman/Handlers/MimeDel.py b/Mailman/Handlers/MimeDel.py
new file mode 100644
index 00000000..3bcdaffa
--- /dev/null
+++ b/Mailman/Handlers/MimeDel.py
@@ -0,0 +1,220 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""MIME-stripping filter for Mailman.
+
+This module scans a message for MIME content, removing those sections whose
+MIME types match one of a list of matches. multipart/alternative sections are
+replaced by the first non-empty component, and multipart/mixed sections
+wrapping only single sections after other processing are replaced by their
+contents.
+"""
+
+import os
+import errno
+import tempfile
+
+from email.Iterators import typed_subpart_iterator
+
+from Mailman import mm_cfg
+from Mailman import Errors
+from Mailman.Message import UserNotification
+from Mailman.Queue.sbcache import get_switchboard
+from Mailman.Logging.Syslog import syslog
+from Mailman.Version import VERSION
+from Mailman.i18n import _
+
+
+
+def process(mlist, msg, msgdata):
+ # Short-circuits
+ if not mlist.filter_content:
+ return
+ if msgdata.get('isdigest'):
+ return
+ # We also don't care about our own digests or plaintext
+ ctype = msg.get_content_type()
+ mtype = msg.get_content_maintype()
+ # Check to see if the outer type matches one of the filter types
+ filtertypes = mlist.filter_mime_types
+ passtypes = mlist.pass_mime_types
+ if ctype in filtertypes or mtype in filtertypes:
+ dispose(mlist, msg, msgdata,
+ _("The message's content type was explicitly disallowed"))
+ # Check to see if there is a pass types and the outer type doesn't match
+ # one of these types
+ if passtypes and not (ctype in passtypes or mtype in passtypes):
+ dispose(mlist, msg, msgdata,
+ _("The message's content type was not explicitly allowed"))
+ numparts = len([subpart for subpart in msg.walk()])
+ # If the message is a multipart, filter out matching subparts
+ if msg.is_multipart():
+ # Recursively filter out any subparts that match the filter list
+ prelen = len(msg.get_payload())
+ filter_parts(msg, filtertypes, passtypes)
+ # If the outer message is now an empty multipart (and it wasn't
+ # before!) then, again it gets discarded.
+ postlen = len(msg.get_payload())
+ if postlen == 0 and prelen > 0:
+ dispose(mlist, msg, msgdata,
+ _("After content filtering, the message was empty"))
+ # Now replace all multipart/alternatives with just the first non-empty
+ # alternative. BAW: We have to special case when the outer part is a
+ # multipart/alternative because we need to retain most of the outer part's
+ # headers. For now we'll move the subpart's payload into the outer part,
+ # and then copy over its Content-Type: and Content-Transfer-Encoding:
+ # headers (any others?).
+ collapse_multipart_alternatives(msg)
+ if ctype == 'multipart/alternative':
+ firstalt = msg.get_payload(0)
+ reset_payload(msg, firstalt)
+ # If we removed some parts, make note of this
+ changedp = 0
+ if numparts <> len([subpart for subpart in msg.walk()]):
+ changedp = 1
+ # Now perhaps convert all text/html to text/plain
+ if mlist.convert_html_to_plaintext and mm_cfg.HTML_TO_PLAIN_TEXT_COMMAND:
+ changedp += to_plaintext(msg)
+ # If we're left with only two parts, an empty body and one attachment,
+ # recast the message to one of just that part
+ if msg.is_multipart() and len(msg.get_payload()) == 2:
+ if msg.get_payload(0).get_payload() == '':
+ useful = msg.get_payload(1)
+ reset_payload(msg, useful)
+ changedp = 1
+ if changedp:
+ msg['X-Content-Filtered-By'] = 'Mailman/MimeDel %s' % VERSION
+
+
+
+def reset_payload(msg, subpart):
+ # Reset payload of msg to contents of subpart, and fix up content headers
+ payload = subpart.get_payload()
+ msg.set_payload(payload)
+ del msg['content-type']
+ del msg['content-transfer-encoding']
+ del msg['content-disposition']
+ del msg['content-description']
+ msg['Content-Type'] = subpart.get('content-type', 'text/plain')
+ cte = subpart.get('content-transfer-encoding')
+ if cte:
+ msg['Content-Transfer-Encoding'] = cte
+ cdisp = subpart.get('content-disposition')
+ if cdisp:
+ msg['Content-Disposition'] = cdisp
+ cdesc = subpart.get('content-description')
+ if cdesc:
+ msg['Content-Description'] = cdesc
+
+
+
+def filter_parts(msg, filtertypes, passtypes):
+ # Look at all the message's subparts, and recursively filter
+ if not msg.is_multipart():
+ return 1
+ payload = msg.get_payload()
+ prelen = len(payload)
+ newpayload = []
+ for subpart in payload:
+ keep = filter_parts(subpart, filtertypes, passtypes)
+ if not keep:
+ continue
+ ctype = subpart.get_content_type()
+ mtype = subpart.get_content_maintype()
+ if ctype in filtertypes or mtype in filtertypes:
+ # Throw this subpart away
+ continue
+ if passtypes and not (ctype in passtypes or mtype in passtypes):
+ # Throw this subpart away
+ continue
+ newpayload.append(subpart)
+ # Check to see if we discarded all the subparts
+ postlen = len(newpayload)
+ msg.set_payload(newpayload)
+ if postlen == 0 and prelen > 0:
+ # We threw away everything
+ return 0
+ return 1
+
+
+
+def collapse_multipart_alternatives(msg):
+ if not msg.is_multipart():
+ return
+ newpayload = []
+ for subpart in msg.get_payload():
+ if subpart.get_content_type() == 'multipart/alternative':
+ try:
+ firstalt = subpart.get_payload(0)
+ newpayload.append(firstalt)
+ except IndexError:
+ pass
+ else:
+ newpayload.append(subpart)
+ msg.set_payload(newpayload)
+
+
+
+def to_plaintext(msg):
+ changedp = 0
+ for subpart in typed_subpart_iterator(msg, 'text', 'html'):
+ filename = tempfile.mktemp('.html')
+ fp = open(filename, 'w')
+ try:
+ fp.write(subpart.get_payload())
+ fp.close()
+ cmd = os.popen(mm_cfg.HTML_TO_PLAIN_TEXT_COMMAND %
+ {'filename': filename})
+ plaintext = cmd.read()
+ rtn = cmd.close()
+ if rtn:
+ syslog('error', 'HTML->text/plain error: %s', rtn)
+ finally:
+ try:
+ os.unlink(filename)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ # Now replace the payload of the subpart and twiddle the Content-Type:
+ subpart.set_payload(plaintext)
+ subpart.set_type('text/plain')
+ changedp = 1
+ return changedp
+
+
+
+def dispose(mlist, msg, msgdata, why):
+ # filter_action == 0 just discards, see below
+ if mlist.filter_action == 1:
+ # Bounce the message to the original author
+ raise Errors.RejectMessage, why
+ if mlist.filter_action == 2:
+ # Forward it on to the list owner
+ listname = mlist.internal_name()
+ mlist.ForwardMessage(
+ msg,
+ text=_("""\
+The attached message matched the %(listname)s mailing list's content filtering
+rules and was prevented from being forwarded on to the list membership. You
+are receiving the only remaining copy of the discarded message.
+
+"""),
+ subject=_('Content filtered message notification'))
+ if mlist.filter_action == 3 and \
+ mm_cfg.OWNERS_CAN_PRESERVE_FILTERED_MESSAGES:
+ badq = get_switchboard(mm_cfg.BADQUEUE_DIR)
+ badq.enqueue(msg, msgdata)
+ # Most cases also discard the message
+ raise Errors.DiscardMessage
diff --git a/Mailman/Handlers/Moderate.py b/Mailman/Handlers/Moderate.py
new file mode 100644
index 00000000..d44cb89b
--- /dev/null
+++ b/Mailman/Handlers/Moderate.py
@@ -0,0 +1,164 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Posting moderation filter.
+"""
+
+import re
+from email.MIMEMessage import MIMEMessage
+from email.MIMEText import MIMEText
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman import Errors
+from Mailman.i18n import _
+from Mailman.Handlers import Hold
+from Mailman.Logging.Syslog import syslog
+
+
+
+class ModeratedMemberPost(Hold.ModeratedPost):
+ # BAW: I wanted to use the reason below to differentiate between this
+ # situation and normal ModeratedPost reasons. Greg Ward and Stonewall
+ # Ballard thought the language was too harsh and mentioned offense taken
+ # by some list members. I'd still like this class's reason to be
+ # different than the base class's reason, but we'll use this until someone
+ # can come up with something more clever but inoffensive.
+ #
+ # reason = _('Posts by member are currently quarantined for moderation')
+ pass
+
+
+
+def process(mlist, msg, msgdata):
+ if msgdata.get('approved'):
+ return
+ # First of all, is the poster a member or not?
+ for sender in msg.get_senders():
+ if mlist.isMember(sender):
+ break
+ else:
+ sender = None
+ if sender:
+ # If the member's moderation flag is on, then perform the moderation
+ # action.
+ if mlist.getMemberOption(sender, mm_cfg.Moderate):
+ # Note that for member_moderation_action, 0==Hold, 1=Reject,
+ # 2==Discard
+ if mlist.member_moderation_action == 0:
+ # Hold. BAW: WIBNI we could add the member_moderation_notice
+ # to the notice sent back to the sender?
+ msgdata['sender'] = sender
+ Hold.hold_for_approval(mlist, msg, msgdata,
+ ModeratedMemberPost)
+ elif mlist.member_moderation_action == 1:
+ # Reject
+ text = mlist.member_moderation_notice
+ if text:
+ text = Utils.wrap(text)
+ else:
+ # Use the default RejectMessage notice string
+ text = None
+ raise Errors.RejectMessage, text
+ elif mlist.member_moderation_action == 2:
+ # Discard. BAW: Again, it would be nice if we could send a
+ # discard notice to the sender
+ raise Errors.DiscardMessage
+ else:
+ assert 0, 'bad member_moderation_action'
+ # Should we do anything explict to mark this message as getting past
+ # this point? No, because further pipeline handlers will need to do
+ # their own thing.
+ return
+ else:
+ sender = msg.get_sender()
+ # From here on out, we're dealing with non-members.
+ if matches_p(sender, mlist.accept_these_nonmembers):
+ return
+ if matches_p(sender, mlist.hold_these_nonmembers):
+ Hold.hold_for_approval(mlist, msg, msgdata, Hold.NonMemberPost)
+ # No return
+ if matches_p(sender, mlist.reject_these_nonmembers):
+ do_reject(mlist)
+ # No return
+ if matches_p(sender, mlist.discard_these_nonmembers):
+ do_discard(mlist, msg)
+ # No return
+ # Okay, so the sender wasn't specified explicitly by any of the non-member
+ # moderation configuration variables. Handle by way of generic non-member
+ # action.
+ assert 0 <= mlist.generic_nonmember_action <= 4
+ if mlist.generic_nonmember_action == 0:
+ # Accept
+ return
+ elif mlist.generic_nonmember_action == 1:
+ Hold.hold_for_approval(mlist, msg, msgdata, Hold.NonMemberPost)
+ elif mlist.generic_nonmember_action == 2:
+ do_reject(mlist)
+ elif mlist.generic_nonmember_action == 3:
+ do_discard(mlist, msg)
+
+
+
+def matches_p(sender, nonmembers):
+ # First strip out all the regular expressions
+ plainaddrs = [addr for addr in nonmembers if not addr.startswith('^')]
+ addrdict = Utils.List2Dict(plainaddrs, foldcase=1)
+ if addrdict.has_key(sender):
+ return 1
+ # Now do the regular expression matches
+ for are in nonmembers:
+ if are.startswith('^'):
+ try:
+ cre = re.compile(are, re.IGNORECASE)
+ except re.error:
+ continue
+ if cre.search(sender):
+ return 1
+ return 0
+
+
+
+def do_reject(mlist):
+ listowner = mlist.GetOwnerEmail()
+ raise Errors.RejectMessage, Utils.wrap(_("""\
+You are not allowed to post to this mailing list, and your message has been
+automatically rejected. If you think that your messages are being rejected in
+error, contact the mailing list owner at %(listowner)s."""))
+
+
+
+def do_discard(mlist, msg):
+ sender = msg.get_sender()
+ # Do we forward auto-discards to the list owners?
+ if mlist.forward_auto_discards:
+ lang = mlist.preferred_language
+ varhelp = '%s/?VARHELP=privacy/sender/discard_these_nonmembers' % \
+ mlist.GetScriptURL('admin', absolute=1)
+ nmsg = Message.UserNotification(mlist.GetOwnerEmail(),
+ mlist.GetBouncesEmail(),
+ _('Auto-discard notification'),
+ lang=lang)
+ nmsg.set_type('multipart/mixed')
+ text = MIMEText(Utils.wrap(_(
+ 'The attached message has been automatically discarded.')),
+ _charset=Utils.GetCharSet(lang))
+ nmsg.attach(text)
+ nmsg.attach(MIMEMessage(msg))
+ nmsg.send(mlist)
+ # Discard this sucker
+ raise Errors.DiscardMessage
diff --git a/Mailman/Handlers/OwnerRecips.py b/Mailman/Handlers/OwnerRecips.py
new file mode 100644
index 00000000..c0a54f3d
--- /dev/null
+++ b/Mailman/Handlers/OwnerRecips.py
@@ -0,0 +1,27 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Calculate the list owner recipients (includes moderators).
+"""
+
+
+
+def process(mlist, msg, msgdata):
+ # The recipients are the owner and the moderator
+ msgdata['recips'] = mlist.owner + mlist.moderator
+ # Don't decorate these messages with the header/footers
+ msgdata['nodecorate'] = 1
+ msgdata['personalize'] = 0
diff --git a/Mailman/Handlers/Replybot.py b/Mailman/Handlers/Replybot.py
new file mode 100644
index 00000000..8a9be5cb
--- /dev/null
+++ b/Mailman/Handlers/Replybot.py
@@ -0,0 +1,120 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Handler for auto-responses.
+"""
+
+import time
+
+from Mailman import Utils
+from Mailman import Message
+from Mailman.i18n import _
+from Mailman.SafeDict import SafeDict
+from Mailman.Logging.Syslog import syslog
+
+
+
+def process(mlist, msg, msgdata):
+ # Normally, the replybot should get a shot at this message, but there are
+ # some important short-circuits, mostly to suppress 'bot storms, at least
+ # for well behaved email bots (there are other governors for misbehaving
+ # 'bots). First, if the original message has an "X-Ack: No" header, we
+ # skip the replybot. Then, if the message has a Precedence header with
+ # values bulk, junk, or list, and there's no explicit "X-Ack: yes" header,
+ # we short-circuit. Finally, if the message metadata has a true 'noack'
+ # key, then we skip the replybot too.
+ ack = msg.get('x-ack', '').lower()
+ if ack == 'no' or msgdata.get('noack'):
+ return
+ precedence = msg.get('precedence', '').lower()
+ if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'):
+ return
+ # Check to see if the list is even configured to autorespond to this email
+ # message. Note: the mailowner script sets the `toadmin' or `toowner' key
+ # (which for replybot purposes are equivalent), and the mailcmd script
+ # sets the `torequest' key.
+ toadmin = msgdata.get('toowner')
+ torequest = msgdata.get('torequest')
+ if ((toadmin and not mlist.autorespond_admin) or
+ (torequest and not mlist.autorespond_requests) or \
+ (not toadmin and not torequest and not mlist.autorespond_postings)):
+ return
+ # Now see if we're in the grace period for this sender. graceperiod <= 0
+ # means always autorespond, as does an "X-Ack: yes" header (useful for
+ # debugging).
+ sender = msg.get_sender()
+ now = time.time()
+ graceperiod = mlist.autoresponse_graceperiod
+ if graceperiod > 0 and ack <> 'yes':
+ if toadmin:
+ quiet_until = mlist.admin_responses.get(sender, 0)
+ elif torequest:
+ quiet_until = mlist.request_responses.get(sender, 0)
+ else:
+ quiet_until = mlist.postings_responses.get(sender, 0)
+ if quiet_until > now:
+ return
+ #
+ # Okay, we know we're going to auto-respond to this sender, craft the
+ # message, send it, and update the database.
+ realname = mlist.real_name
+ subject = _('Auto-response for your message to ') + \
+ msg.get('to', _('the "%(realname)s" mailing list'))
+ # Do string interpolation
+ d = SafeDict({'listname' : realname,
+ 'listurl' : mlist.GetScriptURL('listinfo'),
+ 'requestemail': mlist.GetRequestEmail(),
+ # BAW: Deprecate adminemail; it's not advertised but still
+ # supported for backwards compatibility.
+ 'adminemail' : mlist.GetBouncesEmail(),
+ 'owneremail' : mlist.GetOwnerEmail(),
+ })
+ # Just because we're using a SafeDict doesn't mean we can't get all sorts
+ # of other exceptions from the string interpolation. Let's be ultra
+ # conservative here.
+ if toadmin:
+ rtext = mlist.autoresponse_admin_text
+ elif torequest:
+ rtext = mlist.autoresponse_request_text
+ else:
+ rtext = mlist.autoresponse_postings_text
+ # Using $-strings?
+ if getattr(mlist, 'use_dollar_strings', 0):
+ rtext = Utils.to_percent(rtext)
+ try:
+ text = rtext % d
+ except Exception:
+ syslog('error', 'Bad autoreply text for list: %s\n%s',
+ mlist.internal_name(), rtext)
+ text = rtext
+ # Wrap the response.
+ text = Utils.wrap(text)
+ outmsg = Message.UserNotification(sender, mlist.GetBouncesEmail(),
+ subject, text, mlist.preferred_language)
+ outmsg['X-Mailer'] = _('The Mailman Replybot')
+ # prevent recursions and mail loops!
+ outmsg['X-Ack'] = 'No'
+ outmsg.send(mlist)
+ # update the grace period database
+ if graceperiod > 0:
+ # graceperiod is in days, we need # of seconds
+ quiet_until = now + graceperiod * 24 * 60 * 60
+ if toadmin:
+ mlist.admin_responses[sender] = quiet_until
+ elif torequest:
+ mlist.request_responses[sender] = quiet_until
+ else:
+ mlist.postings_responses[sender] = quiet_until
diff --git a/Mailman/Handlers/SMTPDirect.py b/Mailman/Handlers/SMTPDirect.py
new file mode 100644
index 00000000..3033fdbd
--- /dev/null
+++ b/Mailman/Handlers/SMTPDirect.py
@@ -0,0 +1,349 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Local SMTP direct drop-off.
+
+This module delivers messages via SMTP to a locally specified daemon. This
+should be compatible with any modern SMTP server. It is expected that the MTA
+handles all final delivery. We have to play tricks so that the list object
+isn't locked while delivery occurs synchronously.
+
+Note: This file only handles single threaded delivery. See SMTPThreaded.py
+for a threaded implementation.
+"""
+
+import time
+import socket
+import smtplib
+from types import UnicodeType
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.Handlers import Decorate
+from Mailman.Logging.Syslog import syslog
+from Mailman.SafeDict import MsgSafeDict
+
+import email
+from email.Utils import formataddr
+from email.Header import Header
+from email.Charset import Charset
+
+DOT = '.'
+
+
+
+# Manage a connection to the SMTP server
+class Connection:
+ def __init__(self):
+ self.__connect()
+
+ def __connect(self):
+ self.__conn = smtplib.SMTP()
+ self.__conn.connect(mm_cfg.SMTPHOST, mm_cfg.SMTPPORT)
+ self.__numsessions = mm_cfg.SMTP_MAX_SESSIONS_PER_CONNECTION
+
+ def sendmail(self, envsender, recips, msgtext):
+ try:
+ results = self.__conn.sendmail(envsender, recips, msgtext)
+ except smtplib.SMTPException:
+ # For safety, reconnect
+ self.__conn.quit()
+ self.__connect()
+ # Let exceptions percolate up
+ raise
+ # Decrement the session counter, reconnecting if necessary
+ self.__numsessions -= 1
+ # By testing exactly for equality to 0, we automatically handle the
+ # case for SMTP_MAX_SESSIONS_PER_CONNECTION <= 0 meaning never close
+ # the connection. We won't worry about wraparound <wink>.
+ if self.__numsessions == 0:
+ self.__conn.quit()
+ self.__connect()
+ return results
+
+ def quit(self):
+ self.__conn.quit()
+
+
+
+def process(mlist, msg, msgdata):
+ recips = msgdata.get('recips')
+ if not recips:
+ # Nobody to deliver to!
+ return
+ # Calculate the non-VERP envelope sender.
+ envsender = msgdata.get('envsender')
+ if envsender is None:
+ if mlist:
+ envsender = mlist.GetBouncesEmail()
+ else:
+ envsender = Utils.get_site_email(extra='bounces')
+ # Time to split up the recipient list. If we're personalizing or VERPing
+ # then each chunk will have exactly one recipient. We'll then hand craft
+ # an envelope sender and stitch a message together in memory for each one
+ # separately. If we're not VERPing, then we'll chunkify based on
+ # SMTP_MAX_RCPTS. Note that most MTAs have a limit on the number of
+ # recipients they'll swallow in a single transaction.
+ deliveryfunc = None
+ if (not msgdata.has_key('personalize') or msgdata['personalize']) and (
+ msgdata.get('verp') or mlist.personalize):
+ chunks = [[recip] for recip in recips]
+ msgdata['personalize'] = 1
+ deliveryfunc = verpdeliver
+ elif mm_cfg.SMTP_MAX_RCPTS <= 0:
+ chunks = [recips]
+ else:
+ chunks = chunkify(recips, mm_cfg.SMTP_MAX_RCPTS)
+ # See if this is an unshunted message for which some were undelivered
+ if msgdata.has_key('undelivered'):
+ chunks = msgdata['undelivered']
+ # If we're doing bulk delivery, then we can stitch up the message now.
+ if deliveryfunc is None:
+ # Be sure never to decorate the message more than once!
+ if not msgdata.get('decorated'):
+ Decorate.process(mlist, msg, msgdata)
+ msgdata['decorated'] = 1
+ deliveryfunc = bulkdeliver
+ refused = {}
+ t0 = time.time()
+ # Open the initial connection
+ origrecips = msgdata['recips']
+ # `undelivered' is a copy of chunks that we pop from to do deliveries.
+ # This seems like a good tradeoff between robustness and resource
+ # utilization. If delivery really fails (i.e. qfiles/shunt type
+ # failures), then we'll pick up where we left off with `undelivered'.
+ # This means at worst, the last chunk for which delivery was attempted
+ # could get duplicates but not every one, and no recips should miss the
+ # message.
+ conn = Connection()
+ try:
+ msgdata['undelivered'] = chunks
+ while chunks:
+ chunk = chunks.pop()
+ msgdata['recips'] = chunk
+ try:
+ deliveryfunc(mlist, msg, msgdata, envsender, refused, conn)
+ except Exception:
+ # If /anything/ goes wrong, push the last chunk back on the
+ # undelivered list and re-raise the exception. We don't know
+ # how many of the last chunk might receive the message, so at
+ # worst, everyone in this chunk will get a duplicate. Sigh.
+ chunks.append(chunk)
+ raise
+ del msgdata['undelivered']
+ finally:
+ conn.quit()
+ msgdata['recips'] = origrecips
+ # Log the successful post
+ t1 = time.time()
+ d = MsgSafeDict(msg, {'time' : t1-t0,
+ # BAW: Urg. This seems inefficient.
+ 'size' : len(msg.as_string()),
+ '#recips' : len(recips),
+ '#refused': len(refused),
+ 'listname': mlist.internal_name(),
+ 'sender' : msg.get_sender(),
+ })
+ # We have to use the copy() method because extended call syntax requires a
+ # concrete dictionary object; it does not allow a generic mapping. It's
+ # still worthwhile doing the interpolation in syslog() because it'll catch
+ # any catastrophic exceptions due to bogus format strings.
+ if mm_cfg.SMTP_LOG_EVERY_MESSAGE:
+ syslog.write_ex(mm_cfg.SMTP_LOG_EVERY_MESSAGE[0],
+ mm_cfg.SMTP_LOG_EVERY_MESSAGE[1], kws=d)
+
+ if refused:
+ if mm_cfg.SMTP_LOG_REFUSED:
+ syslog.write_ex(mm_cfg.SMTP_LOG_REFUSED[0],
+ mm_cfg.SMTP_LOG_REFUSED[1], kws=d)
+
+ elif msgdata.get('tolist'):
+ # Log the successful post, but only if it really was a post to the
+ # mailing list. Don't log sends to the -owner, or -admin addrs.
+ # -request addrs should never get here. BAW: it may be useful to log
+ # the other messages, but in that case, we should probably have a
+ # separate configuration variable to control that.
+ if mm_cfg.SMTP_LOG_SUCCESS:
+ syslog.write_ex(mm_cfg.SMTP_LOG_SUCCESS[0],
+ mm_cfg.SMTP_LOG_SUCCESS[1], kws=d)
+
+ # Process any failed deliveries.
+ tempfailures = []
+ permfailures = []
+ for recip, (code, smtpmsg) in refused.items():
+ # DRUMS is an internet draft, but it says:
+ #
+ # [RFC-821] incorrectly listed the error where an SMTP server
+ # exhausts its implementation limit on the number of RCPT commands
+ # ("too many recipients") as having reply code 552. The correct
+ # reply code for this condition is 452. Clients SHOULD treat a 552
+ # code in this case as a temporary, rather than permanent failure
+ # so the logic below works.
+ #
+ if code >= 500 and code <> 552:
+ # A permanent failure
+ permfailures.append(recip)
+ else:
+ # Deal with persistent transient failures by queuing them up for
+ # future delivery. TBD: this could generate lots of log entries!
+ tempfailures.append(recip)
+ if mm_cfg.SMTP_LOG_EACH_FAILURE:
+ d.update({'recipient': recip,
+ 'failcode' : code,
+ 'failmsg' : smtpmsg})
+ syslog.write_ex(mm_cfg.SMTP_LOG_EACH_FAILURE[0],
+ mm_cfg.SMTP_LOG_EACH_FAILURE[1], kws=d)
+ # Return the results
+ if tempfailures or permfailures:
+ raise Errors.SomeRecipientsFailed(tempfailures, permfailures)
+
+
+
+def chunkify(recips, chunksize):
+ # First do a simple sort on top level domain. It probably doesn't buy us
+ # much to try to sort on MX record -- that's the MTA's job. We're just
+ # trying to avoid getting a max recips error. Split the chunks along
+ # these lines (as suggested originally by Chuq Von Rospach and slightly
+ # elaborated by BAW).
+ chunkmap = {'com': 1,
+ 'net': 2,
+ 'org': 2,
+ 'edu': 3,
+ 'us' : 3,
+ 'ca' : 3,
+ }
+ buckets = {}
+ for r in recips:
+ tld = None
+ i = r.rfind('.')
+ if i >= 0:
+ tld = r[i+1:]
+ bin = chunkmap.get(tld, 0)
+ bucket = buckets.get(bin, [])
+ bucket.append(r)
+ buckets[bin] = bucket
+ # Now start filling the chunks
+ chunks = []
+ currentchunk = []
+ chunklen = 0
+ for bin in buckets.values():
+ for r in bin:
+ currentchunk.append(r)
+ chunklen = chunklen + 1
+ if chunklen >= chunksize:
+ chunks.append(currentchunk)
+ currentchunk = []
+ chunklen = 0
+ if currentchunk:
+ chunks.append(currentchunk)
+ currentchunk = []
+ chunklen = 0
+ return chunks
+
+
+
+def verpdeliver(mlist, msg, msgdata, envsender, failures, conn):
+ for recip in msgdata['recips']:
+ # We now need to stitch together the message with its header and
+ # footer. If we're VERPIng, we have to calculate the envelope sender
+ # for each recipient. Note that the list of recipients must be of
+ # length 1.
+ #
+ # BAW: ezmlm includes the message number in the envelope, used when
+ # sending a notification to the user telling her how many messages
+ # they missed due to bouncing. Neat idea.
+ msgdata['recips'] = [recip]
+ # Make a copy of the message and decorate + delivery that
+ msgcopy = email.message_from_string(msg.as_string())
+ Decorate.process(mlist, msgcopy, msgdata)
+ # Calculate the envelope sender, which we may be VERPing
+ if msgdata.get('verp'):
+ bmailbox, bdomain = Utils.ParseEmail(envsender)
+ rmailbox, rdomain = Utils.ParseEmail(recip)
+ d = {'bounces': bmailbox,
+ 'mailbox': rmailbox,
+ 'host' : DOT.join(rdomain),
+ }
+ envsender = '%s@%s' % ((mm_cfg.VERP_FORMAT % d), DOT.join(bdomain))
+ if mlist.personalize == 2:
+ # When fully personalizing, we want the To address to point to the
+ # recipient, not to the mailing list
+ del msgcopy['to']
+ name = None
+ if mlist.isMember(recip):
+ name = mlist.getMemberName(recip)
+ if name:
+ # Convert the name to an email-safe representation. If the
+ # name is a byte string, convert it first to Unicode, given
+ # the character set of the member's language, replacing bad
+ # characters for which we can do nothing about. Once we have
+ # the name as Unicode, we can create a Header instance for it
+ # so that it's properly encoded for email transport.
+ charset = Utils.GetCharSet(mlist.getMemberLanguage(recip))
+ if charset == 'us-ascii':
+ # Since Header already tries both us-ascii and utf-8,
+ # let's add something a bit more useful.
+ charset = 'iso-8859-1'
+ charset = Charset(charset)
+ codec = charset.input_codec or 'ascii'
+ if not isinstance(name, UnicodeType):
+ name = unicode(name, codec, 'replace')
+ name = Header(name, charset).encode()
+ msgcopy['To'] = formataddr((name, recip))
+ else:
+ msgcopy['To'] = recip
+ # We can flag the mail as a duplicate for each member, if they've
+ # already received this message, as calculated by Message-ID. See
+ # AvoidDuplicates.py for details.
+ del msgcopy['x-mailman-copy']
+ if msgdata.get('add-dup-header', {}).has_key(recip):
+ msgcopy['X-Mailman-Copy'] = 'yes'
+ # For the final delivery stage, we can just bulk deliver to a party of
+ # one. ;)
+ bulkdeliver(mlist, msgcopy, msgdata, envsender, failures, conn)
+
+
+
+def bulkdeliver(mlist, msg, msgdata, envsender, failures, conn):
+ # Do some final cleanup of the message header. Start by blowing away
+ # any the Sender: and Errors-To: headers so remote MTAs won't be
+ # tempted to delivery bounces there instead of our envelope sender
+ del msg['sender']
+ del msg['errors-to']
+ msg['Sender'] = envsender
+ msg['Errors-To'] = envsender
+ # Get the plain, flattened text of the message, sans unixfrom
+ msgtext = msg.as_string()
+ refused = {}
+ recips = msgdata['recips']
+ try:
+ # Send the message
+ refused = conn.sendmail(envsender, recips, msgtext)
+ except smtplib.SMTPRecipientsRefused, e:
+ refused = e.recipients
+ # MTA not responding, or other socket problems, or any other kind of
+ # SMTPException. In that case, nothing got delivered
+ except (socket.error, smtplib.SMTPException), e:
+ # BAW: should this be configurable?
+ syslog('smtp', 'All recipients refused: %s', e)
+ # If the exception had an associated error code, use it, otherwise,
+ # fake it with a non-triggering exception code
+ errcode = getattr(e, 'smtp_code', -1)
+ errmsg = getattr(e, 'smtp_error', 'ignore')
+ for r in recips:
+ refused[r] = (errcode, errmsg)
+ failures.update(refused)
diff --git a/Mailman/Handlers/Scrubber.py b/Mailman/Handlers/Scrubber.py
new file mode 100644
index 00000000..5dabadf3
--- /dev/null
+++ b/Mailman/Handlers/Scrubber.py
@@ -0,0 +1,400 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Cleanse a message for archiving.
+"""
+
+import os
+import re
+import sha
+import time
+import errno
+import binascii
+import tempfile
+import mimetypes
+from cStringIO import StringIO
+from types import IntType
+
+from email.Utils import parsedate
+from email.Parser import HeaderParser
+from email.Generator import Generator
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import LockFile
+from Mailman import Message
+from Mailman.Errors import DiscardMessage
+from Mailman.i18n import _
+from Mailman.Logging.Syslog import syslog
+
+# Path characters for common platforms
+pre = re.compile(r'[/\\:]')
+# All other characters to strip out of Content-Disposition: filenames
+# (essentially anything that isn't an alphanum, dot, slash, or underscore.
+sre = re.compile(r'[^-\w.]')
+# Regexp to strip out leading dots
+dre = re.compile(r'^\.*')
+
+BR = '<br>\n'
+SPACE = ' '
+
+
+
+# We're using a subclass of the standard Generator because we want to suppress
+# headers in the subparts of multiparts. We use a hack -- the ctor argument
+# skipheaders to accomplish this. It's set to true for the outer Message
+# object, but false for all internal objects. We recognize that
+# sub-Generators will get created passing only mangle_from_ and maxheaderlen
+# to the ctors.
+#
+# This isn't perfect because we still get stuff like the multipart boundaries,
+# but see below for how we corrupt that to our nefarious goals.
+class ScrubberGenerator(Generator):
+ def __init__(self, outfp, mangle_from_=1, maxheaderlen=78, skipheaders=1):
+ Generator.__init__(self, outfp, mangle_from_=0)
+ self.__skipheaders = skipheaders
+
+ def _write_headers(self, msg):
+ if not self.__skipheaders:
+ Generator._write_headers(self, msg)
+
+
+def safe_strftime(fmt, floatsecs):
+ try:
+ return time.strftime(fmt, floatsecs)
+ except ValueError:
+ return None
+
+
+def calculate_attachments_dir(mlist, msg, msgdata):
+ # Calculate the directory that attachments for this message will go
+ # under. To avoid inode limitations, the scheme will be:
+ # archives/private/<listname>/attachments/YYYYMMDD/<msgid-hash>/<files>
+ # Start by calculating the date-based and msgid-hash components.
+ fmt = '%Y%m%d'
+ datestr = msg.get('Date')
+ if datestr:
+ now = parsedate(datestr)
+ else:
+ now = time.gmtime(msgdata.get('received_time', time.time()))
+ datedir = safe_strftime(fmt, now)
+ if not datedir:
+ datestr = msgdata.get('X-List-Received-Date')
+ if datestr:
+ datedir = safe_strftime(fmt, datestr)
+ if not datedir:
+ # What next? Unixfrom, I guess.
+ parts = msg.get_unixfrom().split()
+ try:
+ month = {'Jan':1, 'Feb':2, 'Mar':3, 'Apr':4, 'May':5, 'Jun':6,
+ 'Jul':7, 'Aug':8, 'Sep':9, 'Oct':10, 'Nov':11, 'Dec':12,
+ }.get(parts[3], 0)
+ day = int(parts[4])
+ year = int(parts[6])
+ except (IndexError, ValueError):
+ # Best we can do I think
+ month = day = year = 0
+ datedir = '%04d%02d%02d' % (year, month, day)
+ assert datedir
+ # As for the msgid hash, we'll base this part on the Message-ID: so that
+ # all attachments for the same message end up in the same directory (we'll
+ # uniquify the filenames in that directory as needed). We use the first 2
+ # and last 2 bytes of the SHA1 hash of the message id as the basis of the
+ # directory name. Clashes here don't really matter too much, and that
+ # still gives us a 32-bit space to work with.
+ msgid = msg['message-id']
+ if msgid is None:
+ msgid = msg['Message-ID'] = Utils.unique_message_id(mlist)
+ # We assume that the message id actually /is/ unique!
+ digest = sha.new(msgid).hexdigest()
+ return os.path.join('attachments', datedir, digest[:4] + digest[-4:])
+
+
+
+def process(mlist, msg, msgdata=None):
+ sanitize = mm_cfg.ARCHIVE_HTML_SANITIZER
+ outer = 1
+ if msgdata is None:
+ msgdata = {}
+ dir = calculate_attachments_dir(mlist, msg, msgdata)
+ charset = None
+ # Now walk over all subparts of this message and scrub out various types
+ for part in msg.walk():
+ ctype = part.get_type(part.get_default_type())
+ # If the part is text/plain, we leave it alone
+ if ctype == 'text/plain':
+ # We need to choose a charset for the scrubbed message, so we'll
+ # arbitrarily pick the charset of the first text/plain part in the
+ # message.
+ if charset is None:
+ charset = part.get_content_charset(charset)
+ elif ctype == 'text/html' and isinstance(sanitize, IntType):
+ if sanitize == 0:
+ if outer:
+ raise DiscardMessage
+ part.set_payload(_('HTML attachment scrubbed and removed'))
+ part.set_type('text/plain')
+ elif sanitize == 2:
+ # By leaving it alone, Pipermail will automatically escape it
+ pass
+ elif sanitize == 3:
+ # Pull it out as an attachment but leave it unescaped. This
+ # is dangerous, but perhaps useful for heavily moderated
+ # lists.
+ omask = os.umask(002)
+ try:
+ url = save_attachment(mlist, part, dir, filter_html=0)
+ finally:
+ os.umask(omask)
+ part.set_payload(_("""\
+An HTML attachment was scrubbed...
+URL: %(url)s
+"""))
+ part.set_type('text/plain')
+ else:
+ # HTML-escape it and store it as an attachment, but make it
+ # look a /little/ bit prettier. :(
+ payload = Utils.websafe(part.get_payload(decode=1))
+ # For whitespace in the margin, change spaces into
+ # non-breaking spaces, and tabs into 8 of those. Then use a
+ # mono-space font. Still looks hideous to me, but then I'd
+ # just as soon discard them.
+ def doreplace(s):
+ return s.replace(' ', '&nbsp;').replace('\t', '&nbsp'*8)
+ lines = [doreplace(s) for s in payload.split('\n')]
+ payload = '<tt>\n' + BR.join(lines) + '\n</tt>\n'
+ part.set_payload(payload)
+ # We're replacing the payload with the decoded payload so this
+ # will just get in the way.
+ del part['content-transfer-encoding']
+ omask = os.umask(002)
+ try:
+ url = save_attachment(mlist, part, dir, filter_html=0)
+ finally:
+ os.umask(omask)
+ part.set_payload(_("""\
+An HTML attachment was scrubbed...
+URL: %(url)s
+"""))
+ part.set_type('text/plain')
+ elif ctype == 'message/rfc822':
+ # This part contains a submessage, so it too needs scrubbing
+ submsg = part.get_payload(0)
+ omask = os.umask(002)
+ try:
+ url = save_attachment(mlist, part, dir)
+ finally:
+ os.umask(omask)
+ subject = submsg.get('subject', _('no subject'))
+ date = submsg.get('date', _('no date'))
+ who = submsg.get('from', _('unknown sender'))
+ size = len(str(submsg))
+ part.set_payload(_("""\
+An embedded message was scrubbed...
+From: %(who)s
+Subject: %(subject)s
+Date: %(date)s
+Size: %(size)s
+Url: %(url)s
+"""))
+ part.set_type('text/plain')
+ # If the message isn't a multipart, then we'll strip it out as an
+ # attachment that would have to be separately downloaded. Pipermail
+ # will transform the url into a hyperlink.
+ elif not part.is_multipart():
+ payload = part.get_payload()
+ ctype = part.get_type()
+ size = len(payload)
+ omask = os.umask(002)
+ try:
+ url = save_attachment(mlist, part, dir)
+ finally:
+ os.umask(omask)
+ desc = part.get('content-description', _('not available'))
+ filename = part.get_filename(_('not available'))
+ part.set_payload(_("""\
+A non-text attachment was scrubbed...
+Name: %(filename)s
+Type: %(ctype)s
+Size: %(size)d bytes
+Desc: %(desc)s
+Url : %(url)s
+"""))
+ part.set_type('text/plain')
+ outer = 0
+ # We still have to sanitize multipart messages to flat text because
+ # Pipermail can't handle messages with list payloads. This is a kludge;
+ # def (n) clever hack ;).
+ if msg.is_multipart():
+ # By default we take the charset of the first text/plain part in the
+ # message, but if there was none, we'll use the list's preferred
+ # language's charset.
+ if charset is None:
+ charset = Utils.GetCharSet(mlist.preferred_language)
+ # We now want to concatenate all the parts which have been scrubbed to
+ # text/plain, into a single text/plain payload. We need to make sure
+ # all the characters in the concatenated string are in the same
+ # encoding, so we'll use the 'replace' key in the coercion call.
+ # BAW: Martin's original patch suggested we might want to try
+ # generalizing to utf-8, and that's probably a good idea (eventually).
+ text = []
+ for part in msg.get_payload():
+ # All parts should be scrubbed to text/plain by now.
+ partctype = part.get_content_type()
+ if partctype <> 'text/plain':
+ text.append(_('Skipped content of type %(partctype)s'))
+ continue
+ try:
+ t = part.get_payload(decode=1)
+ except binascii.Error:
+ t = part.get_payload()
+ partcharset = part.get_charset()
+ if partcharset and partcharset <> charset:
+ try:
+ t = unicode(t, partcharset, 'replace')
+ # Should use HTML-Escape, or try generalizing to UTF-8
+ t = t.encode(charset, 'replace')
+ except UnicodeError:
+ # Replace funny characters
+ t = unicode(t, 'ascii', 'replace').encode('ascii')
+ text.append(t)
+ # Now join the text and set the payload
+ sep = _('-------------- next part --------------\n')
+ msg.set_payload(sep.join(text), charset)
+ msg.set_type('text/plain')
+ del msg['content-transfer-encoding']
+ msg.add_header('Content-Transfer-Encoding', '8bit')
+ return msg
+
+
+
+def makedirs(dir):
+ # Create all the directories to store this attachment in
+ try:
+ os.makedirs(dir, 02775)
+ except OSError, e:
+ if e.errno <> errno.EEXIST: raise
+ # Unfortunately, FreeBSD seems to be broken in that it doesn't honor the
+ # mode arg of mkdir().
+ def twiddle(arg, dirname, names):
+ os.chmod(dirname, 02775)
+ os.path.walk(dir, twiddle, None)
+
+
+
+def save_attachment(mlist, msg, dir, filter_html=1):
+ fsdir = os.path.join(mlist.archive_dir(), dir)
+ makedirs(fsdir)
+ # Figure out the attachment type and get the decoded data
+ decodedpayload = msg.get_payload(decode=1)
+ # BAW: mimetypes ought to handle non-standard, but commonly found types,
+ # e.g. image/jpg (should be image/jpeg). For now we just store such
+ # things as application/octet-streams since that seems the safest.
+ ext = mimetypes.guess_extension(msg.get_type())
+ if not ext:
+ # We don't know what it is, so assume it's just a shapeless
+ # application/octet-stream, unless the Content-Type: is
+ # message/rfc822, in which case we know we'll coerce the type to
+ # text/plain below.
+ if msg.get_type() == 'message/rfc822':
+ ext = '.txt'
+ else:
+ ext = '.bin'
+ path = None
+ # We need a lock to calculate the next attachment number
+ lockfile = os.path.join(fsdir, 'attachments.lock')
+ lock = LockFile.LockFile(lockfile)
+ lock.lock()
+ try:
+ # Now base the filename on what's in the attachment, uniquifying it if
+ # necessary.
+ filename = msg.get_filename()
+ if not filename:
+ filebase = 'attachment'
+ else:
+ # Sanitize the filename given in the message headers
+ parts = pre.split(filename)
+ filename = parts[-1]
+ # Strip off leading dots
+ filename = dre.sub('', filename)
+ # Allow only alphanumerics, dash, underscore, and dot
+ filename = sre.sub('', filename)
+ # If the filename's extension doesn't match the type we guessed,
+ # which one should we go with? For now, let's go with the one we
+ # guessed so attachments can't lie about their type. Also, if the
+ # filename /has/ no extension, then tack on the one we guessed.
+ filebase, ignore = os.path.splitext(filename)
+ # Now we're looking for a unique name for this file on the file
+ # system. If msgdir/filebase.ext isn't unique, we'll add a counter
+ # after filebase, e.g. msgdir/filebase-cnt.ext
+ counter = 0
+ extra = ''
+ while 1:
+ path = os.path.join(fsdir, filebase + extra + ext)
+ # Generally it is not a good idea to test for file existance
+ # before just trying to create it, but the alternatives aren't
+ # wonderful (i.e. os.open(..., O_CREAT | O_EXCL) isn't
+ # NFS-safe). Besides, we have an exclusive lock now, so we're
+ # guaranteed that no other process will be racing with us.
+ if os.path.exists(path):
+ counter += 1
+ extra = '-%04d' % counter
+ else:
+ break
+ finally:
+ lock.unlock()
+ # `path' now contains the unique filename for the attachment. There's
+ # just one more step we need to do. If the part is text/html and
+ # ARCHIVE_HTML_SANITIZER is a string (which it must be or we wouldn't be
+ # here), then send the attachment through the filter program for
+ # sanitization
+ if filter_html and msg.get_type() == 'text/html':
+ base, ext = os.path.splitext(path)
+ tmppath = base + '-tmp' + ext
+ fp = open(tmppath, 'w')
+ try:
+ fp.write(decodedpayload)
+ fp.close()
+ cmd = mm_cfg.ARCHIVE_HTML_SANITIZER % {'filename' : tmppath}
+ progfp = os.popen(cmd, 'r')
+ decodedpayload = progfp.read()
+ status = progfp.close()
+ if status:
+ syslog('error',
+ 'HTML sanitizer exited with non-zero status: %s',
+ status)
+ finally:
+ os.unlink(tmppath)
+ # BAW: Since we've now sanitized the document, it should be plain
+ # text. Blarg, we really want the sanitizer to tell us what the type
+ # if the return data is. :(
+ ext = '.txt'
+ path = base + '.txt'
+ # Is it a message/rfc822 attachment?
+ elif msg.get_type() == 'message/rfc822':
+ submsg = msg.get_payload()
+ # BAW: I'm sure we can eventually do better than this. :(
+ decodedpayload = Utils.websafe(str(submsg))
+ fp = open(path, 'w')
+ fp.write(decodedpayload)
+ fp.close()
+ # Now calculate the url
+ baseurl = mlist.GetBaseArchiveURL()
+ # Private archives will likely have a trailing slash. Normalize.
+ if baseurl[-1] <> '/':
+ baseurl += '/'
+ url = baseurl + '%s/%s%s%s' % (dir, filebase, extra, ext)
+ return url
diff --git a/Mailman/Handlers/Sendmail.py b/Mailman/Handlers/Sendmail.py
new file mode 100644
index 00000000..8bd88697
--- /dev/null
+++ b/Mailman/Handlers/Sendmail.py
@@ -0,0 +1,116 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Deliver a message via command-line drop-off.
+
+WARNING WARNING WARNING: This module is provided for example purposes only.
+It should not be used in a production environment for reasons described
+below. Because of this, you must explicitly enable it with by editing the
+code. See the WARN section in the process() function.
+
+This module delivers the message via the command line interface to the
+sendmail program. It should work for sendmail clones like Postfix. It is
+expected that sendmail handles final delivery, message queueing, etc. The
+recipient list is only trivially split so that the command line is less than
+about 3k in size.
+
+SECURITY WARNING: Because this module uses os.popen(), it goes through the
+shell. This module does not scan the arguments for potential exploits and so
+it should be considered unsafe for production use. For performance reasons,
+it's not recommended either -- use the SMTPDirect delivery module instead,
+even if you're using the sendmail MTA.
+
+DUPLICATES WARNING: Using this module can cause duplicates to be delivered to
+your membership, depending on your MTA! E.g. It is known that if you're using
+the sendmail MTA, and if a message contains a single dot on a line by itself,
+your list members will receive many duplicates.
+"""
+
+import string
+import os
+
+from Mailman import mm_cfg
+from Mailman import Errors
+from Mailman.Logging.Syslog import syslog
+
+MAX_CMDLINE = 3000
+
+
+
+def process(mlist, msg, msgdata):
+ """Process the message object for the given list.
+
+ The message object is an instance of Mailman.Message and must be fully
+ prepared for delivery (i.e. all the appropriate headers must be set). The
+ message object can have the following attributes:
+
+ recips - the list of recipients for the message (required)
+
+ This function processes the message by handing off the delivery of the
+ message to a sendmail (or sendmail clone) program. It can raise a
+ SendmailHandlerError if an error status was returned by the sendmail
+ program.
+
+ """
+ # WARN: If you've read the warnings above and /still/ insist on using this
+ # module, you must comment out the following line. I still recommend you
+ # don't do this!
+ assert 0, 'Use of the Sendmail.py delivery module is highly discouraged'
+ recips = msgdata.get('recips')
+ if not recips:
+ # Nobody to deliver to!
+ return
+ # Use -f to set the envelope sender
+ cmd = mm_cfg.SENDMAIL_CMD + ' -f ' + mlist.GetBouncesEmail() + ' '
+ # make sure the command line is of a manageable size
+ recipchunks = []
+ currentchunk = []
+ chunklen = 0
+ for r in recips:
+ currentchunk.append(r)
+ chunklen = chunklen + len(r) + 1
+ if chunklen > MAX_CMDLINE:
+ recipchunks.append(string.join(currentchunk))
+ currentchunk = []
+ chunklen = 0
+ # pick up the last one
+ if chunklen:
+ recipchunks.append(string.join(currentchunk))
+ # get all the lines of the message, since we're going to do this over and
+ # over again
+ msgtext = str(msg)
+ msglen = len(msgtext)
+ # cycle through all chunks
+ failedrecips = []
+ for chunk in recipchunks:
+ # TBD: SECURITY ALERT. This invokes the shell!
+ fp = os.popen(cmd + chunk, 'w')
+ fp.write(msgtext)
+ status = fp.close()
+ if status:
+ errcode = (status & 0xff00) >> 8
+ syslog('post', 'post to %s from %s, size=%d, failure=%d',
+ mlist.internal_name(), msg.get_sender(),
+ msglen, errcode)
+ # TBD: can we do better than this? What if only one recipient out
+ # of the entire chunk failed?
+ failedrecips.append(chunk)
+ # Log the successful post
+ syslog('post', 'post to %s from %s, size=%d, success',
+ mlist.internal_name(), msg.get_sender(), msglen)
+ if failedrecips:
+ msgdata['recips'] = failedrecips
+ raise Errors.SomeRecipientsFailed
diff --git a/Mailman/Handlers/SpamDetect.py b/Mailman/Handlers/SpamDetect.py
new file mode 100644
index 00000000..7597b9e9
--- /dev/null
+++ b/Mailman/Handlers/SpamDetect.py
@@ -0,0 +1,50 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Do more detailed spam detection.
+
+This module hard codes site wide spam detection. By hacking the
+KNOWN_SPAMMERS variable, you can set up more regular expression matches
+against message headers. If spam is detected the message is discarded
+immediately.
+
+TBD: This needs to be made more configurable and robust.
+"""
+
+import re
+
+from Mailman import mm_cfg
+from Mailman import Errors
+
+
+
+class SpamDetected(Errors.DiscardMessage):
+ """The message contains known spam"""
+
+
+
+def process(mlist, msg, msgdata):
+ if msgdata.get('approved'):
+ return
+ for header, regex in mm_cfg.KNOWN_SPAMMERS:
+ cre = re.compile(regex, re.IGNORECASE)
+ value = msg[header]
+ if not value:
+ continue
+ mo = cre.search(value)
+ if mo:
+ # we've detected spam, so throw the message away
+ raise SpamDetected
diff --git a/Mailman/Handlers/Tagger.py b/Mailman/Handlers/Tagger.py
new file mode 100644
index 00000000..46a03f67
--- /dev/null
+++ b/Mailman/Handlers/Tagger.py
@@ -0,0 +1,156 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Extract topics from the original mail message.
+"""
+
+import re
+import email
+import email.Errors
+import email.Iterators
+import email.Parser
+
+from Mailman.Logging.Syslog import syslog
+
+CRNL = '\r\n'
+EMPTYSTRING = ''
+NLTAB = '\n\t'
+
+
+
+def process(mlist, msg, msgdata):
+ if not mlist.topics_enabled:
+ return
+ # Extract the Subject:, Keywords:, and possibly body text
+ matchlines = []
+ matchlines.append(msg.get('subject', None))
+ matchlines.append(msg.get('keywords', None))
+ if mlist.topics_bodylines_limit == 0:
+ # Don't scan any body lines
+ pass
+ elif mlist.topics_bodylines_limit < 0:
+ # Scan all body lines
+ matchlines.extend(scanbody(msg))
+ else:
+ # Scan just some of the body lines
+ matchlines.extend(scanbody(msg, mlist.topics_bodylines_limit))
+ matchlines = filter(None, matchlines)
+ # For each regular expression in the topics list, see if any of the lines
+ # of interest from the message match the regexp. If so, the message gets
+ # added to the specific topics bucket.
+ hits = {}
+ for name, pattern, desc, emptyflag in mlist.topics:
+ cre = re.compile(pattern, re.IGNORECASE | re.VERBOSE)
+ for line in matchlines:
+ if cre.search(line):
+ hits[name] = 1
+ break
+ if hits:
+ msgdata['topichits'] = hits.keys()
+ msg['X-Topics'] = NLTAB.join(hits.keys())
+
+
+
+def scanbody(msg, numlines=None):
+ # We only scan the body of the message if it is of MIME type text/plain,
+ # or if the outer type is multipart/alternative and there is a text/plain
+ # part. Anything else, and the body is ignored for header-scan purposes.
+ found = None
+ if msg.get_type('text/plain') == 'text/plain':
+ found = msg
+ elif msg.is_multipart() and msg.get_type() == 'multipart/alternative':
+ for found in msg.get_payload():
+ if found.get_type('text/plain') == 'text/plain':
+ break
+ else:
+ found = None
+ if not found:
+ return []
+ # Now that we have a Message object that meets our criteria, let's extract
+ # the first numlines of body text.
+ lines = []
+ lineno = 0
+ reader = list(email.Iterators.body_line_iterator(msg))
+ while numlines is None or lineno < numlines:
+ try:
+ line = reader.pop(0)
+ except IndexError:
+ break
+ # Blank lines don't count
+ if not line.strip():
+ continue
+ lineno += 1
+ lines.append(line)
+ # Concatenate those body text lines with newlines, and then create a new
+ # message object from those lines.
+ p = _ForgivingParser()
+ msg = p.parsestr(EMPTYSTRING.join(lines))
+ return msg.get_all('subject', []) + msg.get_all('keywords', [])
+
+
+
+class _ForgivingParser(email.Parser.HeaderParser):
+ # Be a little more forgiving about non-header/continuation lines, since
+ # we'll just read as much as we can from "header-like" lines in the body.
+ #
+ # BAW: WIBNI we didn't have to cut-n-paste this whole thing just to
+ # specialize the way it returns?
+ def _parseheaders(self, container, fp):
+ # Parse the headers, returning a list of header/value pairs. None as
+ # the header means the Unix-From header.
+ lastheader = ''
+ lastvalue = []
+ lineno = 0
+ while 1:
+ # Don't strip the line before we test for the end condition,
+ # because whitespace-only header lines are RFC compliant
+ # continuation lines.
+ line = fp.readline()
+ if not line:
+ break
+ line = line.splitlines()[0]
+ if not line:
+ break
+ # Ignore the trailing newline
+ lineno += 1
+ # Check for initial Unix From_ line
+ if line.startswith('From '):
+ if lineno == 1:
+ container.set_unixfrom(line)
+ continue
+ else:
+ break
+ # Header continuation line
+ if line[0] in ' \t':
+ if not lastheader:
+ break
+ lastvalue.append(line)
+ continue
+ # Normal, non-continuation header. BAW: this should check to make
+ # sure it's a legal header, e.g. doesn't contain spaces. Also, we
+ # should expose the header matching algorithm in the API, and
+ # allow for a non-strict parsing mode (that ignores the line
+ # instead of raising the exception).
+ i = line.find(':')
+ if i < 0:
+ break
+ if lastheader:
+ container[lastheader] = NLTAB.join(lastvalue)
+ lastheader = line[:i]
+ lastvalue = [line[i+1:].lstrip()]
+ # Make sure we retain the last header
+ if lastheader:
+ container[lastheader] = NLTAB.join(lastvalue)
diff --git a/Mailman/Handlers/ToArchive.py b/Mailman/Handlers/ToArchive.py
new file mode 100644
index 00000000..dc19f963
--- /dev/null
+++ b/Mailman/Handlers/ToArchive.py
@@ -0,0 +1,39 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Add the message to the archives."""
+
+import time
+from cStringIO import StringIO
+
+from Mailman import mm_cfg
+from Mailman.Queue.sbcache import get_switchboard
+
+
+
+def process(mlist, msg, msgdata):
+ # short circuits
+ if msgdata.get('isdigest') or not mlist.archive:
+ return
+ # Common practice seems to favor "X-No-Archive: yes". No other value for
+ # this header seems to make sense, so we'll just test for it's presence.
+ # I'm keeping "X-Archive: no" for backwards compatibility.
+ if msg.has_key('x-no-archive') or msg.get('x-archive', '').lower() == 'no':
+ return
+ # Send the message to the archiver queue
+ archq = get_switchboard(mm_cfg.ARCHQUEUE_DIR)
+ # Send the message to the queue
+ archq.enqueue(msg, msgdata)
diff --git a/Mailman/Handlers/ToDigest.py b/Mailman/Handlers/ToDigest.py
new file mode 100644
index 00000000..d735cd69
--- /dev/null
+++ b/Mailman/Handlers/ToDigest.py
@@ -0,0 +1,351 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Add the message to the list's current digest and possibly send it.
+"""
+
+# Messages are accumulated to a Unix mailbox compatible file containing all
+# the messages destined for the digest. This file must be parsable by the
+# mailbox.UnixMailbox class (i.e. it must be ^From_ quoted).
+#
+# When the file reaches the size threshold, it is moved to the qfiles/digest
+# directory and the DigestRunner will craft the MIME, rfc1153, and
+# (eventually) URL-subject linked digests from the mbox.
+
+import os
+import re
+import time
+from types import ListType
+from cStringIO import StringIO
+
+from email.Parser import Parser
+from email.Generator import Generator
+from email.MIMEBase import MIMEBase
+from email.MIMEText import MIMEText
+from email.MIMEMessage import MIMEMessage
+from email.Utils import getaddresses
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman import i18n
+from Mailman.MemberAdaptor import ENABLED
+from Mailman.Handlers.Decorate import decorate
+from Mailman.Queue.sbcache import get_switchboard
+from Mailman.Mailbox import Mailbox
+
+_ = i18n._
+
+
+# rfc1153 says we should keep only these headers, and present them in this
+# exact order.
+KEEP = ['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, but they don't need to be kept from
+ # the original message: Message
+ ]
+
+
+
+def process(mlist, msg, msgdata):
+ # Short circuit non-digestable lists.
+ if not mlist.digestable or msgdata.get('isdigest'):
+ return
+ mboxfile = os.path.join(mlist.fullpath(), 'digest.mbox')
+ omask = os.umask(007)
+ try:
+ mboxfp = open(mboxfile, 'a+')
+ finally:
+ os.umask(omask)
+ g = Generator(mboxfp)
+ g(msg, unixfrom=1)
+ # Calculate the current size of the accumulation file. This will not tell
+ # us exactly how big the MIME, rfc1153, or any other generated digest
+ # message will be, but it's the most easily available metric to decide
+ # whether the size threshold has been reached.
+ mboxfp.flush()
+ size = os.path.getsize(mboxfile)
+ if size / 1024.0 >= mlist.digest_size_threshhold:
+ # This is a bit of a kludge to get the mbox file moved to the digest
+ # queue directory.
+ mboxfp.seek(0)
+ send_digests(mlist, mboxfp)
+ os.unlink(mboxfile)
+ mboxfp.close()
+
+
+
+def send_digests(mlist, mboxfp):
+ # Set the digest volume and time
+ if mlist.digest_last_sent_at:
+ bump = 0
+ # See if we should bump the digest volume number
+ timetup = time.localtime(mlist.digest_last_sent_at)
+ now = time.localtime(time.time())
+ freq = mlist.digest_volume_frequency
+ if freq == 0 and timetup[0] < now[0]:
+ # Yearly
+ bump = 1
+ elif freq == 1 and timetup[1] <> now[1]:
+ # Monthly, but we take a cheap way to calculate this. We assume
+ # that the clock isn't going to be reset backwards.
+ bump = 1
+ elif freq == 2 and (timetup[1] % 4 <> now[1] % 4):
+ # Quarterly, same caveat
+ bump = 1
+ elif freq == 3:
+ # Once again, take a cheap way of calculating this
+ weeknum_last = int(time.strftime('%W', timetup))
+ weeknum_now = int(time.strftime('%W', now))
+ if weeknum_now > weeknum_last or timetup[0] > now[0]:
+ bump = 1
+ elif freq == 4 and timetup[7] <> now[7]:
+ # Daily
+ bump = 1
+ if bump:
+ mlist.bump_digest_volume()
+ mlist.digest_last_sent_at = time.time()
+ # Wrapper around actually digest crafter to set up the language context
+ # properly. All digests are translated to the list's preferred language.
+ otranslation = i18n.get_translation()
+ i18n.set_language(mlist.preferred_language)
+ try:
+ send_i18n_digests(mlist, mboxfp)
+ finally:
+ i18n.set_translation(otranslation)
+
+
+
+def send_i18n_digests(mlist, mboxfp):
+ mbox = Mailbox(mboxfp)
+ # Prepare common information
+ lang = mlist.preferred_language
+ realname = mlist.real_name
+ volume = mlist.volume
+ issue = mlist.next_digest_number
+ digestid = _('%(realname)s Digest, Vol %(volume)d, Issue %(issue)d')
+ # Set things up for the MIME digest. Only headers not added by
+ # CookHeaders need be added here.
+ mimemsg = Message.Message()
+ mimemsg['Content-Type'] = 'multipart/mixed'
+ mimemsg['MIME-Version'] = '1.0'
+ mimemsg['From'] = mlist.GetRequestEmail()
+ mimemsg['Subject'] = digestid
+ mimemsg['To'] = mlist.GetListEmail()
+ mimemsg['Reply-To'] = mlist.GetListEmail()
+ # Set things up for the rfc1153 digest
+ plainmsg = StringIO()
+ rfc1153msg = Message.Message()
+ rfc1153msg['From'] = mlist.GetRequestEmail()
+ rfc1153msg['Subject'] = digestid
+ rfc1153msg['To'] = mlist.GetListEmail()
+ rfc1153msg['Reply-To'] = mlist.GetListEmail()
+ separator70 = '-' * 70
+ separator30 = '-' * 30
+ # In the rfc1153 digest, the masthead contains the digest boilerplate plus
+ # any digest header. In the MIME digests, the masthead and digest header
+ # are separate MIME subobjects. In either case, it's the first thing in
+ # the digest, and we can calculate it now, so go ahead and add it now.
+ mastheadtxt = Utils.maketext(
+ 'masthead.txt',
+ {'real_name' : mlist.real_name,
+ 'got_list_email': mlist.GetListEmail(),
+ 'got_listinfo_url': mlist.GetScriptURL('listinfo', absolute=1),
+ 'got_request_email': mlist.GetRequestEmail(),
+ 'got_owner_email': mlist.GetOwnerEmail(),
+ }, mlist=mlist)
+ # MIME
+ masthead = MIMEText(mastheadtxt, _charset=Utils.GetCharSet(lang))
+ masthead['Content-Description'] = digestid
+ mimemsg.attach(masthead)
+ # rfc1153
+ print >> plainmsg, mastheadtxt
+ print >> plainmsg
+ # Now add the optional digest header
+ if mlist.digest_header:
+ headertxt = decorate(mlist, mlist.digest_header, _('digest header'))
+ # MIME
+ header = MIMEText(headertxt)
+ header['Content-Description'] = _('Digest Header')
+ mimemsg.attach(header)
+ # rfc1153
+ print >> plainmsg, headertxt
+ print >> plainmsg
+ # Now we have to cruise through all the messages accumulated in the
+ # mailbox file. We can't add these messages to the plainmsg and mimemsg
+ # yet, because we first have to calculate the table of contents
+ # (i.e. grok out all the Subjects). Store the messages in a list until
+ # we're ready for them.
+ #
+ # Meanwhile prepare things for the table of contents
+ toc = StringIO()
+ print >> toc, _("Today's Topics:\n")
+ # Now cruise through all the messages in the mailbox of digest messages,
+ # building the MIME payload and core of the rfc1153 digest. We'll also
+ # accumulate Subject: headers and authors for the table-of-contents.
+ messages = []
+ msgcount = 0
+ msg = mbox.next()
+ while msg is not None:
+ if msg == '':
+ # It was an unparseable message
+ msg = mbox.next()
+ msgcount += 1
+ messages.append(msg)
+ # Get the Subject header
+ subject = msg.get('subject', _('(no subject)'))
+ # Don't include the redundant subject prefix in the toc
+ mo = re.match('(re:? *)?(%s)' % re.escape(mlist.subject_prefix),
+ subject, re.IGNORECASE)
+ if mo:
+ subject = subject[:mo.start(2)] + subject[mo.end(2):]
+ addresses = getaddresses([msg.get('From', '')])
+ username = ''
+ # Take only the first author we find
+ if type(addresses) is ListType and len(addresses) > 0:
+ username = addresses[0][0]
+ if username:
+ username = ' (%s)' % username
+ # Wrap the toc subject line
+ wrapped = Utils.wrap('%2d. %s' % (msgcount, subject))
+ # Split by lines and see if the username can fit on the last line
+ slines = wrapped.split('\n')
+ if len(slines[-1]) + len(username) > 70:
+ slines.append(username)
+ else:
+ slines[-1] += username
+ # Add this subject to the accumulating topics
+ first = 1
+ for line in slines:
+ if first:
+ print >> toc, ' ', line
+ first = 0
+ else:
+ print >> toc, ' ', line
+ # We do not want all the headers of the original message to leak
+ # through in the digest messages. For simplicity, we'll leave the
+ # same set of headers in both digests, i.e. those required in rfc1153
+ # plus a couple of other useful ones. We also need to reorder the
+ # headers according to rfc1153.
+ keeper = {}
+ for keep in KEEP:
+ keeper[keep] = msg.get_all(keep, [])
+ # Now remove all unkempt headers :)
+ for header in msg.keys():
+ del msg[header]
+ # And add back the kept header in the rfc1153 designated order
+ for keep in KEEP:
+ for field in keeper[keep]:
+ msg[keep] = field
+ # And a bit of extra stuff
+ msg['Message'] = `msgcount`
+ # Get the next message in the digest mailbox
+ msg = mbox.next()
+ # Now we're finished with all the messages in the digest. First do some
+ # sanity checking and then on to adding the toc.
+ if msgcount == 0:
+ # Why did we even get here?
+ return
+ toctext = toc.getvalue()
+ # MIME
+ tocpart = MIMEText(toctext)
+ tocpart['Content-Description']= _("Today's Topics (%(msgcount)d messages)")
+ mimemsg.attach(tocpart)
+ # rfc1153
+ print >> plainmsg, toctext
+ print >> plainmsg
+ # For rfc1153 digests, we now need the standard separator
+ print >> plainmsg, separator70
+ print >> plainmsg
+ # Now go through and add each message
+ mimedigest = MIMEBase('multipart', 'digest')
+ mimemsg.attach(mimedigest)
+ first = 1
+ for msg in messages:
+ # MIME
+ mimedigest.attach(MIMEMessage(msg))
+ # rfc1153
+ if first:
+ first = 0
+ else:
+ print >> plainmsg, separator30
+ print >> plainmsg
+ g = Generator(plainmsg)
+ g(msg, unixfrom=0)
+ # Now add the footer
+ if mlist.digest_footer:
+ footertxt = decorate(mlist, mlist.digest_footer, _('digest footer'))
+ # MIME
+ footer = MIMEText(footertxt)
+ footer['Content-Description'] = _('Digest Footer')
+ mimemsg.attach(footer)
+ # rfc1153
+ # BAW: This is not strictly conformant rfc1153. The trailer is only
+ # supposed to contain two lines, i.e. the "End of ... Digest" line and
+ # the row of asterisks. If this screws up MUAs, the solution is to
+ # add the footer as the last message in the rfc1153 digest. I just
+ # hate the way that VM does that and I think it's confusing to users,
+ # so don't do it unless there's a clamor.
+ print >> plainmsg, separator30
+ print >> plainmsg
+ print >> plainmsg, footertxt
+ print >> plainmsg
+ # Do the last bit of stuff for each digest type
+ signoff = _('End of ') + digestid
+ # MIME
+ # BAW: This stuff is outside the normal MIME goo, and it's what the old
+ # MIME digester did. No one seemed to complain, probably because you
+ # won't see it in an MUA that can't display the raw message. We've never
+ # got complaints before, but if we do, just wax this. It's primarily
+ # included for (marginally useful) backwards compatibility.
+ mimemsg.postamble = signoff
+ # rfc1153
+ print >> plainmsg, signoff
+ print >> plainmsg, '*' * len(signoff)
+ # Do our final bit of housekeeping, and then send each message to the
+ # outgoing queue for delivery.
+ mlist.next_digest_number += 1
+ virginq = get_switchboard(mm_cfg.VIRGINQUEUE_DIR)
+ # Calculate the recipients lists
+ plainrecips = []
+ mimerecips = []
+ drecips = mlist.getDigestMemberKeys() + mlist.one_last_digest.keys()
+ for user in mlist.getMemberCPAddresses(drecips):
+ # user might be None if someone who toggled off digest delivery
+ # subsequently unsubscribed from the mailing list. Also, filter out
+ # folks who have disabled delivery.
+ if user is None or mlist.getDeliveryStatus(user) <> ENABLED:
+ continue
+ # Otherwise, decide whether they get MIME or RFC 1153 digests
+ if mlist.getMemberOption(user, mm_cfg.DisableMime):
+ plainrecips.append(user)
+ else:
+ mimerecips.append(user)
+ # Zap this since we're now delivering the last digest to these folks.
+ mlist.one_last_digest.clear()
+ # MIME
+ virginq.enqueue(mimemsg,
+ recips=mimerecips,
+ listname=mlist.internal_name(),
+ isdigest=1)
+ # rfc1153
+ rfc1153msg.set_payload(plainmsg.getvalue())
+ virginq.enqueue(rfc1153msg,
+ recips=plainrecips,
+ listname=mlist.internal_name(),
+ isdigest=1)
diff --git a/Mailman/Handlers/ToOutgoing.py b/Mailman/Handlers/ToOutgoing.py
new file mode 100644
index 00000000..4732b984
--- /dev/null
+++ b/Mailman/Handlers/ToOutgoing.py
@@ -0,0 +1,55 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Re-queue the message to the outgoing queue.
+
+This module is only for use by the IncomingRunner for delivering messages
+posted to the list membership. Anything else that needs to go out to some
+recipient should just be placed in the out queue directly.
+"""
+
+from Mailman import mm_cfg
+from Mailman.Queue.sbcache import get_switchboard
+
+
+
+def process(mlist, msg, msgdata):
+ interval = mm_cfg.VERP_DELIVERY_INTERVAL
+ # Should we VERP this message? If personalization is enabled for this
+ # list and VERP_PERSONALIZED_DELIVERIES is true, then yes we VERP it.
+ # Also, if personalization is /not/ enabled, but VERP_DELIVERY_INTERVAL is
+ # set (and we've hit this interval), then again, this message should be
+ # VERPed. Otherwise, no.
+ #
+ # Note that the verp flag may already be set, e.g. by mailpasswds using
+ # VERP_PASSWORD_REMINDERS. Preserve any existing verp flag.
+ if msgdata.has_key('verp'):
+ pass
+ elif mlist.personalize:
+ if mm_cfg.VERP_PERSONALIZED_DELIVERIES:
+ msgdata['verp'] = 1
+ elif interval == 0:
+ # Never VERP
+ pass
+ elif interval == 1:
+ # VERP every time
+ msgdata['verp'] = 1
+ else:
+ # VERP every `inteval' number of times
+ msgdata['verp'] = not int(mlist.post_id) % interval
+ # And now drop the message in qfiles/out
+ outq = get_switchboard(mm_cfg.OUTQUEUE_DIR)
+ outq.enqueue(msg, msgdata, listname=mlist.internal_name())
diff --git a/Mailman/Handlers/ToUsenet.py b/Mailman/Handlers/ToUsenet.py
new file mode 100644
index 00000000..2d6755b7
--- /dev/null
+++ b/Mailman/Handlers/ToUsenet.py
@@ -0,0 +1,44 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Move the message to the mail->news queue."""
+
+from Mailman import mm_cfg
+from Mailman.Queue.sbcache import get_switchboard
+from Mailman.Logging.Syslog import syslog
+
+COMMASPACE = ', '
+
+
+def process(mlist, msg, msgdata):
+ # short circuits
+ if not mlist.gateway_to_news or \
+ msgdata.get('isdigest') or \
+ msgdata.get('fromusenet'):
+ return
+ # sanity checks
+ error = []
+ if not mlist.linked_newsgroup:
+ error.append('no newsgroup')
+ if not mlist.nntp_host:
+ error.append('no NNTP host')
+ if error:
+ syslog('error', 'NNTP gateway improperly configured: %s',
+ COMMASPACE.join(error))
+ return
+ # Put the message in the news runner's queue
+ newsq = get_switchboard(mm_cfg.NEWSQUEUE_DIR)
+ newsq.enqueue(msg, msgdata, listname=mlist.internal_name())
diff --git a/Mailman/Handlers/__init__.py b/Mailman/Handlers/__init__.py
new file mode 100644
index 00000000..2cbbabb1
--- /dev/null
+++ b/Mailman/Handlers/__init__.py
@@ -0,0 +1,15 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
diff --git a/Mailman/ListAdmin.py b/Mailman/ListAdmin.py
new file mode 100644
index 00000000..82eedc80
--- /dev/null
+++ b/Mailman/ListAdmin.py
@@ -0,0 +1,579 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Mixin class for MailList which handles administrative requests.
+
+Two types of admin requests are currently supported: adding members to a
+closed or semi-closed list, and moderated posts.
+
+Pending subscriptions which are requiring a user's confirmation are handled
+elsewhere.
+"""
+
+import os
+import time
+import marshal
+import errno
+import cPickle
+from cStringIO import StringIO
+
+import email
+from email.MIMEMessage import MIMEMessage
+from email.Generator import Generator
+from email.Utils import getaddresses
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman import Errors
+from Mailman.UserDesc import UserDesc
+from Mailman.Queue.sbcache import get_switchboard
+from Mailman.Logging.Syslog import syslog
+from Mailman import i18n
+
+_ = i18n._
+
+# Request types requiring admin approval
+IGN = 0
+HELDMSG = 1
+SUBSCRIPTION = 2
+UNSUBSCRIPTION = 3
+
+# Return status from __handlepost()
+DEFER = 0
+REMOVE = 1
+LOST = 2
+
+DASH = '-'
+NL = '\n'
+
+
+
+class ListAdmin:
+ def InitVars(self):
+ # non-configurable data
+ self.next_request_id = 1
+
+ def InitTempVars(self):
+ self.__db = None
+
+ def __filename(self):
+ return os.path.join(self.fullpath(), 'request.db')
+
+ def __opendb(self):
+ filename = self.__filename()
+ if self.__db is None:
+ assert self.Locked()
+ try:
+ fp = open(filename)
+ self.__db = marshal.load(fp)
+ fp.close()
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ self.__db = {}
+ except EOFError, e:
+ # The unmarshalling failed, which means the file is corrupt.
+ # Sigh. Start over.
+ syslog('error',
+ 'request.db file corrupt for list %s, blowing it away.',
+ self.internal_name())
+ self.__db = {}
+ # Migrate pre-2.1a3 held subscription records to include the
+ # fullname data field.
+ type, version = self.__db.get('version', (IGN, None))
+ if version is None:
+ # No previous revisiont number, must be upgrading to 2.1a3 or
+ # beyond from some unknown earlier version.
+ for id, (type, data) in self.__db.items():
+ if id == IGN:
+ pass
+ elif id == HELDMSG and len(data) == 5:
+ # tack on a msgdata dictionary
+ self.__db[id] = data + ({},)
+ elif id == SUBSCRIPTION and len(data) == 5:
+ # a fullname field was added
+ stime, addr, password, digest, lang = data
+ self.__db[id] = stime, addr, '', password, digest, lang
+
+
+ def __closedb(self):
+ if self.__db is not None:
+ assert self.Locked()
+ # Save the version number
+ self.__db['version'] = IGN, mm_cfg.REQUESTS_FILE_SCHEMA_VERSION
+ # Now save a temp file and do the tmpfile->real file dance. BAW:
+ # should we be as paranoid as for the config.pck file? Should we
+ # use pickle?
+ tmpfile = self.__filename() + '.tmp'
+ omask = os.umask(002)
+ try:
+ fp = open(tmpfile, 'w')
+ marshal.dump(self.__db, fp)
+ fp.close()
+ self.__db = None
+ finally:
+ os.umask(omask)
+ # Do the dance
+ os.rename(tmpfile, self.__filename())
+
+ def __request_id(self):
+ id = self.next_request_id
+ self.next_request_id += 1
+ return id
+
+ def SaveRequestsDb(self):
+ self.__closedb()
+
+ def NumRequestsPending(self):
+ self.__opendb()
+ # Subtrace one for the version pseudo-entry
+ if self.__db.has_key('version'):
+ return len(self.__db) - 1
+ return len(self.__db)
+
+ def __getmsgids(self, rtype):
+ self.__opendb()
+ ids = [k for k, (type, data) in self.__db.items() if type == rtype]
+ ids.sort()
+ return ids
+
+ def GetHeldMessageIds(self):
+ return self.__getmsgids(HELDMSG)
+
+ def GetSubscriptionIds(self):
+ return self.__getmsgids(SUBSCRIPTION)
+
+ def GetUnsubscriptionIds(self):
+ return self.__getmsgids(UNSUBSCRIPTION)
+
+ def GetRecord(self, id):
+ self.__opendb()
+ type, data = self.__db[id]
+ return data
+
+ def GetRecordType(self, id):
+ self.__opendb()
+ type, data = self.__db[id]
+ return type
+
+ def HandleRequest(self, id, value, comment=None, preserve=None,
+ forward=None, addr=None):
+ self.__opendb()
+ rtype, data = self.__db[id]
+ if rtype == HELDMSG:
+ status = self.__handlepost(data, value, comment, preserve,
+ forward, addr)
+ elif rtype == UNSUBSCRIPTION:
+ status = self.__handleunsubscription(data, value, comment)
+ else:
+ assert rtype == SUBSCRIPTION
+ status = self.__handlesubscription(data, value, comment)
+ if status <> DEFER:
+ # BAW: Held message ids are linked to Pending cookies, allowing
+ # the user to cancel their post before the moderator has approved
+ # it. We should probably remove the cookie associated with this
+ # id, but we have no way currently of correlating them. :(
+ del self.__db[id]
+
+ def HoldMessage(self, msg, reason, msgdata={}):
+ # 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.
+ newmsgdata = {}
+ newmsgdata.update(msgdata)
+ msgdata = newmsgdata
+ # assure that the database is open for writing
+ self.__opendb()
+ # get the next unique id
+ id = self.__request_id()
+ assert not self.__db.has_key(id)
+ # get the message sender
+ sender = msg.get_sender()
+ # calculate the file name for the message text and write it to disk
+ if mm_cfg.HOLD_MESSAGES_AS_PICKLES:
+ ext = 'pck'
+ else:
+ ext = 'txt'
+ filename = 'heldmsg-%s-%d.%s' % (self.internal_name(), id, ext)
+ omask = os.umask(002)
+ fp = None
+ try:
+ fp = open(os.path.join(mm_cfg.DATA_DIR, filename), 'w')
+ if mm_cfg.HOLD_MESSAGES_AS_PICKLES:
+ cPickle.dump(msg, fp, 1)
+ else:
+ g = Generator(fp)
+ g(msg, 1)
+ finally:
+ if fp:
+ fp.close()
+ os.umask(omask)
+ # save the information to the request database. for held message
+ # entries, each record in the database will be of the following
+ # format:
+ #
+ # the time the message was received
+ # the sender of the message
+ # the message's subject
+ # a string description of the problem
+ # name of the file in $PREFIX/data containing the msg text
+ # an additional dictionary of message metadata
+ #
+ msgsubject = msg.get('subject', _('(no subject)'))
+ data = time.time(), sender, msgsubject, reason, filename, msgdata
+ self.__db[id] = (HELDMSG, data)
+ return id
+
+ def __handlepost(self, record, value, comment, preserve, forward, addr):
+ # For backwards compatibility with pre 2.0beta3
+ ptime, sender, subject, reason, filename, msgdata = record
+ path = os.path.join(mm_cfg.DATA_DIR, filename)
+ # Handle message preservation
+ if preserve:
+ parts = os.path.split(path)[1].split(DASH)
+ parts[0] = 'spam'
+ spamfile = DASH.join(parts)
+ # Preserve the message as plain text, not as a pickle
+ try:
+ fp = open(path)
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ return LOST
+ try:
+ msg = cPickle.load(fp)
+ finally:
+ fp.close()
+ # Save the plain text to a .msg file, not a .pck file
+ outpath = os.path.join(mm_cfg.SPAM_DIR, spamfile)
+ head, ext = os.path.splitext(outpath)
+ outpath = head + '.msg'
+ outfp = open(outpath, 'w')
+ try:
+ g = Generator(outfp)
+ g(msg, 1)
+ finally:
+ outfp.close()
+ # Now handle updates to the database
+ rejection = None
+ fp = None
+ msg = None
+ status = REMOVE
+ if value == mm_cfg.DEFER:
+ # Defer
+ status = DEFER
+ elif value == mm_cfg.APPROVE:
+ # Approved.
+ try:
+ msg = readMessage(path)
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ return LOST
+ msg = readMessage(path)
+ msgdata['approved'] = 1
+ # adminapproved is used by the Emergency handler
+ msgdata['adminapproved'] = 1
+ # Calculate a new filebase for the approved message, otherwise
+ # delivery errors will cause duplicates.
+ try:
+ del msgdata['filebase']
+ except KeyError:
+ pass
+ # 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'] = email.Utils.formatdate(localtime=1)
+ syslog('vette', 'held message approved, message-id: %s',
+ msg.get('message-id', 'n/a'))
+ # Stick the message back in the incoming queue for further
+ # processing.
+ inq = get_switchboard(mm_cfg.INQUEUE_DIR)
+ inq.enqueue(msg, _metadata=msgdata)
+ elif value == mm_cfg.REJECT:
+ # Rejected
+ rejection = 'Refused'
+ self.__refuse(_('Posting of your message titled "%(subject)s"'),
+ sender, comment or _('[No reason given]'),
+ lang=self.getMemberLanguage(sender))
+ else:
+ assert value == mm_cfg.DISCARD
+ # Discarded
+ rejection = 'Discarded'
+ # Forward the message
+ if forward and addr:
+ # If we've approved the message, we need to be sure to craft a
+ # completely unique second message for the forwarding operation,
+ # since we don't want to share any state or information with the
+ # normal delivery.
+ try:
+ copy = readMessage(path)
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ raise Errors.LostHeldMessage(path)
+ # It's possible the addr is a comma separated list of addresses.
+ addrs = getaddresses([addr])
+ if len(addrs) == 1:
+ realname, addr = addrs[0]
+ # 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.
+ lang = self.getMemberLanguage(addr)
+ else:
+ # Throw away the realnames
+ addr = [a for realname, a in addrs]
+ # Which member language do we attempt to use? We could use
+ # the first match or the first address, but in the face of
+ # ambiguity, let's just use the list's preferred language
+ lang = self.preferred_language
+ otrans = i18n.get_translation()
+ i18n.set_language(lang)
+ try:
+ fmsg = Message.UserNotification(
+ addr, self.GetBouncesEmail(),
+ _('Forward of moderated message'),
+ lang=lang)
+ finally:
+ i18n.set_translation(otrans)
+ fmsg.set_type('message/rfc822')
+ fmsg.attach(copy)
+ fmsg.send(self)
+ # Log the rejection
+ if rejection:
+ note = '''%(listname)s: %(rejection)s posting:
+\tFrom: %(sender)s
+\tSubject: %(subject)s''' % {
+ 'listname' : self.internal_name(),
+ 'rejection': rejection,
+ 'sender' : sender.replace('%', '%%'),
+ 'subject' : subject.replace('%', '%%'),
+ }
+ if comment:
+ note += '\n\tReason: ' + comment.replace('%', '%%')
+ syslog('vette', note)
+ # Always unlink the file containing the message text. It's not
+ # necessary anymore, regardless of the disposition of the message.
+ if status <> DEFER:
+ try:
+ os.unlink(path)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ # We lost the message text file. Clean up our housekeeping
+ # and inform of this status.
+ return LOST
+ return status
+
+ def HoldSubscription(self, addr, fullname, password, digest, lang):
+ # Assure that the database is open for writing
+ self.__opendb()
+ # Get the next unique id
+ id = self.__request_id()
+ assert not self.__db.has_key(id)
+ #
+ # Save the information to the request database. for held subscription
+ # entries, each record in the database will be one of the following
+ # format:
+ #
+ # the time the subscription request was received
+ # the subscriber's address
+ # the subscriber's selected password (TBD: is this safe???)
+ # the digest flag
+ # the user's preferred language
+ #
+ data = time.time(), addr, fullname, password, digest, lang
+ self.__db[id] = (SUBSCRIPTION, data)
+ #
+ # TBD: this really shouldn't go here but I'm not sure where else is
+ # appropriate.
+ syslog('vette', '%s: held subscription request from %s',
+ self.internal_name(), addr)
+ # Possibly notify the administrator in default list language
+ if self.admin_immed_notify:
+ realname = self.real_name
+ subject = _(
+ 'New subscription request to list %(realname)s from %(addr)s')
+ text = Utils.maketext(
+ 'subauth.txt',
+ {'username' : addr,
+ 'listname' : self.internal_name(),
+ 'hostname' : self.host_name,
+ 'admindb_url': self.GetScriptURL('admindb', absolute=1),
+ }, mlist=self)
+ # This message should appear to come from the <list>-owner so as
+ # to avoid any useless bounce processing.
+ owneraddr = self.GetOwnerEmail()
+ msg = Message.UserNotification(owneraddr, owneraddr, subject, text,
+ self.preferred_language)
+ msg.send(self, **{'tomoderators': 1})
+
+ def __handlesubscription(self, record, value, comment):
+ stime, addr, fullname, password, digest, lang = record
+ if value == mm_cfg.DEFER:
+ return DEFER
+ elif value == mm_cfg.DISCARD:
+ pass
+ elif value == mm_cfg.REJECT:
+ self.__refuse(_('Subscription request'), addr,
+ comment or _('[No reason given]'),
+ lang=lang)
+ else:
+ # subscribe
+ assert value == mm_cfg.SUBSCRIBE
+ try:
+ userdesc = UserDesc(addr, fullname, password, digest, lang)
+ self.ApprovedAddMember(userdesc)
+ except Errors.MMAlreadyAMember:
+ # User has already been subscribed, after sending the request
+ pass
+ # TBD: disgusting hack: ApprovedAddMember() can end up closing
+ # the request database.
+ self.__opendb()
+ return REMOVE
+
+ def HoldUnsubscription(self, addr):
+ # Assure the database is open for writing
+ self.__opendb()
+ # Get the next unique id
+ id = self.__request_id()
+ assert not self.__db.has_key(id)
+ # All we need to do is save the unsubscribing address
+ self.__db[id] = (UNSUBSCRIPTION, addr)
+ syslog('vette', '%s: held unsubscription request from %s',
+ self.internal_name(), addr)
+ # Possibly notify the administrator of the hold
+ if self.admin_immed_notify:
+ realname = self.real_name
+ subject = _(
+ 'New unsubscription request from %(realname)s by %(addr)s')
+ text = Utils.maketext(
+ 'unsubauth.txt',
+ {'username' : addr,
+ 'listname' : self.internal_name(),
+ 'hostname' : self.host_name,
+ 'admindb_url': self.GetScriptURL('admindb', absolute=1),
+ }, mlist=self)
+ # This message should appear to come from the <list>-owner so as
+ # to avoid any useless bounce processing.
+ owneraddr = self.GetOwnerEmail()
+ msg = Message.UserNotification(owneraddr, owneraddr, subject, text,
+ self.preferred_language)
+ msg.send(self, **{'tomoderators': 1})
+
+ def __handleunsubscription(self, record, value, comment):
+ addr = record
+ if value == mm_cfg.DEFER:
+ return DEFER
+ elif value == mm_cfg.DISCARD:
+ pass
+ elif value == mm_cfg.REJECT:
+ self.__refuse(_('Unsubscription request'), addr, comment)
+ else:
+ assert value == mm_cfg.UNSUBSCRIBE
+ try:
+ self.ApprovedDeleteMember(addr)
+ except Errors.NotAMemberError:
+ # User has already been unsubscribed
+ pass
+ return REMOVE
+
+ def __refuse(self, request, recip, comment, origmsg=None, lang=None):
+ # As this message is going to the requestor, try to set the language
+ # to his/her language choice, if they are a member. Otherwise use the
+ # list's preferred language.
+ realname = self.real_name
+ if lang is None:
+ lang = self.getMemberLanguage(recip)
+ text = Utils.maketext(
+ 'refuse.txt',
+ {'listname' : realname,
+ 'request' : request,
+ 'reason' : comment,
+ 'adminaddr': self.GetOwnerEmail(),
+ }, lang=lang, mlist=self)
+ otrans = i18n.get_translation()
+ i18n.set_language(lang)
+ try:
+ # add in original message, but not wrap/filled
+ if origmsg:
+ text = NL.join(
+ [text,
+ '---------- ' + _('Original Message') + ' ----------',
+ str(origmsg)
+ ])
+ subject = _('Request to mailing list %(realname)s rejected')
+ finally:
+ i18n.set_translation(otrans)
+ msg = Message.UserNotification(recip, self.GetBouncesEmail(),
+ subject, text, lang)
+ msg.send(self)
+
+ def _UpdateRecords(self):
+ # Subscription records have changed since MM2.0.x. In that family,
+ # the records were of length 4, containing the request time, the
+ # address, the password, and the digest flag. In MM2.1a2, they grew
+ # an additional language parameter at the end. In MM2.1a4, they grew
+ # a fullname slot after the address. This semi-public method is used
+ # by the update script to coerce all subscription records to the
+ # latest MM2.1 format.
+ #
+ # Held message records have historically either 5 or 6 items too.
+ # These always include the requests time, the sender, subject, default
+ # rejection reason, and message text. When of length 6, it also
+ # includes the message metadata dictionary on the end of the tuple.
+ self.__opendb()
+ for id, (type, info) in self.__db.items():
+ if type == SUBSCRIPTION:
+ if len(info) == 4:
+ # pre-2.1a2 compatibility
+ when, addr, passwd, digest = info
+ fullname = ''
+ lang = self.preferred_language
+ elif len(info) == 5:
+ # pre-2.1a4 compatibility
+ when, addr, passwd, digest, lang = info
+ fullname = ''
+ else:
+ assert len(info) == 6, 'Unknown subscription record layout'
+ continue
+ # Here's the new layout
+ self.__db[id] = when, addr, fullname, passwd, digest, lang
+ elif type == HELDMSG:
+ if len(info) == 5:
+ when, sender, subject, reason, text = info
+ msgdata = {}
+ else:
+ assert len(info) == 6, 'Unknown held msg record layout'
+ continue
+ # Here's the new layout
+ self.__db[id] = when, sender, subject, reason, text, msgdata
+ # All done
+ self.__closedb()
+
+
+
+def readMessage(path):
+ # For backwards compatibility, we must be able to read either a flat text
+ # file or a pickle.
+ ext = os.path.splitext(path)[1]
+ fp = open(path)
+ try:
+ if ext == '.txt':
+ msg = email.message_from_file(fp, Message.Message)
+ else:
+ assert ext == '.pck'
+ msg = cPickle.load(fp)
+ finally:
+ fp.close()
+ return msg
diff --git a/Mailman/LockFile.py b/Mailman/LockFile.py
new file mode 100644
index 00000000..796a81eb
--- /dev/null
+++ b/Mailman/LockFile.py
@@ -0,0 +1,596 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Portable, NFS-safe file locking with timeouts.
+
+This code implements an NFS-safe file-based locking algorithm influenced by
+the GNU/Linux open(2) manpage, under the description of the O_EXCL option.
+From RH6.1:
+
+ [...] O_EXCL is broken on NFS file systems, programs which rely on it
+ for performing locking tasks will contain a race condition. The
+ solution for performing atomic file locking using a lockfile is to
+ create a unique file on the same fs (e.g., incorporating hostname and
+ pid), use link(2) to make a link to the lockfile. If link() returns
+ 0, the lock is successful. Otherwise, use stat(2) on the unique file
+ to check if its link count has increased to 2, in which case the lock
+ is also successful.
+
+The assumption made here is that there will be no `outside interference',
+e.g. no agent external to this code will have access to link() to the affected
+lock files.
+
+LockFile objects support lock-breaking so that you can't wedge a process
+forever. This is especially helpful in a web environment, but may not be
+appropriate for all applications.
+
+Locks have a `lifetime', which is the maximum length of time the process
+expects to retain the lock. It is important to pick a good number here
+because other processes will not break an existing lock until the expected
+lifetime has expired. Too long and other processes will hang; too short and
+you'll end up trampling on existing process locks -- and possibly corrupting
+data. In a distributed (NFS) environment, you also need to make sure that
+your clocks are properly synchronized.
+
+Locks can also log their state to a log file. When running under Mailman, the
+log file is placed in a Mailman-specific location, otherwise, the log file is
+called `LockFile.log' and placed in the temp directory (calculated from
+tempfile.mktemp()).
+
+"""
+
+# This code has undergone several revisions, with contributions from Barry
+# Warsaw, Thomas Wouters, Harald Meland, and John Viega. It should also work
+# well outside of Mailman so it could be used for other Python projects
+# requiring file locking. See the __main__ section at the bottom of the file
+# for unit testing.
+
+import os
+import socket
+import time
+import errno
+import random
+import traceback
+from stat import ST_NLINK, ST_MTIME
+
+# Units are floating-point seconds.
+DEFAULT_LOCK_LIFETIME = 15
+# Allowable a bit of clock skew
+CLOCK_SLOP = 10
+
+
+
+# Figure out what logfile to use. This is different depending on whether
+# we're running in a Mailman context or not.
+_logfile = None
+
+def _get_logfile():
+ global _logfile
+ if _logfile is None:
+ try:
+ from Mailman.Logging.StampedLogger import StampedLogger
+ _logfile = StampedLogger('locks')
+ except ImportError:
+ # not running inside Mailman
+ import tempfile
+ dir = os.path.split(tempfile.mktemp())[0]
+ path = os.path.join(dir, 'LockFile.log')
+ # open in line-buffered mode
+ class SimpleUserFile:
+ def __init__(self, path):
+ self.__fp = open(path, 'a', 1)
+ self.__prefix = '(%d) ' % os.getpid()
+ def write(self, msg):
+ now = '%.3f' % time.time()
+ self.__fp.write(self.__prefix + now + ' ' + msg)
+ _logfile = SimpleUserFile(path)
+ return _logfile
+
+
+
+# Exceptions that can be raised by this module
+class LockError(Exception):
+ """Base class for all exceptions in this module."""
+
+class AlreadyLockedError(LockError):
+ """An attempt is made to lock an already locked object."""
+
+class NotLockedError(LockError):
+ """An attempt is made to unlock an object that isn't locked."""
+
+class TimeOutError(LockError):
+ """The timeout interval elapsed before the lock succeeded."""
+
+
+
+class LockFile:
+ """A portable way to lock resources by way of the file system.
+
+ This class supports the following methods:
+
+ __init__(lockfile[, lifetime[, withlogging]]):
+ Create the resource lock using lockfile as the global lock file. Each
+ process laying claim to this resource lock will create their own
+ temporary lock files based on the path specified by lockfile.
+ Optional lifetime is the number of seconds the process expects to hold
+ the lock. Optional withlogging, when true, turns on lockfile logging
+ (see the module docstring for details).
+
+ set_lifetime(lifetime):
+ Set a new lock lifetime. This takes affect the next time the file is
+ locked, but does not refresh a locked file.
+
+ get_lifetime():
+ Return the lock's lifetime.
+
+ refresh([newlifetime[, unconditionally]]):
+ Refreshes the lifetime of a locked file. Use this if you realize that
+ you need to keep a resource locked longer than you thought. With
+ optional newlifetime, set the lock's lifetime. Raises NotLockedError
+ if the lock is not set, unless optional unconditionally flag is set to
+ true.
+
+ lock([timeout]):
+ Acquire the lock. This blocks until the lock is acquired unless
+ optional timeout is greater than 0, in which case, a TimeOutError is
+ raised when timeout number of seconds (or possibly more) expires
+ without lock acquisition. Raises AlreadyLockedError if the lock is
+ already set.
+
+ unlock([unconditionally]):
+ Relinquishes the lock. Raises a NotLockedError if the lock is not
+ set, unless optional unconditionally is true.
+
+ locked():
+ Return 1 if the lock is set, otherwise 0. To avoid race conditions,
+ this refreshes the lock (on set locks).
+
+ """
+ # BAW: We need to watch out for two lock objects in the same process
+ # pointing to the same lock file. Without this, if you lock lf1 and do
+ # not lock lf2, lf2.locked() will still return true. NOTE: this gimmick
+ # probably does /not/ work in a multithreaded world, but we don't have to
+ # worry about that, do we? <1 wink>.
+ COUNTER = 0
+
+ def __init__(self, lockfile,
+ lifetime=DEFAULT_LOCK_LIFETIME,
+ withlogging=0):
+ """Create the resource lock using lockfile as the global lock file.
+
+ Each process laying claim to this resource lock will create their own
+ temporary lock files based on the path specified by lockfile.
+ Optional lifetime is the number of seconds the process expects to hold
+ the lock. Optional withlogging, when true, turns on lockfile logging
+ (see the module docstring for details).
+
+ """
+ self.__lockfile = lockfile
+ self.__lifetime = lifetime
+ # This works because we know we're single threaded
+ self.__counter = LockFile.COUNTER
+ LockFile.COUNTER += 1
+ self.__tmpfname = '%s.%s.%d.%d' % (
+ lockfile, socket.gethostname(), os.getpid(), self.__counter)
+ self.__withlogging = withlogging
+ self.__logprefix = os.path.split(self.__lockfile)[1]
+ # For transferring ownership across a fork.
+ self.__owned = 1
+
+ def __repr__(self):
+ return '<LockFile %s: %s [%s: %ssec] pid=%s>' % (
+ id(self), self.__lockfile,
+ self.locked() and 'locked' or 'unlocked',
+ self.__lifetime, os.getpid())
+
+ def set_lifetime(self, lifetime):
+ """Set a new lock lifetime.
+
+ This takes affect the next time the file is locked, but does not
+ refresh a locked file.
+ """
+ self.__lifetime = lifetime
+
+ def get_lifetime(self):
+ """Return the lock's lifetime."""
+ return self.__lifetime
+
+ def refresh(self, newlifetime=None, unconditionally=0):
+ """Refreshes the lifetime of a locked file.
+
+ Use this if you realize that you need to keep a resource locked longer
+ than you thought. With optional newlifetime, set the lock's lifetime.
+ Raises NotLockedError if the lock is not set, unless optional
+ unconditionally flag is set to true.
+ """
+ if newlifetime is not None:
+ self.set_lifetime(newlifetime)
+ # Do we have the lock? As a side effect, this refreshes the lock!
+ if not self.locked() and not unconditionally:
+ raise NotLockedError, '%s: %s' % (repr(self), self.__read())
+
+ def lock(self, timeout=0):
+ """Acquire the lock.
+
+ This blocks until the lock is acquired unless optional timeout is
+ greater than 0, in which case, a TimeOutError is raised when timeout
+ number of seconds (or possibly more) expires without lock acquisition.
+ Raises AlreadyLockedError if the lock is already set.
+
+ """
+ if timeout:
+ timeout_time = time.time() + timeout
+ # Make sure my temp lockfile exists, and that its contents are
+ # up-to-date (e.g. the temp file name, and the lock lifetime).
+ self.__write()
+ # TBD: This next call can fail with an EPERM. I have no idea why, but
+ # I'm nervous about wrapping this in a try/except. It seems to be a
+ # very rare occurence, only happens from cron, and (only?) on Solaris
+ # 2.6.
+ self.__touch()
+ self.__writelog('laying claim')
+ # for quieting the logging output
+ loopcount = -1
+ while 1:
+ loopcount = loopcount + 1
+ # Create the hard link and test for exactly 2 links to the file
+ try:
+ os.link(self.__tmpfname, self.__lockfile)
+ # If we got here, we know we know we got the lock, and never
+ # had it before, so we're done. Just touch it again for the
+ # fun of it.
+ self.__writelog('got the lock')
+ self.__touch()
+ break
+ except OSError, e:
+ # The link failed for some reason, possibly because someone
+ # else already has the lock (i.e. we got an EEXIST), or for
+ # some other bizarre reason.
+ if e.errno == errno.ENOENT:
+ # TBD: in some Linux environments, it is possible to get
+ # an ENOENT, which is truly strange, because this means
+ # that self.__tmpfname doesn't exist at the time of the
+ # os.link(), but self.__write() is supposed to guarantee
+ # that this happens! I don't honestly know why this
+ # happens, but for now we just say we didn't acquire the
+ # lock, and try again next time.
+ pass
+ elif e.errno <> errno.EEXIST:
+ # Something very bizarre happened. Clean up our state and
+ # pass the error on up.
+ self.__writelog('unexpected link error: %s' % e)
+ os.unlink(self.__tmpfname)
+ raise
+ elif self.__linkcount() <> 2:
+ # Somebody's messin' with us! Log this, and try again
+ # later. TBD: should we raise an exception?
+ self.__writelog('unexpected linkcount: %d' %
+ self.__linkcount())
+ elif self.__read() == self.__tmpfname:
+ # It was us that already had the link.
+ self.__writelog('already locked')
+ raise AlreadyLockedError
+ # otherwise, someone else has the lock
+ pass
+ # We did not acquire the lock, because someone else already has
+ # it. Have we timed out in our quest for the lock?
+ if timeout and timeout_time < time.time():
+ os.unlink(self.__tmpfname)
+ self.__writelog('timed out')
+ raise TimeOutError
+ # Okay, we haven't timed out, but we didn't get the lock. Let's
+ # find if the lock lifetime has expired.
+ if time.time() > self.__releasetime() + CLOCK_SLOP:
+ # Yes, so break the lock.
+ self.__break()
+ self.__writelog('lifetime has expired, breaking')
+ # Okay, someone else has the lock, our claim hasn't timed out yet,
+ # and the expected lock lifetime hasn't expired yet. So let's
+ # wait a while for the owner of the lock to give it up.
+ elif not loopcount % 100:
+ self.__writelog('waiting for claim')
+ self.__sleep()
+
+ def unlock(self, unconditionally=0):
+ """Unlock the lock.
+
+ If we don't already own the lock (either because of unbalanced unlock
+ calls, or because the lock was stolen out from under us), raise a
+ NotLockedError, unless optional `unconditionally' is true.
+ """
+ islocked = self.locked()
+ if not islocked and not unconditionally:
+ raise NotLockedError
+ # If we owned the lock, remove the global file, relinquishing it.
+ if islocked:
+ try:
+ os.unlink(self.__lockfile)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ # Remove our tempfile
+ try:
+ os.unlink(self.__tmpfname)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ self.__writelog('unlocked')
+
+ def locked(self):
+ """Returns 1 if we own the lock, 0 if we do not.
+
+ Checking the status of the lockfile resets the lock's lifetime, which
+ helps avoid race conditions during the lock status test.
+ """
+ # Discourage breaking the lock for a while.
+ try:
+ self.__touch()
+ except OSError, e:
+ if e.errno == errno.EPERM:
+ # We can't touch the file because we're not the owner. I
+ # don't see how we can own the lock if we're not the owner.
+ return 0
+ else:
+ raise
+ # TBD: can the link count ever be > 2?
+ if self.__linkcount() <> 2:
+ return 0
+ return self.__read() == self.__tmpfname
+
+ def finalize(self):
+ self.unlock(unconditionally=1)
+
+ def __del__(self):
+ if self.__owned:
+ self.finalize()
+
+ # Use these only if you're transfering ownership to a child process across
+ # a fork. Use at your own risk, but it should be race-condition safe.
+ # _transfer_to() is called in the parent, passing in the pid of the
+ # child. _take_possession() is called in the child, and blocks until the
+ # parent has transferred possession to the child. _disown() is used to
+ # set the __owned flag to 0, and it is a disgusting wart necessary to make
+ # forced lock acquisition work in mailmanctl. :(
+ def _transfer_to(self, pid):
+ # First touch it so it won't get broken while we're fiddling about.
+ self.__touch()
+ # Find out current claim's temp filename
+ winner = self.__read()
+ # Now twiddle ours to the given pid
+ self.__tmpfname = '%s.%s.%d' % (
+ self.__lockfile, socket.gethostname(), pid)
+ # Create a hard link from the global lock file to the temp file. This
+ # actually does things in reverse order of normal operation because we
+ # know that lockfile exists, and tmpfname better not!
+ os.link(self.__lockfile, self.__tmpfname)
+ # Now update the lock file to contain a reference to the new owner
+ self.__write()
+ # Toggle off our ownership of the file so we don't try to finalize it
+ # in our __del__()
+ self.__owned = 0
+ # Unlink the old winner, completing the transfer
+ os.unlink(winner)
+ # And do some sanity checks
+ assert self.__linkcount() == 2
+ assert self.locked()
+ self.__writelog('transferred the lock')
+
+ def _take_possession(self):
+ self.__tmpfname = tmpfname = '%s.%s.%d' % (
+ self.__lockfile, socket.gethostname(), os.getpid())
+ # Wait until the linkcount is 2, indicating the parent has completed
+ # the transfer.
+ while self.__linkcount() <> 2 or self.__read() <> tmpfname:
+ time.sleep(0.25)
+ self.__writelog('took possession of the lock')
+
+ def _disown(self):
+ self.__owned = 0
+
+ #
+ # Private interface
+ #
+
+ def __writelog(self, msg):
+ if self.__withlogging:
+ logf = _get_logfile()
+ logf.write('%s %s\n' % (self.__logprefix, msg))
+ traceback.print_stack(file=logf)
+
+ def __write(self):
+ # Make sure it's group writable
+ oldmask = os.umask(002)
+ try:
+ fp = open(self.__tmpfname, 'w')
+ fp.write(self.__tmpfname)
+ fp.close()
+ finally:
+ os.umask(oldmask)
+
+ def __read(self):
+ try:
+ fp = open(self.__lockfile)
+ filename = fp.read()
+ fp.close()
+ return filename
+ except EnvironmentError, e:
+ if e.errno <> errno.ENOENT: raise
+ return None
+
+ def __touch(self, filename=None):
+ t = time.time() + self.__lifetime
+ try:
+ # TBD: We probably don't need to modify atime, but this is easier.
+ os.utime(filename or self.__tmpfname, (t, t))
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+
+ def __releasetime(self):
+ try:
+ return os.stat(self.__lockfile)[ST_MTIME]
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ return -1
+
+ def __linkcount(self):
+ try:
+ return os.stat(self.__lockfile)[ST_NLINK]
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ return -1
+
+ def __break(self):
+ # First, touch the global lock file. This reduces but does not
+ # eliminate the chance for a race condition during breaking. Two
+ # processes could both pass the test for lock expiry in lock() before
+ # one of them gets to touch the global lockfile. This shouldn't be
+ # too bad because all they'll do in this function is wax the lock
+ # files, not claim the lock, and we can be defensive for ENOENTs
+ # here.
+ #
+ # Touching the lock could fail if the process breaking the lock and
+ # the process that claimed the lock have different owners. We could
+ # solve this by set-uid'ing the CGI and mail wrappers, but I don't
+ # think it's that big a problem.
+ try:
+ self.__touch(self.__lockfile)
+ except OSError, e:
+ if e.errno <> errno.EPERM: raise
+ # Get the name of the old winner's temp file.
+ winner = self.__read()
+ # Remove the global lockfile, which actually breaks the lock.
+ try:
+ os.unlink(self.__lockfile)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ # Try to remove the old winner's temp file, since we're assuming the
+ # winner process has hung or died. Don't worry too much if we can't
+ # unlink their temp file -- this doesn't wreck the locking algorithm,
+ # but will leave temp file turds laying around, a minor inconvenience.
+ try:
+ if winner:
+ os.unlink(winner)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+
+ def __sleep(self):
+ interval = random.random() * 2.0 + 0.01
+ time.sleep(interval)
+
+
+
+# Unit test framework
+def _dochild():
+ prefix = '[%d]' % os.getpid()
+ # Create somewhere between 1 and 1000 locks
+ lockfile = LockFile('/tmp/LockTest', withlogging=1, lifetime=120)
+ # Use a lock lifetime of between 1 and 15 seconds. Under normal
+ # situations, Mailman's usage patterns (untested) shouldn't be much longer
+ # than this.
+ workinterval = 5 * random.random()
+ hitwait = 20 * random.random()
+ print prefix, 'workinterval:', workinterval
+ islocked = 0
+ t0 = 0
+ t1 = 0
+ t2 = 0
+ try:
+ try:
+ t0 = time.time()
+ print prefix, 'acquiring...'
+ lockfile.lock()
+ print prefix, 'acquired...'
+ islocked = 1
+ except TimeOutError:
+ print prefix, 'timed out'
+ else:
+ t1 = time.time()
+ print prefix, 'acquisition time:', t1-t0, 'seconds'
+ time.sleep(workinterval)
+ finally:
+ if islocked:
+ try:
+ lockfile.unlock()
+ t2 = time.time()
+ print prefix, 'lock hold time:', t2-t1, 'seconds'
+ except NotLockedError:
+ print prefix, 'lock was broken'
+ # wait for next web hit
+ print prefix, 'webhit sleep:', hitwait
+ time.sleep(hitwait)
+
+
+def _seed():
+ try:
+ fp = open('/dev/random')
+ d = fp.read(40)
+ fp.close()
+ except EnvironmentError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ import sha
+ d = sha.new(`os.getpid()`+`time.time()`).hexdigest()
+ random.seed(d)
+
+
+def _onetest():
+ loopcount = random.randint(1, 100)
+ for i in range(loopcount):
+ print 'Loop %d of %d' % (i+1, loopcount)
+ pid = os.fork()
+ if pid:
+ # parent, wait for child to exit
+ pid, status = os.waitpid(pid, 0)
+ else:
+ # child
+ _seed()
+ try:
+ _dochild()
+ except KeyboardInterrupt:
+ pass
+ os._exit(0)
+
+
+def _reap(kids):
+ if not kids:
+ return
+ pid, status = os.waitpid(-1, os.WNOHANG)
+ if pid <> 0:
+ del kids[pid]
+
+
+def _test(numtests):
+ kids = {}
+ for i in range(numtests):
+ pid = os.fork()
+ if pid:
+ # parent
+ kids[pid] = pid
+ else:
+ # child
+ _seed()
+ try:
+ _onetest()
+ except KeyboardInterrupt:
+ pass
+ os._exit(0)
+ # slightly randomize each kid's seed
+ while kids:
+ _reap(kids)
+
+
+if __name__ == '__main__':
+ import sys
+ import random
+ _test(int(sys.argv[1]))
diff --git a/Mailman/Logging/.cvsignore b/Mailman/Logging/.cvsignore
new file mode 100644
index 00000000..f3c7a7c5
--- /dev/null
+++ b/Mailman/Logging/.cvsignore
@@ -0,0 +1 @@
+Makefile
diff --git a/Mailman/Logging/Logger.py b/Mailman/Logging/Logger.py
new file mode 100644
index 00000000..0cb7c6af
--- /dev/null
+++ b/Mailman/Logging/Logger.py
@@ -0,0 +1,103 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""File-based logger, writes to named category files in mm_cfg.LOG_DIR."""
+
+import sys
+import os
+import codecs
+from types import StringType
+
+from Mailman import mm_cfg
+from Mailman.Logging.Utils import _logexc
+
+# Set this to the encoding to be used for your log file output. If set to
+# None, then it uses your system's default encoding. Otherwise, it must be an
+# encoding string appropriate for codecs.open().
+LOG_ENCODING = 'iso-8859-1'
+
+
+
+class Logger:
+ def __init__(self, category, nofail=1, immediate=0):
+ """nofail says to fallback to sys.__stderr__ if write fails to
+ category file - a complaint message is emitted, but no exception is
+ raised. Set nofail=0 if you want to handle the error in your code,
+ instead.
+
+ immediate=1 says to create the log file on instantiation.
+ Otherwise, the file is created only when there are writes pending.
+ """
+ self.__filename = os.path.join(mm_cfg.LOG_DIR, category)
+ self.__fp = None
+ self.__nofail = nofail
+ self.__encoding = LOG_ENCODING or sys.getdefaultencoding()
+ if immediate:
+ self.__get_f()
+
+ def __del__(self):
+ self.close()
+
+ def __repr__(self):
+ return '<%s to %s>' % (self.__class__.__name__, `self.__filename`)
+
+ def __get_f(self):
+ if self.__fp:
+ return self.__fp
+ else:
+ try:
+ ou = os.umask(002)
+ try:
+ try:
+ f = codecs.open(
+ self.__filename, 'a+', self.__encoding, 'replace',
+ 1)
+ except LookupError:
+ f = open(self.__filename, 'a+', 1)
+ self.__fp = f
+ finally:
+ os.umask(ou)
+ except IOError, e:
+ if self.__nofail:
+ _logexc(self, e)
+ f = self.__fp = sys.__stderr__
+ else:
+ raise
+ return f
+
+ def flush(self):
+ f = self.__get_f()
+ if hasattr(f, 'flush'):
+ f.flush()
+
+ def write(self, msg):
+ if isinstance(msg, StringType):
+ msg = unicode(msg, self.__encoding)
+ f = self.__get_f()
+ try:
+ f.write(msg)
+ except IOError, msg:
+ _logexc(self, msg)
+
+ def writelines(self, lines):
+ for l in lines:
+ self.write(l)
+
+ def close(self):
+ if not self.__fp:
+ return
+ self.__get_f().close()
+ self.__fp = None
diff --git a/Mailman/Logging/Makefile.in b/Mailman/Logging/Makefile.in
new file mode 100644
index 00000000..407f39a9
--- /dev/null
+++ b/Mailman/Logging/Makefile.in
@@ -0,0 +1,69 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman/Logging
+SHELL= /bin/sh
+
+MODULES= *.py
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+
+install:
+ for f in $(MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \
+ done
+
+finish:
+
+clean:
+
+distclean:
+ -rm *.pyc
+ -rm Makefile
diff --git a/Mailman/Logging/MultiLogger.py b/Mailman/Logging/MultiLogger.py
new file mode 100644
index 00000000..3ff11d27
--- /dev/null
+++ b/Mailman/Logging/MultiLogger.py
@@ -0,0 +1,76 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""A mutiple sink logger. Any message written goes to all sub-loggers."""
+
+import sys
+from Mailman.Logging.Utils import _logexc
+
+
+
+class MultiLogger:
+ def __init__(self, *args):
+ self.__loggers = []
+ for logger in args:
+ self.__loggers.append(logger)
+
+ def add_logger(self, logger):
+ if logger not in self.__loggers:
+ self.__loggers.append(logger)
+
+ def del_logger(self, logger):
+ if logger in self.__loggers:
+ self.__loggers.remove(logger)
+
+ def write(self, msg):
+ for logger in self.__loggers:
+ # you want to be sure that a bug in one logger doesn't prevent
+ # logging to all the other loggers
+ try:
+ logger.write(msg)
+ except:
+ _logexc(logger, msg)
+
+ def writelines(self, lines):
+ for line in lines:
+ self.write(line)
+
+ def flush(self):
+ for logger in self.__loggers:
+ if hasattr(logger, 'flush'):
+ # you want to be sure that a bug in one logger doesn't prevent
+ # logging to all the other loggers
+ try:
+ logger.flush()
+ except:
+ _logexc(logger)
+
+ def close(self):
+ for logger in self.__loggers:
+ # you want to be sure that a bug in one logger doesn't prevent
+ # logging to all the other loggers
+ try:
+ if logger <> sys.__stderr__ and logger <> sys.__stdout__:
+ logger.close()
+ except:
+ _logexc(logger)
+
+ def reprime(self):
+ for logger in self.__loggers:
+ try:
+ logger.reprime()
+ except AttributeError:
+ pass
diff --git a/Mailman/Logging/StampedLogger.py b/Mailman/Logging/StampedLogger.py
new file mode 100644
index 00000000..370f1af8
--- /dev/null
+++ b/Mailman/Logging/StampedLogger.py
@@ -0,0 +1,89 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+import os
+import time
+
+from Mailman.Logging.Logger import Logger
+
+
+
+class StampedLogger(Logger):
+ """Record messages in log files, including date stamp and optional label.
+
+ If manual_reprime is on (off by default), then timestamp prefix will
+ included only on first .write() and on any write immediately following a
+ call to the .reprime() method. This is useful for when StampedLogger is
+ substituting for sys.stderr, where you'd like to see the grouping of
+ multiple writes under a single timestamp (and there is often is one group,
+ for uncaught exceptions where a script is bombing).
+
+ In any case, the identifying prefix will only follow writes that start on
+ a new line.
+
+ Nofail (by default) says to fallback to sys.stderr if write fails to
+ category file. A message is emitted, but the IOError is caught.
+ Initialize with nofail=0 if you want to handle the error in your code,
+ instead.
+
+ """
+ def __init__(self, category, label=None, manual_reprime=0, nofail=1,
+ immediate=1):
+ """If specified, optional label is included after timestamp.
+ Other options are passed to the Logger class initializer.
+ """
+ self.__label = label
+ self.__manual_reprime = manual_reprime
+ self.__primed = 1
+ self.__bol = 1
+ Logger.__init__(self, category, nofail, immediate)
+
+ def reprime(self):
+ """Reset so timestamp will be included with next write."""
+ self.__primed = 1
+
+ def write(self, msg):
+ if not self.__bol:
+ prefix = ""
+ else:
+ if not self.__manual_reprime or self.__primed:
+ stamp = time.strftime("%b %d %H:%M:%S %Y ",
+ time.localtime(time.time()))
+ self.__primed = 0
+ else:
+ stamp = ""
+ if self.__label is None:
+ label = "(%d)" % os.getpid()
+ else:
+ label = "%s(%d):" % (self.__label, os.getpid())
+ prefix = stamp + label
+ Logger.write(self, "%s %s" % (prefix, msg))
+ if msg and msg[-1] == '\n':
+ self.__bol = 1
+ else:
+ self.__bol = 0
+
+ def writelines(self, lines):
+ first = 1
+ for l in lines:
+ if first:
+ self.write(l)
+ first = 0
+ else:
+ if l and l[0] not in [' ', '\t', '\n']:
+ Logger.write(self, ' ' + l)
+ else:
+ Logger.write(self, l)
diff --git a/Mailman/Logging/Syslog.py b/Mailman/Logging/Syslog.py
new file mode 100644
index 00000000..3e8d557d
--- /dev/null
+++ b/Mailman/Logging/Syslog.py
@@ -0,0 +1,69 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Central logging class for the Mailman system.
+
+This might eventually be replaced by a syslog based logger, hence the name.
+"""
+
+from Mailman.Logging.StampedLogger import StampedLogger
+
+
+
+# Global, shared logger instance. All clients should use this object.
+syslog = None
+
+
+
+# Don't instantiate except below.
+class _Syslog:
+ def __init__(self):
+ self._logfiles = {}
+
+ def __del__(self):
+ self.close()
+
+ def write(self, kind, msg, *args, **kws):
+ self.write_ex(kind, msg, args, kws)
+
+ # We need this because SMTPDirect tries to pass in a special dict-like
+ # object, which is not a concrete dictionary. This is not allowed by
+ # Python's extended call syntax. :(
+ def write_ex(self, kind, msg, args=None, kws=None):
+ origmsg = msg
+ logf = self._logfiles.get(kind)
+ if not logf:
+ logf = self._logfiles[kind] = StampedLogger(kind)
+ try:
+ if args:
+ msg %= args
+ if kws:
+ msg %= kws
+ # It's really bad if exceptions in the syslogger cause other crashes
+ except Exception, e:
+ msg = 'Bad format "%s": %s: %s' % (origmsg, repr(e), e)
+ logf.write(msg + '\n')
+
+ # For the ultimate in convenience
+ __call__ = write
+
+ def close(self):
+ for kind, logger in self._logfiles.items():
+ logger.close()
+ self._logfiles.clear()
+
+
+syslog = _Syslog()
diff --git a/Mailman/Logging/Utils.py b/Mailman/Logging/Utils.py
new file mode 100644
index 00000000..ef119fb0
--- /dev/null
+++ b/Mailman/Logging/Utils.py
@@ -0,0 +1,52 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+import sys
+import traceback
+
+
+def _logexc(logger=None, msg=''):
+ sys.__stderr__.write('Logging error: %s\n' % logger)
+ traceback.print_exc(file=sys.__stderr__)
+ sys.__stderr__.write('Original log message:\n%s\n' % msg)
+
+
+def LogStdErr(category, label, manual_reprime=1, tee_to_real_stderr=1):
+ """Establish a StampedLogger on sys.stderr if possible.
+
+ If tee_to_real_stderr is true, then the real standard error also gets
+ output, via a MultiLogger.
+
+ Returns the MultiLogger if successful, None otherwise.
+ """
+ from StampedLogger import StampedLogger
+ from MultiLogger import MultiLogger
+ try:
+ logger = StampedLogger(category,
+ label=label,
+ manual_reprime=manual_reprime,
+ nofail=0)
+ if tee_to_real_stderr:
+ if hasattr(sys, '__stderr__'):
+ stderr = sys.__stderr__
+ else:
+ stderr = sys.stderr
+ logger = MultiLogger(stderr, logger)
+ sys.stderr = logger
+ return sys.stderr
+ except IOError:
+ return None
+
diff --git a/Mailman/Logging/__init__.py b/Mailman/Logging/__init__.py
new file mode 100644
index 00000000..2cbbabb1
--- /dev/null
+++ b/Mailman/Logging/__init__.py
@@ -0,0 +1,15 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
diff --git a/Mailman/MTA/.cvsignore b/Mailman/MTA/.cvsignore
new file mode 100644
index 00000000..f3c7a7c5
--- /dev/null
+++ b/Mailman/MTA/.cvsignore
@@ -0,0 +1 @@
+Makefile
diff --git a/Mailman/MTA/Makefile.in b/Mailman/MTA/Makefile.in
new file mode 100644
index 00000000..42a6fcc5
--- /dev/null
+++ b/Mailman/MTA/Makefile.in
@@ -0,0 +1,69 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman/MTA
+SHELL= /bin/sh
+
+MODULES= *.py
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+
+install:
+ for f in $(MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \
+ done
+
+finish:
+
+clean:
+
+distclean:
+ -rm *.pyc
+ -rm Makefile
diff --git a/Mailman/MTA/Manual.py b/Mailman/MTA/Manual.py
new file mode 100644
index 00000000..dd9127cc
--- /dev/null
+++ b/Mailman/MTA/Manual.py
@@ -0,0 +1,135 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Creation/deletion hooks for manual /etc/aliases files."""
+
+import sys
+from cStringIO import StringIO
+
+from Mailman import mm_cfg
+from Mailman import Message
+from Mailman import Utils
+from Mailman.Queue.sbcache import get_switchboard
+from Mailman.i18n import _
+from Mailman.MTA.Utils import makealiases
+
+
+
+# no-ops for interface compliance
+def makelock():
+ class Dummy:
+ def lock(self):
+ pass
+ def unlock(self, unconditionally=0):
+ pass
+ return Dummy()
+
+
+def clear():
+ pass
+
+
+
+# nolock argument is ignored, but exists for interface compliance
+def create(mlist, cgi=0, nolock=0):
+ if mlist is None:
+ return
+ listname = mlist.internal_name()
+ fieldsz = len(listname) + len('-unsubscribe')
+ if cgi:
+ # If a list is being created via the CGI, the best we can do is send
+ # an email message to mailman-owner requesting that the proper aliases
+ # be installed.
+ sfp = StringIO()
+ print >> sfp, _("""\
+The mailing list `%(listname)s' has been created via the through-the-web
+interface. In order to complete the activation of this mailing list, the
+proper /etc/aliases (or equivalent) file must be updated. The program
+`newaliases' may also have to be run.
+
+Here are the entries for the /etc/aliases file:
+""")
+ outfp = sfp
+ else:
+ print _("""
+To finish creating your mailing list, you must edit your /etc/aliases (or
+equivalent) file by adding the following lines, and possibly running the
+`newaliases' program:
+
+## %(listname)s mailing list""")
+ outfp = sys.stdout
+ # Common path
+ for k, v in makealiases(listname):
+ print >> outfp, k + ':', ((fieldsz - len(k)) * ' '), v
+ # If we're using the command line interface, we're done. For ttw, we need
+ # to actually send the message to mailman-owner now.
+ if not cgi:
+ print >> outfp
+ return
+ # Send the message to the site -owner so someone can do something about
+ # this request.
+ siteowner = Utils.get_site_email(extra='owner')
+ # Should this be sent in the site list's preferred language?
+ msg = Message.UserNotification(
+ siteowner, siteowner,
+ _('Mailing list creation request for list %(listname)s'),
+ sfp.getvalue(), mm_cfg.DEFAULT_SERVER_LANGUAGE)
+ outq = get_switchboard(mm_cfg.OUTQUEUE_DIR)
+ outq.enqueue(msg, recips=[siteowner])
+
+
+
+def remove(mlist, cgi=0):
+ listname = mlist.internal_name()
+ fieldsz = len(listname) + len('-unsubscribe')
+ if cgi:
+ # If a list is being removed via the CGI, the best we can do is send
+ # an email message to mailman-owner requesting that the appropriate
+ # aliases be deleted.
+ sfp = StringIO()
+ print >> sfp, _("""\
+The mailing list `%(listname)s' has been removed via the through-the-web
+interface. In order to complete the de-activation of this mailing list, the
+appropriate /etc/aliases (or equivalent) file must be updated. The program
+`newaliases' may also have to be run.
+
+Here are the entries in the /etc/aliases file that should be removed:
+""")
+ outfp = sfp
+ else:
+ print _("""
+To finish removing your mailing list, you must edit your /etc/aliases (or
+equivalent) file by removing the following lines, and possibly running the
+`newaliases' program:
+
+## %(listname)s mailing list""")
+ outfp = sys.stdout
+ # Common path
+ for k, v in makealiases(listname):
+ print >> outfp, k + ':', ((fieldsz - len(k)) * ' '), v
+ # If we're using the command line interface, we're done. For ttw, we need
+ # to actually send the message to mailman-owner now.
+ if not cgi:
+ print >> outfp
+ return
+ siteowner = Utils.get_site_email(extra='owner')
+ # Should this be sent in the site list's preferred language?
+ msg = Message.UserNotification(
+ siteowner, siteowner,
+ _('Mailing list removal request for list %(listname)s'),
+ sfp.getvalue(), mm_cfg.DEFAULT_SERVER_LANGUAGE)
+ outq = get_switchboard(mm_cfg.OUTQUEUE_DIR)
+ outq.enqueue(msg, recips=[siteowner])
diff --git a/Mailman/MTA/Postfix.py b/Mailman/MTA/Postfix.py
new file mode 100644
index 00000000..24c1c7e5
--- /dev/null
+++ b/Mailman/MTA/Postfix.py
@@ -0,0 +1,344 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Creation/deletion hooks for the Postfix MTA.
+"""
+
+import os
+import time
+import errno
+import pwd
+import grp
+from stat import *
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import LockFile
+from Mailman.i18n import _
+from Mailman.MTA.Utils import makealiases
+from Mailman.Logging.Syslog import syslog
+
+LOCKFILE = os.path.join(mm_cfg.LOCK_DIR, 'creator')
+ALIASFILE = os.path.join(mm_cfg.DATA_DIR, 'aliases')
+VIRTFILE = os.path.join(mm_cfg.DATA_DIR, 'virtual-mailman')
+
+
+
+def _update_maps():
+ msg = 'command failed: %s (status: %s, %s)'
+ acmd = mm_cfg.POSTFIX_ALIAS_CMD + ' ' + ALIASFILE
+ status = (os.system(acmd) >> 8) & 0xff
+ if status:
+ errstr = os.strerror(status)
+ syslog('error', msg, acmd, status, errstr)
+ raise RuntimeError, msg % (acmd, status, errstr)
+ if os.path.exists(VIRTFILE):
+ vcmd = mm_cfg.POSTFIX_MAP_CMD + ' ' + VIRTFILE
+ status = (os.system(vcmd) >> 8) & 0xff
+ if status:
+ errstr = os.strerror(status)
+ syslog('error', msg, vcmd, status, errstr)
+ raise RuntimeError, msg % (vcmd, status, errstr)
+
+
+
+def makelock():
+ return LockFile.LockFile(LOCKFILE)
+
+
+def _zapfile(filename):
+ # Truncate the file w/o messing with the file permissions, but only if it
+ # already exists.
+ if os.path.exists(filename):
+ fp = open(filename, 'w')
+ fp.close()
+
+
+def clear():
+ _zapfile(ALIASFILE)
+ _zapfile(VIRTFILE)
+
+
+
+def _addlist(mlist, fp):
+ # Set up the mailman-loop address
+ loopaddr = Utils.ParseEmail(Utils.get_site_email(extra='loop'))[0]
+ loopmbox = os.path.join(mm_cfg.DATA_DIR, 'owner-bounces.mbox')
+ # Seek to the end of the text file, but if it's empty write the standard
+ # disclaimer, and the loop catch address.
+ fp.seek(0, 2)
+ if not fp.tell():
+ print >> fp, """\
+# This file is generated by Mailman, and is kept in sync with the
+# binary hash file aliases.db. YOU SHOULD NOT MANUALLY EDIT THIS FILE
+# unless you know what you're doing, and can keep the two files properly
+# in sync. If you screw it up, you're on your own.
+"""
+ print >> fp, '# The ultimate loop stopper address'
+ print >> fp, '%s: %s' % (loopaddr, loopmbox)
+ print >> fp
+ # Bootstrapping. bin/genaliases must be run before any lists are created,
+ # but if no lists exist yet then mlist is None. The whole point of the
+ # exercise is to get the minimal aliases.db file into existance.
+ if mlist is None:
+ return
+ listname = mlist.internal_name()
+ fieldsz = len(listname) + len('-unsubscribe')
+ # The text file entries get a little extra info
+ print >> fp, '# STANZA START:', listname
+ print >> fp, '# CREATED:', time.ctime(time.time())
+ # Now add all the standard alias entries
+ for k, v in makealiases(listname):
+ # Format the text file nicely
+ print >> fp, k + ':', ((fieldsz - len(k)) * ' ') + v
+ # Finish the text file stanza
+ print >> fp, '# STANZA END:', listname
+ print >> fp
+
+
+
+def _addvirtual(mlist, fp):
+ listname = mlist.internal_name()
+ fieldsz = len(listname) + len('-unsubscribe')
+ hostname = mlist.host_name
+ # Set up the mailman-loop address
+ loopaddr = Utils.get_site_email(mlist.host_name, extra='loop')
+ loopdest = Utils.ParseEmail(loopaddr)[0]
+ # Seek to the end of the text file, but if it's empty write the standard
+ # disclaimer, and the loop catch address.
+ fp.seek(0, 2)
+ if not fp.tell():
+ print >> fp, """\
+# This file is generated by Mailman, and is kept in sync with the binary hash
+# file virtual-mailman.db. YOU SHOULD NOT MANUALLY EDIT THIS FILE unless you
+# know what you're doing, and can keep the two files properly in sync. If you
+# screw it up, you're on your own.
+#
+# Note that you should already have this virtual domain set up properly in
+# your Postfix installation. See README.POSTFIX for details.
+
+# LOOP ADDRESSES START
+%s\t%s
+# LOOP ADDRESSES END
+""" % (loopaddr, loopdest)
+ # The text file entries get a little extra info
+ print >> fp, '# STANZA START:', listname
+ print >> fp, '# CREATED:', time.ctime(time.time())
+ # Now add all the standard alias entries
+ for k, v in makealiases(listname):
+ fqdnaddr = '%s@%s' % (k, hostname)
+ # Format the text file nicely
+ print >> fp, fqdnaddr, ((fieldsz - len(k)) * ' '), k
+ # Finish the text file stanza
+ print >> fp, '# STANZA END:', listname
+ print >> fp
+
+
+
+# Blech.
+def _check_for_virtual_loopaddr(mlist, filename):
+ loopaddr = Utils.get_site_email(mlist.host_name, extra='loop')
+ loopdest = Utils.ParseEmail(loopaddr)[0]
+ infp = open(filename)
+ omask = os.umask(007)
+ try:
+ outfp = open(filename + '.tmp', 'w')
+ finally:
+ os.umask(omask)
+ try:
+ # Find the start of the loop address block
+ while 1:
+ line = infp.readline()
+ if not line:
+ break
+ outfp.write(line)
+ if line.startswith('# LOOP ADDRESSES START'):
+ break
+ # Now see if our domain has already been written
+ while 1:
+ line = infp.readline()
+ if not line:
+ break
+ if line.startswith('# LOOP ADDRESSES END'):
+ # It hasn't
+ print >> outfp, '%s\t%s' % (loopaddr, loopdest)
+ outfp.write(line)
+ break
+ elif line.startswith(loopaddr):
+ # We just found it
+ outfp.write(line)
+ break
+ else:
+ # This isn't our loop address, so spit it out and continue
+ outfp.write(line)
+ outfp.writelines(infp.readlines())
+ finally:
+ infp.close()
+ outfp.close()
+ os.rename(filename + '.tmp', filename)
+
+
+
+def _do_create(mlist, textfile, func):
+ # Crack open the plain text file
+ try:
+ fp = open(textfile, 'r+')
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ omask = os.umask(007)
+ try:
+ fp = open(textfile, 'w+')
+ finally:
+ os.umask(omask)
+ try:
+ func(mlist, fp)
+ finally:
+ fp.close()
+ # Now double check the virtual plain text file
+ if func is _addvirtual:
+ _check_for_virtual_loopaddr(mlist, textfile)
+
+
+def create(mlist, cgi=0, nolock=0):
+ # Acquire the global list database lock
+ lock = None
+ if not nolock:
+ lock = makelock()
+ lock.lock()
+ # Do the aliases file, which need to be done in any case
+ try:
+ _do_create(mlist, ALIASFILE, _addlist)
+ if mlist and mlist.host_name in mm_cfg.POSTFIX_STYLE_VIRTUAL_DOMAINS:
+ _do_create(mlist, VIRTFILE, _addvirtual)
+ _update_maps()
+ finally:
+ if lock:
+ lock.unlock(unconditionally=1)
+
+
+
+def _do_remove(mlist, textfile, virtualp):
+ listname = mlist.internal_name()
+ # Now do our best to filter out the proper stanza from the text file.
+ # The text file better exist!
+ outfp = None
+ try:
+ infp = open(textfile)
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ # Otherwise, there's no text file to filter so we're done.
+ return
+ try:
+ omask = os.umask(007)
+ try:
+ outfp = open(textfile + '.tmp', 'w')
+ finally:
+ os.umask(omask)
+ filteroutp = 0
+ start = '# STANZA START: ' + listname
+ end = '# STANZA END: ' + listname
+ while 1:
+ line = infp.readline()
+ if not line:
+ break
+ # If we're filtering out a stanza, just look for the end marker and
+ # filter out everything in between. If we're not in the middle of
+ # filtering out a stanza, we're just looking for the proper begin
+ # marker.
+ if filteroutp:
+ if line.startswith(end):
+ filteroutp = 0
+ # Discard the trailing blank line, but don't worry if
+ # we're at the end of the file.
+ infp.readline()
+ # Otherwise, ignore the line
+ else:
+ if line.startswith(start):
+ # Filter out this stanza
+ filteroutp = 1
+ else:
+ outfp.write(line)
+ # Close up shop, and rotate the files
+ finally:
+ infp.close()
+ outfp.close()
+ os.rename(textfile+'.tmp', textfile)
+
+
+def remove(mlist, cgi=0):
+ # Acquire the global list database lock
+ lock = makelock()
+ lock.lock()
+ try:
+ _do_remove(mlist, ALIASFILE, 0)
+ if mlist.host_name in mm_cfg.POSTFIX_STYLE_VIRTUAL_DOMAINS:
+ _do_remove(mlist, VIRTFILE, 1)
+ # Regenerate the alias and map files
+ _update_maps()
+ finally:
+ lock.unlock(unconditionally=1)
+
+
+
+def checkperms(state):
+ targetmode = S_IFREG | S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP
+ for file in ALIASFILE, VIRTFILE:
+ if state.VERBOSE:
+ print _('checking permissions on %(file)s')
+ stat = None
+ try:
+ stat = os.stat(file)
+ except OSError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ if stat and (stat[ST_MODE] & targetmode) <> targetmode:
+ state.ERRORS += 1
+ octmode = oct(stat[ST_MODE])
+ print _('%(file)s permissions must be 066x (got %(octmode)s)'),
+ if state.FIX:
+ print _('(fixing)')
+ os.chmod(file, stat[ST_MODE] | targetmode)
+ else:
+ print
+ # Make sure the corresponding .db files are owned by the Mailman user.
+ # We don't need to check the group ownership of the file, since
+ # check_perms checks this itself.
+ dbfile = file + '.db'
+ stat = None
+ try:
+ stat = os.stat(dbfile)
+ except OSError, e:
+ if e.errno <> errno.ENOENT:
+ raise
+ continue
+ if state.VERBOSE:
+ print _('checking ownership of %(dbfile)s')
+ user = mm_cfg.MAILMAN_USER
+ ownerok = stat[ST_UID] == pwd.getpwnam(user)[2]
+ if not ownerok:
+ try:
+ owner = pwd.getpwuid(stat[ST_UID])[0]
+ except KeyError:
+ owner = 'uid %d' % stat[ST_UID]
+ print _('%(dbfile)s owned by %(owner)s (must be owned by %(user)s')
+ state.ERRORS += 1
+ if state.FIX:
+ print _('(fixing)')
+ uid = pwd.getpwnam(user)[2]
+ gid = grp.getgrnam(mm_cfg.MAILMAN_GROUP)[2]
+ os.chown(dbfile, uid, gid)
+ else:
+ print
diff --git a/Mailman/MTA/Utils.py b/Mailman/MTA/Utils.py
new file mode 100644
index 00000000..f55a1ed3
--- /dev/null
+++ b/Mailman/MTA/Utils.py
@@ -0,0 +1,79 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Utilities for list creation/deletion hooks."""
+
+import os
+import pwd
+
+from Mailman import mm_cfg
+
+
+
+def getusername():
+ username = os.environ.get('USER') or os.environ.get('LOGNAME')
+ if not username:
+ import pwd
+ username = pwd.getpwuid(os.getuid())[0]
+ if not username:
+ username = '<unknown>'
+ return username
+
+
+
+def _makealiases_mailprog(listname):
+ wrapper = os.path.join(mm_cfg.WRAPPER_DIR, 'mailman')
+ # Most of the list alias extensions are quite regular. I.e. if the
+ # message is delivered to listname-foobar, it will be filtered to a
+ # program called foobar. There are two exceptions:
+ #
+ # 1) Messages to listname (no extension) go to the post script.
+ # 2) Messages to listname-admin go to the bounces script. This is for
+ # backwards compatibility and may eventually go away (we really have no
+ # need for the -admin address anymore).
+ #
+ # Seed this with the special cases.
+ aliases = [(listname, '"|%s post %s"' % (wrapper, listname)),
+ ]
+ for ext in ('admin', 'bounces', 'confirm', 'join', 'leave', 'owner',
+ 'request', 'subscribe', 'unsubscribe'):
+ aliases.append(('%s-%s' % (listname, ext),
+ '"|%s %s %s"' % (wrapper, ext, listname)))
+ return aliases
+
+
+
+def _makealiases_maildir(listname):
+ maildir = mm_cfg.MAILDIR_DIR
+ if not maildir.endswith('/'):
+ maildir += '/'
+ # Deliver everything using maildir style. This way there's no mail
+ # program, no forking and no wrapper necessary!
+ #
+ # Note, don't use this unless your MTA leaves the envelope recipient in
+ # Delivered-To:, Envelope-To:, or Apparently-To:
+ aliases = [(listname, maildir)]
+ for ext in ('admin', 'bounces', 'confirm', 'join', 'leave', 'owner',
+ 'request', 'subscribe', 'unsubscribe'):
+ aliases.append(('%s-%s' % (listname, ext), maildir))
+ return aliases
+
+
+
+if mm_cfg.USE_MAILDIR:
+ makealiases = _makealiases_maildir
+else:
+ makealiases = _makealiases_mailprog
diff --git a/Mailman/MTA/__init__.py b/Mailman/MTA/__init__.py
new file mode 100644
index 00000000..55cd5826
--- /dev/null
+++ b/Mailman/MTA/__init__.py
@@ -0,0 +1,15 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
diff --git a/Mailman/MailList.py b/Mailman/MailList.py
new file mode 100644
index 00000000..8cffef8c
--- /dev/null
+++ b/Mailman/MailList.py
@@ -0,0 +1,1346 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+"""The class representing a Mailman mailing list.
+
+Mixes in many task-specific classes.
+"""
+
+import sys
+import os
+import time
+import marshal
+import errno
+import re
+import shutil
+import socket
+import urllib
+import cPickle
+
+from cStringIO import StringIO
+from UserDict import UserDict
+from urlparse import urlparse
+from types import *
+
+import email.Iterators
+from email.Utils import getaddresses, formataddr, parseaddr
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman import LockFile
+from Mailman.UserDesc import UserDesc
+
+# base classes
+from Mailman.Archiver import Archiver
+from Mailman.Autoresponder import Autoresponder
+from Mailman.Bouncer import Bouncer
+from Mailman.Deliverer import Deliverer
+from Mailman.Digester import Digester
+from Mailman.GatewayManager import GatewayManager
+from Mailman.HTMLFormatter import HTMLFormatter
+from Mailman.ListAdmin import ListAdmin
+from Mailman.SecurityManager import SecurityManager
+from Mailman.TopicMgr import TopicMgr
+
+# gui components package
+from Mailman import Gui
+
+# other useful classes
+from Mailman import MemberAdaptor
+from Mailman.OldStyleMemberships import OldStyleMemberships
+from Mailman import Message
+from Mailman import Pending
+from Mailman import Site
+from Mailman.i18n import _
+from Mailman.Logging.Syslog import syslog
+
+EMPTYSTRING = ''
+
+
+
+# Use mixins here just to avoid having any one chunk be too large.
+class MailList(HTMLFormatter, Deliverer, ListAdmin,
+ Archiver, Digester, SecurityManager, Bouncer, GatewayManager,
+ Autoresponder, TopicMgr):
+
+ #
+ # A MailList object's basic Python object model support
+ #
+ def __init__(self, name=None, lock=1):
+ # No timeout by default. If you want to timeout, open the list
+ # unlocked, then lock explicitly.
+ #
+ # Only one level of mixin inheritance allowed
+ for baseclass in self.__class__.__bases__:
+ if hasattr(baseclass, '__init__'):
+ baseclass.__init__(self)
+ # Initialize volatile attributes
+ self.InitTempVars(name)
+ # Default membership adaptor class
+ self._memberadaptor = OldStyleMemberships(self)
+ if name:
+ if lock:
+ # This will load the database.
+ self.Lock()
+ else:
+ self.Load()
+ # This extension mechanism allows list-specific overrides of any
+ # method (well, except __init__(), InitTempVars(), and InitVars()
+ # I think).
+ filename = os.path.join(self.fullpath(), 'extend.py')
+ dict = {}
+ try:
+ execfile(filename, dict)
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ else:
+ func = dict.get('extend')
+ if func:
+ func(self)
+
+ def __getattr__(self, name):
+ # Because we're using delegation, we want to be sure that attribute
+ # access to a delegated member function gets passed to the
+ # sub-objects. This of course imposes a specific name resolution
+ # order.
+ try:
+ return getattr(self._memberadaptor, name)
+ except AttributeError:
+ for guicomponent in self._gui:
+ try:
+ return getattr(guicomponent, name)
+ except AttributeError:
+ pass
+ else:
+ raise AttributeError, name
+
+ def __repr__(self):
+ if self.Locked():
+ status = '(locked)'
+ else:
+ status = '(unlocked)'
+ return '<mailing list "%s" %s at %x>' % (
+ self.internal_name(), status, id(self))
+
+
+ #
+ # Lock management
+ #
+ def Lock(self, timeout=0):
+ self.__lock.lock(timeout)
+ # Must reload our database for consistency. Watch out for lists that
+ # don't exist.
+ try:
+ self.Load()
+ except Exception:
+ self.Unlock()
+ raise
+
+ def Unlock(self):
+ self.__lock.unlock(unconditionally=1)
+
+ def Locked(self):
+ return self.__lock.locked()
+
+
+
+ #
+ # Useful accessors
+ #
+ def internal_name(self):
+ return self._internal_name
+
+ def fullpath(self):
+ return self._full_path
+
+ def getListAddress(self, extra=None):
+ if extra is None:
+ return '%s@%s' % (self.internal_name(), self.host_name)
+ return '%s-%s@%s' % (self.internal_name(), extra, self.host_name)
+
+ # For backwards compatibility
+ def GetBouncesEmail(self):
+ return self.getListAddress('bounces')
+
+ def GetOwnerEmail(self):
+ return self.getListAddress('owner')
+
+ def GetRequestEmail(self):
+ return self.getListAddress('request')
+
+ def GetConfirmEmail(self, cookie):
+ return mm_cfg.VERP_CONFIRM_FORMAT % {
+ 'addr' : '%s-confirm' % self.internal_name(),
+ 'cookie': cookie,
+ } + '@' + self.host_name
+
+ def GetListEmail(self):
+ return self.getListAddress()
+
+ 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, scriptname, absolute=0):
+ return Utils.ScriptURL(scriptname, self.web_page_url, absolute) + \
+ '/' + self.internal_name()
+
+ def GetOptionsURL(self, user, obscure=0, absolute=0):
+ url = self.GetScriptURL('options', absolute)
+ if obscure:
+ user = Utils.ObscureEmail(user)
+ return '%s/%s' % (url, urllib.quote(user.lower()))
+
+
+ #
+ # Instance and subcomponent initialization
+ #
+ def InitTempVars(self, name):
+ """Set transient variables of this and inherited classes."""
+ # The timestamp is set whenever we load the state from disk. If our
+ # timestamp is newer than the modtime of the config.pck file, we don't
+ # need to reload, otherwise... we do.
+ self.__timestamp = 0
+ self.__lock = LockFile.LockFile(
+ os.path.join(mm_cfg.LOCK_DIR, name or '<site>') + '.lock',
+ # TBD: is this a good choice of lifetime?
+ lifetime = mm_cfg.LIST_LOCK_LIFETIME,
+ withlogging = mm_cfg.LIST_LOCK_DEBUGGING)
+ self._internal_name = name
+ if name:
+ self._full_path = Site.get_listpath(name)
+ else:
+ self._full_path = None
+ # Only one level of mixin inheritance allowed
+ for baseclass in self.__class__.__bases__:
+ if hasattr(baseclass, 'InitTempVars'):
+ baseclass.InitTempVars(self)
+ # Now, initialize our gui components
+ self._gui = []
+ for component in dir(Gui):
+ if component.startswith('_'):
+ continue
+ self._gui.append(getattr(Gui, component)())
+
+ def InitVars(self, name=None, admin='', crypted_password=''):
+ """Assign default values - some will be overriden by stored state."""
+ # Non-configurable list info
+ if name:
+ self._internal_name = name
+
+ # When was the list created?
+ self.created_at = time.time()
+
+ # Must save this state, even though it isn't configurable
+ self.volume = 1
+ self.members = {} # self.digest_members is initted in mm_digest
+ self.data_version = mm_cfg.DATA_FILE_VERSION
+ self.last_post_time = 0
+
+ self.post_id = 1. # A float so it never has a chance to overflow.
+ self.user_options = {}
+ self.language = {}
+ self.usernames = {}
+ self.passwords = {}
+ self.new_member_options = mm_cfg.DEFAULT_NEW_MEMBER_OPTIONS
+
+ # This stuff is configurable
+ self.respond_to_post_requests = 1
+ self.advertised = mm_cfg.DEFAULT_LIST_ADVERTISED
+ self.max_num_recipients = mm_cfg.DEFAULT_MAX_NUM_RECIPIENTS
+ self.max_message_size = mm_cfg.DEFAULT_MAX_MESSAGE_SIZE
+ # See the note in Defaults.py concerning DEFAULT_HOST_NAME
+ # vs. DEFAULT_EMAIL_HOST.
+ self.host_name = mm_cfg.DEFAULT_HOST_NAME or mm_cfg.DEFAULT_EMAIL_HOST
+ self.web_page_url = (
+ mm_cfg.DEFAULT_URL or
+ mm_cfg.DEFAULT_URL_PATTERN % mm_cfg.DEFAULT_URL_HOST)
+ self.owner = [admin]
+ self.moderator = []
+ self.reply_goes_to_list = mm_cfg.DEFAULT_REPLY_GOES_TO_LIST
+ self.reply_to_address = ''
+ self.first_strip_reply_to = mm_cfg.DEFAULT_FIRST_STRIP_REPLY_TO
+ self.admin_immed_notify = mm_cfg.DEFAULT_ADMIN_IMMED_NOTIFY
+ self.admin_notify_mchanges = \
+ mm_cfg.DEFAULT_ADMIN_NOTIFY_MCHANGES
+ self.require_explicit_destination = \
+ mm_cfg.DEFAULT_REQUIRE_EXPLICIT_DESTINATION
+ self.acceptable_aliases = mm_cfg.DEFAULT_ACCEPTABLE_ALIASES
+ self.umbrella_list = mm_cfg.DEFAULT_UMBRELLA_LIST
+ self.umbrella_member_suffix = \
+ mm_cfg.DEFAULT_UMBRELLA_MEMBER_ADMIN_SUFFIX
+ self.send_reminders = mm_cfg.DEFAULT_SEND_REMINDERS
+ self.send_welcome_msg = mm_cfg.DEFAULT_SEND_WELCOME_MSG
+ self.send_goodbye_msg = mm_cfg.DEFAULT_SEND_GOODBYE_MSG
+ self.bounce_matching_headers = \
+ mm_cfg.DEFAULT_BOUNCE_MATCHING_HEADERS
+ self.anonymous_list = mm_cfg.DEFAULT_ANONYMOUS_LIST
+ internalname = self.internal_name()
+ self.real_name = internalname[0].upper() + internalname[1:]
+ self.description = ''
+ self.info = ''
+ self.welcome_msg = ''
+ self.goodbye_msg = ''
+ self.subscribe_policy = mm_cfg.DEFAULT_SUBSCRIBE_POLICY
+ self.unsubscribe_policy = mm_cfg.DEFAULT_UNSUBSCRIBE_POLICY
+ self.private_roster = mm_cfg.DEFAULT_PRIVATE_ROSTER
+ self.obscure_addresses = mm_cfg.DEFAULT_OBSCURE_ADDRESSES
+ self.admin_member_chunksize = mm_cfg.DEFAULT_ADMIN_MEMBER_CHUNKSIZE
+ self.administrivia = mm_cfg.DEFAULT_ADMINISTRIVIA
+ self.preferred_language = mm_cfg.DEFAULT_SERVER_LANGUAGE
+ self.available_languages = []
+ self.include_rfc2369_headers = 1
+ self.include_list_post_header = 1
+ self.filter_mime_types = mm_cfg.DEFAULT_FILTER_MIME_TYPES
+ self.pass_mime_types = mm_cfg.DEFAULT_PASS_MIME_TYPES
+ self.filter_content = mm_cfg.DEFAULT_FILTER_CONTENT
+ self.convert_html_to_plaintext = \
+ mm_cfg.DEFAULT_CONVERT_HTML_TO_PLAINTEXT
+ self.filter_action = mm_cfg.DEFAULT_FILTER_ACTION
+ # Analogs to these are initted in Digester.InitVars
+ self.nondigestable = mm_cfg.DEFAULT_NONDIGESTABLE
+ self.personalize = 0
+ # New sender-centric moderation (privacy) options
+ self.default_member_moderation = \
+ mm_cfg.DEFAULT_DEFAULT_MEMBER_MODERATION
+ # Emergency moderation bit
+ self.emergency = 0
+ # This really ought to default to mm_cfg.HOLD, but that doesn't work
+ # with the current GUI description model. So, 0==Hold, 1==Reject,
+ # 2==Discard
+ self.member_moderation_action = 0
+ self.member_moderation_notice = ''
+ self.accept_these_nonmembers = []
+ self.hold_these_nonmembers = []
+ self.reject_these_nonmembers = []
+ self.discard_these_nonmembers = []
+ self.forward_auto_discards = mm_cfg.DEFAULT_FORWARD_AUTO_DISCARDS
+ self.generic_nonmember_action = mm_cfg.DEFAULT_GENERIC_NONMEMBER_ACTION
+ # Ban lists
+ self.ban_list = []
+ # BAW: This should really be set in SecurityManager.InitVars()
+ self.password = crypted_password
+ # 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 = {}
+
+ # Only one level of mixin inheritance allowed
+ for baseclass in self.__class__.__bases__:
+ if hasattr(baseclass, 'InitVars'):
+ baseclass.InitVars(self)
+
+ # These need to come near the bottom because they're dependent on
+ # other settings.
+ self.subject_prefix = mm_cfg.DEFAULT_SUBJECT_PREFIX % self.__dict__
+ self.msg_header = mm_cfg.DEFAULT_MSG_HEADER
+ self.msg_footer = mm_cfg.DEFAULT_MSG_FOOTER
+ # Set this to Never if the list's preferred language uses us-ascii,
+ # otherwise set it to As Needed
+ if Utils.GetCharSet(self.preferred_language) == 'us-ascii':
+ self.encode_ascii_prefixes = 0
+ else:
+ self.encode_ascii_prefixes = 2
+
+
+ #
+ # Web API support via administrative categories
+ #
+ def GetConfigCategories(self):
+ class CategoryDict(UserDict):
+ def __init__(self):
+ UserDict.__init__(self)
+ self.keysinorder = mm_cfg.ADMIN_CATEGORIES[:]
+ def keys(self):
+ return self.keysinorder
+ def items(self):
+ items = []
+ for k in mm_cfg.ADMIN_CATEGORIES:
+ items.append((k, self.data[k]))
+ return items
+ def values(self):
+ values = []
+ for k in mm_cfg.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
+
+
+ #
+ # List creation
+ #
+ def Create(self, name, admin, crypted_password, langs=None):
+ if Utils.list_exists(name):
+ raise Errors.MMListAlreadyExistsError, name
+ # Validate what will be the list's posting address. If that's
+ # invalid, we don't want to create the mailing list. The hostname
+ # part doesn't really matter, since that better already be valid.
+ # However, most scripts already catch MMBadEmailError as exceptions on
+ # the admin's email address, so transform the exception.
+ postingaddr = '%s@%s' % (name, mm_cfg.DEFAULT_EMAIL_HOST)
+ try:
+ Utils.ValidateEmail(postingaddr)
+ except Errors.MMBadEmailError:
+ raise Errors.BadListNameError, postingaddr
+ # Validate the admin's email address
+ Utils.ValidateEmail(admin)
+ self._internal_name = name
+ self._full_path = Site.get_listpath(name, create=1)
+ # Don't use Lock() since that tries to load the non-existant config.pck
+ self.__lock.lock()
+ self.InitVars(name, admin, crypted_password)
+ self.CheckValues()
+ if langs is None:
+ self.available_languages = [self.preferred_language]
+ else:
+ self.available_languages = langs
+
+
+
+ #
+ # Database and filesystem I/O
+ #
+ def __save(self, dict):
+ # Save the file as a binary pickle, and rotate the old version to a
+ # backup file. We must guarantee that config.pck is always valid so
+ # we never rotate unless the we've successfully written the temp file.
+ # We use pickle now because marshal is not guaranteed to be compatible
+ # between Python versions.
+ fname = os.path.join(self.fullpath(), 'config.pck')
+ fname_tmp = fname + '.tmp.%s.%d' % (socket.gethostname(), os.getpid())
+ fname_last = fname + '.last'
+ fp = None
+ try:
+ fp = open(fname_tmp, 'w')
+ # Use a binary format... it's more efficient.
+ cPickle.dump(dict, fp, 1)
+ fp.close()
+ except IOError, e:
+ syslog('error',
+ 'Failed config.pck write, retaining old state.\n%s', e)
+ if fp is not None:
+ os.unlink(fname_tmp)
+ raise
+ # Now do config.pck.tmp.xxx -> config.pck -> config.pck.last rotation
+ # as safely as possible.
+ try:
+ # might not exist yet
+ os.unlink(fname_last)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ try:
+ # might not exist yet
+ os.link(fname, fname_last)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ os.rename(fname_tmp, fname)
+ # Reset the timestamp
+ self.__timestamp = os.path.getmtime(fname)
+
+ def Save(self):
+ # Refresh the lock, just to let other processes know we're still
+ # interested in it. This will raise a NotLockedError if we don't have
+ # the lock (which is a serious problem!). TBD: do we need to be more
+ # defensive?
+ self.__lock.refresh()
+ # copy all public attributes to serializable dictionary
+ dict = {}
+ for key, value in self.__dict__.items():
+ if key[0] == '_' or type(value) is MethodType:
+ continue
+ dict[key] = value
+ # Make config.pck unreadable by `other', as it contains all the
+ # list members' passwords (in clear text).
+ omask = os.umask(007)
+ try:
+ self.__save(dict)
+ finally:
+ os.umask(omask)
+ self.SaveRequestsDb()
+ self.CheckHTMLArchiveDir()
+
+ def __load(self, dbfile):
+ # Attempt to load and unserialize the specified database file. This
+ # could actually be a config.db (for pre-2.1alpha3) or config.pck,
+ # i.e. a marshal or a binary pickle. Actually, it could also be a
+ # .last backup file if the primary storage file was corrupt. The
+ # decision on whether to unpickle or unmarshal is based on the file
+ # extension, but we always save it using pickle (since only it, and
+ # not marshal is guaranteed to be compatible across Python versions).
+ #
+ # On success return a 2-tuple of (dictionary, None). On error, return
+ # a 2-tuple of the form (None, errorobj).
+ if dbfile.endswith('.db') or dbfile.endswith('.db.last'):
+ loadfunc = marshal.load
+ elif dbfile.endswith('.pck') or dbfile.endswith('.pck.last'):
+ loadfunc = cPickle.load
+ else:
+ assert 0, 'Bad database file name'
+ try:
+ # Check the mod time of the file first. If it matches our
+ # timestamp, then the state hasn't change since the last time we
+ # loaded it. Otherwise open the file for loading, below. If the
+ # file doesn't exist, we'll get an EnvironmentError with errno set
+ # to ENOENT (EnvironmentError is the base class of IOError and
+ # OSError).
+ mtime = os.path.getmtime(dbfile)
+ if mtime <= self.__timestamp:
+ # File is not newer
+ return None, None
+ fp = open(dbfile)
+ except EnvironmentError, e:
+ if e.errno <> errno.ENOENT: raise
+ # The file doesn't exist yet
+ return None, e
+ try:
+ try:
+ dict = loadfunc(fp)
+ if type(dict) <> DictType:
+ return None, 'Load() expected to return a dictionary'
+ except (EOFError, ValueError, TypeError, MemoryError,
+ cPickle.PicklingError), e:
+ return None, e
+ finally:
+ fp.close()
+ # Update timestamp
+ self.__timestamp = mtime
+ return dict, None
+
+ def Load(self, check_version=1):
+ if not Utils.list_exists(self.internal_name()):
+ raise Errors.MMUnknownListError
+ # We first try to load config.pck, which contains the up-to-date
+ # version of the database. If that fails, perhaps because it's
+ # corrupted or missing, we'll try to load the backup file
+ # config.pck.last.
+ #
+ # Should both of those fail, we'll look for config.db and
+ # config.db.last for backwards compatibility with pre-2.1alpha3
+ pfile = os.path.join(self.fullpath(), 'config.pck')
+ plast = pfile + '.last'
+ dfile = os.path.join(self.fullpath(), 'config.db')
+ dlast = dfile + '.last'
+ for file in (pfile, plast, dfile, dlast):
+ dict, e = self.__load(file)
+ if dict is None:
+ if e is not None:
+ # Had problems with this file; log it and try the next one.
+ syslog('error', "couldn't load config file %s\n%s",
+ file, e)
+ else:
+ # We already have the most up-to-date state
+ return
+ else:
+ break
+ else:
+ # Nothing worked, so we have to give up
+ syslog('error', 'All %s fallbacks were corrupt, giving up',
+ self.internal_name())
+ raise Errors.MMCorruptListDatabaseError, e
+ # Now, if we didn't end up using the primary database file, we want to
+ # copy the fallback into the primary so that the logic in Save() will
+ # still work. For giggles, we'll copy it to a safety backup.
+ if file == plast:
+ shutil.copy(file, pfile)
+ shutil.copy(file, pfile + '.safety')
+ elif file == dlast:
+ shutil.copy(file, dfile)
+ shutil.copy(file, pfile + '.safety')
+ # Copy the loaded dictionary into the attributes of the current
+ # mailing list object, then run sanity check on the data.
+ self.__dict__.update(dict)
+ if check_version:
+ self.CheckVersion(dict)
+ self.CheckValues()
+
+
+ #
+ # Sanity checks
+ #
+ def CheckVersion(self, stored_state):
+ """Auto-update schema if necessary."""
+ if self.data_version >= mm_cfg.DATA_FILE_VERSION:
+ return
+ # Initialize any new variables
+ self.InitVars()
+ # Then reload the database (but don't recurse). Force a reload even
+ # if we have the most up-to-date state.
+ self.__timestamp = 0
+ self.Load(check_version=0)
+ # We must hold the list lock in order to update the schema
+ waslocked = self.Locked()
+ if not waslocked:
+ self.Lock()
+ try:
+ from versions import Update
+ Update(self, stored_state)
+ self.data_version = mm_cfg.DATA_FILE_VERSION
+ self.Save()
+ finally:
+ if not waslocked:
+ self.Unlock()
+
+ def CheckValues(self):
+ """Normalize selected values to known formats."""
+ if '' in urlparse(self.web_page_url)[:2]:
+ # Either the "scheme" or the "network location" part of the parsed
+ # URL is empty; substitute faulty value with (hopefully sane)
+ # default. Note that DEFAULT_URL is obsolete.
+ self.web_page_url = (
+ mm_cfg.DEFAULT_URL or
+ mm_cfg.DEFAULT_URL_PATTERN % mm_cfg.DEFAULT_URL_HOST)
+ if self.web_page_url and self.web_page_url[-1] <> '/':
+ self.web_page_url = self.web_page_url + '/'
+ # Legacy reply_to_address could be an illegal value. We now verify
+ # upon setting and don't check it at the point of use.
+ try:
+ if self.reply_to_address.strip() and self.reply_goes_to_list:
+ Utils.ValidateEmail(self.reply_to_address)
+ except Errors.EmailAddressError:
+ syslog('error', 'Bad reply_to_address "%s" cleared for list: %s',
+ self.reply_to_address, self.internal_name())
+ self.reply_to_address = ''
+ self.reply_goes_to_list = 0
+ # Legacy topics may have bad regular expressions in their patterns
+ goodtopics = []
+ for name, pattern, desc, emptyflag in self.topics:
+ try:
+ re.compile(pattern)
+ except (re.error, TypeError):
+ syslog('error', 'Bad topic pattern "%s" for list: %s',
+ pattern, self.internal_name())
+ else:
+ goodtopics.append((name, pattern, desc, emptyflag))
+ self.topics = goodtopics
+
+
+ #
+ # 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
+ requestaddr = self.GetRequestEmail()
+ # 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
+ userdesc.invitation = 1
+ cookie = Pending.new(Pending.SUBSCRIPTION, userdesc)
+ 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)
+ if mm_cfg.VERP_CONFIRMATIONS:
+ subj = _(
+ 'You have been invited to join the %(listname)s mailing list')
+ sender = self.GetConfirmEmail(cookie)
+ else:
+ # Do it the old fashioned way
+ subj = 'confirm ' + cookie
+ sender = requestaddr
+ msg = Message.UserNotification(
+ invitee, sender, subj,
+ text, lang=self.preferred_language)
+ 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.MMBadEmailError
+
+ # Is the subscribing address banned from this list?
+ ban = 0
+ for pattern in self.ban_list:
+ if pattern.startswith('^'):
+ # This is a regular expression match
+ try:
+ if re.search(pattern, email, re.IGNORECASE):
+ ban = 1
+ break
+ except re.error:
+ # BAW: we should probably remove this pattern
+ pass
+ else:
+ # Do the comparison case insensitively
+ if pattern.lower() == email.lower():
+ ban = 1
+ break
+ if ban:
+ syslog('vette', 'banned subscription: %s (matched: %s)',
+ 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)
+ elif self.subscribe_policy == 1 or self.subscribe_policy == 3:
+ # User confirmation required. BAW: this should probably just
+ # accept a userdesc instance.
+ cookie = Pending.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)
+ realname = self.real_name
+ 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.GetRequestEmail(),
+ 'remote' : remote,
+ 'listadmin' : self.GetOwnerEmail(),
+ 'confirmurl' : confirmurl,
+ }, lang=lang, mlist=self)
+ msg = Message.UserNotification(
+ recipient, self.GetRequestEmail(),
+ text=text, lang=lang)
+ # BAW: See ChangeMemberAddress() for why we do it this way...
+ del msg['subject']
+ msg['Subject'] = 'confirm ' + cookie
+ msg['Reply-To'] = self.GetRequestEmail()
+ msg.send(self)
+ who = formataddr((name, email))
+ syslog('subscribe', '%s: pending %s %s',
+ self.internal_name(), who, by)
+ raise Errors.MMSubscribeNeedsConfirmation
+ 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 ApprovedAddMember(self, userdesc, ack=None, admin_notif=None, text=''):
+ """Add a member right now.
+
+ The member's subscription must be approved by what ever policy the
+ list enforces.
+
+ userdesc is as above in AddMember().
+
+ ack is a flag that specifies whether the user should get an
+ acknowledgement of their being subscribed. Default is to use the
+ list's default flag value.
+
+ admin_notif is a flag that specifies whether the list owner should get
+ an acknowledgement of this subscription. Default is to use the list's
+ default flag value.
+ """
+ assert self.Locked()
+ # Set up default flag values
+ if ack is None:
+ ack = self.send_welcome_msg
+ if admin_notif is None:
+ admin_notif = self.admin_notify_mchanges
+ # Suck values out of userdesc, and apply defaults.
+ 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
+ # Let's be extra cautious
+ Utils.ValidateEmail(email)
+ if self.isMember(email):
+ raise Errors.MMAlreadyAMember, email
+ # Do the actual addition
+ self.addNewMember(email, realname=name, digest=digest,
+ password=password, language=lang)
+ self.setMemberOption(email, mm_cfg.DisableMime,
+ 1 - self.mime_is_default_digest)
+ self.setMemberOption(email, mm_cfg.Moderate,
+ self.default_member_moderation)
+ # Now send and log results
+ if digest:
+ kind = ' (digest)'
+ else:
+ kind = ''
+ syslog('subscribe', '%s: new%s %s', self.internal_name(),
+ kind, formataddr((email, name)))
+ if ack:
+ self.SendSubscribeAck(email, self.getMemberPassword(email),
+ digest, text)
+ if admin_notif:
+ realname = self.real_name
+ subject = _('%(realname)s subscription notification')
+ text = Utils.maketext(
+ "adminsubscribeack.txt",
+ {"listname" : self.real_name,
+ "member" : formataddr((name, email)),
+ }, mlist=self)
+ msg = Message.OwnerNotification(self, subject, text)
+ msg.send(self)
+
+ def DeleteMember(self, name, whence=None, admin_notif=0, userack=1):
+ 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 ApprovedDeleteMember(self, name, whence=None,
+ admin_notif=None, userack=None):
+ if userack is None:
+ userack = self.send_goodbye_msg
+ if admin_notif is None:
+ admin_notif = self.admin_notify_mchanges
+ # Delete a member, for which we know the approval has been made
+ fullname, emailaddr = parseaddr(name)
+ userlang = self.getMemberLanguage(emailaddr)
+ # Remove the member
+ self.removeMember(emailaddr)
+ # And send an acknowledgement to the user...
+ if userack:
+ self.SendUnsubscribeAck(emailaddr, userlang)
+ # ...and to the administrator
+ if admin_notif:
+ realname = self.real_name
+ subject = _('%(realname)s unsubscribe notification')
+ text = Utils.maketext(
+ 'adminunsubscribeack.txt',
+ {'member' : name,
+ 'listname': self.real_name,
+ }, mlist=self)
+ msg = Message.OwnerNotification(self, subject, text)
+ msg.send(self)
+ if whence:
+ whence = "; %s" % whence
+ else:
+ whence = ""
+ syslog('subscribe', '%s: deleted %s%s',
+ self.internal_name(), name, whence)
+
+ def ChangeMemberName(self, addr, name, globally):
+ self.setMemberName(addr, name)
+ if not globally:
+ return
+ for listname in Utils.list_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(addr):
+ continue
+ mlist.Lock()
+ try:
+ mlist.setMemberName(addr, name)
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+ 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.
+ if newaddr == oldaddr and self.isMember(newaddr):
+ raise Errors.MMAlreadyAMember
+ if newaddr == self.GetListEmail().lower():
+ raise Errors.MMBadEmailError
+ # Pend the subscription change
+ cookie = Pending.new(Pending.CHANGE_OF_ADDRESS,
+ oldaddr, newaddr, globally)
+ confirmurl = '%s/%s' % (self.GetScriptURL('confirm', absolute=1),
+ cookie)
+ realname = self.real_name
+ lang = self.getMemberLanguage(oldaddr)
+ text = Utils.maketext(
+ 'verify.txt',
+ {'email' : newaddr,
+ 'listaddr' : self.GetListEmail(),
+ 'listname' : realname,
+ 'cookie' : cookie,
+ 'requestaddr': self.GetRequestEmail(),
+ '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(),
+ text=text, lang=lang)
+ del msg['subject']
+ msg['Subject'] = 'confirm ' + cookie
+ msg['Reply-To'] = self.GetRequestEmail()
+ msg.send(self)
+
+ def ApprovedChangeMemberAddress(self, oldaddr, newaddr, globally):
+ # Change the membership for the current list first. We don't lock and
+ # save ourself since we assume that the list is already locked.
+ if self.isMember(newaddr):
+ # Just delete the old address
+ if self.isMember(oldaddr):
+ self.ApprovedDeleteMember(oldaddr, admin_notif=1, userack=1)
+ else:
+ self.changeMemberAddress(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 Utils.list_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) or mlist.isMember(newaddr):
+ continue
+ mlist.Lock()
+ try:
+ mlist.changeMemberAddress(oldaddr, newaddr)
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+
+
+ #
+ # Confirmation processing
+ #
+ def ProcessConfirmation(self, cookie, context=None):
+ data = Pending.confirm(cookie)
+ if data is None:
+ raise Errors.MMBadConfirmation, 'data is None'
+ try:
+ op = data[0]
+ data = data[1:]
+ except ValueError:
+ raise Errors.MMBadConfirmation, 'op-less data %s' % (data,)
+ if op == Pending.SUBSCRIPTION:
+ 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
+ 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', 0)
+ # 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 not invitation and self.subscribe_policy in (2, 3):
+ self.HoldSubscription(addr, fullname, password, digest, lang)
+ name = self.real_name
+ raise Errors.MMNeedApproval, _(
+ 'subscriptions to %(name)s require administrator approval')
+ self.ApprovedAddMember(userdesc)
+ 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, otherwise it's an approval
+ # (if the passwords match).
+ 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 1:
+ 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
+ # Okay, does the approved header match the list password?
+ if approved and self.Authenticate([mm_cfg.AuthListAdmin,
+ mm_cfg.AuthListModerator],
+ approved) <> mm_cfg.UnAuthorized:
+ action = mm_cfg.APPROVE
+ else:
+ action = mm_cfg.DISCARD
+ try:
+ self.HandleRequest(id, action)
+ except KeyError:
+ # Most likely because the message has already been disposed of
+ # via the admindb page.
+ syslog('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
+
+ def ConfirmUnsubscription(self, addr, lang=None, remote=None):
+ if lang is None:
+ lang = self.getMemberLanguage(addr)
+ cookie = Pending.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.GetRequestEmail(),
+ 'remote' : remote,
+ 'listadmin' : self.GetOwnerEmail(),
+ 'confirmurl' : confirmurl,
+ }, lang=lang, mlist=self)
+ msg = Message.UserNotification(
+ addr, self.GetRequestEmail(),
+ text=text, lang=lang)
+ # BAW: See ChangeMemberAddress() for why we do it this way...
+ del msg['subject']
+ msg['Subject'] = 'confirm ' + cookie
+ msg['Reply-To'] = self.GetRequestEmail()
+ msg.send(self)
+
+
+ #
+ # Miscellaneous stuff
+ #
+ def HasExplicitDest(self, msg):
+ """True if list name or any acceptable_alias is included among the
+ to or cc addrs."""
+ # BAW: fall back to Utils.ParseAddr if the first test fails.
+ # this is the list's full address
+ listfullname = '%s@%s' % (self.internal_name(), self.host_name)
+ recips = []
+ # check all recipient addresses against the list's explicit addresses,
+ # specifically To: Cc: and Resent-to:
+ to = []
+ for header in ('to', 'cc', 'resent-to', 'resent-cc'):
+ to.extend(getaddresses(msg.get_all(header, [])))
+ for fullname, addr in to:
+ # It's possible that if the header doesn't have a valid
+ # (i.e. RFC822) value, we'll get None for the address. So skip
+ # it.
+ if addr is None:
+ continue
+ addr = addr.lower()
+ localpart = addr.split('@')[0]
+ if (# TBD: backwards compatibility: deprecated
+ localpart == self.internal_name() or
+ # exact match against the complete list address
+ addr == listfullname):
+ return 1
+ recips.append((addr, localpart))
+ #
+ # helper function used to match a pattern against an address. Do it
+ def domatch(pattern, addr):
+ try:
+ if re.match(pattern, addr):
+ return 1
+ except re.error:
+ # The pattern is a malformed regexp -- try matching safely,
+ # with all non-alphanumerics backslashed:
+ if re.match(re.escape(pattern), addr):
+ return 1
+ #
+ # Here's the current algorithm for matching acceptable_aliases:
+ #
+ # 1. If the pattern does not have an `@' in it, we first try matching
+ # it against just the localpart. This was the behavior prior to
+ # 2.0beta3, and is kept for backwards compatibility.
+ # (deprecated).
+ #
+ # 2. If that match fails, or the pattern does have an `@' in it, we
+ # try matching against the entire recip address.
+ for addr, localpart in recips:
+ for alias in self.acceptable_aliases.split('\n'):
+ stripped = alias.strip()
+ if not stripped:
+ # ignore blank or empty lines
+ continue
+ if '@' not in stripped and domatch(stripped, localpart):
+ return 1
+ if domatch(stripped, addr):
+ return 1
+ return 0
+
+ def parse_matching_header_opt(self):
+ """Return a list of triples [(field name, regex, line), ...]."""
+ # - Blank lines and lines with '#' as first char are skipped.
+ # - Leading whitespace in the matchexp is trimmed - you can defeat
+ # that by, eg, containing it in gratuitous square brackets.
+ all = []
+ for line in self.bounce_matching_headers.split('\n'):
+ line = line.strip()
+ # Skip blank lines and lines *starting* with a '#'.
+ if not line or line[0] == "#":
+ continue
+ i = line.find(':')
+ if i < 0:
+ # This didn't look like a header line. BAW: should do a
+ # better job of informing the list admin.
+ syslog('config', 'bad bounce_matching_header line: %s\n%s',
+ self.real_name, line)
+ else:
+ header = line[:i]
+ value = line[i+1:].lstrip()
+ try:
+ cre = re.compile(value, re.IGNORECASE)
+ except re.error, e:
+ # The regexp was malformed. BAW: should do a better
+ # job of informing the list admin.
+ syslog('config', '''\
+bad regexp in bounce_matching_header line: %s
+\n%s (cause: %s)''', self.real_name, value, e)
+ else:
+ all.append((header, cre, line))
+ return all
+
+ def hasMatchingHeader(self, msg):
+ """Return true if named header field matches a regexp in the
+ bounce_matching_header list variable.
+
+ Returns constraint line which matches or empty string for no
+ matches.
+ """
+ for header, cre, line in self.parse_matching_header_opt():
+ for value in msg.get_all(header, []):
+ if cre.search(value):
+ return line
+ return 0
+
+ def autorespondToSender(self, sender):
+ """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.
+ """
+ # No limit
+ if mm_cfg.MAX_AUTORESPONSES_PER_DAY == 0:
+ return 1
+ today = time.localtime()[:3]
+ info = self.hold_and_cmd_autoresponses.get(sender)
+ if info is None or info[0] <> today:
+ # First time we've seen a -request/post-hold for this sender
+ self.hold_and_cmd_autoresponses[sender] = (today, 1)
+ # BAW: no check for MAX_AUTORESPONSES_PER_DAY <= 1
+ return 1
+ date, count = info
+ if count < 0:
+ # They've already hit the limit for today.
+ syslog('vette', '-request/hold autoresponse discarded for: %s',
+ sender)
+ return 0
+ if count >= mm_cfg.MAX_AUTORESPONSES_PER_DAY:
+ syslog('vette', '-request/hold autoresponse limit hit for: %s',
+ sender)
+ self.hold_and_cmd_autoresponses[sender] = (today, -1)
+ # Send this notification message instead
+ text = Utils.maketext(
+ 'nomoretoday.txt',
+ {'sender' : sender,
+ 'listname': '%s@%s' % (self.real_name, self.host_name),
+ 'num' : count,
+ 'owneremail': self.GetOwnerEmail(),
+ })
+ msg = Message.UserNotification(
+ sender, self.GetOwnerEmail(),
+ _('Last autoresponse notification for today'),
+ text)
+ msg.send(self)
+ return 0
+ self.hold_and_cmd_autoresponses[sender] = (today, count+1)
+ return 1
+
+
+
+ #
+ # Multilingual (i18n) support
+ #
+ def GetAvailableLanguages(self):
+ langs = self.available_languages
+ # 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 mm_cfg.DEFAULT_SERVER_LANGUAGE not in langs:
+ langs.append(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+ return langs
diff --git a/Mailman/Mailbox.py b/Mailman/Mailbox.py
new file mode 100644
index 00000000..8ab085cc
--- /dev/null
+++ b/Mailman/Mailbox.py
@@ -0,0 +1,101 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Extend mailbox.UnixMailbox.
+"""
+
+import sys
+import mailbox
+
+import email
+from email.Generator import Generator
+from email.Parser import Parser
+from email.Errors import MessageParseError
+
+from Mailman import mm_cfg
+from Mailman.Message import Message
+
+
+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(msg, unixfrom=1)
+
+
+
+# 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 <wink> 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):
+ if mm_cfg.ARCHIVE_SCRUBBER:
+ __import__(mm_cfg.ARCHIVE_SCRUBBER)
+ self._scrubber = sys.modules[mm_cfg.ARCHIVE_SCRUBBER].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/Mailman/Makefile.in b/Mailman/Makefile.in
new file mode 100644
index 00000000..d6fec07b
--- /dev/null
+++ b/Mailman/Makefile.in
@@ -0,0 +1,99 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VERSION= @VERSION@
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman
+SHELL= /bin/sh
+
+MODULES= $(srcdir)/*.py
+SUBDIRS= Cgi Logging Archiver Handlers Bouncers Queue MTA Gui Commands
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+ for d in $(SUBDIRS); \
+ do \
+ (cd $$d; $(MAKE)); \
+ done
+
+install-here:
+ for f in $(MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $$f $(PACKAGEDIR); \
+ done
+ $(INSTALL) -m $(FILEMODE) mm_cfg.py.dist $(PACKAGEDIR)
+ if [ ! -f $(PACKAGEDIR)/mm_cfg.py ]; \
+ then \
+ $(INSTALL) -m $(FILEMODE) mm_cfg.py.dist $(PACKAGEDIR)/mm_cfg.py; \
+ fi
+
+install: install-here
+ for d in $(SUBDIRS); \
+ do \
+ (cd $$d; $(MAKE) install); \
+ done
+
+finish:
+ @for d in $(SUBDIRS); \
+ do \
+ (cd $$d; $(MAKE) finish); \
+ done
+
+clean:
+ for d in $(SUBDIRS); \
+ do \
+ (cd $$d; $(MAKE) clean); \
+ done
+
+distclean:
+ -rm Makefile Defaults.py mm_cfg.py.dist
+ -rm *.pyc
+ for d in $(SUBDIRS); \
+ do \
+ (cd $$d; $(MAKE) distclean); \
+ done
diff --git a/Mailman/MemberAdaptor.py b/Mailman/MemberAdaptor.py
new file mode 100644
index 00000000..dc24ea08
--- /dev/null
+++ b/Mailman/MemberAdaptor.py
@@ -0,0 +1,350 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""This is an interface to list-specific membership information.
+
+This class should not be instantiated directly, but instead, it should be
+subclassed for specific adaptation to membership databases. The default
+MM2.0.x style adaptor is in OldStyleMemberships.py. Through the extend.py
+mechanism, you can instantiate different membership information adaptors to
+get info out of LDAP, Zope, other, or any combination of the above.
+
+Members have three pieces of identifying information: a unique identifying
+opaque key (KEY), a lower-cased email address (LCE), and a case-preserved
+email (CPE) address. Adaptors must ensure that both member keys and lces can
+uniquely identify a member, and that they can (usually) convert freely between
+keys and lces. Most methods must accept either a key or an lce, unless
+specifically documented otherwise.
+
+The CPE is always used to calculate the recipient address for a message. Some
+remote MTAs make a distinction based on localpart case, so we always send
+messages to the case-preserved address. Note that DNS is case insensitive so
+it doesn't matter what the case is for the domain part of an email address,
+although by default, we case-preserve that too.
+
+The adaptors must support the readable interface for getting information about
+memberships, and may optionally support the writeable interface. If they do
+not, then members cannot change their list attributes via Mailman's web or
+email interfaces. Updating membership information in that case is the
+backend's responsibility. Adaptors are allowed to support parts of the
+writeable interface.
+
+For any writeable method not supported, a NotImplemented exception should be
+raised.
+"""
+
+# Delivery statuses
+ENABLED = 0 # enabled
+UNKNOWN = 1 # legacy disabled
+BYUSER = 2 # disabled by user choice
+BYADMIN = 3 # disabled by admin choice
+BYBOUNCE = 4 # disabled by bounces
+
+
+
+class MemberAdaptor:
+ #
+ # The readable interface
+ #
+ def getMembers(self):
+ """Get the LCE for all the members of the mailing list."""
+ raise NotImplemented
+
+ def getRegularMemberKeys(self):
+ """Get the LCE for all regular delivery members (i.e. non-digest)."""
+ raise NotImplemented
+
+ def getDigestMemberKeys(self):
+ """Get the LCE for all digest delivery members."""
+ raise NotImplemented
+
+ def isMember(self, member):
+ """Return 1 if member KEY/LCE is a valid member, otherwise 0."""
+
+ def getMemberKey(self, member):
+ """Return the KEY for the member KEY/LCE.
+
+ If member does not refer to a valid member, raise NotAMemberError.
+ """
+ raise NotImplemented
+
+ def getMemberCPAddress(self, member):
+ """Return the CPE for the member KEY/LCE.
+
+ If member does not refer to a valid member, raise NotAMemberError.
+ """
+ raise NotImplemented
+
+ def getMemberCPAddresses(self, members):
+ """Return a sequence of CPEs for the given sequence of members.
+
+ The returned sequence will be the same length as members. If any of
+ the KEY/LCEs in members does not refer to a valid member, that entry
+ in the returned sequence will be None (i.e. NotAMemberError is never
+ raised).
+ """
+ raise NotImplemented
+
+ def authenticateMember(self, member, response):
+ """Authenticate the member KEY/LCE with the given response.
+
+ If the response authenticates the member, return a secret that is
+ known only to the authenticated member. This need not be the member's
+ password, but it will be used to craft a session cookie, so it should
+ be persistent for the life of the session.
+
+ If the authentication failed return 0. If member did not refer to a
+ valid member, raise NotAMemberError.
+
+ Normally, the response will be the password typed into a web form or
+ given in an email command, but it needn't be. It is up to the adaptor
+ to compare the typed response to the user's authentication token.
+ """
+ raise NotImplemented
+
+ def getMemberPassword(self, member):
+ """Return the member's password.
+
+ If the member KEY/LCE is not a member of the list, raise
+ NotAMemberError.
+ """
+ raise NotImplemented
+
+ def getMemberLanguage(self, member):
+ """Return the preferred language for the member KEY/LCE.
+
+ The language returned must be a key in mm_cfg.LC_DESCRIPTIONS and the
+ mailing list must support that language.
+
+ If member does not refer to a valid member, the list's default
+ language is returned instead of raising a NotAMemberError error.
+ """
+ raise NotImplemented
+
+ def getMemberOption(self, member, flag):
+ """Return the boolean state of the member option for member KEY/LCE.
+
+ Option flags are defined in Defaults.py.
+
+ If member does not refer to a valid member, raise NotAMemberError.
+ """
+ raise NotImplemented
+
+ def getMemberName(self, member):
+ """Return the full name of the member KEY/LCE.
+
+ None is returned if the member has no registered full name. The
+ returned value may be a Unicode string if there are non-ASCII
+ characters in the name. NotAMemberError is raised if member does not
+ refer to a valid member.
+ """
+ raise NotImplemented
+
+ def getMemberTopics(self, member):
+ """Return the list of topics this member is interested in.
+
+ The return value is a list of strings which name the topics.
+ """
+ raise NotImplemented
+
+ def getDeliveryStatus(self, member):
+ """Return the delivery status of this member.
+
+ Value is one of the module constants:
+
+ ENABLED - The deliveries to the user are not disabled
+ UNKNOWN - Deliveries are disabled for unknown reasons. The
+ primary reason for this to happen is that we've copied
+ their delivery status from a legacy version which didn't
+ keep track of disable reasons
+ BYUSER - The user explicitly disable deliveries
+ BYADMIN - The list administrator explicitly disabled deliveries
+ BYBOUNCE - The system disabled deliveries due to bouncing
+
+ If member is not a member of the list, raise NotAMemberError.
+ """
+ raise NotImplemented
+
+ def getDeliveryStatusChangeTime(self, member):
+ """Return the time of the last disabled delivery status change.
+
+ If the current delivery status is ENABLED, the status change time will
+ be zero. If member is not a member of the list, raise
+ NotAMemberError.
+ """
+ raise NotImplemented
+
+ def getDeliveryStatusMembers(self,
+ status=(UNKNOWN, BYUSER, BYADMIN, BYBOUNCE)):
+ """Return the list of members with a matching delivery status.
+
+ Optional `status' if given, must be a sequence containing one or more
+ of ENABLED, UNKNOWN, BYUSER, BYADMIN, or BYBOUNCE. The members whose
+ delivery status is in this sequence are returned.
+ """
+ raise NotImplemented
+
+ def getBouncingMembers(self):
+ """Return the list of members who have outstanding bounce information.
+
+ This list of members doesn't necessarily overlap with
+ getDeliveryStatusMembers() since getBouncingMembers() will return
+ member who have bounced but not yet reached the disable threshold.
+ """
+ raise NotImplemented
+
+ def getBounceInfo(self, member):
+ """Return the member's bounce information.
+
+ A value of None means there is no bounce information registered for
+ the member.
+
+ Bounce info is opaque to the MemberAdaptor. It is set by
+ setBounceInfo() and returned by this method without modification.
+
+ If member is not a member of the list, raise NotAMemberError.
+ """
+ raise NotImplemented
+
+
+ #
+ # The writeable interface
+ #
+ def addNewMember(self, member, **kws):
+ """Subscribes a new member to the mailing list.
+
+ member is the case-preserved address to subscribe. The LCE is
+ calculated from this argument. Return the new member KEY.
+
+ This method also takes a keyword dictionary which can be used to set
+ additional attributes on the member. The actual set of supported
+ keywords is adaptor specific, but should at least include:
+
+ - digest == subscribing to digests instead of regular delivery
+ - password == user's password
+ - language == user's preferred language
+ - realname == user's full name (should be Unicode if there are
+ non-ASCII characters in the name)
+
+ Any values not passed to **kws is set to the adaptor-specific
+ defaults.
+
+ Raise AlreadyAMemberError it the member is already subscribed to the
+ list. Raises ValueError if **kws contains an invalid option.
+ """
+ raise NotImplemented
+
+ def removeMember(self, memberkey):
+ """Unsubscribes the member from the mailing list.
+
+ Raise NotAMemberError if member is not subscribed to the list.
+ """
+ raise NotImplemented
+
+ def changeMemberAddress(self, memberkey, newaddress, nodelete=0):
+ """Change the address for the member KEY.
+
+ memberkey will be a KEY, not an LCE. newaddress should be the
+ new case-preserved address for the member; the LCE will be calculated
+ from newaddress.
+
+ If memberkey does not refer to a valid member, raise NotAMemberError.
+ No verification on the new address is done here (such assertions
+ should be performed by the caller).
+
+ If nodelete flag is true, then the old membership is not removed.
+ """
+ raise NotImplemented
+
+ def setMemberPassword(self, member, password):
+ """Set the password for member LCE/KEY.
+
+ If member does not refer to a valid member, raise NotAMemberError.
+ Also raise BadPasswordError if the password is illegal (e.g. too
+ short or easily guessed via a dictionary attack).
+
+ """
+ raise NotImplemented
+
+ def setMemberLanguage(self, member, language):
+ """Set the language for the member LCE/KEY.
+
+ If member does not refer to a valid member, raise NotAMemberError.
+ Also raise BadLanguageError if the language is invalid (e.g. the list
+ is not configured to support the given language).
+ """
+ raise NotImplemented
+
+ def setMemberOption(self, member, flag, value):
+ """Set the option for the given member to value.
+
+ member is an LCE/KEY, flag is one of the option flags defined in
+ Default.py, and value is a boolean.
+
+ If member does not refer to a valid member, raise NotAMemberError.
+ Also raise BadOptionError if the flag does not refer to a valid
+ option.
+ """
+ raise NotImplemented
+
+ def setMemberName(self, member, realname):
+ """Set the member's full name.
+
+ member is an LCE/KEY and realname is an arbitrary string. It should
+ be a Unicode string if there are non-ASCII characters in the name.
+ NotAMemberError is raised if member does not refer to a valid member.
+ """
+ raise NotImplemented
+
+ def setMemberTopics(self, member, topics):
+ """Add list of topics to member's interest.
+
+ member is an LCE/KEY and realname is an arbitrary string.
+ NotAMemberError is raised if member does not refer to a valid member.
+ topics must be a sequence of strings.
+ """
+ raise NotImplemented
+
+ def setDeliveryStatus(self, member, status):
+ """Set the delivery status of the member's address.
+
+ Status must be one of the module constants:
+
+ ENABLED - The deliveries to the user are not disabled
+ UNKNOWN - Deliveries are disabled for unknown reasons. The
+ primary reason for this to happen is that we've copied
+ their delivery status from a legacy version which didn't
+ keep track of disable reasons
+ BYUSER - The user explicitly disable deliveries
+ BYADMIN - The list administrator explicitly disabled deliveries
+ BYBOUNCE - The system disabled deliveries due to bouncing
+
+ This method also records the time (in seconds since epoch) at which
+ the last status change was made. If the delivery status is changed to
+ ENABLED, then the change time information will be deleted. This value
+ is retrievable via getDeliveryStatusChangeTime().
+ """
+ raise NotImplemented
+
+ def setBounceInfo(self, member, info):
+ """Set the member's bounce information.
+
+ When info is None, any bounce info for the member is cleared.
+
+ Bounce info is opaque to the MemberAdaptor. It is set by this method
+ and returned by getBounceInfo() without modification.
+ """
+ raise NotImplemented
diff --git a/Mailman/Message.py b/Mailman/Message.py
new file mode 100644
index 00000000..b82ddf81
--- /dev/null
+++ b/Mailman/Message.py
@@ -0,0 +1,274 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Standard Mailman message object.
+
+This is a subclass of mimeo.Message but provides a slightly extended interface
+which is more convenient for use inside Mailman.
+"""
+
+import email
+import email.Message
+import email.Utils
+from email.Charset import Charset
+from email.Header import Header
+
+from types import ListType, StringType
+
+from Mailman import mm_cfg
+from Mailman import Utils
+
+COMMASPACE = ', '
+
+VERSION = tuple([int(s) for s in email.__version__.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)
+
+ # 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, StringType):
+ 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, use_envelope=None, preserve_case=0):
+ """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 mm_cfg.USE_ENVELOPE_SENDER is true, then the search order is
+ Sender:, From:, unixfrom
+
+ - Otherwise, the search order is From:, Sender:, unixfrom
+
+ The optional argument use_envelope, if given overrides the
+ mm_cfg.USE_ENVELOPE_SENDER setting. It should be set to either 0 or 1
+ (don't use None since that indicates no-override).
+
+ unixfrom should never be empty. The return address is always
+ lowercased, unless preserve_case is true.
+
+ This method differs from get_senders() in that it returns one and only
+ one address, and uses a different search order.
+ """
+ senderfirst = mm_cfg.USE_ENVELOPE_SENDER
+ if use_envelope is not None:
+ senderfirst = use_envelope
+ 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 = ''
+ if not preserve_case:
+ return address.lower()
+ return address
+
+ def get_senders(self, preserve_case=0, headers=None):
+ """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
+ 3. Reply-To:
+ 4. Sender:
+
+ The return addresses are always lower cased, unless `preserve_case' is
+ true. Optional `headers' gives an alternative search order, with None
+ meaning, search the unixfrom header. Items in `headers' are field
+ names without the trailing colon.
+ """
+ if headers is None:
+ headers = mm_cfg.SENDER_HEADERS
+ pairs = []
+ for h in headers:
+ if h is None:
+ # get_unixfrom() returns None if there's no envelope
+ fieldval = self.get_unixfrom() or ''
+ try:
+ pairs.append(('', fieldval.split()[1]))
+ except IndexError:
+ # Ignore badly formatted unixfroms
+ pass
+ else:
+ fieldvals = self.get_all(h)
+ if fieldvals:
+ pairs.extend(email.Utils.getaddresses(fieldvals))
+ authors = []
+ for pair in pairs:
+ address = pair[1]
+ if address is not None and not preserve_case:
+ address = address.lower()
+ authors.append(address)
+ return authors
+
+
+
+class UserNotification(Message):
+ """Class for internally crafted messages."""
+
+ def __init__(self, recip, sender, subject=None, text=None, lang=None):
+ Message.__init__(self)
+ charset = None
+ if lang is not None:
+ charset = Charset(Utils.GetCharSet(lang))
+ if text is not None:
+ self.set_payload(text, charset)
+ if subject is None:
+ subject = '(no subject)'
+ self['Subject'] = Header(subject, charset, header_name='Subject')
+ self['From'] = sender
+ if isinstance(recip, ListType):
+ 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. Yes, the MTA would give us one, but
+ # this is useful for logging to logs/smtp.
+ if not self.has_key('message-id'):
+ self['Message-ID'] = Utils.unique_message_id(mlist)
+ # Ditto for Date: which is required by RFC 2822
+ if not self.has_key('date'):
+ self['Date'] = email.Utils.formatdate(localtime=1)
+ # 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 not self.has_key('precedence'):
+ self['Precedence'] = 'bulk'
+ self._enqueue(mlist, **_kws)
+
+ def _enqueue(self, mlist, **_kws):
+ # Not imported at module scope to avoid import loop
+ from Mailman.Queue.sbcache import get_switchboard
+ virginq = get_switchboard(mm_cfg.VIRGINQUEUE_DIR)
+ # The message metadata better have a `recip' attribute
+ virginq.enqueue(self,
+ listname = mlist.internal_name(),
+ recips = self.recips,
+ nodecorate = 1,
+ reduced_list_headers = 1,
+ **_kws)
+
+
+
+class OwnerNotification(UserNotification):
+ """Like user notifications, but this message goes to the list owners."""
+
+ def __init__(self, mlist, subject=None, text=None, tomoderators=1):
+ recips = mlist.owner[:]
+ if tomoderators:
+ recips.extend(mlist.moderator)
+ # We have to set the owner to the site's -bounces address, otherwise
+ # we'll get a mail loop if an owner's address bounces.
+ sender = Utils.get_site_email(mlist.host_name, 'bounces')
+ 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.GetOwnerEmail()
+ self._sender = sender
+
+ def _enqueue(self, mlist, **_kws):
+ # Not imported at module scope to avoid import loop
+ from Mailman.Queue.sbcache import get_switchboard
+ virginq = get_switchboard(mm_cfg.VIRGINQUEUE_DIR)
+ # The message metadata better have a `recip' attribute
+ virginq.enqueue(self,
+ listname = mlist.internal_name(),
+ recips = self.recips,
+ nodecorate = 1,
+ reduced_list_headers = 1,
+ envsender = self._sender,
+ **_kws)
diff --git a/Mailman/OldStyleMemberships.py b/Mailman/OldStyleMemberships.py
new file mode 100644
index 00000000..cc42cb90
--- /dev/null
+++ b/Mailman/OldStyleMemberships.py
@@ -0,0 +1,353 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Old style Mailman membership adaptor.
+
+This adaptor gets and sets member information on the MailList object given to
+the constructor. It also equates member keys and lower-cased email addresses,
+i.e. KEY is LCE.
+
+This is the adaptor used by default in Mailman 2.1.
+"""
+
+import time
+from types import StringType
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman import MemberAdaptor
+
+ISREGULAR = 1
+ISDIGEST = 2
+
+# XXX check for bare access to mlist.members, mlist.digest_members,
+# mlist.user_options, mlist.passwords, mlist.topics_userinterest
+
+# XXX Fix Errors.MMAlreadyAMember and Errors.NotAMember
+# Actually, fix /all/ errors
+
+
+
+class OldStyleMemberships(MemberAdaptor.MemberAdaptor):
+ def __init__(self, mlist):
+ self.__mlist = mlist
+
+ #
+ # Read interface
+ #
+ def getMembers(self):
+ return self.__mlist.members.keys() + self.__mlist.digest_members.keys()
+
+ def getRegularMemberKeys(self):
+ return self.__mlist.members.keys()
+
+ def getDigestMemberKeys(self):
+ return self.__mlist.digest_members.keys()
+
+ def __get_cp_member(self, member):
+ lcmember = member.lower()
+ missing = []
+ val = self.__mlist.members.get(lcmember, missing)
+ if val is not missing:
+ if isinstance(val, StringType):
+ return val, ISREGULAR
+ else:
+ return lcmember, ISREGULAR
+ val = self.__mlist.digest_members.get(lcmember, missing)
+ if val is not missing:
+ if isinstance(val, StringType):
+ return val, ISDIGEST
+ else:
+ return lcmember, ISDIGEST
+ return None, None
+
+ def isMember(self, member):
+ cpaddr, where = self.__get_cp_member(member)
+ if cpaddr is not None:
+ return 1
+ return 0
+
+ def getMemberKey(self, member):
+ cpaddr, where = self.__get_cp_member(member)
+ if cpaddr is None:
+ raise Errors.NotAMemberError, member
+ return member.lower()
+
+ def getMemberCPAddress(self, member):
+ cpaddr, where = self.__get_cp_member(member)
+ if cpaddr is None:
+ raise Errors.NotAMemberError, member
+ return cpaddr
+
+ def getMemberCPAddresses(self, members):
+ return [self.__get_cp_member(member)[0] for member in members]
+
+ def getMemberPassword(self, member):
+ secret = self.__mlist.passwords.get(member.lower())
+ if secret is None:
+ raise Errors.NotAMemberError, member
+ return secret
+
+ def authenticateMember(self, member, response):
+ secret = self.getMemberPassword(member)
+ if secret == response:
+ return secret
+ return 0
+
+ def __assertIsMember(self, member):
+ if not self.isMember(member):
+ raise Errors.NotAMemberError, member
+
+ def getMemberLanguage(self, member):
+ return self.__mlist.language.get(member.lower(),
+ self.__mlist.preferred_language)
+
+ def getMemberOption(self, member, flag):
+ self.__assertIsMember(member)
+ if flag == mm_cfg.Digests:
+ cpaddr, where = self.__get_cp_member(member)
+ return where == ISDIGEST
+ option = self.__mlist.user_options.get(member.lower(), 0)
+ return not not (option & flag)
+
+ def getMemberName(self, member):
+ self.__assertIsMember(member)
+ return self.__mlist.usernames.get(member.lower())
+
+ def getMemberTopics(self, member):
+ self.__assertIsMember(member)
+ return self.__mlist.topics_userinterest.get(member.lower(), [])
+
+ def getDeliveryStatus(self, member):
+ self.__assertIsMember(member)
+ return self.__mlist.delivery_status.get(
+ member.lower(),
+ # Values are tuples, so the default should also be a tuple. The
+ # second item will be ignored.
+ (MemberAdaptor.ENABLED, 0))[0]
+
+ def getDeliveryStatusChangeTime(self, member):
+ self.__assertIsMember(member)
+ return self.__mlist.delivery_status.get(
+ member.lower(),
+ # Values are tuples, so the default should also be a tuple. The
+ # second item will be ignored.
+ (MemberAdaptor.ENABLED, 0))[1]
+
+ def getDeliveryStatusMembers(self, status=(MemberAdaptor.UNKNOWN,
+ MemberAdaptor.BYUSER,
+ MemberAdaptor.BYADMIN,
+ MemberAdaptor.BYBOUNCE)):
+ return [member for member in self.getMembers()
+ if self.getDeliveryStatus(member) in status]
+
+ def getBouncingMembers(self):
+ return [member.lower() for member in self.__mlist.bounce_info.keys()]
+
+ def getBounceInfo(self, member):
+ self.__assertIsMember(member)
+ return self.__mlist.bounce_info.get(member.lower())
+
+ #
+ # Write interface
+ #
+ def addNewMember(self, member, **kws):
+ assert self.__mlist.Locked()
+ # Make sure this address isn't already a member
+ if self.__mlist.isMember(member):
+ raise Errors.MMAlreadyAMember, member
+ # Parse the keywords
+ digest = 0
+ password = Utils.MakeRandomPassword()
+ language = self.__mlist.preferred_language
+ realname = None
+ if kws.has_key('digest'):
+ digest = kws['digest']
+ del kws['digest']
+ if kws.has_key('password'):
+ password = kws['password']
+ del kws['password']
+ if kws.has_key('language'):
+ language = kws['language']
+ del kws['language']
+ if kws.has_key('realname'):
+ realname = kws['realname']
+ del kws['realname']
+ # Assert that no other keywords are present
+ if kws:
+ raise ValueError, kws.keys()
+ # If the localpart has uppercase letters in it, then the value in the
+ # members (or digest_members) dict is the case preserved address.
+ # Otherwise the value is 0. Note that the case of the domain part is
+ # of course ignored.
+ if Utils.LCDomain(member) == member.lower():
+ value = 0
+ else:
+ value = member
+ member = member.lower()
+ if digest:
+ self.__mlist.digest_members[member] = value
+ else:
+ self.__mlist.members[member] = value
+ self.setMemberPassword(member, password)
+
+ self.setMemberLanguage(member, language)
+ if realname:
+ self.setMemberName(member, realname)
+ # Set the member's default set of options
+ if self.__mlist.new_member_options:
+ self.__mlist.user_options[member] = self.__mlist.new_member_options
+
+ def removeMember(self, member):
+ assert self.__mlist.Locked()
+ self.__assertIsMember(member)
+ # Delete the appropriate entries from the various MailList attributes.
+ # Remember that not all of them will have an entry (only those with
+ # values different than the default).
+ memberkey = member.lower()
+ for attr in ('passwords', 'user_options', 'members', 'digest_members',
+ 'language', 'topics_userinterest', 'usernames',
+ 'bounce_info', 'delivery_status',
+ ):
+ dict = getattr(self.__mlist, attr)
+ if dict.has_key(memberkey):
+ del dict[memberkey]
+
+ def changeMemberAddress(self, member, newaddress, nodelete=0):
+ assert self.__mlist.Locked()
+ # Make sure the old address is a member. Assertions that the new
+ # address is not already a member is done by addNewMember() below.
+ self.__assertIsMember(member)
+ # Get the old values
+ memberkey = member.lower()
+ fullname = self.getMemberName(memberkey)
+ flags = self.__mlist.user_options.get(memberkey, 0)
+ digestsp = self.getMemberOption(memberkey, mm_cfg.Digests)
+ password = self.__mlist.passwords.get(memberkey,
+ Utils.MakeRandomPassword())
+ lang = self.getMemberLanguage(memberkey)
+ # Add the new member
+ self.addNewMember(newaddress, realname=fullname, digest=digestsp,
+ password=password, language=lang)
+ # Set the entire options bitfield
+ if flags:
+ self.__mlist.user_options[memberkey] = flags
+ # Delete the old memberkey
+ if not nodelete:
+ self.removeMember(memberkey)
+
+ def setMemberPassword(self, memberkey, password):
+ assert self.__mlist.Locked()
+ self.__assertIsMember(memberkey)
+ self.__mlist.passwords[memberkey.lower()] = password
+
+ def setMemberLanguage(self, memberkey, language):
+ assert self.__mlist.Locked()
+ self.__assertIsMember(memberkey)
+ self.__mlist.language[memberkey.lower()] = language
+
+ def setMemberOption(self, member, flag, value):
+ assert self.__mlist.Locked()
+ self.__assertIsMember(member)
+ memberkey = member.lower()
+ # There's one extra gotcha we have to deal with. If the user is
+ # toggling the Digests flag, then we need to move their entry from
+ # mlist.members to mlist.digest_members or vice versa. Blarg. Do
+ # this before the flag setting below in case it fails.
+ if flag == mm_cfg.Digests:
+ if value:
+ # Be sure the list supports digest delivery
+ if not self.__mlist.digestable:
+ raise Errors.CantDigestError
+ # The user is turning on digest mode
+ if self.__mlist.digest_members.has_key(memberkey):
+ raise Errors.AlreadyReceivingDigests, member
+ cpuser = self.__mlist.members.get(memberkey)
+ if cpuser is None:
+ raise Errors.NotAMemberError, member
+ del self.__mlist.members[memberkey]
+ self.__mlist.digest_members[memberkey] = cpuser
+ else:
+ # Be sure the list supports regular delivery
+ if not self.__mlist.nondigestable:
+ raise Errors.MustDigestError
+ # The user is turning off digest mode
+ if self.__mlist.members.has_key(memberkey):
+ raise Errors.AlreadyReceivingRegularDeliveries, member
+ cpuser = self.__mlist.digest_members.get(memberkey)
+ if cpuser is None:
+ raise Errors.NotAMemberError, member
+ del self.__mlist.digest_members[memberkey]
+ self.__mlist.members[memberkey] = cpuser
+ # When toggling off digest delivery, we want to be sure to set
+ # things up so that the user receives one last digest,
+ # otherwise they may lose some email
+ self.__mlist.one_last_digest[memberkey] = cpuser
+ # We don't need to touch user_options because the digest state
+ # isn't kept as a bitfield flag.
+ return
+ # This is a bit kludgey because the semantics are that if the user has
+ # no options set (i.e. the value would be 0), then they have no entry
+ # in the user_options dict. We use setdefault() here, and then del
+ # the entry below just to make things (questionably) cleaner.
+ self.__mlist.user_options.setdefault(memberkey, 0)
+ if value:
+ self.__mlist.user_options[memberkey] |= flag
+ else:
+ self.__mlist.user_options[memberkey] &= ~flag
+ if not self.__mlist.user_options[memberkey]:
+ del self.__mlist.user_options[memberkey]
+
+ def setMemberName(self, member, realname):
+ assert self.__mlist.Locked()
+ self.__assertIsMember(member)
+ self.__mlist.usernames[member.lower()] = realname
+
+ def setMemberTopics(self, member, topics):
+ assert self.__mlist.Locked()
+ self.__assertIsMember(member)
+ memberkey = member.lower()
+ if topics:
+ self.__mlist.topics_userinterest[memberkey] = topics
+ # if topics is empty, then delete the entry in this dictionary
+ elif self.__mlist.topics_userinterest.has_key(memberkey):
+ del self.__mlist.topics_userinterest[memberkey]
+
+ def setDeliveryStatus(self, member, status):
+ assert status in (MemberAdaptor.ENABLED, MemberAdaptor.UNKNOWN,
+ MemberAdaptor.BYUSER, MemberAdaptor.BYADMIN,
+ MemberAdaptor.BYBOUNCE)
+ assert self.__mlist.Locked()
+ self.__assertIsMember(member)
+ member = member.lower()
+ if status == MemberAdaptor.ENABLED:
+ self.setBounceInfo(member, None)
+ # Otherwise, nothing to do
+ else:
+ self.__mlist.delivery_status[member] = (status, time.time())
+
+ def setBounceInfo(self, member, info):
+ assert self.__mlist.Locked()
+ self.__assertIsMember(member)
+ member = member.lower()
+ if info is None:
+ if self.__mlist.bounce_info.has_key(member):
+ del self.__mlist.bounce_info[member]
+ if self.__mlist.delivery_status.has_key(member):
+ del self.__mlist.delivery_status[member]
+ else:
+ self.__mlist.bounce_info[member] = info
diff --git a/Mailman/Pending.py b/Mailman/Pending.py
new file mode 100644
index 00000000..be1c6cac
--- /dev/null
+++ b/Mailman/Pending.py
@@ -0,0 +1,204 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+""" Track pending confirmation of subscriptions.
+
+new(stuff...) places an item's data in the db, returning its cookie.
+
+confirmed(cookie) returns a tuple for the data, removing the item
+from the db. It returns None if the cookie is not registered.
+"""
+
+import os
+import time
+import sha
+import marshal
+import cPickle
+import random
+import errno
+
+from Mailman import mm_cfg
+from Mailman import LockFile
+
+DBFILE = os.path.join(mm_cfg.DATA_DIR, 'pending.db')
+PCKFILE = os.path.join(mm_cfg.DATA_DIR, 'pending.pck')
+LOCKFILE = os.path.join(mm_cfg.LOCK_DIR, 'pending.lock')
+
+# Types of pending records
+SUBSCRIPTION = 'S'
+UNSUBSCRIPTION = 'U'
+CHANGE_OF_ADDRESS = 'C'
+HELD_MESSAGE = 'H'
+RE_ENABLE = 'E'
+
+_ALLKEYS = [(x,) for x in (SUBSCRIPTION, UNSUBSCRIPTION,
+ CHANGE_OF_ADDRESS, HELD_MESSAGE,
+ RE_ENABLE,
+ )]
+
+
+
+def new(*content):
+ """Create a new entry in the pending database, returning cookie for it."""
+ # It's a programming error if this assertion fails! We do it this way so
+ # the assert test won't fail if the sequence is empty.
+ assert content[:1] in _ALLKEYS
+ # Acquire the pending database lock, letting TimeOutError percolate up.
+ lock = LockFile.LockFile(LOCKFILE)
+ lock.lock(timeout=30)
+ try:
+ # Load the current database
+ db = _load()
+ # Calculate a unique cookie
+ while 1:
+ n = random.random()
+ now = time.time()
+ hashfood = str(now) + str(n) + str(content)
+ cookie = sha.new(hashfood).hexdigest()
+ if not db.has_key(cookie):
+ break
+ # Store the content, plus the time in the future when this entry will
+ # be evicted from the database, due to staleness.
+ db[cookie] = content
+ evictions = db.setdefault('evictions', {})
+ evictions[cookie] = now + mm_cfg.PENDING_REQUEST_LIFE
+ _save(db)
+ return cookie
+ finally:
+ lock.unlock()
+
+
+
+def confirm(cookie, expunge=1):
+ """Return data for cookie, or None if not found.
+
+ If optional expunge is true (the default), the record is also removed from
+ the database.
+ """
+ # Acquire the pending database lock, letting TimeOutError percolate up.
+ # BAW: we perhaps shouldn't acquire the lock if expunge==0.
+ lock = LockFile.LockFile(LOCKFILE)
+ lock.lock(timeout=30)
+ try:
+ # Load the database
+ db = _load()
+ missing = []
+ content = db.get(cookie, missing)
+ if content is missing:
+ return None
+ # Remove the entry from the database
+ if expunge:
+ del db[cookie]
+ del db['evictions'][cookie]
+ _save(db)
+ return content
+ finally:
+ lock.unlock()
+
+
+
+def _load():
+ # The list's lock must be acquired.
+ #
+ # First try to load the pickle file
+ fp = None
+ try:
+ try:
+ fp = open(PCKFILE)
+ return cPickle.load(fp)
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ try:
+ # Try to load the old DBFILE
+ fp = open(DBFILE)
+ return marshal.load(fp)
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ # Fresh pendings database
+ return {'evictions': {}}
+ finally:
+ if fp:
+ fp.close()
+
+
+def _save(db):
+ # Lock must be acquired.
+ evictions = db['evictions']
+ now = time.time()
+ for cookie, data in db.items():
+ if cookie in ('evictions', 'version'):
+ continue
+ timestamp = evictions[cookie]
+ if now > timestamp:
+ # The entry is stale, so remove it.
+ del db[cookie]
+ del evictions[cookie]
+ # Clean out any bogus eviction entries.
+ for cookie in evictions.keys():
+ if not db.has_key(cookie):
+ del evictions[cookie]
+ db['version'] = mm_cfg.PENDING_FILE_SCHEMA_VERSION
+ omask = os.umask(007)
+ # Always save this as a pickle (safely), and after that succeeds, blow
+ # away any old marshal file.
+ tmpfile = PCKFILE + '.tmp'
+ fp = None
+ try:
+ fp = open(tmpfile, 'w')
+ cPickle.dump(db, fp)
+ fp.close()
+ fp = None
+ os.rename(tmpfile, PCKFILE)
+ if os.path.exists(DBFILE):
+ os.remove(DBFILE)
+ finally:
+ if fp:
+ fp.close()
+ os.umask(omask)
+
+
+
+def _update(olddb):
+ # Update an old pending_subscriptions.db database to the new format
+ lock = LockFile.LockFile(LOCKFILE)
+ lock.lock(timeout=30)
+ try:
+ # We don't need this entry anymore
+ if olddb.has_key('lastculltime'):
+ del olddb['lastculltime']
+ db = _load()
+ evictions = db.setdefault('evictions', {})
+ for cookie, data in olddb.items():
+ # The cookies used to be kept as a 6 digit integer. We now keep
+ # the cookies as a string (sha in our case, but it doesn't matter
+ # for cookie matching).
+ cookie = str(cookie)
+ # The old format kept the content as a tuple and tacked the
+ # timestamp on as the last element of the tuple. We keep the
+ # timestamps separate, but require the prepending of a record type
+ # indicator. We know that the only things that were kept in the
+ # old format were subscription requests. Also, the old request
+ # format didn't have the subscription language. Best we can do
+ # here is use the server default.
+ db[cookie] = (SUBSCRIPTION,) + data[:-1] + \
+ (mm_cfg.DEFAULT_SERVER_LANGUAGE,)
+ # The old database format kept the timestamp as the time the
+ # request was made. The new format keeps it as the time the
+ # request should be evicted.
+ evictions[cookie] = data[-1] + mm_cfg.PENDING_REQUEST_LIFE
+ _save(db)
+ finally:
+ lock.unlock()
diff --git a/Mailman/Post.py b/Mailman/Post.py
new file mode 100644
index 00000000..a184f1cf
--- /dev/null
+++ b/Mailman/Post.py
@@ -0,0 +1,61 @@
+#! /usr/bin/env 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+import sys
+
+from Mailman import mm_cfg
+from Mailman.Queue.sbcache import get_switchboard
+
+
+
+def inject(listname, msg, recips=None, qdir=None):
+ if qdir is None:
+ qdir = mm_cfg.INQUEUE_DIR
+ queue = get_switchboard(qdir)
+ kws = {'listname' : listname,
+ 'tolist' : 1,
+ '_plaintext': 1,
+ }
+ if recips:
+ kws['recips'] = recips
+ queue.enqueue(msg, **kws)
+
+
+
+if __name__ == '__main__':
+ # When called as a command line script, standard input is read to get the
+ # list that this message is destined to, the list of explicit recipients,
+ # and the message to send (in its entirety). stdin must have the
+ # following format:
+ #
+ # line 1: the internal name of the mailing list
+ # line 2: the number of explicit recipients to follow. 0 means to use the
+ # list's membership to calculate recipients.
+ # line 3 - 3+recipnum: explicit recipients, one per line
+ # line 4+recipnum - end of file: the message in RFC 822 format (may
+ # include an initial Unix-from header)
+ listname = sys.stdin.readline().strip()
+ numrecips = int(sys.stdin.readline())
+ if numrecips == 0:
+ recips = None
+ else:
+ recips = []
+ for i in range(numrecips):
+ recips.append(sys.stdin.readline().strip())
+ # If the message isn't parsable, we won't get an error here
+ inject(listname, sys.stdin.read(), recips)
diff --git a/Mailman/Queue/.cvsignore b/Mailman/Queue/.cvsignore
new file mode 100644
index 00000000..f3c7a7c5
--- /dev/null
+++ b/Mailman/Queue/.cvsignore
@@ -0,0 +1 @@
+Makefile
diff --git a/Mailman/Queue/ArchRunner.py b/Mailman/Queue/ArchRunner.py
new file mode 100644
index 00000000..14097332
--- /dev/null
+++ b/Mailman/Queue/ArchRunner.py
@@ -0,0 +1,76 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Outgoing queue runner."""
+
+import time
+from email.Utils import parsedate_tz, mktime_tz, formatdate
+
+from Mailman import mm_cfg
+from Mailman import LockFile
+from Mailman.Queue.Runner import Runner
+
+
+
+class ArchRunner(Runner):
+ QDIR = mm_cfg.ARCHQUEUE_DIR
+
+ def _dispose(self, mlist, msg, msgdata):
+ # Support clobber_date, i.e. setting the date in the archive to the
+ # received date, not the (potentially bogus) Date: header of the
+ # original message.
+ clobber = 0
+ originaldate = msg.get('date')
+ receivedtime = formatdate(msgdata['received_time'])
+ if not originaldate:
+ clobber = 1
+ elif mm_cfg.ARCHIVER_CLOBBER_DATE_POLICY == 1:
+ clobber = 1
+ elif mm_cfg.ARCHIVER_CLOBBER_DATE_POLICY == 2:
+ # what's the timestamp on the original message?
+ tup = parsedate_tz(originaldate)
+ now = time.time()
+ try:
+ if not tup:
+ clobber = 1
+ elif abs(now - mktime_tz(tup)) > \
+ mm_cfg.ARCHIVER_ALLOWABLE_SANE_DATE_SKEW:
+ clobber = 1
+ except ValueError:
+ # The likely cause of this is that the year in the Date: field
+ # is horribly incorrect, e.g. (from SF bug # 571634):
+ # Date: Tue, 18 Jun 0102 05:12:09 +0500
+ # Obviously clobber such dates.
+ clobber = 1
+ if clobber:
+ del msg['date']
+ del msg['x-original-date']
+ msg['Date'] = receivedtime
+ if originaldate:
+ msg['X-Original-Date'] = originaldate
+ # Always put an indication of when we received the message.
+ msg['X-List-Received-Date'] = receivedtime
+ # Now try to get the list lock
+ try:
+ mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT)
+ except LockFile.TimeOutError:
+ # oh well, try again later
+ return 1
+ try:
+ mlist.ArchiveMail(msg)
+ mlist.Save()
+ finally:
+ mlist.Unlock()
diff --git a/Mailman/Queue/BounceRunner.py b/Mailman/Queue/BounceRunner.py
new file mode 100644
index 00000000..e59ac47e
--- /dev/null
+++ b/Mailman/Queue/BounceRunner.py
@@ -0,0 +1,195 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Bounce queue runner."""
+
+import re
+from email.MIMEText import MIMEText
+from email.MIMEMessage import MIMEMessage
+from email.Utils import parseaddr
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import LockFile
+from Mailman.Message import UserNotification
+from Mailman.Bouncers import BouncerAPI
+from Mailman.Queue.Runner import Runner
+from Mailman.Queue.sbcache import get_switchboard
+from Mailman.Logging.Syslog import syslog
+from Mailman.i18n import _
+
+COMMASPACE = ', '
+
+
+
+class BounceRunner(Runner):
+ QDIR = mm_cfg.BOUNCEQUEUE_DIR
+ # We only do bounce processing once per minute.
+ SLEEPTIME = mm_cfg.minutes(1)
+
+ def _dispose(self, mlist, msg, msgdata):
+ # Make sure we have the most up-to-date state
+ mlist.Load()
+ outq = get_switchboard(mm_cfg.OUTQUEUE_DIR)
+ # There are a few possibilities here:
+ #
+ # - the message could have been VERP'd in which case, we know exactly
+ # who the message was destined for. That make our job easy.
+ # - the message could have been originally destined for a list owner,
+ # but a list owner address itself bounced. That's bad, and for now
+ # we'll simply log the problem and attempt to deliver the message to
+ # the site owner.
+ #
+ # All messages to list-owner@vdom.ain have their envelope sender set
+ # to site-owner@dom.ain (no virtual domain). Is this a bounce for a
+ # message to a list owner, coming to the site owner?
+ if msg.get('to', '') == Utils.get_site_email(extra='-owner'):
+ # Send it on to the site owners, but craft the envelope sender to
+ # be the -loop detection address, so if /they/ bounce, we won't
+ # get stuck in a bounce loop.
+ outq.enqueue(msg, msgdata,
+ recips=[Utils.get_site_email()],
+ envsender=Utils.get_site_email(extra='loop'),
+ )
+ # List isn't doing bounce processing?
+ if not mlist.bounce_processing:
+ return
+ # Try VERP detection first, since it's quick and easy
+ addrs = verp_bounce(mlist, msg)
+ if not addrs:
+ # That didn't give us anything useful, so try the old fashion
+ # bounce matching modules
+ addrs = BouncerAPI.ScanMessages(mlist, msg)
+ # If that still didn't return us any useful addresses, then send it on
+ # or discard it.
+ if not addrs:
+ syslog('bounce', 'bounce message w/no discernable addresses: %s',
+ msg.get('message-id'))
+ maybe_forward(mlist, msg)
+ return
+ # BAW: It's possible that there are None's in the list of addresses,
+ # although I'm unsure how that could happen. Possibly ScanMessages()
+ # can let None's sneak through. In any event, this will kill them.
+ addrs = filter(None, addrs)
+ # Okay, we have some recognized addresses. We now need to register
+ # the bounces for each of these. If the bounce came to the site list,
+ # then we'll register the address on every list in the system, but
+ # note: this could be VERY resource intensive!
+ foundp = 0
+ listname = mlist.internal_name()
+ if listname == mm_cfg.MAILMAN_SITE_LIST:
+ foundp = 1
+ for listname in Utils.list_names():
+ xlist = self._open_list(listname)
+ xlist.Load()
+ for addr in addrs:
+ if xlist.isMember(addr):
+ unlockp = 0
+ if not xlist.Locked():
+ try:
+ xlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT)
+ except LockFile.TimeOutError:
+ # Oh well, forget aboutf this list
+ continue
+ unlockp = 1
+ try:
+ xlist.registerBounce(addr, msg)
+ foundp = 1
+ xlist.Save()
+ finally:
+ if unlockp:
+ xlist.Unlock()
+ else:
+ try:
+ mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT)
+ except LockFile.TimeOutError:
+ # Try again later
+ syslog('bounce', "%s: couldn't get list lock", listname)
+ return 1
+ else:
+ try:
+ for addr in addrs:
+ if mlist.isMember(addr):
+ mlist.registerBounce(addr, msg)
+ foundp = 1
+ mlist.Save()
+ finally:
+ mlist.Unlock()
+ if not foundp:
+ # It means an address was recognized but it wasn't an address
+ # that's on any mailing list at this site. BAW: don't forward
+ # these, but do log it.
+ syslog('bounce', 'bounce message with non-members of %s: %s',
+ listname, COMMASPACE.join(addrs))
+ #maybe_forward(mlist, msg)
+
+
+
+def verp_bounce(mlist, msg):
+ bmailbox, bdomain = Utils.ParseEmail(mlist.GetBouncesEmail())
+ # Sadly not every MTA bounces VERP messages correctly, or consistently.
+ # Fall back to Delivered-To: (Postfix), Envelope-To: (Exim) and
+ # Apparently-To:, and then short-circuit if we still don't have anything
+ # to work with. Note that there can be multiple Delivered-To: headers so
+ # we need to search them all (and we don't worry about false positives for
+ # forwarded email, because only one should match VERP_REGEXP).
+ vals = []
+ for header in ('to', 'delivered-to', 'envelope-to', 'apparently-to'):
+ vals.extend(msg.get_all(header, []))
+ for field in vals:
+ to = parseaddr(field)[1]
+ if not to:
+ continue # empty header
+ mo = re.search(mm_cfg.VERP_REGEXP, to)
+ if not mo:
+ continue # no match of regexp
+ try:
+ if bmailbox <> mo.group('bounces'):
+ continue # not a bounce to our list
+ # All is good
+ addr = '%s@%s' % mo.group('mailbox', 'host')
+ except IndexError:
+ syslog('error',
+ "VERP_REGEXP doesn't yield the right match groups: %s",
+ mm_cfg.VERP_REGEXP)
+ return []
+ return [addr]
+
+
+
+def maybe_forward(mlist, msg):
+ # Does the list owner want to get non-matching bounce messages?
+ # If not, simply discard it.
+ if mlist.bounce_unrecognized_goes_to_list_owner:
+ adminurl = mlist.GetScriptURL('admin', absolute=1) + '/bounce'
+ mlist.ForwardMessage(msg,
+ text=_("""\
+The attached message was received as a bounce, but either the bounce format
+was not recognized, or no member addresses could be extracted from it. This
+mailing list has been configured to send all unrecognized bounce messages to
+the list administrator(s).
+
+For more information see:
+%(adminurl)s
+
+"""),
+ subject=_('Uncaught bounce notification'),
+ tomoderators=0)
+ syslog('bounce', 'forwarding unrecognized, message-id: %s',
+ msg.get('message-id', 'n/a'))
+ else:
+ syslog('bounce', 'discarding unrecognized, message-id: %s',
+ msg.get('message-id', 'n/a'))
diff --git a/Mailman/Queue/CommandRunner.py b/Mailman/Queue/CommandRunner.py
new file mode 100644
index 00000000..303d4c52
--- /dev/null
+++ b/Mailman/Queue/CommandRunner.py
@@ -0,0 +1,220 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""-request robot command queue runner."""
+
+# See the delivery diagram in IncomingRunner.py. This module handles all
+# email destined for mylist-request, -join, and -leave. It no longer handles
+# bounce messages (i.e. -admin or -bounces), nor does it handle mail to
+# -owner.
+
+
+
+# BAW: get rid of this when we Python 2.2 is a minimum requirement.
+from __future__ import nested_scopes
+
+import sys
+import re
+from types import StringType, UnicodeType
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman.Handlers import Replybot
+from Mailman.i18n import _
+from Mailman.Queue.Runner import Runner
+from Mailman.Logging.Syslog import syslog
+from Mailman import LockFile
+
+from email.MIMEText import MIMEText
+from email.MIMEMessage import MIMEMessage
+from email.Iterators import typed_subpart_iterator
+
+NL = '\n'
+
+
+
+class Results:
+ def __init__(self, mlist, msg, msgdata):
+ self.mlist = mlist
+ self.msg = msg
+ self.msgdata = msgdata
+ # Only set returnaddr if the response is to go to someone other than
+ # the address specified in the From: header (e.g. for the password
+ # command).
+ self.returnaddr = None
+ self.commands = []
+ self.results = []
+ self.ignored = []
+ self.lineno = 0
+ self.subjcmdretried = 0
+ self.respond = 1
+ # Always process the Subject: header first
+ self.commands.append(msg.get('subject', ''))
+ # Find the first text/plain part
+ part = None
+ for part in typed_subpart_iterator(msg, 'text', 'plain'):
+ break
+ if part is None or part is not msg:
+ # Either there was no text/plain part or we ignored some
+ # non-text/plain parts.
+ self.results.append(_('Ignoring non-text/plain MIME parts'))
+ if part is None:
+ # E.g the outer Content-Type: was text/html
+ return
+ body = part.get_payload()
+ # text/plain parts better have string payloads
+ assert isinstance(body, StringType) or isinstance(body, UnicodeType)
+ lines = body.splitlines()
+ # Use no more lines than specified
+ self.commands.extend(lines[:mm_cfg.DEFAULT_MAIL_COMMANDS_MAX_LINES])
+ self.ignored.extend(lines[mm_cfg.DEFAULT_MAIL_COMMANDS_MAX_LINES:])
+
+ def process(self):
+ # Now, process each line until we find an error. The first
+ # non-command line found stops processing.
+ stop = 0
+ for line in self.commands:
+ if line and line.strip():
+ args = line.split()
+ cmd = args.pop(0).lower()
+ stop = self.do_command(cmd, args)
+ self.lineno += 1
+ if stop:
+ break
+
+ def do_command(self, cmd, args=None):
+ if args is None:
+ args = ()
+ # Try to import a command handler module for this command
+ modname = 'Mailman.Commands.cmd_' + cmd
+ try:
+ __import__(modname)
+ handler = sys.modules[modname]
+ except ImportError:
+ # If we're on line zero, it was the Subject: header that didn't
+ # contain a command. It's possible there's a Re: prefix (or
+ # localized version thereof) on the Subject: line that's messing
+ # things up. Pop the prefix off and try again... once.
+ #
+ # If that still didn't work it isn't enough to stop processing.
+ # BAW: should we include a message that the Subject: was ignored?
+ if not self.subjcmdretried and args:
+ self.subjcmdretried += 1
+ cmd = args.pop(0)
+ return self.do_command(cmd, args)
+ return self.lineno <> 0
+ return handler.process(self, args)
+
+ def send_response(self):
+ # Helper
+ def indent(lines):
+ return [' ' + line for line in lines]
+ # Quick exit for some commands which don't need a response
+ if not self.respond:
+ return
+ resp = [Utils.wrap(_("""\
+The results of your email command are provided below.
+Attached is your original message.
+"""))]
+ if self.results:
+ resp.append(_('- Results:'))
+ resp.extend(indent(self.results))
+ # Ignore empty lines
+ unprocessed = [line for line in self.commands[self.lineno:]
+ if line and line.strip()]
+ if unprocessed:
+ resp.append(_('\n- Unprocessed:'))
+ resp.extend(indent(unprocessed))
+ if self.ignored:
+ resp.append(_('\n- Ignored:'))
+ resp.extend(indent(self.ignored))
+ resp.append(_('\n- Done.\n\n'))
+ results = MIMEText(
+ NL.join(resp),
+ _charset=Utils.GetCharSet(self.mlist.preferred_language))
+ # Safety valve for mail loops with misconfigured email 'bots. We
+ # don't respond to commands sent with "Precedence: bulk|junk|list"
+ # unless they explicitly "X-Ack: yes", but not all mail 'bots are
+ # correctly configured, so we max out the number of responses we'll
+ # give to an address in a single day.
+ #
+ # BAW: We wait until now to make this decision since our sender may
+ # not be self.msg.get_sender(), but I'm not sure this is right.
+ recip = self.returnaddr or self.msg.get_sender()
+ if not self.mlist.autorespondToSender(recip):
+ return
+ msg = Message.UserNotification(
+ recip,
+ self.mlist.GetBouncesEmail(),
+ _('The results of your email commands'),
+ lang=self.mlist.preferred_language)
+ msg.set_type('multipart/mixed')
+ msg.attach(results)
+ orig = MIMEMessage(self.msg)
+ msg.attach(orig)
+ msg.send(self.mlist)
+
+
+
+class CommandRunner(Runner):
+ QDIR = mm_cfg.CMDQUEUE_DIR
+
+ def _dispose(self, mlist, msg, msgdata):
+ # The policy here is similar to the Replybot policy. If a message has
+ # "Precedence: bulk|junk|list" and no "X-Ack: yes" header, we discard
+ # it to prevent replybot response storms.
+ precedence = msg.get('precedence', '').lower()
+ ack = msg.get('x-ack', '').lower()
+ if ack <> 'yes' and precedence in ('bulk', 'junk', 'list'):
+ syslog('vette', 'Precedence: %s message discarded by: %s',
+ precedence, mlist.GetRequestEmail())
+ return 0
+ # Do replybot for commands
+ mlist.Load()
+ Replybot.process(mlist, msg, msgdata)
+ if mlist.autorespond_requests == 1:
+ syslog('vette', 'replied and discard')
+ # w/discard
+ return 0
+ # Now craft the response
+ res = Results(mlist, msg, msgdata)
+ # BAW: Not all the functions of this qrunner require the list to be
+ # locked. Still, it's more convenient to lock it here and now and
+ # deal with lock failures in one place.
+ try:
+ mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT)
+ except LockFile.TimeOutError:
+ # Oh well, try again later
+ return 1
+ # This message will have been delivered to one of mylist-request,
+ # mylist-join, or mylist-leave, and the message metadata will contain
+ # a key to which one was used.
+ try:
+ if msgdata.get('torequest'):
+ res.process()
+ elif msgdata.get('tojoin'):
+ res.do_command('join')
+ elif msgdata.get('toleave'):
+ res.do_command('leave')
+ elif msgdata.get('toconfirm'):
+ mo = re.match(mm_cfg.VERP_CONFIRM_REGEXP, msg.get('to', ''))
+ if mo:
+ res.do_command('confirm', (mo.group('cookie'),))
+ res.send_response()
+ mlist.Save()
+ finally:
+ mlist.Unlock()
diff --git a/Mailman/Queue/IncomingRunner.py b/Mailman/Queue/IncomingRunner.py
new file mode 100644
index 00000000..4a60ceb9
--- /dev/null
+++ b/Mailman/Queue/IncomingRunner.py
@@ -0,0 +1,170 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Incoming queue runner."""
+
+# A typical Mailman list exposes nine aliases which point to seven different
+# wrapped scripts. E.g. for a list named `mylist', you'd have:
+#
+# mylist-bounces -> bounces (-admin is a deprecated alias)
+# mylist-confirm -> confirm
+# mylist-join -> join (-subscribe is an alias)
+# mylist-leave -> leave (-unsubscribe is an alias)
+# mylist-owner -> owner
+# mylist -> post
+# mylist-request -> request
+#
+# -request, -join, and -leave are a robot addresses; their sole purpose is to
+# process emailed commands in a Majordomo-like fashion (although the latter
+# two are hardcoded to subscription and unsubscription requests). -bounces is
+# the automated bounce processor, and all messages to list members have their
+# return address set to -bounces. If the bounce processor fails to extract a
+# bouncing member address, it can optionally forward the message on to the
+# list owners.
+#
+# -owner is for reaching a human operator with minimal list interaction
+# (i.e. no bounce processing). -confirm is another robot address which
+# processes replies to VERP-like confirmation notices.
+#
+# So delivery flow of messages look like this:
+#
+# joerandom ---> mylist ---> list members
+# | |
+# | |[bounces]
+# | mylist-bounces <---+ <-------------------------------+
+# | | |
+# | +--->[internal bounce processing] |
+# | ^ | |
+# | | | [bounce found] |
+# | [bounces *] +--->[register and discard] |
+# | | | | |
+# | | | |[*] |
+# | [list owners] |[no bounce found] | |
+# | ^ | | |
+# | | | | |
+# +-------> mylist-owner <--------+ | |
+# | | |
+# | data/owner-bounces.mbox <--[site list] <---+ |
+# | |
+# +-------> mylist-join--+ |
+# | | |
+# +------> mylist-leave--+ |
+# | | |
+# | v |
+# +-------> mylist-request |
+# | | |
+# | +---> [command processor] |
+# | | |
+# +-----> mylist-confirm ----> +---> joerandom |
+# | |
+# |[bounces] |
+# +----------------------+
+#
+# A person can send an email to the list address (for posting), the -owner
+# address (to reach the human operator), or the -confirm, -join, -leave, and
+# -request mailbots. Message to the list address are then forwarded on to the
+# list membership, with bounces directed to the -bounces address.
+#
+# [*] Messages sent to the -owner address are forwarded on to the list
+# owner/moderators. All -owner destined messages have their bounces directed
+# to the site list -bounces address, regardless of whether a human sent the
+# message or the message was crafted internally. The intention here is that
+# the site owners want to be notified when one of their list owners' addresses
+# starts bouncing (yes, the will be automated in a future release).
+#
+# Any messages to site owners has their bounces directed to a special
+# "loop-killer" address, which just dumps the message into
+# data/owners-bounces.mbox.
+#
+# Finally, message to any of the mailbots causes the requested action to be
+# performed. Results notifications are sent to the author of the message,
+# which all bounces pointing back to the -bounces address.
+
+
+import sys
+import os
+from cStringIO import StringIO
+
+from Mailman import mm_cfg
+from Mailman import Errors
+from Mailman import LockFile
+from Mailman.Queue.Runner import Runner
+from Mailman.Logging.Syslog import syslog
+
+
+
+class IncomingRunner(Runner):
+ QDIR = mm_cfg.INQUEUE_DIR
+
+ def _dispose(self, mlist, msg, msgdata):
+ # Try to get the list lock.
+ try:
+ mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT)
+ except LockFile.TimeOutError:
+ # Oh well, try again later
+ return 1
+ # Process the message through a handler pipeline. The handler
+ # pipeline can actually come from one of three places: the message
+ # metadata, the mlist, or the global pipeline.
+ #
+ # If a message was requeued due to an uncaught exception, its metadata
+ # will contain the retry pipeline. Use this above all else.
+ # Otherwise, if the mlist has a `pipeline' attribute, it should be
+ # used. Final fallback is the global pipeline.
+ try:
+ pipeline = self._get_pipeline(mlist, msg, msgdata)
+ status = self._dopipeline(mlist, msg, msgdata, pipeline)
+ if status:
+ msgdata['pipeline'] = pipeline
+ mlist.Save()
+ return status
+ finally:
+ mlist.Unlock()
+
+ # Overridable
+ def _get_pipeline(self, mlist, msg, msgdata):
+ # We must return a copy of the list, otherwise, the first message that
+ # flows through the pipeline will empty it out!
+ return msgdata.get('pipeline',
+ getattr(mlist, 'pipeline',
+ mm_cfg.GLOBAL_PIPELINE))[:]
+
+ def _dopipeline(self, mlist, msg, msgdata, pipeline):
+ while pipeline:
+ handler = pipeline.pop(0)
+ modname = 'Mailman.Handlers.' + handler
+ __import__(modname)
+ try:
+ pid = os.getpid()
+ sys.modules[modname].process(mlist, msg, msgdata)
+ # Failsafe -- a child may have leaked through.
+ if pid <> os.getpid():
+ syslog('error', 'child process leaked thru: %s', modname)
+ os._exit(1)
+ except Errors.DiscardMessage:
+ # Throw the message away; we need do nothing else with it.
+ syslog('vette', 'Message discarded, msgid: %s',
+ msg.get('message-id', 'n/a'))
+ return 0
+ except Errors.HoldMessage:
+ # Let the approval process take it from here. The message no
+ # longer needs to be queued.
+ return 0
+ except Errors.RejectMessage, e:
+ mlist.BounceMessage(msg, msgdata, e)
+ return 0
+ # We've successfully completed handling of this message
+ return 0
diff --git a/Mailman/Queue/MaildirRunner.py b/Mailman/Queue/MaildirRunner.py
new file mode 100644
index 00000000..e14ab339
--- /dev/null
+++ b/Mailman/Queue/MaildirRunner.py
@@ -0,0 +1,184 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Maildir pre-queue runner.
+
+Most MTAs can be configured to deliver messages to a `Maildir'[1]. This
+runner will read messages from a maildir's new/ directory and inject them into
+Mailman's qfiles/in directory for processing in the normal pipeline. This
+delivery mechanism contrasts with mail program delivery, where incoming
+messages end up in qfiles/in via the MTA executing the scripts/post script
+(and likewise for the other -aliases for each mailing list).
+
+The advantage to Maildir delivery is that it is more efficient; there's no
+need to fork an intervening program just to take the message from the MTA's
+standard output, to the qfiles/in directory.
+
+[1] http://cr.yp.to/proto/maildir.html
+
+We're going to use the :info flag == 1, experimental status flag for our own
+purposes. The :1 can be followed by one of these letters:
+
+- P means that MaildirRunner's in the process of parsing and enqueuing the
+ message. If successful, it will delete the file.
+
+- X means something failed during the parse/enqueue phase. An error message
+ will be logged to log/error and the file will be renamed <filename>:1,X.
+ MaildirRunner will never automatically return to this file, but once the
+ problem is fixed, you can manually move the file back to the new/ directory
+ and MaildirRunner will attempt to re-process it. At some point we may do
+ this automatically.
+
+See the variable USE_MAILDIR in Defaults.py.in for enabling this delivery
+mechanism.
+"""
+
+# NOTE: Maildir delivery is experimental in Mailman 2.1.
+
+import os
+import re
+import errno
+
+from email.Parser import Parser
+from email.Utils import parseaddr
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman.Message import Message
+from Mailman.Queue.Runner import Runner
+from Mailman.Queue.sbcache import get_switchboard
+from Mailman.Logging.Syslog import syslog
+
+# We only care about the listname and the subq as in listname@ or
+# listname-request@
+lre = re.compile(r"""
+ ^ # start of string
+ (?P<listname>[^-@]+) # listname@ or listname-subq@
+ (?: # non-grouping
+ - # dash separator
+ (?P<subq>[^-+@]+) # everything up to + or - or @
+ )? # if it exists
+ """, re.VERBOSE | re.IGNORECASE)
+
+
+
+class MaildirRunner(Runner):
+ # This class is much different than most runners because it pulls files
+ # of a different format than what scripts/post and friends leaves. The
+ # files this runner reads are just single message files as dropped into
+ # the directory by the MTA. This runner will read the file, and enqueue
+ # it in the expected qfiles directory for normal processing.
+ def __init__(self, slice=None, numslices=1):
+ # Don't call the base class constructor, but build enough of the
+ # underlying attributes to use the base class's implementation.
+ self._stop = 0
+ self._dir = os.path.join(mm_cfg.MAILDIR_DIR, 'new')
+ self._cur = os.path.join(mm_cfg.MAILDIR_DIR, 'cur')
+ self._parser = Parser(Message)
+
+ def _oneloop(self):
+ # Refresh this each time through the list. BAW: could be too
+ # expensive.
+ listnames = Utils.list_names()
+ # Cruise through all the files currently in the new/ directory
+ try:
+ files = os.listdir(self._dir)
+ except OSError, e:
+ if e.errno <> errno.ENOENT: raise
+ # Nothing's been delivered yet
+ return 0
+ for file in files:
+ srcname = os.path.join(self._dir, file)
+ dstname = os.path.join(self._cur, file + ':1,P')
+ xdstname = os.path.join(self._cur, file + ':1,X')
+ try:
+ os.rename(srcname, dstname)
+ except OSError, e:
+ if e.errno == errno.ENOENT:
+ # Some other MaildirRunner beat us to it
+ continue
+ syslog('error', 'Could not rename maildir file: %s', srcname)
+ raise
+ # Now open, read, parse, and enqueue this message
+ try:
+ fp = open(dstname)
+ try:
+ msg = self._parser.parse(fp)
+ finally:
+ fp.close()
+ # Now we need to figure out which queue of which list this
+ # message was destined for. See verp_bounce() in
+ # BounceRunner.py for why we do things this way.
+ vals = []
+ for header in ('delivered-to', 'envelope-to', 'apparently-to'):
+ vals.extend(msg.get_all(header, []))
+ for field in vals:
+ to = parseaddr(field)[1]
+ if not to:
+ continue
+ mo = lre.match(to)
+ if not mo:
+ # This isn't an address we care about
+ continue
+ listname, subq = mo.group('listname', 'subq')
+ if listname in listnames:
+ break
+ else:
+ # As far as we can tell, this message isn't destined for
+ # any list on the system. What to do?
+ syslog('error', 'Message apparently not for any list: %s',
+ xdstname)
+ os.rename(dstname, xdstname)
+ continue
+ # BAW: blech, hardcoded
+ msgdata = {'listname': listname}
+ # -admin is deprecated
+ if subq in ('bounces', 'admin'):
+ queue = get_switchboard(mm_cfg.BOUNCEQUEUE_DIR)
+ elif subq == 'confirm':
+ msgdata['toconfirm'] = 1
+ queue = get_switchboard(mm_cfg.CMDQUEUE_DIR)
+ elif subq in ('join', 'subscribe'):
+ msgdata['tojoin'] = 1
+ queue = get_switchboard(mm_cfg.CMDQUEUE_DIR)
+ elif subq in ('leave', 'unsubscribe'):
+ msgdata['toleave'] = 1
+ queue = get_switchboard(mm_cfg.CMDQUEUE_DIR)
+ elif subq == 'owner':
+ msgdata.update({
+ 'toowner': 1,
+ 'envsender': Utils.get_site_email(extra='bounces'),
+ 'pipeline': mm_cfg.OWNER_PIPELINE,
+ })
+ queue = get_switchboard(mm_cfg.INQUEUE_DIR)
+ elif subq is None:
+ msgdata['tolist'] = 1
+ queue = get_switchboard(mm_cfg.INQUEUE_DIR)
+ elif subq == 'request':
+ msgdata['torequest'] = 1
+ queue = get_switchboard(mm_cfg.CMDQUEUE_DIR)
+ else:
+ syslog('error', 'Unknown sub-queue: %s', subq)
+ os.rename(dstname, xdstname)
+ continue
+ queue.enqueue(msg, msgdata)
+ os.unlink(dstname)
+ except Exception, e:
+ os.rename(dstname, xdstname)
+ syslog('error', str(e))
+
+ def _cleanup(self):
+ pass
diff --git a/Mailman/Queue/Makefile.in b/Mailman/Queue/Makefile.in
new file mode 100644
index 00000000..a92ae67d
--- /dev/null
+++ b/Mailman/Queue/Makefile.in
@@ -0,0 +1,69 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# NOTE: Makefile.in is converted into Makefile by the configure script
+# in the parent directory. Once configure has run, you can recreate
+# the Makefile by running just config.status.
+
+# Variables set by configure
+
+VPATH= @srcdir@
+srcdir= @srcdir@
+bindir= @bindir@
+prefix= @prefix@
+exec_prefix= @exec_prefix@
+
+CC= @CC@
+CHMOD= @CHMOD@
+INSTALL= @INSTALL@
+
+DEFS= @DEFS@
+
+# Customizable but not set by configure
+
+OPT= @OPT@
+CFLAGS= $(OPT) $(DEFS)
+PACKAGEDIR= $(prefix)/Mailman/Queue
+SHELL= /bin/sh
+
+MODULES= *.py
+
+# Modes for directories and executables created by the install
+# process. Default to group-writable directories but
+# user-only-writable for executables.
+DIRMODE= 775
+EXEMODE= 755
+FILEMODE= 644
+INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE)
+
+
+# Rules
+
+all:
+
+install:
+ for f in $(MODULES); \
+ do \
+ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(PACKAGEDIR); \
+ done
+
+finish:
+
+clean:
+
+distclean:
+ -rm *.pyc
+ -rm Makefile
diff --git a/Mailman/Queue/NewsRunner.py b/Mailman/Queue/NewsRunner.py
new file mode 100644
index 00000000..0439f0e1
--- /dev/null
+++ b/Mailman/Queue/NewsRunner.py
@@ -0,0 +1,158 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""NNTP queue runner."""
+
+import re
+import socket
+import nntplib
+from cStringIO import StringIO
+
+import email
+from email.Utils import getaddresses
+
+COMMASPACE = ', '
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman.Queue.Runner import Runner
+from Mailman.Logging.Syslog import syslog
+
+
+# Matches our Mailman crafted Message-IDs. See Utils.unique_message_id()
+mcre = re.compile(r"""
+ <mailman. # match the prefix
+ \d+. # serial number
+ \d+. # time in seconds since epoch
+ \d+. # pid
+ (?P<listname>[^@]+) # list's internal_name()
+ @ # localpart@dom.ain
+ (?P<hostname>[^>]+) # list's host_name
+ > # trailer
+ """, re.VERBOSE)
+
+
+
+class NewsRunner(Runner):
+ QDIR = mm_cfg.NEWSQUEUE_DIR
+
+ def _dispose(self, mlist, msg, msgdata):
+ # Make sure we have the most up-to-date state
+ mlist.Load()
+ if not msgdata.get('prepped'):
+ prepare_message(mlist, msg, msgdata)
+ try:
+ # Flatten the message object, sticking it in a StringIO object
+ fp = StringIO(msg.as_string())
+ conn = None
+ try:
+ try:
+ conn = nntplib.NNTP(mlist.nntp_host, readermode=1,
+ user=mm_cfg.NNTP_USERNAME,
+ password=mm_cfg.NNTP_PASSWORD)
+ conn.post(fp)
+ except nntplib.error_temp, e:
+ syslog('error',
+ '(NNTPDirect) NNTP error for list "%s": %s',
+ mlist.internal_name(), e)
+ except socket.error, e:
+ syslog('error',
+ '(NNTPDirect) socket error for list "%s": %s',
+ mlist.internal_name(), e)
+ finally:
+ if conn:
+ conn.quit()
+ except Exception, e:
+ # Some other exception occurred, which we definitely did not
+ # expect, so set this message up for requeuing.
+ self._log(e)
+ return 1
+ return 0
+
+
+
+def prepare_message(mlist, msg, msgdata):
+ # If the newsgroup is moderated, we need to add this header for the Usenet
+ # software to accept the posting, and not forward it on to the n.g.'s
+ # moderation address. The posting would not have gotten here if it hadn't
+ # already been approved. 1 == open list, mod n.g., 2 == moderated
+ if mlist.news_moderation in (1, 2):
+ del msg['approved']
+ msg['Approved'] = mlist.GetListEmail()
+ # Should we restore the original, non-prefixed subject for gatewayed
+ # messages?
+ origsubj = msgdata.get('origsubj')
+ if not mlist.news_prefix_subject_too and origsubj is not None:
+ del msg['subject']
+ msg['subject'] = origsubj
+ # Add the appropriate Newsgroups: header
+ ngheader = msg['newsgroups']
+ if ngheader is not None:
+ # See if the Newsgroups: header already contains our linked_newsgroup.
+ # If so, don't add it again. If not, append our linked_newsgroup to
+ # the end of the header list
+ ngroups = [s.strip() for s in ngheader.split(',')]
+ if mlist.linked_newsgroup not in ngroups:
+ ngroups.append(mlist.linked_newsgroup)
+ # Subtitute our new header for the old one.
+ del msg['newsgroups']
+ msg['Newsgroups'] = COMMASPACE.join(ngroups)
+ else:
+ # Newsgroups: isn't in the message
+ msg['Newsgroups'] = mlist.linked_newsgroup
+ # Note: We need to be sure two messages aren't ever sent to the same list
+ # in the same process, since message ids need to be unique. Further, if
+ # messages are crossposted to two Usenet-gated mailing lists, they each
+ # need to have unique message ids or the nntpd will only accept one of
+ # them. The solution here is to substitute any existing message-id that
+ # isn't ours with one of ours, so we need to parse it to be sure we're not
+ # looping.
+ #
+ # Our Message-ID format is <mailman.secs.pid.listname@hostname>
+ msgid = msg['message-id']
+ hackmsgid = 1
+ if msgid:
+ mo = mcre.search(msgid)
+ if mo:
+ lname, hname = mo.group('listname', 'hostname')
+ if lname == mlist.internal_name() and hname == mlist.host_name:
+ hackmsgid = 0
+ if hackmsgid:
+ del msg['message-id']
+ msg['Message-ID'] = Utils.unique_message_id(mlist)
+ # Lines: is useful
+ if msg['Lines'] is None:
+ # BAW: is there a better way?
+ count = len(list(email.Iterators.body_line_iterator(msg)))
+ msg['Lines'] = str(count)
+ # Massage the message headers by remove some and rewriting others. This
+ # woon't completely sanitize the message, but it will eliminate the bulk
+ # of the rejections based on message headers. The NNTP server may still
+ # reject the message because of other problems.
+ for header in mm_cfg.NNTP_REMOVE_HEADERS:
+ del msg[header]
+ for header, rewrite in mm_cfg.NNTP_REWRITE_DUPLICATE_HEADERS:
+ values = msg.get_all(header, [])
+ if len(values) < 2:
+ # We only care about duplicates
+ continue
+ del msg[header]
+ # But keep the first one...
+ msg[header] = values[0]
+ for v in values[1:]:
+ msg[rewrite] = v
+ # Mark this message as prepared in case it has to be requeued
+ msgdata['prepped'] = 1
diff --git a/Mailman/Queue/OutgoingRunner.py b/Mailman/Queue/OutgoingRunner.py
new file mode 100644
index 00000000..aed8dcb9
--- /dev/null
+++ b/Mailman/Queue/OutgoingRunner.py
@@ -0,0 +1,139 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Outgoing queue runner."""
+
+import sys
+import os
+import time
+import socket
+
+import email
+
+from Mailman import mm_cfg
+from Mailman import Message
+from Mailman import Errors
+from Mailman import LockFile
+from Mailman.Queue.Runner import Runner
+from Mailman.Logging.Syslog import syslog
+
+# This controls how often _doperiodic() will try to deal with deferred
+# permanent failures.
+DEAL_WITH_PERMFAILURES_EVERY = 1
+
+
+
+class OutgoingRunner(Runner):
+ QDIR = mm_cfg.OUTQUEUE_DIR
+
+ def __init__(self, slice=None, numslices=1):
+ Runner.__init__(self, slice, numslices)
+ # Maps mailing lists to (recip, msg) tuples
+ self._permfailures = {}
+ self._permfail_counter = 0
+ # We look this function up only at startup time
+ modname = 'Mailman.Handlers.' + mm_cfg.DELIVERY_MODULE
+ mod = __import__(modname)
+ self._func = getattr(sys.modules[modname], 'process')
+ # This prevents smtp server connection problems from filling up the
+ # error log. It gets reset if the message was successfully sent, and
+ # set if there was a socket.error.
+ self.__logged = 0
+
+ def _dispose(self, mlist, msg, msgdata):
+ # Make sure we have the most up-to-date state
+ mlist.Load()
+ try:
+ pid = os.getpid()
+ self._func(mlist, msg, msgdata)
+ # Failsafe -- a child may have leaked through.
+ if pid <> os.getpid():
+ syslog('error', 'child process leaked thru: %s', modname)
+ os._exit(1)
+ self.__logged = 0
+ except socket.error:
+ # There was a problem connecting to the SMTP server. Log this
+ # once, but crank up our sleep time so we don't fill the error
+ # log.
+ port = mm_cfg.SMTPPORT
+ if port == 0:
+ port = 'smtp'
+ # Log this just once.
+ if not self.__logged:
+ syslog('error', 'Cannot connect to SMTP server %s on port %s',
+ mm_cfg.SMTPHOST, port)
+ self.__logged = 1
+ return 1
+ except Errors.SomeRecipientsFailed, e:
+ # The delivery module being used (SMTPDirect or Sendmail) failed
+ # to deliver the message to one or all of the recipients.
+ # Permanent failures should be registered (but registration
+ # requires the list lock), and temporary failures should be
+ # retried later.
+ #
+ # For permanent failures, make a copy of the message for bounce
+ # handling. I'm not sure this is necessary, or the right thing to
+ # do.
+ pcnt = len(e.permfailures)
+ copy = email.message_from_string(str(msg))
+ self._permfailures.setdefault(mlist, []).extend(
+ zip(e.permfailures, [copy] * pcnt))
+ # Temporary failures
+ if not e.tempfailures:
+ # Don't need to keep the message queued if there were only
+ # permanent failures.
+ return 0
+ now = time.time()
+ recips = e.tempfailures
+ last_recip_count = msgdata.get('last_recip_count', 0)
+ deliver_until = msgdata.get('deliver_until', now)
+ if len(recips) == last_recip_count:
+ # We didn't make any progress, so don't attempt delivery any
+ # longer. BAW: is this the best disposition?
+ if now > deliver_until:
+ return 0
+ else:
+ # Keep trying to delivery this for 3 days
+ deliver_until = now + mm_cfg.DELIVERY_RETRY_PERIOD
+ msgdata['last_recip_count'] = len(recips)
+ msgdata['deliver_until'] = deliver_until
+ msgdata['recips'] = recips
+ # Requeue
+ return 1
+ # We've successfully completed handling of this message
+ return 0
+
+ def _doperiodic(self):
+ # Periodically try to acquire the list lock and clear out the
+ # permanent failures.
+ self._permfail_counter += 1
+ if self._permfail_counter < DEAL_WITH_PERMFAILURES_EVERY:
+ return
+ # Reset the counter
+ self._permfail_counter = 0
+ # And deal with the deferred permanent failures.
+ for mlist in self._permfailures.keys():
+ try:
+ mlist.Lock(timeout=mm_cfg.LIST_LOCK_TIMEOUT)
+ except LockFile.TimeOutError:
+ return
+ try:
+ for recip, msg in self._permfailures[mlist]:
+ mlist.registerBounce(recip, msg)
+ del self._permfailures[mlist]
+ mlist.Save()
+ finally:
+ mlist.Unlock()
diff --git a/Mailman/Queue/Runner.py b/Mailman/Queue/Runner.py
new file mode 100644
index 00000000..134dac99
--- /dev/null
+++ b/Mailman/Queue/Runner.py
@@ -0,0 +1,245 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Generic queue runner class.
+"""
+
+import time
+import traceback
+import weakref
+from cStringIO import StringIO
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman import MailList
+from Mailman import i18n
+
+from Mailman.Queue.Switchboard import Switchboard
+from Mailman.Logging.Syslog import syslog
+
+
+
+class Runner:
+ QDIR = None
+ SLEEPTIME = mm_cfg.QRUNNER_SLEEP_TIME
+
+ def __init__(self, slice=None, numslices=1):
+ self._kids = {}
+ # Create our own switchboard. Don't use the switchboard cache because
+ # we want to provide slice and numslice arguments.
+ self._switchboard = Switchboard(self.QDIR, slice, numslices)
+ # Create the shunt switchboard
+ self._shunt = Switchboard(mm_cfg.SHUNTQUEUE_DIR)
+ self._stop = 0
+
+ def stop(self):
+ self._stop = 1
+
+ def run(self):
+ # Start the main loop for this queue runner.
+ try:
+ try:
+ while 1:
+ # Once through the loop that processes all the files in
+ # the queue directory.
+ filecnt = self._oneloop()
+ # Do the periodic work for the subclass. BAW: this
+ # shouldn't be called here. There should be one more
+ # _doperiodic() call at the end of the _oneloop() loop.
+ self._doperiodic()
+ # If the stop flag is set, we're done.
+ if self._stop:
+ break
+ # If there were no files to process, then we'll simply
+ # sleep for a little while and expect some to show up.
+ if not filecnt:
+ self._snooze()
+ except KeyboardInterrupt:
+ pass
+ finally:
+ # We've broken out of our main loop, so we want to reap all the
+ # subprocesses we've created and do any other necessary cleanups.
+ self._cleanup()
+
+ def _oneloop(self):
+ # First, list all the files in our queue directory.
+ # Switchboard.files() is guaranteed to hand us the files in FIFO
+ # order. Return an integer count of the number of files that were
+ # available for this qrunner to process. A non-zero value tells run()
+ # not to snooze for a while.
+ files = self._switchboard.files()
+ for filebase in files:
+ # Ask the switchboard for the message and metadata objects
+ # associated with this filebase.
+ msg, msgdata = self._switchboard.dequeue(filebase)
+ # It's possible one or both files got lost. If so, just ignore
+ # this filebase entry. dequeue() will automatically unlink the
+ # other file, but we should log an error message for diagnostics.
+ if msg is None or msgdata is None:
+ syslog('error', 'lost data files for filebase: %s', filebase)
+ else:
+ # Now that we've dequeued the message, we want to be
+ # incredibly anal about making sure that no uncaught exception
+ # could cause us to lose the message. All runners that
+ # implement _dispose() must guarantee that exceptions are
+ # caught and dealt with properly. Still, there may be a bug
+ # in the infrastructure, and we do not want those to cause
+ # messages to be lost. Any uncaught exceptions will cause the
+ # message to be stored in the shunt queue for human
+ # intervention.
+ try:
+ self._onefile(msg, msgdata)
+ except Exception, e:
+ self._log(e)
+ syslog('error', 'SHUNTING: %s', filebase)
+ # Put a marker in the metadata for unshunting
+ msgdata['whichq'] = self._switchboard.whichq()
+ self._shunt.enqueue(msg, msgdata)
+ # Other work we want to do each time through the loop
+ Utils.reap(self._kids, once=1)
+ self._doperiodic()
+ if self._shortcircuit():
+ break
+ return len(files)
+
+ def _onefile(self, msg, msgdata):
+ # Do some common sanity checking on the message metadata. It's got to
+ # be destined for a particular mailing list. This switchboard is used
+ # to shunt off badly formatted messages. We don't want to just trash
+ # them because they may be fixable with human intervention. Just get
+ # them out of our site though.
+ #
+ # Find out which mailing list this message is destined for.
+ listname = msgdata.get('listname')
+ if not listname:
+ listname = mm_cfg.MAILMAN_SITE_LIST
+ mlist = self._open_list(listname)
+ if not mlist:
+ syslog('error',
+ 'Dequeuing message destined for missing list: %s',
+ listname)
+ self._shunt.enqueue(msg, msgdata)
+ return
+ # Now process this message, keeping track of any subprocesses that may
+ # have been spawned. We'll reap those later.
+ #
+ # We also want to set up the language context for this message. The
+ # context will be the preferred language for the user if a member of
+ # the list, or the list's preferred language. However, we must take
+ # special care to reset the defaults, otherwise subsequent messages
+ # may be translated incorrectly. BAW: I'm not sure I like this
+ # approach, but I can't think of anything better right now.
+ otranslation = i18n.get_translation()
+ sender = msg.get_sender()
+ if mlist:
+ lang = mlist.getMemberLanguage(sender)
+ else:
+ lang = mm_cfg.DEFAULT_SERVER_LANGUAGE
+ i18n.set_language(lang)
+ msgdata['lang'] = lang
+ try:
+ keepqueued = self._dispose(mlist, msg, msgdata)
+ finally:
+ i18n.set_translation(otranslation)
+ # Keep tabs on any child processes that got spawned.
+ kids = msgdata.get('_kids')
+ if kids:
+ self._kids.update(kids)
+ if keepqueued:
+ self._switchboard.enqueue(msg, msgdata)
+
+ # Mapping of listnames to MailList instances as a weak value dictionary.
+ _listcache = weakref.WeakValueDictionary()
+
+ def _open_list(self, listname):
+ # Cache the open list so that any use of the list within this process
+ # uses the same object. We use a WeakValueDictionary so that when the
+ # list is no longer necessary, its memory is freed.
+ mlist = self._listcache.get(listname)
+ if not mlist:
+ try:
+ mlist = MailList.MailList(listname, lock=0)
+ except Errors.MMListError, e:
+ syslog('error', 'error opening list: %s\n%s', listname, e)
+ return None
+ else:
+ self._listcache[listname] = mlist
+ return mlist
+
+ def _log(self, exc):
+ syslog('error', 'Uncaught runner exception: %s', exc)
+ s = StringIO()
+ traceback.print_exc(file=s)
+ syslog('error', s.getvalue())
+
+ #
+ # Subclasses can override these methods.
+ #
+ def _cleanup(self):
+ """Clean up upon exit from the main processing loop.
+
+ Called when the Runner's main loop is stopped, this should perform
+ any necessary resource deallocation. Its return value is irrelevant.
+ """
+ Utils.reap(self._kids)
+
+ def _dispose(self, mlist, msg, msgdata):
+ """Dispose of a single message destined for a mailing list.
+
+ Called for each message that the Runner is responsible for, this is
+ the primary overridable method for processing each message.
+ Subclasses, must provide implementation for this method.
+
+ mlist is the MailList instance this message is destined for.
+
+ msg is the Message object representing the message.
+
+ msgdata is a dictionary of message metadata.
+ """
+ raise NotImplementedError
+
+ def _doperiodic(self):
+ """Do some processing `every once in a while'.
+
+ Called every once in a while both from the Runner's main loop, and
+ from the Runner's hash slice processing loop. You can do whatever
+ special periodic processing you want here, and the return value is
+ irrelevant.
+
+ """
+ pass
+
+ def _snooze(self):
+ """Sleep for a little while, because there was nothing to do.
+
+ This is called from the Runner's main loop, but only when the last
+ processing loop had no work to do (i.e. there were no messages in it's
+ little slice of hash space).
+ """
+ if self.SLEEPTIME <= 0:
+ return
+ time.sleep(self.SLEEPTIME)
+
+ def _shortcircuit(self):
+ """Return a true value if the individual file processing loop should
+ exit before it's finished processing each message in the current slice
+ of hash space. A false value tells _oneloop() to continue processing
+ until the current snapshot of hash space is exhausted.
+
+ You could, for example, implement a throttling algorithm here.
+ """
+ return self._stop
diff --git a/Mailman/Queue/Switchboard.py b/Mailman/Queue/Switchboard.py
new file mode 100644
index 00000000..530055ad
--- /dev/null
+++ b/Mailman/Queue/Switchboard.py
@@ -0,0 +1,340 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Reading and writing message objects and message metadata.
+"""
+
+# enqueue() and dequeue() are not symmetric. enqueue() takes a Message
+# object. dequeue() returns a email.Message object tree.
+#
+# Message metadata is represented internally as a Python dictionary. Keys and
+# values must be strings. When written to a queue directory, the metadata is
+# written into an externally represented format, as defined here. Because
+# components of the Mailman system may be written in something other than
+# Python, the external interchange format should be chosen based on what those
+# other components can read and write.
+#
+# Most efficient, and recommended if everything is Python, is Python marshal
+# format. Also supported by default is Berkeley db format (using the default
+# bsddb module compiled into your Python executable -- usually Berkeley db
+# 2), and rfc822 style plain text. You can write your own if you have other
+# needs.
+
+import os
+import time
+import sha
+import marshal
+import errno
+import cPickle
+
+import email
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman.Logging.Syslog import syslog
+
+# 20 bytes of all bits set, maximum sha.digest() value
+shamax = 0xffffffffffffffffffffffffffffffffffffffffL
+
+SAVE_MSGS_AS_PICKLES = 1
+
+
+
+class _Switchboard:
+ def __init__(self, whichq, slice=None, numslices=1):
+ self.__whichq = whichq
+ # Create the directory if it doesn't yet exist.
+ # FIXME
+ omask = os.umask(0) # rwxrws---
+ try:
+ try:
+ os.mkdir(self.__whichq, 0770)
+ except OSError, e:
+ if e.errno <> errno.EEXIST: raise
+ finally:
+ os.umask(omask)
+ # Fast track for no slices
+ self.__lower = None
+ self.__upper = None
+ # BAW: test performance and end-cases of this algorithm
+ if numslices <> 1:
+ self.__lower = (shamax * slice) / numslices
+ self.__upper = (shamax * (slice+1)) / numslices
+
+ def whichq(self):
+ return self.__whichq
+
+ def enqueue(self, _msg, _metadata={}, **_kws):
+ # Calculate the SHA hexdigest of the message to get a unique base
+ # filename. We're also going to use the digest as a hash into the set
+ # of parallel qrunner processes.
+ data = _metadata.copy()
+ data.update(_kws)
+ listname = data.get('listname', '--nolist--')
+ # Get some data for the input to the sha hash
+ now = time.time()
+ if SAVE_MSGS_AS_PICKLES and not data.get('_plaintext'):
+ msgsave = cPickle.dumps(_msg, 1)
+ ext = '.pck'
+ else:
+ msgsave = str(_msg)
+ ext = '.msg'
+ hashfood = msgsave + listname + `now`
+ # Encode the current time into the file name for FIFO sorting in
+ # files(). The file name consists of two parts separated by a `+':
+ # the received time for this message (i.e. when it first showed up on
+ # this system) and the sha hex digest.
+ #rcvtime = data.setdefault('received_time', now)
+ rcvtime = data.setdefault('received_time', now)
+ filebase = `rcvtime` + '+' + sha.new(hashfood).hexdigest()
+ # Figure out which queue files the message is to be written to.
+ msgfile = os.path.join(self.__whichq, filebase + ext)
+ dbfile = os.path.join(self.__whichq, filebase + '.db')
+ # Always add the metadata schema version number
+ data['version'] = mm_cfg.QFILE_SCHEMA_VERSION
+ # Filter out volatile entries
+ for k in data.keys():
+ if k[0] == '_':
+ del data[k]
+ # Now write the message text to one file and the metadata to another
+ # file. The metadata is always written second to avoid race
+ # conditions with the various queue runners (which key off of the .db
+ # filename).
+ omask = os.umask(007) # -rw-rw----
+ try:
+ msgfp = open(msgfile, 'w')
+ finally:
+ os.umask(omask)
+ msgfp.write(msgsave)
+ msgfp.close()
+ # Now write the metadata using the appropriate external metadata
+ # format. We play rename-switcheroo here to further plug the race
+ # condition holes.
+ tmpfile = dbfile + '.tmp'
+ self._ext_write(tmpfile, data)
+ os.rename(tmpfile, dbfile)
+
+ def dequeue(self, filebase):
+ # Calculate the .db and .msg filenames from the given filebase.
+ msgfile = os.path.join(self.__whichq, filebase + '.msg')
+ pckfile = os.path.join(self.__whichq, filebase + '.pck')
+ dbfile = os.path.join(self.__whichq, 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 = data = None
+ try:
+ data = self._ext_read(dbfile)
+ os.unlink(dbfile)
+ except EnvironmentError, e:
+ if e.errno <> errno.ENOENT: raise
+ # 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, e:
+ # This message was unparsable, most likely because its
+ # MIME encapsulation was broken. For now, there's not
+ # much we can do about it.
+ syslog('error', 'message is unparsable: %s', filebase)
+ msgfp.close()
+ msgfp = None
+ if mm_cfg.QRUNNER_SAVE_BAD_MESSAGES:
+ # Cheapo way to ensure the directory exists w/ the
+ # proper permissions.
+ sb = Switchboard(mm_cfg.BADQUEUE_DIR)
+ os.rename(msgfile, os.path.join(
+ mm_cfg.BADQUEUE_DIR, filebase + '.txt'))
+ else:
+ os.unlink(msgfile)
+ msg = data = None
+ finally:
+ if msgfp:
+ msgfp.close()
+ return msg, data
+
+ def files(self):
+ times = {}
+ lower = self.__lower
+ upper = self.__upper
+ for f in os.listdir(self.__whichq):
+ # We only care about the file's base name (i.e. no extension).
+ # Thus we'll ignore anything that doesn't end in .db.
+ if not f.endswith('.db'):
+ continue
+ filebase = os.path.splitext(f)[0]
+ when, digest = filebase.split('+')
+ # Throw out any files which don't match our bitrange. BAW: test
+ # performance and end-cases of this algorithm.
+ if not lower or (lower <= long(digest, 16) < upper):
+ times[float(when)] = filebase
+ # FIFO sort
+ keys = times.keys()
+ keys.sort()
+ return [times[k] for k in keys]
+
+ def _ext_write(self, tmpfile, data):
+ raise NotImplementedError
+
+ def _ext_read(self, dbfile):
+ raise NotImplementedError
+
+
+
+class MarshalSwitchboard(_Switchboard):
+ """Python marshal format."""
+ FLOAT_ATTRIBUTES = ['received_time']
+
+ def _ext_write(self, filename, dict):
+ omask = os.umask(007) # -rw-rw----
+ try:
+ fp = open(filename, 'w')
+ finally:
+ os.umask(omask)
+ # Python's marshal, up to and including in Python 2.1, has a bug where
+ # the full precision of floats was not stored. We work around this
+ # bug by hardcoding a list of float values we know about, repr()-izing
+ # them ourselves, and doing the reverse conversion on _ext_read().
+ for attr in self.FLOAT_ATTRIBUTES:
+ # We use try/except because we expect a hitrate of nearly 100%
+ try:
+ fval = dict[attr]
+ except KeyError:
+ pass
+ else:
+ dict[attr] = repr(fval)
+ marshal.dump(dict, fp)
+ fp.close()
+
+ def _ext_read(self, filename):
+ fp = open(filename)
+ dict = marshal.load(fp)
+ # Update from version 2 files
+ if dict.get('version', 0) == 2:
+ del dict['filebase']
+ # Do the reverse conversion (repr -> float)
+ for attr in self.FLOAT_ATTRIBUTES:
+ try:
+ sval = dict[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.
+ dict[attr] = eval(sval, {'__builtins__': {}})
+ fp.close()
+ return dict
+
+
+
+class BSDDBSwitchboard(_Switchboard):
+ """Native (i.e. compiled-in) Berkeley db format."""
+ def _ext_write(self, filename, dict):
+ import bsddb
+ omask = os.umask(0)
+ try:
+ hashfile = bsddb.hashopen(filename, 'n', 0660)
+ finally:
+ os.umask(omask)
+ # values must be strings
+ for k, v in dict.items():
+ hashfile[k] = marshal.dumps(v)
+ hashfile.sync()
+ hashfile.close()
+
+ def _ext_read(self, filename):
+ import bsddb
+ dict = {}
+ hashfile = bsddb.hashopen(filename, 'r')
+ for k in hashfile.keys():
+ dict[k] = marshal.loads(hashfile[k])
+ hashfile.close()
+ return dict
+
+
+
+class ASCIISwitchboard(_Switchboard):
+ """Human readable .db file format.
+
+ key/value pairs are written as
+
+ key = value
+
+ as real Python code which can be execfile'd.
+ """
+
+ def _ext_write(self, filename, dict):
+ omask = os.umask(007) # -rw-rw----
+ try:
+ fp = open(filename, 'w')
+ finally:
+ os.umask(omask)
+ for k, v in dict.items():
+ print >> fp, '%s = %s' % (k, repr(v))
+ fp.close()
+
+ def _ext_read(self, filename):
+ dict = {'__builtins__': {}}
+ execfile(filename, dict)
+ del dict['__builtins__']
+ return dict
+
+
+
+# Here are the various types of external file formats available. The format
+# chosen is given defined in the mm_cfg.py configuration file.
+if mm_cfg.METADATA_FORMAT == mm_cfg.METAFMT_MARSHAL:
+ Switchboard = MarshalSwitchboard
+elif mm_cfg.METADATA_FORMAT == mm_cfg.METAFMT_BSDDB_NATIVE:
+ Switchboard = BSDDBSwitchboard
+elif mm_cfg.METADATA_FORMAT == mm_cfg.METAFMT_ASCII:
+ Switchboard = ASCIISwitchboard
+else:
+ syslog('error', 'Undefined metadata format: %d (using marshals)',
+ mm_cfg.METADATA_FORMAT)
+ Switchboard = MarshalSwitchboard
+
+
+
+# For bin/dumpdb
+class DumperSwitchboard(Switchboard):
+ def __init__(self):
+ pass
+
+ def read(self, filename):
+ return self._ext_read(filename)
diff --git a/Mailman/Queue/VirginRunner.py b/Mailman/Queue/VirginRunner.py
new file mode 100644
index 00000000..720ecd25
--- /dev/null
+++ b/Mailman/Queue/VirginRunner.py
@@ -0,0 +1,43 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Virgin message queue runner.
+
+This qrunner handles messages that the Mailman system gives virgin birth to.
+E.g. acknowledgement responses to user posts or Replybot messages. They need
+to go through some minimal processing before they can be sent out to the
+recipient.
+"""
+
+from Mailman import mm_cfg
+from Mailman.Queue.Runner import Runner
+from Mailman.Queue.IncomingRunner import IncomingRunner
+
+
+
+class VirginRunner(IncomingRunner):
+ QDIR = mm_cfg.VIRGINQUEUE_DIR
+
+ def _dispose(self, mlist, msg, msgdata):
+ # We need to fasttrack this message through any handlers that touch
+ # it. E.g. especially CookHeaders.
+ msgdata['_fasttrack'] = 1
+ return IncomingRunner._dispose(self, mlist, msg, msgdata)
+
+ def _get_pipeline(self, mlist, msg, msgdata):
+ # It's okay to hardcode this, since it'll be the same for all
+ # internally crafted messages.
+ return ['CookHeaders', 'ToOutgoing']
diff --git a/Mailman/Queue/__init__.py b/Mailman/Queue/__init__.py
new file mode 100644
index 00000000..cdf93257
--- /dev/null
+++ b/Mailman/Queue/__init__.py
@@ -0,0 +1,15 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
diff --git a/Mailman/Queue/sbcache.py b/Mailman/Queue/sbcache.py
new file mode 100644
index 00000000..9b918fc5
--- /dev/null
+++ b/Mailman/Queue/sbcache.py
@@ -0,0 +1,26 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""A factory of Switchboards with caching."""
+
+from Mailman.Queue.Switchboard import Switchboard
+
+# a mapping from queue directory to Switchboard instance
+_sbcache = {}
+
+def get_switchboard(qdir):
+ switchboard = _sbcache.setdefault(qdir, Switchboard(qdir))
+ return switchboard
diff --git a/Mailman/SafeDict.py b/Mailman/SafeDict.py
new file mode 100644
index 00000000..37b5198a
--- /dev/null
+++ b/Mailman/SafeDict.py
@@ -0,0 +1,70 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""A `safe' dictionary for string interpolation."""
+
+from types import StringType
+from UserDict import UserDict
+
+COMMASPACE = ', '
+
+
+
+class SafeDict(UserDict):
+ """Dictionary which returns a default value for unknown keys.
+
+ This is used in maketext so that editing templates is a bit more robust.
+ """
+ def __getitem__(self, key):
+ try:
+ return self.data[key]
+ except KeyError:
+ if isinstance(key, StringType):
+ return '%('+key+')s'
+ else:
+ return '<Missing key: %s>' % `key`
+
+ def interpolate(self, template):
+ return template % self
+
+
+
+class MsgSafeDict(SafeDict):
+ def __init__(self, msg, dict=None):
+ self.__msg = msg
+ SafeDict.__init__(self, dict)
+
+ def __getitem__(self, key):
+ if key.startswith('msg_'):
+ return self.__msg.get(key[4:], 'n/a')
+ elif key.startswith('allmsg_'):
+ missing = []
+ all = self.__msg.get_all(key[7:], missing)
+ if all is missing:
+ return 'n/a'
+ return COMMASPACE.join(all)
+ else:
+ return SafeDict.__getitem__(self, key)
+
+ def copy(self):
+ d = self.data.copy()
+ for k in self.__msg.keys():
+ vals = self.__msg.get_all(k)
+ if len(vals) == 1:
+ d['msg_'+k.lower()] = vals[0]
+ else:
+ d['allmsg_'+k.lower()] = COMMASPACE.join(vals)
+ return d
diff --git a/Mailman/SecurityManager.py b/Mailman/SecurityManager.py
new file mode 100644
index 00000000..8b65738e
--- /dev/null
+++ b/Mailman/SecurityManager.py
@@ -0,0 +1,333 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+"""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 time
+import sha
+import marshal
+import binascii
+import Cookie
+from types import StringType, TupleType
+from urlparse import urlparse
+
+try:
+ import crypt
+except ImportError:
+ crypt = None
+import md5
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Errors
+from Mailman.Logging.Syslog import syslog
+
+
+
+class SecurityManager:
+ def InitVars(self):
+ # We used to set self.password here, from a crypted_password argument,
+ # but that's been removed when we generalized the mixin architecture.
+ # self.password is really a SecurityManager attribute, but it's set in
+ # MailList.InitVars().
+ self.mod_password = None
+ # Non configurable
+ self.passwords = {}
+
+ 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 = self.internal_name() + '+'
+ if authcontext == mm_cfg.AuthUser:
+ if user is None:
+ # A bad system error
+ raise TypeError, 'No user supplied for AuthUser context'
+ secret = self.getMemberPassword(user)
+ key += 'user+%s' % Utils.ObscureEmail(user)
+ elif authcontext == mm_cfg.AuthListModerator:
+ secret = self.mod_password
+ key += 'moderator'
+ elif authcontext == mm_cfg.AuthListAdmin:
+ secret = self.password
+ key += 'admin'
+ # BAW: AuthCreator
+ elif authcontext == mm_cfg.AuthSiteAdmin:
+ sitepass = Utils.get_global_password()
+ if mm_cfg.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 == mm_cfg.AuthCreator:
+ ok = Utils.check_global_password(response, siteadmin=0)
+ if ok:
+ return mm_cfg.AuthCreator
+ elif ac == mm_cfg.AuthSiteAdmin:
+ ok = Utils.check_global_password(response)
+ if ok:
+ return mm_cfg.AuthSiteAdmin
+ elif ac == mm_cfg.AuthListAdmin:
+ def cryptmatchp(response, secret):
+ try:
+ salt = secret[:2]
+ if crypt and crypt.crypt(response, salt) == secret:
+ return 1
+ return 0
+ except TypeError:
+ # BAW: Hard to say why we can get a TypeError here.
+ # SF bug report #585776 says crypt.crypt() can raise
+ # this if salt contains null bytes, although I don't
+ # know how that can happen (perhaps if a MM2.0 list
+ # with USE_CRYPT = 0 has been updated? Doubtful.
+ return 0
+ # 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. Note however, that for backwards
+ # compatibility reasons, we'll also check the admin response
+ # against the crypted and md5'd passwords, and if they match,
+ # we'll auto-migrate the passwords to sha.
+ key, secret = self.AuthContextInfo(ac)
+ if secret is None:
+ continue
+ sharesponse = sha.new(response).hexdigest()
+ upgrade = ok = 0
+ if sharesponse == secret:
+ ok = 1
+ elif md5.new(response).digest() == secret:
+ ok = 1
+ upgrade = 1
+ elif cryptmatchp(response, secret):
+ ok = 1
+ upgrade = 1
+ if upgrade:
+ save_and_unlock = 0
+ if not self.Locked():
+ self.Lock()
+ save_and_unlock = 1
+ try:
+ self.password = sharesponse
+ if save_and_unlock:
+ self.Save()
+ finally:
+ if save_and_unlock:
+ self.Unlock()
+ if ok:
+ return ac
+ elif ac == mm_cfg.AuthListModerator:
+ # The list moderator password must be sha'd
+ key, secret = self.AuthContextInfo(ac)
+ if secret and sha.new(response).hexdigest() == secret:
+ return ac
+ elif ac == mm_cfg.AuthUser:
+ if self.authenticateMember(user, response):
+ return ac
+ else:
+ # What is this context???
+ syslog('error', 'Bad authcontext: %s', ac)
+ raise ValueError, 'Bad authcontext: %s' % ac
+ return mm_cfg.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 must not be None.
+ #
+ # Returns a flag indicating whether authentication succeeded or not.
+ try:
+ for ac in authcontexts:
+ ok = self.CheckCookie(ac, user)
+ if ok:
+ return 1
+ # Check passwords
+ ac = self.Authenticate(authcontexts, response, user)
+ if ac:
+ print self.MakeCookie(ac, user)
+ return 1
+ except Errors.NotAMemberError:
+ pass
+ return 0
+
+ def MakeCookie(self, authcontext, user=None):
+ key, secret = self.AuthContextInfo(authcontext, user)
+ if key is None or secret is None or not isinstance(secret, StringType):
+ raise ValueError
+ # Timestamp
+ issued = int(time.time())
+ # Get a digest of the secret, plus other information.
+ mac = sha.new(secret + `issued`).hexdigest()
+ # Create the cookie object.
+ c = Cookie.SimpleCookie()
+ c[key] = binascii.hexlify(marshal.dumps((issued, mac)))
+ # The path to all Mailman stuff, minus the scheme and host,
+ # i.e. usually the string `/mailman'
+ path = urlparse(self.web_page_url)[2]
+ c[key]['path'] = 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] = ''
+ # The path to all Mailman stuff, minus the scheme and host,
+ # i.e. usually the string `/mailman'
+ path = urlparse(self.web_page_url)[2]
+ c[key]['path'] = 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 0
+ # Treat the cookie data as simple strings, and do application level
+ # decoding as necessary. By using SimpleCookie, we prevent any kind
+ # of security breach due to untrusted cookie data being unpickled
+ # (which is quite unsafe).
+ try:
+ c = Cookie.SimpleCookie(cookiedata)
+ except Cookie.CookieError:
+ return 0
+ # 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 == mm_cfg.AuthUser:
+ if user:
+ usernames = [user]
+ else:
+ usernames = []
+ prefix = self.internal_name() + '+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 1
+ return 0
+ 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.
+ key, secret = self.AuthContextInfo(authcontext, user)
+ if not c.has_key(key) or not isinstance(secret, StringType):
+ return 0
+ # 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].value))
+ issued, received_mac = data
+ except (EOFError, ValueError, TypeError, KeyError):
+ return 0
+ # Make sure the issued timestamp makes sense
+ now = time.time()
+ if now < issued:
+ return 0
+ # Calculate what the mac ought to be based on the cookie's timestamp
+ # and the shared secret.
+ mac = sha.new(secret + `issued`).hexdigest()
+ if mac <> received_mac:
+ return 0
+ # Authenticated!
+ return 1
diff --git a/Mailman/Site.py b/Mailman/Site.py
new file mode 100644
index 00000000..d4360d2c
--- /dev/null
+++ b/Mailman/Site.py
@@ -0,0 +1,107 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""Provide some customization for site-wide behavior.
+
+This should be considered experimental for Mailman 2.1. The default
+implementation should work for standard Mailman.
+"""
+
+import os
+import errno
+
+from Mailman import mm_cfg
+
+
+
+def _makedir(path):
+ try:
+ omask = os.umask(0)
+ try:
+ os.makedirs(path, 02775)
+ finally:
+ os.umask(omask)
+ except OSError, e:
+ # Ignore the exceptions if the directory already exists
+ if e.errno <> errno.EEXIST:
+ raise
+
+
+
+# BAW: We don't really support domain<>None yet. This will be added in a
+# future version. By default, Mailman will never pass in a domain argument.
+def get_listpath(listname, domain=None, create=0):
+ """Return the file system path to the list directory for the named list.
+
+ If domain is given, it is the virtual domain for the named list. The
+ default is to not distinguish list paths on the basis of virtual domains.
+
+ If the create flag is true, then this method should create the path
+ hierarchy if necessary. If the create flag is false, then this function
+ should not attempt to create the path heirarchy (and in fact the absence
+ of the path might be significant).
+ """
+ path = os.path.join(mm_cfg.LIST_DATA_DIR, listname)
+ if create:
+ _makedir(path)
+ return path
+
+
+
+# BAW: We don't really support domain<>None yet. This will be added in a
+# future version. By default, Mailman will never pass in a domain argument.
+def get_archpath(listname, domain=None, create=0, public=0):
+ """Return the file system path to the list's archive directory for the
+ named list in the named virtual domain.
+
+ If domain is given, it is the virtual domain for the named list. The
+ default is to not distinguish list paths on the basis of virtual domains.
+
+ If the create flag is true, then this method should create the path
+ hierarchy if necessary. If the create flag is false, then this function
+ should not attempt to create the path heirarchy (and in fact the absence
+ of the path might be significant).
+
+ If public is true, then the path points to the public archive path (which
+ is usually a symlink instead of a directory).
+ """
+ if public:
+ subdir = mm_cfg.PUBLIC_ARCHIVE_FILE_DIR
+ else:
+ subdir = mm_cfg.PRIVATE_ARCHIVE_FILE_DIR
+ path = os.path.join(subdir, listname)
+ if create:
+ _makedir(path)
+ return path
+
+
+
+# BAW: We don't really support domain<>None yet. This will be added in a
+# future version. By default, Mailman will never pass in a domain argument.
+def get_listnames(domain=None):
+ """Return the names of all the known lists for the given domain.
+
+ If domain is given, it is the virtual domain for the named list. The
+ default is to not distinguish list paths on the basis of virtual domains.
+ """
+ # Import this here to avoid circular imports
+ from Mailman.Utils import list_exists
+ # We don't currently support separate virtual domain directories
+ got = []
+ for fn in os.listdir(mm_cfg.LIST_DATA_DIR):
+ if list_exists(fn):
+ got.append(fn)
+ return got
diff --git a/Mailman/TopicMgr.py b/Mailman/TopicMgr.py
new file mode 100644
index 00000000..09c10d9b
--- /dev/null
+++ b/Mailman/TopicMgr.py
@@ -0,0 +1,61 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""This class mixes in topic feature configuration for mailing lists.
+"""
+
+import re
+
+from Mailman import mm_cfg
+from Mailman.i18n import _
+
+
+
+class TopicMgr:
+ def InitVars(self):
+ # Configurable
+ #
+ # `topics' is a list of 4-tuples of the following form:
+ #
+ # (name, pattern, description, emptyflag)
+ #
+ # name is a required arbitrary string displayed to the user when they
+ # get to select their topics of interest
+ #
+ # pattern is a required verbose regular expression pattern which is
+ # used as IGNORECASE.
+ #
+ # description is an optional description of what this topic is
+ # supposed to match
+ #
+ # emptyflag is a boolean used internally in the admin interface to
+ # signal whether a topic entry is new or not (new ones which do not
+ # have a name or pattern are not saved when the submit button is
+ # pressed).
+ self.topics = []
+ self.topics_enabled = 0
+ self.topics_bodylines_limit = 5
+ # Non-configurable
+ #
+ # This is a mapping between user "names" (i.e. addresses) and
+ # information about which topics that user is interested in. The
+ # values are a list of topic names that the user is interested in,
+ # which should match the topic names in self.topics above.
+ #
+ # If the user has not selected any topics of interest, then the rule
+ # is that they will get all messages, and they will not have an entry
+ # in this dictionary.
+ self.topics_userinterest = {}
diff --git a/Mailman/UserDesc.py b/Mailman/UserDesc.py
new file mode 100644
index 00000000..aa06639f
--- /dev/null
+++ b/Mailman/UserDesc.py
@@ -0,0 +1,57 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""User description class/structure, for ApprovedAddMember and friends."""
+
+class UserDesc:
+ def __init__(self, address=None, fullname=None, password=None,
+ digest=None, lang=None):
+ if address is not None:
+ self.address = address
+ if fullname is not None:
+ self.fullname = fullname
+ if password is not None:
+ self.password = password
+ if digest is not None:
+ self.digest = digest
+ if lang is not None:
+ self.language = lang
+
+ def __iadd__(self, other):
+ if getattr(other, 'address', None) is not None:
+ self.address = other.address
+ if getattr(other, 'fullname', None) is not None:
+ self.fullname = other.fullname
+ if getattr(other, 'password', None) is not None:
+ self.password = other.password
+ if getattr(other, 'digest', None) is not None:
+ self.digest = other.digest
+ if getattr(other, 'language', None) is not None:
+ self.language = other.language
+ return self
+
+ def __repr__(self):
+ address = getattr(self, 'address', 'n/a')
+ fullname = getattr(self, 'fullname', 'n/a')
+ password = getattr(self, 'password', 'n/a')
+ digest = getattr(self, 'digest', 'n/a')
+ if digest == 0:
+ digest = 'no'
+ elif digest == 1:
+ digest = 'yes'
+ language = getattr(self, 'language', 'n/a')
+ return '<UserDesc %s (%s) [%s] [digest? %s] [%s]>' % (
+ address, fullname, password, digest, language)
diff --git a/Mailman/Utils.py b/Mailman/Utils.py
new file mode 100644
index 00000000..b814f3d0
--- /dev/null
+++ b/Mailman/Utils.py
@@ -0,0 +1,773 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+"""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.
+
+"""
+
+from __future__ import nested_scopes
+
+import os
+import re
+import random
+import urlparse
+import sha
+import errno
+import time
+import cgi
+import htmlentitydefs
+import email.Iterators
+from types import UnicodeType
+from string import whitespace, digits
+try:
+ # Python 2.2
+ from string import ascii_letters
+except ImportError:
+ # Older Pythons
+ _lower = 'abcdefghijklmnopqrstuvwxyz'
+ ascii_letters = _lower + _lower.upper()
+
+from Mailman import mm_cfg
+from Mailman import Errors
+from Mailman import Site
+from Mailman.SafeDict import SafeDict
+
+EMPTYSTRING = ''
+NL = '\n'
+DOT = '.'
+IDENTCHARS = ascii_letters + digits + '_'
+
+# 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)
+
+
+
+def list_exists(listname):
+ """Return true iff list `listname' exists."""
+ # The existance of any of the following file proves the list exists
+ # <wink>: config.pck, config.pck.last, config.db, config.db.last
+ #
+ # The former two are for 2.1alpha3 and beyond, while the latter two are
+ # for all earlier versions.
+ basepath = Site.get_listpath(listname)
+ for ext in ('.pck', '.pck.last', '.db', '.db.last'):
+ dbfile = os.path.join(basepath, 'config' + ext)
+ if os.path.exists(dbfile):
+ return 1
+ return 0
+
+
+def list_names():
+ """Return the names of all lists in default list directory."""
+ # We don't currently support separate listings of virtual domains
+ return Site.get_listnames()
+
+
+
+# a much more naive implementation than say, Emacs's fill-paragraph!
+def wrap(text, column=70, honor_leading_ws=1):
+ """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 = 0
+ for line in para.split(NL):
+ if not line:
+ lines.append(line)
+ continue
+ if honor_leading_ws and line[0] in whitespace:
+ fillthis = 0
+ else:
+ fillthis = 1
+ 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 = bol - 1
+ # now find the last non-whitespace character
+ eol = bol
+ while eol > 0 and text[eol] in whitespace:
+ eol = 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 = eol + 1
+ bol = eol
+ while bol < len(text) and \
+ text[bol] in whitespace:
+ bol = bol + 1
+ bol = bol - 1
+ line = text[:eol+1] + '\n'
+ # find the next non-whitespace character
+ bol = bol + 1
+ while bol < len(text) and text[bol] in whitespace:
+ bol = bol + 1
+ text = text[bol:]
+ wrapped = wrapped + line
+ wrapped = wrapped + '\n'
+ # end while text
+ wrapped = 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('[][()<>|;^,/]')
+
+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 s.count(' ') > 0:
+ raise Errors.MMBadEmailError
+ if _badchars.search(s) or s[0] == '-':
+ raise Errors.MMHostileAddress, s
+ user, domain_parts = ParseEmail(s)
+ # This means local, unqualified addresses, are no allowed
+ if not domain_parts:
+ raise Errors.MMBadEmailError, s
+ if len(domain_parts) < 2:
+ raise Errors.MMBadEmailError, s
+
+
+
+def GetPathPieces(envar='PATH_INFO'):
+ path = os.environ.get(envar)
+ if path:
+ return [p for p in path.split('/') if p]
+ return None
+
+
+
+def ScriptURL(target, web_page_url=None, absolute=0):
+ """target - scriptname only, nothing extra
+ web_page_url - the list's configvar of the same name
+ absolute - a flag which if set, generates an absolute url
+ """
+ if web_page_url is None:
+ web_page_url = mm_cfg.DEFAULT_URL_PATTERN % get_domain()
+ if web_page_url[-1] <> '/':
+ web_page_url = web_page_url + '/'
+ fullpath = os.environ.get('REQUEST_URI')
+ if fullpath is None:
+ fullpath = os.environ.get('SCRIPT_NAME', '') + \
+ os.environ.get('PATH_INFO', '')
+ baseurl = urlparse.urlparse(web_page_url)[2]
+ if not absolute and fullpath[:len(baseurl)] == baseurl:
+ # Use relative addressing
+ fullpath = fullpath[len(baseurl):]
+ i = fullpath.find('?')
+ if i > 0:
+ count = fullpath.count('/', 0, i)
+ else:
+ count = fullpath.count('/')
+ path = ('../' * count) + target
+ else:
+ path = web_page_url + target
+ return path + mm_cfg.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(list, foldcase=0):
+ """Return a dict keyed by the entries in the list passed to it."""
+ d = {}
+ if foldcase:
+ for i in list:
+ d[i.lower()] = 1
+ else:
+ for i in list:
+ d[i] = 1
+ 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 MakeRandomPassword(length=6):
+ syls = []
+ while len(syls)*2 < length:
+ syls.append(random.choice(_syllables))
+ return EMPTYSTRING.join(syls)[:length]
+
+def GetRandomSeed():
+ chr1 = int(random.random() * 52)
+ chr2 = int(random.random() * 52)
+ def mkletter(c):
+ if 0 <= c < 26:
+ c = c + 65
+ if 26 <= c < 52:
+ c = c - 26 + 97
+ return c
+ return "%c%c" % tuple(map(mkletter, (chr1, chr2)))
+
+
+
+def set_global_password(pw, siteadmin=1):
+ if siteadmin:
+ filename = mm_cfg.SITE_PW_FILE
+ else:
+ filename = mm_cfg.LISTCREATOR_PW_FILE
+ omask = os.umask(026) # rw-r-----
+ try:
+ fp = open(filename, 'w')
+ fp.write(sha.new(pw).hexdigest() + '\n')
+ fp.close()
+ finally:
+ os.umask(omask)
+
+
+def get_global_password(siteadmin=1):
+ if siteadmin:
+ filename = mm_cfg.SITE_PW_FILE
+ else:
+ filename = mm_cfg.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, just return false
+ return None
+ return challenge
+
+
+def check_global_password(response, siteadmin=1):
+ challenge = get_global_password(siteadmin)
+ if challenge is None:
+ return None
+ return challenge == sha.new(response).hexdigest()
+
+
+
+def websafe(s):
+ return cgi.escape(s, quote=1)
+
+
+
+# Just changing these two functions should be enough to control the way
+# that email address obscuring is handled.
+def ObscureEmail(addr, for_text=0):
+ """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--', '@')
+
+
+
+def maketext(templatefile, dict=None, raw=0, 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/<listname>/<language>
+ #
+ # 2. the domain-specific language directory
+ # templates/<list.host_name>/<language>
+ #
+ # 3. the site-wide language directory
+ # templates/site/<language>
+ #
+ # 4. the global default language directory
+ # templates/<language>
+ #
+ # 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/<language> 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 <language>. 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
+ # <language>. If that still doesn't yield a template, then the standard
+ # distribution's English language template is used as an ultimate
+ # fallback. If that's 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/<listname>/*.{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.
+ #
+ # Calculate the languages to scan
+ languages = []
+ if lang is not None:
+ languages.append(lang)
+ if mlist is not None:
+ languages.append(mlist.preferred_language)
+ languages.append(mm_cfg.DEFAULT_SERVER_LANGUAGE)
+ # Calculate the locations to scan
+ searchdirs = []
+ if mlist is not None:
+ searchdirs.append(mlist.fullpath())
+ searchdirs.append(os.path.join(mm_cfg.TEMPLATE_DIR, mlist.host_name))
+ searchdirs.append(os.path.join(mm_cfg.TEMPLATE_DIR, 'site'))
+ searchdirs.append(mm_cfg.TEMPLATE_DIR)
+ # Start scanning
+ quickexit = 'quickexit'
+ fp = None
+ try:
+ for lang in languages:
+ for dir in searchdirs:
+ filename = os.path.join(dir, lang, templatefile)
+ try:
+ fp = open(filename)
+ raise quickexit
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ # Okay, it doesn't exist, keep looping
+ fp = None
+ except quickexit:
+ 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:
+ fp = open(os.path.join(mm_cfg.TEMPLATE_DIR, 'en', templatefile))
+ except IOError, e:
+ if e.errno <> errno.ENOENT: raise
+ # We never found the template. BAD!
+ raise IOError(errno.ENOENT, 'No template file found', templatefile)
+ template = fp.read()
+ fp.close()
+ text = template
+ if dict is not None:
+ try:
+ sdict = SafeDict(dict)
+ try:
+ text = sdict.interpolate(template)
+ except UnicodeError:
+ # Try again after coercing the template to unicode
+ utemplate = unicode(template, GetCharSet(lang), 'replace')
+ text = sdict.interpolate(utemplate)
+ except (TypeError, ValueError):
+ # The template is really screwed up
+ pass
+ if raw:
+ return text
+ return wrap(text)
+
+
+
+ADMINDATA = {
+ # admin keyword: (minimum #args, maximum #args)
+ 'confirm': (1, 1),
+ 'help': (0, 0),
+ 'info': (0, 0),
+ 'lists': (0, 0),
+ 'options': (0, 0),
+ 'password': (2, 2),
+ 'remove': (0, 0),
+ 'set': (3, 3),
+ 'subscribe': (0, 3),
+ 'unsubscribe': (0, 1),
+ 'who': (0, 0),
+ }
+
+# Given a Message.Message object, test for administrivia (eg subscribe,
+# unsubscribe, etc). The test must be a good guess -- messages that return
+# true get sent to the list admin instead of the entire list.
+def is_administrivia(msg):
+ linecnt = 0
+ lines = []
+ for line in email.Iterators.body_line_iterator(msg):
+ # Strip out any signatures
+ if line == '-- ':
+ break
+ if line.strip():
+ linecnt += 1
+ if linecnt > mm_cfg.DEFAULT_MAIL_COMMANDS_MAX_LINES:
+ return 0
+ lines.append(line)
+ bodytext = NL.join(lines)
+ # See if the body text has only one word, and that word is administrivia
+ if ADMINDATA.has_key(bodytext.strip().lower()):
+ return 1
+ # Look at the first N lines and see if there is any administrivia on the
+ # line. BAW: N is currently hardcoded to 5. str-ify the Subject: header
+ # because it may be an email.Header.Header instance rather than a string.
+ bodylines = lines[:5]
+ subject = str(msg.get('subject', ''))
+ bodylines.append(subject)
+ for line in bodylines:
+ if not line.strip():
+ continue
+ words = [word.lower() for word in line.split()]
+ minargs, maxargs = ADMINDATA.get(words[0], (None, None))
+ if minargs is None and maxargs is None:
+ continue
+ if minargs <= len(words[1:]) <= maxargs:
+ # Special case the `set' keyword. BAW: I don't know why this is
+ # here.
+ if words[0] == 'set' and words[2] not in ('on', 'off'):
+ continue
+ return 1
+ return 0
+
+
+
+def GetRequestURI(fallback=None, escape=1):
+ """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 os.environ.has_key('REQUEST_URI'):
+ url = os.environ['REQUEST_URI']
+ elif os.environ.has_key('SCRIPT_NAME') and os.environ.has_key('PATH_INFO'):
+ url = os.environ['SCRIPT_NAME'] + os.environ['PATH_INFO']
+ if escape:
+ return websafe(url)
+ return url
+
+
+
+# Wait on a dictionary of child pids
+def reap(kids, func=None, once=0):
+ while kids:
+ if func:
+ func()
+ try:
+ pid, status = os.waitpid(-1, os.WNOHANG)
+ except OSError, e:
+ # If the child procs had a bug we might have no children
+ if e.errno <> errno.ECHILD:
+ raise
+ kids.clear()
+ break
+ if pid <> 0:
+ try:
+ del kids[pid]
+ except KeyError:
+ # Huh? How can this happen?
+ pass
+ if once:
+ break
+
+
+def GetLanguageDescr(lang):
+ return mm_cfg.LC_DESCRIPTIONS[lang][0]
+
+
+def GetCharSet(lang):
+ return mm_cfg.LC_DESCRIPTIONS[lang][1]
+
+
+
+def get_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]
+ if mm_cfg.VIRTUAL_HOST_OVERVIEW and host:
+ return host.lower()
+ else:
+ # See the note in Defaults.py concerning DEFAULT_HOST_NAME
+ # vs. DEFAULT_EMAIL_HOST.
+ hostname = mm_cfg.DEFAULT_HOST_NAME or mm_cfg.DEFAULT_EMAIL_HOST
+ return hostname.lower()
+
+
+def get_site_email(hostname=None, extra=None):
+ if hostname is None:
+ hostname = mm_cfg.VIRTUAL_HOSTS.get(get_domain(), get_domain())
+ if extra is None:
+ return '%s@%s' % (mm_cfg.MAILMAN_SITE_LIST, hostname)
+ return '%s-%s@%s' % (mm_cfg.MAILMAN_SITE_LIST, extra, hostname)
+
+
+
+# This algorithm crafts a guaranteed unique message-id. The theory here is
+# that pid+listname+host will distinguish the message-id for every process on
+# the system, except when process ids wrap around. To further distinguish
+# message-ids, we prepend the integral time in seconds since the epoch. It's
+# still possible that we'll vend out more than one such message-id per second,
+# so we prepend a monotonically incrementing serial number. It's highly
+# unlikely that within a single second, there'll be a pid wraparound.
+_serial = 0
+def unique_message_id(mlist):
+ global _serial
+ msgid = '<mailman.%d.%d.%d.%s@%s>' % (
+ _serial, time.time(), os.getpid(),
+ mlist.internal_name(), mlist.host_name)
+ _serial += 1
+ return msgid
+
+
+# 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,))
+
+
+
+# Utilities to convert from simplified $identifier substitutions to/from
+# standard Python $(identifier)s substititions. The "Guido rules" for the
+# former are:
+# $$ -> $
+# $identifier -> $(identifier)s
+# ${identifier} -> $(identifier)s
+
+def to_dollar(s):
+ """Convert from %-strings to $-strings."""
+ s = s.replace('$', '$$').replace('%%', '%')
+ parts = cre.split(s)
+ for i in range(1, len(parts), 2):
+ if parts[i+1] and parts[i+1][0] in IDENTCHARS:
+ parts[i] = '${' + parts[i] + '}'
+ else:
+ parts[i] = '$' + parts[i]
+ return EMPTYSTRING.join(parts)
+
+
+def to_percent(s):
+ """Convert from $-strings to %-strings."""
+ s = s.replace('%', '%%').replace('$$', '$')
+ parts = dre.split(s)
+ for i in range(1, len(parts), 4):
+ if parts[i] is not None:
+ parts[i] = '$'
+ elif parts[i+1] is not None:
+ parts[i+1] = '%(' + parts[i+1] + ')s'
+ else:
+ parts[i+2] = '%(' + parts[i+2] + ')s'
+ return EMPTYSTRING.join(filter(None, parts))
+
+
+def dollar_identifiers(s):
+ """Return the set (dictionary) of identifiers found in a $-string."""
+ d = {}
+ for name in filter(None, [b or c or None for a, b, c in dre.findall(s)]):
+ d[name] = 1
+ return d
+
+
+def percent_identifiers(s):
+ """Return the set (dictionary) of identifiers found in a %-string."""
+ d = {}
+ for name in cre.findall(s):
+ d[name] = 1
+ return d
+
+
+
+# Utilities to canonicalize a string, which means un-HTML-ifying the string to
+# produce a Unicode string or an 8-bit string if all the characters are ASCII.
+def canonstr(s, lang=None):
+ newparts = []
+ parts = re.split(r'&(?P<ref>[^;]+);', s)
+ def appchr(i):
+ if i < 256:
+ newparts.append(chr(i))
+ else:
+ newparts.append(unichr(i))
+ while 1:
+ newparts.append(parts.pop(0))
+ if not parts:
+ break
+ ref = parts.pop(0)
+ if ref.startswith('#'):
+ try:
+ appchr(int(ref[1:]))
+ except ValueError:
+ # Non-convertable, stick with what we got
+ newparts.append('&'+ref+';')
+ else:
+ c = htmlentitydefs.entitydefs.get(ref, '?')
+ if c.startswith('#') and c.endswith(';'):
+ appchr(int(ref[1:-1]))
+ else:
+ newparts.append(c)
+ newstr = EMPTYSTRING.join(newparts)
+ if isinstance(newstr, UnicodeType):
+ return newstr
+ # We want the default fallback to be iso-8859-1 even if the language is
+ # English (us-ascii). This seems like a practical compromise so that
+ # non-ASCII characters in names can be used in English lists w/o having to
+ # change the global charset for English from us-ascii (which I
+ # superstitiously think my have unintended consequences).
+ if lang is None:
+ charset = 'iso-8859-1'
+ else:
+ charset = GetCharSet(lang)
+ if charset == 'us-ascii':
+ charset = 'iso-8859-1'
+ return unicode(newstr, charset, 'replace')
+
+
+# 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, UnicodeType):
+ 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))
diff --git a/Mailman/Version.py b/Mailman/Version.py
new file mode 100644
index 00000000..11fb2c0a
--- /dev/null
+++ b/Mailman/Version.py
@@ -0,0 +1,48 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+# Mailman version
+VERSION = "2.1"
+
+# And as a hex number in the manner of PY_VERSION_HEX
+ALPHA = 0xa
+BETA = 0xb
+GAMMA = 0xc
+# release candidates
+RC = GAMMA
+FINAL = 0xf
+
+MAJOR_REV = 2
+MINOR_REV = 1
+MICRO_REV = 0
+REL_LEVEL = FINAL
+# at most 15 beta releases!
+REL_SERIAL = 0
+
+HEX_VERSION = ((MAJOR_REV << 24) | (MINOR_REV << 16) | (MICRO_REV << 8) |
+ (REL_LEVEL << 4) | (REL_SERIAL << 0))
+
+# config.pck schema version number
+DATA_FILE_VERSION = 88
+
+# qfile/*.db schema version number
+QFILE_SCHEMA_VERSION = 3
+
+# version number for the data/pending.db file schema
+PENDING_FILE_SCHEMA_VERSION = 1
+
+# version number for the lists/<listname>/request.db file schema
+REQUESTS_FILE_SCHEMA_VERSION = 1
diff --git a/Mailman/__init__.py b/Mailman/__init__.py
new file mode 100644
index 00000000..2cbbabb1
--- /dev/null
+++ b/Mailman/__init__.py
@@ -0,0 +1,15 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
diff --git a/Mailman/htmlformat.py b/Mailman/htmlformat.py
new file mode 100644
index 00000000..0175bccb
--- /dev/null
+++ b/Mailman/htmlformat.py
@@ -0,0 +1,678 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+"""Library for program-based construction of an HTML documents.
+
+Encapsulate HTML formatting directives in classes that act as containers
+for python and, recursively, for nested HTML formatting objects.
+"""
+
+
+# Eventually could abstract down to HtmlItem, which outputs an arbitrary html
+# object given start / end tags, valid options, and a value. Ug, objects
+# shouldn't be adding their own newlines. The next object should.
+
+
+import types
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman.i18n import _
+
+SPACE = ' '
+EMPTYSTRING = ''
+NL = '\n'
+
+
+
+# Format an arbitrary object.
+def HTMLFormatObject(item, indent):
+ "Return a presentation of an object, invoking their Format method if any."
+ if type(item) == type(''):
+ return item
+ elif not hasattr(item, "Format"):
+ return `item`
+ else:
+ return item.Format(indent)
+
+def CaseInsensitiveKeyedDict(d):
+ result = {}
+ for (k,v) in d.items():
+ result[k.lower()] = v
+ return result
+
+# Given references to two dictionaries, copy the second dictionary into the
+# first one.
+def DictMerge(destination, fresh_dict):
+ for (key, value) in fresh_dict.items():
+ destination[key] = value
+
+class Table:
+ def __init__(self, **table_opts):
+ self.cells = []
+ self.cell_info = {}
+ self.row_info = {}
+ self.opts = table_opts
+
+ def AddOptions(self, opts):
+ DictMerge(self.opts, opts)
+
+ # Sets all of the cells. It writes over whatever cells you had there
+ # previously.
+
+ def SetAllCells(self, cells):
+ self.cells = cells
+
+ # Add a new blank row at the end
+ def NewRow(self):
+ self.cells.append([])
+
+ # Add a new blank cell at the end
+ def NewCell(self):
+ self.cells[-1].append('')
+
+ def AddRow(self, row):
+ self.cells.append(row)
+
+ def AddCell(self, cell):
+ self.cells[-1].append(cell)
+
+ def AddCellInfo(self, row, col, **kws):
+ kws = CaseInsensitiveKeyedDict(kws)
+ if not self.cell_info.has_key(row):
+ self.cell_info[row] = { col : kws }
+ elif self.cell_info[row].has_key(col):
+ DictMerge(self.cell_info[row], kws)
+ else:
+ self.cell_info[row][col] = kws
+
+ def AddRowInfo(self, row, **kws):
+ kws = CaseInsensitiveKeyedDict(kws)
+ if not self.row_info.has_key(row):
+ self.row_info[row] = kws
+ else:
+ DictMerge(self.row_info[row], kws)
+
+ # What's the index for the row we just put in?
+ def GetCurrentRowIndex(self):
+ return len(self.cells)-1
+
+ # What's the index for the col we just put in?
+ def GetCurrentCellIndex(self):
+ return len(self.cells[-1])-1
+
+ def ExtractCellInfo(self, info):
+ valid_mods = ['align', 'valign', 'nowrap', 'rowspan', 'colspan',
+ 'bgcolor']
+ output = ''
+
+ for (key, val) in info.items():
+ if not key in valid_mods:
+ continue
+ if key == 'nowrap':
+ output = output + ' NOWRAP'
+ continue
+ else:
+ output = output + ' %s="%s"' % (key.upper(), val)
+
+ return output
+
+ def ExtractRowInfo(self, info):
+ valid_mods = ['align', 'valign', 'bgcolor']
+ output = ''
+
+ for (key, val) in info.items():
+ if not key in valid_mods:
+ continue
+ output = output + ' %s="%s"' % (key.upper(), val)
+
+ return output
+
+ def ExtractTableInfo(self, info):
+ valid_mods = ['align', 'width', 'border', 'cellspacing', 'cellpadding',
+ 'bgcolor']
+
+ output = ''
+
+ for (key, val) in info.items():
+ if not key in valid_mods:
+ continue
+ if key == 'border' and val == None:
+ output = output + ' BORDER'
+ continue
+ else:
+ output = output + ' %s="%s"' % (key.upper(), val)
+
+ return output
+
+ def FormatCell(self, row, col, indent):
+ try:
+ my_info = self.cell_info[row][col]
+ except:
+ my_info = None
+
+ output = '\n' + ' '*indent + '<td'
+ if my_info:
+ output = output + self.ExtractCellInfo(my_info)
+ item = self.cells[row][col]
+ item_format = HTMLFormatObject(item, indent+4)
+ output = '%s>%s</td>' % (output, item_format)
+ return output
+
+ def FormatRow(self, row, indent):
+ try:
+ my_info = self.row_info[row]
+ except:
+ my_info = None
+
+ output = '\n' + ' '*indent + '<tr'
+ if my_info:
+ output = output + self.ExtractRowInfo(my_info)
+ output = output + '>'
+
+ for i in range(len(self.cells[row])):
+ output = output + self.FormatCell(row, i, indent + 2)
+
+ output = output + '\n' + ' '*indent + '</tr>'
+
+ return output
+
+ def Format(self, indent=0):
+ output = '\n' + ' '*indent + '<table'
+ output = output + self.ExtractTableInfo(self.opts)
+ output = output + '>'
+
+ for i in range(len(self.cells)):
+ output = output + self.FormatRow(i, indent + 2)
+
+ output = output + '\n' + ' '*indent + '</table>\n'
+
+ return output
+
+
+class Link:
+ def __init__(self, href, text, target=None):
+ self.href = href
+ self.text = text
+ self.target = target
+
+ def Format(self, indent=0):
+ texpr = ""
+ if self.target != None:
+ texpr = ' target="%s"' % self.target
+ return '<a href="%s"%s>%s</a>' % (HTMLFormatObject(self.href, indent),
+ texpr,
+ HTMLFormatObject(self.text, indent))
+
+class FontSize:
+ """FontSize is being deprecated - use FontAttr(..., size="...") instead."""
+ def __init__(self, size, *items):
+ self.items = list(items)
+ self.size = size
+
+ def Format(self, indent=0):
+ output = '<font size="%s">' % self.size
+ for item in self.items:
+ output = output + HTMLFormatObject(item, indent)
+ output = output + '</font>'
+ return output
+
+class FontAttr:
+ """Present arbitrary font attributes."""
+ def __init__(self, *items, **kw):
+ self.items = list(items)
+ self.attrs = kw
+
+ def Format(self, indent=0):
+ seq = []
+ for k, v in self.attrs.items():
+ seq.append('%s="%s"' % (k, v))
+ output = '<font %s>' % SPACE.join(seq)
+ for item in self.items:
+ output = output + HTMLFormatObject(item, indent)
+ output = output + '</font>'
+ return output
+
+
+class Container:
+ def __init__(self, *items):
+ if not items:
+ self.items = []
+ else:
+ self.items = items
+
+ def AddItem(self, obj):
+ self.items.append(obj)
+
+ def Format(self, indent=0):
+ output = []
+ for item in self.items:
+ output.append(HTMLFormatObject(item, indent))
+ return EMPTYSTRING.join(output)
+
+
+class Label(Container):
+ align = 'right'
+
+ def __init__(self, *items):
+ Container.__init__(self, *items)
+
+ def Format(self, indent=0):
+ return ('<div align="%s">' % self.align) + \
+ Container.Format(self, indent) + \
+ '</div>'
+
+
+# My own standard document template. YMMV.
+# something more abstract would be more work to use...
+
+class Document(Container):
+ title = None
+ language = None
+ bgcolor = mm_cfg.WEB_BG_COLOR
+ suppress_head = 0
+
+ def set_language(self, lang=None):
+ self.language = lang
+
+ def set_bgcolor(self, color):
+ self.bgcolor = color
+
+ def SetTitle(self, title):
+ self.title = title
+
+ def Format(self, indent=0, **kws):
+ charset = 'us-ascii'
+ if self.language:
+ charset = Utils.GetCharSet(self.language)
+ output = ['Content-Type: text/html; charset=%s\n' % charset]
+ if not self.suppress_head:
+ kws.setdefault('bgcolor', self.bgcolor)
+ tab = ' ' * indent
+ output.extend([tab,
+ '<HTML>',
+ '<HEAD>'
+ ])
+ if mm_cfg.IMAGE_LOGOS:
+ output.append('<LINK REL="SHORTCUT ICON" HREF="%s">' %
+ (mm_cfg.IMAGE_LOGOS + mm_cfg.SHORTCUT_ICON))
+ # Hit all the bases
+ output.append('<META http-equiv="Content-Type" '
+ 'content="text/html; charset=%s">' % charset)
+ if self.title:
+ output.append('%s<TITLE>%s</TITLE>' % (tab, self.title))
+ output.append('%s</HEAD>' % tab)
+ quals = []
+ # Default link colors
+ if mm_cfg.WEB_VLINK_COLOR:
+ kws.setdefault('vlink', mm_cfg.WEB_VLINK_COLOR)
+ if mm_cfg.WEB_ALINK_COLOR:
+ kws.setdefault('alink', mm_cfg.WEB_ALINK_COLOR)
+ if mm_cfg.WEB_LINK_COLOR:
+ kws.setdefault('alink', mm_cfg.WEB_LINK_COLOR)
+ for k, v in kws.items():
+ quals.append('%s="%s"' % (k, v))
+ output.append('%s<BODY %s>' % (tab, SPACE.join(quals)))
+ # Always do this...
+ output.append(Container.Format(self, indent))
+ if not self.suppress_head:
+ output.append('%s</BODY>' % tab)
+ output.append('%s</HTML>' % tab)
+ return NL.join(output)
+
+ def addError(self, errmsg, tag=None, *args):
+ if tag is None:
+ tag = _('Error: ')
+ self.AddItem(Header(3, Bold(FontAttr(
+ _(tag), color=mm_cfg.WEB_ERROR_COLOR, size='+2')).Format() +
+ Italic(errmsg % args).Format()))
+
+
+class HeadlessDocument(Document):
+ """Document without head section, for templates that provide their own."""
+ suppress_head = 1
+
+
+class StdContainer(Container):
+ def Format(self, indent=0):
+ # If I don't start a new I ignore indent
+ output = '<%s>' % self.tag
+ output = output + Container.Format(self, indent)
+ output = '%s</%s>' % (output, self.tag)
+ return output
+
+
+class QuotedContainer(Container):
+ def Format(self, indent=0):
+ # If I don't start a new I ignore indent
+ output = '<%s>%s</%s>' % (
+ self.tag,
+ Utils.websafe(Container.Format(self, indent)),
+ self.tag)
+ return output
+
+class Header(StdContainer):
+ def __init__(self, num, *items):
+ self.items = items
+ self.tag = 'h%d' % num
+
+class Address(StdContainer):
+ tag = 'address'
+
+class Underline(StdContainer):
+ tag = 'u'
+
+class Bold(StdContainer):
+ tag = 'strong'
+
+class Italic(StdContainer):
+ tag = 'em'
+
+class Preformatted(QuotedContainer):
+ tag = 'pre'
+
+class Subscript(StdContainer):
+ tag = 'sub'
+
+class Superscript(StdContainer):
+ tag = 'sup'
+
+class Strikeout(StdContainer):
+ tag = 'strike'
+
+class Center(StdContainer):
+ tag = 'center'
+
+class Form(Container):
+ def __init__(self, action='', method='POST', encoding=None, *items):
+ apply(Container.__init__, (self,) + items)
+ self.action = action
+ self.method = method
+ self.encoding = encoding
+
+ def set_action(self, action):
+ self.action = action
+
+ def Format(self, indent=0):
+ spaces = ' ' * indent
+ encoding = ''
+ if self.encoding:
+ encoding = 'enctype="%s"' % self.encoding
+ output = '\n%s<FORM action="%s" method="%s" %s>\n' % (
+ spaces, self.action, self.method, encoding)
+ output = output + Container.Format(self, indent+2)
+ output = '%s\n%s</FORM>\n' % (output, spaces)
+ return output
+
+
+class InputObj:
+ def __init__(self, name, ty, value, checked, **kws):
+ self.name = name
+ self.type = ty
+ self.value = value
+ self.checked = checked
+ self.kws = kws
+
+ def Format(self, indent=0):
+ output = ['<INPUT name="%s" type="%s" value="%s"' %
+ (self.name, self.type, self.value)]
+ for item in self.kws.items():
+ output.append('%s="%s"' % item)
+ if self.checked:
+ output.append('CHECKED')
+ output.append('>')
+ return SPACE.join(output)
+
+
+class SubmitButton(InputObj):
+ def __init__(self, name, button_text):
+ InputObj.__init__(self, name, "SUBMIT", button_text, checked=0)
+
+class PasswordBox(InputObj):
+ def __init__(self, name, value='', size=mm_cfg.TEXTFIELDWIDTH):
+ InputObj.__init__(self, name, "PASSWORD", value, checked=0, size=size)
+
+class TextBox(InputObj):
+ def __init__(self, name, value='', size=mm_cfg.TEXTFIELDWIDTH):
+ InputObj.__init__(self, name, "TEXT", value, checked=0, size=size)
+
+class Hidden(InputObj):
+ def __init__(self, name, value=''):
+ InputObj.__init__(self, name, 'HIDDEN', value, checked=0)
+
+class TextArea:
+ def __init__(self, name, text='', rows=None, cols=None, wrap='soft',
+ readonly=0):
+ self.name = name
+ self.text = text
+ self.rows = rows
+ self.cols = cols
+ self.wrap = wrap
+ self.readonly = readonly
+
+ def Format(self, indent=0):
+ output = '<TEXTAREA NAME=%s' % self.name
+ if self.rows:
+ output += ' ROWS=%s' % self.rows
+ if self.cols:
+ output += ' COLS=%s' % self.cols
+ if self.wrap:
+ output += ' WRAP=%s' % self.wrap
+ if self.readonly:
+ output += ' READONLY'
+ output += '>%s</TEXTAREA>' % self.text
+ return output
+
+class FileUpload(InputObj):
+ def __init__(self, name, rows=None, cols=None, **kws):
+ apply(InputObj.__init__, (self, name, 'FILE', '', 0), kws)
+
+class RadioButton(InputObj):
+ def __init__(self, name, value, checked=0, **kws):
+ apply(InputObj.__init__, (self, name, 'RADIO', value, checked), kws)
+
+class CheckBox(InputObj):
+ def __init__(self, name, value, checked=0, **kws):
+ apply(InputObj.__init__, (self, name, "CHECKBOX", value, checked), kws)
+
+class VerticalSpacer:
+ def __init__(self, size=10):
+ self.size = size
+ def Format(self, indent=0):
+ output = '<spacer type="vertical" height="%d">' % self.size
+ return output
+
+class WidgetArray:
+ Widget = None
+
+ def __init__(self, name, button_names, checked, horizontal, values):
+ self.name = name
+ self.button_names = button_names
+ self.checked = checked
+ self.horizontal = horizontal
+ self.values = values
+ assert len(values) == len(button_names)
+ # Don't assert `checked' because for RadioButtons it is a scalar while
+ # for CheckedBoxes it is a vector. Subclasses will assert length.
+
+ def ischecked(self, i):
+ raise NotImplemented
+
+ def Format(self, indent=0):
+ t = Table(cellspacing=5)
+ items = []
+ for i, name, value in zip(range(len(self.button_names)),
+ self.button_names,
+ self.values):
+ ischecked = (self.ischecked(i))
+ item = self.Widget(self.name, value, ischecked).Format() + name
+ items.append(item)
+ if not self.horizontal:
+ t.AddRow(items)
+ items = []
+ if self.horizontal:
+ t.AddRow(items)
+ return t.Format(indent)
+
+class RadioButtonArray(WidgetArray):
+ Widget = RadioButton
+
+ def __init__(self, name, button_names, checked=None, horizontal=1,
+ values=None):
+ if values is None:
+ values = range(len(button_names))
+ # BAW: assert checked is a scalar...
+ WidgetArray.__init__(self, name, button_names, checked, horizontal,
+ values)
+
+ def ischecked(self, i):
+ return self.checked == i
+
+class CheckBoxArray(WidgetArray):
+ Widget = CheckBox
+
+ def __init__(self, name, button_names, checked=None, horizontal=0,
+ values=None):
+ if checked is None:
+ checked = [0] * len(button_names)
+ else:
+ assert len(checked) == len(button_names)
+ if values is None:
+ values = range(len(button_names))
+ WidgetArray.__init__(self, name, button_names, checked, horizontal,
+ values)
+
+ def ischecked(self, i):
+ return self.checked[i]
+
+class UnorderedList(Container):
+ def Format(self, indent=0):
+ spaces = ' ' * indent
+ output = '\n%s<ul>\n' % spaces
+ for item in self.items:
+ output = output + '%s<li>%s\n' % \
+ (spaces, HTMLFormatObject(item, indent + 2))
+ output = output + '%s</ul>\n' % spaces
+ return output
+
+class OrderedList(Container):
+ def Format(self, indent=0):
+ spaces = ' ' * indent
+ output = '\n%s<ol>\n' % spaces
+ for item in self.items:
+ output = output + '%s<li>%s\n' % \
+ (spaces, HTMLFormatObject(item, indent + 2))
+ output = output + '%s</ol>\n' % spaces
+ return output
+
+class DefinitionList(Container):
+ def Format(self, indent=0):
+ spaces = ' ' * indent
+ output = '\n%s<dl>\n' % spaces
+ for dt, dd in self.items:
+ output = output + '%s<dt>%s\n<dd>%s\n' % \
+ (spaces, HTMLFormatObject(dt, indent+2),
+ HTMLFormatObject(dd, indent+2))
+ output = output + '%s</dl>\n' % spaces
+ return output
+
+
+
+# Logo constants
+#
+# These are the URLs which the image logos link to. The Mailman home page now
+# points at the gnu.org site instead of the www.list.org mirror.
+#
+from mm_cfg import MAILMAN_URL
+PYTHON_URL = 'http://www.python.org/'
+GNU_URL = 'http://www.gnu.org/'
+
+# The names of the image logo files. These are concatentated onto
+# mm_cfg.IMAGE_LOGOS (not urljoined).
+DELIVERED_BY = 'mailman.jpg'
+PYTHON_POWERED = 'PythonPowered.png'
+GNU_HEAD = 'gnu-head-tiny.jpg'
+
+
+def MailmanLogo():
+ t = Table(border=0, width='100%')
+ if mm_cfg.IMAGE_LOGOS:
+ def logo(file):
+ return mm_cfg.IMAGE_LOGOS + file
+ mmlink = '<img src="%s" alt="Delivered by Mailman" border=0>' \
+ '<br>version %s' % (logo(DELIVERED_BY), mm_cfg.VERSION)
+ pylink = '<img src="%s" alt="Python Powered" border=0>' % \
+ logo(PYTHON_POWERED)
+ gnulink = '<img src="%s" alt="GNU\'s Not Unix" border=0>' % \
+ logo(GNU_HEAD)
+ t.AddRow([mmlink, pylink, gnulink])
+ else:
+ # use only textual links
+ version = mm_cfg.VERSION
+ mmlink = Link(MAILMAN_URL,
+ _('Delivered by Mailman<br>version %(version)s'))
+ pylink = Link(PYTHON_URL, _('Python Powered'))
+ gnulink = Link(GNU_URL, _("Gnu's Not Unix"))
+ t.AddRow([mmlink, pylink, gnulink])
+ return t
+
+
+class SelectOptions:
+ def __init__(self, varname, values, legend,
+ selected=0, size=1, multiple=None):
+ self.varname = varname
+ self.values = values
+ self.legend = legend
+ self.size = size
+ self.multiple = multiple
+ # we convert any type to tuple, commas are needed
+ if not multiple:
+ if type(selected) == types.IntType:
+ self.selected = (selected,)
+ elif type(selected) == types.TupleType:
+ self.selected = (selected[0],)
+ elif type(selected) == types.ListType:
+ self.selected = (selected[0],)
+ else:
+ self.selected = (0,)
+
+ def Format(self, indent=0):
+ spaces = " " * indent
+ items = min( len(self.values), len(self.legend) )
+
+ # jcrey: If there is no argument, we return nothing to avoid errors
+ if items == 0:
+ return ""
+
+ text = "\n" + spaces + "<Select name=\"%s\"" % self.varname
+ if self.size > 1:
+ text = text + " size=%d" % self.size
+ if self.multiple:
+ text = text + " multiple"
+ text = text + ">\n"
+
+ for i in range(items):
+ if i in self.selected:
+ checked = " Selected"
+ else:
+ checked = ""
+
+ opt = " <option value=\"%s\"%s> %s </option>" % (
+ self.values[i], checked, self.legend[i])
+ text = text + spaces + opt + "\n"
+
+ return text + spaces + '</Select>'
diff --git a/Mailman/i18n.py b/Mailman/i18n.py
new file mode 100644
index 00000000..d38eba85
--- /dev/null
+++ b/Mailman/i18n.py
@@ -0,0 +1,129 @@
+# Copyright (C) 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+import sys
+import time
+import gettext
+from types import StringType
+
+from Mailman import mm_cfg
+from Mailman.SafeDict import SafeDict
+
+_translation = None
+
+
+
+def set_language(language=None):
+ global _translation
+ if language is not None:
+ language = [language]
+ try:
+ _translation = gettext.translation('mailman', mm_cfg.MESSAGES_DIR,
+ language)
+ except IOError:
+ # The selected language was not installed in messages, so fall back to
+ # untranslated English.
+ _translation = gettext.NullTranslations()
+
+def get_translation():
+ return _translation
+
+def set_translation(translation):
+ global _translation
+ _translation = translation
+
+
+# Set up the global translation based on environment variables. Mostly used
+# for command line scripts.
+if _translation is None:
+ set_language()
+
+
+
+def _(s):
+ if s == '':
+ return s
+ assert s
+ # Do translation of the given string into the current language, and do
+ # Ping-string interpolation into the resulting string.
+ #
+ # This lets you write something like:
+ #
+ # now = time.ctime(time.time())
+ # print _('The current time is: %(now)s')
+ #
+ # and have it Just Work. Note that the lookup order for keys in the
+ # original string is 1) locals dictionary, 2) globals dictionary.
+ #
+ # First, get the frame of the caller
+ frame = sys._getframe(1)
+ # A `safe' dictionary is used so we won't get an exception if there's a
+ # missing key in the dictionary.
+ dict = SafeDict(frame.f_globals.copy())
+ dict.update(frame.f_locals)
+ # Translate the string, then interpolate into it.
+ return _translation.gettext(s) % dict
+
+
+
+def ctime(date):
+ # Don't make these module globals since we have to do runtime translation
+ # of the strings anyway.
+ daysofweek = [
+ _('Mon'), _('Tue'), _('Wed'), _('Thu'),
+ _('Fri'), _('Sat'), _('Sun')
+ ]
+ months = [
+ '',
+ _('Jan'), _('Feb'), _('Mar'), _('Apr'), _('May'), _('Jun'),
+ _('Jul'), _('Aug'), _('Sep'), _('Oct'), _('Nov'), _('Dec')
+ ]
+
+ tzname = _('Server Local Time')
+ if isinstance(date, StringType):
+ try:
+ year, mon, day, hh, mm, ss, wday, ydat, dst = time.strptime(date)
+ tzname = time.tzname[dst and 1 or 0]
+ except ValueError:
+ try:
+ wday, mon, day, hms, year = date.split()
+ hh, mm, ss = hms.split(':')
+ year = int(year)
+ day = int(day)
+ hh = int(hh)
+ mm = int(mm)
+ ss = int(ss)
+ except ValueError:
+ return date
+ else:
+ for i in range(0, 7):
+ wconst = (1999, 1, 1, 0, 0, 0, i, 1, 0)
+ if wday.lower() == time.strftime('%a', wconst).lower():
+ wday = i
+ break
+ for i in range(1, 13):
+ mconst = (1999, i, 1, 0, 0, 0, 0, 1, 0)
+ if mon.lower() == time.strftime('%b', mconst).lower():
+ mon = i
+ break
+ else:
+ year, mon, day, hh, mm, ss, wday, yday, dst = time.localtime(date)
+ tzname = time.tzname[dst and 1 or 0]
+
+ wday = daysofweek[wday]
+ mon = months[mon]
+ return _('%(wday)s %(mon)s %(day)2i %(hh)02i:%(mm)02i:%(ss)02i '
+ '%(tzname)s %(year)04i')
diff --git a/Mailman/mm_cfg.py.dist.in b/Mailman/mm_cfg.py.dist.in
new file mode 100644
index 00000000..c11ac755
--- /dev/null
+++ b/Mailman/mm_cfg.py.dist.in
@@ -0,0 +1,44 @@
+# -*- 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+"""This module contains your site-specific settings.
+
+From a brand new distribution it should be copied to mm_cfg.py. If you
+already have an mm_cfg.py, be careful to add in only the new settings you
+want. Mailman's installation procedure will never overwrite your mm_cfg.py
+file.
+
+The complete set of distributed defaults, with documentation, are in the file
+Defaults.py. In mm_cfg.py, override only those you want to change, after the
+
+ from Defaults import *
+
+line (see below).
+
+Note that these are just default settings; many can be overridden via the
+administrator and user interfaces on a per-list or per-user basis.
+
+"""
+
+###############################################
+# Here's where we get the distributed defaults.
+
+from Defaults import *
+
+##################################################
+# Put YOUR site-specific settings below this line.
diff --git a/Mailman/versions.py b/Mailman/versions.py
new file mode 100644
index 00000000..7aa9d9e8
--- /dev/null
+++ b/Mailman/versions.py
@@ -0,0 +1,495 @@
+# 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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
+
+
+"""Routines which rectify an old mailing list with current structure.
+
+The MailList.CheckVersion() method looks for an old .data_version setting in
+the loaded structure, and if found calls the Update() routine from this
+module, supplying the list and the state last loaded from storage. The state
+is necessary to distinguish from default assignments done in the .InitVars()
+methods, before .CheckVersion() is called.
+
+For new versions you should add sections to the UpdateOldVars() and the
+UpdateOldUsers() sections, to preserve the sense of settings across structural
+changes. Note that the routines have only one pass - when .CheckVersions()
+finds a version change it runs this routine and then updates the data_version
+number of the list, and then does a .Save(), so the transformations won't be
+run again until another version change is detected.
+
+"""
+
+
+from types import ListType, StringType
+
+from Mailman import mm_cfg
+from Mailman import Utils
+from Mailman import Message
+from Mailman.MemberAdaptor import UNKNOWN
+from Mailman.Logging.Syslog import syslog
+
+
+
+def Update(l, stored_state):
+ "Dispose of old vars and user options, mapping to new ones when suitable."
+ ZapOldVars(l)
+ UpdateOldUsers(l)
+ NewVars(l)
+ UpdateOldVars(l, stored_state)
+ CanonicalizeUserOptions(l)
+ NewRequestsDatabase(l)
+
+
+
+def ZapOldVars(mlist):
+ for name in ('num_spawns', 'filter_prog', 'clobber_date',
+ 'public_archive_file_dir', 'private_archive_file_dir',
+ 'archive_directory',
+ # Pre-2.1a4 bounce data
+ 'minimum_removal_date',
+ 'minimum_post_count_before_bounce_action',
+ 'automatic_bounce_action',
+ 'max_posts_between_bounces',
+ ):
+ if hasattr(mlist, name):
+ delattr(mlist, name)
+
+
+
+uniqueval = []
+def UpdateOldVars(l, stored_state):
+ """Transform old variable values into new ones, deleting old ones.
+ stored_state is last snapshot from file, as opposed to from InitVars()."""
+
+ def PreferStored(oldname, newname, newdefault=uniqueval,
+ l=l, state=stored_state):
+ """Use specified old value if new value is not in stored state.
+
+ If the old attr does not exist, and no newdefault is specified, the
+ new attr is *not* created - so either specify a default or be positive
+ that the old attr exists - or don't depend on the new attr.
+
+ """
+ if hasattr(l, oldname):
+ if not state.has_key(newname):
+ setattr(l, newname, getattr(l, oldname))
+ delattr(l, oldname)
+ if not hasattr(l, newname) and newdefault is not uniqueval:
+ setattr(l, newname, newdefault)
+
+ # Migrate to 2.1b3, baw 17-Aug-2001
+ if hasattr(l, 'dont_respond_to_post_requests'):
+ oldval = getattr(l, 'dont_respond_to_post_requests')
+ if not hasattr(l, 'respond_to_post_requests'):
+ l.respond_to_post_requests = not oldval
+ del l.dont_respond_to_post_requests
+
+ # Migrate to 2.1b3, baw 13-Oct-2001
+ # Basic defaults for new variables
+ if not hasattr(l, 'default_member_moderation'):
+ l.default_member_moderation = mm_cfg.DEFAULT_DEFAULT_MEMBER_MODERATION
+ if not hasattr(l, 'accept_these_nonmembers'):
+ l.accept_these_nonmembers = []
+ if not hasattr(l, 'hold_these_nonmembers'):
+ l.hold_these_nonmembers = []
+ if not hasattr(l, 'reject_these_nonmembers'):
+ l.reject_these_nonmembers = []
+ if not hasattr(l, 'discard_these_nonmembers'):
+ l.discard_these_nonmembers = []
+ if not hasattr(l, 'forward_auto_discards'):
+ l.forward_auto_discards = mm_cfg.DEFAULT_FORWARD_AUTO_DISCARDS
+ if not hasattr(l, 'generic_nonmember_action'):
+ l.generic_nonmember_action = mm_cfg.DEFAULT_GENERIC_NONMEMBER_ACTION
+ # Now convert what we can... Note that the interaction between the
+ # MM2.0.x attributes `moderated', `member_posting_only', and `posters' is
+ # so confusing, it makes my brain really ache. Which is why they go away
+ # in MM2.1. I think the best we can do semantically is the following:
+ #
+ # - If moderated == yes, then any sender who's address is not on the
+ # posters attribute would get held for approval. If the sender was on
+ # the posters list, then we'd defer judgement to a later step
+ # - If member_posting_only == yes, then members could post without holds,
+ # and if there were any addresses added to posters, they could also post
+ # without holds.
+ # - If member_posting_only == no, then what happens depends on the value
+ # of the posters attribute:
+ # o If posters was empty, then anybody can post without their
+ # message being held for approval
+ # o If posters was non-empty, then /only/ those addresses could post
+ # without approval, i.e. members not on posters would have their
+ # messages held for approval.
+ #
+ # How to translate this mess to MM2.1 values? I'm sure I got this wrong
+ # before, but here's how we're going to do it, as of MM2.1b3.
+ #
+ # - We'll control member moderation through their Moderate flag, and
+ # non-member moderation through the generic_nonmember_action,
+ # hold_these_nonmembers, and accept_these_nonmembers.
+ # - If moderated == yes then we need to troll through the addresses on
+ # posters, and any non-members would get added to
+ # accept_these_nonmembers. /Then/ we need to troll through the
+ # membership and any member on posters would get their Moderate flag
+ # unset, while members not on posters would get their Moderate flag set.
+ # Then generic_nonmember_action gets set to 1 (hold) so nonmembers get
+ # moderated, and default_member_moderation will be set to 1 (hold) so
+ # new members will also get held for moderation. We'll stop here.
+ # - We only get to here if moderated == no.
+ # - If member_posting_only == yes, then we'll turn off the Moderate flag
+ # for members. We troll through the posters attribute and add all those
+ # addresses to accept_these_nonmembers. We'll also set
+ # generic_nonmember_action to 1 and default_member_moderation to 0.
+ # We'll stop here.
+ # - We only get to here if member_posting_only == no
+ # - If posters is empty, then anybody could post without being held for
+ # approval, so we'll set generic_nonmember_action to 0 (accept), and
+ # we'll turn off the Moderate flag for all members. We'll also turn off
+ # default_member_moderation so new members can post without approval.
+ # We'll stop here.
+ # - We only get here if posters is non-empty.
+ # - This means that /only/ the addresses on posters got to post without
+ # being held for approval. So first, we troll through posters and add
+ # all non-members to accept_these_nonmembers. Then we troll through the
+ # membership and if their address is on posters, we'll clear their
+ # Moderate flag, otherwise we'll set it. We'll turn on
+ # default_member_moderation so new members get moderated. We'll set
+ # generic_nonmember_action to 1 (hold) so all other non-members will get
+ # moderated. And I think we're finally done.
+ #
+ # SIGH.
+ if hasattr(l, 'moderated'):
+ # We'll assume we're converting all these attributes at once
+ if l.moderated:
+ #syslog('debug', 'Case 1')
+ for addr in l.posters:
+ if not l.isMember(addr):
+ l.accept_these_nonmembers.append(addr)
+ for member in l.getMembers():
+ l.setMemberOption(member, mm_cfg.Moderate,
+ # reset for explicitly named members
+ member not in l.posters)
+ l.generic_nonmember_action = 1
+ l.default_member_moderation = 1
+ elif l.member_posting_only:
+ #syslog('debug', 'Case 2')
+ for addr in l.posters:
+ if not l.isMember(addr):
+ l.accept_these_nonmembers.append(addr)
+ for member in l.getMembers():
+ l.setMemberOption(member, mm_cfg.Moderate, 0)
+ l.generic_nonmember_action = 1
+ l.default_member_moderation = 0
+ elif not l.posters:
+ #syslog('debug', 'Case 3')
+ for member in l.getMembers():
+ l.setMemberOption(member, mm_cfg.Moderate, 0)
+ l.generic_nonmember_action = 0
+ l.default_member_moderation = 0
+ else:
+ #syslog('debug', 'Case 4')
+ for addr in l.posters:
+ if not l.isMember(addr):
+ l.accept_these_nonmembers.append(addr)
+ for member in l.getMembers():
+ l.setMemberOption(member, mm_cfg.Moderate,
+ # reset for explicitly named members
+ member not in l.posters)
+ l.generic_nonmember_action = 1
+ l.default_member_moderation = 1
+ # Now get rid of the old attributes
+ del l.moderated
+ del l.posters
+ del l.member_posting_only
+ if hasattr(l, 'forbidden_posters'):
+ # For each of the posters on this list, if they are members, toggle on
+ # their moderation flag. If they are not members, then add them to
+ # hold_these_nonmembers.
+ forbiddens = l.forbidden_posters
+ for addr in forbiddens:
+ if l.isMember(addr):
+ l.setMemberOption(addr, mm_cfg.Moderate, 1)
+ else:
+ l.hold_these_nonmembers.append(addr)
+ del l.forbidden_posters
+
+ # Migrate to 1.0b6, klm 10/22/1998:
+ PreferStored('reminders_to_admins', 'umbrella_list',
+ mm_cfg.DEFAULT_UMBRELLA_LIST)
+
+ # Migrate up to 1.0b5:
+ PreferStored('auto_subscribe', 'open_subscribe')
+ PreferStored('closed', 'private_roster')
+ PreferStored('mimimum_post_count_before_removal',
+ 'mimimum_post_count_before_bounce_action')
+ PreferStored('bad_posters', 'forbidden_posters')
+ PreferStored('automatically_remove', 'automatic_bounce_action')
+ if hasattr(l, "open_subscribe"):
+ if l.open_subscribe:
+ if mm_cfg.ALLOW_OPEN_SUBSCRIBE:
+ l.subscribe_policy = 0
+ else:
+ l.subscribe_policy = 1
+ else:
+ l.subscribe_policy = 2 # admin approval
+ delattr(l, "open_subscribe")
+ if not hasattr(l, "administrivia"):
+ setattr(l, "administrivia", mm_cfg.DEFAULT_ADMINISTRIVIA)
+ if not hasattr(l, "admin_member_chunksize"):
+ setattr(l, "admin_member_chunksize",
+ mm_cfg.DEFAULT_ADMIN_MEMBER_CHUNKSIZE)
+ #
+ # this attribute was added then deleted, so there are a number of
+ # cases to take care of
+ #
+ if hasattr(l, "posters_includes_members"):
+ if l.posters_includes_members:
+ if l.posters:
+ l.member_posting_only = 1
+ else:
+ if l.posters:
+ l.member_posting_only = 0
+ delattr(l, "posters_includes_members")
+ elif l.data_version <= 10 and l.posters:
+ # make sure everyone gets the behavior the list used to have, but only
+ # for really old versions of Mailman (1.0b5 or before). Any newer
+ # version of Mailman should not get this attribute whacked.
+ l.member_posting_only = 0
+ #
+ # transfer the list data type for holding members and digest members
+ # to the dict data type starting file format version 11
+ #
+ if type(l.members) is ListType:
+ members = {}
+ for m in l.members:
+ members[m] = 1
+ l.members = members
+ if type(l.digest_members) is ListType:
+ dmembers = {}
+ for dm in l.digest_members:
+ dmembers[dm] = 1
+ l.digest_members = dmembers
+ #
+ # set admin_notify_mchanges
+ #
+ if not hasattr(l, "admin_notify_mchanges"):
+ setattr(l, "admin_notify_mchanges",
+ mm_cfg.DEFAULT_ADMIN_NOTIFY_MCHANGES)
+ #
+ # Convert the members and digest_members addresses so that the keys of
+ # both these are always lowercased, but if there is a case difference, the
+ # value contains the case preserved value
+ #
+ for k in l.members.keys():
+ if k.lower() <> k:
+ l.members[k.lower()] = Utils.LCDomain(k)
+ del l.members[k]
+ elif type(l.members[k]) == StringType and k == l.members[k].lower():
+ # already converted
+ pass
+ else:
+ l.members[k] = 0
+ for k in l.digest_members.keys():
+ if k.lower() <> k:
+ l.digest_members[k.lower()] = Utils.LCDomain(k)
+ del l.digest_members[k]
+ elif type(l.digest_members[k]) == StringType and \
+ k == l.digest_members[k].lower():
+ # already converted
+ pass
+ else:
+ l.digest_members[k] = 0
+
+
+
+def NewVars(l):
+ """Add defaults for these new variables if they don't exist."""
+ def add_only_if_missing(attr, initval, l=l):
+ if not hasattr(l, attr):
+ setattr(l, attr, initval)
+ # 1.2 beta 1, baw 18-Feb-2000
+ # Autoresponder mixin class attributes
+ add_only_if_missing('autorespond_postings', 0)
+ add_only_if_missing('autorespond_admin', 0)
+ add_only_if_missing('autorespond_requests', 0)
+ add_only_if_missing('autoresponse_postings_text', '')
+ add_only_if_missing('autoresponse_admin_text', '')
+ add_only_if_missing('autoresponse_request_text', '')
+ add_only_if_missing('autoresponse_graceperiod', 90)
+ add_only_if_missing('postings_responses', {})
+ add_only_if_missing('admin_responses', {})
+ add_only_if_missing('reply_goes_to_list', '')
+ add_only_if_missing('preferred_language', mm_cfg.DEFAULT_SERVER_LANGUAGE)
+ add_only_if_missing('available_languages', [])
+ add_only_if_missing('digest_volume_frequency',
+ mm_cfg.DEFAULT_DIGEST_VOLUME_FREQUENCY)
+ add_only_if_missing('digest_last_sent_at', 0)
+ add_only_if_missing('mod_password', None)
+ add_only_if_missing('moderator', [])
+ add_only_if_missing('topics', [])
+ add_only_if_missing('topics_enabled', 0)
+ add_only_if_missing('topics_bodylines_limit', 5)
+ add_only_if_missing('one_last_digest', {})
+ add_only_if_missing('usernames', {})
+ add_only_if_missing('personalize', 0)
+ add_only_if_missing('first_strip_reply_to',
+ mm_cfg.DEFAULT_FIRST_STRIP_REPLY_TO)
+ add_only_if_missing('unsubscribe_policy',
+ mm_cfg.DEFAULT_UNSUBSCRIBE_POLICY)
+ add_only_if_missing('send_goodbye_msg', mm_cfg.DEFAULT_SEND_GOODBYE_MSG)
+ add_only_if_missing('include_rfc2369_headers', 1)
+ add_only_if_missing('include_list_post_header', 1)
+ add_only_if_missing('bounce_score_threshold',
+ mm_cfg.DEFAULT_BOUNCE_SCORE_THRESHOLD)
+ add_only_if_missing('bounce_info_stale_after',
+ mm_cfg.DEFAULT_BOUNCE_INFO_STALE_AFTER)
+ add_only_if_missing('bounce_you_are_disabled_warnings',
+ mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS)
+ add_only_if_missing(
+ 'bounce_you_are_disabled_warnings_interval',
+ mm_cfg.DEFAULT_BOUNCE_YOU_ARE_DISABLED_WARNINGS_INTERVAL)
+ add_only_if_missing(
+ 'bounce_unrecognized_goes_to_list_owner',
+ mm_cfg.DEFAULT_BOUNCE_UNRECOGNIZED_GOES_TO_LIST_OWNER)
+ add_only_if_missing(
+ 'bounce_notify_owner_on_disable',
+ mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_DISABLE)
+ add_only_if_missing(
+ 'bounce_notify_owner_on_removal',
+ mm_cfg.DEFAULT_BOUNCE_NOTIFY_OWNER_ON_REMOVAL)
+ add_only_if_missing('ban_list', [])
+ add_only_if_missing('filter_mime_types', mm_cfg.DEFAULT_FILTER_MIME_TYPES)
+ add_only_if_missing('pass_mime_types', mm_cfg.DEFAULT_PASS_MIME_TYPES)
+ add_only_if_missing('filter_content', mm_cfg.DEFAULT_FILTER_CONTENT)
+ add_only_if_missing('convert_html_to_plaintext',
+ mm_cfg.DEFAULT_CONVERT_HTML_TO_PLAINTEXT)
+ add_only_if_missing('filter_action', mm_cfg.DEFAULT_FILTER_ACTION)
+ add_only_if_missing('delivery_status', {})
+ # This really ought to default to mm_cfg.HOLD, but that doesn't work with
+ # the current GUI description model. So, 0==Hold, 1==Reject, 2==Discard
+ add_only_if_missing('member_moderation_action', 0)
+ add_only_if_missing('member_moderation_notice', '')
+ add_only_if_missing('new_member_options',
+ mm_cfg.DEFAULT_NEW_MEMBER_OPTIONS)
+ # Emergency moderation flag
+ add_only_if_missing('emergency', 0)
+ add_only_if_missing('hold_and_cmd_autoresponses', {})
+ add_only_if_missing('news_prefix_subject_too', 1)
+ # Should prefixes be encoded?
+ if Utils.GetCharSet(l.preferred_language) == 'us-ascii':
+ encode = 0
+ else:
+ encode = 2
+ add_only_if_missing('encode_ascii_prefixes', encode)
+ add_only_if_missing('news_moderation', 0)
+
+
+
+def UpdateOldUsers(mlist):
+ """Transform sense of changed user options."""
+ # pre-1.0b11 to 1.0b11. Force all keys in l.passwords to be lowercase
+ passwords = {}
+ for k, v in mlist.passwords.items():
+ passwords[k.lower()] = v
+ mlist.passwords = passwords
+ # Go through all the keys in bounce_info. If the key is not a member, or
+ # if the data is not a _BounceInfo instance, chuck the bounce info. We're
+ # doing things differently now.
+ from Mailman.Bouncer import _BounceInfo
+ for m in mlist.bounce_info.keys():
+ if not mlist.isMember(m) or not isinstance(mlist.getBounceInfo(m),
+ _BounceInfo):
+ del mlist.bounce_info[m]
+
+
+
+def CanonicalizeUserOptions(l):
+ """Fix up the user options."""
+ # I want to put a flag in the list database which tells this routine to
+ # never try to canonicalize the user options again.
+ if getattr(l, 'useropts_version', 0) > 0:
+ return
+ # pre 1.0rc2 to 1.0rc3. For all keys in l.user_options to be lowercase,
+ # but merge options for both cases
+ options = {}
+ for k, v in l.user_options.items():
+ if k is None:
+ continue
+ lcuser = k.lower()
+ flags = 0
+ if options.has_key(lcuser):
+ flags = options[lcuser]
+ flags |= v
+ options[lcuser] = flags
+ l.user_options = options
+ # 2.1alpha3 -> 2.1alpha4. The DisableDelivery flag is now moved into
+ # get/setDeilveryStatus(). This must be done after the addresses are
+ # canonicalized.
+ for k, v in l.user_options.items():
+ if not l.isMember(k):
+ # There's a key in user_options that isn't associated with a real
+ # member address. This is likely caused by an earlier bug.
+ del l.user_options[k]
+ continue
+ if l.getMemberOption(k, mm_cfg.DisableDelivery):
+ # Convert this flag into a legacy disable
+ l.setDeliveryStatus(k, UNKNOWN)
+ l.setMemberOption(k, mm_cfg.DisableDelivery, 0)
+ l.useropts_version = 1
+
+
+
+def NewRequestsDatabase(l):
+ """With version 1.2, we use a new pending request database schema."""
+ r = getattr(l, 'requests', {})
+ if not r:
+ # no old-style requests
+ return
+ for k, v in r.items():
+ if k == 'post':
+ # This is a list of tuples with the following format
+ #
+ # a sequential request id integer
+ # a timestamp float
+ # a message tuple: (author-email-str, message-text-str)
+ # a reason string
+ # the subject string
+ #
+ # We'll re-submit this as a new HoldMessage request, but we'll
+ # blow away the original timestamp and request id. This means the
+ # request will live a little longer than it possibly should have,
+ # but that's no big deal.
+ for p in v:
+ author, text = p[2]
+ reason = p[3]
+ msg = Message.OutgoingMessage(text)
+ l.HoldMessage(msg, reason)
+ del r[k]
+ elif k == 'add_member':
+ # This is a list of tuples with the following format
+ #
+ # a sequential request id integer
+ # a timestamp float
+ # a digest flag (0 == nodigest, 1 == digest)
+ # author-email-str
+ # password
+ #
+ # See the note above; the same holds true.
+ for ign, ign, digest, addr, password in v:
+ l.HoldSubscription(addr, password, digest)
+ del r[k]
+ else:
+ syslog('error', """\
+VERY BAD NEWS. Unknown pending request type `%s' found for list: %s""",
+ k, l.internal_name())