diff options
| author | Barry Warsaw | 2007-08-01 16:11:08 -0400 |
|---|---|---|
| committer | Barry Warsaw | 2007-08-01 16:11:08 -0400 |
| commit | f8a6c46455a409125dcc0fcace7d7116898b0319 (patch) | |
| tree | 4d1c942d92e4b63eb8f000e25477079c14bb5346 | |
| parent | d72336c1e5932158f6e1f80e5ea9e90d264b7c52 (diff) | |
| parent | 7a7826e112a1d3f1999cb7a11e6df98cfcb712c9 (diff) | |
| download | mailman-f8a6c46455a409125dcc0fcace7d7116898b0319.tar.gz mailman-f8a6c46455a409125dcc0fcace7d7116898b0319.tar.zst mailman-f8a6c46455a409125dcc0fcace7d7116898b0319.zip | |
Move the pending database into the SQLAlchemy/Elixir layer. The old
Pending.py module is removed. Added an interface to this functionality such
that any IPendable (essentially a key/value mapping) can be associated with a
token, and that token can be confirmed and has a lifetime. Any keys and
values can be stored, as long as both are unicodes.
Added a doctest.
Modified initialization of the database layer to support pluggability via
setuptools. No longer is this layer initialized from a module, but now it's
instantiated from a class that implements IDatabase. The StockDatabase class
implements the SQLAchemy/Elixir layer, but this can be overridden in a
setup.py. Bye bye MANAGERS_INIT_FUNCTION, we hardly knew ye.
Added a package Mailman.app which will contain certain application specific
functionality. Right now, the only there there is an IRegistar
implementation, which didn't seem to fit anywhere else.
Speaking of which, the IRegistrar interface implements all the logic related
to registration and verification of email addresses. Think the equivalent of
MailList.AddMember() except generalized out of a mailing list context. This
latter will eventually go away. The IRegistrar sends the confirmation email.
Added an IDomain interface, though the only implementation of this so far
lives in the registration.txt doctest. This defines the context necessary for
domain-level things, like address confirmation.
A bunch of other cleanups in modules that are necessary due to the refactoring
of Pending, but don't affect anything that's actually tested yet, so I won't
vouch for them (except that they don't throw errors on import!).
Clean up Defaults.py; also turn the functions seconds(), minutes(), hours()
and days() into their datetime.timedelta equivalents.
Consolidated the bogus email address exceptions.
In some places where appropriate, use email 4.0 module names instead of the
older brand.
Switch from Mailman.Utils.unique_message_id() to email.utils.make_msgid()
everywhere. This is because we need to allow sending not in the context of a
mailing list (i.e. domain-wide address confirmation message). So we can't use
a Message-ID generator that requires a mailing list. OTOH, this breaks
Message-ID collision detection in the mail->news gateway. I'll fix that
eventually.
Remove the 'verified' row on the Address table. Now verification is checked
by Address.verified_on not being None.
36 files changed, 1201 insertions, 358 deletions
diff --git a/Mailman/Bouncer.py b/Mailman/Bouncer.py index a9dbd0c56..46dd13114 100644 --- a/Mailman/Bouncer.py +++ b/Mailman/Bouncer.py @@ -27,7 +27,6 @@ from email.MIMEText import MIMEText from Mailman import Defaults from Mailman import MemberAdaptor from Mailman import Message -from Mailman import Pending from Mailman import Utils from Mailman import i18n from Mailman.configuration import config @@ -37,7 +36,7 @@ EMPTYSTRING = '' # This constant is supposed to represent the day containing the first midnight # after the epoch. We'll add (0,)*6 to this tuple to get a value appropriate # for time.mktime(). -ZEROHOUR_PLUSONEDAY = time.localtime(Defaults.days(1))[:3] +ZEROHOUR_PLUSONEDAY = time.localtime(60 * 60 * 24)[:3] def _(s): return s diff --git a/Mailman/Cgi/admin.py b/Mailman/Cgi/admin.py index cfca3a88e..069bb6b31 100644 --- a/Mailman/Cgi/admin.py +++ b/Mailman/Cgi/admin.py @@ -1287,16 +1287,13 @@ def change_options(mlist, category, subcat, cgidata, doc): whence='admin mass sub') except Errors.MMAlreadyAMember: subscribe_errors.append((entry, _('Already a member'))) - except Errors.MMBadEmailError: + except Errors.InvalidEmailAddress: if userdesc.address == '': subscribe_errors.append((_('<blank line>'), _('Bad/Invalid email address'))) else: subscribe_errors.append((entry, _('Bad/Invalid email address'))) - except Errors.MMHostileAddress: - subscribe_errors.append( - (entry, _('Hostile address (illegal characters)'))) except Errors.MembershipIsBanned, pattern: subscribe_errors.append( (entry, _('Banned address (matched %(pattern)s)'))) diff --git a/Mailman/Cgi/options.py b/Mailman/Cgi/options.py index faa813489..8048aeb73 100644 --- a/Mailman/Cgi/options.py +++ b/Mailman/Cgi/options.py @@ -381,10 +381,8 @@ address. Upon confirmation, any other mailing list containing the address mlist.Save() finally: mlist.Unlock() - except Errors.MMBadEmailError: - msg = _('Bad email address provided') - except Errors.MMHostileAddress: - msg = _('Illegal email address provided') + except Errors.InvalidEmailAddress: + msg = _('Invalid email address provided') except Errors.MMAlreadyAMember: msg = _('%(newaddr)s is already a member of the list.') except Errors.MembershipIsBanned: diff --git a/Mailman/Cgi/subscribe.py b/Mailman/Cgi/subscribe.py index 512cd3195..e703dd59a 100644 --- a/Mailman/Cgi/subscribe.py +++ b/Mailman/Cgi/subscribe.py @@ -165,14 +165,8 @@ email which contains further instructions.""") results = _("""The email address you supplied is banned from this mailing list. If you think this restriction is erroneous, please contact the list owners at %(listowner)s.""") - except Errors.MMBadEmailError: - results = _("""\ -The email address you supplied is not valid. (E.g. it must contain an -`@'.)""") - except Errors.MMHostileAddress: - results = _("""\ -Your subscription is not allowed because the email address you gave is -insecure.""") + except Errors.InvalidEmailAddress: + results = _('The email address you supplied is not valid.') except Errors.MMSubscribeNeedsConfirmation: # Results string depends on whether we have private rosters or not if privacy_results: diff --git a/Mailman/Commands/cmd_subscribe.py b/Mailman/Commands/cmd_subscribe.py index 51d43c9d1..103e39f1c 100644 --- a/Mailman/Commands/cmd_subscribe.py +++ b/Mailman/Commands/cmd_subscribe.py @@ -107,15 +107,9 @@ The email address you supplied is banned from this mailing list. If you think this restriction is erroneous, please contact the list owners at %(listowner)s.""")) return STOP - except Errors.MMBadEmailError: + except Errors.InvalidEmailAddress: res.results.append(_("""\ -Mailman won't accept the given email address as a valid address. -(E.g. it must have an @ in it.)""")) - return STOP - except Errors.MMHostileAddress: - res.results.append(_("""\ -Your subscription is not allowed because -the email address you gave is insecure.""")) +Mailman won't accept the given email address as a valid address.""")) return STOP except Errors.MMAlreadyAMember: res.results.append(_('You are already subscribed!')) diff --git a/Mailman/Defaults.py b/Mailman/Defaults.py index 3c06b709a..951b61bfc 100644 --- a/Mailman/Defaults.py +++ b/Mailman/Defaults.py @@ -1,5 +1,3 @@ -# -*- python -*- - # Copyright (C) 1998-2007 by the Free Software Foundation, Inc. # # This program is free software; you can redistribute it and/or @@ -19,20 +17,24 @@ """Distributed default settings for significant Mailman config variables.""" -# NEVER make site configuration changes to this file. ALWAYS make them in -# mm_cfg.py instead, in the designated area. See the comments in that file -# for details. - - import os +from datetime import timedelta from munepy import Enum -def seconds(s): return s -def minutes(m): return m * 60 -def hours(h): return h * 60 * 60 -def days(d): return d * 60 * 60 * 24 +def seconds(s): + return timedelta(seconds=s) + +def minutes(m): + return timedelta(minutes=m) + +def hours(h): + return timedelta(hours=h) + +def days(d): + return timedelta(days=d) + # Some convenient constants Yes = yes = On = on = True @@ -110,11 +112,6 @@ DEFAULT_VAR_DIRECTORY = '/var/mailman' # 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 diff --git a/Mailman/Deliverer.py b/Mailman/Deliverer.py index e4ff15a58..c9b246400 100644 --- a/Mailman/Deliverer.py +++ b/Mailman/Deliverer.py @@ -25,7 +25,6 @@ from email.MIMEText import MIMEText from Mailman import Errors from Mailman import Message -from Mailman import Pending from Mailman import Utils from Mailman import i18n from Mailman.configuration import config diff --git a/Mailman/Errors.py b/Mailman/Errors.py index 5d44cebab..abfeb5d38 100644 --- a/Mailman/Errors.py +++ b/Mailman/Errors.py @@ -93,21 +93,18 @@ class MMLoopingPost(MailmanError): pass + # Exception hierarchy for bad email address errors that can be raised from # Utils.ValidateEmail() class EmailAddressError(MailmanError): """Base class for email address validation errors.""" - pass -class MMBadEmailError(EmailAddressError): - """Email address is invalid (empty string or not fully qualified).""" - pass -class MMHostileAddress(EmailAddressError): - """Email address has potentially hostile characters in it.""" - pass +class InvalidEmailAddress(EmailAddressError): + """Email address is invalid.""" + # Exceptions for admin request database class LostHeldMessage(MailmanError): """Held message was lost.""" diff --git a/Mailman/Gui/GUIBase.py b/Mailman/Gui/GUIBase.py index 3bb558dce..2083fa8f7 100644 --- a/Mailman/Gui/GUIBase.py +++ b/Mailman/Gui/GUIBase.py @@ -52,7 +52,7 @@ class GUIBase: # widget in the interface, so watch out if we ever add any new # ones. if val: - # Let MMBadEmailError and MMHostileAddress propagate + # Let InvalidEmailAddress propagate. Utils.ValidateEmail(val) return val # These widget types contain lists of email addresses, one per line. @@ -156,7 +156,7 @@ class GUIBase: val = self._getValidValue(mlist, property, wtype, val) except ValueError: doc.addError(_('Invalid value for variable: %(property)s')) - # This is the parent of MMBadEmailError and MMHostileAddress + # This is the parent of InvalidEmailAddress except Errors.EmailAddressError: doc.addError( _('Bad email address for option %(property)s: %(val)s')) diff --git a/Mailman/Handlers/Hold.py b/Mailman/Handlers/Hold.py index 2eae08fc1..d4f4845e5 100644 --- a/Mailman/Handlers/Hold.py +++ b/Mailman/Handlers/Hold.py @@ -30,10 +30,10 @@ message handling should stop. import email import logging -import email.Utils +import email.utils -from email.MIMEMessage import MIMEMessage -from email.MIMEText import MIMEText +from email.mime.message import MIMEMessage +from email.mime.text import MIMEText from types import ClassType from Mailman import Errors @@ -151,7 +151,7 @@ def process(mlist, msg, msgdata): # Are there too many recipients to the message? if mlist.max_num_recipients > 0: # figure out how many recipients there are - recips = email.Utils.getaddresses(msg.get_all('to', []) + + recips = email.utils.getaddresses(msg.get_all('to', []) + msg.get_all('cc', [])) if len(recips) >= mlist.max_num_recipients: hold_for_approval(mlist, msg, msgdata, TooManyRecipients) @@ -287,8 +287,8 @@ also appear in the first line of the body of the reply.""")), dmsg['Subject'] = 'confirm ' + cookie dmsg['Sender'] = requestaddr dmsg['From'] = requestaddr - dmsg['Date'] = email.Utils.formatdate(localtime=True) - dmsg['Message-ID'] = Utils.unique_message_id(mlist) + dmsg['Date'] = email.utils.formatdate(localtime=True) + dmsg['Message-ID'] = email.utils.make_msgid() nmsg.attach(text) nmsg.attach(MIMEMessage(msg)) nmsg.attach(MIMEMessage(dmsg)) diff --git a/Mailman/Handlers/Scrubber.py b/Mailman/Handlers/Scrubber.py index 097e1d462..a70cb0c71 100644 --- a/Mailman/Handlers/Scrubber.py +++ b/Mailman/Handlers/Scrubber.py @@ -29,10 +29,10 @@ import tempfile from cStringIO import StringIO from mimetypes import guess_all_extensions -from email.Charset import Charset -from email.Generator import Generator -from email.Parser import HeaderParser -from email.Utils import parsedate +from email.charset import Charset +from email.generator import Generator +from email.parser import HeaderParser +from email.utils import make_msgid, parsedate from Mailman import LockFile from Mailman import Message @@ -133,7 +133,7 @@ def calculate_attachments_dir(mlist, msg, msgdata): # still gives us a 32-bit space to work with. msgid = msg['message-id'] if msgid is None: - msgid = msg['Message-ID'] = Utils.unique_message_id(mlist) + msgid = msg['Message-ID'] = make_msgid() # We assume that the message id actually /is/ unique! digest = sha.new(msgid).hexdigest() return os.path.join('attachments', datedir, digest[:4] + digest[-4:]) diff --git a/Mailman/Handlers/ToDigest.py b/Mailman/Handlers/ToDigest.py index d30f05c71..ece052f14 100644 --- a/Mailman/Handlers/ToDigest.py +++ b/Mailman/Handlers/ToDigest.py @@ -32,14 +32,14 @@ import time import logging from StringIO import StringIO # cStringIO can't handle unicode. -from email.Charset import Charset -from email.Generator import Generator -from email.Header import decode_header, make_header, Header -from email.MIMEBase import MIMEBase -from email.MIMEMessage import MIMEMessage -from email.MIMEText import MIMEText -from email.Parser import Parser -from email.Utils import getaddresses, formatdate +from email.charset import Charset +from email.generator import Generator +from email.header import decode_header, make_header, Header +from email.mime.base import MIMEBase +from email.mime.message import MIMEMessage +from email.mime.text import MIMEText +from email.parser import Parser +from email.utils import formatdate, getaddresses, make_msgid from Mailman import Errors from Mailman import Message @@ -161,7 +161,7 @@ def send_i18n_digests(mlist, mboxfp): mimemsg['To'] = mlist.posting_address mimemsg['Reply-To'] = mlist.posting_address mimemsg['Date'] = formatdate(localtime=1) - mimemsg['Message-ID'] = Utils.unique_message_id(mlist) + mimemsg['Message-ID'] = make_msgid() # Set things up for the rfc1153 digest plainmsg = StringIO() rfc1153msg = Message.Message() @@ -170,7 +170,7 @@ def send_i18n_digests(mlist, mboxfp): rfc1153msg['To'] = mlist.posting_address rfc1153msg['Reply-To'] = mlist.posting_address rfc1153msg['Date'] = formatdate(localtime=1) - rfc1153msg['Message-ID'] = Utils.unique_message_id(mlist) + rfc1153msg['Message-ID'] = make_msgid() separator70 = '-' * 70 separator30 = '-' * 30 # In the rfc1153 digest, the masthead contains the digest boilerplate plus diff --git a/Mailman/MailList.py b/Mailman/MailList.py index 76538e6c2..c2680835d 100644 --- a/Mailman/MailList.py +++ b/Mailman/MailList.py @@ -54,7 +54,6 @@ from Mailman.configuration import config from Mailman.interfaces import * # Base classes -from Mailman import Pending from Mailman.Archiver import Archiver from Mailman.Autoresponder import Autoresponder from Mailman.Bouncer import Bouncer @@ -90,7 +89,7 @@ slog = logging.getLogger('mailman.subscribe') # Use mixins here just to avoid having any one chunk be too large. class MailList(object, HTMLFormatter, Deliverer, ListAdmin, Archiver, Digester, SecurityManager, Bouncer, GatewayManager, - Autoresponder, TopicMgr, Pending.Pending): + Autoresponder, TopicMgr): implements( IMailingList, @@ -502,7 +501,7 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, # Validate the list's posting address, which should be fqdn_listname. # If that's invalid, do not create any of the mailing list artifacts # (the subdir in lists/ and the subdirs in archives/public and - # archives/private. Most scripts already catch MMBadEmailError as + # archives/private. Most scripts already catch InvalidEmailAddress as # exceptions on the admin's email address, so transform the exception. if '@' not in fqdn_listname: raise Errors.BadListNameError(fqdn_listname) @@ -511,7 +510,7 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, raise Errors.BadDomainSpecificationError(email_host) try: Utils.ValidateEmail(fqdn_listname) - except Errors.MMBadEmailError: + except Errors.InvalidEmailAddress: raise Errors.BadListNameError(fqdn_listname) # See if the mailing list already exists. if Utils.list_exists(fqdn_listname): @@ -706,7 +705,7 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, raise Errors.MMAlreadyAMember, email if email.lower() == self.GetListEmail().lower(): # Trying to subscribe the list to itself! - raise Errors.MMBadEmailError + raise Errors.InvalidEmailAddress realname = self.real_name # Is the subscribing address banned from this list? pattern = self.GetBannedPattern(email) @@ -931,7 +930,7 @@ class MailList(object, HTMLFormatter, Deliverer, ListAdmin, if not globally and newaddr == oldaddr and self.isMember(newaddr): raise Errors.MMAlreadyAMember if newaddr == self.GetListEmail().lower(): - raise Errors.MMBadEmailError + raise Errors.InvalidEmailAddress realname = self.real_name # Don't allow changing to a banned address. MAS: maybe we should # unsubscribe the oldaddr too just for trying, but that's probably diff --git a/Mailman/Message.py b/Mailman/Message.py index e535b88cc..0ae724009 100644 --- a/Mailman/Message.py +++ b/Mailman/Message.py @@ -23,11 +23,11 @@ which is more convenient for use inside Mailman. import re import email -import email.Message -import email.Utils +import email.message +import email.utils -from email.Charset import Charset -from email.Header import Header +from email.charset import Charset +from email.header import Header from Mailman import Utils from Mailman.configuration import config @@ -39,11 +39,11 @@ VERSION = tuple([int(s) for s in mo.group().split('.')]) -class Message(email.Message.Message): +class Message(email.message.Message): def __init__(self): # We need a version number so that we can optimize __setstate__() self.__version__ = VERSION - email.Message.Message.__init__(self) + email.message.Message.__init__(self) # BAW: For debugging w/ bin/dumpdb. Apparently pprint uses repr. def __repr__(self): @@ -126,7 +126,7 @@ class Message(email.Message.Message): fieldval = self[h] if not fieldval: continue - addrs = email.Utils.getaddresses([fieldval]) + addrs = email.utils.getaddresses([fieldval]) try: realname, address = addrs[0] except IndexError: @@ -179,7 +179,7 @@ class Message(email.Message.Message): else: fieldvals = self.get_all(h) if fieldvals: - pairs.extend(email.Utils.getaddresses(fieldvals)) + pairs.extend(email.utils.getaddresses(fieldvals)) authors = [] for pair in pairs: address = pair[1] @@ -193,7 +193,7 @@ class Message(email.Message.Message): Mailman to stop delivery in Scrubber.py (called from ToDigest.py). """ try: - filename = email.Message.Message.get_filename(self, failobj) + filename = email.message.Message.get_filename(self, failobj) return filename except (UnicodeError, LookupError, ValueError): return failobj @@ -223,22 +223,22 @@ class UserNotification(Message): self.recips = [recip] def send(self, mlist, **_kws): - """Sends the message by enqueuing it to the `virgin' queue. + """Sends the message by enqueuing it to the 'virgin' queue. This is used for all internally crafted messages. """ # Since we're crafting the message from whole cloth, let's make sure # this message has a Message-ID. Yes, the MTA would give us one, but # this is useful for logging to logs/smtp. - if not self.has_key('message-id'): - self['Message-ID'] = Utils.unique_message_id(mlist) + if 'message-id' not in self: + self['Message-ID'] = email.utils.make_msgid() # Ditto for Date: which is required by RFC 2822 - if not self.has_key('date'): - self['Date'] = email.Utils.formatdate(localtime=1) + if 'date' not in self: + self['Date'] = email.utils.formatdate(localtime=True) # UserNotifications are typically for admin messages, and for messages # other than list explosions. Send these out as Precedence: bulk, but # don't override an existing Precedence: header. - if not self.has_key('precedence'): + if 'precedence' not in self: self['Precedence'] = 'bulk' self._enqueue(mlist, **_kws) @@ -246,13 +246,16 @@ class UserNotification(Message): # Not imported at module scope to avoid import loop from Mailman.Queue.sbcache import get_switchboard virginq = get_switchboard(config.VIRGINQUEUE_DIR) - # The message metadata better have a `recip' attribute - virginq.enqueue(self, - listname=mlist.fqdn_listname, - recips=self.recips, - nodecorate=True, - reduced_list_headers=True, - **_kws) + # The message metadata better have a 'recip' attribute. + enqueue_kws = dict( + recips=self.recips, + nodecorate=True, + reduced_list_headers=True, + ) + if mlist is not None: + enqueue_kws[listname] = mlist.fqdn_listname + enqueue_kws.update(_kws) + virginq.enqueue(self, **enqueue_kws) diff --git a/Mailman/Pending.py b/Mailman/Pending.py deleted file mode 100644 index 1d133e018..000000000 --- a/Mailman/Pending.py +++ /dev/null @@ -1,179 +0,0 @@ -# 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. - -"""Track pending actions which require confirmation.""" - -from __future__ import with_statement - -import os -import sha -import time -import errno -import random -import cPickle - -from Mailman.configuration import config - - -# Types of pending records -CHANGE_OF_ADDRESS = 'C' -HELD_MESSAGE = 'H' -PROBE_BOUNCE = 'P' -RE_ENABLE = 'E' -SUBSCRIPTION = 'S' -UNSUBSCRIPTION = 'U' - -_ALLKEYS = ( - CHANGE_OF_ADDRESS, - HELD_MESSAGE, - PROBE_BOUNCE, - RE_ENABLE, - SUBSCRIPTION, - UNSUBSCRIPTION, - ) - -_missing = object() -_default = object() - - - -class Pending: - def InitTempVars(self): - 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. - """ - assert op in _ALLKEYS, 'op: %s' % op - lifetime = kws.get('lifetime', config.PENDING_REQUEST_LIFE) - # We try the main loop several times. If we get a lock error somewhere - # (for instance because someone broke the lock) we simply try again. - assert self.Locked() - # Load the database - db = self.__load() - # Calculate a unique cookie. Algorithm vetted by the Timbot. time() - # has high resolution on Linux, clock() on Windows. random gives us - # about 45 bits in Python 2.2, 53 bits on Python 2.3. The time and - # clock values basically help obscure the random number generator, as - # does the hash calculation. The integral parts of the time values - # are discarded because they're the most predictable bits. - while True: - now = time.time() - x = random.random() + now % 1.0 + time.clock() % 1.0 - cookie = sha.new(repr(x)).hexdigest() - # We'll never get a duplicate, but we'll be anal about checking - # anyway. - if not db.has_key(cookie): - break - # Store the content, plus the time in the future when this entry will - # be evicted from the database, due to staleness. - db[cookie] = (op,) + content - evictions = db.setdefault('evictions', {}) - evictions[cookie] = now + lifetime - self.__save(db) - return cookie - - def __load(self): - try: - with open(self._pendfile) as fp: - return cPickle.load(fp) - except IOError, e: - if e.errno <> errno.ENOENT: - raise - return {'evictions': {}} - - def __save(self, db): - evictions = db['evictions'] - now = time.time() - for cookie, data in db.items(): - if cookie in ('evictions', 'version'): - continue - timestamp = evictions[cookie] - if now > timestamp: - # The entry is stale, so remove it. - del db[cookie] - del evictions[cookie] - # Clean out any bogus eviction entries. - for cookie in evictions.keys(): - if not db.has_key(cookie): - del evictions[cookie] - db['version'] = config.PENDING_FILE_SCHEMA_VERSION - 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()) - os.rename(tmpfile, self._pendfile) - - def pend_confirm(self, cookie, expunge=True): - """Return data for cookie, or None if not found. - - If optional expunge is True (the default), the record is also removed - from the database. - """ - db = self.__load() - # If we're not expunging, the database is read-only. - if not expunge: - return db.get(cookie) - # Since we're going to modify the database, we must make sure the list - # is locked, since it's the list lock that protects pending.pck. - assert self.Locked() - content = db.get(cookie, _missing) - if content is _missing: - return None - # Do the expunge - del db[cookie] - del db['evictions'][cookie] - self.__save(db) - return content - - def pend_repend(self, cookie, data, lifetime=_default): - assert self.Locked() - if lifetime is _default: - lifetime = config.PENDING_REQUEST_LIFE - db = self.__load() - db[cookie] = data - db['evictions'][cookie] = time.time() + lifetime - self.__save(db) - - - -def _update(olddb): - db = {} - # We don't need this entry anymore - if olddb.has_key('lastculltime'): - del olddb['lastculltime'] - evictions = db.setdefault('evictions', {}) - for cookie, data in olddb.items(): - # The cookies used to be kept as a 6 digit integer. We now keep the - # cookies as a string (sha in our case, but it doesn't matter for - # cookie matching). - cookie = str(cookie) - # The old format kept the content as a tuple and tacked the timestamp - # on as the last element of the tuple. We keep the timestamps - # separate, but require the prepending of a record type indicator. We - # know that the only things that were kept in the old format were - # subscription requests. Also, the old request format didn't have the - # subscription language. Best we can do here is use the server - # default. - db[cookie] = (SUBSCRIPTION,) + data[:-1] + \ - (config.DEFAULT_SERVER_LANGUAGE,) - # The old database format kept the timestamp as the time the request - # was made. The new format keeps it as the time the request should be - # evicted. - evictions[cookie] = data[-1] + config.PENDING_REQUEST_LIFE - return db diff --git a/Mailman/Queue/NewsRunner.py b/Mailman/Queue/NewsRunner.py index 27c0e239c..48e3f48ee 100644 --- a/Mailman/Queue/NewsRunner.py +++ b/Mailman/Queue/NewsRunner.py @@ -24,7 +24,7 @@ import logging import nntplib from cStringIO import StringIO -from email.Utils import getaddresses +from email.utils import getaddresses, make_msgid COMMASPACE = ', ' @@ -35,6 +35,7 @@ from Mailman.configuration import config log = logging.getLogger('mailman.error') # Matches our Mailman crafted Message-IDs. See Utils.unique_message_id() +# XXX The move to email.utils.make_msgid() breaks this. mcre = re.compile(r""" <mailman. # match the prefix \d+. # serial number diff --git a/Mailman/Utils.py b/Mailman/Utils.py index 2fff35c12..4034bfdd8 100644 --- a/Mailman/Utils.py +++ b/Mailman/Utils.py @@ -200,15 +200,15 @@ def ValidateEmail(s): """Verify that the an email address isn't grossly evil.""" # Pretty minimal, cheesy check. We could do better... if not s or ' ' in s: - raise Errors.MMBadEmailError + raise Errors.InvalidEmailAddress(repr(s)) if _badchars.search(s) or s[0] == '-': - raise Errors.MMHostileAddress, s + raise Errors.InvalidEmailAddress(repr(s)) user, domain_parts = ParseEmail(s) - # This means local, unqualified addresses, are no allowed + # Local, unqualified addresses are not allowed. if not domain_parts: - raise Errors.MMBadEmailError, s + raise Errors.InvalidEmailAddress(repr(s)) if len(domain_parts) < 2: - raise Errors.MMBadEmailError, s + raise Errors.InvalidEmailAddress(repr(s)) @@ -678,22 +678,6 @@ def get_site_noreply(): -# This algorithm crafts a guaranteed unique message-id. The theory here is -# that pid+listname+host will distinguish the message-id for every process on -# the system, except when process ids wrap around. To further distinguish -# message-ids, we prepend the integral time in seconds since the epoch. It's -# still possible that we'll vend out more than one such message-id per second, -# so we prepend a monotonically incrementing serial number. It's highly -# unlikely that within a single second, there'll be a pid wraparound. -_serial = 0 -def unique_message_id(mlist): - global _serial - msgid = '<mailman.%d.%d.%d.%s>' % ( - _serial, time.time(), os.getpid(), mlist.fqdn_listname) - _serial += 1 - return msgid - - # Figure out epoch seconds of midnight at the start of today (or the given # 3-tuple date of (year, month, day). def midnight(date=None): diff --git a/Mailman/app/__init__.py b/Mailman/app/__init__.py new file mode 100755 index 000000000..e69de29bb --- /dev/null +++ b/Mailman/app/__init__.py diff --git a/Mailman/app/registrar.py b/Mailman/app/registrar.py new file mode 100644 index 000000000..e44394134 --- /dev/null +++ b/Mailman/app/registrar.py @@ -0,0 +1,149 @@ +# 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. + +"""Implementation of the IUserRegistrar interface.""" + +__metaclass__ = type +__all__ = [ + 'Registrar', + ] + + +import datetime +import pkg_resources + +from zope.interface import implements + +from Mailman.Message import UserNotification +from Mailman.Utils import ValidateEmail +from Mailman.configuration import config +from Mailman.i18n import _ +from Mailman.interfaces import IDomain, IPendable, IPending, IRegistrar + +__i18n_templates__ = True + + + +class PendableRegistration(dict): + implements(IPendable) + PEND_KEY = 'registration' + + + +class Registrar: + implements(IRegistrar) + + def __init__(self, context): + self._context = context + + def register(self, address, real_name=None): + """See `IUserRegistrar`.""" + # First, do validation on the email address. If the address is + # invalid, it will raise an exception, otherwise it just returns. + ValidateEmail(address) + # Check to see if there is already a verified IAddress in the database + # matching this address. If so, there's nothing to do. + usermgr = config.user_manager + addr = usermgr.get_address(address) + if addr and addr.verified_on: + # Before returning, see if this address is linked to a user. If + # not, create one and link it now since no future verification + # will be done. + user = usermgr.get_user(address) + if user is None: + user = usermgr.create_user() + user.real_name = (real_name if real_name else addr.real_name) + user.link(addr) + return None + # Calculate the token for this confirmation record. + pendable = PendableRegistration(type=PendableRegistration.PEND_KEY, + address=address, + real_name=real_name) + pendingdb = IPending(config.db) + token = pendingdb.add(pendable) + # Set up some local variables for translation interpolation. + domain = IDomain(self._context) + domain_name = _(domain.domain_name) + contact_address = domain.contact_address + confirm_url = domain.confirm_url(token) + confirm_address = domain.confirm_address(token) + email_address = address + # Calculate the message's Subject header. XXX Have to deal with + # translating this subject header properly. XXX Must deal with + # VERP_CONFIRMATIONS as well. + subject = 'confirm ' + token + # Send a verification email to the address. + text = _(pkg_resources.resource_string( + 'Mailman.templates.en', 'verify.txt')) + msg = UserNotification(address, confirm_address, subject, text) + msg.send(mlist=None) + return token + + def confirm(self, token): + """See `IUserRegistrar`.""" + # For convenience + pendingdb = IPending(config.db) + pendable = pendingdb.confirm(token) + if pendable is None: + return False + missing = object() + address = pendable.get('address', missing) + real_name = pendable.get('real_name', missing) + if (pendable.get('type') <> PendableRegistration.PEND_KEY or + address is missing or real_name is missing): + # It seems like it would be very difficult to accurately guess + # tokens, or brute force an attack on the SHA1 hash, so we'll just + # throw the pendable away in that case. It's possible we'll need + # to repend the event or adjust the API to handle this case + # better, but for now, the simpler the better. + return False + # We are going to end up with an IAddress for the verified address + # and an IUser linked to this IAddress. See if any of these objects + # currently exist in our database. + usermgr = config.user_manager + addr = usermgr.get_address(address) + user = usermgr.get_user(address) + # If there is neither an address nor a user matching the confirmed + # record, then create the user, which will in turn create the address + # and link the two together + if addr is None: + assert user is None, 'How did we get a user but not an address?' + user = usermgr.create_user(address, real_name) + # Because the database changes haven't been flushed, we can't use + # IUserManager.get_address() to find the IAddress just created + # under the hood. Instead, iterate through the IUser's addresses, + # of which really there should be only one. + for addr in user.addresses: + if addr.address == address: + break + else: + raise AssertionError('Could not find expected IAddress') + elif user is None: + user = usermgr.create_user() + user.real_name = real_name + user.link(addr) + else: + # The IAddress and linked IUser already exist, so all we need to + # do is verify the address. + pass + addr.verified_on = datetime.datetime.now() + return True + + def discard(self, token): + pendingdb = IPending(config.db) + # Throw the record away. + pendingdb.confirm(token) diff --git a/Mailman/bin/add_members.py b/Mailman/bin/add_members.py index d6f147fbc..96abbc74d 100644 --- a/Mailman/bin/add_members.py +++ b/Mailman/bin/add_members.py @@ -145,13 +145,11 @@ def addall(mlist, members, digest, ack, outfp): mlist.ApprovedAddMember(userdesc, ack, 0) except Errors.MMAlreadyAMember: print >> tee, _('Already a member: $member') - except Errors.MMBadEmailError: + except Errors.InvalidEmailAddress: if userdesc.address == '': print >> tee, _('Bad/Invalid email address: blank line') else: print >> tee, _('Bad/Invalid email address: $member') - except Errors.MMHostileAddress: - print >> tee, _('Hostile address (illegal characters): $member') else: print >> tee, _('Subscribed: $member') diff --git a/Mailman/database/__init__.py b/Mailman/database/__init__.py index 11afe5f3e..e27ad8cf1 100644 --- a/Mailman/database/__init__.py +++ b/Mailman/database/__init__.py @@ -17,35 +17,59 @@ from __future__ import with_statement +__metaclass__ = type +__all__ = [ + 'StockDatabase', + 'flush', # for test convenience + ] + import os from elixir import objectstore +from zope.interface import implements +from Mailman.interfaces import IDatabase, IPending from Mailman.database.listmanager import ListManager from Mailman.database.usermanager import UserManager from Mailman.database.messagestore import MessageStore +from Mailman.database.model import Pendings -__all__ = [ - 'initialize', - 'flush', - ] +# Test suite convenience. +flush = None -def initialize(): - from Mailman.LockFile import LockFile - from Mailman.configuration import config - 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>') - with LockFile(lockfile): - model.initialize() - config.list_manager = ListManager() - config.user_manager = UserManager() - config.message_store = MessageStore() - flush() +class StockDatabase: + implements(IDatabase) + + def __init__(self): + # Expose the flush() method for test case convenience using the stock + # database. + global flush + flush = self.flush + self.list_manager = None + self.user_manager = None + self.message_store = None + + def initialize(self): + from Mailman.LockFile import LockFile + from Mailman.configuration import config + 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>') + with LockFile(lockfile): + model.initialize() + self.list_manager = ListManager() + self.user_manager = UserManager() + self.message_store = MessageStore() + self.flush() + def flush(self): + objectstore.flush() -def flush(): - objectstore.flush() + def __conform__(self, protocol): + if protocol is IPending: + return Pendings() + # Let the rest of the adaptation machinery take a crack at it. + return None diff --git a/Mailman/database/listmanager.py b/Mailman/database/listmanager.py index 83c913ffb..b53bb44b3 100644 --- a/Mailman/database/listmanager.py +++ b/Mailman/database/listmanager.py @@ -25,8 +25,8 @@ 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 +from Mailman.database.model import MailingList, Pendings +from Mailman.interfaces import IListManager, IPending diff --git a/Mailman/database/model/__init__.py b/Mailman/database/model/__init__.py index 5b9d32ce0..2da97f63d 100644 --- a/Mailman/database/model/__init__.py +++ b/Mailman/database/model/__init__.py @@ -20,6 +20,7 @@ __all__ = [ 'Language', 'MailingList', 'Message', + 'Pendings', 'Preferences', 'User', 'Version', @@ -45,6 +46,7 @@ from Mailman.database.model.language import Language from Mailman.database.model.mailinglist import MailingList from Mailman.database.model.member import Member from Mailman.database.model.message import Message +from Mailman.database.model.pending import Pendings from Mailman.database.model.preferences import Preferences from Mailman.database.model.user import User from Mailman.database.model.version import Version diff --git a/Mailman/database/model/address.py b/Mailman/database/model/address.py index ca51af265..9c36d2472 100644 --- a/Mailman/database/model/address.py +++ b/Mailman/database/model/address.py @@ -33,9 +33,8 @@ class Address(Entity): has_field('address', Unicode) has_field('_original', Unicode) has_field('real_name', Unicode) - has_field('verified', Boolean) + has_field('verified_on', DateTime) has_field('registered_on', DateTime) - has_field('validated_on', DateTime) # Relationships belongs_to('user', of_kind=USER_KIND) belongs_to('preferences', of_kind=PREFERENCE_KIND) @@ -54,7 +53,7 @@ class Address(Entity): return formataddr((self.real_name, addr)) def __repr__(self): - verified = ('verified' if self.verified else 'not verified') + verified = ('verified' if self.verified_on else 'not verified') address_str = str(self) if self._original is None: return '<Address: %s [%s] at %#x>' % ( diff --git a/Mailman/database/model/pending.py b/Mailman/database/model/pending.py new file mode 100644 index 000000000..0ac438535 --- /dev/null +++ b/Mailman/database/model/pending.py @@ -0,0 +1,124 @@ +# 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 time +import random +import hashlib +import datetime + +from elixir import * +from zope.interface import implements +from zope.interface.verify import verifyObject + +from Mailman.configuration import config +from Mailman.interfaces import IPending, IPendable + +PEND_KIND = 'Mailman.database.model.pending.Pending' + + + +class PendedKeyValue(Entity): + """A pended key/value pair, tied to a token.""" + + has_field('key', Unicode) + has_field('value', Unicode) + # Relationships + belongs_to('pended', of_kind=PEND_KIND) + # Options + using_options(shortnames=True) + + +class Pending(Entity): + """A pended event, tied to a token.""" + + has_field('token', Unicode) + has_field('expiration_date', DateTime) + # Options + using_options(shortnames=True) + + + +class UnpendedPendable(dict): + implements(IPendable) + + + +class Pendings(object): + """Implementation of the IPending interface.""" + + implements(IPending) + + def add(self, pendable, lifetime=None): + verifyObject(IPendable, pendable) + # Calculate the token and the lifetime. + if lifetime is None: + lifetime = config.PENDING_REQUEST_LIFE + # Calculate a unique token. Algorithm vetted by the Timbot. time() + # has high resolution on Linux, clock() on Windows. random gives us + # about 45 bits in Python 2.2, 53 bits on Python 2.3. The time and + # clock values basically help obscure the random number generator, as + # does the hash calculation. The integral parts of the time values + # are discarded because they're the most predictable bits. + while True: + now = time.time() + x = random.random() + now % 1.0 + time.clock() % 1.0 + # Use sha1 because it produces shorter strings. + token = hashlib.sha1(repr(x)).hexdigest() + # In practice, we'll never get a duplicate, but we'll be anal + # about checking anyway. + if not Pending.select_by(token=token): + break + # Create the record, and then the individual key/value pairs. + pending = Pending( + token=token, + expiration_date=datetime.datetime.now() + lifetime) + for key, value in pendable.items(): + PendedKeyValue(key=key, value=value, pended=pending) + return token + + def confirm(self, token, expunge=True): + pendings = Pending.select_by(token=token) + assert 0 <= len(pendings) <= 1, 'Unexpected token search results' + if len(pendings) == 0: + return None + pending = pendings[0] + pendable = UnpendedPendable() + # Find all PendingKeyValue entries that are associated with the + # pending object's ID. + q = PendedKeyValue.filter( + PendedKeyValue.c.pended_id == Pending.c.id).filter( + Pending.c.id == pending.id) + for keyvalue in q.all(): + pendable[keyvalue.key] = keyvalue.value + if expunge: + keyvalue.delete() + if expunge: + pending.delete() + return pendable + + def evict(self): + now = datetime.datetime.now() + for pending in Pending.select(): + if pending.expiration_date < now: + # Find all PendingKeyValue entries that are associated with + # the pending object's ID. + q = PendedKeyValue.filter( + PendedKeyValue.c.pended_id == Pending.c.id).filter( + Pending.c.id == pending.id) + for keyvalue in q: + keyvalue.delete() + pending.delete() diff --git a/Mailman/docs/pending.txt b/Mailman/docs/pending.txt new file mode 100644 index 000000000..518dadf87 --- /dev/null +++ b/Mailman/docs/pending.txt @@ -0,0 +1,104 @@ +The pending database +==================== + +The pending database is where various types of events which need confirmation +are stored. These can include email address registration events, held +messages (but only for user confirmation), auto-approvals, and probe bounces. +This is not where messages held for administrator approval are kept. + + >>> from Mailman.configuration import config + >>> from Mailman.database import flush + >>> from Mailman.interfaces import IPendable, IPending + >>> from zope.interface import implements + >>> from zope.interface.verify import verifyObject + +In order to pend an event, you first need a pending database, which is +available by adapting the list manager. + + >>> pendingdb = IPending(config.list_manager) + >>> verifyObject(IPending, pendingdb) + True + +The pending database can add any IPendable to the database, returning a token +that can be used in urls and such. + + >>> class SimplePendable(dict): + ... implements(IPendable) + >>> subscription = SimplePendable( + ... type='subscription', + ... address='aperson@example.com', + ... realname='Anne Person', + ... language='en', + ... password='xyz') + >>> token = pendingdb.add(subscription) + >>> flush() + >>> len(token) + 40 + +There's not much you can do with tokens except to 'confirm' them, which +basically means returning the IPendable structure (as a dict) from the +database that matches the token. If the token isn't in the database, None is +returned. + + >>> pendable = pendingdb.confirm('missing') + >>> print pendable + None + >>> pendable = pendingdb.confirm(token) + >>> flush() + >>> sorted(pendable.items()) + [('address', 'aperson@example.com'), + ('language', 'en'), + ('password', 'xyz'), + ('realname', 'Anne Person'), + ('type', 'subscription')] + +After confirmation, the token is no longer in the database. + + >>> pendable = pendingdb.confirm(token) + >>> print pendable + None + +There are a few other things you can do with the pending database. When you +confirm a token, you can leave it in the database, or in otherwords, not +expunge it. + + >>> event_1 = SimplePendable(type='one') + >>> token_1 = pendingdb.add(event_1) + >>> event_2 = SimplePendable(type='two') + >>> token_2 = pendingdb.add(event_2) + >>> event_3 = SimplePendable(type='three') + >>> token_3 = pendingdb.add(event_3) + >>> flush() + >>> pendable = pendingdb.confirm(token_1, expunge=False) + >>> flush() + >>> pendable.items() + [('type', 'one')] + >>> pendable = pendingdb.confirm(token_1, expunge=True) + >>> flush() + >>> pendable.items() + [('type', 'one')] + >>> pendable = pendingdb.confirm(token_1) + >>> flush() + >>> print pendable + None + +An event can be given a lifetime when it is pended, otherwise it just uses a +default lifetime. + + >>> from datetime import timedelta + >>> yesterday = timedelta(days=-1) + >>> event_4 = SimplePendable(type='four') + >>> token_4 = pendingdb.add(event_4, lifetime=yesterday) + >>> flush() + +Every once in a while the pending database is cleared of old records. + + >>> pendingdb.evict() + >>> flush() + >>> pendable = pendingdb.confirm(token_4) + >>> print pendable + None + >>> pendable = pendingdb.confirm(token_2) + >>> pendable.items() + [('type', 'two')] + >>> flush() diff --git a/Mailman/docs/registration.txt b/Mailman/docs/registration.txt new file mode 100644 index 000000000..1d59f184b --- /dev/null +++ b/Mailman/docs/registration.txt @@ -0,0 +1,384 @@ +Address registration +==================== + +When a user wants to join a mailing list -- any mailing list -- in the running +instance, he or she must first register with Mailman. The only thing they +must supply is an email address, although there is additional information they +may supply. All registered email addresses must be verified before Mailman +will send them any list traffic. + + >>> from Mailman.app.registrar import Registrar + >>> from Mailman.configuration import config + >>> from Mailman.database import flush + >>> from Mailman.interfaces import IRegistrar + +The IUserManager manages users, but it does so at a fairly low level. +Specifically, it does not handle verifications, email address syntax validity +checks, etc. The IRegistrar is the interface to the object handling all this +stuff. + +Create a dummy domain, which will provide the context for the verification +email message. + + >>> from zope.interface import implements + >>> from Mailman.interfaces import IDomain + >>> class TestDomain(object): + ... implements(IDomain) + ... def __init__(self): + ... self.domain_name = 'example.com' + ... self.description = 'mail.example.com' + ... self.contact_address = 'postmaster@mail.example.com' + ... self.base_url = 'http://mail.example.com' + ... def confirm_address(self, token=''): + ... return 'confirm+%s@example.com' % token + ... def confirm_url(self, token=''): + ... return self.base_url + '/confirm/' + token + ... def __conform__(self, protocol): + ... if protocol is IRegistrar: + ... return Registrar(self) + ... return None + >>> domain = TestDomain() + +Get a registrar by adapting a context to the interface. + + >>> from zope.interface.verify import verifyObject + >>> registrar = IRegistrar(domain) + >>> verifyObject(IRegistrar, registrar) + True + +Here is a helper function to check the token strings. + + >>> def check_token(token): + ... assert isinstance(token, basestring), 'Not a string' + ... assert len(token) == 40, 'Unexpected length: %d' % len(token) + ... assert token.isalnum(), 'Not alphanumeric' + ... print 'ok' + +Here is a helper function to extract tokens from confirmation messages. + + >>> import re + >>> cre = re.compile('http://mail.example.com/confirm/(.*)') + >>> def extract_token(msg): + ... mo = cre.search(qmsg.get_payload()) + ... return mo.group(1) + + +Invalid email addresses +----------------------- + +The only piece of information you need to register is the email address. +Some amount of sanity checks are performed on the email address, although +honestly, not as much as probably should be done. Still, some patently bad +addresses are rejected outright. + + >>> registrar.register('') + Traceback (most recent call last): + ... + InvalidEmailAddress: '' + >>> registrar.register('some name@example.com') + Traceback (most recent call last): + ... + InvalidEmailAddress: 'some name@example.com' + >>> registrar.register('<script>@example.com') + Traceback (most recent call last): + ... + InvalidEmailAddress: '<script>@example.com' + >>> registrar.register('\xa0@example.com') + Traceback (most recent call last): + ... + InvalidEmailAddress: '\xa0@example.com' + >>> registrar.register('noatsign') + Traceback (most recent call last): + ... + InvalidEmailAddress: 'noatsign' + >>> registrar.register('nodom@ain') + Traceback (most recent call last): + ... + InvalidEmailAddress: 'nodom@ain' + + +Register an email address +------------------------- + +Registration of an unknown address creates nothing until the confirmation step +is complete. No IUser or IAddress is created at registration time, but a +record is added to the pending database, and the token for that record is +returned. + + >>> token = registrar.register('aperson@example.com', 'Anne Person') + >>> flush() + >>> check_token(token) + ok + +There should be no records in the user manager for this address yet. + + >>> usermgr = config.user_manager + >>> print usermgr.get_user('aperson@example.com') + None + >>> print usermgr.get_address('aperson@example.com') + None + +But this address is waiting for confirmation. + + >>> from Mailman.interfaces import IPending + >>> pendingdb = IPending(config.db) + >>> sorted(pendingdb.confirm(token, expunge=False).items()) + [('address', 'aperson@example.com'), + ('real_name', 'Anne Person'), + ('type', 'registration')] + + +Verification by email +--------------------- + +There is also a verification email sitting in the virgin queue now. This +message is sent to the user in order to verify the registered address. + + >>> from Mailman.Queue.Switchboard import Switchboard + >>> switchboard = Switchboard(config.VIRGINQUEUE_DIR) + >>> len(switchboard.files) + 1 + >>> filebase = switchboard.files[0] + >>> qmsg, qdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> print qmsg.as_string() + MIME-Version: 1.0 + Content-Type: text/plain; charset="us-ascii" + Content-Transfer-Encoding: 7bit + Subject: confirm ... + From: confirm+...@example.com + To: aperson@example.com + Message-ID: <...> + Date: ... + Precedence: bulk + <BLANKLINE> + Email Address Registration Confirmation + <BLANKLINE> + Hello, this is the GNU Mailman server at example.com. + <BLANKLINE> + We have received a registration request for the email address + <BLANKLINE> + aperson@example.com + <BLANKLINE> + Before you can start using GNU Mailman at this site, you must first + confirm that this is your email address. You can do this by replying to + this message, keeping the Subject header intact. Or you can visit this + web page + <BLANKLINE> + http://mail.example.com/confirm/... + <BLANKLINE> + If you do not wish to register this email address simply disregard this + message. If you think you are being maliciously subscribed to the list, or + have any other questions, you may contact + <BLANKLINE> + postmaster@mail.example.com + <BLANKLINE> + >>> sorted(qdata.items()) + [('_parsemsg', False), + ('nodecorate', True), + ('received_time', ...), + ('recips', ['aperson@example.com']), + ('reduced_list_headers', True), + ('version', 3)] + +The confirmation token shows up in several places, each of which provides an +easy way for the user to complete the confirmation. The token will always +appear in a URL in the body of the message. + + >>> sent_token = extract_token(qmsg) + >>> sent_token == token + True + +The same token will appear in the From header. + + >>> qmsg['from'] == 'confirm+' + token + '@example.com' + True + +It will also appear in the Subject header. + + >>> qmsg['subject'] == 'confirm ' + token + True + +The user would then validate their just registered address by clicking on a +url or responding to the message. Either way, the confirmation process +extracts the token and uses that to confirm the pending registration. + + >>> registrar.confirm(token) + True + >>> flush() + +Now, there is an IAddress in the database matching the address, as well as an +IUser linked to this address. The IAddress is verified. + + >>> found_address = usermgr.get_address('aperson@example.com') + >>> found_address + <Address: Anne Person <aperson@example.com> [verified] at ...> + >>> found_user = usermgr.get_user('aperson@example.com') + >>> found_user + <User "Anne Person" at ...> + >>> found_user.controls(found_address.address) + True + >>> from datetime import datetime + >>> isinstance(found_address.verified_on, datetime) + True + + +Non-standard registrations +-------------------------- + +If you try to confirm a registration token twice, of course only the first one +will work. The second one is ignored. + + >>> token = registrar.register('bperson@example.com') + >>> flush() + >>> check_token(token) + ok + >>> filebase = switchboard.files[0] + >>> qmsg, qdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> sent_token = extract_token(qmsg) + >>> token == sent_token + True + >>> registrar.confirm(token) + True + >>> flush() + >>> registrar.confirm(token) + False + +If an address is in the system, but that address is not linked to a user yet +and the address is not yet validated, then no user is created until the +confirmation step is completed. + + >>> usermgr.create_address('cperson@example.com') + <Address: cperson@example.com [not verified] at ...> + >>> flush() + >>> token = registrar.register('cperson@example.com', 'Claire Person') + >>> flush() + >>> print usermgr.get_user('cperson@example.com') + None + >>> filebase = switchboard.files[0] + >>> qmsg, qdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> registrar.confirm(token) + True + >>> flush() + >>> usermgr.get_user('cperson@example.com') + <User "Claire Person" at ...> + >>> usermgr.get_address('cperson@example.com') + <Address: cperson@example.com [verified] at ...> + +If an address being registered has already been verified, linked or not to a +user, then registration sends no confirmation. + + >>> print registrar.register('cperson@example.com') + None + >>> len(switchboard.files) + 0 + +But if the already verified address is not linked to a user, then a user is +created now and they are linked, with no confirmation necessary. + + >>> address = usermgr.create_address('dperson@example.com', 'Dave Person') + >>> address.verified_on = datetime.now() + >>> flush() + >>> print usermgr.get_user('dperson@example.com') + None + >>> print registrar.register('dperson@example.com') + None + >>> flush() + >>> len(switchboard.files) + 0 + >>> usermgr.get_user('dperson@example.com') + <User "Dave Person" at ...> + + +Discarding +---------- + +A confirmation token can also be discarded, say if the user changes his or her +mind about registering. When discarded, no IAddress or IUser is created. + + >>> token = registrar.register('eperson@example.com', 'Elly Person') + >>> check_token(token) + ok + >>> flush() + >>> registrar.discard(token) + >>> flush() + >>> print pendingdb.confirm(token) + None + >>> print usermgr.get_address('eperson@example.com') + None + >>> print usermgr.get_user('eperson@example.com') + None + + +Registering a new address for an existing user +---------------------------------------------- + +When a new address for an existing user is registered, there isn't too much +different except that the new address will still need to be verified before it +can be used. + + >>> dperson = usermgr.get_user('dperson@example.com') + >>> dperson + <User "Dave Person" at ...> + >>> from operator import attrgetter + >>> sorted((addr for addr in dperson.addresses), key=attrgetter('address')) + [<Address: Dave Person <dperson@example.com> [verified] at ...>] + >>> dperson.register('david.person@example.com', 'David Person') + <Address: David Person <david.person@example.com> [not verified] at ...> + >>> flush() + >>> token = registrar.register('david.person@example.com') + >>> flush() + >>> filebase = switchboard.files[0] + >>> qmsg, qdata = switchboard.dequeue(filebase) + >>> switchboard.finish(filebase) + >>> registrar.confirm(token) + True + >>> user = usermgr.get_user('david.person@example.com') + >>> user is dperson + True + >>> user + <User "Dave Person" at ...> + >>> sorted((addr for addr in user.addresses), key=attrgetter('address')) + [<Address: David Person <david.person@example.com> [verified] at ...>, + <Address: Dave Person <dperson@example.com> [verified] at ...>] + + +Corner cases +------------ + +If you try to confirm a token that doesn't exist in the pending database, the +confirm method will just return None. + + >>> registrar.confirm('no token') + False + +Likewise, if you try to confirm, through the IUserRegistrar interface, a token +that doesn't match a registration even, you will get None. However, the +pending even matched with that token will still be removed. + + >>> from Mailman.interfaces import IPendable, IPending + >>> pendingdb = IPending(config.db) + >>> class SimplePendable(dict): + ... implements(IPendable) + >>> pendable = SimplePendable(type='foo', bar='baz') + >>> token = pendingdb.add(pendable) + >>> flush() + >>> registrar.confirm(token) + False + >>> flush() + >>> print pendingdb.confirm(token) + None + +If somehow the pending registration event doesn't have an address in its +record, you will also get None back, and the record will be removed. + + >>> pendable = SimplePendable(type='registration', foo='bar') + >>> token = pendingdb.add(pendable) + >>> flush() + >>> registrar.confirm(token) + False + >>> flush() + >>> print pendingdb.confirm(token) + None diff --git a/Mailman/initialize.py b/Mailman/initialize.py index 9dee94cbe..9d16600ef 100644 --- a/Mailman/initialize.py +++ b/Mailman/initialize.py @@ -26,12 +26,17 @@ by the command line arguments. import os import sys +import pkg_resources + +from zope.interface.verify import verifyObject import Mailman.configuration -import Mailman.database import Mailman.ext import Mailman.loginit +from Mailman.interfaces import ( + IDatabase, IListManager, IMessageStore, IUserManager) + DOT = '.' @@ -55,17 +60,46 @@ def initialize_1(config, propagate_logs): 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(): - Mailman.database.initialize() + # Find all declared entry points in the mailman.database group. There + # must be exactly one or two such entry points defined. If there are two, + # then we remove the one called 'stock' since that's the one that we + # distribute and it's obviously being overridden. If we're still left + # with more than one after we filter out the stock one, it is an error. + entrypoints = list(pkg_resources.iter_entry_points('mailman.database')) + if len(entrypoints) == 0: + raise RuntimeError('No database entry points found') + elif len(entrypoints) == 1: + # Okay, this is the one to use. + entrypoint = entrypoints[0] + elif len(database) == 2: + # Find the one /not/ named 'stock'. + entrypoints = [ep for ep in entrypoints if ep.name <> 'stock'] + if len(entrypoints) == 0: + raise RuntimeError('No database entry points found') + elif len(entrypoints) == 2: + raise RuntimeError('Too many database entry points defined') + else: + assert len(entrypoints) == 1, 'Insanity' + entrypoint = entrypoint[0] + else: + raise RuntimeError('Too many database entry points defined') + # Instantiate the database entry point, ensure that it's of the right + # type, and initialize it. Then stash the object on our configuration + # object. + ep_object = entrypoint.load() + db = ep_object() + verifyObject(IDatabase, db) + db.initialize() + Mailman.configuration.config.db = db + verifyObject(IListManager, db.list_manager) + Mailman.configuration.config.list_manager = db.list_manager + verifyObject(IUserManager, db.user_manager) + Mailman.configuration.config.user_manager = db.user_manager + verifyObject(IMessageStore, db.message_store) + Mailman.configuration.config.message_store = db.message_store def initialize(config=None, propagate_logs=False): diff --git a/Mailman/interfaces/address.py b/Mailman/interfaces/address.py index 8a6e42c60..6b00d7915 100644 --- a/Mailman/interfaces/address.py +++ b/Mailman/interfaces/address.py @@ -51,7 +51,7 @@ class IAddress(Interface): been implicitly registered, e.g. by showing up in a non-member posting.""") - validated_on = Attribute( + verified_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/database.py b/Mailman/interfaces/database.py new file mode 100644 index 000000000..86bd54ce6 --- /dev/null +++ b/Mailman/interfaces/database.py @@ -0,0 +1,51 @@ +# 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 database interaction. + +By providing an object with this interface and declaring it in a package +setup.py file as an entry point in the 'mailman.database' group with the name +'initializer', you can distribute entirely different database layers for +Mailman's back end. +""" + +from zope.interface import Interface, Attribute + + + +class IDatabase(Interface): + """Database layer interface.""" + + def initialize(): + """Initialize the database layer, using whatever means necessary.""" + + def flush(): + """Flush current database changes.""" + + # XXX Eventually we probably need to support a transaction manager + # interface, e.g. begin(), commit(), abort(). We will probably also need + # to support a shutdown() method for cleanly disconnecting from the + # database.sy + + list_manager = Attribute( + """The IListManager instance provided by the database layer.""") + + user_manager = Attribute( + """The IUserManager instance provided by the database layer.""") + + message_store = Attribute( + """The IMessageStore instance provided by the database layer.""") diff --git a/Mailman/interfaces/domain.py b/Mailman/interfaces/domain.py new file mode 100644 index 000000000..db72e95db --- /dev/null +++ b/Mailman/interfaces/domain.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 representing domains.""" + +from zope.interface import Interface, Attribute + + + +class IDomain(Interface): + """Interface representing domains.""" + + domain_name = Attribute( + """The domain's name, e.g. python.org.""") + + description = Attribute( + """The human readable description of the domain name. + + E.g. Python Dot Org or mail.python.org. + """) + + contact_address = Attribute( + """The contact address for the human at this domain. + + E.g. postmaster@python.org. + """) + + base_url = Attribute( + """The base url for the Mailman server at this domain. + + E.g. https://mail.python.org + """) + + def confirm_address(token=''): + """The address used for various forms of email confirmation.""" + + def confirm_url(token=''): + """The url used for various forms of confirmation.""" + diff --git a/Mailman/interfaces/pending.py b/Mailman/interfaces/pending.py new file mode 100644 index 000000000..5e26fbf6e --- /dev/null +++ b/Mailman/interfaces/pending.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. + +"""Interfaces for the pending database.""" + +from munepy import Enum +from zope.interface import Interface, Attribute + + + +class IPendable(Interface): + """A pendable object.""" + + def keys(): + """The keys of the pending event data, all of which are strings.""" + + def values(): + """The values of the pending event data, all of which are strings.""" + + def items(): + """The key/value pairs of the pending event data. + + Both the keys and values must be strings. + """ + + + +class IPending(Interface): + """Interface to pending database.""" + + def add(pendable, lifetime=None): + """Create a new entry in the pending database, returning a token. + + :param pendable: The IPendable instance to add. + :param lifetime: The amount of time, as a `datetime.timedelta` that + the pended item should remain in the database. When None is + given, a system default maximum lifetime is used. + :return: A token string for inclusion in urls and email confirmations. + """ + + def confirm(token, expunge=True): + """Return the IPendable matching the token. + + :param token: The token string for the IPendable given by the `.add()` + method. + :param expunge: A flag indicating whether the pendable record should + also be removed from the database or not. + :return: The matching IPendable or None if no match was found. + """ + + def evict(): + """Remove all pended items whose lifetime has expired.""" diff --git a/Mailman/interfaces/registrar.py b/Mailman/interfaces/registrar.py new file mode 100644 index 000000000..b86764cdd --- /dev/null +++ b/Mailman/interfaces/registrar.py @@ -0,0 +1,75 @@ +# 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 registration service. + +This is a higher level interface to user registration, address confirmation, +etc. than the IUserManager. The latter does no validation, syntax checking, +or confirmation, while this interface does. +""" + +from zope.interface import Interface, Attribute + + + +class IRegistrar(Interface): + """Interface for registering and verifying addresses and users. + + This is a higher level interface to user registration, address + confirmation, etc. than the IUserManager. The latter does no validation, + syntax checking, or confirmation, while this interface does. + """ + + def register(address, real_name=None): + """Register the email address, requesting verification. + + No IAddress or IUser is created during this step, but after successful + confirmation, it is guaranteed that an IAddress with a linked IUser + will exist. When a verified IAddress matching address already exists, + this method will do nothing, except link a new IUser to the IAddress + if one is not yet associated with the address. + + In all cases, the email address is sanity checked for validity first. + + :param address: The textual email address to register. + :param real_name: The optional real name of the user. + :return: The confirmation token string. + :raises InvalidEmailAddress: if the address is not allowed. + """ + + def confirm(token): + """Confirm the pending registration matched to the given `token`. + + Confirmation ensures that the IAddress exists and is linked to an + IUser, with the latter being created and linked if necessary. + + :param token: A token matching a pending event with a type of + 'registration'. + :return: Boolean indicating whether the confirmation succeeded or + not. It may fail if the token is no longer in the database, or if + the token did not match a registration event. + """ + + def discard(token): + """Discard the pending registration matched to the given `token`. + + The event record is discarded and the IAddress is not verified. No + IUser is created. + + :param token: A token matching a pending event with a type of + 'registration'. + """ diff --git a/Mailman/templates/en/__init__.py b/Mailman/templates/en/__init__.py new file mode 100755 index 000000000..e69de29bb --- /dev/null +++ b/Mailman/templates/en/__init__.py diff --git a/Mailman/templates/en/verify.txt b/Mailman/templates/en/verify.txt index 8e767f072..d02cb462b 100644 --- a/Mailman/templates/en/verify.txt +++ b/Mailman/templates/en/verify.txt @@ -1,22 +1,19 @@ -Mailing list subscription confirmation notice for mailing list %(listname)s +Email Address Registration Confirmation -We have received a request%(remote)s for subscription of your email -address, "%(email)s", to the %(listaddr)s mailing list. To confirm -that you want to be added to this mailing list, simply reply to this -message, keeping the Subject: header intact. Or visit this web page: +Hello, this is the GNU Mailman server at ${domain_name}. - %(confirmurl)s +We have received a registration request for the email address -Or include the following line -- and only the following line -- in a -message to %(requestaddr)s: + $email_address - confirm %(cookie)s +Before you can start using GNU Mailman at this site, you must first confirm +that this is your email address. You can do this by replying to this message, +keeping the Subject header intact. Or you can visit this web page -Note that simply sending a `reply' to this message should work from -most mail readers, since that usually leaves the Subject: line in -the right form (additional "Re:" text in the Subject: is okay). + $confirm_url -If you do not wish to be subscribed to this list, please simply -disregard this message. If you think you are being maliciously -subscribed to the list, or have any other questions, send them to -%(listadmin)s. +If you do not wish to register this email address simply disregard this +message. If you think you are being maliciously subscribed to the list, or +have any other questions, you may contact + + $contact_address @@ -82,9 +82,9 @@ Any other spelling is incorrect.""", include_package_data = True, entry_points = { 'console_scripts': list(scripts), - 'setuptools.file_finders': [ - 'bzr = setuptoolsbzr:find_files_for_bzr', - ], + 'setuptools.file_finders': 'bzr = setuptoolsbzr:find_files_for_bzr', + # Entry point for plugging in different database backends. + 'mailman.database': 'stock = Mailman.database:StockDatabase', }, # Third-party requirements. install_requires = [ |
