diff options
Diffstat (limited to 'Mailman')
101 files changed, 3753 insertions, 940 deletions
diff --git a/Mailman/Cgi/admin.py b/Mailman/Cgi/admin.py index ae9850fba..553f26656 100644 --- a/Mailman/Cgi/admin.py +++ b/Mailman/Cgi/admin.py @@ -182,10 +182,7 @@ def admin_overview(msg=''): bgcolor=mm_cfg.WEB_HEADER_COLOR) # Skip any mailing list that isn't advertised. advertised = [] - listnames = list(Utils.list_names()) - listnames.sort() - - for name in listnames: + for name in sorted(config.list_manager.names): mlist = MailList.MailList(name, lock=False) if mlist.advertised: if hostname not in mlist.web_page_url: diff --git a/Mailman/Cgi/listinfo.py b/Mailman/Cgi/listinfo.py index b43d10f91..13689b767 100644 --- a/Mailman/Cgi/listinfo.py +++ b/Mailman/Cgi/listinfo.py @@ -82,10 +82,7 @@ def listinfo_overview(msg=''): # Skip any mailing lists that isn't advertised. advertised = [] - listnames = list(Utils.list_names()) - listnames.sort() - - for name in listnames: + for name in sorted(config.list_manager.names): mlist = MailList.MailList(name, lock=False) if mlist.advertised: if hostname not in mlist.web_page_url: diff --git a/Mailman/Cgi/options.py b/Mailman/Cgi/options.py index f357bcf15..29f05cf7b 100644 --- a/Mailman/Cgi/options.py +++ b/Mailman/Cgi/options.py @@ -895,7 +895,7 @@ def loginpage(mlist, doc, user, lang): def lists_of_member(mlist, user): hostname = mlist.host_name onlists = [] - for listname in Utils.list_names(): + for listname in config.list_manager.names: # The current list will always handle things in the mainline if listname == mlist.internal_name(): continue diff --git a/Mailman/Commands/cmd_lists.py b/Mailman/Commands/cmd_lists.py index 10e8aad71..6d23d4745 100644 --- a/Mailman/Commands/cmd_lists.py +++ b/Mailman/Commands/cmd_lists.py @@ -21,8 +21,8 @@ """ from Mailman import mm_cfg -from Mailman import Utils from Mailman.MailList import MailList +from Mailman.configuration import config from Mailman.i18n import _ @@ -43,10 +43,8 @@ def process(res, args): return STOP hostname = mlist.host_name res.results.append(_('Public mailing lists at %(hostname)s:')) - lists = Utils.list_names() - lists.sort() i = 1 - for listname in lists: + for listname in sorted(config.list_manager.names): if listname == mlist.internal_name(): xlist = mlist else: diff --git a/Mailman/Defaults.py.in b/Mailman/Defaults.py.in index 7008be8f9..87d5db125 100644 --- a/Mailman/Defaults.py.in +++ b/Mailman/Defaults.py.in @@ -26,6 +26,9 @@ import os +from munepy import Enum + + def seconds(s): return s def minutes(m): return m * 60 def hours(h): return h * 60 * 60 @@ -79,7 +82,7 @@ SITE_OWNER_ADDRESS = 'changeme@example.com' DEFAULT_HOST_NAME = None DEFAULT_URL = None -HOME_PAGE = 'index.html' +HOME_PAGE = 'index.html' # Normally when a site administrator authenticates to a web page with the site # password, they get a cookie which authorizes them as the list admin. It @@ -104,6 +107,11 @@ PASSWORD_SCHEME = 'ssha' # Database options ##### +# Initialization function for creating the IListManager, IUserManager, and +# IMessageManager objects, as a Python dotted name. This function must take +# zero arguments. +MANAGERS_INIT_FUNCTION = 'Mailman.database.initialize' + # Use this to set the SQLAlchemy database engine URL. You generally have one # primary database connection for all of Mailman. List data and most rosters # will store their data in this database, although external rosters may access @@ -467,6 +475,15 @@ NNTP_REWRITE_DUPLICATE_HEADERS = [ ('mime-version', 'X-MIME-Version'), ] +# Some list posts and mail to the -owner address may contain DomainKey or +# DomainKeys Identified Mail (DKIM) signature headers <http://www.dkim.org/>. +# Various list transformations to the message such as adding a list header or +# footer or scrubbing attachments or even reply-to munging can break these +# signatures. It is generally felt that these signatures have value, even if +# broken and even if the outgoing message is resigned. However, some sites +# may wish to remove these headers by setting this to Yes. +REMOVE_DKIM_HEADERS = No + # All `normal' messages which are delivered to the entire list membership go # through this pipeline of handler modules. Lists themselves can override the # global pipeline by defining a `pipeline' attribute. @@ -525,6 +542,8 @@ OWNER_PIPELINE = [ # - propagate -- Boolean specifying whether to propagate log message from this # logger to the root "mailman" logger. You cannot override # settings for the root logger. +# +# The file name may be absolute, or relative to Mailman's etc directory. LOG_CONFIG_FILE = None # This defines log format strings for the SMTPDirect delivery module (see @@ -664,9 +683,9 @@ VERP_DELIVERY_INTERVAL = 0 # friendly Subject: on the message, but requires cooperation from the MTA. # Format is like VERP_FORMAT above, but with the following substitutions: # -# %(addr)s -- the list-confirm mailbox will be set here -# %(cookie)s -- the confirmation cookie will be set here -VERP_CONFIRM_FORMAT = '%(addr)s+%(cookie)s' +# $address -- the list-confirm address +# $cookie -- the confirmation cookie +VERP_CONFIRM_FORMAT = '$address+$cookie' # This is analogous to VERP_REGEXP, but for splitting apart the # VERP_CONFIRM_FORMAT. MUAs have been observed that mung @@ -1295,6 +1314,24 @@ ReceiveNonmatchingTopics = 64 Moderate = 128 DontReceiveDuplicates = 256 + +class DeliveryMode(Enum): + # Non-digest delivery + Regular = 1 + # Digest delivery modes + MIME = 2 + Plain = 3 + + +class DeliveryStatus(Enum): + Enabled = 0 + # Disabled reason + Unknown = 1 + ByUser = 2 + ByAdmin = 3 + ByBounce = 4 + + # A mapping between short option tags and their flag OPTINFO = {'hide' : ConcealSubscription, 'nomail' : DisableDelivery, diff --git a/Mailman/Errors.py b/Mailman/Errors.py index 10081f08d..6b5abac5a 100644 --- a/Mailman/Errors.py +++ b/Mailman/Errors.py @@ -212,3 +212,33 @@ class BadPasswordSchemeError(PasswordError): def __str__(self): return 'A bad password scheme was given: %s' % self.scheme_name + + + +class UserError(MailmanError): + """A general user-related error occurred.""" + + +class RosterError(UserError): + """A roster-related error occurred.""" + + +class RosterExistsError(RosterError): + """The named roster already exists.""" + + + +class AddressError(MailmanError): + """A general address-related error occurred.""" + + +class ExistingAddressError(AddressError): + """The given email address already exists.""" + + +class AddressAlreadyLinkedError(AddressError): + """The address is already linked to a user.""" + + +class AddressNotLinkedError(AddressError): + """The address is not linked to the user.""" diff --git a/Mailman/Gui/Language.py b/Mailman/Gui/Language.py index 8f73a8b3f..7b7433232 100644 --- a/Mailman/Gui/Language.py +++ b/Mailman/Gui/Language.py @@ -23,7 +23,7 @@ from Mailman import Utils from Mailman import i18n from Mailman import mm_cfg from Mailman.Gui.GUIBase import GUIBase -from Mailman.database.languages import Language +from Mailman.database.tables.languages import Language _ = i18n._ diff --git a/Mailman/Handlers/CleanseDKIM.py b/Mailman/Handlers/CleanseDKIM.py index 8abdbb972..9dee4fcb0 100644 --- a/Mailman/Handlers/CleanseDKIM.py +++ b/Mailman/Handlers/CleanseDKIM.py @@ -25,9 +25,12 @@ and it will also give the MTA the opportunity to regenerate valid keys originating at the Mailman server for the outgoing message. """ +from Mailman.configuration import config + def process(mlist, msg, msgdata): - del msg['domainkey-signature'] - del msg['dkim-signature'] - del msg['authentication-results'] + if config.REMOVE_DKIM_HEADERS: + del msg['domainkey-signature'] + del msg['dkim-signature'] + del msg['authentication-results'] diff --git a/Mailman/Handlers/Scrubber.py b/Mailman/Handlers/Scrubber.py index a7a825852..4c53b11ac 100644 --- a/Mailman/Handlers/Scrubber.py +++ b/Mailman/Handlers/Scrubber.py @@ -174,7 +174,19 @@ def process(mlist, msg, msgdata=None): if ctype == 'text/plain': # We need to choose a charset for the scrubbed message, so we'll # arbitrarily pick the charset of the first text/plain part in the - # message. Also get the RFC 3676 stuff from this part. + # message. + # + # Also get the RFC 3676 stuff from this part. This seems to + # work okay for scrub_nondigest. It will also work as far as + # scrubbing messages for the archive is concerned, but Pipermail + # doesn't pay any attention to the RFC 3676 parameters. The plain + # format digest is going to be a disaster in any case as some of + # messages will be format="flowed" and some not. ToDigest creates + # its own Content-Type: header for the plain digest which won't + # have RFC 3676 parameters. If the message Content-Type: headers + # are retained for display in the digest, the parameters will be + # there for information, but not for the MUA. This is the best we + # can do without having get_payload() process the parameters. if charset is None: charset = part.get_content_charset(lcset) format = part.get_param('format') @@ -318,7 +330,8 @@ URL: %(url)s partcharset = part.get_content_charset('us-ascii') try: t = unicode(t, partcharset, 'replace') - except (UnicodeError, LookupError, ValueError, TypeError): + except (UnicodeError, LookupError, ValueError, TypeError, + AssertionError): # What is the cause to come this exception now ? # Replace funny characters. We use errors='replace'. u = unicode(t, 'ascii', 'replace') @@ -331,6 +344,13 @@ URL: %(url)s charsets.append(partcharset) # Now join the text and set the payload sep = _('-------------- next part --------------\n') + # The i18n separator is in the list's charset. Coerce it to the + # message charset. + try: + s = unicode(sep, lcset, 'replace') + sep = s.encode(charset, 'replace') + except (UnicodeError, LookupError, ValueError): + pass rept = sep.join(text) # Replace entire message with text and scrubbed notice. # Try with message charsets and utf-8 diff --git a/Mailman/ListAdmin.py b/Mailman/ListAdmin.py index bcef7d2c9..cadf0146e 100644 --- a/Mailman/ListAdmin.py +++ b/Mailman/ListAdmin.py @@ -24,6 +24,8 @@ Pending subscriptions which are requiring a user's confirmation are handled elsewhere. """ +from __future__ import with_statement + import os import time import email @@ -71,104 +73,99 @@ class ListAdmin: self.next_request_id = 1 def InitTempVars(self): - self.__db = None - self.__filename = os.path.join(self.fullpath(), 'request.pck') + self._db = None + self._filename = os.path.join(self.full_path, 'request.pck') - def __opendb(self): - if self.__db is None: + def _opendb(self): + if self._db is None: assert self.Locked() try: - fp = open(self.__filename) - try: - self.__db = cPickle.load(fp) - finally: - fp.close() + with open(self._filename) as fp: + self._db = cPickle.load(fp) except IOError, e: - if e.errno <> errno.ENOENT: raise - self.__db = {} + if e.errno <> errno.ENOENT: + raise + self._db = {} # put version number in new database - self.__db['version'] = IGN, config.REQUESTS_FILE_SCHEMA_VERSION + self._db['version'] = IGN, config.REQUESTS_FILE_SCHEMA_VERSION - def __closedb(self): - if self.__db is not None: + def _closedb(self): + if self._db is not None: assert self.Locked() # Save the version number - self.__db['version'] = IGN, config.REQUESTS_FILE_SCHEMA_VERSION + self._db['version'] = IGN, config.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' - fp = open(tmpfile, 'w') - try: - cPickle.dump(self.__db, fp, 1) + tmpfile = self._filename + '.tmp' + with open(tmpfile, 'w') as fp: + cPickle.dump(self._db, fp, 1) fp.flush() os.fsync(fp.fileno()) - finally: - fp.close() - self.__db = None + self._db = None # Do the dance - os.rename(tmpfile, self.__filename) + os.rename(tmpfile, self._filename) - def __nextid(self): + @property + def _next_id(self): assert self.Locked() while True: + missing = object() next = self.next_request_id self.next_request_id += 1 - if not self.__db.has_key(next): - break - return next + if self._db.setdefault(next, missing) is missing: + yield next def SaveRequestsDb(self): - self.__closedb() + self._closedb() def NumRequestsPending(self): - self.__opendb() + self._opendb() # Subtract one for the version pseudo-entry - return len(self.__db) - 1 + return len(self._db) - 1 - def __getmsgids(self, rtype): - self.__opendb() - ids = [k for k, (op, data) in self.__db.items() if op == rtype] - ids.sort() + def _getmsgids(self, rtype): + self._opendb() + ids = sorted([k for k, (op, data) in self._db.items() if op == rtype]) return ids def GetHeldMessageIds(self): - return self.__getmsgids(HELDMSG) + return self._getmsgids(HELDMSG) def GetSubscriptionIds(self): - return self.__getmsgids(SUBSCRIPTION) + return self._getmsgids(SUBSCRIPTION) def GetUnsubscriptionIds(self): - return self.__getmsgids(UNSUBSCRIPTION) + return self._getmsgids(UNSUBSCRIPTION) def GetRecord(self, id): - self.__opendb() - type, data = self.__db[id] + self._opendb() + type, data = self._db[id] return data def GetRecordType(self, id): - self.__opendb() - type, data = self.__db[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] + self._opendb() + rtype, data = self._db[id] if rtype == HELDMSG: - status = self.__handlepost(data, value, comment, preserve, - forward, addr) + status = self._handlepost(data, value, comment, preserve, + forward, addr) elif rtype == UNSUBSCRIPTION: - status = self.__handleunsubscription(data, value, comment) + status = self._handleunsubscription(data, value, comment) else: assert rtype == SUBSCRIPTION - status = self.__handlesubscription(data, value, comment) + 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] + del self._db[id] def HoldMessage(self, msg, reason, msgdata={}): # Make a copy of msgdata so that subsequent changes won't corrupt the @@ -176,9 +173,9 @@ class ListAdmin: # not be relevant when the message is resurrected. msgdata = msgdata.copy() # assure that the database is open for writing - self.__opendb() + self._opendb() # get the next unique id - id = self.__nextid() + id = self._next_id # get the message sender sender = msg.get_sender() # calculate the file name for the message text and write it to disk @@ -187,8 +184,7 @@ class ListAdmin: else: ext = 'txt' filename = 'heldmsg-%s-%d.%s' % (self.internal_name(), id, ext) - fp = open(os.path.join(config.DATA_DIR, filename), 'w') - try: + with open(os.path.join(config.DATA_DIR, filename), 'w') as fp: if config.HOLD_MESSAGES_AS_PICKLES: cPickle.dump(msg, fp, 1) else: @@ -196,8 +192,6 @@ class ListAdmin: g(msg, 1) fp.flush() os.fsync(fp.fileno()) - finally: - fp.close() # save the information to the request database. for held message # entries, each record in the database will be of the following # format: @@ -211,10 +205,10 @@ class ListAdmin: # msgsubject = msg.get('subject', _('(no subject)')) data = time.time(), sender, msgsubject, reason, filename, msgdata - self.__db[id] = (HELDMSG, data) + self._db[id] = (HELDMSG, data) return id - def __handlepost(self, record, value, comment, preserve, forward, addr): + 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(config.DATA_DIR, filename) @@ -225,24 +219,19 @@ class ListAdmin: spamfile = DASH.join(parts) # Preserve the message as plain text, not as a pickle try: - fp = open(path) + with open(path) as fp: + msg = cPickle.load(fp) except IOError, e: - if e.errno <> errno.ENOENT: raise + 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(config.SPAM_DIR, spamfile) head, ext = os.path.splitext(outpath) outpath = head + '.msg' - outfp = open(outpath, 'w') - try: + with open(outpath, 'w') as outfp: g = Generator(outfp) g(msg, 1) - finally: - outfp.close() # Now handle updates to the database rejection = None fp = None @@ -256,7 +245,8 @@ class ListAdmin: try: msg = readMessage(path) except IOError, e: - if e.errno <> errno.ENOENT: raise + if e.errno <> errno.ENOENT: + raise return LOST msg = readMessage(path) msgdata['approved'] = 1 @@ -281,7 +271,7 @@ class ListAdmin: elif value == config.REJECT: # Rejected rejection = 'Refused' - self.__refuse(_('Posting of your message titled "%(subject)s"'), + self._refuse(_('Posting of your message titled "%(subject)s"'), sender, comment or _('[No reason given]'), lang=self.getMemberLanguage(sender)) else: @@ -297,7 +287,8 @@ class ListAdmin: try: copy = readMessage(path) except IOError, e: - if e.errno <> errno.ENOENT: raise + 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]) @@ -354,9 +345,9 @@ class ListAdmin: def HoldSubscription(self, addr, fullname, password, digest, lang): # Assure that the database is open for writing - self.__opendb() + self._opendb() # Get the next unique id - id = self.__nextid() + id = self._next_id # Save the information to the request database. for held subscription # entries, each record in the database will be one of the following # format: @@ -367,7 +358,7 @@ class ListAdmin: # the digest flag # the user's preferred language data = time.time(), addr, fullname, password, digest, lang - self.__db[id] = (SUBSCRIPTION, data) + self._db[id] = (SUBSCRIPTION, data) # # TBD: this really shouldn't go here but I'm not sure where else is # appropriate. @@ -399,7 +390,7 @@ class ListAdmin: elif value == config.DISCARD: pass elif value == config.REJECT: - self.__refuse(_('Subscription request'), addr, + self._refuse(_('Subscription request'), addr, comment or _('[No reason given]'), lang=lang) else: @@ -413,16 +404,16 @@ class ListAdmin: pass # TBD: disgusting hack: ApprovedAddMember() can end up closing # the request database. - self.__opendb() + self._opendb() return REMOVE def HoldUnsubscription(self, addr): # Assure the database is open for writing - self.__opendb() + self._opendb() # Get the next unique id - id = self.__nextid() + id = self._next_id # All we need to do is save the unsubscribing address - self.__db[id] = (UNSUBSCRIPTION, addr) + self._db[id] = (UNSUBSCRIPTION, addr) log.info('%s: held unsubscription request from %s', self.internal_name(), addr) # Possibly notify the administrator of the hold @@ -444,14 +435,14 @@ class ListAdmin: self.preferred_language) msg.send(self, **{'tomoderators': 1}) - def __handleunsubscription(self, record, value, comment): + def _handleunsubscription(self, record, value, comment): addr = record if value == config.DEFER: return DEFER elif value == config.DISCARD: pass elif value == config.REJECT: - self.__refuse(_('Unsubscription request'), addr, comment) + self._refuse(_('Unsubscription request'), addr, comment) else: assert value == config.UNSUBSCRIBE try: @@ -461,7 +452,7 @@ class ListAdmin: pass return REMOVE - def __refuse(self, request, recip, comment, origmsg=None, lang=None): + 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. @@ -492,82 +483,16 @@ class ListAdmin: 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. - # - # In Mailman 2.1.5 we converted these files to pickles. - filename = os.path.join(self.fullpath(), 'request.db') - try: - fp = open(filename) - try: - self.__db = marshal.load(fp) - finally: - fp.close() - os.unlink(filename) - except IOError, e: - if e.errno <> errno.ENOENT: raise - filename = os.path.join(self.fullpath(), 'request.pck') - try: - fp = open(filename) - try: - self.__db = cPickle.load(fp) - finally: - fp.close() - except IOError, e: - if e.errno <> errno.ENOENT: raise - self.__db = {} - for id, (op, info) in self.__db.items(): - if op == 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 op == 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: + with open(path) as fp: if ext == '.txt': msg = email.message_from_file(fp, Message.Message) else: assert ext == '.pck' msg = cPickle.load(fp) - finally: - fp.close() return msg diff --git a/Mailman/LockFile.py b/Mailman/LockFile.py index b240ce5a4..d83cef7e2 100644 --- a/Mailman/LockFile.py +++ b/Mailman/LockFile.py @@ -321,6 +321,16 @@ class LockFile: if self._owned: self.finalize() + # Python 2.5 context manager protocol support. + def __enter__(self): + self.lock() + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.unlock() + # Don't suppress any exception that might have occurred. + return False + # Use these only if you're transfering ownership to a child process across # a fork. Use at your own risk, but it should be race-condition safe. # _transfer_to() is called in the parent, passing in the pid of the child. diff --git a/Mailman/MailList.py b/Mailman/MailList.py index cb310dd74..343040157 100644 --- a/Mailman/MailList.py +++ b/Mailman/MailList.py @@ -36,8 +36,10 @@ import email.Iterators from UserDict import UserDict from cStringIO import StringIO +from string import Template from types import MethodType from urlparse import urlparse +from zope.interface import implements from email.Header import Header from email.Utils import getaddresses, formataddr, parseaddr @@ -49,7 +51,8 @@ from Mailman import Version from Mailman import database from Mailman.UserDesc import UserDesc from Mailman.configuration import config -from Mailman.database.languages import Language +from Mailman.database.tables.languages import Language +from Mailman.interfaces import * # Base classes from Mailman import Pending @@ -89,84 +92,49 @@ slog = logging.getLogger('mailman.subscribe') class MailList(object, HTMLFormatter, Deliverer, ListAdmin, Archiver, Digester, SecurityManager, Bouncer, GatewayManager, Autoresponder, TopicMgr, Pending.Pending): - def __new__(cls, *args, **kws): - # Search positional and keyword arguments to find the name of the - # existing list that is being opened, with the latter taking - # precedence. If no name can be found, then make sure there are no - # arguments, otherwise it's an error. - if 'name' in kws: - listname = kws.pop('name') - elif not args: - if not kws: - # We're probably being created from the ORM layer, so just let - # the super class do its thing. - return super(MailList, cls).__new__(cls, *args, **kws) - raise ValueError("'name' argument required'") - else: - listname = args[0] - fqdn_listname = Utils.fqdn_listname(listname) - listname, hostname = Utils.split_listname(fqdn_listname) - mlist = database.find_list(listname, hostname) - if not mlist: - raise Errors.MMUnknownListError(fqdn_listname) - return mlist - # - # A MailList object's basic Python object model support - # - def __init__(self, name=None, lock=True, check_version=True): - # No timeout by default. If you want to timeout, open the list - # unlocked, then lock explicitly. - # + implements( + IMailingList, + IMailingListAddresses, + IMailingListIdentity, + IMailingListRosters, + ) + + def __init__(self, data): + self._data = data # Only one level of mixin inheritance allowed for baseclass in self.__class__.__bases__: if hasattr(baseclass, '__init__'): baseclass.__init__(self) # Initialize volatile attributes - self.InitTempVars(name, check_version) - # This extension mechanism allows list-specific overrides of any - # method (well, except __init__(), InitTempVars(), and InitVars() - # I think). Note that fullpath() will return None when we're creating - # the list, which will only happen when name is None. - if name is None: - return - filename = os.path.join(self.fullpath(), 'extend.py') - dict = {} + self.InitTempVars() + # Give the extension mechanism a chance to process this list. try: - execfile(filename, dict) - except IOError, e: - # Ignore missing files, but log other errors - if e.errno == errno.ENOENT: - pass - else: - elog.error('IOError reading list extension: %s', e) + from Mailman.ext import init_mlist + except ImportError: + pass else: - func = dict.get('extend') - if func: - func(self) - if lock: - # This will load the database. - self.Lock() - else: - self.Load(name, check_version) + init_mlist(self) def __getattr__(self, name): + missing = object() if name.startswith('_'): return super(MailList, self).__getattr__(name) - # Because we're using delegation, we want to be sure that attribute - # access to a delegated member function gets passed to the - # sub-objects. This of course imposes a specific name resolution - # order. - try: - return getattr(self._memberadaptor, name) - except AttributeError: - for guicomponent in self._gui: - try: - return getattr(guicomponent, name) - except AttributeError: - pass - else: - raise AttributeError(name) + # Delegate to the database model object if it has the attribute. + obj = getattr(self._data, name, missing) + if obj is not missing: + return obj + # Delegate to the member adapter next. + obj = getattr(self._memberadaptor, name, missing) + if obj is not missing: + return obj + # Finally, delegate to one of the gui components. + for guicomponent in self._gui: + obj = getattr(guicomponent, name, missing) + if obj is not missing: + return obj + # Nothing left to delegate to, so it's got to be an error. + raise AttributeError(name) def __repr__(self): if self.Locked(): @@ -212,43 +180,63 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, # # Useful accessors # - def internal_name(self): - return self._internal_name - - def fullpath(self): + @property + def full_path(self): return self._full_path + + + # IMailingListIdentity + @property def fqdn_listname(self): - return '%s@%s' % (self._internal_name, self.host_name) + return Utils.fqdn_listname(self._data.list_name, self._data.host_name) + + + + # IMailingListAddresses + + @property + def posting_address(self): + return self.fqdn_listname @property - def no_reply_address(self): + def noreply_address(self): return '%s@%s' % (config.NO_REPLY_ADDRESS, self.host_name) - def getListAddress(self, extra=None): - if extra is None: - return self.fqdn_listname - return '%s-%s@%s' % (self.internal_name(), extra, self.host_name) + @property + def owner_address(self): + return '%s-owner@%s' % (self.list_name, self.host_name) + + @property + def request_address(self): + return '%s-request@%s' % (self.list_name, self.host_name) - # For backwards compatibility - def GetBouncesEmail(self): - return self.getListAddress('bounces') + @property + def bounces_address(self): + return '%s-bounces@%s' % (self.list_name, self.host_name) - def GetOwnerEmail(self): - return self.getListAddress('owner') + @property + def join_address(self): + return '%s-join@%s' % (self.list_name, self.host_name) - def GetRequestEmail(self, cookie=''): - if config.VERP_CONFIRMATIONS and cookie: - return self.GetConfirmEmail(cookie) - else: - return self.getListAddress('request') + @property + def leave_address(self): + return '%s-leave@%s' % (self.list_name, self.host_name) + + @property + def subscribe_address(self): + return '%s-subscribe@%s' % (self.list_name, self.host_name) + + @property + def unsubscribe_address(self): + return '%s-unsubscribe@%s' % (self.list_name, self.host_name) - def GetConfirmEmail(self, cookie): - return config.VERP_CONFIRM_FORMAT % { - 'addr' : '%s-confirm' % self.internal_name(), - 'cookie': cookie, - } + '@' + self.host_name + def confirm_address(self, cookie): + local_part = Template(config.VERP_CONFIRM_FORMAT).safe_substitute( + address = '%s-confirm' % self.list_name, + cookie = cookie) + return '%s@%s' % (local_part, self.host_name) def GetConfirmJoinSubject(self, listname, cookie): if config.VERP_CONFIRMATIONS and cookie: @@ -303,12 +291,11 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, user = Utils.ObscureEmail(user) return '%s/%s' % (url, urllib.quote(user.lower())) - # # Instance and subcomponent initialization # - def InitTempVars(self, name, check_version=True): + def InitTempVars(self): """Set transient variables of this and inherited classes.""" # Because of the semantics of the database layer, it's possible that # this method gets called more than once on an existing object. For @@ -325,27 +312,9 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, __import__(adaptor_module) mod = sys.modules[adaptor_module] self._memberadaptor = getattr(mod, adaptor_class)(self) - # The timestamp is set whenever we load the state from disk. If our - # timestamp is newer than the modtime of the config.pck file, we don't - # need to reload, otherwise... we do. - self._timestamp = 0 - self._make_lock(name or '<site>') - # XXX FIXME Sometimes name is fully qualified, sometimes it's not. - if name: - if '@' in name: - self._internal_name, self.host_name = name.split('@', 1) - self._full_path = os.path.join(config.LIST_DATA_DIR, name) - else: - self._internal_name = name - self.host_name = config.DEFAULT_EMAIL_HOST - if check_version: - self._full_path = os.path.join(config.LIST_DATA_DIR, - name + '@' + - self.host_name) - else: - self._full_path = os.path.join(config.LIST_DATA_DIR, name) - else: - self._full_path = '' + self._make_lock(self.fqdn_listname) + self._full_path = os.path.join(config.LIST_DATA_DIR, + self.fqdn_listname) # Only one level of mixin inheritance allowed for baseclass in self.__class__.__bases__: if hasattr(baseclass, 'InitTempVars'): @@ -597,18 +566,9 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, self.SaveRequestsDb() self.CheckHTMLArchiveDir() - def Load(self, fqdn_listname=None, check_version=True): - if fqdn_listname is None: - fqdn_listname = self.fqdn_listname - if not Utils.list_exists(fqdn_listname): - raise Errors.MMUnknownListError(fqdn_listname) + def Load(self): database.load(self) self._memberadaptor.load() - if check_version: - # XXX for now disable version checks. We'll fold this into schema - # updates eventually. - #self.CheckVersion(dict) - self.CheckValues() @@ -623,7 +583,6 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, self.InitVars() # Then reload the database (but don't recurse). Force a reload even # if we have the most up-to-date state. - self._timestamp = 0 self.Load(self.fqdn_listname, check_version=False) # We must hold the list lock in order to update the schema waslocked = self.Locked() @@ -955,7 +914,7 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, self.setMemberName(addr, name) if not globally: return - for listname in Utils.list_names(): + for listname in config.list_manager.names: # Don't bother with ourselves if listname == self.internal_name(): continue @@ -1047,7 +1006,7 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, # oldaddr is a member. if not globally: return - for listname in Utils.list_names(): + for listname in config.list_manager.names: # Don't bother with ourselves if listname == self.internal_name(): continue diff --git a/Mailman/Makefile.in b/Mailman/Makefile.in index 43536ec22..e71d86ce8 100644 --- a/Mailman/Makefile.in +++ b/Mailman/Makefile.in @@ -44,7 +44,7 @@ SHELL= /bin/sh MODULES= $(srcdir)/*.py SUBDIRS= Cgi Archiver Handlers Bouncers Queue MTA Gui Commands \ - bin database testing + bin database docs ext interfaces testing # Modes for directories and executables created by the install # process. Default to group-writable directories but diff --git a/Mailman/Pending.py b/Mailman/Pending.py index f5794453d..1d133e018 100644 --- a/Mailman/Pending.py +++ b/Mailman/Pending.py @@ -17,6 +17,8 @@ """Track pending actions which require confirmation.""" +from __future__ import with_statement + import os import sha import time @@ -51,7 +53,7 @@ _default = object() class Pending: def InitTempVars(self): - self.__pendfile = os.path.join(self.fullpath(), 'pending.pck') + self._pendfile = os.path.join(self.full_path, 'pending.pck') def pend_new(self, op, *content, **kws): """Create a new entry in the pending database, returning cookie for it. @@ -87,14 +89,12 @@ class Pending: def __load(self): try: - fp = open(self.__pendfile) + with open(self._pendfile) as fp: + return cPickle.load(fp) except IOError, e: - if e.errno <> errno.ENOENT: raise + if e.errno <> errno.ENOENT: + raise return {'evictions': {}} - try: - return cPickle.load(fp) - finally: - fp.close() def __save(self, db): evictions = db['evictions'] @@ -112,15 +112,12 @@ class Pending: if not db.has_key(cookie): del evictions[cookie] db['version'] = config.PENDING_FILE_SCHEMA_VERSION - tmpfile = '%s.tmp.%d.%d' % (self.__pendfile, os.getpid(), now) - fp = open(tmpfile, 'w') - try: + tmpfile = '%s.tmp.%d.%d' % (self._pendfile, os.getpid(), now) + with open(tmpfile, 'w') as fp: cPickle.dump(db, fp) fp.flush() os.fsync(fp.fileno()) - finally: - fp.close() - os.rename(tmpfile, self.__pendfile) + os.rename(tmpfile, self._pendfile) def pend_confirm(self, cookie, expunge=True): """Return data for cookie, or None if not found. diff --git a/Mailman/Queue/LMTPRunner.py b/Mailman/Queue/LMTPRunner.py index 0d2214641..81c912653 100644 --- a/Mailman/Queue/LMTPRunner.py +++ b/Mailman/Queue/LMTPRunner.py @@ -48,7 +48,6 @@ import asyncore from email.utils import parseaddr -from Mailman import Utils from Mailman.Message import Message from Mailman.Queue.Runner import Runner from Mailman.Queue.sbcache import get_switchboard @@ -122,7 +121,7 @@ class LMTPRunner(Runner, smtpd.SMTPServer): # since the set of mailing lists could have changed. However, on # a big site this could be fairly expensive, so we may need to # cache this in some way. - listnames = Utils.list_names() + listnames = set(config.list_manager.names) # Parse the message data. XXX Should we reject the message # immediately if it has defects? Usually only spam has defects. msg = email.message_from_string(data, Message) diff --git a/Mailman/Queue/MaildirRunner.py b/Mailman/Queue/MaildirRunner.py index 012aacc4a..ff193bf22 100644 --- a/Mailman/Queue/MaildirRunner.py +++ b/Mailman/Queue/MaildirRunner.py @@ -56,7 +56,6 @@ import logging from email.Parser import Parser from email.Utils import parseaddr -from Mailman import Utils from Mailman.Message import Message from Mailman.Queue.Runner import Runner from Mailman.Queue.sbcache import get_switchboard @@ -101,9 +100,8 @@ class MaildirRunner(Runner): self._parser = Parser(Message) def _oneloop(self): - # Refresh this each time through the list. BAW: could be too - # expensive. - listnames = Utils.list_names() + # Refresh this each time through the list. + listnames = list(config.list_manager.names) # Cruise through all the files currently in the new/ directory try: files = os.listdir(self._dir) diff --git a/Mailman/Queue/Runner.py b/Mailman/Queue/Runner.py index 4b782b3e3..2045022fa 100644 --- a/Mailman/Queue/Runner.py +++ b/Mailman/Queue/Runner.py @@ -92,16 +92,16 @@ class Runner: # Ask the switchboard for the message and metadata objects # associated with this filebase. msg, msgdata = self._switchboard.dequeue(filebase) - except email.Errors.MessageParseError, e: - # It's possible to get here if the message was stored in the - # pickle in plain text, and the metadata had a _parsemsg key - # that was true, /and/ if the message had some bogosity in - # it. It's almost always going to be spam or bounced spam. - # There's not much we can do (and we didn't even get the - # metadata, so just log the exception and continue. + except Exception, e: + # This used to just catch email.Errors.MessageParseError, + # but other problems can occur in message parsing, e.g. + # ValueError, and exceptions can occur in unpickling too. + # We don't want the runner to die, so we just log and skip + # this entry, but preserve it for analysis. self._log(e) - log.error('Ignoring unparseable message: %s', filebase) - self._switchboard.finish(filebase) + log.error('Skipping and preserving unparseable message: %s', + filebase) + self._switchboard.finish(filebase, preserve=True) continue try: self._onefile(msg, msgdata) @@ -116,9 +116,21 @@ class Runner: self._log(e) # Put a marker in the metadata for unshunting msgdata['whichq'] = self._switchboard.whichq() - new_filebase = self._shunt.enqueue(msg, msgdata) - log.error('SHUNTING: %s', new_filebase) - self._switchboard.finish(filebase) + # It is possible that shunting can throw an exception, e.g. a + # permissions problem or a MemoryError due to a really large + # message. Try to be graceful. + try: + new_filebase = self._shunt.enqueue(msg, msgdata) + log.error('SHUNTING: %s', new_filebase) + self._switchboard.finish(filebase) + except Exception, e: + # The message wasn't successfully shunted. Log the + # exception and try to preserve the original queue entry + # for possible analysis. + self._log(e) + log.error('SHUNTING FAILED, preserving original entry: %s', + filebase) + self._switchboard.finish(filebase, preserve=True) # Other work we want to do each time through the loop Utils.reap(self._kids, once=True) self._doperiodic() diff --git a/Mailman/Queue/Switchboard.py b/Mailman/Queue/Switchboard.py index fabb1d099..6f5cd6222 100644 --- a/Mailman/Queue/Switchboard.py +++ b/Mailman/Queue/Switchboard.py @@ -149,12 +149,20 @@ class Switchboard: msg = email.message_from_string(msg, Message.Message) return msg, data - def finish(self, filebase): + def finish(self, filebase, preserve=False): bakfile = os.path.join(self.__whichq, filebase + '.bak') try: - os.unlink(bakfile) + if preserve: + psvfile = os.path.join(config.SHUNTQUEUE_DIR, + filebase + '.psv') + # Create the directory if it doesn't yet exist. + Utils.makedirs(config.SHUNTQUEUE_DIR, 0770) + os.rename(bakfile, psvfile) + else: + os.unlink(bakfile) except EnvironmentError, e: - elog.exception('Failed to unlink backup file: %s', bakfile) + elog.exception('Failed to unlink/preserve backup file: %s', + bakfile) def files(self, extension='.pck'): times = {} diff --git a/Mailman/Utils.py b/Mailman/Utils.py index afb2f1fd5..27a61567e 100644 --- a/Mailman/Utils.py +++ b/Mailman/Utils.py @@ -40,7 +40,6 @@ from email.Errors import HeaderParseError from string import ascii_letters, digits, whitespace from Mailman import Errors -from Mailman import database from Mailman import passwords from Mailman.SafeDict import SafeDict from Mailman.configuration import config @@ -66,13 +65,13 @@ log = logging.getLogger('mailman.error') def list_exists(fqdn_listname): """Return true iff list `fqdn_listname' exists.""" listname, hostname = split_listname(fqdn_listname) - return bool(database.find_list(listname, hostname)) + return bool(config.list_manager.find_list(listname, hostname)) def list_names(): """Return the fqdn names of all lists in default list directory.""" return ['%s@%s' % (listname, hostname) - for listname, hostname in database.get_list_names()] + for listname, hostname in config.list_manager.get_list_names()] def split_listname(listname): @@ -81,8 +80,10 @@ def split_listname(listname): return listname, config.DEFAULT_EMAIL_HOST -def fqdn_listname(listname): - return AT.join(split_listname(listname)) +def fqdn_listname(listname, hostname=None): + if hostname is None: + return AT.join(split_listname(listname)) + return AT.join((listname, hostname)) diff --git a/Mailman/bin/bumpdigests.py b/Mailman/bin/bumpdigests.py index 602ecd763..94a010950 100644 --- a/Mailman/bin/bumpdigests.py +++ b/Mailman/bin/bumpdigests.py @@ -20,7 +20,6 @@ import optparse from Mailman import Errors from Mailman import MailList -from Mailman import Utils from Mailman import Version from Mailman.configuration import config from Mailman.i18n import _ @@ -52,7 +51,7 @@ def main(): opts, args, parser = parseargs() config.load(opts.config) - listnames = set(args or Utils.list_names()) + listnames = set(args or config.list_manager.names) if not listnames: print _('Nothing to do.') sys.exit(0) diff --git a/Mailman/bin/change_pw.py b/Mailman/bin/change_pw.py index 57080d4fa..3830b76ae 100644 --- a/Mailman/bin/change_pw.py +++ b/Mailman/bin/change_pw.py @@ -114,13 +114,10 @@ def main(): # Cull duplicates domains = set(opts.domains) - if opts.all: - listnames = set(Utils.list_names()) - else: - listnames = set(opts.listnames) + listnames = set(config.list_manager.names if opts.all else opts.listnames) if domains: - for name in Utils.list_names(): + for name in config.list_manager.names: mlist = openlist(name) if mlist.host_name in domains: listnames.add(name) diff --git a/Mailman/bin/checkdbs.py b/Mailman/bin/checkdbs.py index 1423421c9..691417780 100755 --- a/Mailman/bin/checkdbs.py +++ b/Mailman/bin/checkdbs.py @@ -132,7 +132,7 @@ def main(): i18n.set_language(config.DEFAULT_SERVER_LANGUAGE) - for name in Utils.list_names(): + for name in config.list_manager.names: # The list must be locked in order to open the requests database mlist = MailList.MailList(name) try: diff --git a/Mailman/bin/disabled.py b/Mailman/bin/disabled.py index 36999793b..81916e7d9 100644 --- a/Mailman/bin/disabled.py +++ b/Mailman/bin/disabled.py @@ -23,7 +23,6 @@ from Mailman import Errors from Mailman import MailList from Mailman import MemberAdaptor from Mailman import Pending -from Mailman import Utils from Mailman import Version from Mailman import loginit from Mailman.Bouncer import _BounceInfo @@ -122,7 +121,7 @@ def main(): elog = logging.getLogger('mailman.error') blog = logging.getLogger('mailman.bounce') - listnames = set(opts.listnames or Utils.list_names()) + listnames = set(opts.listnames or config.list_manager.names) who = tuple(opts.who) msg = _('[disabled by periodic sweep and cull, no message available]') diff --git a/Mailman/bin/export.py b/Mailman/bin/export.py index b0b5519ef..42b5a17b0 100644 --- a/Mailman/bin/export.py +++ b/Mailman/bin/export.py @@ -31,7 +31,6 @@ from xml.sax.saxutils import escape from Mailman import Defaults from Mailman import Errors from Mailman import MemberAdaptor -from Mailman import Utils from Mailman import Version from Mailman.MailList import MailList from Mailman.configuration import config @@ -308,7 +307,7 @@ def main(): listname = '%s@%s' % (listname, config.DEFAULT_EMAIL_HOST) listnames.append(listname) else: - listnames = Utils.list_names() + listnames = config.list_manager.names dumper.dump(listnames) dumper.close() finally: diff --git a/Mailman/bin/find_member.py b/Mailman/bin/find_member.py index a0116cd03..187345bfe 100644 --- a/Mailman/bin/find_member.py +++ b/Mailman/bin/find_member.py @@ -21,7 +21,6 @@ import optparse from Mailman import Errors from Mailman import MailList -from Mailman import Utils from Mailman import Version from Mailman.configuration import config from Mailman.i18n import _ @@ -79,9 +78,8 @@ def main(): parser, opts, args = parseargs() config.load(opts.config) - if not opts.listnames: - opts.listnames = Utils.list_names() - includes = set(listname.lower() for listname in opts.listnames) + 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 diff --git a/Mailman/bin/gate_news.py b/Mailman/bin/gate_news.py index 71cf060d8..491660f2f 100644 --- a/Mailman/bin/gate_news.py +++ b/Mailman/bin/gate_news.py @@ -164,7 +164,7 @@ def poll_newsgroup(mlist, conn, first, last, glock): def process_lists(glock): - for listname in Utils.list_names(): + 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 diff --git a/Mailman/bin/genaliases.py b/Mailman/bin/genaliases.py index 4ce8160fd..239f88c91 100644 --- a/Mailman/bin/genaliases.py +++ b/Mailman/bin/genaliases.py @@ -21,7 +21,6 @@ import sys import optparse from Mailman import MailList -from Mailman import Utils from Mailman import Version from Mailman.configuration import config from Mailman.i18n import _ @@ -68,7 +67,7 @@ def main(): lock.lock() # Group lists by virtual hostname mlists = {} - for listname in Utils.list_names(): + for listname in config.list_manager.names: mlist = MailList.MailList(listname, lock=False) mlists.setdefault(mlist.host_name, []).append(mlist) try: diff --git a/Mailman/bin/list_lists.py b/Mailman/bin/list_lists.py index b4ca44366..14a57f002 100644 --- a/Mailman/bin/list_lists.py +++ b/Mailman/bin/list_lists.py @@ -19,7 +19,6 @@ import optparse from Mailman import Defaults from Mailman import MailList -from Mailman import Utils from Mailman import Version from Mailman.i18n import _ from Mailman.initialize import initialize @@ -66,12 +65,10 @@ def main(): parser, opts, args = parseargs() initialize(opts.config) - names = list(Utils.list_names()) - names.sort() mlists = [] longest = 0 - for n in names: + for n in sorted(config.list_manager.names): mlist = MailList.MailList(n, lock=False) if opts.advertised and not mlist.advertised: continue diff --git a/Mailman/bin/list_owners.py b/Mailman/bin/list_owners.py index b9f12047d..5bc01eeee 100644 --- a/Mailman/bin/list_owners.py +++ b/Mailman/bin/list_owners.py @@ -18,7 +18,6 @@ import sys import optparse -from Mailman import Utils from Mailman import Version from Mailman.MailList import MailList from Mailman.configuration import config @@ -55,7 +54,7 @@ def main(): parser, opts, args = parseargs() config.load(opts.config) - listnames = args or Utils.list_names() + listnames = set(args or config.list_manager.names) bylist = {} for listname in listnames: diff --git a/Mailman/bin/nightly_gzip.py b/Mailman/bin/nightly_gzip.py index 4bd9271bc..efd054293 100644 --- a/Mailman/bin/nightly_gzip.py +++ b/Mailman/bin/nightly_gzip.py @@ -25,7 +25,6 @@ except ImportError: sys.exit(0) from Mailman import MailList -from Mailman import Utils from Mailman import Version from Mailman.configuration import config from Mailman.i18n import _ @@ -86,7 +85,7 @@ def main(): return # Process all the specified lists - for listname in set(args or Utils.list_names()): + for listname in set(args or config.list_manager.names): mlist = MailList.MailList(listname, lock=False) if not mlist.archive: continue diff --git a/Mailman/bin/senddigests.py b/Mailman/bin/senddigests.py index 96e3cfabd..a2c7089df 100644 --- a/Mailman/bin/senddigests.py +++ b/Mailman/bin/senddigests.py @@ -19,7 +19,6 @@ import sys import optparse from Mailman import MailList -from Mailman import Utils from Mailman import Version from Mailman.i18n import _ from Mailman.initialize import initialize @@ -59,7 +58,7 @@ def main(): opts, args, parser = parseargs() initialize(opts.config) - for listname in set(opts.listnames or Utils.list_names()): + for listname in set(opts.listnames or config.list_manager.names): mlist = MailList.MailList(listname, lock=False) if mlist.digest_send_periodic: mlist.Lock() diff --git a/Mailman/bin/update.py b/Mailman/bin/update.py index 281f40420..93bb0021b 100644 --- a/Mailman/bin/update.py +++ b/Mailman/bin/update.py @@ -242,10 +242,6 @@ def dolist(listname): for addr in noinfo.keys(): mlist.setDeliveryStatus(addr, ENABLED) - # Update the held requests database - print _("""Updating the held requests database.""") - mlist._UpdateRecords() - mbox_dir = make_varabs('archives/private/%s.mbox' % (listname)) mbox_file = make_varabs('archives/private/%s.mbox/%s' % (listname, listname)) @@ -538,121 +534,6 @@ def dequeue(filebase): -def update_pending(): - file20 = os.path.join(config.DATA_DIR, 'pending_subscriptions.db') - file214 = os.path.join(config.DATA_DIR, 'pending.pck') - db = None - # Try to load the Mailman 2.0 file - try: - fp = open(file20) - except IOError, e: - if e.errno <> errno.ENOENT: - raise - else: - print _('Updating Mailman 2.0 pending_subscriptions.db database') - db = marshal.load(fp) - # Convert to the pre-Mailman 2.1.5 format - db = Pending._update(db) - if db is None: - # Try to load the Mailman 2.1.x where x < 5, file - try: - fp = open(file214) - except IOError, e: - if e.errno <> errno.ENOENT: - raise - else: - print _('Updating Mailman 2.1.4 pending.pck database') - db = cPickle.load(fp) - if db is None: - print _('Nothing to do.') - return - # Now upgrade the database to the 2.1.5 format. Each list now has its own - # pending.pck file, but only the RE_ENABLE operation actually recorded the - # listname in the request. For the SUBSCRIPTION, UNSUBSCRIPTION, and - # CHANGE_OF_ADDRESS operations, we know the address of the person making - # the request so we can repend this request just for the lists the person - # is a member of. For the HELD_MESSAGE operation, we can check the list's - # requests.pck file for correlation. Evictions will take care of any - # misdirected pendings. - reenables_by_list = {} - addrops_by_address = {} - holds_by_id = {} - subs_by_address = {} - for key, val in db.items(): - if key in ('evictions', 'version'): - continue - try: - op = val[0] - data = val[1:] - except (IndexError, ValueError): - print _('Ignoring bad pended data: $key: $val') - continue - if op in (Pending.UNSUBSCRIPTION, Pending.CHANGE_OF_ADDRESS): - # data[0] is the address being unsubscribed - addrops_by_address.setdefault(data[0], []).append((key, val)) - elif op == Pending.SUBSCRIPTION: - # data[0] is a UserDesc object - addr = data[0].address - subs_by_address.setdefault(addr, []).append((key, val)) - elif op == Pending.RE_ENABLE: - # data[0] is the mailing list's internal name - reenables_by_list.setdefault(data[0], []).append((key, val)) - elif op == Pending.HELD_MESSAGE: - # data[0] is the hold id. There better only be one entry per id - id = data[0] - if holds_by_id.has_key(id): - print _('WARNING: Ignoring duplicate pending ID: $id.') - else: - holds_by_id[id] = (key, val) - # Now we have to lock every list and re-pend all the appropriate - # requests. Note that this will reset all the expiration dates, but that - # should be fine. - for listname in Utils.list_names(): - mlist = MailList.MailList(listname) - # This is not the most efficient way to do this because it loads and - # saves the pending.pck file each time. :( - try: - for cookie, data in reenables_by_list.get(listname, []): - mlist.pend_repend(cookie, data) - for id, (cookie, data) in holds_by_id.items(): - try: - rec = mlist.GetRecord(id) - except KeyError: - # Not for this list - pass - else: - mlist.pend_repend(cookie, data) - del holds_by_id[id] - for addr, recs in subs_by_address.items(): - # We shouldn't have a subscription confirmation if the address - # is already a member of the mailing list. - if mlist.isMember(addr): - continue - for cookie, data in recs: - mlist.pend_repend(cookie, data) - for addr, recs in addrops_by_address.items(): - # We shouldn't have unsubscriptions or change of address - # requests for addresses which aren't members of the list. - if not mlist.isMember(addr): - continue - for cookie, data in recs: - mlist.pend_repend(cookie, data) - mlist.Save() - finally: - mlist.Unlock() - try: - os.unlink(file20) - except OSError, e: - if e.errno <> errno.ENOENT: - raise - try: - os.unlink(file214) - except OSError, e: - if e.errno <> errno.ENOENT: - raise - - - def main(): parser, opts, args = parseargs() initialize(opts.config) @@ -682,8 +563,7 @@ Exiting.""") 'scripts/mailowner', 'mail/wrapper', 'Mailman/pythonlib', 'cgi-bin/archives', 'Mailman/MailCommandHandler'): remove_old_sources(mod) - listnames = Utils.list_names() - if not listnames: + if not config.list_manager.names: print _('no lists == nothing to do, exiting') return # For people with web archiving, make sure the directories @@ -695,7 +575,7 @@ 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 listnames: + 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: @@ -732,10 +612,6 @@ If your archives are big, this could take a minute or two...""") mlist.Unlock() os.unlink(wmfile) print _('- usenet watermarks updated and gate_watermarks removed') - # In Mailman 2.1, the pending database format and file name changed, but - # in Mailman 2.1.5 it changed again. This should update all existing - # files to the 2.1.5 format. - update_pending() # 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 diff --git a/Mailman/bin/withlist.py b/Mailman/bin/withlist.py index b108e4a18..ac1ab0aac 100644 --- a/Mailman/bin/withlist.py +++ b/Mailman/bin/withlist.py @@ -22,7 +22,6 @@ import optparse from Mailman import Errors from Mailman import MailList -from Mailman import Utils from Mailman import Version from Mailman import interact from Mailman.configuration import config @@ -235,7 +234,8 @@ def main(): r = None if opts.all: - r = [do_list(listname, args, func) for listname in Utils.list_names()] + 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) diff --git a/Mailman/configuration.py b/Mailman/configuration.py index a0d35e483..3247204b3 100644 --- a/Mailman/configuration.py +++ b/Mailman/configuration.py @@ -97,6 +97,7 @@ class Configuration(object): self.DATA_DIR = datadir = os.path.join(VAR_PREFIX, 'data') self.ETC_DIR = etcdir = os.path.join(VAR_PREFIX, 'etc') self.SPAM_DIR = os.path.join(VAR_PREFIX, 'spam') + self.EXT_DIR = os.path.join(VAR_PREFIX, 'ext') self.WRAPPER_DIR = os.path.join(EXEC_PREFIX, 'mail') self.BIN_DIR = os.path.join(PREFIX, 'bin') self.SCRIPTS_DIR = os.path.join(PREFIX, 'scripts') diff --git a/Mailman/constants.py b/Mailman/constants.py new file mode 100644 index 000000000..852704364 --- /dev/null +++ b/Mailman/constants.py @@ -0,0 +1,44 @@ +# Copyright (C) 2006-2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Various constants and enumerations.""" + +from munepy import Enum + + + +class DeliveryMode(Enum): + # Regular (i.e. non-digest) delivery + regular = 1 + # Plain text digest delivery + plaintext_digests = 2 + # MIME digest delivery + mime_digests = 3 + # Summary digests + summary_digests = 4 + + + +class DeliveryStatus(Enum): + # Delivery is enabled + enabled = 1 + # Delivery was disabled by the user + by_user = 2 + # Delivery was disabled due to bouncing addresses + by_bounces = 3 + # Delivery was disabled by an administrator or moderator + by_moderator = 4 diff --git a/Mailman/database/Makefile.in b/Mailman/database/Makefile.in index 8936d791b..56fcb3f29 100644 --- a/Mailman/database/Makefile.in +++ b/Mailman/database/Makefile.in @@ -1,4 +1,4 @@ -# Copyright (C) 1998-2007 by the Free Software Foundation, Inc. +# Copyright (C) 2007 by the Free Software Foundation, Inc. # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License @@ -42,6 +42,7 @@ PACKAGEDIR= $(prefix)/Mailman/database SHELL= /bin/sh MODULES= *.py +SUBDIRS= tables model # Modes for directories and executables created by the install # process. Default to group-writable directories but @@ -55,17 +56,37 @@ INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) # Rules all: + for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE)); \ + done install: for f in $(MODULES); \ do \ $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(DESTDIR)$(PACKAGEDIR); \ done + for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE) DESTDIR=$(DESTDIR) install); \ + done finish: + @for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE) DESTDIR=$(DESTDIR) finish); \ + done clean: + for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE) clean); \ + done distclean: -rm *.pyc -rm Makefile + for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE) distclean); \ + done diff --git a/Mailman/database/__init__.py b/Mailman/database/__init__.py index 808ad9fd0..6c6312d0a 100644 --- a/Mailman/database/__init__.py +++ b/Mailman/database/__init__.py @@ -15,31 +15,35 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. -# This module exposes the higher level interface methods that the rest of -# Mailman should use. It essentially hides the dbcontext and the SQLAlchemy -# session from all other code. The preferred way to use these methods is: -# -# from Mailman import database -# database.add_list(foo) +from __future__ import with_statement import os +from elixir import objectstore + +from Mailman.database.listmanager import ListManager +from Mailman.database.usermanager import UserManager + +__all__ = [ + 'initialize', + 'flush', + ] + + def initialize(): - from Mailman import database from Mailman.LockFile import LockFile from Mailman.configuration import config - from Mailman.database.dbcontext import dbcontext + from Mailman.database import model # Serialize this so we don't get multiple processes trying to create the # database at the same time. lockfile = os.path.join(config.LOCK_DIR, '<dbcreatelock>') - lock = LockFile(lockfile) - lock.lock() - try: - dbcontext.connect() - finally: - lock.unlock() - for attr in dir(dbcontext): - if attr.startswith('api_'): - exposed_name = attr[4:] - setattr(database, exposed_name, getattr(dbcontext, attr)) + with LockFile(lockfile): + model.initialize() + config.list_manager = ListManager() + config.user_manager = UserManager() + flush() + + +def flush(): + objectstore.flush() diff --git a/Mailman/database/dbcontext.py b/Mailman/database/dbcontext.py index 5421fb6f2..eaf87be93 100644 --- a/Mailman/database/dbcontext.py +++ b/Mailman/database/dbcontext.py @@ -16,19 +16,17 @@ # USA. import os +import sys import logging import weakref -from sqlalchemy import BoundMetaData, create_session +from elixir import create_all, metadata, objectstore +from sqlalchemy import create_engine from string import Template from urlparse import urlparse from Mailman import Version from Mailman.configuration import config -from Mailman.database import address -from Mailman.database import languages -from Mailman.database import listdata -from Mailman.database import version from Mailman.database.txnsupport import txn @@ -39,19 +37,9 @@ class MlistRef(weakref.ref): self.fqdn_listname = mlist.fqdn_listname -class Tables(object): - def bind(self, table, attrname=None): - if attrname is None: - attrname = table.name.lower() - setattr(self, attrname, table) - - class DBContext(object): def __init__(self): - self.tables = Tables() - self.metadata = None - self.session = None # Special transaction used only for MailList.Lock() .Save() and # .Unlock() interface. self._mlist_txns = {} @@ -75,37 +63,30 @@ class DBContext(object): # engines, and yes, we could have chmod'd the file after the fact, but # half dozen and all... self._touch(url) - self.metadata = BoundMetaData(url) - self.metadata.engine.echo = config.SQLALCHEMY_ECHO - # Create all the table objects, and then let SA conditionally create - # them if they don't yet exist. NOTE: this order matters! - for module in (languages, address, listdata, version): - module.make_table(self.metadata, self.tables) - self.metadata.create_all() - # Validate schema version, updating if necessary (XXX) - r = self.tables.version.select( - self.tables.version.c.component=='schema').execute() - row = r.fetchone() - if row is None: + engine = create_engine(url) + engine.echo = config.SQLALCHEMY_ECHO + metadata.connect(engine) + # Load and create the Elixir active records. This works by + # side-effect. + import Mailman.database.model + create_all() + # Validate schema version. + v = Mailman.database.model.Version.get_by(component='schema') + if not v: # Database has not yet been initialized - self.tables.version.insert().execute( + v = Mailman.database.model.Version( component='schema', version=Version.DATABASE_SCHEMA_VERSION) - elif row.version <> Version.DATABASE_SCHEMA_VERSION: + objectstore.flush() + elif v.version <> Version.DATABASE_SCHEMA_VERSION: # XXX Update schema - raise SchemaVersionMismatchError(row.version) - self.session = create_session() - - def close(self): - self.session.close() - self.session = None + raise SchemaVersionMismatchError(v.version) def _touch(self, url): parts = urlparse(url) - # XXX Python 2.5; use parts.scheme and parts.path - if parts[0] <> 'sqlite': + if parts.scheme <> 'sqlite': return - path = os.path.normpath(parts[2]) + path = os.path.normpath(parts.path) fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK | os.O_CREAT, 0666) # Ignore errors if fd > 0: @@ -114,7 +95,7 @@ class DBContext(object): # Cooperative method for use with @txn decorator def _withtxn(self, meth, *args, **kws): try: - txn = self.session.create_transaction() + txn = objectstore.session.current.create_transaction() rtn = meth(*args, **kws) except: txn.rollback() @@ -133,7 +114,7 @@ class DBContext(object): # Don't try to re-lock a list if mlist.fqdn_listname in self._mlist_txns: return - txn = self.session.create_transaction() + txn = objectstore.session.current.create_transaction() mref = MlistRef(mlist, self._unlock_mref) # If mlist.host_name is changed, its fqdn_listname attribute will no # longer match, so its transaction will not get committed when the @@ -155,7 +136,7 @@ class DBContext(object): def api_load(self, mlist): # Mark the MailList object such that future attribute accesses will # refresh from the database. - self.session.expire(mlist) + objectstore.session.current.expire(mlist) def api_save(self, mlist): # When dealing with MailLists, .Save() will always be followed by @@ -172,29 +153,22 @@ class DBContext(object): @txn def api_add_list(self, mlist): - self.session.save(mlist) + objectstore.session.current.save(mlist) @txn def api_remove_list(self, mlist): - self.session.delete(mlist) + objectstore.session.current.delete(mlist) @txn def api_find_list(self, listname, hostname): from Mailman.MailList import MailList - q = self.session.query(MailList) + q = objectstore.session.current.query(MailList) mlists = q.select_by(list_name=listname, host_name=hostname) assert len(mlists) <= 1, 'Duplicate mailing lists!' if mlists: return mlists[0] return None - @txn - def api_get_list_names(self): - table = self.tables.listdata - results = table.select().execute() - return [(row[table.c.list_name], row[table.c.host_name]) - for row in results.fetchall()] - dbcontext = DBContext() diff --git a/Mailman/database/listmanager.py b/Mailman/database/listmanager.py new file mode 100644 index 000000000..de8abbd58 --- /dev/null +++ b/Mailman/database/listmanager.py @@ -0,0 +1,81 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""SQLAlchemy/Elixir based provider of IListManager.""" + +import weakref + +from elixir import * +from zope.interface import implements + +from Mailman import Errors +from Mailman.Utils import split_listname, fqdn_listname +from Mailman.configuration import config +from Mailman.database.model import MailingList +from Mailman.interfaces import IListManager + + + +class ListManager(object): + implements(IListManager) + + def __init__(self): + self._objectmap = weakref.WeakKeyDictionary() + + def create(self, fqdn_listname): + listname, hostname = split_listname(fqdn_listname) + mlist = MailingList.get_by(list_name=listname, + host_name=hostname) + if mlist: + raise Errors.MMListAlreadyExistsError(fqdn_listname) + mlist = MailingList(fqdn_listname) + # Wrap the database model object in an application MailList object and + # return the latter. Keep track of the wrapper so we can clean it up + # when we're done with it. + from Mailman.MailList import MailList + wrapper = MailList(mlist) + self._objectmap[mlist] = wrapper + return wrapper + + def delete(self, mlist): + # Delete the wrapped backing data. XXX It's kind of icky to reach + # into the MailList object this way. + mlist._data.delete_rosters() + mlist._data.delete() + mlist._data = None + + def get(self, fqdn_listname): + listname, hostname = split_listname(fqdn_listname) + mlist = MailingList.get_by(list_name=listname, + host_name=hostname) + if not mlist: + raise Errors.MMUnknownListError(fqdn_listname) + from Mailman.MailList import MailList + wrapper = self._objectmap.setdefault(mlist, MailList(mlist)) + return wrapper + + @property + def mailing_lists(self): + # Don't forget, the MailingList objects that this class manages must + # be wrapped in a MailList object as expected by this interface. + for fqdn_listname in self.names: + yield self.get(fqdn_listname) + + @property + def names(self): + for mlist in MailingList.select(): + yield fqdn_listname(mlist.list_name, mlist.host_name) diff --git a/Mailman/database/model/Makefile.in b/Mailman/database/model/Makefile.in new file mode 100644 index 000000000..2db8ce45e --- /dev/null +++ b/Mailman/database/model/Makefile.in @@ -0,0 +1,71 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ +DESTDIR= + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/database/model +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(DESTDIR)$(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/database/model/__init__.py b/Mailman/database/model/__init__.py new file mode 100644 index 000000000..11ca11f89 --- /dev/null +++ b/Mailman/database/model/__init__.py @@ -0,0 +1,96 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +__all__ = [ + 'Address', + 'Language', + 'MailingList', + 'Profile', + 'Roster', + 'RosterSet', + 'User', + 'Version', + ] + +import os +import sys +import elixir + +from sqlalchemy import create_engine +from string import Template +from urlparse import urlparse + +import Mailman.Version + +elixir.delay_setup = True + +from Mailman import constants +from Mailman.Errors import SchemaVersionMismatchError +from Mailman.configuration import config +from Mailman.database.model.address import Address +from Mailman.database.model.language import Language +from Mailman.database.model.mailinglist import MailingList +from Mailman.database.model.profile import Profile +from Mailman.database.model.roster import Roster +from Mailman.database.model.rosterset import RosterSet +from Mailman.database.model.user import User +from Mailman.database.model.version import Version + + + +def initialize(): + # Calculate the engine url + url = Template(config.SQLALCHEMY_ENGINE_URL).safe_substitute(config.paths) + # XXX By design of SQLite, database file creation does not honor + # umask. See their ticket #1193: + # http://www.sqlite.org/cvstrac/tktview?tn=1193,31 + # + # This sucks for us because the mailman.db file /must/ be group writable, + # however even though we guarantee our umask is 002 here, it still gets + # created without the necessary g+w permission, due to SQLite's policy. + # This should only affect SQLite engines because its the only one that + # creates a little file on the local file system. This kludges around + # their bug by "touch"ing the database file before SQLite has any chance + # to create it, thus honoring the umask and ensuring the right + # permissions. We only try to do this for SQLite engines, and yes, we + # could have chmod'd the file after the fact, but half dozen and all... + touch(url) + engine = create_engine(url) + engine.echo = config.SQLALCHEMY_ECHO + elixir.metadata.connect(engine) + elixir.setup_all() + # Validate schema version. + v = Version.get_by(component='schema') + if not v: + # Database has not yet been initialized + v = Version(component='schema', + version=Mailman.Version.DATABASE_SCHEMA_VERSION) + elixir.objectstore.flush() + elif v.version <> Mailman.Version.DATABASE_SCHEMA_VERSION: + # XXX Update schema + raise SchemaVersionMismatchError(v.version) + + +def touch(url): + parts = urlparse(url) + if parts.scheme <> 'sqlite': + return + path = os.path.normpath(parts.path) + fd = os.open(path, os.O_WRONLY | os.O_NONBLOCK | os.O_CREAT, 0666) + # Ignore errors + if fd > 0: + os.close(fd) diff --git a/Mailman/database/model/address.py b/Mailman/database/model/address.py new file mode 100644 index 000000000..53d5016e5 --- /dev/null +++ b/Mailman/database/model/address.py @@ -0,0 +1,46 @@ +# Copyright (C) 2006-2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +from elixir import * +from email.utils import formataddr +from zope.interface import implements + +from Mailman.interfaces import IAddress + + +ROSTER_KIND = 'Mailman.database.model.roster.Roster' +USER_KIND = 'Mailman.database.model.user.User' + + +class Address(Entity): + implements(IAddress) + + has_field('address', Unicode) + has_field('real_name', Unicode) + has_field('verified', Boolean) + has_field('registered_on', DateTime) + has_field('validated_on', DateTime) + # Relationships + has_and_belongs_to_many('rosters', of_kind=ROSTER_KIND) + belongs_to('user', of_kind=USER_KIND) + + def __str__(self): + return formataddr((self.real_name, self.address)) + + def __repr__(self): + return '<Address: %s [%s]>' % ( + str(self), ('verified' if self.verified else 'not verified')) diff --git a/Mailman/database/address.py b/Mailman/database/model/language.py index 672a366b1..3597a128d 100644 --- a/Mailman/database/address.py +++ b/Mailman/database/model/language.py @@ -15,16 +15,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, # USA. -"""Email addresses.""" +from elixir import * -from sqlalchemy import * - - -def make_table(metadata, tables): - table = Table( - 'Address', metadata, - Column('address_id', Integer, primary_key=True), - Column('address', Unicode(4096)), - ) - tables.bind(table) +class Language(Entity): + has_field('code', Unicode) diff --git a/Mailman/database/model/mailinglist.py b/Mailman/database/model/mailinglist.py new file mode 100644 index 000000000..28e2c11dc --- /dev/null +++ b/Mailman/database/model/mailinglist.py @@ -0,0 +1,275 @@ +# Copyright (C) 2006-2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +from elixir import * +from zope.interface import implements + +from Mailman.Utils import fqdn_listname, split_listname +from Mailman.configuration import config +from Mailman.interfaces import * + + + +class MailingList(Entity): + implements( + IMailingList, + IMailingListAddresses, + IMailingListIdentity, + IMailingListRosters, + ) + + # List identity + has_field('list_name', Unicode), + has_field('host_name', Unicode), + # Attributes not directly modifiable via the web u/i + has_field('web_page_url', Unicode), + has_field('admin_member_chunksize', Integer), + # Attributes which are directly modifiable via the web u/i. The more + # complicated attributes are currently stored as pickles, though that + # will change as the schema and implementation is developed. + has_field('next_request_id', Integer), + has_field('next_digest_number', Integer), + has_field('admin_responses', PickleType), + has_field('postings_responses', PickleType), + has_field('request_responses', PickleType), + has_field('digest_last_sent_at', Float), + has_field('one_last_digest', PickleType), + has_field('volume', Integer), + has_field('last_post_time', Float), + # OldStyleMemberships attributes, temporarily stored as pickles. + has_field('bounce_info', PickleType), + has_field('delivery_status', PickleType), + has_field('digest_members', PickleType), + has_field('language', PickleType), + has_field('members', PickleType), + has_field('passwords', PickleType), + has_field('topics_userinterest', PickleType), + has_field('user_options', PickleType), + has_field('usernames', PickleType), + # Attributes which are directly modifiable via the web u/i. The more + # complicated attributes are currently stored as pickles, though that + # will change as the schema and implementation is developed. + has_field('accept_these_nonmembers', PickleType), + has_field('acceptable_aliases', PickleType), + has_field('admin_immed_notify', Boolean), + has_field('admin_notify_mchanges', Boolean), + has_field('administrivia', Boolean), + has_field('advertised', Boolean), + has_field('anonymous_list', Boolean), + has_field('archive', Boolean), + has_field('archive_private', Boolean), + has_field('archive_volume_frequency', Integer), + has_field('autorespond_admin', Boolean), + has_field('autorespond_postings', Boolean), + has_field('autorespond_requests', Integer), + has_field('autoresponse_admin_text', Unicode), + has_field('autoresponse_graceperiod', Integer), + has_field('autoresponse_postings_text', Unicode), + has_field('autoresponse_request_text', Unicode), + has_field('ban_list', PickleType), + has_field('bounce_info_stale_after', Integer), + has_field('bounce_matching_headers', Unicode), + has_field('bounce_notify_owner_on_disable', Boolean), + has_field('bounce_notify_owner_on_removal', Boolean), + has_field('bounce_processing', Boolean), + has_field('bounce_score_threshold', Integer), + has_field('bounce_unrecognized_goes_to_list_owner', Boolean), + has_field('bounce_you_are_disabled_warnings', Integer), + has_field('bounce_you_are_disabled_warnings_interval', Integer), + has_field('collapse_alternatives', Boolean), + has_field('convert_html_to_plaintext', Boolean), + has_field('default_member_moderation', Boolean), + has_field('description', Unicode), + has_field('digest_footer', Unicode), + has_field('digest_header', Unicode), + has_field('digest_is_default', Boolean), + has_field('digest_send_periodic', Boolean), + has_field('digest_size_threshhold', Integer), + has_field('digest_volume_frequency', Integer), + has_field('digestable', Boolean), + has_field('discard_these_nonmembers', PickleType), + has_field('emergency', Boolean), + has_field('encode_ascii_prefixes', Boolean), + has_field('filter_action', Integer), + has_field('filter_content', Boolean), + has_field('filter_filename_extensions', PickleType), + has_field('filter_mime_types', PickleType), + has_field('first_strip_reply_to', Boolean), + has_field('forward_auto_discards', Boolean), + has_field('gateway_to_mail', Boolean), + has_field('gateway_to_news', Boolean), + has_field('generic_nonmember_action', Integer), + has_field('goodbye_msg', Unicode), + has_field('header_filter_rules', PickleType), + has_field('hold_these_nonmembers', PickleType), + has_field('include_list_post_header', Boolean), + has_field('include_rfc2369_headers', Boolean), + has_field('info', Unicode), + has_field('linked_newsgroup', Unicode), + has_field('max_days_to_hold', Integer), + has_field('max_message_size', Integer), + has_field('max_num_recipients', Integer), + has_field('member_moderation_action', Boolean), + has_field('member_moderation_notice', Unicode), + has_field('mime_is_default_digest', Boolean), + has_field('mod_password', Unicode), + has_field('msg_footer', Unicode), + has_field('msg_header', Unicode), + has_field('new_member_options', Integer), + has_field('news_moderation', Boolean), + has_field('news_prefix_subject_too', Boolean), + has_field('nntp_host', Unicode), + has_field('nondigestable', Boolean), + has_field('nonmember_rejection_notice', Unicode), + has_field('obscure_addresses', Boolean), + has_field('pass_filename_extensions', PickleType), + has_field('pass_mime_types', PickleType), + has_field('password', Unicode), + has_field('personalize', Integer), + has_field('post_id', Integer), + has_field('preferred_language', Unicode), + has_field('private_roster', Boolean), + has_field('real_name', Unicode), + has_field('reject_these_nonmembers', PickleType), + has_field('reply_goes_to_list', Boolean), + has_field('reply_to_address', Unicode), + has_field('require_explicit_destination', Boolean), + has_field('respond_to_post_requests', Boolean), + has_field('scrub_nondigest', Boolean), + has_field('send_goodbye_msg', Boolean), + has_field('send_reminders', Boolean), + has_field('send_welcome_msg', Boolean), + has_field('subject_prefix', Unicode), + has_field('subscribe_auto_approval', PickleType), + has_field('subscribe_policy', Integer), + has_field('topics', PickleType), + has_field('topics_bodylines_limit', Integer), + has_field('topics_enabled', Boolean), + has_field('umbrella_list', Boolean), + has_field('umbrella_member_suffix', Unicode), + has_field('unsubscribe_policy', Integer), + has_field('welcome_msg', Unicode), + # Indirect relationships + has_field('owner_rosterset', Unicode), + has_field('moderator_rosterset', Unicode), + # Relationships +## has_and_belongs_to_many( +## 'available_languages', +## of_kind='Mailman.database.model.languages.Language') + + def __init__(self, fqdn_listname): + super(MailingList, self).__init__() + listname, hostname = split_listname(fqdn_listname) + self.list_name = listname + self.host_name = hostname + # Create two roster sets, one for the owners and one for the + # moderators. MailingLists are connected to RosterSets indirectly, in + # order to preserve the ability to store user data and list data in + # different databases. + name = fqdn_listname + ' owners' + self.owner_rosterset = name + roster = config.user_manager.create_roster(name) + config.user_manager.create_rosterset(name).add(roster) + name = fqdn_listname + ' moderators' + self.moderator_rosterset = name + roster = config.user_manager.create_roster(name) + config.user_manager.create_rosterset(name).add(roster) + + def delete_rosters(self): + listname = fqdn_listname(self.list_name, self.host_name) + # Delete the list owner roster and roster set. + name = listname + ' owners' + roster = config.user_manager.get_roster(name) + assert roster, 'Missing roster: %s' % name + config.user_manager.delete_roster(roster) + rosterset = config.user_manager.get_rosterset(name) + assert rosterset, 'Missing roster set: %s' % name + config.user_manager.delete_rosterset(rosterset) + name = listname + ' moderators' + roster = config.user_manager.get_roster(name) + assert roster, 'Missing roster: %s' % name + config.user_manager.delete_roster(roster) + rosterset = config.user_manager.get_rosterset(name) + assert rosterset, 'Missing roster set: %s' % name + config.user_manager.delete_rosterset(rosterset) + + # IMailingListRosters + + @property + def owners(self): + for user in _collect_users(self.owner_rosterset): + yield user + + @property + def moderators(self): + for user in _collect_users(self.moderator_rosterset): + yield user + + @property + def administrators(self): + for user in _collect_users(self.owner_rosterset, + self.moderator_rosterset): + yield user + + @property + def owner_rosters(self): + rosterset = config.user_manager.get_rosterset(self.owner_rosterset) + for roster in rosterset.rosters: + yield roster + + @property + def moderator_rosters(self): + rosterset = config.user_manager.get_rosterset(self.moderator_rosterset) + for roster in rosterset.rosters: + yield roster + + def add_owner_roster(self, roster): + rosterset = config.user_manager.get_rosterset(self.owner_rosterset) + rosterset.add(roster) + + def delete_owner_roster(self, roster): + rosterset = config.user_manager.get_rosterset(self.owner_rosterset) + rosterset.delete(roster) + + def add_moderator_roster(self, roster): + rosterset = config.user_manager.get_rosterset(self.moderator_rosterset) + rosterset.add(roster) + + def delete_moderator_roster(self, roster): + rosterset = config.user_manager.get_rosterset(self.moderator_rosterset) + rosterset.delete(roster) + + + +def _collect_users(*rosterset_names): + users = set() + for name in rosterset_names: + # We have to indirectly look up the roster set's name in the user + # manager. This is how we enforce separation between the list manager + # and the user manager storages. + rosterset = config.user_manager.get_rosterset(name) + assert rosterset is not None, 'No RosterSet named: %s' % name + for roster in rosterset.rosters: + # Rosters collect addresses. It's not required that an address is + # linked to a user, but it must be the case that all addresses on + # the owner roster are linked to a user. Get the user that's + # linked to each address and add it to the set. + for address in roster.addresses: + user = config.user_manager.get_user(address.address) + assert user is not None, 'Unlinked address: ' + address.address + users.add(user) + return users diff --git a/Mailman/database/model/profile.py b/Mailman/database/model/profile.py new file mode 100644 index 000000000..49e108728 --- /dev/null +++ b/Mailman/database/model/profile.py @@ -0,0 +1,46 @@ +# Copyright (C) 2006-2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +from elixir import * +from email.utils import formataddr +from zope.interface import implements + +from Mailman.constants import DeliveryMode +from Mailman.database.types import EnumType +from Mailman.interfaces import IProfile + + +class Profile(Entity): + implements(IProfile) + + has_field('acknowledge_posts', Boolean) + has_field('hide_address', Boolean) + has_field('preferred_language', Unicode) + has_field('receive_list_copy', Boolean) + has_field('receive_own_postings', Boolean) + has_field('delivery_mode', EnumType) + # Relationships + belongs_to('user', of_kind='Mailman.database.model.user.User') + + def __init__(self): + super(Profile, self).__init__() + self.acknowledge_posts = False + self.hide_address = True + self.preferred_language = 'en' + self.receive_list_copy = True + self.receive_own_postings = True + self.delivery_mode = DeliveryMode.regular diff --git a/Mailman/database/model/roster.py b/Mailman/database/model/roster.py new file mode 100644 index 000000000..bf8447433 --- /dev/null +++ b/Mailman/database/model/roster.py @@ -0,0 +1,51 @@ +# Copyright (C) 2006-2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +from elixir import * +from zope.interface import implements + +from Mailman.Errors import ExistingAddressError +from Mailman.interfaces import IRoster + + +ADDRESS_KIND = 'Mailman.database.model.address.Address' +ROSTERSET_KIND = 'Mailman.database.model.rosterset.RosterSet' + + +class Roster(Entity): + implements(IRoster) + + has_field('name', Unicode) + # Relationships + has_and_belongs_to_many('addresses', of_kind=ADDRESS_KIND) + has_and_belongs_to_many('roster_set', of_kind=ROSTERSET_KIND) + + def create(self, email_address, real_name=None): + """See IRoster""" + from Mailman.database.model.address import Address + addr = Address.get_by(address=email_address) + if addr: + raise ExistingAddressError(email_address) + addr = Address(address=email_address, real_name=real_name) + # Make sure all the expected links are made, including to the null + # (i.e. everyone) roster. + self.addresses.append(addr) + addr.rosters.append(self) + null_roster = Roster.get_by(name='') + null_roster.addresses.append(addr) + addr.rosters.append(null_roster) + return addr diff --git a/Mailman/database/model/rosterset.py b/Mailman/database/model/rosterset.py new file mode 100644 index 000000000..f84b52c15 --- /dev/null +++ b/Mailman/database/model/rosterset.py @@ -0,0 +1,41 @@ +# Copyright (C) 2006-2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +from elixir import * +from zope.interface import implements + +from Mailman.interfaces import IRosterSet + +ROSTER_KIND = 'Mailman.database.model.roster.Roster' + + + +# Internal implementation of roster sets for use with mailing lists. These +# are owned by the user storage. +class RosterSet(Entity): + implements(IRosterSet) + + has_field('name', Unicode) + has_and_belongs_to_many('rosters', of_kind=ROSTER_KIND) + + def add(self, roster): + if roster not in self.rosters: + self.rosters.append(roster) + + def delete(self, roster): + if roster in self.rosters: + self.rosters.remove(roster) diff --git a/Mailman/database/model/user.py b/Mailman/database/model/user.py new file mode 100644 index 000000000..be634b9df --- /dev/null +++ b/Mailman/database/model/user.py @@ -0,0 +1,50 @@ +# Copyright (C) 2006-2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +from elixir import * +from email.utils import formataddr +from zope.interface import implements + +from Mailman import Errors +from Mailman.database.model import Address +from Mailman.interfaces import IUser + + +class User(Entity): + implements(IUser) + + has_field('real_name', Unicode) + has_field('password', Unicode) + # Relationships + has_one('profile', of_kind='Mailman.database.model.profile.Profile') + has_many('addresses', of_kind='Mailman.database.model.address.Address') + + def link(self, address): + if address.user is not None: + raise Errors.AddressAlreadyLinkedError(address) + address.user = self + self.addresses.append(address) + + def unlink(self, address): + if address.user is None: + raise Errors.AddressNotLinkedError(address) + address.user = None + self.addresses.remove(address) + + def controls(self, address): + found = Address.get_by(address=address.address) + return bool(found and found.user is self) diff --git a/Mailman/database/model/version.py b/Mailman/database/model/version.py new file mode 100644 index 000000000..e22e8ae11 --- /dev/null +++ b/Mailman/database/model/version.py @@ -0,0 +1,25 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +from elixir import * + + +class Version(Entity): + with_fields( + component = Field(String), + version = Field(Integer), + ) diff --git a/Mailman/database/tables/Makefile.in b/Mailman/database/tables/Makefile.in new file mode 100644 index 000000000..dd99e125f --- /dev/null +++ b/Mailman/database/tables/Makefile.in @@ -0,0 +1,71 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ +DESTDIR= + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/database/tables +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(DESTDIR)$(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/database/tables/__init__.py b/Mailman/database/tables/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/Mailman/database/tables/__init__.py diff --git a/Mailman/database/tables/addresses.py b/Mailman/database/tables/addresses.py new file mode 100644 index 000000000..922984646 --- /dev/null +++ b/Mailman/database/tables/addresses.py @@ -0,0 +1,45 @@ +# Copyright (C) 2006-2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Email addresses.""" + +from sqlalchemy import * + + + +class Address(object): + pass + + +def make_table(metadata, tables): + address_table = Table( + 'Addresses', metadata, + Column('address_id', Integer, primary_key=True), + Column('profile_id', Integer, ForeignKey('Profiles.profile_id')), + Column('address', Unicode), + Column('verified', Boolean), + Column('bounce_info', PickleType), + ) + # Associate Rosters + address_rosters_table = Table( + 'AddressRoster', metadata, + Column('roster_id', Integer, ForeignKey('Rosters.roster_id')), + Column('address_id', Integer, ForeignKey('Addresses.address_id')), + ) + mapper(Address, address_table) + tables.bind(address_table) + tables.bind(address_rosters_table, 'address_rosters') diff --git a/Mailman/database/languages.py b/Mailman/database/tables/languages.py index 6032a67df..fa10974b7 100644 --- a/Mailman/database/languages.py +++ b/Mailman/database/tables/languages.py @@ -37,16 +37,16 @@ class Language(object): def make_table(metadata, tables): language_table = Table( - 'Language', metadata, + 'Languages', metadata, # Two letter language code Column('language_id', Integer, primary_key=True), Column('code', Unicode), ) # Associate List available_languages_table = Table( - 'AvailableLanguage', metadata, + 'AvailableLanguages', metadata, Column('list_id', Integer, ForeignKey('Listdata.list_id')), - Column('language_id', Integer, ForeignKey('Language.language_id')), + Column('language_id', Integer, ForeignKey('Languages.language_id')), ) mapper(Language, language_table) tables.bind(language_table) diff --git a/Mailman/database/listdata.py b/Mailman/database/tables/listdata.py index 9f537b229..fff396980 100644 --- a/Mailman/database/listdata.py +++ b/Mailman/database/tables/listdata.py @@ -29,6 +29,12 @@ def make_table(metadata, tables): Column('list_name', Unicode), Column('web_page_url', Unicode), Column('admin_member_chunksize', Integer), + # Foreign keys - XXX ondelete='all, delete=orphan' ?? + Column('owner', Integer, ForeignKey('RosterSets.rosterset_id')), + Column('moderator', Integer, ForeignKey('RosterSets.rosterset_id')), + # Attributes which are directly modifiable via the web u/i. The more + # complicated attributes are currently stored as pickles, though that + # will change as the schema and implementation is developed. Column('next_request_id', Integer), Column('next_digest_number', Integer), Column('admin_responses', PickleType), @@ -116,7 +122,6 @@ def make_table(metadata, tables): Column('member_moderation_notice', Unicode), Column('mime_is_default_digest', Boolean), Column('mod_password', Unicode), - Column('moderator', PickleType), Column('msg_footer', Unicode), Column('msg_header', Unicode), Column('new_member_options', Integer), @@ -126,7 +131,6 @@ def make_table(metadata, tables): Column('nondigestable', Boolean), Column('nonmember_rejection_notice', Unicode), Column('obscure_addresses', Boolean), - Column('owner', PickleType), Column('pass_filename_extensions', PickleType), Column('pass_mime_types', PickleType), Column('password', Unicode), @@ -157,14 +161,18 @@ def make_table(metadata, tables): ) # Avoid circular imports from Mailman.MailList import MailList - from Mailman.database.languages import Language + from Mailman.database.tables.languages import Language + from Mailman.database.tables.rosters import RosterSet # We need to ensure MailList.InitTempVars() is called whenever a MailList # instance is created from a row. Use a mapper extension for this. - props = dict(available_languages= - relation(Language, - secondary=tables.available_languages, - lazy=False)) + props = dict( + # listdata* <-> language* + available_languages= relation(Language, + secondary=tables.available_languages, + lazy=False)) mapper(MailList, table, + # The mapper extension ensures MailList.InitTempVars() is called + # whenever a MailList instance is created from a row. extension=MailListMapperExtension(), properties=props) tables.bind(table) diff --git a/Mailman/database/tables/profiles.py b/Mailman/database/tables/profiles.py new file mode 100644 index 000000000..9f65bdf03 --- /dev/null +++ b/Mailman/database/tables/profiles.py @@ -0,0 +1,77 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Mailman user profile information.""" + +from sqlalchemy import * + +from Mailman import Defaults + + + +class Profile(object): + pass + + + +# Both of these Enum types are stored in the database as integers, and +# converted back into their enums on retrieval. + +class DeliveryModeType(types.TypeDecorator): + impl = types.Integer + + def convert_bind_param(self, value, engine): + return int(value) + + def convert_result_value(self, value, engine): + return Defaults.DeliveryMode(value) + + +class DeliveryStatusType(types.TypeDecorator): + impl = types.Integer + + def convert_bind_param(self, value, engine): + return int(value) + + def convert_result_value(self, value, engine): + return Defaults.DeliveryStatus(value) + + + +def make_table(metadata, tables): + table = Table( + 'Profiles', metadata, + Column('profile_id', Integer, primary_key=True), + # OldStyleMemberships attributes, temporarily stored as pickles. + Column('ack', Boolean), + Column('delivery_mode', DeliveryModeType), + Column('delivery_status', DeliveryStatusType), + Column('hide', Boolean), + Column('language', Unicode), + Column('nodupes', Boolean), + Column('nomail', Boolean), + Column('notmetoo', Boolean), + Column('password', Unicode), + Column('realname', Unicode), + Column('topics', PickleType), + ) + # Avoid circular references + from Mailman.database.tables.addresses import Address + # profile -> address* + props = dict(addresses=relation(Address, cascade='all, delete-orphan')) + mapper(Profile, table, properties=props) + tables.bind(table) diff --git a/Mailman/database/tables/rosters.py b/Mailman/database/tables/rosters.py new file mode 100644 index 000000000..eea0cbb39 --- /dev/null +++ b/Mailman/database/tables/rosters.py @@ -0,0 +1,59 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Collections of email addresses. + +Rosters contain email addresses. RosterSets contain Rosters. Most attributes +on the listdata table take RosterSets so that it's easy to compose just about +any combination of addresses. +""" + +from sqlalchemy import * + +from Mailman.database.tables.addresses import Address + + + +class Roster(object): + pass + + +class RosterSet(object): + pass + + + +def make_table(metadata, tables): + table = Table( + 'Rosters', metadata, + Column('roster_id', Integer, primary_key=True), + ) + # roster* <-> address* + props = dict(addresses= + relation(Address, + secondary=tables.address_rosters, + lazy=False)) + mapper(Roster, table, properties=props) + tables.bind(table) + table = Table( + 'RosterSets', metadata, + Column('rosterset_id', Integer, primary_key=True), + ) + # rosterset -> roster* + props = dict(rosters=relation(Roster, cascade='all, delete=orphan')) + mapper(RosterSet, table, properties=props) + tables.bind(table) diff --git a/Mailman/database/version.py b/Mailman/database/tables/versions.py index 57c50b0ef..09fd21bf7 100644 --- a/Mailman/database/version.py +++ b/Mailman/database/tables/versions.py @@ -23,9 +23,9 @@ from sqlalchemy import * def make_table(metadata, tables): table = Table( - 'Version', metadata, + 'Versions', metadata, Column('version_id', Integer, primary_key=True), - Column('component', String(20)), + Column('component', String), Column('version', Integer), ) tables.bind(table) diff --git a/Mailman/database/types.py b/Mailman/database/types.py new file mode 100644 index 000000000..00ad29559 --- /dev/null +++ b/Mailman/database/types.py @@ -0,0 +1,40 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +import sys + +from sqlalchemy import types + + + +# SQLAlchemy custom type for storing enums in the database. +class EnumType(types.TypeDecorator): + # Enums can be stored as strings of the form: + # full.path.to.Enum:intval + impl = types.String + + def convert_bind_param(self, value, engine): + return '%s:%s.%d' % (value.enumclass.__module__, + value.enumclass.__name__, + int(value)) + + def convert_result_value(self, value, engine): + path, intvalue = value.rsplit(':', 1) + modulename, classname = intvalue.rsplit('.', 1) + __import__(modulename) + cls = getattr(sys.modules[modulename], classname) + return cls[int(intvalue)] diff --git a/Mailman/database/usermanager.py b/Mailman/database/usermanager.py new file mode 100644 index 000000000..97a740803 --- /dev/null +++ b/Mailman/database/usermanager.py @@ -0,0 +1,91 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""SQLAlchemy/Elixir based provider of IUserManager.""" + +from __future__ import with_statement + +import os + +from elixir import * +from zope.interface import implements + +from Mailman import Errors +from Mailman.LockFile import LockFile +from Mailman.configuration import config +from Mailman.database.model import * +from Mailman.interfaces import IUserManager + + + +class UserManager(object): + implements(IUserManager) + + def __init__(self): + # Create the null roster if it does not already exist. It's more + # likely to exist than not so try to get it before creating it. + lockfile = os.path.join(config.LOCK_DIR, '<umgrcreatelock>') + with LockFile(lockfile): + roster = self.get_roster('') + if roster is None: + self.create_roster('') + objectstore.flush() + + def create_roster(self, name): + roster = Roster.get_by(name=name) + if roster: + raise Errors.RosterExistsError(name) + return Roster(name=name) + + def get_roster(self, name): + return Roster.get_by(name=name) + + def delete_roster(self, roster): + roster.delete() + + @property + def rosters(self): + for roster in Roster.select(): + yield roster + + def create_rosterset(self, name): + return RosterSet(name=name) + + def delete_rosterset(self, rosterset): + rosterset.delete() + + def get_rosterset(self, name): + return RosterSet.get_by(name=name) + + def create_user(self): + user = User() + # Users always have a profile + user.profile = Profile() + user.profile.user = user + return user + + def delete_user(self, user): + user.delete() + + @property + def users(self): + for user in User.select(): + yield user + + def get_user(self, address): + found = Address.get_by(address=address) + return found and found.user diff --git a/Mailman/docs/Makefile.in b/Mailman/docs/Makefile.in new file mode 100644 index 000000000..0662d8a3e --- /dev/null +++ b/Mailman/docs/Makefile.in @@ -0,0 +1,82 @@ +# Copyright (C) 1998-2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ +DESTDIR= + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/docs +SHELL= /bin/sh + +OTHERFILES= *.txt +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + +# Directories make should decend into +SUBDIRS= + +# Rules + +all: + +install: + for f in $(MODULES) $(OTHERFILES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(DESTDIR)$(PACKAGEDIR); \ + done + for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE) DESTDIR=$(DESTDIR) install); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile + @for d in $(SUBDIRS); \ + do \ + (cd $$d; $(MAKE) distclean); \ + done diff --git a/Mailman/docs/__init__.py b/Mailman/docs/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/Mailman/docs/__init__.py diff --git a/Mailman/docs/addresses.txt b/Mailman/docs/addresses.txt new file mode 100644 index 000000000..a8cf9f655 --- /dev/null +++ b/Mailman/docs/addresses.txt @@ -0,0 +1,144 @@ +Email addresses and rosters +=========================== + +Addresses represent email address, and nothing more. Some addresses are tied +to users that Mailman knows about. For example, a list member is a user that +the system knows about, but a non-member posting from a brand new email +address is a counter-example. + + +Creating a roster +----------------- + +Email address objects are tied to rosters, and rosters are tied to the user +manager. To get things started, access the global user manager and create a +new roster. + + >>> from Mailman.database import flush + >>> from Mailman.configuration import config + >>> mgr = config.user_manager + >>> roster_1 = mgr.create_roster('roster-1') + >>> sorted(roster_1.addresses) + [] + + +Creating addresses +------------------ + +Creating a simple email address object is straight forward. + + >>> addr_1 = roster_1.create('aperson@example.com') + >>> flush() + >>> addr_1.address + 'aperson@example.com' + >>> addr_1.real_name is None + True + +You can also create an email address object with a real name. + + >>> addr_2 = roster_1.create('bperson@example.com', 'Barney Person') + >>> addr_2.address + 'bperson@example.com' + >>> addr_2.real_name + 'Barney Person' + +You can also iterate through all the addresses on a roster. + + >>> sorted(addr.address for addr in roster_1.addresses) + ['aperson@example.com', 'bperson@example.com'] + +You can create another roster and add a bunch of existing addresses to the +second roster. + + >>> roster_2 = mgr.create_roster('roster-2') + >>> flush() + >>> sorted(roster_2.addresses) + [] + >>> for address in roster_1.addresses: + ... roster_2.addresses.append(address) + >>> roster_2.create('cperson@example.com', 'Charlie Person') + <Address: Charlie Person <cperson@example.com> [not verified]> + >>> sorted(addr.address for addr in roster_2.addresses) + ['aperson@example.com', 'bperson@example.com', 'cperson@example.com'] + +The first roster hasn't been affected. + + >>> sorted(addr.address for addr in roster_1.addresses) + ['aperson@example.com', 'bperson@example.com'] + + +Removing addresses +------------------ + +You can remove an address from a roster just by deleting it. + + >>> for addr in roster_1.addresses: + ... if addr.address == 'aperson@example.com': + ... break + >>> addr.address + 'aperson@example.com' + >>> roster_1.addresses.remove(addr) + >>> sorted(addr.address for addr in roster_1.addresses) + ['bperson@example.com'] + +Again, this doesn't affect the other rosters. + + >>> sorted(addr.address for addr in roster_2.addresses) + ['aperson@example.com', 'bperson@example.com', 'cperson@example.com'] + + +Registration and validation +--------------------------- + +Addresses have two dates, the date the address was registered on and the date +the address was validated on. Neither date isset by default. + + >>> addr = roster_1.create('dperson@example.com', 'David Person') + >>> addr.registered_on is None + True + >>> addr.validated_on is None + True + +The registered date takes a Python datetime object. + + >>> from datetime import datetime + >>> addr.registered_on = datetime(2007, 5, 8, 22, 54, 1) + >>> print addr.registered_on + 2007-05-08 22:54:01 + >>> addr.validated_on is None + True + +And of course, you can also set the validation date. + + >>> addr.validated_on = datetime(2007, 5, 13, 22, 54, 1) + >>> print addr.registered_on + 2007-05-08 22:54:01 + >>> print addr.validated_on + 2007-05-13 22:54:01 + + +The null roster +--------------- + +All address objects that have been created are members of the null roster. + + >>> all = mgr.get_roster('') + >>> sorted(addr.address for addr in all.addresses) + ['aperson@example.com', 'bperson@example.com', + 'cperson@example.com', 'dperson@example.com'] + +And conversely, all addresses should have the null roster on their list of +rosters. + + >>> for addr in all.addresses: + ... assert all in addr.rosters, 'Address is missing null roster' + + +Clean up +-------- + + >>> for roster in mgr.rosters: + ... mgr.delete_roster(roster) + >>> flush() + >>> sorted(roster.name for roster in mgr.rosters) + [] diff --git a/Mailman/docs/mlist-addresses.txt b/Mailman/docs/mlist-addresses.txt new file mode 100644 index 000000000..257cf95c7 --- /dev/null +++ b/Mailman/docs/mlist-addresses.txt @@ -0,0 +1,85 @@ +Mailing list addresses +====================== + +Every mailing list has a number of addresses which are publicly available. +These are defined in the IMailingListAddresses interface. + + >>> from Mailman.configuration import config + >>> from Mailman.interfaces import IMailingListAddresses + >>> mlist = config.list_manager.create('_xtest@example.com') + >>> IMailingListAddresses.providedBy(mlist) + True + +The posting address is where people send messages to be posted to the mailing +list. This is exactly the same as the fully qualified list name. + + >>> mlist.fqdn_listname + '_xtest@example.com' + >>> mlist.posting_address + '_xtest@example.com' + +Messages to the mailing list's 'no reply' address always get discarded without +prejudice. + + >>> mlist.noreply_address + 'noreply@example.com' + +The mailing list's owner address reaches the human moderators. + + >>> mlist.owner_address + '_xtest-owner@example.com' + +The request address goes to the list's email command robot. + + >>> mlist.request_address + '_xtest-request@example.com' + +The bounces address accepts and processes all potential bounces. + + >>> mlist.bounces_address + '_xtest-bounces@example.com' + +The join (a.k.a. subscribe) address is where someone can email to get added to +the mailing list. The subscribe alias is a synonym for join, but it's +deprecated. + + >>> mlist.join_address + '_xtest-join@example.com' + >>> mlist.subscribe_address + '_xtest-subscribe@example.com' + +The leave (a.k.a. unsubscribe) address is where someone can email to get added +to the mailing list. The unsubscribe alias is a synonym for leave, but it's +deprecated. + + >>> mlist.leave_address + '_xtest-leave@example.com' + >>> mlist.unsubscribe_address + '_xtest-unsubscribe@example.com' + + +Email confirmations +------------------- + +Email confirmation messages are sent when actions such as subscriptions need +to be confirmed. It requires that a cookie be provided, which will be +included in the local part of the email address. The exact format of this is +dependent on the VERP_CONFIRM_FORMAT configuration variable. + + >>> mlist.confirm_address('cookie') + '_xtest-confirm+cookie@example.com' + >>> mlist.confirm_address('wookie') + '_xtest-confirm+wookie@example.com' + + >>> old_format = config.VERP_CONFIRM_FORMAT + >>> config.VERP_CONFIRM_FORMAT = '$address---$cookie' + >>> mlist.confirm_address('cookie') + '_xtest-confirm---cookie@example.com' + >>> config.VERP_CONFIRM_FORMAT = old_format + + +Clean up +-------- + + >>> for mlist in config.list_manager.mailing_lists: + ... config.list_manager.delete(mlist) diff --git a/Mailman/docs/mlist-rosters.txt b/Mailman/docs/mlist-rosters.txt new file mode 100644 index 000000000..490a07e0c --- /dev/null +++ b/Mailman/docs/mlist-rosters.txt @@ -0,0 +1,118 @@ +Mailing list rosters +==================== + +Mailing lists use rosters to manage and organize users for various purposes. +In order to allow for separate storage of mailing list data and user data, the +connection between mailing list objects and rosters is indirect. Mailing +lists manage roster names, and these roster names are used to find the rosters +that contain the actual users. + + +Privileged rosters +------------------ + +Mailing lists have two types of privileged users, owners and moderators. +Owners get to change the configuration of mailing lists and moderators get to +approve or deny held messages and subscription requests. + +When a mailing list is created, it automatically contains a roster for the +list owners and a roster for the list moderators. + + >>> from Mailman.database import flush + >>> from Mailman.configuration import config + >>> mlist = config.list_manager.create('_xtest@example.com') + >>> flush() + >>> sorted(roster.name for roster in mlist.owner_rosters) + ['_xtest@example.com owners'] + >>> sorted(roster.name for roster in mlist.moderator_rosters) + ['_xtest@example.com moderators'] + +These rosters are initially empty. + + >>> owner_roster = list(mlist.owner_rosters)[0] + >>> sorted(address for address in owner_roster.addresses) + [] + >>> moderator_roster = list(mlist.moderator_rosters)[0] + >>> sorted(address for address in moderator_roster.addresses) + [] + +You can create new rosters and add them to the list of owner or moderator +rosters. + + >>> roster_1 = config.user_manager.create_roster('roster-1') + >>> roster_2 = config.user_manager.create_roster('roster-2') + >>> roster_3 = config.user_manager.create_roster('roster-3') + >>> flush() + +Make roster-1 an owner roster, roster-2 a moderator roster, and roster-3 both +an owner and a moderator roster. + + >>> mlist.add_owner_roster(roster_1) + >>> mlist.add_moderator_roster(roster_2) + >>> mlist.add_owner_roster(roster_3) + >>> mlist.add_moderator_roster(roster_3) + >>> flush() + + >>> sorted(roster.name for roster in mlist.owner_rosters) + ['_xtest@example.com owners', 'roster-1', 'roster-3'] + >>> sorted(roster.name for roster in mlist.moderator_rosters) + ['_xtest@example.com moderators', 'roster-2', 'roster-3'] + + +Privileged users +---------------- + +Rosters are the lower level way of managing owners and moderators, but usually +you just want to know which users have owner and moderator privileges. You +can get the list of such users by using different attributes. + +Because the rosters are all empty to start with, we can create a bunch of +users that will end up being our owners and moderators. + + >>> aperson = config.user_manager.create_user() + >>> bperson = config.user_manager.create_user() + >>> cperson = config.user_manager.create_user() + +These users need addresses, because rosters manage addresses. + + >>> address_1 = roster_1.create('aperson@example.com', 'Anne Person') + >>> aperson.link(address_1) + >>> address_2 = roster_2.create('bperson@example.com', 'Ben Person') + >>> bperson.link(address_2) + >>> address_3 = roster_1.create('cperson@example.com', 'Claire Person') + >>> cperson.link(address_3) + >>> roster_3.addresses.append(address_3) + >>> flush() + +Now that everything is set up, we can iterate through the various collections +of privileged users. Here are the owners of the list. + + >>> from Mailman.interfaces import IUser + >>> addresses = [] + >>> for user in mlist.owners: + ... assert IUser.providedBy(user), 'Non-IUser owner found' + ... for address in user.addresses: + ... addresses.append(address.address) + >>> sorted(addresses) + ['aperson@example.com', 'cperson@example.com'] + +Here are the moderators of the list. + + >>> addresses = [] + >>> for user in mlist.moderators: + ... assert IUser.providedBy(user), 'Non-IUser moderator found' + ... for address in user.addresses: + ... addresses.append(address.address) + >>> sorted(addresses) + ['bperson@example.com', 'cperson@example.com'] + +The administrators of a mailing list are the union of the owners and +moderators. + + >>> addresses = [] + >>> for user in mlist.administrators: + ... assert IUser.providedBy(user), 'Non-IUser administrator found' + ... for address in user.addresses: + ... addresses.append(address.address) + >>> sorted(addresses) + ['aperson@example.com', 'bperson@example.com', 'cperson@example.com'] diff --git a/Mailman/docs/use-listmanager.txt b/Mailman/docs/use-listmanager.txt new file mode 100644 index 000000000..9e237f02f --- /dev/null +++ b/Mailman/docs/use-listmanager.txt @@ -0,0 +1,124 @@ +Using the IListManager interface +================================ + +The IListManager is how you create, delete, and retrieve mailing list +objects. The Mailman system instantiates an IListManager for you based on the +configuration variable MANAGERS_INIT_FUNCTION. The instance is accessible +on the global config object. + + >>> from Mailman.database import flush + >>> from Mailman.configuration import config + >>> from Mailman.interfaces import IListManager + >>> IListManager.providedBy(config.list_manager) + True + >>> mgr = config.list_manager + + +Creating a mailing list +----------------------- + +Creating the list returns the newly created IMailList object. + + >>> from Mailman.interfaces import IMailingList + >>> mlist = mgr.create('_xtest@example.com') + >>> flush() + >>> IMailingList.providedBy(mlist) + True + +This object has an identity. + + >>> from Mailman.interfaces import IMailingListIdentity + >>> IMailingListIdentity.providedBy(mlist) + True + +All lists with identities have a short name, a host name, and a fully +qualified listname. This latter is what uniquely distinguishes the mailing +list to the system. + + >>> mlist.list_name + '_xtest' + >>> mlist.host_name + 'example.com' + >>> mlist.fqdn_listname + '_xtest@example.com' + +If you try to create a mailing list with the same name as an existing list, +you will get an exception. + + >>> mlist_dup = mgr.create('_xtest@example.com') + Traceback (most recent call last): + ... + MMListAlreadyExistsError: _xtest@example.com + + +Deleting a mailing list +----------------------- + +Deleting an existing mailing list also deletes its rosters and roster sets. + + >>> sorted(r.name for r in config.user_manager.rosters) + ['', '_xtest@example.com moderators', '_xtest@example.com owners'] + + >>> mgr.delete(mlist) + >>> flush() + >>> sorted(mgr.names) + [] + >>> sorted(r.name for r in config.user_manager.rosters) + [''] + +Attempting to access attributes of the deleted mailing list raises an +exception: + + >>> mlist.fqdn_listname + Traceback (most recent call last): + ... + AttributeError: fqdn_listname + +After deleting the list, you can create it again. + + >>> mlist = mgr.create('_xtest@example.com') + >>> flush() + >>> mlist.fqdn_listname + '_xtest@example.com' + + +Retrieving a mailing list +------------------------- + +When a mailing list exists, you can ask the list manager for it and you will +always get the same object back. + + >>> mlist_2 = mgr.get('_xtest@example.com') + >>> mlist_2 is mlist + True + +Don't try to get a list that doesn't exist yet though, or you will get an +exception. + + >>> mgr.get('_xtest_2@example.com') + Traceback (most recent call last): + ... + MMUnknownListError: _xtest_2@example.com + + +Iterating over all mailing lists +-------------------------------- + +Once you've created a bunch of mailing lists, you can use the list manager to +iterate over either the list objects, or the list names. + + >>> mlist_3 = mgr.create('_xtest_3@example.com') + >>> mlist_4 = mgr.create('_xtest_4@example.com') + >>> flush() + >>> sorted(mgr.names) + ['_xtest@example.com', '_xtest_3@example.com', '_xtest_4@example.com'] + >>> sorted(m.fqdn_listname for m in mgr.mailing_lists) + ['_xtest@example.com', '_xtest_3@example.com', '_xtest_4@example.com'] + + +Cleaning up +----------- + + >>> for mlist in mgr.mailing_lists: + ... mgr.delete(mlist) + >>> flush() diff --git a/Mailman/docs/use-usermanager.txt b/Mailman/docs/use-usermanager.txt new file mode 100644 index 000000000..f79bff8c6 --- /dev/null +++ b/Mailman/docs/use-usermanager.txt @@ -0,0 +1,100 @@ +The user manager and rosters +============================ + +The IUserManager is how you create, delete, and roster objects. Rosters +manage collections of users. The Mailman system instantiates an IUserManager +for you based on the configuration variable MANAGERS_INIT_FUNCTION. The +instance is accessible on the global config object. + + >>> from Mailman.configuration import config + >>> from Mailman.interfaces import IUserManager + >>> mgr = config.user_manager + >>> IUserManager.providedBy(mgr) + True + + +The default roster +------------------ + +The user manager always contains at least one roster, the 'null' roster or +'all inclusive roster'. + + >>> sorted(roster.name for roster in mgr.rosters) + [''] + + +Adding rosters +-------------- + +You create a roster to hold users. The only thing a roster needs is a name, +basically just an identifying string. + + >>> from Mailman.database import flush + >>> from Mailman.interfaces import IRoster + >>> roster = mgr.create_roster('roster-1') + >>> IRoster.providedBy(roster) + True + >>> roster.name + 'roster-1' + >>> flush() + +If you try to create a roster with the same name as an existing roster, you +will get an exception. + + >>> roster_dup = mgr.create_roster('roster-1') + Traceback (most recent call last): + ... + RosterExistsError: roster-1 + + +Deleting a roster +----------------- + +Delete the roster, and you can then create it again. + + >>> mgr.delete_roster(roster) + >>> flush() + >>> roster = mgr.create_roster('roster-1') + >>> flush() + >>> roster.name + 'roster-1' + + +Retrieving a roster +------------------- + +When a roster exists, you can ask the user manager for it and you will always +get the same object back. + + >>> roster_2 = mgr.get_roster('roster-1') + >>> roster_2.name + 'roster-1' + >>> roster is roster_2 + True + +Trying to get a roster that does not yet exist returns None. + + >>> print mgr.get_roster('no roster') + None + + +Iterating over all the rosters +------------------------------ + +Once you've created a bunch of rosters, you can use the user manager to +iterate over all the rosters. + + >>> roster_2 = mgr.create_roster('roster-2') + >>> roster_3 = mgr.create_roster('roster-3') + >>> roster_4 = mgr.create_roster('roster-4') + >>> flush() + >>> sorted(roster.name for roster in mgr.rosters) + ['', 'roster-1', 'roster-2', 'roster-3', 'roster-4'] + + +Cleaning up +----------- + + >>> for roster in mgr.rosters: + ... mgr.delete_roster(roster) + >>> flush() diff --git a/Mailman/docs/users.txt b/Mailman/docs/users.txt new file mode 100644 index 000000000..caad6b216 --- /dev/null +++ b/Mailman/docs/users.txt @@ -0,0 +1,180 @@ +Users +===== + +Users are entities that combine addresses, preferences, and a password +scheme. Password schemes can be anything from a traditional +challenge/response type password string to an OpenID url. + + +Create, deleting, and managing users +------------------------------------ + +Users are managed by the IUserManager. Users don't have any unique +identifying information, and no such id is needed to create them. + + >>> from Mailman.database import flush + >>> from Mailman.configuration import config + >>> mgr = config.user_manager + >>> user = mgr.create_user() + +Users have a real name, a password scheme, a default profile, and a set of +addresses that they control. All of these data are None or empty for a newly +created user. + + >>> user.real_name is None + True + >>> user.password is None + True + >>> user.addresses + [] + +You can iterate over all the users in a user manager. + + >>> another_user = mgr.create_user() + >>> flush() + >>> all_users = list(mgr.users) + >>> len(list(all_users)) + 2 + >>> user is not another_user + True + >>> user in all_users + True + >>> another_user in all_users + True + +You can also delete users from the user manager. + + >>> mgr.delete_user(user) + >>> mgr.delete_user(another_user) + >>> flush() + >>> len(list(mgr.users)) + 0 + + +Simple user information +----------------------- + +Users may have a real name and a password scheme. + + >>> user = mgr.create_user() + >>> user.password = 'my password' + >>> user.real_name = 'Zoe Person' + >>> flush() + >>> only_person = list(mgr.users)[0] + >>> only_person.password + 'my password' + >>> only_person.real_name + 'Zoe Person' + +The password and real name can be changed at any time. + + >>> user.real_name = 'Zoe X. Person' + >>> user.password = 'another password' + >>> only_person.real_name + 'Zoe X. Person' + >>> only_person.password + 'another password' + + +Users and addresses +------------------- + +One of the pieces of information that a user links to is a set of email +addresses, in the form of IAddress objects. A user can control many +addresses, but addresses may be control by only one user. + +Given a user and an address, you can link the two together. + + >>> roster = mgr.get_roster('') + >>> address = roster.create('aperson@example.com', 'Anne Person') + >>> user.link(address) + >>> flush() + >>> sorted(address.address for address in user.addresses) + ['aperson@example.com'] + +But don't try to link an address to more than one user. + + >>> another_user = mgr.create_user() + >>> another_user.link(address) + Traceback (most recent call last): + ... + AddressAlreadyLinkedError: Anne Person <aperson@example.com> + +You can also ask whether a given user controls a given address. + + >>> user.controls(address) + True + >>> not_my_address = roster.create('bperson@example.com', 'Ben Person') + >>> user.controls(not_my_address) + False + +Given a text email address, the user manager can find the user that controls +that address. + + >>> mgr.get_user('aperson@example.com') is user + True + >>> mgr.get_user('bperson@example.com') is None + True + +Addresses can also be unlinked from a user. + + >>> user.unlink(address) + >>> user.controls(address) + False + >>> mgr.get_user('aperson@example.com') is None + True + +But don't try to unlink the address from a user it's not linked to. + + >>> user.unlink(address) + Traceback (most recent call last): + ... + AddressNotLinkedError: Anne Person <aperson@example.com> + >>> another_user.unlink(address) + Traceback (most recent call last): + ... + AddressNotLinkedError: Anne Person <aperson@example.com> + >>> mgr.delete_user(another_user) + + +Users and profiles +------------------ + +Users always have a default profile. + + >>> from Mailman.interfaces import IProfile + >>> IProfile.providedBy(user.profile) + True + +A profile is a set of preferences such as whether the user wants to receive an +acknowledgment of all of their posts to a mailing list... + + >>> user.profile.acknowledge_posts + False + +...whether the user wants to hide their email addresses on web pages and in +postings to the list... + + >>> user.profile.hide_address + True + +...the language code for the user's preferred language... + + >>> user.profile.preferred_language + 'en' + +...whether the user wants to receive the list's copy of a message if they are +explicitly named in one of the recipient headers... + + >>> user.profile.receive_list_copy + True + +...whether the user wants to receive a copy of their own postings... + + >>> user.profile.receive_own_postings + True + +...and the preferred delivery method. + + >>> print user.profile.delivery_mode + DeliveryMode.regular diff --git a/Mailman/enum.py b/Mailman/enum.py deleted file mode 100644 index 893e988ba..000000000 --- a/Mailman/enum.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright (C) 2004-2007 by the Free Software Foundation, Inc. -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, -# USA. - -"""Enumeration meta class. - -To define your own enumeration, do something like: - ->>> class Colors(Enum): -... red = 1 -... green = 2 -... blue = 3 - -Enum subclasses cannot be instantiated, but you can convert them to integers -and from integers. Returned enumeration attributes are singletons and can be -compared by identity only. -""" - -COMMASPACE = ', ' - -# Based on example by Jeremy Hylton -# Modified and extended by Barry Warsaw - - - -class EnumMetaclass(type): - def __init__(cls, name, bases, dict): - # cls == the class being defined - # name == the name of the class - # bases == the class's bases - # dict == the class attributes - super(EnumMetaclass, cls).__init__(name, bases, dict) - # Store EnumValues here for easy access. - cls._enums = {} - # Figure out the set of enum values on the base classes, to ensure - # that we don't get any duplicate values (which would screw up - # conversion from integer). - for basecls in cls.__mro__: - if hasattr(basecls, '_enums'): - cls._enums.update(basecls._enums) - # For each class attribute, create an EnumValue and store that back on - # the class instead of the int. Skip Python reserved names. Also add - # a mapping from the integer to the instance so we can return the same - # object on conversion. - for attr in dict: - if not (attr.startswith('__') and attr.endswith('__')): - intval = dict[attr] - enumval = EnumValue(name, intval, attr) - if intval in cls._enums: - raise TypeError('Multiple enum values: %s' % enumval) - # Store as an attribute on the class, and save the attr name - setattr(cls, attr, enumval) - cls._enums[intval] = attr - - def __getattr__(cls, name): - if name == '__members__': - return cls._enums.values() - raise AttributeError(name) - - def __repr__(cls): - enums = ['%s: %d' % (cls._enums[k], k) for k in sorted(cls._enums)] - return '<%s {%s}>' % (cls.__name__, COMMASPACE.join(enums)) - - def __iter__(cls): - for i in sorted(cls._enums): - yield getattr(cls, cls._enums[i]) - - def __getitem__(cls, i): - # i can be an integer or a string - attr = cls._enums.get(i) - if attr is None: - # It wasn't an integer -- try attribute name - try: - return getattr(cls, i) - except (AttributeError, TypeError): - raise ValueError(i) - return getattr(cls, attr) - - # Support both MyEnum[i] and MyEnum(i) - __call__ = __getitem__ - - - -class EnumValue(object): - """Class to represent an enumeration value. - - EnumValue('Color', 'red', 12) prints as 'Color.red' and can be converted - to the integer 12. - """ - def __init__(self, classname, value, enumname): - self._classname = classname - self._value = value - self._enumname = enumname - - def __repr__(self): - return 'EnumValue(%s, %s, %d)' % ( - self._classname, self._enumname, self._value) - - def __str__(self): - return self._enumname - - def __int__(self): - return self._value - - # Support only comparison by identity. Yes, really raise - # NotImplementedError instead of returning NotImplemented. - def __eq__(self, other): - raise NotImplementedError - - __ne__ = __eq__ - __lt__ = __eq__ - __gt__ = __eq__ - __le__ = __eq__ - __ge__ = __eq__ - - - -class Enum: - __metaclass__ = EnumMetaclass diff --git a/Mailman/ext/Makefile.in b/Mailman/ext/Makefile.in new file mode 100644 index 000000000..be14be4f4 --- /dev/null +++ b/Mailman/ext/Makefile.in @@ -0,0 +1,71 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ +DESTDIR= + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/ext +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(DESTDIR)$(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/ext/__init__.py b/Mailman/ext/__init__.py new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/Mailman/ext/__init__.py diff --git a/Mailman/initialize.py b/Mailman/initialize.py index a47fbd045..2e7e65b70 100644 --- a/Mailman/initialize.py +++ b/Mailman/initialize.py @@ -25,11 +25,15 @@ by the command line arguments. """ import os +import sys import Mailman.configuration import Mailman.database +import Mailman.ext import Mailman.loginit +DOT = '.' + # These initialization calls are separated for the testing framework, which @@ -47,6 +51,15 @@ def initialize_1(config, propagate_logs): os.umask(007) Mailman.configuration.config.load(config) Mailman.loginit.initialize(propagate_logs) + # Set up site extensions directory + Mailman.ext.__path__.append(Mailman.configuration.config.EXT_DIR) + # Initialize the IListManager, IMemberManager, and IMessageManager + modparts = Mailman.configuration.config.MANAGERS_INIT_FUNCTION.split(DOT) + funcname = modparts.pop() + modname = DOT.join(modparts) + __import__(modname) + initfunc = getattr(sys.modules[modname], funcname) + initfunc() def initialize_2(): diff --git a/Mailman/interfaces/Makefile.in b/Mailman/interfaces/Makefile.in new file mode 100644 index 000000000..d16bf9b70 --- /dev/null +++ b/Mailman/interfaces/Makefile.in @@ -0,0 +1,71 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +# NOTE: Makefile.in is converted into Makefile by the configure script +# in the parent directory. Once configure has run, you can recreate +# the Makefile by running just config.status. + +# Variables set by configure + +VPATH= @srcdir@ +srcdir= @srcdir@ +bindir= @bindir@ +prefix= @prefix@ +exec_prefix= @exec_prefix@ +DESTDIR= + +CC= @CC@ +CHMOD= @CHMOD@ +INSTALL= @INSTALL@ + +DEFS= @DEFS@ + +# Customizable but not set by configure + +OPT= @OPT@ +CFLAGS= $(OPT) $(DEFS) +PACKAGEDIR= $(prefix)/Mailman/interfaces +SHELL= /bin/sh + +MODULES= *.py + +# Modes for directories and executables created by the install +# process. Default to group-writable directories but +# user-only-writable for executables. +DIRMODE= 775 +EXEMODE= 755 +FILEMODE= 644 +INSTALL_PROGRAM=$(INSTALL) -m $(EXEMODE) + + +# Rules + +all: + +install: + for f in $(MODULES); \ + do \ + $(INSTALL) -m $(FILEMODE) $(srcdir)/$$f $(DESTDIR)$(PACKAGEDIR); \ + done + +finish: + +clean: + +distclean: + -rm *.pyc + -rm Makefile diff --git a/Mailman/interfaces/__init__.py b/Mailman/interfaces/__init__.py new file mode 100644 index 000000000..e04ad1e78 --- /dev/null +++ b/Mailman/interfaces/__init__.py @@ -0,0 +1,46 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +import os +import sys + +from zope.interface import implementedBy +from zope.interface.interfaces import IInterface + +__all__ = [] + + + +def _populate(): + import Mailman.interfaces + iface_mod = sys.modules['Mailman.interfaces'] + # Expose interfaces defined in sub-modules into the top-level package + for filename in os.listdir(os.path.dirname(iface_mod.__file__)): + base, ext = os.path.splitext(filename) + if ext <> '.py': + continue + modname = 'Mailman.interfaces.' + base + __import__(modname) + module = sys.modules[modname] + for name in dir(module): + obj = getattr(module, name) + if IInterface.providedBy(obj): + setattr(iface_mod, name, obj) + __all__.append(name) + + +_populate() diff --git a/Mailman/interfaces/address.py b/Mailman/interfaces/address.py new file mode 100644 index 000000000..5f6a9193d --- /dev/null +++ b/Mailman/interfaces/address.py @@ -0,0 +1,44 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for email address related information.""" + +from zope.interface import Interface, Attribute + + + +class IAddress(Interface): + """Email address related information.""" + + address = Attribute( + """Read-only text email address.""") + + real_name = Attribute( + """Optional real name associated with the email address.""") + + registered_on = Attribute( + """The date and time at which this email address was registered. + + Registeration is really the date at which this address became known to + us. It may have been explicitly registered by a user, or it may have + been implicitly registered, e.g. by showing up in a non-member + posting.""") + + validated_on = Attribute( + """The date and time at which this email address was validated, or + None if the email address has not yet been validated. The specific + method of validation is not defined here.""") diff --git a/Mailman/interfaces/listmanager.py b/Mailman/interfaces/listmanager.py new file mode 100644 index 000000000..113ca35af --- /dev/null +++ b/Mailman/interfaces/listmanager.py @@ -0,0 +1,61 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for list storage, deleting, and finding.""" + +from zope.interface import Interface, Attribute + + + +class IListManager(Interface): + """The interface of the global list manager. + + The list manager manages IMailingList objects. You can add and remove + IMailingList objects from the list manager, and you can retrieve them + from the manager via their fully qualified list name + (e.g. 'mylist@example.com'). + """ + + def create(fqdn_listname): + """Create an IMailingList with the given fully qualified list name. + + Raises MMListAlreadyExistsError if the named list already exists. + """ + + def get(fqdn_listname): + """Return the IMailingList with the given fully qualified list name. + + Raises MMUnknownListError if the names list does not exist. + """ + + def delete(mlist): + """Remove the IMailingList from the backend storage.""" + + def get(fqdn_listname): + """Find the IMailingList with the matching fully qualified list name. + + Returns the matching IMailList instance or None if there was no + matching mailing list. fqdn_listname + """ + + mailing_lists = Attribute( + """An iterator over all the IMailingList objects managed by this list + manager.""") + + names = Attribute( + """An iterator over the fully qualified list names of all mailing + lists managed by this list manager.""") diff --git a/Mailman/interfaces/mailinglist.py b/Mailman/interfaces/mailinglist.py new file mode 100644 index 000000000..c162801ab --- /dev/null +++ b/Mailman/interfaces/mailinglist.py @@ -0,0 +1,28 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Marker interface for a mailing list.""" + +from zope.interface import Interface, Attribute + + + +class IMailingList(Interface): + """Marker interface for a mailing list. + + This is usually composed with several other interfaces. + """ diff --git a/Mailman/interfaces/manager.py b/Mailman/interfaces/manager.py new file mode 100644 index 000000000..fe22ec74d --- /dev/null +++ b/Mailman/interfaces/manager.py @@ -0,0 +1,57 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Generic database object manager interface.""" + +from zope.interface import Interface, Attribute + + + +class IManaged(Interface): + """An object managed by an IManager.""" + + name = Attribute("""The name of the managed object.""") + + + +class IManager(Interface): + """Create and manage profiles.""" + + def create(name): + """Create and return a new IManaged object. + + name is the unique name for this object. Raises + ExistingManagedObjectError if an IManaged object with the given name + already exists. + """ + + def get(name): + """Return the named IManaged object. + + Raises NoSuchManagedObjectError if the named IManaged object does not + exist. + """ + + def delete(name): + """Delete the named IManaged object. + + Raises NoSuchManagedObjectError if the named IManaged object does not + exist. + """ + + iterator = Attribute( + """Return an iterator over the all the IManaged objects.""") diff --git a/Mailman/interfaces/mlistdigests.py b/Mailman/interfaces/mlistdigests.py new file mode 100644 index 000000000..bd9467b14 --- /dev/null +++ b/Mailman/interfaces/mlistdigests.py @@ -0,0 +1,66 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for digest related information.""" + +from zope.interface import Interface, Attribute + + + +class IMailingListDigests(Interface): + """Digest related information for the mailing list.""" + + volume_number = Attribute( + """A monotonically increasing integer sequentially assigned to each + new digest volume. The volume number may be bumped either + automatically (i.e. on a defined schedule) or manually. When the + volume number is bumped, the digest number is always reset to 1.""") + + digest_number = Attribute( + """A sequence number for a specific digest in a given volume. When + the digest volume number is bumped, the digest number is reset to + 1.""") + + def bump(): + """Bump the digest's volume number to the next integer in the + sequence, and reset the digest number to 1. + """ + + message_count = Attribute( + """The number of messages in the digest currently being collected.""") + + digest_size = Attribute( + """The approximate size in kilobytes of the digest currently being + collected.""") + + messages = Attribute( + """An iterator over all the messages in the digest currently being + created. Returns individual IPostedMessage objects. + """) + + limits = Attribute( + """An iterator over the IDigestLimiters associated with this digest. + Each limiter can make a determination of whether the digest has + reached the threshold for being automatically sent.""") + + def send(): + """Send this digest now.""" + + decorators = Attribute( + """An iterator over all the IDecorators associated with this digest. + When a digest is being sent, each decorator may modify the final + digest text.""") diff --git a/Mailman/interfaces/mlistemail.py b/Mailman/interfaces/mlistemail.py new file mode 100644 index 000000000..958ea324d --- /dev/null +++ b/Mailman/interfaces/mlistemail.py @@ -0,0 +1,78 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for the email addresses associated with a mailing list.""" + +from zope.interface import Interface, Attribute + + + +class IMailingListAddresses(Interface): + """The email addresses associated with a mailing list. + + All attributes are read-only. + """ + + posting_address = Attribute( + """The address to which messages are posted for copying to the full + list membership, where 'full' of course means those members for which + delivery is currently enabled.""") + + noreply_address = Attribute( + """The address to which all messages will be immediately discarded, + without prejudice or record. This address is specific to the ddomain, + even though it's available on the IMailingListAddresses interface. + Generally, humans should never respond directly to this address.""") + + owner_address = Attribute( + """The address which reaches the owners and moderators of the mailing + list. There is no address which reaches just the owners or just the + moderators of a mailing list.""") + + request_address = Attribute( + """The address which reaches the email robot for this mailing list. + This robot can process various email commands such as changing + delivery options, getting information or help about the mailing list, + or processing subscrptions and unsubscriptions (although for the + latter two, it's better to use the join_address and leave_address.""") + + bounces_address = Attribute( + """The address which reaches the automated bounce processor for this + mailing list. Generally, humans should never respond directly to this + address.""") + + join_address = Attribute( + """The address to which subscription requests should be sent. See + subscribe_address for a backward compatible alias.""") + + leave_address = Attribute( + """The address to which unsubscription requests should be sent. See + unsubscribe_address for a backward compatible alias.""") + + subscribe_address = Attribute( + """Deprecated address to which subscription requests may be sent. + This address is provided for backward compatibility only. See + join_address for the preferred alias.""") + + leave_address = Attribute( + """Deprecated address to which unsubscription requests may be sent. + This address is provided for backward compatibility only. See + leave_address for the preferred alias.""") + + def confirm_address(cookie=''): + """The address used for various forms of email confirmation.""" + diff --git a/Mailman/interfaces/mlistid.py b/Mailman/interfaces/mlistid.py new file mode 100644 index 000000000..ecd4b39cb --- /dev/null +++ b/Mailman/interfaces/mlistid.py @@ -0,0 +1,46 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for a mailing list identity.""" + +from zope.interface import Interface, Attribute + + + +class IMailingListIdentity(Interface): + """The basic identifying information of a mailing list.""" + + list_name = Attribute( + """The read-only short name of the mailing list. Note that where a + Mailman installation supports multiple domains, this short name may + not be unique. Use the fqdn_listname attribute for a guaranteed + unique id for the mailing list. This short name is always the local + part of the posting email address. For example, if messages are + posted to mylist@example.com, then the list_name is 'mylist'.""") + + host_name = Attribute( + """The read-only domain name 'hosting' this mailing list. This is + always the domain name part of the posting email address, and it may + bear no relationship to the web url used to access this mailing list. + For example, if messages are posted to mylist@example.com, then the + host_name is 'example.com'.""") + + fqdn_listname = Attribute( + """The read-only fully qualified name of the mailing list. This is + the guaranteed unique id for the mailing list, and it is always the + address to which messages are posted, e.g. mylist@example.com. It is + always comprised of the list_name + '@' + host_name.""") diff --git a/Mailman/interfaces/mlistrequest.py b/Mailman/interfaces/mlistrequest.py new file mode 100644 index 000000000..b9e1d4702 --- /dev/null +++ b/Mailman/interfaces/mlistrequest.py @@ -0,0 +1,29 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for a web request accessing a mailing list.""" + +from zope.interface import Interface, Attribute + + + +class IMailingListRequest(Interface): + """The web request accessing a mailing list.""" + + location = Attribute( + """The url location of the request, used to calculate relative urls by + other components.""") diff --git a/Mailman/interfaces/mlistrosters.py b/Mailman/interfaces/mlistrosters.py new file mode 100644 index 000000000..1b407f472 --- /dev/null +++ b/Mailman/interfaces/mlistrosters.py @@ -0,0 +1,96 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for mailing list rosters and roster sets.""" + +from zope.interface import Interface, Attribute + + + +class IMailingListRosters(Interface): + """Mailing list rosters, roster sets, and members. + + This are all the email addresses that might possibly get messages from or + relating to this mailing list. + """ + + owners = Attribute( + """The IUser owners of this mailing list. + + This does not include the IUsers who are moderators but not owners of + the mailing list.""") + + moderators = Attribute( + """The IUser moderators of this mailing list. + + This does not include the IUsers who are owners but not moderators of + the mailing list.""") + + administrators = Attribute( + """The IUser administrators of this mailing list. + + This includes the IUsers who are both owners and moderators of the + mailing list.""") + + owner_rosters = Attribute( + """An iterator over the IRosters containing all the owners of this + mailing list.""") + + moderator_rosters = Attribute( + """An iterator over the IRosters containing all the moderators of this + mailing list.""") + + def add_owner_roster(roster): + """Add an IRoster to this mailing list's set of owner rosters.""" + + def delete_owner_roster(roster): + """Remove an IRoster from this mailing list's set of owner rosters.""" + + def add_moderator_roster(roster): + """Add an IRoster to this mailing list's set of moderator rosters.""" + + def delete_moderator_roster(roster): + """Remove an IRoster from this mailing list's set of moderator + rosters.""" + + members = Attribute( + """An iterator over all the members of the mailing list, regardless of + whether they are to receive regular messages or digests, or whether + they have their delivery disabled or not.""") + + regular_members = Attribute( + """An iterator over all the IMembers who are to receive regular + postings (i.e. non-digests) from the mailing list, regardless of + whether they have their delivery disabled or not.""") + + digest_members = Attribute( + """An iterator over all the IMembers who are to receive digests of + postings to this mailing list, regardless of whether they have their + deliver disabled or not, or of the type of digest they are to + receive.""") + + member_rosters = Attribute( + """An iterator over the IRosters containing all the members of this + mailing list.""") + + def add_member_roster(roster): + """Add the given IRoster to the list of rosters for the members of this + mailing list.""" + + def remove_member_roster(roster): + """Remove the given IRoster to the list of rosters for the members of + this mailing list.""" diff --git a/Mailman/interfaces/mliststats.py b/Mailman/interfaces/mliststats.py new file mode 100644 index 000000000..9ed25b1ce --- /dev/null +++ b/Mailman/interfaces/mliststats.py @@ -0,0 +1,38 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for various mailing list statistics.""" + +from zope.interface import Interface, Attribute + + + +class IMailingListStatistics(Interface): + """Various statistics of a mailing list.""" + + creation_date = Attribute( + """The date and time that the mailing list was created.""") + + last_post_date = Attribute( + """The date and time a message was last posted to the mailing list.""") + + post_number = Attribute( + """A monotonically increasing integer sequentially assigned to each + list posting.""") + + last_digest_date = Attribute( + """The date and time a digest of this mailing list was last sent.""") diff --git a/Mailman/interfaces/mlistweb.py b/Mailman/interfaces/mlistweb.py new file mode 100644 index 000000000..16eb94281 --- /dev/null +++ b/Mailman/interfaces/mlistweb.py @@ -0,0 +1,39 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for the web addresses associated with a mailing list.""" + +from zope.interface import Interface, Attribute + + + +class IMailingListURLs(Interface): + """The web addresses associated with a mailing list.""" + + protocol = Attribute( + """The web protocol to use to contact the server providing the web + interface for this mailing list, e.g. 'http' or 'https'.""") + + web_host = Attribute( + """The read-only domain name of the host to contact for interacting + with the web interface of the mailing list.""") + + def script_url(target, context=None): + """Return the url to the given script target. If 'context' is not + given, or is None, then an absolute url is returned. If context is + given, it must be an IMailingListRequest object, and the returned url + will be relative to that object's 'location' attribute.""" diff --git a/Mailman/interfaces/permissions.py b/Mailman/interfaces/permissions.py new file mode 100644 index 000000000..c6c5e015a --- /dev/null +++ b/Mailman/interfaces/permissions.py @@ -0,0 +1,28 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interfaces for various permissions.""" + +from zope.interface import Interface, Attribute + + + +class IPostingPermission(Interface): + """Posting related permissions.""" + + okay_to_post = Attribute( + """Boolean specifying whether it is okay to post to the list.""") diff --git a/Mailman/interfaces/profile.py b/Mailman/interfaces/profile.py new file mode 100644 index 000000000..ed3968f9d --- /dev/null +++ b/Mailman/interfaces/profile.py @@ -0,0 +1,53 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for a profile, which describes delivery related information.""" + +from zope.interface import Interface, Attribute + + + +class IProfile(Interface): + """Delivery related information.""" + + acknowledge_posts = Attribute( + """Boolean specifying whether to send an acknowledgment receipt for + every posting to the mailing list. + """) + + hide_address = Attribute( + """Boolean specifying whether to hide this email address from fellow + list members. + """) + + preferred_language = Attribute( + """Preferred language for interacting with a mailing list.""") + + receive_list_copy = Attribute( + """Boolean specifying whether to receive a list copy if the user is + explicitly named in one of the recipient headers. + """) + + receive_own_postings = Attribute( + """Boolean specifying whether to receive a list copy of the user's own + postings to the mailing list. + """) + + delivery_mode = Attribute( + """The preferred delivery mode. + + This is an enum constant of the type DeliveryMode.""") diff --git a/Mailman/interfaces/roster.py b/Mailman/interfaces/roster.py new file mode 100644 index 000000000..7ddbd5101 --- /dev/null +++ b/Mailman/interfaces/roster.py @@ -0,0 +1,42 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for a roster of members.""" + +from zope.interface import Interface, Attribute + + + +class IRoster(Interface): + """A roster is a collection of IUsers.""" + + name = Attribute( + """The name for this roster. + + Rosters are considered equal if they have the same name.""") + + addresses = Attribute( + """An iterator over all the addresses managed by this roster.""") + + def create(email_address, real_name=None): + """Create an IAddress and return it. + + email_address is textual email address to add. real_name is the + optional real name that gets associated with the email address. + + Raises ExistingAddressError if address already exists. + """ diff --git a/Mailman/interfaces/rosterset.py b/Mailman/interfaces/rosterset.py new file mode 100644 index 000000000..12f28bffa --- /dev/null +++ b/Mailman/interfaces/rosterset.py @@ -0,0 +1,47 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface for a collection of rosters.""" + +from zope.interface import Interface, Attribute + + + +class IRosterSet(Interface): + """A collection of IRosters.""" + + serial = Attribute( + """The unique integer serial number for this roster set. + + This is necessary to enforce the separation between the list storage + and the user/roster storage. You should always reference a roster set + indirectly through its serial number.""") + + rosters = Attribute( + """An iterator over all the IRosters in this collection.""") + + def add(roster): + """Add the IRoster to this collection. + + Does nothing if the roster is already a member of this collection. + """ + + def delete(roster): + """Delete the IRoster from this collection. + + Does nothing if the roster is not a member of this collection. + """ diff --git a/Mailman/interfaces/user.py b/Mailman/interfaces/user.py new file mode 100644 index 000000000..6990eee4b --- /dev/null +++ b/Mailman/interfaces/user.py @@ -0,0 +1,59 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface describing the basics of a user.""" + +from zope.interface import Interface, Attribute + + + +class IUser(Interface): + """A basic user.""" + + real_name = Attribute( + """This user's Real Name.""") + + password = Attribute( + """This user's password information.""") + + profile = Attribute( + """The default IProfile for this user.""") + + addresses = Attribute( + """An iterator over all the IAddresses controlled by this user.""") + + def link(address): + """Link this user to the given IAddress. + + Raises AddressAlreadyLinkedError if this IAddress is already linked to + another user. + """ + + def unlink(address): + """Unlink this IAddress from the user. + + Raises AddressNotLinkedError if this address is not linked to this + user, either because it's not linked to any user or it's linked to + some other user. + """ + + def controls(address): + """Determine whether this user controls the given email address. + + 'address' is a text email address. This method returns true if the + user controls the given email address, otherwise false. + """ diff --git a/Mailman/interfaces/usermanager.py b/Mailman/interfaces/usermanager.py new file mode 100644 index 000000000..302fe9b60 --- /dev/null +++ b/Mailman/interfaces/usermanager.py @@ -0,0 +1,82 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Interface describing a user manager service.""" + +from zope.interface import Interface, Attribute + + + +class IUserManager(Interface): + """The interface of a global user manager service. + + Different user managers have different concepts of what a user is, and the + users managed by different IUserManagers are completely independent. This + is how you can separate the user contexts for different domains, on a + multiple domain system. + + There is one special roster, the null roster ('') which contains all + IUsers in all IRosters. + """ + + def create_roster(name): + """Create and return the named IRoster. + + Raises RosterExistsError if the named roster already exists. + """ + + def get_roster(name): + """Return the named IRoster. + + Raises NoSuchRosterError if the named roster doesnot yet exist. + """ + + def delete_roster(name): + """Delete the named IRoster. + + Raises NoSuchRosterError if the named roster doesnot yet exist. + """ + + rosters = Attribute( + """An iterator over all IRosters managed by this user manager.""") + + def create_user(): + """Create and return an IUser.""" + + def delete_user(user): + """Delete the given IUser.""" + + def get_user(address): + """Get the user that controls the given email address, or None. + + 'address' is a text email address. + """ + + users = Attribute( + """An iterator over all the IUsers managed by this user manager.""") + + def create_rosterset(): + """Create and return a new IRosterSet. + + IRosterSets manage groups of IRosters. + """ + + def delete_rosterset(rosterset): + """Delete the given IRosterSet.""" + + def get_rosterset(serial): + """Return the IRosterSet that matches the serial number, or None.""" diff --git a/Mailman/loginit.py b/Mailman/loginit.py index 678875707..c3b3aac05 100644 --- a/Mailman/loginit.py +++ b/Mailman/loginit.py @@ -121,7 +121,8 @@ def initialize(propagate=False): # minimal overloading of our logger configurations. cp = ReallySafeConfigParser() if config.LOG_CONFIG_FILE: - cp.read(config.LOG_CONFIG_FILE) + path = os.path.join(config.ETC_DIR, config.LOG_CONFIG_FILE) + cp.read(os.path.normpath(path)) # Create the subloggers for logger in LOGGERS: log = logging.getLogger('mailman.' + logger) diff --git a/Mailman/passwords.py b/Mailman/passwords.py index 615bbcc17..9e7c5615c 100644 --- a/Mailman/passwords.py +++ b/Mailman/passwords.py @@ -28,9 +28,9 @@ import hmac from array import array from base64 import urlsafe_b64decode as decode from base64 import urlsafe_b64encode as encode +from munepy import Enum from Mailman import Errors -from Mailman.enum import Enum SALT_LENGTH = 20 # bytes ITERATIONS = 2000 diff --git a/Mailman/testing/inmemory.py b/Mailman/testing/inmemory.py index ca5313452..1489f0ffb 100644 --- a/Mailman/testing/inmemory.py +++ b/Mailman/testing/inmemory.py @@ -134,65 +134,6 @@ class Address(object): -class RegularDelivery(object): - implements(IRegularDelivery) - - -class PlainTextDigestDelivery(object): - implements(IPlainTextDigestDelivery) - - -class MIMEDigestDelivery(object): - implements(IMIMEDigestDeliver) - - - -class DeliveryEnabled(object): - implements(IDeliveryStatus) - - @property - def enabled(self): - return True - - -class DeliveryDisabled(object): - implements(IDeliveryStatus) - - @property - def enabled(self): - return False - - -class DeliveryDisabledByUser(DeliveryDisabled): - implements(IDeliveryDisabledByUser) - - -class DeliveryDisabledbyAdministrator(DeliveryDisabled): - implements(IDeliveryDisabledByAdministrator) - - reason = u'Unknown' - - -class DeliveryDisabledByBounces(DeliveryDisabled): - implements(IDeliveryDisabledByBounces) - - bounce_info = 'XXX' - - -class DeliveryTemporarilySuspended(object): - implements(IDeliveryTemporarilySuspended) - - def __init__(self, start_date, end_date): - self.start_date = start_date - self.end_date = end_date - - @property - def enabled(self): - now = datetime.datetime.now() - return not (self.start_date <= now < self.end_date) - - - class OkayToPost(object): implements(IPostingPermission) @@ -201,21 +142,6 @@ class OkayToPost(object): -class Profile(object): - implements(IProfile) - - # System defaults - acknowledge = False - hide = True - language = 'en' - list_copy = True - own_postings = True - delivery_mode = RegularDelivery() - delivery_status = DeliveryEnabled() - posting_permission = OkayToPost() - - - class Roster(object): implements(IRoster) @@ -247,24 +173,6 @@ class Roster(object): -class Member(object): - implements(IMember) - - def __init__(self, address, roster, profile=None): - self._address = address - self._roster = roster - self.profile = profile or Profile() - - @property - def address(self): - return self._address - - @property - def roster(self): - return self._roster - - - class ListManager(object): implements(IListManager) diff --git a/Mailman/testing/test_address.py b/Mailman/testing/test_address.py new file mode 100644 index 000000000..a04b3e795 --- /dev/null +++ b/Mailman/testing/test_address.py @@ -0,0 +1,30 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Doctest harness for testing IAddress interface.""" + +import doctest +import unittest + +options = doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocFileSuite('../docs/addresses.txt', + optionflags=options)) + return suite diff --git a/Mailman/testing/test_enum.py b/Mailman/testing/test_enum.py deleted file mode 100644 index a8c389bb4..000000000 --- a/Mailman/testing/test_enum.py +++ /dev/null @@ -1,125 +0,0 @@ -# Copyright (C) 2007 by the Free Software Foundation, Inc. -# -# This program is free software; you can redistribute it and/or -# modify it under the terms of the GNU General Public License -# as published by the Free Software Foundation; either version 2 -# of the License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, -# USA. - -"""Unit tests for Enums.""" - -import operator -import unittest - -from Mailman.enum import Enum - - - -class Colors(Enum): - red = 1 - green = 2 - blue = 3 - - -class MoreColors(Colors): - pink = 4 - cyan = 5 - - -class OtherColors(Enum): - red = 1 - blue = 2 - yellow = 3 - - - -class TestEnum(unittest.TestCase): - def test_enum_basics(self): - unless = self.failUnless - raises = self.assertRaises - # Cannot compare by equality - raises(NotImplementedError, operator.eq, Colors.red, Colors.red) - raises(NotImplementedError, operator.ne, Colors.red, Colors.red) - raises(NotImplementedError, operator.lt, Colors.red, Colors.red) - raises(NotImplementedError, operator.gt, Colors.red, Colors.red) - raises(NotImplementedError, operator.le, Colors.red, Colors.red) - raises(NotImplementedError, operator.ge, Colors.red, Colors.red) - raises(NotImplementedError, operator.eq, Colors.red, 1) - raises(NotImplementedError, operator.ne, Colors.red, 1) - raises(NotImplementedError, operator.lt, Colors.red, 1) - raises(NotImplementedError, operator.gt, Colors.red, 1) - raises(NotImplementedError, operator.le, Colors.red, 1) - raises(NotImplementedError, operator.ge, Colors.red, 1) - # Comparison by identity - unless(Colors.red is Colors.red) - unless(Colors.red is MoreColors.red) - unless(Colors.red is not OtherColors.red) - unless(Colors.red is not Colors.blue) - - def test_enum_conversions(self): - eq = self.assertEqual - unless = self.failUnless - raises = self.assertRaises - unless(Colors.red is Colors['red']) - unless(Colors.red is Colors[1]) - unless(Colors.red is Colors('red')) - unless(Colors.red is Colors(1)) - unless(Colors.red is not Colors['blue']) - unless(Colors.red is not Colors[2]) - unless(Colors.red is not Colors('blue')) - unless(Colors.red is not Colors(2)) - unless(Colors.red is MoreColors['red']) - unless(Colors.red is MoreColors[1]) - unless(Colors.red is MoreColors('red')) - unless(Colors.red is MoreColors(1)) - unless(Colors.red is not OtherColors['red']) - unless(Colors.red is not OtherColors[1]) - unless(Colors.red is not OtherColors('red')) - unless(Colors.red is not OtherColors(1)) - raises(ValueError, Colors.__getitem__, 'magenta') - raises(ValueError, Colors.__getitem__, 99) - raises(ValueError, Colors.__call__, 'magenta') - raises(ValueError, Colors.__call__, 99) - eq(int(Colors.red), 1) - eq(int(Colors.blue), 3) - eq(int(MoreColors.red), 1) - eq(int(OtherColors.blue), 2) - - def test_enum_duplicates(self): - try: - # This is bad because kyle and kenny have the same integer value. - class Bad(Enum): - cartman = 1 - stan = 2 - kyle = 3 - kenny = 3 - butters = 4 - except TypeError: - got_error = True - else: - got_error = False - self.failUnless(got_error) - - def test_enum_iteration(self): - eq = self.assertEqual - # Iteration sorts on the int value of the enum - values = [str(v) for v in MoreColors] - eq(values, ['red', 'green', 'blue', 'pink', 'cyan']) - values = [int(v) for v in MoreColors] - eq(values, [1, 2, 3, 4, 5]) - - - -def test_suite(): - suite = unittest.TestSuite() - suite.addTest(unittest.makeSuite(TestEnum)) - return suite diff --git a/Mailman/testing/test_mlist_addresses.py b/Mailman/testing/test_mlist_addresses.py new file mode 100644 index 000000000..fe32c12b2 --- /dev/null +++ b/Mailman/testing/test_mlist_addresses.py @@ -0,0 +1,28 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Doctest harness for the IMailingListAddresses interface.""" + +import doctest +import unittest + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocFileSuite('../docs/mlist-addresses.txt', + optionflags=doctest.ELLIPSIS)) + return suite diff --git a/Mailman/testing/test_mlist_rosters.py b/Mailman/testing/test_mlist_rosters.py new file mode 100644 index 000000000..e8713b828 --- /dev/null +++ b/Mailman/testing/test_mlist_rosters.py @@ -0,0 +1,30 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Doctest harness for the IMailingListRosters interface.""" + +import doctest +import unittest + +options = doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocFileSuite('../docs/mlist-rosters.txt', + optionflags=options)) + return suite diff --git a/Mailman/testing/test_use_listmanager.py b/Mailman/testing/test_use_listmanager.py new file mode 100644 index 000000000..f78b50a0f --- /dev/null +++ b/Mailman/testing/test_use_listmanager.py @@ -0,0 +1,28 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Doctest harness for testing mailing list creation and deletion.""" + +import doctest +import unittest + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocFileSuite('../docs/use-listmanager.txt', + optionflags=doctest.ELLIPSIS)) + return suite diff --git a/Mailman/testing/test_use_usermanager.py b/Mailman/testing/test_use_usermanager.py new file mode 100644 index 000000000..0484e3e10 --- /dev/null +++ b/Mailman/testing/test_use_usermanager.py @@ -0,0 +1,28 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Doctest harness for testing mailing list creation and deletion.""" + +import doctest +import unittest + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocFileSuite('../docs/use-usermanager.txt', + optionflags=doctest.ELLIPSIS)) + return suite diff --git a/Mailman/testing/test_user.py b/Mailman/testing/test_user.py new file mode 100644 index 000000000..1c075a164 --- /dev/null +++ b/Mailman/testing/test_user.py @@ -0,0 +1,30 @@ +# Copyright (C) 2007 by the Free Software Foundation, Inc. +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, +# USA. + +"""Doctest harness for testing users.""" + +import doctest +import unittest + +options = doctest.ELLIPSIS | doctest.NORMALIZE_WHITESPACE + + +def test_suite(): + suite = unittest.TestSuite() + suite.addTest(doctest.DocFileSuite('../docs/users.txt', + optionflags=options)) + return suite diff --git a/Mailman/testing/testing.cfg.in b/Mailman/testing/testing.cfg.in index 80e5e8bfc..2609ef5cf 100644 --- a/Mailman/testing/testing.cfg.in +++ b/Mailman/testing/testing.cfg.in @@ -4,7 +4,6 @@ # both the process running the tests and all sub-processes (e.g. qrunners) # must share the same configuration file. -MANAGERS_INIT_FUNCTION = 'Mailman.testing.inmemory.initialize' SMTPPORT = 10825 MAX_RESTARTS = 1 MTA = None |
