diff options
| author | Barry Warsaw | 2009-01-25 13:01:41 -0500 |
|---|---|---|
| committer | Barry Warsaw | 2009-01-25 13:01:41 -0500 |
| commit | eefd06f1b88b8ecbb23a9013cd223b72ca85c20d (patch) | |
| tree | 72c947fe16fce0e07e996ee74020b26585d7e846 /src/mailman/bin | |
| parent | 07871212f74498abd56bef3919bf3e029eb8b930 (diff) | |
| download | mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.gz mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.zst mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.zip | |
Diffstat (limited to 'src/mailman/bin')
35 files changed, 6263 insertions, 0 deletions
diff --git a/src/mailman/bin/__init__.py b/src/mailman/bin/__init__.py new file mode 100644 index 000000000..d61693c5e --- /dev/null +++ b/src/mailman/bin/__init__.py @@ -0,0 +1,61 @@ +# Copyright (C) 2007-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +__all__ = [ + 'add_members', + 'arch', + 'bounces', + 'bumpdigests', + 'check_perms', + 'checkdbs', + 'cleanarch', + 'config_list', + 'confirm', + 'create_list', + 'disabled', + 'dumpdb', + 'export', + 'find_member', + 'gate_news', + 'genaliases', + 'import', + 'inject', + 'join', + 'leave', + 'list_lists', + 'list_members', + 'list_owners', + 'mailmanctl', + 'make_instance', + 'master', + 'mmsitepass', + 'nightly_gzip', + 'owner', + 'post', + 'qrunner', + 'remove_list', + 'request', + 'senddigests', + 'set_members', + 'show_config', + 'show_qfiles', + 'testall', + 'unshunt', + 'update', + 'version', + 'withlist', + ] diff --git a/src/mailman/bin/add_members.py b/src/mailman/bin/add_members.py new file mode 100644 index 000000000..9c87f4af9 --- /dev/null +++ b/src/mailman/bin/add_members.py @@ -0,0 +1,186 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys +import codecs + +from cStringIO import StringIO +from email.utils import parseaddr + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.app.membership import add_member +from mailman.config import config +from mailman.core import errors +from mailman.interfaces.member import AlreadySubscribedError, DeliveryMode +from mailman.options import SingleMailingListOptions + +_ = i18n._ + + + +class ScriptOptions(SingleMailingListOptions): + usage=_("""\ +%prog [options] + +Add members to a list. 'listname' is the name of the Mailman list you are +adding members to; the list must already exist. + +You must supply at least one of -r and -d options. At most one of the +files can be '-'. +""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-r', '--regular-members-file', + type='string', dest='regular', help=_("""\ +A file containing addresses of the members to be added, one address per line. +This list of people become non-digest members. If file is '-', read addresses +from stdin.""")) + self.parser.add_option( + '-d', '--digest-members-file', + type='string', dest='digest', help=_("""\ +Similar to -r, but these people become digest members.""")) + self.parser.add_option( + '-w', '--welcome-msg', + type='yesno', metavar='<y|n>', help=_("""\ +Set whether or not to send the list members a welcome message, overriding +whatever the list's 'send_welcome_msg' setting is.""")) + self.parser.add_option( + '-a', '--admin-notify', + type='yesno', metavar='<y|n>', help=_("""\ +Set whether or not to send the list administrators a notification on the +success/failure of these subscriptions, overriding whatever the list's +'admin_notify_mchanges' setting is.""")) + + def sanity_check(self): + if not self.options.listname: + self.parser.error(_('Missing listname')) + if len(self.arguments) > 0: + self.parser.print_error(_('Unexpected arguments')) + if self.options.regular is None and self.options.digest is None: + parser.error(_('At least one of -r or -d is required')) + if self.options.regular == '-' and self.options.digest == '-': + parser.error(_("-r and -d cannot both be '-'")) + + + +def readfile(filename): + if filename == '-': + fp = sys.stdin + else: + # XXX Need to specify other encodings. + fp = codecs.open(filename, encoding='utf-8') + # Strip all the lines of whitespace and discard blank lines + try: + return set(line.strip() for line in fp if line) + finally: + if fp is not sys.stdin: + fp.close() + + + +class Tee: + def __init__(self, outfp): + self._outfp = outfp + + def write(self, msg): + sys.stdout.write(msg) + self._outfp.write(msg) + + + +def addall(mlist, subscribers, delivery_mode, ack, admin_notify, outfp): + tee = Tee(outfp) + for subscriber in subscribers: + try: + fullname, address = parseaddr(subscriber) + # Watch out for the empty 8-bit string. + if not fullname: + fullname = u'' + password = Utils.MakeRandomPassword() + add_member(mlist, address, fullname, password, delivery_mode, + unicode(config.mailman.default_language)) + # XXX Support ack and admin_notify + except AlreadySubscribedError: + print >> tee, _('Already a member: $subscriber') + except errors.InvalidEmailAddress: + if not address: + print >> tee, _('Bad/Invalid email address: blank line') + else: + print >> tee, _('Bad/Invalid email address: $subscriber') + else: + print >> tee, _('Subscribing: $subscriber') + + + +def main(): + options = ScriptOptions() + options.initialize() + + fqdn_listname = options.options.listname + mlist = config.db.list_manager.get(fqdn_listname) + if mlist is None: + parser.error(_('No such list: $fqdn_listname')) + + # Set up defaults. + send_welcome_msg = (options.options.welcome_msg + if options.options.welcome_msg is not None + else mlist.send_welcome_msg) + admin_notify = (options.options.admin_notify + if options.options.admin_notify is not None + else mlist.admin_notify) + + with i18n.using_language(mlist.preferred_language): + if options.options.digest: + dmembers = readfile(options.options.digest) + else: + dmembers = set() + if options.options.regular: + nmembers = readfile(options.options.regular) + else: + nmembers = set() + + if not dmembers and not nmembers: + print _('Nothing to do.') + sys.exit(0) + + outfp = StringIO() + if nmembers: + addall(mlist, nmembers, DeliveryMode.regular, + send_welcome_msg, admin_notify, outfp) + + if dmembers: + addall(mlist, dmembers, DeliveryMode.mime_digests, + send_welcome_msg, admin_notify, outfp) + + config.db.commit() + + if admin_notify: + subject = _('$mlist.real_name subscription notification') + msg = Message.UserNotification( + mlist.owner, mlist.no_reply_address, subject, + outfp.getvalue(), mlist.preferred_language) + msg.send(mlist) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/arch.py b/src/mailman/bin/arch.py new file mode 100644 index 000000000..a27fa8d7f --- /dev/null +++ b/src/mailman/bin/arch.py @@ -0,0 +1,151 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys +import errno +import shutil +import optparse + +from locknix.lockfile import Lock + +from mailman import i18n +from mailman.Archiver.HyperArch import HyperArchive +from mailman.Defaults import hours +from mailman.configuration import config +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + +_ = i18n._ + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%%prog [options] listname [mbox] + +Rebuild a list's archive. + +Use this command to rebuild the archives for a mailing list. You may want to +do this if you edit some messages in an archive, or remove some messages from +an archive. + +Where 'mbox' is the path to a list's complete mbox archive. Usually this will +be some path in the archives/private directory. For example: + +% bin/arch mylist archives/private/mylist.mbox/mylist.mbox + +'mbox' is optional. If it is missing, it is calculated from the listname. +""")) + parser.add_option('-q', '--quiet', + dest='verbose', default=True, action='store_false', + help=_('Make the archiver output less verbose')) + parser.add_option('--wipe', + default=False, action='store_true', + help=_("""\ +First wipe out the original archive before regenerating. You usually want to +specify this argument unless you're generating the archive in chunks.""")) + parser.add_option('-s', '--start', + default=None, type='int', metavar='N', + help=_("""\ +Start indexing at article N, where article 0 is the first in the mbox. +Defaults to 0.""")) + parser.add_option('-e', '--end', + default=None, type='int', metavar='M', + help=_("""\ +End indexing at article M. This script is not very efficient with respect to +memory management, and for large archives, it may not be possible to index the +mbox entirely. For that reason, you can specify the start and end article +numbers.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if len(args) < 1: + parser.print_help() + print >> sys.stderr, _('listname is required') + sys.exit(1) + if len(args) > 2: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + + i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + + listname = args[0].lower().strip() + if len(args) < 2: + mbox = None + else: + mbox = args[1] + + # Open the mailing list object + mlist = config.list_manager.get(listname) + if mlist is None: + parser.error(_('No such list: $listname')) + if mbox is None: + mbox = mlist.ArchiveFileName() + + i18n.set_language(mlist.preferred_language) + # Lay claim to the archive's lock file. This is so no other post can + # mess up the archive while we're processing it. Try to pick a + # suitably long period of time for the lock lifetime even though we + # really don't know how long it will take. + # + # XXX processUnixMailbox() should refresh the lock. + lock_path = os.path.join(mlist.data_path, '.archiver.lck') + with Lock(lock_path, lifetime=int(hours(3))): + # Try to open mbox before wiping old archive. + try: + fp = open(mbox) + except IOError, e: + if e.errno == errno.ENOENT: + print >> sys.stderr, _('Cannot open mbox file: $mbox') + else: + print >> sys.stderr, e + sys.exit(1) + # Maybe wipe the old archives + if opts.wipe: + if mlist.scrub_nondigest: + # TK: save the attachments dir because they are not in mbox + saved = False + atchdir = os.path.join(mlist.archive_dir(), 'attachments') + savedir = os.path.join(mlist.archive_dir() + '.mbox', + 'attachments') + try: + os.rename(atchdir, savedir) + saved = True + except OSError, e: + if e.errno <> errno.ENOENT: + raise + shutil.rmtree(mlist.archive_dir()) + if mlist.scrub_nondigest and saved: + os.renames(savedir, atchdir) + + archiver = HyperArchive(mlist) + archiver.VERBOSE = opts.verbose + try: + archiver.processUnixMailbox(fp, opts.start, opts.end) + finally: + archiver.close() + fp.close() diff --git a/src/mailman/bin/bumpdigests.py b/src/mailman/bin/bumpdigests.py new file mode 100644 index 000000000..b1ed37a21 --- /dev/null +++ b/src/mailman/bin/bumpdigests.py @@ -0,0 +1,74 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import sys +import optparse + +from mailman import errors +from mailman import MailList +from mailman.configuration import config +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + +# Work around known problems with some RedHat cron daemons +import signal +signal.signal(signal.SIGCHLD, signal.SIG_DFL) + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] [listname ...] + +Increment the digest volume number and reset the digest number to one. All +the lists named on the command line are bumped. If no list names are given, +all lists are bumped.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + return opts, args, parser + + + +def main(): + opts, args, parser = parseargs() + config.load(opts.config) + + listnames = set(args or config.list_manager.names) + if not listnames: + print _('Nothing to do.') + sys.exit(0) + + for listname in listnames: + try: + # Be sure the list is locked + mlist = MailList.MailList(listname) + except errors.MMListError, e: + parser.print_help() + print >> sys.stderr, _('No such list: $listname') + sys.exit(1) + try: + mlist.bump_digest_volume() + finally: + mlist.Save() + mlist.Unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/check_perms.py b/src/mailman/bin/check_perms.py new file mode 100644 index 000000000..4b75aa9f6 --- /dev/null +++ b/src/mailman/bin/check_perms.py @@ -0,0 +1,408 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys +import pwd +import grp +import errno +import optparse + +from stat import * + +from mailman.configuration import config +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +# XXX Need to check the archives/private/*/database/* files + + + +class State: + FIX = False + VERBOSE = False + ERRORS = 0 + +STATE = State() + +DIRPERMS = S_ISGID | S_IRWXU | S_IRWXG | S_IROTH | S_IXOTH +QFILEPERMS = S_ISGID | S_IRWXU | S_IRWXG +PYFILEPERMS = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH +ARTICLEFILEPERMS = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP +MBOXPERMS = S_IRGRP | S_IWGRP | S_IRUSR | S_IWUSR +PRIVATEPERMS = QFILEPERMS + + + +def statmode(path): + return os.stat(path).st_mode + + +def statgidmode(path): + stat = os.stat(path) + return stat.st_mode, stat.st_gid + + +seen = {} + +# libc's getgrgid re-opens /etc/group each time :( +_gidcache = {} + +def getgrgid(gid): + data = _gidcache.get(gid) + if data is None: + data = grp.getgrgid(gid) + _gidcache[gid] = data + return data + + + +def checkwalk(arg, dirname, names): + # Short-circuit duplicates + if seen.has_key(dirname): + return + seen[dirname] = True + for name in names: + path = os.path.join(dirname, name) + if arg.VERBOSE: + print _(' checking gid and mode for $path') + try: + mode, gid = statgidmode(path) + except OSError, e: + if e.errno <> errno.ENOENT: raise + continue + if gid <> MAILMAN_GID: + try: + groupname = getgrgid(gid)[0] + except KeyError: + groupname = '<anon gid %d>' % gid + arg.ERRORS += 1 + print _( + '$path bad group (has: $groupname, expected $MAILMAN_GROUP)'), + if STATE.FIX: + print _('(fixing)') + os.chown(path, -1, MAILMAN_GID) + else: + print + # Most directories must be at least rwxrwsr-x. + # The private archive directory and database directory must be at + # least rwxrws---. Their 'other' permissions are checked in + # checkarchives() and checkarchivedbs() below. Their 'user' and + # 'group' permissions are checked here. + # The directories under qfiles should be rwxrws---. Their 'user' and + # 'group' permissions are checked here. Their 'other' permissions + # aren't checked. + private = config.PRIVATE_ARCHIVE_FILE_DIR + if path == private or ( + os.path.commonprefix((path, private)) == private + and os.path.split(path)[1] == 'database'): + # then... + targetperms = PRIVATEPERMS + elif (os.path.commonprefix((path, config.QUEUE_DIR)) + == config.QUEUE_DIR): + targetperms = QFILEPERMS + else: + targetperms = DIRPERMS + octperms = oct(targetperms) + if S_ISDIR(mode) and (mode & targetperms) <> targetperms: + arg.ERRORS += 1 + print _('directory permissions must be $octperms: $path'), + if STATE.FIX: + print _('(fixing)') + os.chmod(path, mode | targetperms) + else: + print + elif os.path.splitext(path)[1] in ('.py', '.pyc', '.pyo'): + octperms = oct(PYFILEPERMS) + if mode & PYFILEPERMS <> PYFILEPERMS: + print _('source perms must be $octperms: $path'), + arg.ERRORS += 1 + if STATE.FIX: + print _('(fixing)') + os.chmod(path, mode | PYFILEPERMS) + else: + print + elif path.endswith('-article'): + # Article files must be group writeable + octperms = oct(ARTICLEFILEPERMS) + if mode & ARTICLEFILEPERMS <> ARTICLEFILEPERMS: + print _('article db files must be $octperms: $path'), + arg.ERRORS += 1 + if STATE.FIX: + print _('(fixing)') + os.chmod(path, mode | ARTICLEFILEPERMS) + else: + print + + + +def checkall(): + # first check PREFIX + if STATE.VERBOSE: + prefix = config.PREFIX + print _('checking mode for $prefix') + dirs = {} + for d in (config.PREFIX, config.EXEC_PREFIX, config.VAR_PREFIX, + config.LOG_DIR): + dirs[d] = True + for d in dirs.keys(): + try: + mode = statmode(d) + except OSError, e: + if e.errno <> errno.ENOENT: raise + print _('WARNING: directory does not exist: $d') + continue + if (mode & DIRPERMS) <> DIRPERMS: + STATE.ERRORS += 1 + print _('directory must be at least 02775: $d'), + if STATE.FIX: + print _('(fixing)') + os.chmod(d, mode | DIRPERMS) + else: + print + # check all subdirs + os.path.walk(d, checkwalk, STATE) + + + +def checkarchives(): + private = config.PRIVATE_ARCHIVE_FILE_DIR + if STATE.VERBOSE: + print _('checking perms on $private') + # private archives must not be other readable + mode = statmode(private) + if mode & S_IROTH: + STATE.ERRORS += 1 + print _('$private must not be other-readable'), + if STATE.FIX: + print _('(fixing)') + os.chmod(private, mode & ~S_IROTH) + else: + print + # In addition, on a multiuser system you may want to hide the private + # archives so other users can't read them. + if mode & S_IXOTH: + print _("""\ +Warning: Private archive directory is other-executable (o+x). + This could allow other users on your system to read private archives. + If you're on a shared multiuser system, you should consult the + installation manual on how to fix this.""") + + + +def checkmboxfile(mboxdir): + absdir = os.path.join(config.PRIVATE_ARCHIVE_FILE_DIR, mboxdir) + for f in os.listdir(absdir): + if not f.endswith('.mbox'): + continue + mboxfile = os.path.join(absdir, f) + mode = statmode(mboxfile) + if (mode & MBOXPERMS) <> MBOXPERMS: + STATE.ERRORS = STATE.ERRORS + 1 + print _('mbox file must be at least 0660:'), mboxfile + if STATE.FIX: + print _('(fixing)') + os.chmod(mboxfile, mode | MBOXPERMS) + else: + print + + + +def checkarchivedbs(): + # The archives/private/listname/database file must not be other readable + # or executable otherwise those files will be accessible when the archives + # are public. That may not be a horrible breach, but let's close this off + # anyway. + for dir in os.listdir(config.PRIVATE_ARCHIVE_FILE_DIR): + if dir.endswith('.mbox'): + checkmboxfile(dir) + dbdir = os.path.join(config.PRIVATE_ARCHIVE_FILE_DIR, dir, 'database') + try: + mode = statmode(dbdir) + except OSError, e: + if e.errno not in (errno.ENOENT, errno.ENOTDIR): raise + continue + if mode & S_IRWXO: + STATE.ERRORS += 1 + print _('$dbdir "other" perms must be 000'), + if STATE.FIX: + print _('(fixing)') + os.chmod(dbdir, mode & ~S_IRWXO) + else: + print + + + +def checkcgi(): + cgidir = os.path.join(config.EXEC_PREFIX, 'cgi-bin') + if STATE.VERBOSE: + print _('checking cgi-bin permissions') + exes = os.listdir(cgidir) + for f in exes: + path = os.path.join(cgidir, f) + if STATE.VERBOSE: + print _(' checking set-gid for $path') + mode = statmode(path) + if mode & S_IXGRP and not mode & S_ISGID: + STATE.ERRORS += 1 + print _('$path must be set-gid'), + if STATE.FIX: + print _('(fixing)') + os.chmod(path, mode | S_ISGID) + else: + print + + + +def checkmail(): + wrapper = os.path.join(config.WRAPPER_DIR, 'mailman') + if STATE.VERBOSE: + print _('checking set-gid for $wrapper') + mode = statmode(wrapper) + if not mode & S_ISGID: + STATE.ERRORS += 1 + print _('$wrapper must be set-gid'), + if STATE.FIX: + print _('(fixing)') + os.chmod(wrapper, mode | S_ISGID) + + + +def checkadminpw(): + for pwfile in (os.path.join(config.DATA_DIR, 'adm.pw'), + os.path.join(config.DATA_DIR, 'creator.pw')): + targetmode = S_IFREG | S_IRUSR | S_IWUSR | S_IRGRP + if STATE.VERBOSE: + print _('checking permissions on $pwfile') + try: + mode = statmode(pwfile) + except OSError, e: + if e.errno <> errno.ENOENT: + raise + return + if mode <> targetmode: + STATE.ERRORS += 1 + octmode = oct(mode) + print _('$pwfile permissions must be exactly 0640 (got $octmode)'), + if STATE.FIX: + print _('(fixing)') + os.chmod(pwfile, targetmode) + else: + print + + +def checkmta(): + if config.MTA: + modname = 'mailman.MTA.' + config.MTA + __import__(modname) + try: + sys.modules[modname].checkperms(STATE) + except AttributeError: + pass + + + +def checkdata(): + targetmode = S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP + checkfiles = ('config.pck', 'config.pck.last', + 'config.db', 'config.db.last', + 'next-digest', 'next-digest-topics', + 'digest.mbox', 'pending.pck', + 'request.db', 'request.db.tmp') + if STATE.VERBOSE: + print _('checking permissions on list data') + for dir in os.listdir(config.LIST_DATA_DIR): + for file in checkfiles: + path = os.path.join(config.LIST_DATA_DIR, dir, file) + if STATE.VERBOSE: + print _(' checking permissions on: $path') + try: + mode = statmode(path) + except OSError, e: + if e.errno <> errno.ENOENT: + raise + continue + if (mode & targetmode) <> targetmode: + STATE.ERRORS += 1 + print _('file permissions must be at least 660: $path'), + if STATE.FIX: + print _('(fixing)') + os.chmod(path, mode | targetmode) + else: + print + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Check the permissions of all Mailman files. With no options, just report the +permission and ownership problems found.""")) + parser.add_option('-f', '--fix', + default=False, action='store_true', help=_("""\ +Fix all permission and ownership problems found. With this option, you must +run check_perms as root.""")) + parser.add_option('-v', '--verbose', + default=False, action='store_true', + help=_('Produce more verbose output')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return parser, opts, args + + + +def main(): + global MAILMAN_USER, MAILMAN_GROUP, MAILMAN_UID, MAILMAN_GID + + parser, opts, args = parseargs() + STATE.FIX = opts.fix + STATE.VERBOSE = opts.verbose + + config.load(opts.config) + + MAILMAN_USER = config.MAILMAN_USER + MAILMAN_GROUP = config.MAILMAN_GROUP + # Let KeyErrors percolate + MAILMAN_GID = grp.getgrnam(MAILMAN_GROUP).gr_gid + MAILMAN_UID = pwd.getpwnam(MAILMAN_USER).pw_uid + + checkall() + checkarchives() + checkarchivedbs() + checkcgi() + checkmail() + checkdata() + checkadminpw() + checkmta() + + if not STATE.ERRORS: + print _('No problems found') + else: + print _('Problems found:'), STATE.ERRORS + print _('Re-run as $MAILMAN_USER (or root) with -f flag to fix') + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/checkdbs.py b/src/mailman/bin/checkdbs.py new file mode 100644 index 000000000..2ce08aab7 --- /dev/null +++ b/src/mailman/bin/checkdbs.py @@ -0,0 +1,199 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import sys +import time +import optparse + +from email.Charset import Charset + +from mailman import MailList +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.app.requests import handle_request +from mailman.configuration import config +from mailman.version import MAILMAN_VERSION + +_ = i18n._ + +# Work around known problems with some RedHat cron daemons +import signal +signal.signal(signal.SIGCHLD, signal.SIG_DFL) + +NL = u'\n' +now = time.time() + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Check for pending admin requests and mail the list owners if necessary.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return opts, args, parser + + + +def pending_requests(mlist): + # Must return a byte string + lcset = Utils.GetCharSet(mlist.preferred_language) + pending = [] + first = True + requestsdb = config.db.get_list_requests(mlist) + for request in requestsdb.of_type(RequestType.subscription): + if first: + pending.append(_('Pending subscriptions:')) + first = False + key, data = requestsdb.get_request(request.id) + when = data['when'] + addr = data['addr'] + fullname = data['fullname'] + passwd = data['passwd'] + digest = data['digest'] + lang = data['lang'] + if fullname: + if isinstance(fullname, unicode): + fullname = fullname.encode(lcset, 'replace') + fullname = ' (%s)' % fullname + pending.append(' %s%s %s' % (addr, fullname, time.ctime(when))) + first = True + for request in requestsdb.of_type(RequestType.held_message): + if first: + pending.append(_('\nPending posts:')) + first = False + key, data = requestsdb.get_request(request.id) + when = data['when'] + sender = data['sender'] + subject = data['subject'] + reason = data['reason'] + text = data['text'] + msgdata = data['msgdata'] + subject = Utils.oneline(subject, lcset) + date = time.ctime(when) + reason = _(reason) + pending.append(_("""\ +From: $sender on $date +Subject: $subject +Cause: $reason""")) + pending.append('') + # Coerce all items in pending to a Unicode so we can join them + upending = [] + charset = Utils.GetCharSet(mlist.preferred_language) + for s in pending: + if isinstance(s, unicode): + upending.append(s) + else: + upending.append(unicode(s, charset, 'replace')) + # Make sure that the text we return from here can be encoded to a byte + # string in the charset of the list's language. This could fail if for + # example, the request was pended while the list's language was French, + # but then it was changed to English before checkdbs ran. + text = NL.join(upending) + charset = Charset(Utils.GetCharSet(mlist.preferred_language)) + incodec = charset.input_codec or 'ascii' + outcodec = charset.output_codec or 'ascii' + if isinstance(text, unicode): + return text.encode(outcodec, 'replace') + # Be sure this is a byte string encodeable in the list's charset + utext = unicode(text, incodec, 'replace') + return utext.encode(outcodec, 'replace') + + + +def auto_discard(mlist): + # Discard old held messages + discard_count = 0 + expire = config.days(mlist.max_days_to_hold) + requestsdb = config.db.get_list_requests(mlist) + heldmsgs = list(requestsdb.of_type(RequestType.held_message)) + if expire and heldmsgs: + for request in heldmsgs: + key, data = requestsdb.get_request(request.id) + if now - data['date'] > expire: + handle_request(mlist, request.id, config.DISCARD) + discard_count += 1 + mlist.Save() + return discard_count + + + +def main(): + opts, args, parser = parseargs() + config.load(opts.config) + + i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) + + for name in config.list_manager.names: + # The list must be locked in order to open the requests database + mlist = MailList.MailList(name) + try: + count = config.db.requests.get_list_requests(mlist).count + # While we're at it, let's evict yesterday's autoresponse data + midnight_today = Utils.midnight() + evictions = [] + for sender in mlist.hold_and_cmd_autoresponses.keys(): + date, respcount = mlist.hold_and_cmd_autoresponses[sender] + if Utils.midnight(date) < midnight_today: + evictions.append(sender) + if evictions: + for sender in evictions: + del mlist.hold_and_cmd_autoresponses[sender] + # This is the only place we've changed the list's database + mlist.Save() + if count: + i18n.set_language(mlist.preferred_language) + realname = mlist.real_name + discarded = auto_discard(mlist) + if discarded: + count = count - discarded + text = _( + 'Notice: $discarded old request(s) automatically expired.\n\n') + else: + text = '' + if count: + text += Utils.maketext( + 'checkdbs.txt', + {'count' : count, + 'host_name': mlist.host_name, + 'adminDB' : mlist.GetScriptURL('admindb', absolute=1), + 'real_name': realname, + }, mlist=mlist) + text += '\n' + pending_requests(mlist) + subject = _('$count $realname moderator request(s) waiting') + else: + subject = _('$realname moderator request check result') + msg = Message.UserNotification(mlist.GetOwnerEmail(), + mlist.GetBouncesEmail(), + subject, text, + mlist.preferred_language) + msg.send(mlist, **{'tomoderators': True}) + finally: + mlist.Unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/cleanarch.py b/src/mailman/bin/cleanarch.py new file mode 100644 index 000000000..325fad91a --- /dev/null +++ b/src/mailman/bin/cleanarch.py @@ -0,0 +1,133 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Clean up an .mbox archive file.""" + +import re +import sys +import mailbox +import optparse + +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +cre = re.compile(mailbox.UnixMailbox._fromlinepattern) +# From RFC 2822, a header field name must contain only characters from 33-126 +# inclusive, excluding colon. I.e. from oct 41 to oct 176 less oct 072. Must +# use re.match() so that it's anchored at the beginning of the line. +fre = re.compile(r'[\041-\071\073-\176]+') + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] < inputfile > outputfile + +The archiver looks for Unix-From lines separating messages in an mbox archive +file. For compatibility, it specifically looks for lines that start with +'From ' -- i.e. the letters capital-F, lowercase-r, o, m, space, ignoring +everything else on the line. + +Normally, any lines that start 'From ' in the body of a message should be +escaped such that a > character is actually the first on a line. It is +possible though that body lines are not actually escaped. This script +attempts to fix these by doing a stricter test of the Unix-From lines. Any +lines that start From ' but do not pass this stricter test are escaped with a +'>' character.""")) + parser.add_option('-q', '--quiet', + default=False, action='store_true', help=_("""\ +Don't print changed line information to standard error.""")) + parser.add_option('-s', '--status', + default=-1, type='int', help=_("""\ +Print a '#' character for every n lines processed. With a number less than or +equal to zero, suppress the '#' characters.""")) + parser.add_option('-n', '--dry-run', + default=False, action='store_true', help=_("""\ +Don't actually output anything.""")) + opts, args = parser.parser_args() + if args: + parser.print_error(_('Unexpected arguments')) + return parser, opts, args + + + +def escape_line(line, lineno, quiet, output): + if output: + sys.stdout.write('>' + line) + if not quiet: + print >> sys.stderr, _('Unix-From line changed: $lineno') + print >> sys.stderr, line[:-1] + + + +def main(): + parser, opts, args = parseargs() + + lineno = 0 + statuscnt = 0 + messages = 0 + prevline = None + while True: + lineno += 1 + line = sys.stdin.readline() + if not line: + break + if line.startswith('From '): + if cre.match(line): + # This is a real Unix-From line. But it could be a message + # /about/ Unix-From lines, so as a second order test, make + # sure there's at least one RFC 2822 header following + nextline = sys.stdin.readline() + lineno += 1 + if not nextline: + # It was the last line of the mbox, so it couldn't have + # been a Unix-From + escape_line(line, lineno, quiet, output) + break + fieldname = nextline.split(':', 1) + if len(fieldname) < 2 or not fre.match(nextline): + # The following line was not a header, so this wasn't a + # valid Unix-From + escape_line(line, lineno, quiet, output) + if output: + sys.stdout.write(nextline) + else: + # It's a valid Unix-From line + messages += 1 + if output: + # Before we spit out the From_ line, make sure the + # previous line was blank. + if prevline is not None and prevline <> '\n': + sys.stdout.write('\n') + sys.stdout.write(line) + sys.stdout.write(nextline) + else: + # This is a bogus Unix-From line + escape_line(line, lineno, quiet, output) + elif output: + # Any old line + sys.stdout.write(line) + if status > 0 and (lineno % status) == 0: + sys.stderr.write('#') + statuscnt += 1 + if statuscnt > 50: + print >> sys.stderr + statuscnt = 0 + prevline = line + print >> sys.stderr, _('%(messages)d messages found') diff --git a/src/mailman/bin/config_list.py b/src/mailman/bin/config_list.py new file mode 100644 index 000000000..a5cec9480 --- /dev/null +++ b/src/mailman/bin/config_list.py @@ -0,0 +1,332 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import re +import sys +import time +import optparse + +from mailman import errors +from mailman import MailList +from mailman import Utils +from mailman import i18n +from mailman.configuration import config +from mailman.version import MAILMAN_VERSION + +_ = i18n._ + +NL = '\n' +nonasciipat = re.compile(r'[\x80-\xff]') + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] listname + +Configure a list from a text file description, or dump a list's configuration +settings.""")) + parser.add_option('-i', '--inputfile', + metavar='FILENAME', default=None, type='string', + help=_("""\ +Configure the list by assigning each module-global variable in the file to an +attribute on the mailing list object, then save the list. The named file is +loaded with execfile() and must be legal Python code. Any variable that isn't +already an attribute of the list object is ignored (a warning message is +printed). See also the -c option. + +A special variable named 'mlist' is put into the globals during the execfile, +which is bound to the actual MailList object. This lets you do all manner of +bizarre thing to the list object, but BEWARE! Using this can severely (and +possibly irreparably) damage your mailing list! + +The may not be used with the -o option.""")) + parser.add_option('-o', '--outputfile', + metavar='FILENAME', default=None, type='string', + help=_("""\ +Instead of configuring the list, print out a mailing list's configuration +variables in a format suitable for input using this script. In this way, you +can easily capture the configuration settings for a particular list and +imprint those settings on another list. FILENAME is the file to output the +settings to. If FILENAME is `-', standard out is used. + +This may not be used with the -i option.""")) + parser.add_option('-c', '--checkonly', + default=False, action='store_true', help=_("""\ +With this option, the modified list is not actually changed. This is only +useful with the -i option.""")) + parser.add_option('-v', '--verbose', + default=False, action='store_true', help=_("""\ +Print the name of each attribute as it is being changed. This is only useful +with the -i option.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if len(args) > 1: + parser.print_help() + parser.error(_('Unexpected arguments')) + if not args: + parser.error(_('List name is required')) + return parser, opts, args + + + +def do_output(listname, outfile, parser): + closep = False + try: + if outfile == '-': + outfp = sys.stdout + else: + outfp = open(outfile, 'w') + closep = True + # Open the specified list unlocked, since we're only reading it. + try: + mlist = MailList.MailList(listname, lock=False) + except errors.MMListError: + parser.error(_('No such list: $listname')) + # Preamble for the config info. PEP 263 charset and capture time. + language = mlist.preferred_language + charset = Utils.GetCharSet(language) + i18n.set_language(language) + if not charset: + charset = 'us-ascii' + when = time.ctime(time.time()) + print >> outfp, _('''\ +# -*- python -*- +# -*- coding: $charset -*- +## "$listname" mailing list configuration settings +## captured on $when +''') + # Get all the list config info. All this stuff is accessible via the + # web interface. + for k in config.ADMIN_CATEGORIES: + subcats = mlist.GetConfigSubCategories(k) + if subcats is None: + do_list_categories(mlist, k, None, outfp) + else: + for subcat in [t[0] for t in subcats]: + do_list_categories(mlist, k, subcat, outfp) + finally: + if closep: + outfp.close() + + + +def do_list_categories(mlist, k, subcat, outfp): + info = mlist.GetConfigInfo(k, subcat) + label, gui = mlist.GetConfigCategories()[k] + if info is None: + return + charset = Utils.GetCharSet(mlist.preferred_language) + print >> outfp, '##', k.capitalize(), _('options') + print >> outfp, '#' + # First, massage the descripton text, which could have obnoxious + # leading whitespace on second and subsequent lines due to + # triple-quoted string nonsense in the source code. + desc = NL.join([s.lstrip() for s in info[0].splitlines()]) + # Print out the category description + desc = Utils.wrap(desc) + for line in desc.splitlines(): + print >> outfp, '#', line + print >> outfp + for data in info[1:]: + if not isinstance(data, tuple): + continue + varname = data[0] + # Variable could be volatile + if varname[0] == '_': + continue + vtype = data[1] + # First, massage the descripton text, which could have + # obnoxious leading whitespace on second and subsequent lines + # due to triple-quoted string nonsense in the source code. + desc = NL.join([s.lstrip() for s in data[-1].splitlines()]) + # Now strip out all HTML tags + desc = re.sub('<.*?>', '', desc) + # And convert </> to <> + desc = re.sub('<', '<', desc) + desc = re.sub('>', '>', desc) + # Print out the variable description. + desc = Utils.wrap(desc) + for line in desc.split('\n'): + print >> outfp, '#', line + # munge the value based on its type + value = None + if hasattr(gui, 'getValue'): + value = gui.getValue(mlist, vtype, varname, data[2]) + if value is None and not varname.startswith('_'): + value = getattr(mlist, varname) + if vtype in (config.String, config.Text, config.FileUpload): + print >> outfp, varname, '=', + lines = value.splitlines() + if not lines: + print >> outfp, "''" + elif len(lines) == 1: + if charset <> 'us-ascii' and nonasciipat.search(lines[0]): + # This is more readable for non-english list. + print >> outfp, '"' + lines[0].replace('"', '\\"') + '"' + else: + print >> outfp, repr(lines[0]) + else: + if charset == 'us-ascii' and nonasciipat.search(value): + # Normally, an english list should not have non-ascii char. + print >> outfp, repr(NL.join(lines)) + else: + outfp.write(' """') + outfp.write(NL.join(lines).replace('"', '\\"')) + outfp.write('"""\n') + elif vtype in (config.Radio, config.Toggle): + print >> outfp, '#' + print >> outfp, '#', _('legal values are:') + # TBD: This is disgusting, but it's special cased + # everywhere else anyway... + if varname == 'subscribe_policy' and \ + not config.ALLOW_OPEN_SUBSCRIBE: + i = 1 + else: + i = 0 + for choice in data[2]: + print >> outfp, '# ', i, '= "%s"' % choice + i += 1 + print >> outfp, varname, '=', repr(value) + else: + print >> outfp, varname, '=', repr(value) + print >> outfp + + + +def getPropertyMap(mlist): + guibyprop = {} + categories = mlist.GetConfigCategories() + for category, (label, gui) in categories.items(): + if not hasattr(gui, 'GetConfigInfo'): + continue + subcats = mlist.GetConfigSubCategories(category) + if subcats is None: + subcats = [(None, None)] + for subcat, sclabel in subcats: + for element in gui.GetConfigInfo(mlist, category, subcat): + if not isinstance(element, tuple): + continue + propname = element[0] + wtype = element[1] + guibyprop[propname] = (gui, wtype) + return guibyprop + + +class FakeDoc: + # Fake the error reporting API for the htmlformat.Document class + def addError(self, s, tag=None, *args): + if tag: + print >> sys.stderr, tag + print >> sys.stderr, s % args + + def set_language(self, val): + pass + + + +def do_input(listname, infile, checkonly, verbose, parser): + fakedoc = FakeDoc() + # Open the specified list locked, unless checkonly is set + try: + mlist = MailList.MailList(listname, lock=not checkonly) + except errors.MMListError, e: + parser.error(_('No such list "$listname"\n$e')) + savelist = False + guibyprop = getPropertyMap(mlist) + try: + globals = {'mlist': mlist} + # Any exception that occurs in execfile() will cause the list to not + # be saved, but any other problems are not save-fatal. + execfile(infile, globals) + savelist = True + for k, v in globals.items(): + if k in ('mlist', '__builtins__'): + continue + if not hasattr(mlist, k): + print >> sys.stderr, _('attribute "$k" ignored') + continue + if verbose: + print >> sys.stderr, _('attribute "$k" changed') + missing = [] + gui, wtype = guibyprop.get(k, (missing, missing)) + if gui is missing: + # This isn't an official property of the list, but that's + # okay, we'll just restore it the old fashioned way + print >> sys.stderr, _('Non-standard property restored: $k') + setattr(mlist, k, v) + else: + # BAW: This uses non-public methods. This logic taken from + # the guts of GUIBase.handleForm(). + try: + validval = gui._getValidValue(mlist, k, wtype, v) + except ValueError: + print >> sys.stderr, _('Invalid value for property: $k') + except errors.EmailAddressError: + print >> sys.stderr, _( + 'Bad email address for option $k: $v') + else: + # BAW: Horrible hack, but then this is special cased + # everywhere anyway. :( Privacy._setValue() knows that + # when ALLOW_OPEN_SUBSCRIBE is false, the web values are + # 0, 1, 2 but these really should be 1, 2, 3, so it adds + # one. But we really do provide [0..3] so we need to undo + # the hack that _setValue adds. :( :( + if k == 'subscribe_policy' and \ + not config.ALLOW_OPEN_SUBSCRIBE: + validval -= 1 + # BAW: Another horrible hack. This one is just too hard + # to fix in a principled way in Mailman 2.1 + elif k == 'new_member_options': + # Because this is a Checkbox, _getValidValue() + # transforms the value into a list of one item. + validval = validval[0] + validval = [bitfield for bitfield, bitval + in config.OPTINFO.items() + if validval & bitval] + gui._setValue(mlist, k, validval, fakedoc) + # BAW: when to do gui._postValidate()??? + finally: + if savelist and not checkonly: + mlist.Save() + mlist.Unlock() + + + +def main(): + parser, opts, args = parseargs() + config.load(opts.config) + listname = args[0] + + # Sanity check + if opts.inputfile and opts.outputfile: + parser.error(_('Only one of -i or -o is allowed')) + if not opts.inputfile and not opts.outputfile: + parser.error(_('One of -i or -o is required')) + + if opts.outputfile: + do_output(listname, opts.outputfile, parser) + else: + do_input(listname, opts.inputfile, opts.checkonly, + opts.verbose, parser) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/create_list.py b/src/mailman/bin/create_list.py new file mode 100644 index 000000000..8058a7d67 --- /dev/null +++ b/src/mailman/bin/create_list.py @@ -0,0 +1,129 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import sys + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman.app.lifecycle import create_list +from mailman.config import config +from mailman.core import errors +from mailman.interfaces.listmanager import ListAlreadyExistsError +from mailman.options import SingleMailingListOptions + + +_ = i18n._ + + + +class ScriptOptions(SingleMailingListOptions): + usage = _("""\ +%prog [options] + +Create a new mailing list. + +fqdn_listname is the 'fully qualified list name', basically the posting +address of the list. It must be a valid email address and the domain must be +registered with Mailman. + +Note that listnames are forced to lowercase.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '--language', + type='unicode', action='store', + help=_("""\ +Make the list's preferred language LANGUAGE, which must be a two letter +language code.""")) + self.parser.add_option( + '-o', '--owner', + type='unicode', action='append', default=[], + dest='owners', help=_("""\ +Specific a listowner email address. If the address is not currently +registered with Mailman, the address is registered and linked to a user. +Mailman will send a confirmation message to the address, but it will also send +a list creation notice to the address. More than one owner can be +specified.""")) + self.parser.add_option( + '-q', '--quiet', + default=False, action='store_true', + help=_("""\ +Normally the administrator is notified by email (after a prompt) that their +list has been created. This option suppresses the prompt and +notification.""")) + self.parser.add_option( + '-a', '--automate', + default=False, action='store_true', + help=_("""\ +This option suppresses the prompt prior to administrator notification but +still sends the notification. It can be used to make newlist totally +non-interactive but still send the notification, assuming at least one list +owner is specified with the -o option..""")) + + def sanity_check(self): + """Set up some defaults we couldn't set up earlier.""" + if self.options.language is None: + self.options.language = unicode(config.mailman.default_language) + # Is the language known? + if self.options.language not in config.languages.enabled_codes: + self.parser.error(_('Unknown language: $opts.language')) + # Handle variable number of positional arguments + if len(self.arguments) > 0: + parser.error(_('Unexpected arguments')) + + + +def main(): + options = ScriptOptions() + options.initialize() + + # Create the mailing list, applying styles as appropriate. + fqdn_listname = options.options.listname + if fqdn_listname is None: + options.parser.error(_('--listname is required')) + try: + mlist = create_list(fqdn_listname, options.options.owners) + mlist.preferred_language = options.options.language + except errors.InvalidEmailAddress: + options.parser.error(_('Illegal list name: $fqdn_listname')) + except ListAlreadyExistsError: + options.parser.error(_('List already exists: $fqdn_listname')) + except errors.BadDomainSpecificationError, domain: + options.parser.error(_('Undefined domain: $domain')) + + config.db.commit() + + if not options.options.quiet: + d = dict( + listname = mlist.fqdn_listname, + admin_url = mlist.script_url('admin'), + listinfo_url = mlist.script_url('listinfo'), + requestaddr = mlist.request_address, + siteowner = mlist.no_reply_address, + ) + text = Utils.maketext('newlist.txt', d, mlist=mlist) + # Set the I18N language to the list's preferred language so the header + # will match the template language. Stashing and restoring the old + # translation context is just (healthy? :) paranoia. + with i18n.using_language(mlist.preferred_language): + msg = Message.UserNotification( + owner_mail, mlist.no_reply_address, + _('Your new mailing list: $fqdn_listname'), + text, mlist.preferred_language) + msg.send(mlist) diff --git a/src/mailman/bin/disabled.py b/src/mailman/bin/disabled.py new file mode 100644 index 000000000..cc8eb2c69 --- /dev/null +++ b/src/mailman/bin/disabled.py @@ -0,0 +1,201 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import time +import logging +import optparse + +from mailman import errors +from mailman import MailList +from mailman import MemberAdaptor +from mailman import Pending +from mailman import loginit +from mailman.Bouncer import _BounceInfo +from mailman.configuration import config +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +# Work around known problems with some RedHat cron daemons +import signal +signal.signal(signal.SIGCHLD, signal.SIG_DFL) + +ALL = (MemberAdaptor.BYBOUNCE, + MemberAdaptor.BYADMIN, + MemberAdaptor.BYUSER, + MemberAdaptor.UNKNOWN, + ) + + + +def who_callback(option, opt, value, parser): + dest = getattr(parser.values, option.dest) + if opt in ('-o', '--byadmin'): + dest.add(MemberAdaptor.BYADMIN) + elif opt in ('-m', '--byuser'): + dest.add(MemberAdaptor.BYUSER) + elif opt in ('-u', '--unknown'): + dest.add(MemberAdaptor.UNKNOWN) + elif opt in ('-b', '--notbybounce'): + dest.discard(MemberAdaptor.BYBOUNCE) + elif opt in ('-a', '--all'): + dest.update(ALL) + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Process disabled members, recommended once per day. + +This script iterates through every mailing list looking for members whose +delivery is disabled. If they have been disabled due to bounces, they will +receive another notification, or they may be removed if they've received the +maximum number of notifications. + +Use the --byadmin, --byuser, and --unknown flags to also send notifications to +members whose accounts have been disabled for those reasons. Use --all to +send the notification to all disabled members.""")) + # This is the set of working flags for who to send notifications to. By + # default, we notify anybody who has been disable due to bounces. + parser.set_defaults(who=set([MemberAdaptor.BYBOUNCE])) + parser.add_option('-o', '--byadmin', + callback=who_callback, action='callback', dest='who', + help=_("""\ +Also send notifications to any member disabled by the list +owner/administrator.""")) + parser.add_option('-m', '--byuser', + callback=who_callback, action='callback', dest='who', + help=_("""\ +Also send notifications to any member who has disabled themself.""")) + parser.add_option('-u', '--unknown', + callback=who_callback, action='callback', dest='who', + help=_("""\ +Also send notifications to any member disabled for unknown reasons +(usually a legacy disabled address).""")) + parser.add_option('-b', '--notbybounce', + callback=who_callback, action='callback', dest='who', + help=_("""\ +Don't send notifications to members disabled because of bounces (the +default is to notify bounce disabled members).""")) + parser.add_option('-a', '--all', + callback=who_callback, action='callback', dest='who', + help=_('Send notifications to all disabled members')) + parser.add_option('-f', '--force', + default=False, action='store_true', + help=_("""\ +Send notifications to disabled members even if they're not due a new +notification yet.""")) + parser.add_option('-l', '--listname', + dest='listnames', action='append', default=[], + type='string', help=_("""\ +Process only the given list, otherwise do all lists.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + return opts, args, parser + + + +def main(): + opts, args, parser = parseargs() + config.load(opts.config) + + loginit.initialize(propagate=True) + elog = logging.getLogger('mailman.error') + blog = logging.getLogger('mailman.bounce') + + listnames = set(opts.listnames or config.list_manager.names) + who = tuple(opts.who) + + msg = _('[disabled by periodic sweep and cull, no message available]') + today = time.mktime(time.localtime()[:3] + (0,) * 6) + for listname in listnames: + # List of members to notify + notify = [] + mlist = MailList.MailList(listname) + try: + interval = mlist.bounce_you_are_disabled_warnings_interval + # Find all the members who are currently bouncing and see if + # they've reached the disable threshold but haven't yet been + # disabled. This is a sweep through the membership catching + # situations where they've bounced a bunch, then the list admin + # lowered the threshold, but we haven't (yet) seen more bounces + # from the member. Note: we won't worry about stale information + # or anything else since the normal bounce processing code will + # handle that. + disables = [] + for member in mlist.getBouncingMembers(): + if mlist.getDeliveryStatus(member) <> MemberAdaptor.ENABLED: + continue + info = mlist.getBounceInfo(member) + if info.score >= mlist.bounce_score_threshold: + disables.append((member, info)) + if disables: + for member, info in disables: + mlist.disableBouncingMember(member, info, msg) + # Go through all the members who have delivery disabled, and find + # those that are due to have another notification. If they are + # disabled for another reason than bouncing, and we're processing + # them (because of the command line switch) then they won't have a + # bounce info record. We can piggyback on that for all disable + # purposes. + members = mlist.getDeliveryStatusMembers(who) + for member in members: + info = mlist.getBounceInfo(member) + if not info: + # See if they are bounce disabled, or disabled for some + # other reason. + status = mlist.getDeliveryStatus(member) + if status == MemberAdaptor.BYBOUNCE: + elog.error( + '%s disabled BYBOUNCE lacks bounce info, list: %s', + member, mlist.internal_name()) + continue + info = _BounceInfo( + member, 0, today, + mlist.bounce_you_are_disabled_warnings, + mlist.pend_new(Pending.RE_ENABLE, + mlist.internal_name(), + member)) + mlist.setBounceInfo(member, info) + lastnotice = time.mktime(info.lastnotice + (0,) * 6) + if opts.force or today >= lastnotice + interval: + notify.append(member) + # Now, send notifications to anyone who is due + for member in notify: + blog.info('Notifying disabled member %s for list: %s', + member, mlist.internal_name()) + try: + mlist.sendNextNotification(member) + except errors.NotAMemberError: + # There must have been some problem with the data we have + # on this member. Most likely it's that they don't have a + # password assigned. Log this and delete the member. + blog.info( + 'NotAMemberError when sending disabled notice: %s', + member) + mlist.ApprovedDeleteMember(member, 'cron/disabled') + mlist.Save() + finally: + mlist.Unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/docs/master.txt b/src/mailman/bin/docs/master.txt new file mode 100644 index 000000000..0d3cade77 --- /dev/null +++ b/src/mailman/bin/docs/master.txt @@ -0,0 +1,49 @@ +Mailman queue runner control +============================ + +Mailman has a number of queue runners which process messages in its queue file +directories. In normal operation, a command line script called 'mailmanctl' +is used to start, stop and manage the queue runners. mailmanctl actually is +just a wrapper around the real queue runner watcher script called master.py. + + >>> from mailman.testing.helpers import TestableMaster + +Start the master in a subthread. + + >>> master = TestableMaster() + >>> master.start() + +There should be a process id for every qrunner that claims to be startable. + + >>> from lazr.config import as_boolean + >>> startable_qrunners = [qconf for qconf in config.qrunner_configs + ... if as_boolean(qconf.start)] + >>> len(list(master.qrunner_pids)) == len(startable_qrunners) + True + +Now verify that all the qrunners are running. + + >>> import os + + # This should produce no output. + >>> for pid in master.qrunner_pids: + ... os.kill(pid, 0) + +Stop the master process, which should also kill (and not restart) the child +queue runner processes. + + >>> master.stop() + +None of the children are running now. + + >>> import errno + >>> for pid in master.qrunner_pids: + ... try: + ... os.kill(pid, 0) + ... print 'Process did not exit:', pid + ... except OSError, error: + ... if error.errno == errno.ESRCH: + ... # The child process exited. + ... pass + ... else: + ... raise diff --git a/src/mailman/bin/dumpdb.py b/src/mailman/bin/dumpdb.py new file mode 100644 index 000000000..6657602e4 --- /dev/null +++ b/src/mailman/bin/dumpdb.py @@ -0,0 +1,88 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import pprint +import cPickle + +from mailman.config import config +from mailman.i18n import _ +from mailman.interact import interact +from mailman.options import Options + + +COMMASPACE = ', ' +m = [] + + + +class ScriptOptions(Options): + usage=_("""\ +%prog [options] filename + +Dump the contents of any Mailman queue file. The queue file is a data file +with multiple Python pickles in it.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-n', '--noprint', + dest='doprint', default=True, action='store_false', + help=_("""\ +Don't attempt to pretty print the object. This is useful if there is some +problem with the object and you just want to get an unpickled representation. +Useful with 'bin/dumpdb -i <file>'. In that case, the list of +unpickled objects will be left in a variable called 'm'.""")) + self.parser.add_option( + '-i', '--interact', + default=False, action='store_true', + help=_("""\ +Start an interactive Python session, with a variable called 'm' containing the +list of unpickled objects.""")) + + def sanity_check(self): + if len(self.arguments) < 1: + self.parser.error(_('No filename given.')) + elif len(self.arguments) > 1: + self.parser.error(_('Unexpected arguments')) + else: + self.filename = self.arguments[0] + + + +def main(): + options = ScriptOptions() + options.initialize() + + pp = pprint.PrettyPrinter(indent=4) + with open(options.filename) as fp: + while True: + try: + m.append(cPickle.load(fp)) + except EOFError: + break + if options.options.doprint: + print _('[----- start pickle -----]') + for i, obj in enumerate(m): + count = i + 1 + print _('<----- start object $count ----->') + if isinstance(obj, basestring): + print obj + else: + pp.pprint(obj) + print _('[----- end pickle -----]') + if options.options.interact: + interact() diff --git a/src/mailman/bin/export.py b/src/mailman/bin/export.py new file mode 100644 index 000000000..d1992b4b4 --- /dev/null +++ b/src/mailman/bin/export.py @@ -0,0 +1,310 @@ +# Copyright (C) 2006-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Export an XML representation of a mailing list.""" + +import sys +import codecs +import datetime +import optparse + +from xml.sax.saxutils import escape + +from mailman import Defaults +from mailman import errors +from mailman import MemberAdaptor +from mailman.MailList import MailList +from mailman.configuration import config +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + + +SPACE = ' ' + +TYPES = { + Defaults.Toggle : 'bool', + Defaults.Radio : 'radio', + Defaults.String : 'string', + Defaults.Text : 'text', + Defaults.Email : 'email', + Defaults.EmailList : 'email_list', + Defaults.Host : 'host', + Defaults.Number : 'number', + Defaults.FileUpload : 'upload', + Defaults.Select : 'select', + Defaults.Topics : 'topics', + Defaults.Checkbox : 'checkbox', + Defaults.EmailListEx : 'email_list_ex', + Defaults.HeaderFilter : 'header_filter', + } + + + +class Indenter: + def __init__(self, fp, indentwidth=4): + self._fp = fp + self._indent = 0 + self._width = indentwidth + + def indent(self): + self._indent += 1 + + def dedent(self): + self._indent -= 1 + assert self._indent >= 0 + + def write(self, s): + if s <> '\n': + self._fp.write(self._indent * self._width * ' ') + self._fp.write(s) + + + +class XMLDumper(object): + def __init__(self, fp): + self._fp = Indenter(fp) + self._tagbuffer = None + self._stack = [] + + def _makeattrs(self, tagattrs): + # The attribute values might contain angle brackets. They might also + # be None. + attrs = [] + for k, v in tagattrs.items(): + if v is None: + v = '' + else: + v = escape(str(v)) + attrs.append('%s="%s"' % (k, v)) + return SPACE.join(attrs) + + def _flush(self, more=True): + if not self._tagbuffer: + return + name, attributes = self._tagbuffer + self._tagbuffer = None + if attributes: + attrstr = ' ' + self._makeattrs(attributes) + else: + attrstr = '' + if more: + print >> self._fp, '<%s%s>' % (name, attrstr) + self._fp.indent() + self._stack.append(name) + else: + print >> self._fp, '<%s%s/>' % (name, attrstr) + + # Use this method when you know you have sub-elements. + def _push_element(self, _name, **_tagattrs): + self._flush() + self._tagbuffer = (_name, _tagattrs) + + def _pop_element(self, _name): + buffered = bool(self._tagbuffer) + self._flush(more=False) + if not buffered: + name = self._stack.pop() + assert name == _name, 'got: %s, expected: %s' % (_name, name) + self._fp.dedent() + print >> self._fp, '</%s>' % name + + # Use this method when you do not have sub-elements + def _element(self, _name, _value=None, **_attributes): + self._flush() + if _attributes: + attrs = ' ' + self._makeattrs(_attributes) + else: + attrs = '' + if _value is None: + print >> self._fp, '<%s%s/>' % (_name, attrs) + else: + # The value might contain angle brackets. + value = escape(unicode(_value)) + print >> self._fp, '<%s%s>%s</%s>' % (_name, attrs, value, _name) + + def _do_list_categories(self, mlist, k, subcat=None): + info = mlist.GetConfigInfo(k, subcat) + label, gui = mlist.GetConfigCategories()[k] + if info is None: + return + for data in info[1:]: + if not isinstance(data, tuple): + continue + varname = data[0] + # Variable could be volatile + if varname.startswith('_'): + continue + vtype = data[1] + # Munge the value based on its type + value = None + if hasattr(gui, 'getValue'): + value = gui.getValue(mlist, vtype, varname, data[2]) + if value is None: + value = getattr(mlist, varname) + widget_type = TYPES[vtype] + if isinstance(value, list): + self._push_element('option', name=varname, type=widget_type) + for v in value: + self._element('value', v) + self._pop_element('option') + else: + self._element('option', value, name=varname, type=widget_type) + + def _dump_list(self, mlist): + # Write list configuration values + self._push_element('list', name=mlist.fqdn_listname) + self._push_element('configuration') + self._element('option', + mlist.preferred_language, + name='preferred_language') + for k in config.ADMIN_CATEGORIES: + subcats = mlist.GetConfigSubCategories(k) + if subcats is None: + self._do_list_categories(mlist, k) + else: + for subcat in [t[0] for t in subcats]: + self._do_list_categories(mlist, k, subcat) + self._pop_element('configuration') + # Write membership + self._push_element('roster') + digesters = set(mlist.getDigestMemberKeys()) + for member in sorted(mlist.getMembers()): + attrs = dict(id=member) + cased = mlist.getMemberCPAddress(member) + if cased <> member: + attrs['original'] = cased + self._push_element('member', **attrs) + self._element('realname', mlist.getMemberName(member)) + self._element('password', mlist.getMemberPassword(member)) + self._element('language', mlist.getMemberLanguage(member)) + # Delivery status, combined with the type of delivery + attrs = {} + status = mlist.getDeliveryStatus(member) + if status == MemberAdaptor.ENABLED: + attrs['status'] = 'enabled' + else: + attrs['status'] = 'disabled' + attrs['reason'] = {MemberAdaptor.BYUSER : 'byuser', + MemberAdaptor.BYADMIN : 'byadmin', + MemberAdaptor.BYBOUNCE : 'bybounce', + }.get(mlist.getDeliveryStatus(member), + 'unknown') + if member in digesters: + if mlist.getMemberOption(member, Defaults.DisableMime): + attrs['delivery'] = 'plain' + else: + attrs['delivery'] = 'mime' + else: + attrs['delivery'] = 'regular' + changed = mlist.getDeliveryStatusChangeTime(member) + if changed: + when = datetime.datetime.fromtimestamp(changed) + attrs['changed'] = when.isoformat() + self._element('delivery', **attrs) + for option, flag in Defaults.OPTINFO.items(): + # Digest/Regular delivery flag must be handled separately + if option in ('digest', 'plain'): + continue + value = mlist.getMemberOption(member, flag) + self._element(option, value) + topics = mlist.getMemberTopics(member) + if not topics: + self._element('topics') + else: + self._push_element('topics') + for topic in topics: + self._element('topic', topic) + self._pop_element('topics') + self._pop_element('member') + self._pop_element('roster') + self._pop_element('list') + + def dump(self, listnames): + print >> self._fp, '<?xml version="1.0" encoding="UTF-8"?>' + self._push_element('mailman', **{ + 'xmlns:xsi': 'http://www.w3.org/2001/XMLSchema-instance', + 'xsi:noNamespaceSchemaLocation': 'ssi-1.0.xsd', + }) + for listname in sorted(listnames): + try: + mlist = MailList(listname, lock=False) + except errors.MMUnknownListError: + print >> sys.stderr, _('No such list: $listname') + continue + self._dump_list(mlist) + self._pop_element('mailman') + + def close(self): + while self._stack: + self._pop_element() + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Export the configuration and members of a mailing list in XML format.""")) + parser.add_option('-o', '--outputfile', + metavar='FILENAME', default=None, type='string', + help=_("""\ +Output XML to FILENAME. If not given, or if FILENAME is '-', standard out is +used.""")) + parser.add_option('-l', '--listname', + default=[], action='append', type='string', + metavar='LISTNAME', dest='listnames', help=_("""\ +The list to include in the output. If not given, then all mailing lists are +included in the XML output. Multiple -l flags may be given.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + parser.error(_('Unexpected arguments')) + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + + close = False + if opts.outputfile in (None, '-'): + writer = codecs.getwriter('utf-8') + fp = writer(sys.stdout) + else: + fp = codecs.open(opts.outputfile, 'w', 'utf-8') + close = True + + try: + dumper = XMLDumper(fp) + if opts.listnames: + listnames = [] + for listname in opts.listnames: + if '@' not in listname: + listname = '%s@%s' % (listname, config.DEFAULT_EMAIL_HOST) + listnames.append(listname) + else: + listnames = config.list_manager.names + dumper.dump(listnames) + dumper.close() + finally: + if close: + fp.close() diff --git a/src/mailman/bin/find_member.py b/src/mailman/bin/find_member.py new file mode 100644 index 000000000..0982724a0 --- /dev/null +++ b/src/mailman/bin/find_member.py @@ -0,0 +1,135 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import re +import sys +import optparse + +from mailman import errors +from mailman import MailList +from mailman.configuration import config +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +AS_MEMBER = 0x01 +AS_OWNER = 0x02 + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] regex [regex ...] + +Find all lists that a member's address is on. + +The interaction between -l and -x (see below) is as follows. If any -l option +is given then only the named list will be included in the search. If any -x +option is given but no -l option is given, then all lists will be search +except those specifically excluded. + +Regular expression syntax uses the Python 're' module. Complete +specifications are at: + +http://www.python.org/doc/current/lib/module-re.html + +Address matches are case-insensitive, but case-preserved addresses are +displayed.""")) + parser.add_option('-l', '--listname', + type='string', default=[], action='append', + dest='listnames', + help=_('Include only the named list in the search')) + parser.add_option('-x', '--exclude', + type='string', default=[], action='append', + dest='excludes', + help=_('Exclude the named list from the search')) + parser.add_option('-w', '--owners', + default=False, action='store_true', + help=_('Search list owners as well as members')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if not args: + parser.print_help() + print >> sys.stderr, _('Search regular expression required') + sys.exit(1) + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + config.load(opts.config) + + listnames = opts.listnames or config.list_manager.names + includes = set(listname.lower() for listname in listnames) + excludes = set(listname.lower() for listname in opts.excludes) + listnames = includes - excludes + + if not listnames: + print _('No lists to search') + return + + cres = [] + for r in args: + cres.append(re.compile(r, re.IGNORECASE)) + # dictionary of {address, (listname, ownerp)} + matches = {} + for listname in listnames: + try: + mlist = MailList.MailList(listname, lock=False) + except errors.MMListError: + print _('No such list: $listname') + continue + if opts.owners: + owners = mlist.owner + else: + owners = [] + for cre in cres: + for member in mlist.getMembers(): + if cre.search(member): + addr = mlist.getMemberCPAddress(member) + entries = matches.get(addr, {}) + aswhat = entries.get(listname, 0) + aswhat |= AS_MEMBER + entries[listname] = aswhat + matches[addr] = entries + for owner in owners: + if cre.search(owner): + entries = matches.get(owner, {}) + aswhat = entries.get(listname, 0) + aswhat |= AS_OWNER + entries[listname] = aswhat + matches[owner] = entries + addrs = matches.keys() + addrs.sort() + for k in addrs: + hits = matches[k] + lists = hits.keys() + print k, _('found in:') + for name in lists: + aswhat = hits[name] + if aswhat & AS_MEMBER: + print ' ', name + if aswhat & AS_OWNER: + print ' ', name, _('(as owner)') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/gate_news.py b/src/mailman/bin/gate_news.py new file mode 100644 index 000000000..eac30422d --- /dev/null +++ b/src/mailman/bin/gate_news.py @@ -0,0 +1,243 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys +import time +import socket +import logging +import nntplib +import optparse +import email.Errors + +from email.Parser import Parser +from locknix import lockfile + +from mailman import MailList +from mailman import Message +from mailman import Utils +from mailman import loginit +from mailman.configuration import config +from mailman.i18n import _ +from mailman.queue import Switchboard +from mailman.version import MAILMAN_VERSION + +# Work around known problems with some RedHat cron daemons +import signal +signal.signal(signal.SIGCHLD, signal.SIG_DFL) + +NL = '\n' + +log = None + +class _ContinueLoop(Exception): + pass + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Poll the NNTP servers for messages to be gatewayed to mailing lists.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return opts, args, parser + + + +_hostcache = {} + +def open_newsgroup(mlist): + # Split host:port if given + nntp_host, nntp_port = Utils.nntpsplit(mlist.nntp_host) + # Open up a "mode reader" connection to nntp server. This will be shared + # for all the gated lists having the same nntp_host. + conn = _hostcache.get(mlist.nntp_host) + if conn is None: + try: + conn = nntplib.NNTP(nntp_host, nntp_port, + readermode=True, + user=config.NNTP_USERNAME, + password=config.NNTP_PASSWORD) + except (socket.error, nntplib.NNTPError, IOError), e: + log.error('error opening connection to nntp_host: %s\n%s', + mlist.nntp_host, e) + raise + _hostcache[mlist.nntp_host] = conn + # Get the GROUP information for the list, but we're only really interested + # in the first article number and the last article number + r, c, f, l, n = conn.group(mlist.linked_newsgroup) + return conn, int(f), int(l) + + +def clearcache(): + for conn in set(_hostcache.values()): + conn.quit() + _hostcache.clear() + + + +# This function requires the list to be locked. +def poll_newsgroup(mlist, conn, first, last, glock): + listname = mlist.internal_name() + # NEWNEWS is not portable and has synchronization issues. + for num in range(first, last): + glock.refresh() + try: + headers = conn.head(repr(num))[3] + found_to = False + beenthere = False + for header in headers: + i = header.find(':') + value = header[:i].lower() + if i > 0 and value == 'to': + found_to = True + if value <> 'x-beenthere': + continue + if header[i:] == ': %s' % mlist.posting_address: + beenthere = True + break + if not beenthere: + body = conn.body(repr(num))[3] + # Usenet originated messages will not have a Unix envelope + # (i.e. "From " header). This breaks Pipermail archiving, so + # we will synthesize one. Be sure to use the format searched + # for by mailbox.UnixMailbox._isrealfromline(). BAW: We use + # the -bounces address here in case any downstream clients use + # the envelope sender for bounces; I'm not sure about this, + # but it's the closest to the old semantics. + lines = ['From %s %s' % (mlist.GetBouncesEmail(), + time.ctime(time.time()))] + lines.extend(headers) + lines.append('') + lines.extend(body) + lines.append('') + p = Parser(Message.Message) + try: + msg = p.parsestr(NL.join(lines)) + except email.Errors.MessageError, e: + log.error('email package exception for %s:%d\n%s', + mlist.linked_newsgroup, num, e) + raise _ContinueLoop + if found_to: + del msg['X-Originally-To'] + msg['X-Originally-To'] = msg['To'] + del msg['To'] + msg['To'] = mlist.posting_address + # Post the message to the locked list + inq = Switchboard(config.INQUEUE_DIR) + inq.enqueue(msg, + listname=mlist.internal_name(), + fromusenet=True) + log.info('posted to list %s: %7d', listname, num) + except nntplib.NNTPError, e: + log.exception('NNTP error for list %s: %7d', listname, num) + except _ContinueLoop: + continue + # Even if we don't post the message because it was seen on the + # list already, update the watermark + mlist.usenet_watermark = num + + + +def process_lists(glock): + for listname in config.list_manager.names: + glock.refresh() + # Open the list unlocked just to check to see if it is gating news to + # mail. If not, we're done with the list. Otherwise, lock the list + # and gate the group. + mlist = MailList.MailList(listname, lock=False) + if not mlist.gateway_to_mail: + continue + # Get the list's watermark, i.e. the last article number that we gated + # from news to mail. None means that this list has never polled its + # newsgroup and that we should do a catch up. + watermark = getattr(mlist, 'usenet_watermark', None) + # Open the newsgroup, but let most exceptions percolate up. + try: + conn, first, last = open_newsgroup(mlist) + except (socket.error, nntplib.NNTPError): + break + log.info('%s: [%d..%d]', listname, first, last) + try: + try: + if watermark is None: + mlist.Lock(timeout=config.LIST_LOCK_TIMEOUT) + # This is the first time we've tried to gate this + # newsgroup. We essentially do a mass catch-up, otherwise + # we'd flood the mailing list. + mlist.usenet_watermark = last + log.info('%s caught up to article %d', listname, last) + else: + # The list has been polled previously, so now we simply + # grab all the messages on the newsgroup that have not + # been seen by the mailing list. The first such article + # is the maximum of the lowest article available in the + # newsgroup and the watermark. It's possible that some + # articles have been expired since the last time gate_news + # has run. Not much we can do about that. + start = max(watermark + 1, first) + if start > last: + log.info('nothing new for list %s', listname) + else: + mlist.Lock(timeout=config.LIST_LOCK_TIMEOUT) + log.info('gating %s articles [%d..%d]', + listname, start, last) + # Use last+1 because poll_newsgroup() employes a for + # loop over range, and this will not include the last + # element in the list. + poll_newsgroup(mlist, conn, start, last + 1, glock) + except lockfile.TimeOutError: + log.error('Could not acquire list lock: %s', listname) + finally: + if mlist.Locked(): + mlist.Save() + mlist.Unlock() + log.info('%s watermark: %d', listname, mlist.usenet_watermark) + + + +def main(): + opts, args, parser = parseargs() + config.load(opts.config) + + GATENEWS_LOCK_FILE = os.path.join(config.LOCK_DIR, 'gate_news.lock') + LOCK_LIFETIME = config.hours(2) + + loginit.initialize(propagate=True) + log = logging.getLogger('mailman.fromusenet') + + try: + with lockfile.Lock(GATENEWS_LOCK_FILE, + # It's okay to hijack this + lifetime=LOCK_LIFETIME) as lock: + process_lists(lock) + clearcache() + except lockfile.TimeOutError: + log.error('Could not acquire gate_news lock') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/genaliases.py b/src/mailman/bin/genaliases.py new file mode 100644 index 000000000..e8916d030 --- /dev/null +++ b/src/mailman/bin/genaliases.py @@ -0,0 +1,64 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +__metaclass__ = type +__all__ = [ + 'main', + ] + + +import sys + +from mailman.config import config +from mailman.i18n import _ +from mailman.options import Options + + + +class ScriptOptions(Options): + """Options for the genaliases script.""" + + usage = _("""\ +%prog [options] + +Regenerate the Mailman specific MTA aliases from scratch. The actual output +depends on the value of the 'MTA' variable in your etc/mailman.cfg file.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-q', '--quiet', + default=False, action='store_true', help=_("""\ +Some MTA output can include more verbose help text. Use this to tone down the +verbosity.""")) + + + + +def main(): + options = ScriptOptions() + options.initialize() + + # Get the MTA-specific module. + module_path, class_path = config.mta.incoming.rsplit('.', 1) + __import__(module_path) + getattr(sys.modules[module_path], class_path)().regenerate() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/import.py b/src/mailman/bin/import.py new file mode 100644 index 000000000..d2361e808 --- /dev/null +++ b/src/mailman/bin/import.py @@ -0,0 +1,315 @@ +# Copyright (C) 2006-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Import the XML representation of a mailing list.""" + +import sys +import codecs +import optparse +import traceback + +from xml.dom import minidom +from xml.parsers.expat import ExpatError + +from mailman import Defaults +from mailman import errors +from mailman import MemberAdaptor +from mailman import Utils +from mailman import passwords +from mailman.MailList import MailList +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + + +OPTS = None + + + +def nodetext(node): + # Expect only one TEXT_NODE in the list of children + for child in node.childNodes: + if child.nodeType == node.TEXT_NODE: + return child.data + return u'' + + +def nodegen(node, *elements): + for child in node.childNodes: + if child.nodeType <> minidom.Node.ELEMENT_NODE: + continue + if elements and child.tagName not in elements: + print _('Ignoring unexpected element: $node.tagName') + else: + yield child + + + +def parse_config(node): + config = dict() + for child in nodegen(node, 'option'): + name = child.getAttribute('name') + if not name: + print _('Skipping unnamed option') + continue + vtype = child.getAttribute('type') or 'string' + if vtype in ('email_list', 'email_list_ex', 'checkbox'): + value = [] + for subnode in nodegen(child): + value.append(nodetext(subnode)) + elif vtype == 'bool': + value = nodetext(child) + try: + value = bool(int(value)) + except ValueError: + value = {'true' : True, + 'false': False, + }.get(value.lower()) + if value is None: + print _('Skipping bad boolean value: $value') + continue + elif vtype == 'radio': + value = nodetext(child).lower() + boolval = {'true' : True, + 'false': False, + }.get(value) + if boolval is None: + value = int(value) + else: + value = boolval + elif vtype == 'number': + value = nodetext(child) + # First try int then float + try: + value = int(value) + except ValueError: + value = float(value) + elif vtype in ('header_filter', 'topics'): + value = [] + fakebltins = dict(__builtins__ = dict(True=True, False=False)) + for subnode in nodegen(child): + reprstr = nodetext(subnode) + # Turn the reprs back into tuples, in a safe way + tupleval = eval(reprstr, fakebltins) + value.append(tupleval) + else: + value = nodetext(child) + # And now some special casing :( + if name == 'new_member_options': + value = int(nodetext(child)) + config[name] = value + return config + + + + +def parse_roster(node): + members = [] + for child in nodegen(node, 'member'): + member = dict() + member['id'] = mid = child.getAttribute('id') + if not mid: + print _('Skipping member with no id') + continue + if OPTS.verbose: + print _('* Processing member: $mid') + for subnode in nodegen(child): + attr = subnode.tagName + if attr == 'delivery': + value = (subnode.getAttribute('status'), + subnode.getAttribute('delivery')) + elif attr in ('hide', 'ack', 'notmetoo', 'nodupes', 'nomail'): + value = {'true' : True, + 'false': False, + }.get(nodetext(subnode).lower(), False) + elif attr == 'topics': + value = [] + for subsubnode in nodegen(subnode): + value.append(nodetext(subsubnode)) + elif attr == 'password': + value = nodetext(subnode) + if OPTS.reset_passwords or value == '{NONE}' or not value: + value = passwords.make_secret(Utils.MakeRandomPassword()) + else: + value = nodetext(subnode) + member[attr] = value + members.append(member) + return members + + + +def load(fp): + try: + doc = minidom.parse(fp) + except ExpatError: + print _('Expat error in file: $fp.name') + traceback.print_exc() + sys.exit(1) + doc.normalize() + # Make sure there's only one top-level <mailman> node + gen = nodegen(doc, 'mailman') + top = gen.next() + try: + gen.next() + except StopIteration: + pass + else: + print _('Malformed XML; duplicate <mailman> nodes') + sys.exit(1) + all_listdata = [] + for listnode in nodegen(top, 'list'): + listdata = dict() + name = listnode.getAttribute('name') + if OPTS.verbose: + print _('Processing list: $name') + if not name: + print _('Ignoring malformed <list> node') + continue + for child in nodegen(listnode, 'configuration', 'roster'): + if child.tagName == 'configuration': + list_config = parse_config(child) + else: + assert(child.tagName == 'roster') + list_roster = parse_roster(child) + all_listdata.append((name, list_config, list_roster)) + return all_listdata + + + +def create(all_listdata): + for name, list_config, list_roster in all_listdata: + fqdn_listname = '%s@%s' % (name, list_config['host_name']) + if Utils.list_exists(fqdn_listname): + print _('Skipping already existing list: $fqdn_listname') + continue + mlist = MailList() + try: + if OPTS.verbose: + print _('Creating mailing list: $fqdn_listname') + mlist.Create(fqdn_listname, list_config['owner'][0], + list_config['password']) + except errors.BadDomainSpecificationError: + print _('List is not in a supported domain: $fqdn_listname') + continue + # Save the list creation, then unlock and relock the list. This is so + # that we use normal SQLAlchemy transactions to manage all the + # attribute and membership updates. Without this, no transaction will + # get committed in the second Save() below and we'll lose all our + # updates. + mlist.Save() + mlist.Unlock() + mlist.Lock() + try: + for option, value in list_config.items(): + # XXX Here's what sucks. Some properties need to have + # _setValue() called on the gui component, because those + # methods do some pre-processing on the values before they're + # applied to the MailList instance. But we don't have a good + # way to find a category and sub-category that a particular + # property belongs to. Plus this will probably change. So + # for now, we'll just hard code the extra post-processing + # here. The good news is that not all _setValue() munging + # needs to be done -- for example, we've already converted + # everything to dollar strings. + if option in ('filter_mime_types', 'pass_mime_types', + 'filter_filename_extensions', + 'pass_filename_extensions'): + value = value.splitlines() + if option == 'available_languages': + mlist.set_languages(*value) + else: + setattr(mlist, option, value) + for member in list_roster: + mid = member['id'] + if OPTS.verbose: + print _('* Adding member: $mid') + status, delivery = member['delivery'] + kws = {'password' : member['password'], + 'language' : member['language'], + 'realname' : member['realname'], + 'digest' : delivery <> 'regular', + } + mlist.addNewMember(mid, **kws) + status = {'enabled' : MemberAdaptor.ENABLED, + 'byuser' : MemberAdaptor.BYUSER, + 'byadmin' : MemberAdaptor.BYADMIN, + 'bybounce' : MemberAdaptor.BYBOUNCE, + }.get(status, MemberAdaptor.UNKNOWN) + mlist.setDeliveryStatus(mid, status) + for opt in ('hide', 'ack', 'notmetoo', 'nodupes', 'nomail'): + mlist.setMemberOption(mid, + Defaults.OPTINFO[opt], + member[opt]) + topics = member.get('topics') + if topics: + mlist.setMemberTopics(mid, topics) + mlist.Save() + finally: + mlist.Unlock() + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Import the configuration and/or members of a mailing list in XML format. The +imported mailing list must not already exist. All mailing lists named in the +XML file are imported, but those that already exist are skipped unless --error +is given.""")) + parser.add_option('-i', '--inputfile', + metavar='FILENAME', default=None, type='string', + help=_("""\ +Input XML from FILENAME. If not given, or if FILENAME is '-', standard input +is used.""")) + parser.add_option('-p', '--reset-passwords', + default=False, action='store_true', help=_("""\ +With this option, user passwords in the XML are ignored and are reset to a +random password. If the generated passwords were not included in the input +XML, they will always be randomly generated.""")) + parser.add_option('-v', '--verbose', + default=False, action='store_true', + help=_('Produce more verbose output')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + parser.error(_('Unexpected arguments')) + return parser, opts, args + + + +def main(): + global OPTS + + parser, opts, args = parseargs() + initialize(opts.config) + OPTS = opts + + if opts.inputfile in (None, '-'): + fp = sys.stdin + else: + fp = open(opts.inputfile, 'r') + + try: + listbags = load(fp) + create(listbags) + finally: + if fp is not sys.stdin: + fp.close() diff --git a/src/mailman/bin/inject.py b/src/mailman/bin/inject.py new file mode 100644 index 000000000..2bc8a49e3 --- /dev/null +++ b/src/mailman/bin/inject.py @@ -0,0 +1,89 @@ +# Copyright (C) 2002-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys + +from email import message_from_string + +from mailman import Utils +from mailman.Message import Message +from mailman.configuration import config +from mailman.i18n import _ +from mailman.inject import inject_text +from mailman.options import SingleMailingListOptions + + + +class ScriptOptions(SingleMailingListOptions): + usage=_("""\ +%prog [options] [filename] + +Inject a message from a file into Mailman's incoming queue. 'filename' is the +name of the plaintext message file to inject. If omitted, or the string '-', +standard input is used. +""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-q', '--queue', + type='string', help=_("""\ +The name of the queue to inject the message to. The queuename must be one of +the directories inside the qfiles directory. If omitted, the incoming queue +is used.""")) + + def sanity_check(self): + if not self.options.listname: + self.parser.error(_('Missing listname')) + if len(self.arguments) == 0: + self.filename = '-' + elif len(self.arguments) > 1: + self.parser.print_error(_('Unexpected arguments')) + else: + self.filename = self.arguments[0] + + + +def main(): + options = ScriptOptions() + options.initialize() + + if options.options.queue is None: + qdir = config.INQUEUE_DIR + else: + qdir = os.path.join(config.QUEUE_DIR, options.options.queue) + if not os.path.isdir(qdir): + options.parser.error(_('Bad queue directory: $qdir')) + + fqdn_listname = options.options.listname + mlist = config.db.list_manager.get(fqdn_listname) + if mlist is None: + options.parser.error(_('No such list: $fqdn_listname')) + + if options.filename == '-': + message_text = sys.stdin.read() + else: + with open(options.filename) as fp: + message_text = fp.read() + + inject_text(mlist, message_text, qdir=qdir) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/list_lists.py b/src/mailman/bin/list_lists.py new file mode 100644 index 000000000..ea1640910 --- /dev/null +++ b/src/mailman/bin/list_lists.py @@ -0,0 +1,104 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +from mailman.config import config +from mailman.i18n import _ +from mailman.options import Options + + + +class ScriptOptions(Options): + usage = _("""\ +%prog [options] + +List all mailing lists.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-a', '--advertised', + default=False, action='store_true', + help=_("""\ +List only those mailing lists that are publicly advertised""")) + self.parser.add_option( + '-b', '--bare', + default=False, action='store_true', + help=_("""\ +Displays only the list name, with no description.""")) + self.parser.add_option( + '-d', '--domain', + default=[], type='string', action='append', + dest='domains', help=_("""\ +List only those mailing lists that match the given virtual domain, which may +be either the email host or the url host name. Multiple -d options may be +given.""")) + self.parser.add_option( + '-f', '--full', + default=False, action='store_true', + help=_("""\ +Print the full list name, including the posting address.""")) + + def sanity_check(self): + if len(self.arguments) > 0: + self.parser.error(_('Unexpected arguments')) + + + +def main(): + options = ScriptOptions() + options.initialize() + + mlists = [] + longest = 0 + + listmgr = config.db.list_manager + for fqdn_name in sorted(listmgr.names): + mlist = listmgr.get(fqdn_name) + if options.options.advertised and not mlist.advertised: + continue + if options.options.domains: + for domain in options.options.domains: + if domain in mlist.web_page_url or domain == mlist.host_name: + mlists.append(mlist) + break + else: + mlists.append(mlist) + if options.options.full: + name = mlist.fqdn_listname + else: + name = mlist.real_name + longest = max(len(name), longest) + + if not mlists and not options.options.bare: + print _('No matching mailing lists found') + return + + if not options.options.bare: + num_mlists = len(mlists) + print _('$num_mlists matching mailing lists found:') + + format = '%%%ds - %%.%ds' % (longest, 77 - longest) + for mlist in mlists: + if options.options.full: + name = mlist.fqdn_listname + else: + name = mlist.real_name + if options.options.bare: + print name + else: + description = mlist.description or _('[no description available]') + print ' ', format % (name, description) diff --git a/src/mailman/bin/list_members.py b/src/mailman/bin/list_members.py new file mode 100644 index 000000000..443f764d6 --- /dev/null +++ b/src/mailman/bin/list_members.py @@ -0,0 +1,201 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import sys + +from email.Utils import formataddr + +from mailman import Utils +from mailman.config import config +from mailman.core import errors +from mailman.i18n import _ +from mailman.interfaces import DeliveryStatus +from mailman.options import SingleMailingListOptions + + +COMMASPACE = ', ' + +WHYCHOICES = { + 'enabled' : DeliveryStatus.enabled, + 'byuser' : DeliveryStatus.by_user, + 'byadmin' : DeliveryStatus.by_moderator, + 'bybounce': DeliveryStatus.by_bounces, + } + +KINDCHOICES = set(('mime', 'plain', 'any')) + + + +class ScriptOptions(SingleMailingListOptions): + usage = _("""\ +%prog [options] + +List all the members of a mailing list. Note that with the options below, if +neither -r or -d is supplied, regular members are printed first, followed by +digest members, but no indication is given as to address status. + +listname is the name of the mailing list to use.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-o', '--output', + type='string', help=_("""\ +Write output to specified file instead of standard out.""")) + self.parser.add_option( + '-r', '--regular', + default=None, action='store_true', + help=_('Print just the regular (non-digest) members.')) + self.parser.add_option( + '-d', '--digest', + default=None, type='string', metavar='KIND', + help=_("""\ +Print just the digest members. KIND can be 'mime', 'plain', or +'any'. 'mime' prints just the members receiving MIME digests, while 'plain' +prints just the members receiving plain text digests. 'any' prints all +members receiving any kind of digest.""")) + self.parser.add_option( + '-n', '--nomail', + type='string', metavar='WHY', help=_("""\ +Print the members that have delivery disabled. WHY selects just the subset of +members with delivery disabled for a particular reason, where 'any' prints all +disabled members. 'byadmin', 'byuser', 'bybounce', and 'unknown' prints just +the users who are disabled for that particular reason. WHY can also be +'enabled' which prints just those members for whom delivery is enabled.""")) + self.parser.add_option( + '-f', '--fullnames', + default=False, action='store_true', + help=_('Include the full names in the output')) + self.parser.add_option( + '-i', '--invalid', + default=False, action='store_true', help=_("""\ +Print only the addresses in the membership list that are invalid. Ignores -r, +-d, -n.""")) + + def sanity_check(self): + if not self.options.listname: + self.parser.error(_('Missing listname')) + if len(self.arguments) > 0: + self.parser.print_error(_('Unexpected arguments')) + if self.options.digest is not None: + self.options.kind = self.options.digest.lower() + if self.options.kind not in KINDCHOICES: + self.parser.error( + _('Invalid value for -d: $self.options.digest')) + if self.options.nomail is not None: + why = self.options.nomail.lower() + if why == 'any': + self.options.why = 'any' + elif why not in WHYCHOICES: + self.parser.error( + _('Invalid value for -n: $self.options.nomail')) + self.options.why = why + if self.options.regular is None and self.options.digest is None: + self.options.regular = self.options.digest = True + self.options.kind = 'any' + + + +def safe(string): + if not string: + return '' + return string.encode(sys.getdefaultencoding(), 'replace') + + +def isinvalid(addr): + try: + Utils.ValidateEmail(addr) + return False + except errors.EmailAddressError: + return True + + + +def whymatches(mlist, addr, why): + # Return true if the `why' matches the reason the address is enabled, or + # in the case of why is None, that they are disabled for any reason + # (i.e. not enabled). + status = mlist.getDeliveryStatus(addr) + if why in (None, 'any'): + return status <> DeliveryStatus.enabled + return status == WHYCHOICES[why] + + + +def main(): + options = ScriptOptions() + options.initialize() + + fqdn_listname = options.options.listname + if options.options.output: + try: + fp = open(options.output, 'w') + except IOError: + options.parser.error( + _('Could not open file for writing: $options.options.output')) + else: + fp = sys.stdout + + mlist = config.db.list_manager.get(fqdn_listname) + if mlist is None: + options.parser.error(_('No such list: $fqdn_listname')) + + # The regular delivery and digest members. + rmembers = set(mlist.regular_members.members) + dmembers = set(mlist.digest_members.members) + + fullnames = options.options.fullnames + if options.options.invalid: + all = sorted(member.address.address for member in rmembers + dmembers) + for address in all: + user = config.db.user_manager.get_user(address) + name = (user.real_name if fullnames and user else u'') + if options.options.invalid and isinvalid(address): + print >> fp, formataddr((safe(name), address)) + return + if options.options.regular: + for address in sorted(member.address.address for member in rmembers): + user = config.db.user_manager.get_user(address) + name = (user.real_name if fullnames and user else u'') + # Filter out nomails + if (options.options.nomail and + not whymatches(mlist, address, options.options.why)): + continue + print >> fp, formataddr((safe(name), address)) + if options.options.digest: + for address in sorted(member.address.address for member in dmembers): + user = config.db.user_manager.get_user(address) + name = (user.real_name if fullnames and user else u'') + # Filter out nomails + if (options.options.nomail and + not whymatches(mlist, address, options.options.why)): + continue + # Filter out digest kinds +## if mlist.getMemberOption(addr, config.DisableMime): +## # They're getting plain text digests +## if opts.kind == 'mime': +## continue +## else: +## # They're getting MIME digests +## if opts.kind == 'plain': +## continue + print >> fp, formataddr((safe(name), address)) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/list_owners.py b/src/mailman/bin/list_owners.py new file mode 100644 index 000000000..953fb8941 --- /dev/null +++ b/src/mailman/bin/list_owners.py @@ -0,0 +1,88 @@ +# Copyright (C) 2002-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import sys +import optparse + +from mailman.MailList import MailList +from mailman.configuration import config +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] [listname ...] + +List the owners of a mailing list, or all mailing lists if no list names are +given.""")) + parser.add_option('-w', '--with-listnames', + default=False, action='store_true', + help=_("""\ +Group the owners by list names and include the list names in the output. +Otherwise, the owners will be sorted and uniquified based on the email +address.""")) + parser.add_option('-m', '--moderators', + default=False, action='store_true', + help=_('Include the list moderators in the output.')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + + listmgr = config.db.list_manager + listnames = set(args or listmgr.names) + bylist = {} + + for listname in listnames: + mlist = listmgr.get(listname) + addrs = [addr.address for addr in mlist.owners.addresses] + if opts.moderators: + addrs.extend([addr.address for addr in mlist.moderators.addresses]) + bylist[listname] = addrs + + if opts.with_listnames: + for listname in listnames: + unique = set() + for addr in bylist[listname]: + unique.add(addr) + keys = list(unique) + keys.sort() + print listname + for k in keys: + print '\t', k + else: + unique = set() + for listname in listnames: + for addr in bylist[listname]: + unique.add(addr) + for k in sorted(unique): + print k + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/mailmanctl.py b/src/mailman/bin/mailmanctl.py new file mode 100644 index 000000000..667a46a70 --- /dev/null +++ b/src/mailman/bin/mailmanctl.py @@ -0,0 +1,232 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Mailman start/stop script.""" + +import os +import grp +import pwd +import sys +import errno +import signal +import logging + +from optparse import OptionParser + +from mailman.config import config +from mailman.core.initialize import initialize +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +COMMASPACE = ', ' + +log = None +parser = None + + + +def parseargs(): + parser = OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +Primary start-up and shutdown script for Mailman's qrunner daemon. + +This script starts, stops, and restarts the main Mailman queue runners, making +sure that the various long-running qrunners are still alive and kicking. It +does this by forking and exec'ing the qrunners and waiting on their pids. +When it detects a subprocess has exited, it may restart it. + +The qrunners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT, SIGTERM +and SIGUSR1 all cause the qrunners to exit cleanly, but the master will only +restart qrunners that have exited due to a SIGUSR1. SIGHUP causes the master +and the qrunners to close their log files, and reopen then upon the next +printed message. + +The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP, which it +simply passes on to the qrunners (note that the master will close and reopen +its own log files on receipt of a SIGHUP). The master also leaves its own +process id in the file data/master-qrunner.pid but you normally don't need to +use this pid directly. The `start', `stop', `restart', and `reopen' commands +handle everything for you. + +Commands: + + start - Start the master daemon and all qrunners. Prints a message and + exits if the master daemon is already running. + + stop - Stops the master daemon and all qrunners. After stopping, no + more messages will be processed. + + restart - Restarts the qrunners, but not the master process. Use this + whenever you upgrade or update Mailman so that the qrunners will + use the newly installed code. + + reopen - This will close all log files, causing them to be re-opened the + next time a message is written to them + +Usage: %prog [options] [ start | stop | restart | reopen ]""")) + parser.add_option('-u', '--run-as-user', + default=True, action='store_false', + help=_("""\ +Normally, this script will refuse to run if the user id and group id are not +set to the `mailman' user and group (as defined when you configured Mailman). +If run as root, this script will change to this user and group before the +check is made. + +This can be inconvenient for testing and debugging purposes, so the -u flag +means that the step that sets and checks the uid/gid is skipped, and the +program is run as the current user and group. This flag is not recommended +for normal production environments. + +Note though, that if you run with -u and are not in the mailman group, you may +have permission problems, such as begin unable to delete a list's archives +through the web. Tough luck!""")) + parser.add_option('-f', '--force', + default=False, action='store_true', + help=_("""\ +If the master watcher finds an existing master lock, it will normally exit +with an error message. With this option,the master will perform an extra +level of checking. If a process matching the host/pid described in the lock +file is running, the master will still exit, requiring you to manually clean +up the lock. But if no matching process is found, the master will remove the +apparently stale lock and make another attempt to claim the master lock.""")) + parser.add_option('-q', '--quiet', + default=False, action='store_true', + help=_("""\ +Don't print status messages. Error messages are still printed to standard +error.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + options, arguments = parser.parse_args() + if not arguments: + parser.error(_('No command given.')) + if len(arguments) > 1: + commands = COMMASPACE.join(arguments) + parser.error(_('Bad command: $commands')) + parser.options = options + parser.arguments = arguments + return parser + + + +def kill_watcher(sig): + try: + with open(config.PIDFILE) as f: + pid = int(f.read().strip()) + except (IOError, ValueError), e: + # For i18n convenience + print >> sys.stderr, _('PID unreadable in: $config.PIDFILE') + print >> sys.stderr, e + print >> sys.stderr, _('Is qrunner even running?') + return + try: + os.kill(pid, sig) + except OSError, error: + if e.errno <> errno.ESRCH: + raise + print >> sys.stderr, _('No child with pid: $pid') + print >> sys.stderr, e + print >> sys.stderr, _('Stale pid file removed.') + os.unlink(config.PIDFILE) + + + +def check_privileges(): + # If we're running as root (uid == 0), coerce the uid and gid to that + # which Mailman was configured for, and refuse to run if we didn't coerce + # the uid/gid. + gid = grp.getgrnam(config.MAILMAN_GROUP).gr_gid + uid = pwd.getpwnam(config.MAILMAN_USER).pw_uid + myuid = os.getuid() + if myuid == 0: + # Set the process's supplimental groups. + groups = [group.gr_gid for group in grp.getgrall() + if config.MAILMAN_USER in group.gr_mem] + groups.append(gid) + os.setgroups(groups) + os.setgid(gid) + os.setuid(uid) + elif myuid <> uid: + name = config.MAILMAN_USER + parser.error( + _('Run this program as root or as the $name user, or use -u.')) + + + +def main(): + global log, parser + + parser = parseargs() + initialize(parser.options.config) + + log = logging.getLogger('mailman.qrunner') + + if not parser.options.run_as_user: + check_privileges() + else: + if not parser.options.quiet: + print _('Warning! You may encounter permission problems.') + + # Handle the commands + command = parser.arguments[0].lower() + if command == 'stop': + if not parser.options.quiet: + print _("Shutting down Mailman's master qrunner") + kill_watcher(signal.SIGTERM) + elif command == 'restart': + if not parser.options.quiet: + print _("Restarting Mailman's master qrunner") + kill_watcher(signal.SIGUSR1) + elif command == 'reopen': + if not parser.options.quiet: + print _('Re-opening all log files') + kill_watcher(signal.SIGHUP) + elif command == 'start': + # Start the master qrunner watcher process. + # + # Daemon process startup according to Stevens, Advanced Programming in + # the UNIX Environment, Chapter 13. + pid = os.fork() + if pid: + # parent + if not parser.options.quiet: + print _("Starting Mailman's master qrunner.") + return + # child + # + # Create a new session and become the session leader, but since we + # won't be opening any terminal devices, don't do the ultra-paranoid + # suggestion of doing a second fork after the setsid() call. + os.setsid() + # Instead of cd'ing to root, cd to the Mailman runtime directory. + os.chdir(config.VAR_DIR) + # Exec the master watcher. + args = [sys.executable, sys.executable, + os.path.join(config.BIN_DIR, 'master')] + if parser.options.force: + args.append('--force') + if parser.options.config: + args.extend(['-C', parser.options.config]) + log.debug('starting: %s', args) + os.execl(*args) + # We should never get here. + raise RuntimeError('os.execl() failed') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/master.py b/src/mailman/bin/master.py new file mode 100644 index 000000000..d954bc865 --- /dev/null +++ b/src/mailman/bin/master.py @@ -0,0 +1,452 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +"""Master sub-process watcher.""" + +__metaclass__ = type +__all__ = [ + 'Loop', + 'get_lock_data', + ] + + +import os +import sys +import errno +import signal +import socket +import logging + +from datetime import timedelta +from lazr.config import as_boolean +from locknix import lockfile +from munepy import Enum + +from mailman.config import config +from mailman.core.logging import reopen +from mailman.i18n import _ +from mailman.options import Options + + +DOT = '.' +LOCK_LIFETIME = timedelta(days=1, hours=6) +SECONDS_IN_A_DAY = 86400 + + + +class ScriptOptions(Options): + """Options for the master watcher.""" + + usage = _("""\ +Master sub-process watcher. + +Start and watch the configured queue runners and ensure that they stay alive +and kicking. Each are fork and exec'd in turn, with the master waiting on +their process ids. When it detects a child queue runner has exited, it may +restart it. + +The queue runners respond to SIGINT, SIGTERM, SIGUSR1 and SIGHUP. SIGINT, +SIGTERM and SIGUSR1 all cause the qrunners to exit cleanly. The master will +restart qrunners that have exited due to a SIGUSR1 or some kind of other exit +condition (say because of an exception). SIGHUP causes the master and the +qrunners to close their log files, and reopen then upon the next printed +message. + +The master also responds to SIGINT, SIGTERM, SIGUSR1 and SIGHUP, which it +simply passes on to the qrunners. Note that the master will close and reopen +its own log files on receipt of a SIGHUP. The master also leaves its own +process id in the file `data/master-qrunner.pid` but you normally don't need +to use this pid directly. + +Usage: %prog [options]""") + + def add_options(self): + self.parser.add_option( + '-n', '--no-restart', + dest='restartable', default=True, action='store_false', + help=_("""\ +Don't restart the qrunners when they exit because of an error or a SIGUSR1. +Use this only for debugging.""")) + self.parser.add_option( + '-f', '--force', + default=False, action='store_true', + help=_("""\ +If the master watcher finds an existing master lock, it will normally exit +with an error message. With this option,the master will perform an extra +level of checking. If a process matching the host/pid described in the lock +file is running, the master will still exit, requiring you to manually clean +up the lock. But if no matching process is found, the master will remove the +apparently stale lock and make another attempt to claim the master lock.""")) + self.parser.add_option( + '-r', '--runner', + dest='runners', action='append', default=[], + help=_("""\ +Override the default set of queue runners that the master watch will invoke +instead of the default set. Multiple -r options may be given. The values for +-r are passed straight through to bin/qrunner.""")) + + def sanity_check(self): + if len(self.arguments) > 0: + self.parser.error(_('Too many arguments')) + + + +def get_lock_data(): + """Get information from the master lock file. + + :return: A 3-tuple of the hostname, integer process id, and file name of + the lock file. + """ + with open(config.LOCK_FILE) as fp: + filename = os.path.split(fp.read().strip())[1] + parts = filename.split('.') + hostname = DOT.join(parts[1:-2]) + pid = int(parts[-2]) + return hostname, int(pid), filename + + +class WatcherState(Enum): + # Another master watcher is running. + conflict = 1 + # No conflicting process exists. + stale_lock = 2 + # Hostname from lock file doesn't match. + host_mismatch = 3 + + +def master_state(): + """Get the state of the master watcher. + + :return: WatcherState describing the state of the lock file. + """ + + # 1 if proc exists on host (but is it qrunner? ;) + # 0 if host matches but no proc + # hostname if hostname doesn't match + hostname, pid, tempfile = get_lock_data() + if hostname <> socket.gethostname(): + return WatcherState.host_mismatch + # Find out if the process exists by calling kill with a signal 0. + try: + os.kill(pid, 0) + return WatcherState.conflict + except OSError, e: + if e.errno == errno.ESRCH: + # No matching process id. + return WatcherState.stale_lock + # Some other error occurred. + raise + + +def acquire_lock_1(force): + """Try to acquire the master queue runner lock. + + :param force: Flag that controls whether to force acquisition of the lock. + :return: The master queue runner lock. + :raises: `TimeOutError` if the lock could not be acquired. + """ + lock = lockfile.Lock(config.LOCK_FILE, LOCK_LIFETIME) + try: + lock.lock(timedelta(seconds=0.1)) + return lock + except lockfile.TimeOutError: + if not force: + raise + # Force removal of lock first. + lock.disown() + hostname, pid, tempfile = get_lock_data() + os.unlink(config.LOCK_FILE) + os.unlink(os.path.join(config.LOCK_DIR, tempfile)) + return acquire_lock_1(force=False) + + +def acquire_lock(force): + """Acquire the master queue runner lock. + + :return: The master queue runner lock or None if the lock couldn't be + acquired. In that case, an error messages is also printed to standard + error. + """ + try: + lock = acquire_lock_1(force) + return lock + except lockfile.TimeOutError: + status = master_state() + if status == WatcherState.conflict: + # Hostname matches and process exists. + message = _("""\ +The master qrunner lock could not be acquired because it appears +as though another master qrunner is already running. +""") + elif status == WatcherState.stale_lock: + # Hostname matches but the process does not exist. + message = _("""\ +The master qrunner lock could not be acquired. It appears as though there is +a stale master qrunner lock. Try re-running mailmanctl with the -s flag. +""") + else: + assert status == WatcherState.host_mismatch, ( + 'Invalid enum value: %s' % status) + # Hostname doesn't even match. + hostname, pid, tempfile = get_lock_data() + message = _("""\ +The master qrunner lock could not be acquired, because it appears as if some +process on some other host may have acquired it. We can't test for stale +locks across host boundaries, so you'll have to clean this up manually. + +Lock file: $config.LOCK_FILE +Lock host: $hostname + +Exiting.""") + config.options.parser.error(message) + + + +class Loop: + """Main control loop class.""" + + def __init__(self, lock=None, restartable=None, config_file=None): + self._lock = lock + self._restartable = restartable + self._config_file = config_file + self._kids = {} + + def install_signal_handlers(self): + """Install various signals handlers for control from mailmanctl.""" + log = logging.getLogger('mailman.qrunner') + # Set up our signal handlers. Also set up a SIGALRM handler to + # refresh the lock once per day. The lock lifetime is 1 day + 6 hours + # so this should be plenty. + def sigalrm_handler(signum, frame): + self._lock.refresh() + signal.alarm(SECONDS_IN_A_DAY) + signal.signal(signal.SIGALRM, sigalrm_handler) + signal.alarm(SECONDS_IN_A_DAY) + # SIGHUP tells the qrunners to close and reopen their log files. + def sighup_handler(signum, frame): + reopen() + for pid in self._kids: + os.kill(pid, signal.SIGHUP) + log.info('Master watcher caught SIGHUP. Re-opening log files.') + signal.signal(signal.SIGHUP, sighup_handler) + # SIGUSR1 is used by 'mailman restart'. + def sigusr1_handler(signum, frame): + for pid in self._kids: + os.kill(pid, signal.SIGUSR1) + log.info('Master watcher caught SIGUSR1. Exiting.') + signal.signal(signal.SIGUSR1, sigusr1_handler) + # SIGTERM is what init will kill this process with when changing run + # levels. It's also the signal 'mailmanctl stop' uses. + def sigterm_handler(signum, frame): + for pid in self._kids: + os.kill(pid, signal.SIGTERM) + log.info('Master watcher caught SIGTERM. Exiting.') + signal.signal(signal.SIGTERM, sigterm_handler) + # SIGINT is what control-C gives. + def sigint_handler(signum, frame): + for pid in self._kids: + os.kill(pid, signal.SIGINT) + log.info('Master watcher caught SIGINT. Restarting.') + signal.signal(signal.SIGINT, sigint_handler) + + def _start_runner(self, spec): + """Start a queue runner. + + All arguments are passed to the qrunner process. + + :param spec: A queue runner spec, in a format acceptable to + bin/qrunner's --runner argument, e.g. name:slice:count + :type spec: string + :return: The process id of the child queue runner. + :rtype: int + """ + pid = os.fork() + if pid: + # Parent. + return pid + # Child. + # + # Craft the command line arguments for the exec() call. + rswitch = '--runner=' + spec + # Wherever mailmanctl lives, so too must live the qrunner script. + exe = os.path.join(config.BIN_DIR, 'qrunner') + # config.PYTHON, which is the absolute path to the Python interpreter, + # must be given as argv[0] due to Python's library search algorithm. + args = [sys.executable, sys.executable, exe, rswitch, '-s'] + if self._config_file is not None: + args.extend(['-C', self._config_file]) + log = logging.getLogger('mailman.qrunner') + log.debug('starting: %s', args) + os.execl(*args) + # We should never get here. + raise RuntimeError('os.execl() failed') + + def start_qrunners(self, qrunner_names=None): + """Start all the configured qrunners. + + :param qrunners: If given, a sequence of queue runner names to start. + If not given, this sequence is taken from the configuration file. + :type qrunners: a sequence of strings + """ + if not qrunner_names: + qrunner_names = [] + for qrunner_config in config.qrunner_configs: + # Strip off the 'qrunner.' prefix. + assert qrunner_config.name.startswith('qrunner.'), ( + 'Unexpected qrunner configuration section name: %s', + qrunner_config.name) + qrunner_names.append(qrunner_config.name[8:]) + # For each qrunner we want to start, find their config section, which + # will tell us the name of the class to instantiate, along with the + # number of hash space slices to manage. + for name in qrunner_names: + section_name = 'qrunner.' + name + # Let AttributeError propagate. + qrunner_config = getattr(config, section_name) + if not as_boolean(qrunner_config.start): + continue + package, class_name = qrunner_config['class'].rsplit(DOT, 1) + __import__(package) + # Let AttributeError propagate. + class_ = getattr(sys.modules[package], class_name) + # Find out how many qrunners to instantiate. This must be a power + # of 2. + count = int(qrunner_config.instances) + assert (count & (count - 1)) == 0, ( + 'Queue runner "%s", not a power of 2: %s', name, count) + for slice_number in range(count): + # qrunner name, slice #, # of slices, restart count + info = (name, slice_number, count, 0) + spec = '%s:%d:%d' % (name, slice_number, count) + pid = self._start_runner(spec) + log = logging.getLogger('mailman.qrunner') + log.debug('[%d] %s', pid, spec) + self._kids[pid] = info + + def loop(self): + """Main loop. + + Wait until all the qrunners have exited, restarting them if necessary + and configured to do so. + """ + log = logging.getLogger('mailman.qrunner') + while True: + try: + pid, status = os.wait() + except OSError, error: + # No children? We're done. + if error.errno == errno.ECHILD: + break + # If the system call got interrupted, just restart it. + elif error.errno == errno.EINTR: + continue + else: + raise + # Find out why the subprocess exited by getting the signal + # received or exit status. + if os.WIFSIGNALED(status): + why = os.WTERMSIG(status) + elif os.WIFEXITED(status): + why = os.WEXITSTATUS(status) + else: + why = None + # We'll restart the subprocess if it exited with a SIGUSR1 or + # because of a failure (i.e. no exit signal), and the no-restart + # command line switch was not given. This lets us better handle + # runaway restarts (e.g. if the subprocess had a syntax error!) + qrname, slice_number, count, restarts = self._kids.pop(pid) + config_name = 'qrunner.' + qrname + restart = False + if why == signal.SIGUSR1 and self._restartable: + restart = True + # Have we hit the maximum number of restarts? + restarts += 1 + max_restarts = int(getattr(config, config_name).max_restarts) + if restarts > max_restarts: + restart = False + # Are we permanently non-restartable? + log.debug("""\ +Master detected subprocess exit +(pid: %d, why: %s, class: %s, slice: %d/%d) %s""", + pid, why, qrname, slice_number + 1, count, + ('[restarting]' if restart else '')) + # See if we've reached the maximum number of allowable restarts + if restarts > max_restarts: + log.info("""\ +qrunner %s reached maximum restart limit of %d, not restarting.""", + qrname, max_restarts) + # Now perhaps restart the process unless it exited with a + # SIGTERM or we aren't restarting. + if restart: + spec = '%s:%d:%d' % (qrname, slice_number, count) + newpid = self._start_runner(spec) + self._kids[newpid] = (qrname, slice_number, count, restarts) + + def cleanup(self): + """Ensure that all children have exited.""" + log = logging.getLogger('mailman.qrunner') + # Send SIGTERMs to all the child processes and wait for them all to + # exit. + for pid in self._kids: + try: + os.kill(pid, signal.SIGTERM) + except OSError, error: + if error.errno == errno.ESRCH: + # The child has already exited. + log.info('ESRCH on pid: %d', pid) + # Wait for all the children to go away. + while self._kids: + try: + pid, status = os.wait() + del self._kids[pid] + except OSError, e: + if e.errno == errno.ECHILD: + break + elif e.errno == errno.EINTR: + continue + raise + + + +def main(): + """Main process.""" + + options = ScriptOptions() + options.initialize() + + # Acquire the master lock, exiting if we can't acquire it. We'll let the + # caller handle any clean up or lock breaking. No with statement here + # because Lock's constructor doesn't support a timeout. + lock = acquire_lock(options.options.force) + try: + with open(config.PIDFILE, 'w') as fp: + print >> fp, os.getpid() + loop = Loop(lock, options.options.restartable, options.options.config) + loop.install_signal_handlers() + try: + loop.start_qrunners(options.options.runners) + loop.loop() + finally: + loop.cleanup() + os.remove(config.PIDFILE) + finally: + lock.unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/mmsitepass.py b/src/mailman/bin/mmsitepass.py new file mode 100644 index 000000000..132803fc9 --- /dev/null +++ b/src/mailman/bin/mmsitepass.py @@ -0,0 +1,113 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import sys +import getpass +import optparse + +from mailman import Utils +from mailman import passwords +from mailman.configuration import config +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] [password] + +Set the site or list creator password. + +The site password can be used in most if not all places that the list +administrator's password can be used, which in turn can be used in most places +that a list user's password can be used. The list creator password is a +separate password that can be given to non-site administrators to delegate the +ability to create new mailing lists. + +If password is not given on the command line, it will be prompted for. +""")) + parser.add_option('-c', '--listcreator', + default=False, action='store_true', + help=_("""\ +Set the list creator password instead of the site password. The list +creator is authorized to create and remove lists, but does not have +the total power of the site administrator.""")) + parser.add_option('-p', '--password-scheme', + default='', type='string', + help=_("""\ +Specify the RFC 2307 style hashing scheme for passwords included in the +output. Use -P to get a list of supported schemes, which are +case-insensitive.""")) + parser.add_option('-P', '--list-hash-schemes', + default=False, action='store_true', help=_("""\ +List the supported password hashing schemes and exit. The scheme labels are +case-insensitive.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if len(args) > 1: + parser.error(_('Unexpected arguments')) + if opts.list_hash_schemes: + for label in passwords.Schemes: + print str(label).upper() + sys.exit(0) + return parser, opts, args + + +def check_password_scheme(parser, password_scheme): + # shoule be checked after config is loaded. + if password_scheme == '': + password_scheme = config.PASSWORD_SCHEME + scheme = passwords.lookup_scheme(password_scheme.lower()) + if not scheme: + parser.error(_('Invalid password scheme')) + return scheme + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + opts.password_scheme = check_password_scheme(parser, opts.password_scheme) + if args: + password = args[0] + else: + # Prompt for the password + if opts.listcreator: + prompt_1 = _('New list creator password: ') + else: + prompt_1 = _('New site administrator password: ') + pw1 = getpass.getpass(prompt_1) + pw2 = getpass.getpass(_('Enter password again to confirm: ')) + if pw1 <> pw2: + print _('Passwords do not match; no changes made.') + sys.exit(1) + password = pw1 + Utils.set_global_password(password, + not opts.listcreator, opts.password_scheme) + if Utils.check_global_password(password, not opts.listcreator): + print _('Password changed.') + else: + print _('Password change failed.') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/nightly_gzip.py b/src/mailman/bin/nightly_gzip.py new file mode 100644 index 000000000..f886e5801 --- /dev/null +++ b/src/mailman/bin/nightly_gzip.py @@ -0,0 +1,117 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys +import optparse + +try: + import gzip +except ImportError: + sys.exit(0) + +from mailman import MailList +from mailman.configuration import config +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] [listname ...] + +Re-generate the Pipermail gzip'd archive flat files.""")) + parser.add_option('-v', '--verbose', + default=False, action='store_true', + help=_("Print each file as it's being gzip'd")) + parser.add_option('-z', '--level', + default=6, type='int', + help=_('Specifies the compression level')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if opts.level < 1 or opts.level > 9: + parser.print_help() + print >> sys.stderr, _('Illegal compression level: $opts.level') + sys.exit(1) + return opts, args, parser + + + +def compress(txtfile, opts): + if opts.verbose: + print _("gzip'ing: $txtfile") + infp = outfp = None + try: + infp = open(txtfile) + outfp = gzip.open(txtfile + '.gz', 'wb', opts.level) + outfp.write(infp.read()) + finally: + if outfp: + outfp.close() + if infp: + infp.close() + + + +def main(): + opts, args, parser = parseargs() + initialize(opts.config) + + if config.ARCHIVE_TO_MBOX not in (1, 2) or config.GZIP_ARCHIVE_TXT_FILES: + # We're only going to run the nightly archiver if messages are + # archived to the mbox, and the gzip file is not created on demand + # (i.e. for every individual post). This is the normal mode of + # operation. + return + + # Process all the specified lists + for listname in set(args or config.list_manager.names): + mlist = MailList.MailList(listname, lock=False) + if not mlist.archive: + continue + dir = mlist.archive_dir() + try: + allfiles = os.listdir(dir) + except OSError: + # Has the list received any messages? If not, last_post_time will + # be zero, so it's not really a bogus archive dir. + if mlist.last_post_time > 0: + print _('List $listname has a bogus archive_directory: $dir') + continue + if opts.verbose: + print _('Processing list: $listname') + files = [] + for f in allfiles: + if os.path.splitext(f)[1] <> '.txt': + continue + # stat both the .txt and .txt.gz files and append them only if + # the former is newer than the latter. + txtfile = os.path.join(dir, f) + gzpfile = txtfile + '.gz' + txt_mtime = os.path.getmtime(txtfile) + try: + gzp_mtime = os.path.getmtime(gzpfile) + except OSError: + gzp_mtime = -1 + if txt_mtime > gzp_mtime: + files.append(txtfile) + for f in files: + compress(f, opts) diff --git a/src/mailman/bin/qrunner.py b/src/mailman/bin/qrunner.py new file mode 100644 index 000000000..62e943aad --- /dev/null +++ b/src/mailman/bin/qrunner.py @@ -0,0 +1,269 @@ +# Copyright (C) 2001-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import sys +import signal +import logging + +from mailman.config import config +from mailman.core.logging import reopen +from mailman.i18n import _ +from mailman.options import Options + + +COMMASPACE = ', ' +log = None + + + +def r_callback(option, opt, value, parser): + dest = getattr(parser.values, option.dest) + parts = value.split(':') + if len(parts) == 1: + runner = parts[0] + rslice = rrange = 1 + elif len(parts) == 3: + runner = parts[0] + try: + rslice = int(parts[1]) + rrange = int(parts[2]) + except ValueError: + parser.print_help() + print >> sys.stderr, _('Bad runner specification: $value') + sys.exit(1) + else: + parser.print_help() + print >> sys.stderr, _('Bad runner specification: $value') + sys.exit(1) + dest.append((runner, rslice, rrange)) + + + +class ScriptOptions(Options): + + usage = _("""\ +Run one or more qrunners, once or repeatedly. + +Each named runner class is run in round-robin fashion. In other words, the +first named runner is run to consume all the files currently in its +directory. When that qrunner is done, the next one is run to consume all the +files in /its/ directory, and so on. The number of total iterations can be +given on the command line. + +Usage: %prog [options] + +-r is required unless -l or -h is given, and its argument must be one of the +names displayed by the -l switch. + +Normally, this script should be started from mailmanctl. Running it +separately or with -o is generally useful only for debugging. +""") + + def add_options(self): + self.parser.add_option( + '-r', '--runner', + metavar='runner[:slice:range]', dest='runners', + type='string', default=[], + action='callback', callback=r_callback, + help=_("""\ +Run the named qrunner, which must be one of the strings returned by the -l +option. Optional slice:range if given, is used to assign multiple qrunner +processes to a queue. range is the total number of qrunners for this queue +while slice is the number of this qrunner from [0..range). + +When using the slice:range form, you must ensure that each qrunner for the +queue is given the same range value. If slice:runner is not given, then 1:1 +is used. + +Multiple -r options may be given, in which case each qrunner will run once in +round-robin fashion. The special runner `All' is shorthand for a qrunner for +each listed by the -l option.""")) + self.parser.add_option( + '-o', '--once', + default=False, action='store_true', help=_("""\ +Run each named qrunner exactly once through its main loop. Otherwise, each +qrunner runs indefinitely, until the process receives signal.""")) + self.parser.add_option( + '-l', '--list', + default=False, action='store_true', + help=_('List the available qrunner names and exit.')) + self.parser.add_option( + '-v', '--verbose', + default=0, action='count', help=_("""\ +Display more debugging information to the logs/qrunner log file.""")) + self.parser.add_option( + '-s', '--subproc', + default=False, action='store_true', help=_("""\ +This should only be used when running qrunner as a subprocess of the +mailmanctl startup script. It changes some of the exit-on-error behavior to +work better with that framework.""")) + + def sanity_check(self): + if self.arguments: + self.parser.error(_('Unexpected arguments')) + if not self.options.runners and not self.options.list: + self.parser.error(_('No runner name given.')) + + + +def make_qrunner(name, slice, range, once=False): + # Several conventions for specifying the runner name are supported. It + # could be one of the shortcut names. If the name is a full module path, + # use it explicitly. If the name starts with a dot, it's a class name + # relative to the Mailman.queue package. + qrunner_config = getattr(config, 'qrunner.' + name, None) + if qrunner_config is not None: + # It was a shortcut name. + class_path = qrunner_config['class'] + elif name.startswith('.'): + class_path = 'mailman.queue' + name + else: + class_path = name + module_name, class_name = class_path.rsplit('.', 1) + try: + __import__(module_name) + except ImportError, e: + if config.options.options.subproc: + # Exit with SIGTERM exit code so the master watcher won't try to + # restart us. + print >> sys.stderr, _('Cannot import runner module: $module_name') + print >> sys.stderr, e + sys.exit(signal.SIGTERM) + else: + raise + qrclass = getattr(sys.modules[module_name], class_name) + if once: + # Subclass to hack in the setting of the stop flag in _do_periodic() + class Once(qrclass): + def _do_periodic(self): + self.stop() + qrunner = Once(name, slice) + else: + qrunner = qrclass(name, slice) + return qrunner + + + +def set_signals(loop): + """Set up the signal handlers. + + Signals caught are: SIGTERM, SIGINT, SIGUSR1 and SIGHUP. The latter is + used to re-open the log files. SIGTERM and SIGINT are treated exactly the + same -- they cause qrunner to exit with no restart from the master. + SIGUSR1 also causes qrunner to exit, but the master watcher will restart + it in that case. + + :param loop: A loop queue runner instance. + """ + def sigterm_handler(signum, frame): + # Exit the qrunner cleanly + loop.stop() + loop.status = signal.SIGTERM + log.info('%s qrunner caught SIGTERM. Stopping.', loop.name()) + signal.signal(signal.SIGTERM, sigterm_handler) + def sigint_handler(signum, frame): + # Exit the qrunner cleanly + loop.stop() + loop.status = signal.SIGINT + log.info('%s qrunner caught SIGINT. Stopping.', loop.name()) + signal.signal(signal.SIGINT, sigint_handler) + def sigusr1_handler(signum, frame): + # Exit the qrunner cleanly + loop.stop() + loop.status = signal.SIGUSR1 + log.info('%s qrunner caught SIGUSR1. Stopping.', loop.name()) + signal.signal(signal.SIGUSR1, sigusr1_handler) + # SIGHUP just tells us to rotate our log files. + def sighup_handler(signum, frame): + reopen() + log.info('%s qrunner caught SIGHUP. Reopening logs.', loop.name()) + signal.signal(signal.SIGHUP, sighup_handler) + + + +def main(): + global log + + options = ScriptOptions() + options.initialize() + + if options.options.list: + prefixlen = max(len(shortname) + for shortname in config.qrunner_shortcuts) + for shortname in sorted(config.qrunner_shortcuts): + runnername = config.qrunner_shortcuts[shortname] + shortname = (' ' * (prefixlen - len(shortname))) + shortname + print _('$shortname runs $runnername') + sys.exit(0) + + # Fast track for one infinite runner + if len(options.options.runners) == 1 and not options.options.once: + qrunner = make_qrunner(*options.options.runners[0]) + class Loop: + status = 0 + def __init__(self, qrunner): + self._qrunner = qrunner + def name(self): + return self._qrunner.__class__.__name__ + def stop(self): + self._qrunner.stop() + loop = Loop(qrunner) + set_signals(loop) + # Now start up the main loop + log = logging.getLogger('mailman.qrunner') + log.info('%s qrunner started.', loop.name()) + qrunner.run() + log.info('%s qrunner exiting.', loop.name()) + else: + # Anything else we have to handle a bit more specially + qrunners = [] + for runner, rslice, rrange in options.options.runners: + qrunner = make_qrunner(runner, rslice, rrange, once=True) + qrunners.append(qrunner) + # This class is used to manage the main loop + class Loop: + status = 0 + def __init__(self): + self._isdone = False + def name(self): + return 'Main loop' + def stop(self): + self._isdone = True + def isdone(self): + return self._isdone + loop = Loop() + set_signals(loop) + log.info('Main qrunner loop started.') + while not loop.isdone(): + for qrunner in qrunners: + # In case the SIGTERM came in the middle of this iteration + if loop.isdone(): + break + if options.options.verbose: + log.info('Now doing a %s qrunner iteration', + qrunner.__class__.__bases__[0].__name__) + qrunner.run() + if options.options.once: + break + log.info('Main qrunner loop exiting.') + # All done + sys.exit(loop.status) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/remove_list.py b/src/mailman/bin/remove_list.py new file mode 100644 index 000000000..05211b200 --- /dev/null +++ b/src/mailman/bin/remove_list.py @@ -0,0 +1,83 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import sys + +from mailman.app.lifecycle import remove_list +from mailman.config import config +from mailman.i18n import _ +from mailman.options import MultipleMailingListOptions + + + +class ScriptOptions(MultipleMailingListOptions): + usage = _("""\ +%prog [options] + +Remove the components of a mailing list with impunity - beware! + +This removes (almost) all traces of a mailing list. By default, the lists +archives are not removed, which is very handy for retiring old lists. +""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-a', '--archives', + default=False, action='store_true', + help=_("""\ +Remove the list's archives too, or if the list has already been deleted, +remove any residual archives.""")) + self.parser.add_option( + '-q', '--quiet', + default=False, action='store_true', + help=_('Suppress status messages')) + + def sanity_check(self): + if len(self.options.listnames) == 0: + self.parser.error(_('Nothing to do')) + if len(self.arguments) > 0: + self.parser.error(_('Unexpected arguments')) + + + +def main(): + options = ScriptOptions() + options.initialize() + + for fqdn_listname in options.options.listnames: + if not options.options.quiet: + print _('Removing list: $fqdn_listname') + mlist = config.db.list_manager.get(fqdn_listname) + if mlist is None: + if options.options.archives: + print _("""\ +No such list: ${fqdn_listname}. Removing its residual archives.""") + else: + print >> sys.stderr, _( + 'No such list (or list already deleted): $fqdn_listname') + + if not options.options.archives: + print _('Not removing archives. Reinvoke with -a to remove them.') + + remove_list(fqdn_listname, mlist, options.options.archives) + config.db.commit() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/senddigests.py b/src/mailman/bin/senddigests.py new file mode 100644 index 000000000..fb057d6b9 --- /dev/null +++ b/src/mailman/bin/senddigests.py @@ -0,0 +1,83 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys +import optparse + +from mailman import MailList +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.version import MAILMAN_VERSION + +# Work around known problems with some RedHat cron daemons +import signal +signal.signal(signal.SIGCHLD, signal.SIG_DFL) + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] + +Dispatch digests for lists w/pending messages and digest_send_periodic +set.""")) + parser.add_option('-l', '--listname', + type='string', default=[], action='append', + dest='listnames', help=_("""\ +Send the digest for the given list only, otherwise the digests for all +lists are sent out. Multiple -l options may be given.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return opts, args, parser + + + +def main(): + opts, args, parser = parseargs() + initialize(opts.config) + + for listname in set(opts.listnames or config.list_manager.names): + mlist = MailList.MailList(listname, lock=False) + if mlist.digest_send_periodic: + mlist.Lock() + try: + try: + mlist.send_digest_now() + mlist.Save() + # We are unable to predict what exception may occur in digest + # processing and we don't want to lose the other digests, so + # we catch everything. + except Exception, errmsg: + print >> sys.stderr, \ + 'List: %s: problem processing %s:\n%s' % \ + (listname, + os.path.join(mlist.data_path, 'digest.mbox'), + errmsg) + finally: + mlist.Unlock() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/set_members.py b/src/mailman/bin/set_members.py new file mode 100644 index 000000000..cdd11c56f --- /dev/null +++ b/src/mailman/bin/set_members.py @@ -0,0 +1,189 @@ +# Copyright (C) 2007-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import csv +import optparse + +from mailman import Message +from mailman import Utils +from mailman import i18n +from mailman import passwords +from mailman.app.membership import add_member +from mailman.app.notifications import ( + send_admin_subscription_notice, send_welcome_message) +from mailman.configuration import config +from mailman.initialize import initialize +from mailman.interfaces import DeliveryMode +from mailman.version import MAILMAN_VERSION + + +_ = i18n._ + +DELIVERY_MODES = { + 'regular': DeliveryMode.regular, + 'plain': DeliveryMode.plaintext_digests, + 'mime': DeliveryMode.mime_digests, + } + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] csv-file + +Set the membership of a mailing list to that described in a CSV file. Each +row of the CSV file has the following format. Only the address column is +required. + + - email address + - full name (default: the empty string) + - delivery mode (default: regular delivery) [1] + +[1] The delivery mode is a case insensitive string of the following values: + + regular - regular, i.e. immediate delivery + mime - MIME digest delivery + plain - plain text (RFC 1153) digest delivery + +Any address not included in the CSV file is removed from the list membership. +""")) + parser.add_option('-l', '--listname', + type='string', help=_("""\ +Mailng list to set the membership for.""")) + parser.add_option('-w', '--welcome-msg', + type='string', metavar='<y|n>', help=_("""\ +Set whether or not to send the list members a welcome message, overriding +whatever the list's 'send_welcome_msg' setting is.""")) + parser.add_option('-a', '--admin-notify', + type='string', metavar='<y|n>', help=_("""\ +Set whether or not to send the list administrators a notification on the +success/failure of these subscriptions, overriding whatever the list's +'admin_notify_mchanges' setting is.""")) + parser.add_option('-v', '--verbose', action='store_true', + help=_('Increase verbosity')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if opts.welcome_msg is not None: + ch = opts.welcome_msg[0].lower() + if ch == 'y': + opts.welcome_msg = True + elif ch == 'n': + opts.welcome_msg = False + else: + parser.error(_('Illegal value for -w: $opts.welcome_msg')) + if opts.admin_notify is not None: + ch = opts.admin_notify[0].lower() + if ch == 'y': + opts.admin_notify = True + elif ch == 'n': + opts.admin_notify = False + else: + parser.error(_('Illegal value for -a: $opts.admin_notify')) + return parser, opts, args + + + +def parse_file(filename): + members = {} + with open(filename) as fp: + for row in csv.reader(fp): + if len(row) == 0: + continue + elif len(row) == 1: + address = row[0] + real_name = None + delivery_mode = DeliveryMode.regular + elif len(row) == 2: + address, real_name = row + delivery_mode = DeliveryMode.regular + else: + # Ignore extra columns + address, real_name = row[0:2] + delivery_mode = DELIVERY_MODES.get(row[2].lower()) + if delivery_mode is None: + delivery_mode = DeliveryMode.regular + members[address] = real_name, delivery_mode + return members + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + + mlist = config.db.list_manager.get(opts.listname) + if mlist is None: + parser.error(_('No such list: $opts.listname')) + + # Set up defaults. + if opts.welcome_msg is None: + send_welcome_msg = mlist.send_welcome_msg + else: + send_welcome_msg = opts.welcome_msg + if opts.admin_notify is None: + admin_notify = mlist.admin_notify_mchanges + else: + admin_notify = opts.admin_notify + + # Parse the csv files. + member_data = {} + for filename in args: + member_data.update(parse_file(filename)) + + future_members = set(member_data) + current_members = set(obj.address for obj in mlist.members.addresses) + add_members = future_members - current_members + delete_members = current_members - future_members + change_members = current_members & future_members + + with i18n.using_language(mlist.preferred_language): + # Start by removing all the delete members. + for address in delete_members: + print _('deleting address: $address') + member = mlist.members.get_member(address) + member.unsubscribe() + # For all members that are in both lists, update their full name and + # delivery mode. + for address in change_members: + print _('updating address: $address') + real_name, delivery_mode = member_data[address] + member = mlist.members.get_member(address) + member.preferences.delivery_mode = delivery_mode + user = config.db.user_manager.get_user(address) + user.real_name = real_name + for address in add_members: + print _('adding address: $address') + real_name, delivery_mode = member_data[address] + password = passwords.make_secret( + Utils.MakeRandomPassword(), + passwords.lookup_scheme(config.PASSWORD_SCHEME)) + add_member(mlist, address, real_name, password, delivery_mode, + mlist.preferred_language, send_welcome_msg, + admin_notify) + if send_welcome_msg: + send_welcome_message(mlist, address, language, delivery_mode) + if admin_notify: + send_admin_subscription_notice(mlist, address, real_name) + + config.db.flush() + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/show_config.py b/src/mailman/bin/show_config.py new file mode 100644 index 000000000..8d26c5c97 --- /dev/null +++ b/src/mailman/bin/show_config.py @@ -0,0 +1,97 @@ +# Copyright (C) 2006-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import re +import sys +import pprint +import optparse + +from mailman.configuration import config +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +# List of names never to show even if --verbose +NEVER_SHOW = ['__builtins__', '__doc__'] + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%%prog [options] [pattern ...] + +Show the values of various Defaults.py/mailman.cfg variables. +If one or more patterns are given, show only those variables +whose names match a pattern""")) + parser.add_option('-v', '--verbose', + default=False, action='store_true', + help=_( +"Show all configuration names, not just 'settings'.")) + parser.add_option('-i', '--ignorecase', + default=False, action='store_true', + help=_("Match patterns case-insensitively.")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + + patterns = [] + if opts.ignorecase: + flag = re.IGNORECASE + else: + flag = 0 + for pattern in args: + patterns.append(re.compile(pattern, flag)) + + pp = pprint.PrettyPrinter(indent=4) + config.load(opts.config) + names = config.__dict__.keys() + names.sort() + for name in names: + if name in NEVER_SHOW: + continue + if not opts.verbose: + if name.startswith('_') or re.search('[a-z]', name): + continue + if patterns: + hit = False + for pattern in patterns: + if pattern.search(name): + hit = True + break + if not hit: + continue + value = config.__dict__[name] + if isinstance(value, str): + if re.search('\n', value): + print '%s = """%s"""' %(name, value) + else: + print "%s = '%s'" % (name, value) + else: + print '%s = ' % name, + pp.pprint(value) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/show_qfiles.py b/src/mailman/bin/show_qfiles.py new file mode 100644 index 000000000..e4b64e0cd --- /dev/null +++ b/src/mailman/bin/show_qfiles.py @@ -0,0 +1,91 @@ +# Copyright (C) 2006-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys + +from cPickle import load + +from mailman.config import config +from mailman.i18n import _ +from mailman.options import Options + + + +class ScriptOptions(Options): + usage = _(""" +%%prog [options] qfiles ... + +Show the contents of one or more Mailman queue files.""") + + def add_options(self): + super(ScriptOptions, self).add_options() + self.parser.add_option( + '-q', '--quiet', + default=False, action='store_true', + help=_("Don't print 'helpful' message delimiters.")) + self.parser.add_option( + '-s', '--summary', + default=False, action='store_true', + help=_('Show a summary of queue files.')) + + + +def main(): + options = ScriptOptions() + options.initialize() + + if options.options.summary: + queue_totals = {} + files_by_queue = {} + for switchboard in config.switchboards.values(): + total = 0 + file_mappings = {} + for filename in os.listdir(switchboard.queue_directory): + base, ext = os.path.splitext(filename) + file_mappings[ext] = file_mappings.get(ext, 0) + 1 + total += 1 + files_by_queue[switchboard.queue_directory] = file_mappings + queue_totals[switchboard.queue_directory] = total + # Sort by queue name. + for queue_directory in sorted(files_by_queue): + total = queue_totals[queue_directory] + print queue_directory + print _('\tfile count: $total') + file_mappings = files_by_queue[queue_directory] + for ext in sorted(file_mappings): + print '\t{0}: {1}'.format(ext, file_mappings[ext]) + return + # No summary. + for filename in options.arguments: + if not options.options.quiet: + print '====================>', filename + with open(filename) as fp: + if filename.endswith('.pck'): + msg = load(fp) + data = load(fp) + if data.get('_parsemsg'): + sys.stdout.write(msg) + else: + sys.stdout.write(msg.as_string()) + else: + sys.stdout.write(fp.read()) + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/unshunt.py b/src/mailman/bin/unshunt.py new file mode 100644 index 000000000..fc889377c --- /dev/null +++ b/src/mailman/bin/unshunt.py @@ -0,0 +1,51 @@ +# Copyright (C) 2002-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +__metaclass__ = type +__all__ = [ + 'main', + ] + + +import sys + +from mailman.config import config +from mailman.i18n import _ +from mailman.options import Options + + + +def main(): + options = Options() + options.initialize() + + switchboard = config.switchboards['shunt'] + switchboard.recover_backup_files() + + for filebase in switchboard.files: + try: + msg, msgdata = switchboard.dequeue(filebase) + whichq = msgdata.get('whichq', 'in') + config.switchboards[whichq].enqueue(msg, msgdata) + except Exception, e: + # If there are any unshunting errors, log them and continue trying + # other shunted messages. + print >> sys.stderr, _( + 'Cannot unshunt message $filebase, skipping:\n$e') + else: + # Unlink the .bak file left by dequeue() + switchboard.finish(filebase) diff --git a/src/mailman/bin/update.py b/src/mailman/bin/update.py new file mode 100644 index 000000000..34ea6cda3 --- /dev/null +++ b/src/mailman/bin/update.py @@ -0,0 +1,660 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import md5 +import sys +import time +import email +import errno +import shutil +import cPickle +import marshal +import optparse + +from locknix.lockfile import TimeOutError + +from mailman import MailList +from mailman import Message +from mailman import Pending +from mailman import Utils +from mailman import version +from mailman.MemberAdaptor import BYBOUNCE, ENABLED +from mailman.OldStyleMemberships import OldStyleMemberships +from mailman.Queue.Switchboard import Switchboard +from mailman.configuration import config +from mailman.i18n import _ +from mailman.initialize import initialize +from mailman.utilities.filesystem import makedirs + + +FRESH = 0 +NOTFRESH = -1 + + + +def parseargs(): + parser = optparse.OptionParser(version=version.MAILMAN_VERSION, + usage=_("""\ +Perform all necessary upgrades. + +%prog [options]""")) + parser.add_option('-f', '--force', + default=False, action='store_true', help=_("""\ +Force running the upgrade procedures. Normally, if the version number of the +installed Mailman matches the current version number (or a 'downgrade' is +detected), nothing will be done.""")) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + if args: + parser.print_help() + print >> sys.stderr, _('Unexpected arguments') + sys.exit(1) + return parser, opts, args + + + +def calcversions(): + # Returns a tuple of (lastversion, thisversion). If the last version + # could not be determined, lastversion will be FRESH or NOTFRESH, + # depending on whether this installation appears to be fresh or not. The + # determining factor is whether there are files in the $var_prefix/logs + # subdir or not. The version numbers are HEX_VERSIONs. + # + # See if we stored the last updated version + lastversion = None + thisversion = version.HEX_VERSION + try: + fp = open(os.path.join(config.DATA_DIR, 'last_mailman_version')) + data = fp.read() + fp.close() + lastversion = int(data, 16) + except (IOError, ValueError): + pass + # + # try to figure out if this is a fresh install + if lastversion is None: + lastversion = FRESH + try: + if os.listdir(config.LOG_DIR): + lastversion = NOTFRESH + except OSError: + pass + return (lastversion, thisversion) + + + +def makeabs(relpath): + return os.path.join(config.PREFIX, relpath) + + +def make_varabs(relpath): + return os.path.join(config.VAR_PREFIX, relpath) + + + +def move_language_templates(mlist): + listname = mlist.internal_name() + print _('Fixing language templates: $listname') + # Mailman 2.1 has a new cascading search for its templates, defined and + # described in Utils.py:maketext(). Putting templates in the top level + # templates/ subdir or the lists/<listname> subdir is deprecated and no + # longer searched.. + # + # What this means is that most templates can live in the global templates/ + # subdirectory, and only needs to be copied into the list-, vhost-, or + # site-specific language directories when needed. + # + # Also, by default all standard (i.e. English) templates must now live in + # the templates/en directory. This update cleans up all the templates, + # deleting more-specific duplicates (as calculated by md5 checksums) in + # favor of more-global locations. + # + # First, get rid of any lists/<list> template or lists/<list>/en template + # that is identical to the global templates/* default. + for gtemplate in os.listdir(os.path.join(config.TEMPLATE_DIR, 'en')): + # BAW: get rid of old templates, e.g. admlogin.txt and + # handle_opts.html + try: + fp = open(os.path.join(config.TEMPLATE_DIR, gtemplate)) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + # No global template + continue + gcksum = md5.new(fp.read()).digest() + fp.close() + # Match against the lists/<list>/* template + try: + fp = open(os.path.join(mlist.fullpath(), gtemplate)) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + else: + tcksum = md5.new(fp.read()).digest() + fp.close() + if gcksum == tcksum: + os.unlink(os.path.join(mlist.fullpath(), gtemplate)) + # Match against the lists/<list>/*.prev template + try: + fp = open(os.path.join(mlist.fullpath(), gtemplate + '.prev')) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + else: + tcksum = md5.new(fp.read()).digest() + fp.close() + if gcksum == tcksum: + os.unlink(os.path.join(mlist.fullpath(), gtemplate + '.prev')) + # Match against the lists/<list>/en/* templates + try: + fp = open(os.path.join(mlist.fullpath(), 'en', gtemplate)) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + else: + tcksum = md5.new(fp.read()).digest() + fp.close() + if gcksum == tcksum: + os.unlink(os.path.join(mlist.fullpath(), 'en', gtemplate)) + # Match against the templates/* template + try: + fp = open(os.path.join(config.TEMPLATE_DIR, gtemplate)) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + else: + tcksum = md5.new(fp.read()).digest() + fp.close() + if gcksum == tcksum: + os.unlink(os.path.join(config.TEMPLATE_DIR, gtemplate)) + # Match against the templates/*.prev template + try: + fp = open(os.path.join(config.TEMPLATE_DIR, gtemplate + '.prev')) + except IOError, e: + if e.errno <> errno.ENOENT: + raise + else: + tcksum = md5.new(fp.read()).digest() + fp.close() + if gcksum == tcksum: + os.unlink(os.path.join(config.TEMPLATE_DIR, + gtemplate + '.prev')) + + + +def situate_list(listname): + # This turns the directory called 'listname' into a directory called + # 'listname@domain'. Start by finding out what the domain should be. + # A list's domain is its email host. + mlist = MailList.MailList(listname, lock=False, check_version=False) + fullname = mlist.fqdn_listname + oldpath = os.path.join(config.VAR_PREFIX, 'lists', listname) + newpath = os.path.join(config.VAR_PREFIX, 'lists', fullname) + if os.path.exists(newpath): + print >> sys.stderr, _('WARNING: could not situate list: $listname') + else: + os.rename(oldpath, newpath) + print _('situated list $listname to $fullname') + return fullname + + + +def dolist(listname): + mlist = MailList.MailList(listname, lock=False) + try: + mlist.Lock(0.5) + except TimeOutError: + print >> sys.stderr, _( + 'WARNING: could not acquire lock for list: $listname') + return 1 + # Sanity check the invariant that every BYBOUNCE disabled member must have + # bounce information. Some earlier betas broke this. BAW: we're + # submerging below the MemberAdaptor interface, so skip this if we're not + # using OldStyleMemberships. + if isinstance(mlist._memberadaptor, OldStyleMemberships): + noinfo = {} + for addr, (reason, when) in mlist.delivery_status.items(): + if reason == BYBOUNCE and not mlist.bounce_info.has_key(addr): + noinfo[addr] = reason, when + # What to do about these folks with a BYBOUNCE delivery status and no + # bounce info? This number should be very small, and I think it's + # fine to simple re-enable them and let the bounce machinery + # re-disable them if necessary. + n = len(noinfo) + if n > 0: + print _( + 'Resetting $n BYBOUNCEs disabled addrs with no bounce info') + for addr in noinfo.keys(): + mlist.setDeliveryStatus(addr, ENABLED) + + mbox_dir = make_varabs('archives/private/%s.mbox' % (listname)) + mbox_file = make_varabs('archives/private/%s.mbox/%s' % (listname, + listname)) + o_pub_mbox_file = make_varabs('archives/public/%s' % (listname)) + o_pri_mbox_file = make_varabs('archives/private/%s' % (listname)) + html_dir = o_pri_mbox_file + o_html_dir = makeabs('public_html/archives/%s' % (listname)) + # Make the mbox directory if it's not there. + if not os.path.exists(mbox_dir): + makedirs(mbox_dir) + else: + # This shouldn't happen, but hey, just in case + if not os.path.isdir(mbox_dir): + print _("""\ +For some reason, $mbox_dir exists as a file. This won't work with b6, so I'm +renaming it to ${mbox_dir}.tmp and proceeding.""") + os.rename(mbox_dir, "%s.tmp" % (mbox_dir)) + makedirs(mbox_dir) + # Move any existing mboxes around, but watch out for both a public and a + # private one existing + if os.path.isfile(o_pri_mbox_file) and os.path.isfile(o_pub_mbox_file): + if mlist.archive_private: + print _("""\ + +$listname has both public and private mbox archives. Since this list +currently uses private archiving, I'm installing the private mbox archive -- +$o_pri_mbox_file -- as the active archive, and renaming + $o_pub_mbox_file +to + ${o_pub_mbox_file}.preb6 + +You can integrate that into the archives if you want by using the 'arch' +script. +""") % (mlist._internal_name, o_pri_mbox_file, o_pub_mbox_file, + o_pub_mbox_file) + os.rename(o_pub_mbox_file, "%s.preb6" % (o_pub_mbox_file)) + else: + print _("""\ +$mlist._internal_name has both public and private mbox archives. Since this +list currently uses public archiving, I'm installing the public mbox file +archive file ($o_pub_mbox_file) as the active one, and renaming +$o_pri_mbox_file to ${o_pri_mbox_file}.preb6 + +You can integrate that into the archives if you want by using the 'arch' +script. +""") + os.rename(o_pri_mbox_file, "%s.preb6" % (o_pri_mbox_file)) + # Move private archive mbox there if it's around + # and take into account all sorts of absurdities + print _('- updating old private mbox file') + if os.path.exists(o_pri_mbox_file): + if os.path.isfile(o_pri_mbox_file): + os.rename(o_pri_mbox_file, mbox_file) + elif not os.path.isdir(o_pri_mbox_file): + newname = "%s.mm_install-dunno_what_this_was_but_its_in_the_way" \ + % o_pri_mbox_file + os.rename(o_pri_mbox_file, newname) + print _("""\ + unknown file in the way, moving + $o_pri_mbox_file + to + $newname""") + else: + # directory + print _("""\ + looks like you have a really recent development installation... + you're either one brave soul, or you already ran me""") + # Move public archive mbox there if it's around + # and take into account all sorts of absurdities. + print _('- updating old public mbox file') + if os.path.exists(o_pub_mbox_file): + if os.path.isfile(o_pub_mbox_file): + os.rename(o_pub_mbox_file, mbox_file) + elif not os.path.isdir(o_pub_mbox_file): + newname = "%s.mm_install-dunno_what_this_was_but_its_in_the_way" \ + % o_pub_mbox_file + os.rename(o_pub_mbox_file, newname) + print _("""\ + unknown file in the way, moving + $o_pub_mbox_file + to + $newname""") + else: # directory + print _("""\ + looks like you have a really recent development installation... + you're either one brave soul, or you already ran me""") + # Move the html archives there + if os.path.isdir(o_html_dir): + os.rename(o_html_dir, html_dir) + # chmod the html archives + os.chmod(html_dir, 02775) + # BAW: Is this still necessary?! + mlist.Save() + # Check to see if pre-b4 list-specific templates are around + # and move them to the new place if there's not already + # a new one there + tmpl_dir = os.path.join(config.PREFIX, "templates") + list_dir = os.path.join(config.PREFIX, "lists") + b4_tmpl_dir = os.path.join(tmpl_dir, mlist._internal_name) + new_tmpl_dir = os.path.join(list_dir, mlist._internal_name) + if os.path.exists(b4_tmpl_dir): + print _("""\ +- This list looks like it might have <= b4 list templates around""") + for f in os.listdir(b4_tmpl_dir): + o_tmpl = os.path.join(b4_tmpl_dir, f) + n_tmpl = os.path.join(new_tmpl_dir, f) + if os.path.exists(o_tmpl): + if not os.path.exists(n_tmpl): + os.rename(o_tmpl, n_tmpl) + print _('- moved $o_tmpl to $n_tmpl') + else: + print _("""\ +- both $o_tmpl and $n_tmpl exist, leaving untouched""") + else: + print _("""\ +- $o_tmpl doesn't exist, leaving untouched""") + # Move all the templates to the en language subdirectory as required for + # Mailman 2.1 + move_language_templates(mlist) + # Avoid eating filehandles with the list lockfiles + mlist.Unlock() + return 0 + + + +def archive_path_fixer(unused_arg, dir, files): + # Passed to os.path.walk to fix the perms on old html archives. + for f in files: + abs = os.path.join(dir, f) + if os.path.isdir(abs): + if f == "database": + os.chmod(abs, 02770) + else: + os.chmod(abs, 02775) + elif os.path.isfile(abs): + os.chmod(abs, 0664) + + +def remove_old_sources(module): + # Also removes old directories. + src = '%s/%s' % (config.PREFIX, module) + pyc = src + "c" + if os.path.isdir(src): + print _('removing directory $src and everything underneath') + shutil.rmtree(src) + elif os.path.exists(src): + print _('removing $src') + try: + os.unlink(src) + except os.error, rest: + print _("Warning: couldn't remove $src -- $rest") + if module.endswith('.py') and os.path.exists(pyc): + try: + os.unlink(pyc) + except OSError, rest: + print _("couldn't remove old file $pyc -- $rest") + + + +def update_qfiles(): + print _('updating old qfiles') + prefix = `time.time()` + '+' + # Be sure the qfiles/in directory exists (we don't really need the + # switchboard object, but it's convenient for creating the directory). + sb = Switchboard(config.INQUEUE_DIR) + for filename in os.listdir(config.QUEUE_DIR): + # Updating means just moving the .db and .msg files to qfiles/in where + # it should be dequeued, converted, and processed normally. + if os.path.splitext(filename) == '.msg': + oldmsgfile = os.path.join(config.QUEUE_DIR, filename) + newmsgfile = os.path.join(config.INQUEUE_DIR, prefix + filename) + os.rename(oldmsgfile, newmsgfile) + elif os.path.splitext(filename) == '.db': + olddbfile = os.path.join(config.QUEUE_DIR, filename) + newdbfile = os.path.join(config.INQUEUE_DIR, prefix + filename) + os.rename(olddbfile, newdbfile) + # Now update for the Mailman 2.1.5 qfile format. For every filebase in + # the qfiles/* directories that has both a .pck and a .db file, pull the + # data out and re-queue them. + for dirname in os.listdir(config.QUEUE_DIR): + dirpath = os.path.join(config.QUEUE_DIR, dirname) + if dirpath == config.BADQUEUE_DIR: + # The files in qfiles/bad can't possibly be pickles + continue + sb = Switchboard(dirpath) + try: + for filename in os.listdir(dirpath): + filepath = os.path.join(dirpath, filename) + filebase, ext = os.path.splitext(filepath) + # Handle the .db metadata files as part of the handling of the + # .pck or .msg message files. + if ext not in ('.pck', '.msg'): + continue + msg, data = dequeue(filebase) + if msg is not None and data is not None: + sb.enqueue(msg, data) + except EnvironmentError, e: + if e.errno <> errno.ENOTDIR: + raise + print _('Warning! Not a directory: $dirpath') + + + +# Implementations taken from the pre-2.1.5 Switchboard +def ext_read(filename): + fp = open(filename) + d = marshal.load(fp) + # Update from version 2 files + if d.get('version', 0) == 2: + del d['filebase'] + # Do the reverse conversion (repr -> float) + for attr in ['received_time']: + try: + sval = d[attr] + except KeyError: + pass + else: + # Do a safe eval by setting up a restricted execution + # environment. This may not be strictly necessary since we + # know they are floats, but it can't hurt. + d[attr] = eval(sval, {'__builtins__': {}}) + fp.close() + return d + + +def dequeue(filebase): + # Calculate the .db and .msg filenames from the given filebase. + msgfile = os.path.join(filebase + '.msg') + pckfile = os.path.join(filebase + '.pck') + dbfile = os.path.join(filebase + '.db') + # Now we are going to read the message and metadata for the given + # filebase. We want to read things in this order: first, the metadata + # file to find out whether the message is stored as a pickle or as + # plain text. Second, the actual message file. However, we want to + # first unlink the message file and then the .db file, because the + # qrunner only cues off of the .db file + msg = None + try: + data = ext_read(dbfile) + os.unlink(dbfile) + except EnvironmentError, e: + if e.errno <> errno.ENOENT: + raise + data = {} + # Between 2.1b4 and 2.1b5, the `rejection-notice' key in the metadata + # was renamed to `rejection_notice', since dashes in the keys are not + # supported in METAFMT_ASCII. + if data.has_key('rejection-notice'): + data['rejection_notice'] = data['rejection-notice'] + del data['rejection-notice'] + msgfp = None + try: + try: + msgfp = open(pckfile) + msg = cPickle.load(msgfp) + os.unlink(pckfile) + except EnvironmentError, e: + if e.errno <> errno.ENOENT: raise + msgfp = None + try: + msgfp = open(msgfile) + msg = email.message_from_file(msgfp, Message.Message) + os.unlink(msgfile) + except EnvironmentError, e: + if e.errno <> errno.ENOENT: raise + except (email.Errors.MessageParseError, ValueError), e: + # This message was unparsable, most likely because its + # MIME encapsulation was broken. For now, there's not + # much we can do about it. + print _('message is unparsable: $filebase') + msgfp.close() + msgfp = None + if config.QRUNNER_SAVE_BAD_MESSAGES: + # Cheapo way to ensure the directory exists w/ the + # proper permissions. + sb = Switchboard(config.BADQUEUE_DIR) + os.rename(msgfile, os.path.join( + config.BADQUEUE_DIR, filebase + '.txt')) + else: + os.unlink(msgfile) + msg = data = None + except EOFError: + # For some reason the pckfile was empty. Just delete it. + print _('Warning! Deleting empty .pck file: $pckfile') + os.unlink(pckfile) + finally: + if msgfp: + msgfp.close() + return msg, data + + + +def main(): + parser, opts, args = parseargs() + initialize(opts.config) + + # calculate the versions + lastversion, thisversion = calcversions() + hexlversion = hex(lastversion) + hextversion = hex(thisversion) + if lastversion == thisversion and not opts.force: + # nothing to do + print _('No updates are necessary.') + sys.exit(0) + if lastversion > thisversion and not opts.force: + print _("""\ +Downgrade detected, from version $hexlversion to version $hextversion +This is probably not safe. +Exiting.""") + sys.exit(1) + print _('Upgrading from version $hexlversion to $hextversion') + errors = 0 + # get rid of old stuff + print _('getting rid of old source files') + for mod in ('mailman/Archiver.py', 'mailman/HyperArch.py', + 'mailman/HyperDatabase.py', 'mailman/pipermail.py', + 'mailman/smtplib.py', 'mailman/Cookie.py', + 'bin/update_to_10b6', 'scripts/mailcmd', + 'scripts/mailowner', 'mail/wrapper', 'mailman/pythonlib', + 'cgi-bin/archives', 'mailman/MailCommandHandler'): + remove_old_sources(mod) + if not config.list_manager.names: + print _('no lists == nothing to do, exiting') + return + # For people with web archiving, make sure the directories + # in the archiving are set with proper perms for b6. + if os.path.isdir("%s/public_html/archives" % config.PREFIX): + print _("""\ +fixing all the perms on your old html archives to work with b6 +If your archives are big, this could take a minute or two...""") + os.path.walk("%s/public_html/archives" % config.PREFIX, + archive_path_fixer, "") + print _('done') + for listname in config.list_manager.names: + # With 2.2.0a0, all list names grew an @domain suffix. If you find a + # list without that, move it now. + if not '@' in listname: + listname = situate_list(listname) + print _('Updating mailing list: $listname') + errors += dolist(listname) + print + print _('Updating Usenet watermarks') + wmfile = os.path.join(config.DATA_DIR, 'gate_watermarks') + try: + fp = open(wmfile) + except IOError: + print _('- nothing to update here') + else: + d = marshal.load(fp) + fp.close() + for listname in d.keys(): + if listname not in listnames: + # this list no longer exists + continue + mlist = MailList.MailList(listname, lock=0) + try: + mlist.Lock(0.5) + except TimeOutError: + print >> sys.stderr, _( + 'WARNING: could not acquire lock for list: $listname') + errors = errors + 1 + else: + # Pre 1.0b7 stored 0 in the gate_watermarks file to indicate + # that no gating had been done yet. Without coercing this to + # None, the list could now suddenly get flooded. + mlist.usenet_watermark = d[listname] or None + mlist.Save() + mlist.Unlock() + os.unlink(wmfile) + print _('- usenet watermarks updated and gate_watermarks removed') + # In Mailman 2.1, the qfiles directory has a different structure and a + # different content. Also, in Mailman 2.1.5 we collapsed the message + # files from separate .msg (pickled Message objects) and .db (marshalled + # dictionaries) to a shared .pck file containing two pickles. + update_qfiles() + # This warning was necessary for the upgrade from 1.0b9 to 1.0b10. + # There's no good way of figuring this out for releases prior to 2.0beta2 + # :( + if lastversion == NOTFRESH: + print _(""" + +NOTE NOTE NOTE NOTE NOTE + + You are upgrading an existing Mailman installation, but I can't tell what + version you were previously running. + + If you are upgrading from Mailman 1.0b9 or earlier you will need to + manually update your mailing lists. For each mailing list you need to + copy the file templates/options.html lists/<listname>/options.html. + + However, if you have edited this file via the Web interface, you will have + to merge your changes into this file, otherwise you will lose your + changes. + +NOTE NOTE NOTE NOTE NOTE + +""") + if not errors: + # Record the version we just upgraded to + fp = open(os.path.join(config.DATA_DIR, 'last_mailman_version'), 'w') + fp.write(hex(config.HEX_VERSION) + '\n') + fp.close() + else: + lockdir = config.LOCK_DIR + print _('''\ + +ERROR: + +The locks for some lists could not be acquired. This means that either +Mailman was still active when you upgraded, or there were stale locks in the +$lockdir directory. + +You must put Mailman into a quiescent state and remove all stale locks, then +re-run "make update" manually. See the INSTALL and UPGRADE files for details. +''') diff --git a/src/mailman/bin/version.py b/src/mailman/bin/version.py new file mode 100644 index 000000000..0fb2c5a5b --- /dev/null +++ b/src/mailman/bin/version.py @@ -0,0 +1,46 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import optparse + +from mailman import version +from mailman.i18n import _ + + + +def parseargs(): + parser = optparse.OptionParser(version=version.MAILMAN_VERSION, + usage=_("""\ +%prog + +Print the Mailman version and exit.""")) + opts, args = parser.parse_args() + if args: + parser.error(_('Unexpected arguments')) + return parser, opts, args + + + +def main(): + parser, opts, args = parseargs() + # Yes, this is kind of silly + print _('Using $version.MAILMAN_VERSION ($version.CODENAME)') + + + +if __name__ == '__main__': + main() diff --git a/src/mailman/bin/withlist.py b/src/mailman/bin/withlist.py new file mode 100644 index 000000000..8f2d8a2b5 --- /dev/null +++ b/src/mailman/bin/withlist.py @@ -0,0 +1,220 @@ +# Copyright (C) 1998-2009 by the Free Software Foundation, Inc. +# +# This file is part of GNU Mailman. +# +# GNU Mailman is free software: you can redistribute it and/or modify it under +# the terms of the GNU General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) +# any later version. +# +# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or +# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for +# more details. +# +# You should have received a copy of the GNU General Public License along with +# GNU Mailman. If not, see <http://www.gnu.org/licenses/>. + +import os +import sys +import optparse + +from mailman import interact +from mailman.config import config +from mailman.core.initialize import initialize +from mailman.i18n import _ +from mailman.version import MAILMAN_VERSION + + +LAST_MLIST = None +VERBOSE = True + + + +def do_list(listname, args, func): + global LAST_MLIST + + if '@' not in listname: + listname += '@' + config.DEFAULT_EMAIL_HOST + + # XXX FIXME Remove this when this script is converted to + # MultipleMailingListOptions. + listname = listname.decode(sys.getdefaultencoding()) + mlist = config.db.list_manager.get(listname) + if mlist is None: + print >> sys.stderr, _('Unknown list: $listname') + else: + if VERBOSE: + print >> sys.stderr, _('Loaded list: $listname') + LAST_MLIST = mlist + # Try to import the module and run the callable. + if func: + return func(mlist, *args) + return None + + + +def parseargs(): + parser = optparse.OptionParser(version=MAILMAN_VERSION, + usage=_("""\ +%prog [options] listname [args ...] + +General framework for interacting with a mailing list object. + +There are two ways to use this script: interactively or programmatically. +Using it interactively allows you to play with, examine and modify a +IMailinglist object from Python's interactive interpreter. When running +interactively, a IMailingList object called 'm' will be available in the +global namespace. + +Programmatically, you can write a function to operate on a IMailingList +object, and this script will take care of the housekeeping (see below for +examples). In that case, the general usage syntax is: + + % bin/withlist [options] listname [args ...] + +Here's an example of how to use the -r option. Say you have a file in the +Mailman installation directory called 'listaddr.py', with the following +two functions: + + def listaddr(mlist): + print mlist.posting_address + + def requestaddr(mlist): + print mlist.request_address + +Now, from the command line you can print the list's posting address by running +the following from the command line: + + % bin/withlist -r listaddr mylist + Loading list: mylist + Importing listaddr ... + Running listaddr.listaddr() ... + mylist@myhost.com + +And you can print the list's request address by running: + + % bin/withlist -r listaddr.requestaddr mylist + Loading list: mylist + Importing listaddr ... + Running listaddr.requestaddr() ... + mylist-request@myhost.com + +As another example, say you wanted to change the password for a particular +user on a particular list. You could put the following function in a file +called 'changepw.py': + + from mailman.errors import NotAMemberError + + def changepw(mlist, addr, newpasswd): + try: + mlist.setMemberPassword(addr, newpasswd) + mlist.Save() + except NotAMemberError: + print 'No address matched:', addr + +and run this from the command line: + + % bin/withlist -l -r changepw mylist somebody@somewhere.org foobar""")) + parser.add_option('-i', '--interactive', + default=None, action='store_true', help=_("""\ +Leaves you at an interactive prompt after all other processing is complete. +This is the default unless the -r option is given.""")) + parser.add_option('-r', '--run', + type='string', help=_("""\ +This can be used to run a script with the opened IMailingList object. This +works by attempting to import'module' (which must be in the directory +containing withlist, or already be accessible on your sys.path), and then +calling 'callable' from the module. callable can be a class or function; it +is called with the IMailingList object as the first argument. If additional +args are given on the command line, they are passed as subsequent positional +args to the callable. + +Note that 'module.' is optional; if it is omitted then a module with the name +'callable' will be imported. + +The global variable 'r' will be set to the results of this call.""")) + parser.add_option('-a', '--all', + default=False, action='store_true', help=_("""\ +This option only works with the -r option. Use this if you want to execute +the script on all mailing lists. When you use -a you should not include a +listname argument on the command line. The variable 'r' will be a list of all +the results.""")) + parser.add_option('-q', '--quiet', + default=False, action='store_true', + help=_('Suppress all status messages.')) + parser.add_option('-C', '--config', + help=_('Alternative configuration file to use')) + opts, args = parser.parse_args() + return parser, opts, args + + + +def main(): + global VERBOSE + + parser, opts, args = parseargs() + config_file = (os.getenv('MAILMAN_CONFIG_FILE') + if opts.config is None + else opts.config) + initialize(config_file, not opts.quiet) + + VERBOSE = not opts.quiet + # The default for interact is true unless -r was given + if opts.interactive is None: + if not opts.run: + opts.interactive = True + else: + opts.interactive = False + + dolist = True + if len(args) < 1 and not opts.all: + warning = _('No list name supplied.') + if opts.interactive: + # Let them keep going + print >> sys.stderr, warning + dolist = False + else: + parser.error(warning) + + if opts.all and not opts.run: + parser.error(_('--all requires --run')) + + # Try to import the module for the callable + func = None + if opts.run: + i = opts.run.rfind('.') + if i < 0: + module = opts.run + callable = opts.run + else: + module = opts.run[:i] + callable = opts.run[i+1:] + if VERBOSE: + print >> sys.stderr, _('Importing $module ...') + __import__(module) + mod = sys.modules[module] + if VERBOSE: + print >> sys.stderr, _('Running ${module}.${callable}() ...') + func = getattr(mod, callable) + + r = None + if opts.all: + r = [do_list(listname, args, func) + for listname in config.list_manager.names] + elif dolist: + listname = args.pop(0).lower().strip() + r = do_list(listname, args, func) + + # Now go to interactive mode, perhaps + if opts.interactive: + if dolist: + banner = _( + "The variable 'm' is the $listname mailing list") + else: + banner = interact.DEFAULT_BANNER + overrides = dict(m=LAST_MLIST, r=r, + commit=config.db.commit, + abort=config.db.abort, + config=config) + interact.interact(upframe=False, banner=banner, overrides=overrides) |
