# Copyright (C) 2001-2003 by the Free Software Foundation, Inc. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. """Reading and writing message objects and message metadata. """ # enqueue() and dequeue() are not symmetric. enqueue() takes a Message # object. dequeue() returns a email.Message object tree. # # Message metadata is represented internally as a Python dictionary. Keys and # values must be strings. When written to a queue directory, the metadata is # written into an externally represented format, as defined here. Because # components of the Mailman system may be written in something other than # Python, the external interchange format should be chosen based on what those # other components can read and write. # # Most efficient, and recommended if everything is Python, is Python marshal # format. Also supported by default is Berkeley db format (using the default # bsddb module compiled into your Python executable -- usually Berkeley db # 2), and rfc822 style plain text. You can write your own if you have other # needs. import os import time import sha import marshal import errno import cPickle import email from Mailman import mm_cfg from Mailman import Utils from Mailman import Message from Mailman.Logging.Syslog import syslog # 20 bytes of all bits set, maximum sha.digest() value shamax = 0xffffffffffffffffffffffffffffffffffffffffL SAVE_MSGS_AS_PICKLES = 1 class _Switchboard: def __init__(self, whichq, slice=None, numslices=1): self.__whichq = whichq # Create the directory if it doesn't yet exist. # FIXME omask = os.umask(0) # rwxrws--- try: try: os.mkdir(self.__whichq, 0770) except OSError, e: if e.errno <> errno.EEXIST: raise finally: os.umask(omask) # Fast track for no slices self.__lower = None self.__upper = None # BAW: test performance and end-cases of this algorithm if numslices <> 1: self.__lower = (shamax * slice) / numslices self.__upper = (shamax * (slice+1)) / numslices def whichq(self): return self.__whichq def enqueue(self, _msg, _metadata={}, **_kws): # Calculate the SHA hexdigest of the message to get a unique base # filename. We're also going to use the digest as a hash into the set # of parallel qrunner processes. data = _metadata.copy() data.update(_kws) listname = data.get('listname', '--nolist--') # Get some data for the input to the sha hash now = time.time() if SAVE_MSGS_AS_PICKLES and not data.get('_plaintext'): msgsave = cPickle.dumps(_msg, 1) ext = '.pck' else: msgsave = str(_msg) ext = '.msg' hashfood = msgsave + listname + `now` # Encode the current time into the file name for FIFO sorting in # files(). The file name consists of two parts separated by a `+': # the received time for this message (i.e. when it first showed up on # this system) and the sha hex digest. #rcvtime = data.setdefault('received_time', now) rcvtime = data.setdefault('received_time', now) filebase = `rcvtime` + '+' + sha.new(hashfood).hexdigest() # Figure out which queue files the message is to be written to. msgfile = os.path.join(self.__whichq, filebase + ext) dbfile = os.path.join(self.__whichq, filebase + '.db') # Always add the metadata schema version number data['version'] = mm_cfg.QFILE_SCHEMA_VERSION # Filter out volatile entries for k in data.keys(): if k[0] == '_': del data[k] # Now write the message text to one file and the metadata to another # file. The metadata is always written second to avoid race # conditions with the various queue runners (which key off of the .db # filename). omask = os.umask(007) # -rw-rw---- try: msgfp = open(msgfile, 'w') finally: os.umask(omask) msgfp.write(msgsave) msgfp.close() # Now write the metadata using the appropriate external metadata # format. We play rename-switcheroo here to further plug the race # condition holes. tmpfile = dbfile + '.tmp' self._ext_write(tmpfile, data) os.rename(tmpfile, dbfile) def dequeue(self, filebase): # Calculate the .db and .msg filenames from the given filebase. msgfile = os.path.join(self.__whichq, filebase + '.msg') pckfile = os.path.join(self.__whichq, filebase + '.pck') dbfile = os.path.join(self.__whichq, filebase + '.db') # Now we are going to read the message and metadata for the given # filebase. We want to read things in this order: first, the metadata # file to find out whether the message is stored as a pickle or as # plain text. Second, the actual message file. However, we want to # first unlink the message file and then the .db file, because the # qrunner only cues off of the .db file msg = data = None try: data = self._ext_read(dbfile) os.unlink(dbfile) except EnvironmentError, e: if e.errno <> errno.ENOENT: raise 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, e: # This message was unparsable, most likely because its # MIME encapsulation was broken. For now, there's not # much we can do about it. syslog('error', 'message is unparsable: %s', filebase) msgfp.close() msgfp = None if mm_cfg.QRUNNER_SAVE_BAD_MESSAGES: # Cheapo way to ensure the directory exists w/ the # proper permissions. sb = Switchboard(mm_cfg.BADQUEUE_DIR) os.rename(msgfile, os.path.join( mm_cfg.BADQUEUE_DIR, filebase + '.txt')) else: os.unlink(msgfile) msg = data = None finally: if msgfp: msgfp.close() return msg, data def files(self): times = {} lower = self.__lower upper = self.__upper for f in os.listdir(self.__whichq): # We only care about the file's base name (i.e. no extension). # Thus we'll ignore anything that doesn't end in .db. if not f.endswith('.db'): continue filebase = os.path.splitext(f)[0] when, digest = filebase.split('+') # Throw out any files which don't match our bitrange. BAW: test # performance and end-cases of this algorithm. if not lower or (lower <= long(digest, 16) < upper): times[float(when)] = filebase # FIFO sort keys = times.keys() keys.sort() return [times[k] for k in keys] def _ext_write(self, tmpfile, data): raise NotImplementedError def _ext_read(self, dbfile): raise NotImplementedError class MarshalSwitchboard(_Switchboard): """Python marshal format.""" FLOAT_ATTRIBUTES = ['received_time'] def _ext_write(self, filename, dict): omask = os.umask(007) # -rw-rw---- try: fp = open(filename, 'w') finally: os.umask(omask) # Python's marshal, up to and including in Python 2.1, has a bug where # the full precision of floats was not stored. We work around this # bug by hardcoding a list of float values we know about, repr()-izing # them ourselves, and doing the reverse conversion on _ext_read(). for attr in self.FLOAT_ATTRIBUTES: # We use try/except because we expect a hitrate of nearly 100% try: fval = dict[attr] except KeyError: pass else: dict[attr] = repr(fval) marshal.dump(dict, fp) fp.close() def _ext_read(self, filename): fp = open(filename) dict = marshal.load(fp) # Update from version 2 files if dict.get('version', 0) == 2: del dict['filebase'] # Do the reverse conversion (repr -> float) for attr in self.FLOAT_ATTRIBUTES: try: sval = dict[attr] except KeyError: pass else: # Do a safe eval by setting up a restricted execution # environment. This may not be strictly necessary since we # know they are floats, but it can't hurt. dict[attr] = eval(sval, {'__builtins__': {}}) fp.close() return dict class BSDDBSwitchboard(_Switchboard): """Native (i.e. compiled-in) Berkeley db format.""" def _ext_write(self, filename, dict): import bsddb omask = os.umask(0) try: hashfile = bsddb.hashopen(filename, 'n', 0660) finally: os.umask(omask) # values must be strings for k, v in dict.items(): hashfile[k] = marshal.dumps(v) hashfile.sync() hashfile.close() def _ext_read(self, filename): import bsddb dict = {} hashfile = bsddb.hashopen(filename, 'r') for k in hashfile.keys(): dict[k] = marshal.loads(hashfile[k]) hashfile.close() return dict class ASCIISwitchboard(_Switchboard): """Human readable .db file format. key/value pairs are written as key = value as real Python code which can be execfile'd. """ def _ext_write(self, filename, dict): omask = os.umask(007) # -rw-rw---- try: fp = open(filename, 'w') finally: os.umask(omask) for k, v in dict.items(): print >> fp, '%s = %s' % (k, repr(v)) fp.close() def _ext_read(self, filename): dict = {'__builtins__': {}} execfile(filename, dict) del dict['__builtins__'] return dict # Here are the various types of external file formats available. The format # chosen is given defined in the mm_cfg.py configuration file. if mm_cfg.METADATA_FORMAT == mm_cfg.METAFMT_MARSHAL: Switchboard = MarshalSwitchboard elif mm_cfg.METADATA_FORMAT == mm_cfg.METAFMT_BSDDB_NATIVE: Switchboard = BSDDBSwitchboard elif mm_cfg.METADATA_FORMAT == mm_cfg.METAFMT_ASCII: Switchboard = ASCIISwitchboard else: syslog('error', 'Undefined metadata format: %d (using marshals)', mm_cfg.METADATA_FORMAT) Switchboard = MarshalSwitchboard # For bin/dumpdb class DumperSwitchboard(Switchboard): def __init__(self): pass def read(self, filename): return self._ext_read(filename)