diff options
Diffstat (limited to '')
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 = ( ('&', '&'), + ("<", '<'), + (">", '>'), + ('"', '"')) + 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('"', '"'), 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'^([>|:]|>)+') + + + +# 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([' ', ' ']) + 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> <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> <br>')) + otherlinks.AddItem(Link('%s/logout' % adminurl, + # BAW: What I really want is a blank line, but + # adding an 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([' ', ' ']) + # 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 = ' '*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([' ', ' ']) + 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((_('<blank line>'), + _('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 = ' ' * 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() + \ + ' ' + _('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() + + ' ' + + _('Preserve messages for the site administrator') + ]) + left.AddCellInfo(left.GetCurrentRowIndex(), 0, colspan=2) + left.AddRow([ + CheckBox('senderforward-' + qsender, 1).Format() + + ' ' + + _('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() + + ' ' + + _("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() + + ' ' + + _('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() + + ' ' + + _("""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([' ', ' ']) + 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([' ', Bold(_('Size:')), str(size) + _(' bytes')]) + if reason: + reason = _(reason) + else: + reason = _('not available') + t.AddRow([' ', Bold(_('Reason:')), reason]) + # Include the date we received the message, if available + when = msgdata.get('received_time') + if when: + t.AddRow([' ', 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=' '*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([' ', + CheckBox('preserve-%d' % id, 'on', 0).Format() + + ' ' + _('Preserve message for site administrator') + ]) + t.AddRow([' ', + CheckBox('forward-%d' % id, 'on', 0).Format() + + ' ' + _('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([' ', ' ']) + 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 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 <br> - 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 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 Management') + + def GetConfigSubCategories(self, category): + if category == 'members': + return [('list', _('Membership List')), + ('add', _('Mass Subscription')), + ('remove', _('Mass 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 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 rules')), + ('sender', _('Sender filters')), + ('recipient', _('Recipient filters')), + ('spam', _('Spam 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<->News 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') + + " ") + 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(' ', ' ').replace('\t', ' '*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()) |