diff options
author | <> | 2003-01-02 05:25:50 +0000 |
---|---|---|
committer | <> | 2003-01-02 05:25:50 +0000 |
commit | b132a73f15e432eaf43310fce9196ca0c0651465 (patch) | |
tree | c15f816ba7c4de99fef510e3bd75af0890d47441 /Mailman/ListAdmin.py | |
download | mailman2-b132a73f15e432eaf43310fce9196ca0c0651465.tar.gz mailman2-b132a73f15e432eaf43310fce9196ca0c0651465.tar.xz mailman2-b132a73f15e432eaf43310fce9196ca0c0651465.zip |
This commit was manufactured by cvs2svn to create branch
'Release_2_1-maint'.
Diffstat (limited to '')
-rw-r--r-- | Mailman/ListAdmin.py | 579 |
1 files changed, 579 insertions, 0 deletions
diff --git a/Mailman/ListAdmin.py b/Mailman/ListAdmin.py new file mode 100644 index 00000000..82eedc80 --- /dev/null +++ b/Mailman/ListAdmin.py @@ -0,0 +1,579 @@ +# Copyright (C) 1998,1999,2000,2001,2002 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. + +"""Mixin class for MailList which handles administrative requests. + +Two types of admin requests are currently supported: adding members to a +closed or semi-closed list, and moderated posts. + +Pending subscriptions which are requiring a user's confirmation are handled +elsewhere. +""" + +import os +import time +import marshal +import errno +import cPickle +from cStringIO import StringIO + +import email +from email.MIMEMessage import MIMEMessage +from email.Generator import Generator +from email.Utils import getaddresses + +from Mailman import mm_cfg +from Mailman import Utils +from Mailman import Message +from Mailman import Errors +from Mailman.UserDesc import UserDesc +from Mailman.Queue.sbcache import get_switchboard +from Mailman.Logging.Syslog import syslog +from Mailman import i18n + +_ = i18n._ + +# Request types requiring admin approval +IGN = 0 +HELDMSG = 1 +SUBSCRIPTION = 2 +UNSUBSCRIPTION = 3 + +# Return status from __handlepost() +DEFER = 0 +REMOVE = 1 +LOST = 2 + +DASH = '-' +NL = '\n' + + + +class ListAdmin: + def InitVars(self): + # non-configurable data + self.next_request_id = 1 + + def InitTempVars(self): + self.__db = None + + def __filename(self): + return os.path.join(self.fullpath(), 'request.db') + + def __opendb(self): + filename = self.__filename() + if self.__db is None: + assert self.Locked() + try: + fp = open(filename) + self.__db = marshal.load(fp) + fp.close() + except IOError, e: + if e.errno <> errno.ENOENT: raise + self.__db = {} + except EOFError, e: + # The unmarshalling failed, which means the file is corrupt. + # Sigh. Start over. + syslog('error', + 'request.db file corrupt for list %s, blowing it away.', + self.internal_name()) + self.__db = {} + # Migrate pre-2.1a3 held subscription records to include the + # fullname data field. + type, version = self.__db.get('version', (IGN, None)) + if version is None: + # No previous revisiont number, must be upgrading to 2.1a3 or + # beyond from some unknown earlier version. + for id, (type, data) in self.__db.items(): + if id == IGN: + pass + elif id == HELDMSG and len(data) == 5: + # tack on a msgdata dictionary + self.__db[id] = data + ({},) + elif id == SUBSCRIPTION and len(data) == 5: + # a fullname field was added + stime, addr, password, digest, lang = data + self.__db[id] = stime, addr, '', password, digest, lang + + + def __closedb(self): + if self.__db is not None: + assert self.Locked() + # Save the version number + self.__db['version'] = IGN, mm_cfg.REQUESTS_FILE_SCHEMA_VERSION + # Now save a temp file and do the tmpfile->real file dance. BAW: + # should we be as paranoid as for the config.pck file? Should we + # use pickle? + tmpfile = self.__filename() + '.tmp' + omask = os.umask(002) + try: + fp = open(tmpfile, 'w') + marshal.dump(self.__db, fp) + fp.close() + self.__db = None + finally: + os.umask(omask) + # Do the dance + os.rename(tmpfile, self.__filename()) + + def __request_id(self): + id = self.next_request_id + self.next_request_id += 1 + return id + + def SaveRequestsDb(self): + self.__closedb() + + def NumRequestsPending(self): + self.__opendb() + # Subtrace one for the version pseudo-entry + if self.__db.has_key('version'): + return len(self.__db) - 1 + return len(self.__db) + + def __getmsgids(self, rtype): + self.__opendb() + ids = [k for k, (type, data) in self.__db.items() if type == rtype] + ids.sort() + return ids + + def GetHeldMessageIds(self): + return self.__getmsgids(HELDMSG) + + def GetSubscriptionIds(self): + return self.__getmsgids(SUBSCRIPTION) + + def GetUnsubscriptionIds(self): + return self.__getmsgids(UNSUBSCRIPTION) + + def GetRecord(self, id): + self.__opendb() + type, data = self.__db[id] + return data + + def GetRecordType(self, id): + self.__opendb() + type, data = self.__db[id] + return type + + def HandleRequest(self, id, value, comment=None, preserve=None, + forward=None, addr=None): + self.__opendb() + rtype, data = self.__db[id] + if rtype == HELDMSG: + status = self.__handlepost(data, value, comment, preserve, + forward, addr) + elif rtype == UNSUBSCRIPTION: + status = self.__handleunsubscription(data, value, comment) + else: + assert rtype == SUBSCRIPTION + status = self.__handlesubscription(data, value, comment) + if status <> DEFER: + # BAW: Held message ids are linked to Pending cookies, allowing + # the user to cancel their post before the moderator has approved + # it. We should probably remove the cookie associated with this + # id, but we have no way currently of correlating them. :( + del self.__db[id] + + def HoldMessage(self, msg, reason, msgdata={}): + # Make a copy of msgdata so that subsequent changes won't corrupt the + # request database. TBD: remove the `filebase' key since this will + # not be relevant when the message is resurrected. + newmsgdata = {} + newmsgdata.update(msgdata) + msgdata = newmsgdata + # assure that the database is open for writing + self.__opendb() + # get the next unique id + id = self.__request_id() + assert not self.__db.has_key(id) + # get the message sender + sender = msg.get_sender() + # calculate the file name for the message text and write it to disk + if mm_cfg.HOLD_MESSAGES_AS_PICKLES: + ext = 'pck' + else: + ext = 'txt' + filename = 'heldmsg-%s-%d.%s' % (self.internal_name(), id, ext) + omask = os.umask(002) + fp = None + try: + fp = open(os.path.join(mm_cfg.DATA_DIR, filename), 'w') + if mm_cfg.HOLD_MESSAGES_AS_PICKLES: + cPickle.dump(msg, fp, 1) + else: + g = Generator(fp) + g(msg, 1) + finally: + if fp: + fp.close() + os.umask(omask) + # save the information to the request database. for held message + # entries, each record in the database will be of the following + # format: + # + # the time the message was received + # the sender of the message + # the message's subject + # a string description of the problem + # name of the file in $PREFIX/data containing the msg text + # an additional dictionary of message metadata + # + msgsubject = msg.get('subject', _('(no subject)')) + data = time.time(), sender, msgsubject, reason, filename, msgdata + self.__db[id] = (HELDMSG, data) + return id + + def __handlepost(self, record, value, comment, preserve, forward, addr): + # For backwards compatibility with pre 2.0beta3 + ptime, sender, subject, reason, filename, msgdata = record + path = os.path.join(mm_cfg.DATA_DIR, filename) + # Handle message preservation + if preserve: + parts = os.path.split(path)[1].split(DASH) + parts[0] = 'spam' + spamfile = DASH.join(parts) + # Preserve the message as plain text, not as a pickle + try: + fp = open(path) + except IOError, e: + if e.errno <> errno.ENOENT: raise + return LOST + try: + msg = cPickle.load(fp) + finally: + fp.close() + # Save the plain text to a .msg file, not a .pck file + outpath = os.path.join(mm_cfg.SPAM_DIR, spamfile) + head, ext = os.path.splitext(outpath) + outpath = head + '.msg' + outfp = open(outpath, 'w') + try: + g = Generator(outfp) + g(msg, 1) + finally: + outfp.close() + # Now handle updates to the database + rejection = None + fp = None + msg = None + status = REMOVE + if value == mm_cfg.DEFER: + # Defer + status = DEFER + elif value == mm_cfg.APPROVE: + # Approved. + try: + msg = readMessage(path) + except IOError, e: + if e.errno <> errno.ENOENT: raise + return LOST + msg = readMessage(path) + msgdata['approved'] = 1 + # adminapproved is used by the Emergency handler + msgdata['adminapproved'] = 1 + # Calculate a new filebase for the approved message, otherwise + # delivery errors will cause duplicates. + try: + del msgdata['filebase'] + except KeyError: + pass + # Queue the file for delivery by qrunner. Trying to deliver the + # message directly here can lead to a huge delay in web + # turnaround. Log the moderation and add a header. + msg['X-Mailman-Approved-At'] = email.Utils.formatdate(localtime=1) + syslog('vette', 'held message approved, message-id: %s', + msg.get('message-id', 'n/a')) + # Stick the message back in the incoming queue for further + # processing. + inq = get_switchboard(mm_cfg.INQUEUE_DIR) + inq.enqueue(msg, _metadata=msgdata) + elif value == mm_cfg.REJECT: + # Rejected + rejection = 'Refused' + self.__refuse(_('Posting of your message titled "%(subject)s"'), + sender, comment or _('[No reason given]'), + lang=self.getMemberLanguage(sender)) + else: + assert value == mm_cfg.DISCARD + # Discarded + rejection = 'Discarded' + # Forward the message + if forward and addr: + # If we've approved the message, we need to be sure to craft a + # completely unique second message for the forwarding operation, + # since we don't want to share any state or information with the + # normal delivery. + try: + copy = readMessage(path) + except IOError, e: + if e.errno <> errno.ENOENT: raise + raise Errors.LostHeldMessage(path) + # It's possible the addr is a comma separated list of addresses. + addrs = getaddresses([addr]) + if len(addrs) == 1: + realname, addr = addrs[0] + # If the address getting the forwarded message is a member of + # the list, we want the headers of the outer message to be + # encoded in their language. Otherwise it'll be the preferred + # language of the mailing list. + lang = self.getMemberLanguage(addr) + else: + # Throw away the realnames + addr = [a for realname, a in addrs] + # Which member language do we attempt to use? We could use + # the first match or the first address, but in the face of + # ambiguity, let's just use the list's preferred language + lang = self.preferred_language + otrans = i18n.get_translation() + i18n.set_language(lang) + try: + fmsg = Message.UserNotification( + addr, self.GetBouncesEmail(), + _('Forward of moderated message'), + lang=lang) + finally: + i18n.set_translation(otrans) + fmsg.set_type('message/rfc822') + fmsg.attach(copy) + fmsg.send(self) + # Log the rejection + if rejection: + note = '''%(listname)s: %(rejection)s posting: +\tFrom: %(sender)s +\tSubject: %(subject)s''' % { + 'listname' : self.internal_name(), + 'rejection': rejection, + 'sender' : sender.replace('%', '%%'), + 'subject' : subject.replace('%', '%%'), + } + if comment: + note += '\n\tReason: ' + comment.replace('%', '%%') + syslog('vette', note) + # Always unlink the file containing the message text. It's not + # necessary anymore, regardless of the disposition of the message. + if status <> DEFER: + try: + os.unlink(path) + except OSError, e: + if e.errno <> errno.ENOENT: raise + # We lost the message text file. Clean up our housekeeping + # and inform of this status. + return LOST + return status + + def HoldSubscription(self, addr, fullname, password, digest, lang): + # Assure that the database is open for writing + self.__opendb() + # Get the next unique id + id = self.__request_id() + assert not self.__db.has_key(id) + # + # Save the information to the request database. for held subscription + # entries, each record in the database will be one of the following + # format: + # + # the time the subscription request was received + # the subscriber's address + # the subscriber's selected password (TBD: is this safe???) + # the digest flag + # the user's preferred language + # + data = time.time(), addr, fullname, password, digest, lang + self.__db[id] = (SUBSCRIPTION, data) + # + # TBD: this really shouldn't go here but I'm not sure where else is + # appropriate. + syslog('vette', '%s: held subscription request from %s', + self.internal_name(), addr) + # Possibly notify the administrator in default list language + if self.admin_immed_notify: + realname = self.real_name + subject = _( + 'New subscription request to list %(realname)s from %(addr)s') + text = Utils.maketext( + 'subauth.txt', + {'username' : addr, + 'listname' : self.internal_name(), + 'hostname' : self.host_name, + 'admindb_url': self.GetScriptURL('admindb', absolute=1), + }, mlist=self) + # This message should appear to come from the <list>-owner so as + # to avoid any useless bounce processing. + owneraddr = self.GetOwnerEmail() + msg = Message.UserNotification(owneraddr, owneraddr, subject, text, + self.preferred_language) + msg.send(self, **{'tomoderators': 1}) + + def __handlesubscription(self, record, value, comment): + stime, addr, fullname, password, digest, lang = record + if value == mm_cfg.DEFER: + return DEFER + elif value == mm_cfg.DISCARD: + pass + elif value == mm_cfg.REJECT: + self.__refuse(_('Subscription request'), addr, + comment or _('[No reason given]'), + lang=lang) + else: + # subscribe + assert value == mm_cfg.SUBSCRIBE + try: + userdesc = UserDesc(addr, fullname, password, digest, lang) + self.ApprovedAddMember(userdesc) + except Errors.MMAlreadyAMember: + # User has already been subscribed, after sending the request + pass + # TBD: disgusting hack: ApprovedAddMember() can end up closing + # the request database. + self.__opendb() + return REMOVE + + def HoldUnsubscription(self, addr): + # Assure the database is open for writing + self.__opendb() + # Get the next unique id + id = self.__request_id() + assert not self.__db.has_key(id) + # All we need to do is save the unsubscribing address + self.__db[id] = (UNSUBSCRIPTION, addr) + syslog('vette', '%s: held unsubscription request from %s', + self.internal_name(), addr) + # Possibly notify the administrator of the hold + if self.admin_immed_notify: + realname = self.real_name + subject = _( + 'New unsubscription request from %(realname)s by %(addr)s') + text = Utils.maketext( + 'unsubauth.txt', + {'username' : addr, + 'listname' : self.internal_name(), + 'hostname' : self.host_name, + 'admindb_url': self.GetScriptURL('admindb', absolute=1), + }, mlist=self) + # This message should appear to come from the <list>-owner so as + # to avoid any useless bounce processing. + owneraddr = self.GetOwnerEmail() + msg = Message.UserNotification(owneraddr, owneraddr, subject, text, + self.preferred_language) + msg.send(self, **{'tomoderators': 1}) + + def __handleunsubscription(self, record, value, comment): + addr = record + if value == mm_cfg.DEFER: + return DEFER + elif value == mm_cfg.DISCARD: + pass + elif value == mm_cfg.REJECT: + self.__refuse(_('Unsubscription request'), addr, comment) + else: + assert value == mm_cfg.UNSUBSCRIBE + try: + self.ApprovedDeleteMember(addr) + except Errors.NotAMemberError: + # User has already been unsubscribed + pass + return REMOVE + + def __refuse(self, request, recip, comment, origmsg=None, lang=None): + # As this message is going to the requestor, try to set the language + # to his/her language choice, if they are a member. Otherwise use the + # list's preferred language. + realname = self.real_name + if lang is None: + lang = self.getMemberLanguage(recip) + text = Utils.maketext( + 'refuse.txt', + {'listname' : realname, + 'request' : request, + 'reason' : comment, + 'adminaddr': self.GetOwnerEmail(), + }, lang=lang, mlist=self) + otrans = i18n.get_translation() + i18n.set_language(lang) + try: + # add in original message, but not wrap/filled + if origmsg: + text = NL.join( + [text, + '---------- ' + _('Original Message') + ' ----------', + str(origmsg) + ]) + subject = _('Request to mailing list %(realname)s rejected') + finally: + i18n.set_translation(otrans) + msg = Message.UserNotification(recip, self.GetBouncesEmail(), + subject, text, lang) + msg.send(self) + + def _UpdateRecords(self): + # Subscription records have changed since MM2.0.x. In that family, + # the records were of length 4, containing the request time, the + # address, the password, and the digest flag. In MM2.1a2, they grew + # an additional language parameter at the end. In MM2.1a4, they grew + # a fullname slot after the address. This semi-public method is used + # by the update script to coerce all subscription records to the + # latest MM2.1 format. + # + # Held message records have historically either 5 or 6 items too. + # These always include the requests time, the sender, subject, default + # rejection reason, and message text. When of length 6, it also + # includes the message metadata dictionary on the end of the tuple. + self.__opendb() + for id, (type, info) in self.__db.items(): + if type == SUBSCRIPTION: + if len(info) == 4: + # pre-2.1a2 compatibility + when, addr, passwd, digest = info + fullname = '' + lang = self.preferred_language + elif len(info) == 5: + # pre-2.1a4 compatibility + when, addr, passwd, digest, lang = info + fullname = '' + else: + assert len(info) == 6, 'Unknown subscription record layout' + continue + # Here's the new layout + self.__db[id] = when, addr, fullname, passwd, digest, lang + elif type == HELDMSG: + if len(info) == 5: + when, sender, subject, reason, text = info + msgdata = {} + else: + assert len(info) == 6, 'Unknown held msg record layout' + continue + # Here's the new layout + self.__db[id] = when, sender, subject, reason, text, msgdata + # All done + self.__closedb() + + + +def readMessage(path): + # For backwards compatibility, we must be able to read either a flat text + # file or a pickle. + ext = os.path.splitext(path)[1] + fp = open(path) + try: + if ext == '.txt': + msg = email.message_from_file(fp, Message.Message) + else: + assert ext == '.pck' + msg = cPickle.load(fp) + finally: + fp.close() + return msg |