summaryrefslogtreecommitdiff
path: root/src/mailman/docs
diff options
context:
space:
mode:
authorBarry Warsaw2009-01-25 13:01:41 -0500
committerBarry Warsaw2009-01-25 13:01:41 -0500
commiteefd06f1b88b8ecbb23a9013cd223b72ca85c20d (patch)
tree72c947fe16fce0e07e996ee74020b26585d7e846 /src/mailman/docs
parent07871212f74498abd56bef3919bf3e029eb8b930 (diff)
downloadmailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.gz
mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.tar.zst
mailman-eefd06f1b88b8ecbb23a9013cd223b72ca85c20d.zip
Diffstat (limited to 'src/mailman/docs')
-rw-r--r--src/mailman/docs/__init__.py0
-rw-r--r--src/mailman/docs/addresses.txt231
-rw-r--r--src/mailman/docs/archivers.txt184
-rw-r--r--src/mailman/docs/bounces.txt107
-rw-r--r--src/mailman/docs/chains.txt345
-rw-r--r--src/mailman/docs/domains.txt46
-rw-r--r--src/mailman/docs/languages.txt104
-rw-r--r--src/mailman/docs/lifecycle.txt136
-rw-r--r--src/mailman/docs/listmanager.txt88
-rw-r--r--src/mailman/docs/membership.txt230
-rw-r--r--src/mailman/docs/message.txt48
-rw-r--r--src/mailman/docs/messagestore.txt113
-rw-r--r--src/mailman/docs/mlist-addresses.txt76
-rw-r--r--src/mailman/docs/pending.txt94
-rw-r--r--src/mailman/docs/pipelines.txt186
-rw-r--r--src/mailman/docs/registration.txt362
-rw-r--r--src/mailman/docs/requests.txt883
-rw-r--r--src/mailman/docs/styles.txt156
-rw-r--r--src/mailman/docs/usermanager.txt124
-rw-r--r--src/mailman/docs/users.txt195
20 files changed, 3708 insertions, 0 deletions
diff --git a/src/mailman/docs/__init__.py b/src/mailman/docs/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/mailman/docs/__init__.py
diff --git a/src/mailman/docs/addresses.txt b/src/mailman/docs/addresses.txt
new file mode 100644
index 000000000..9eccb2673
--- /dev/null
+++ b/src/mailman/docs/addresses.txt
@@ -0,0 +1,231 @@
+Email addresses
+===============
+
+Addresses represent a text email address, along with some meta data about
+those addresses, such as their registration date, and whether and when they've
+been validated. Addresses may be linked to the users that Mailman knows
+about. Addresses are subscribed to mailing lists though members.
+
+ >>> usermgr = config.db.user_manager
+
+
+Creating addresses
+------------------
+
+Addresses are created directly through the user manager, which starts out with
+no addresses.
+
+ >>> sorted(address.address for address in usermgr.addresses)
+ []
+
+Creating an unlinked email address is straightforward.
+
+ >>> address_1 = usermgr.create_address(u'aperson@example.com')
+ >>> sorted(address.address for address in usermgr.addresses)
+ [u'aperson@example.com']
+
+However, such addresses have no real name.
+
+ >>> address_1.real_name
+ u''
+
+You can also create an email address object with a real name.
+
+ >>> address_2 = usermgr.create_address(
+ ... u'bperson@example.com', u'Ben Person')
+ >>> sorted(address.address for address in usermgr.addresses)
+ [u'aperson@example.com', u'bperson@example.com']
+ >>> sorted(address.real_name for address in usermgr.addresses)
+ [u'', u'Ben Person']
+
+The str() of the address is the RFC 2822 preferred originator format, while
+the repr() carries more information.
+
+ >>> str(address_2)
+ 'Ben Person <bperson@example.com>'
+ >>> repr(address_2)
+ '<Address: Ben Person <bperson@example.com> [not verified] at 0x...>'
+
+You can assign real names to existing addresses.
+
+ >>> address_1.real_name = u'Anne Person'
+ >>> sorted(address.real_name for address in usermgr.addresses)
+ [u'Anne Person', u'Ben Person']
+
+These addresses are not linked to users, and can be seen by searching the user
+manager for an associated user.
+
+ >>> print usermgr.get_user(u'aperson@example.com')
+ None
+ >>> print usermgr.get_user(u'bperson@example.com')
+ None
+
+You can create email addresses that are linked to users by using a different
+interface.
+
+ >>> user_1 = usermgr.create_user(u'cperson@example.com', u'Claire Person')
+ >>> sorted(address.address for address in user_1.addresses)
+ [u'cperson@example.com']
+ >>> sorted(address.address for address in usermgr.addresses)
+ [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com']
+ >>> sorted(address.real_name for address in usermgr.addresses)
+ [u'Anne Person', u'Ben Person', u'Claire Person']
+
+And now you can find the associated user.
+
+ >>> print usermgr.get_user(u'aperson@example.com')
+ None
+ >>> print usermgr.get_user(u'bperson@example.com')
+ None
+ >>> usermgr.get_user(u'cperson@example.com')
+ <User "Claire Person" at ...>
+
+
+Deleting addresses
+------------------
+
+You can remove an unlinked address from the user manager.
+
+ >>> usermgr.delete_address(address_1)
+ >>> sorted(address.address for address in usermgr.addresses)
+ [u'bperson@example.com', u'cperson@example.com']
+ >>> sorted(address.real_name for address in usermgr.addresses)
+ [u'Ben Person', u'Claire Person']
+
+Deleting a linked address does not delete the user, but it does unlink the
+address from the user.
+
+ >>> sorted(address.address for address in user_1.addresses)
+ [u'cperson@example.com']
+ >>> user_1.controls(u'cperson@example.com')
+ True
+ >>> address_3 = list(user_1.addresses)[0]
+ >>> usermgr.delete_address(address_3)
+ >>> sorted(address.address for address in user_1.addresses)
+ []
+ >>> user_1.controls(u'cperson@example.com')
+ False
+ >>> sorted(address.address for address in usermgr.addresses)
+ [u'bperson@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 is set by default.
+
+ >>> address_4 = usermgr.create_address(
+ ... u'dperson@example.com', u'Dan Person')
+ >>> print address_4.registered_on
+ None
+ >>> print address_4.verified_on
+ None
+
+The registered date takes a Python datetime object.
+
+ >>> from datetime import datetime
+ >>> address_4.registered_on = datetime(2007, 5, 8, 22, 54, 1)
+ >>> print address_4.registered_on
+ 2007-05-08 22:54:01
+ >>> print address_4.verified_on
+ None
+
+And of course, you can also set the validation date.
+
+ >>> address_4.verified_on = datetime(2007, 5, 13, 22, 54, 1)
+ >>> print address_4.registered_on
+ 2007-05-08 22:54:01
+ >>> print address_4.verified_on
+ 2007-05-13 22:54:01
+
+
+Subscriptions
+-------------
+
+Addresses get subscribed to mailing lists, not users. When the address is
+subscribed, a role is specified.
+
+ >>> address_5 = usermgr.create_address(
+ ... u'eperson@example.com', u'Elly Person')
+ >>> mlist = config.db.list_manager.create(u'_xtext@example.com')
+ >>> from mailman.interfaces.member import MemberRole
+ >>> address_5.subscribe(mlist, MemberRole.owner)
+ <Member: Elly Person <eperson@example.com> on
+ _xtext@example.com as MemberRole.owner>
+ >>> address_5.subscribe(mlist, MemberRole.member)
+ <Member: Elly Person <eperson@example.com> on
+ _xtext@example.com as MemberRole.member>
+
+Now Elly is both an owner and a member of the mailing list.
+
+ >>> sorted(mlist.owners.members)
+ [<Member: Elly Person <eperson@example.com> on
+ _xtext@example.com as MemberRole.owner>]
+ >>> sorted(mlist.moderators.members)
+ []
+ >>> sorted(mlist.administrators.members)
+ [<Member: Elly Person <eperson@example.com> on
+ _xtext@example.com as MemberRole.owner>]
+ >>> sorted(mlist.members.members)
+ [<Member: Elly Person <eperson@example.com> on
+ _xtext@example.com as MemberRole.member>]
+ >>> sorted(mlist.regular_members.members)
+ [<Member: Elly Person <eperson@example.com> on
+ _xtext@example.com as MemberRole.member>]
+ >>> sorted(mlist.digest_members.members)
+ []
+
+
+Case-preserved addresses
+------------------------
+
+Technically speaking, email addresses are case sensitive in the local part.
+Mailman preserves the case of addresses and uses the case preserved version
+when sending the user a message, but it treats addresses that are different in
+case equivalently in all other situations.
+
+ >>> address_6 = usermgr.create_address(
+ ... u'FPERSON@example.com', u'Frank Person')
+
+The str() of such an address prints the RFC 2822 preferred originator format
+with the original case-preserved address. The repr() contains all the gory
+details.
+
+ >>> str(address_6)
+ 'Frank Person <FPERSON@example.com>'
+ >>> repr(address_6)
+ '<Address: Frank Person <FPERSON@example.com> [not verified]
+ key: fperson@example.com at 0x...>'
+
+Both the case-insensitive version of the address and the original
+case-preserved version are available on attributes of the IAddress object.
+
+ >>> address_6.address
+ u'fperson@example.com'
+ >>> address_6.original_address
+ u'FPERSON@example.com'
+
+Because addresses are case-insensitive for all other purposes, you cannot
+create an address that differs only in case.
+
+ >>> usermgr.create_address(u'fperson@example.com')
+ Traceback (most recent call last):
+ ...
+ ExistingAddressError: FPERSON@example.com
+ >>> usermgr.create_address(u'fperson@EXAMPLE.COM')
+ Traceback (most recent call last):
+ ...
+ ExistingAddressError: FPERSON@example.com
+ >>> usermgr.create_address(u'FPERSON@example.com')
+ Traceback (most recent call last):
+ ...
+ ExistingAddressError: FPERSON@example.com
+
+You can get the address using either the lower cased version or case-preserved
+version. In fact, searching for an address is case insensitive.
+
+ >>> usermgr.get_address(u'fperson@example.com').address
+ u'fperson@example.com'
+ >>> usermgr.get_address(u'FPERSON@example.com').address
+ u'fperson@example.com'
diff --git a/src/mailman/docs/archivers.txt b/src/mailman/docs/archivers.txt
new file mode 100644
index 000000000..ef36a25ac
--- /dev/null
+++ b/src/mailman/docs/archivers.txt
@@ -0,0 +1,184 @@
+Archivers
+=========
+
+Mailman supports pluggable archivers, and it comes with several default
+archivers.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'test@example.com')
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.org
+ ... To: test@example.com
+ ... Subject: An archived message
+ ... Message-ID: <12345>
+ ...
+ ... Here is an archived message.
+ ... """)
+
+Archivers support an interface which provides the RFC 2369 List-Archive
+header, and one that provides a 'permalink' to the specific message object in
+the archive. This latter is appropriate for the message footer or for the RFC
+5064 Archived-At header.
+
+Pipermail does not support a permalink, so that interface returns None.
+Mailman defines a draft spec for how list servers and archivers can
+interoperate.
+
+ >>> archivers = {}
+ >>> from operator import attrgetter
+ >>> for archiver in sorted(config.archivers, key=attrgetter('name')):
+ ... print archiver.name
+ ... print ' ', archiver.list_url(mlist)
+ ... print ' ', archiver.permalink(mlist, msg)
+ ... archivers[archiver.name] = archiver
+ mail-archive
+ http://go.mail-archive.dev/test%40example.com
+ http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc=
+ mhonarc
+ http://lists.example.com/.../test@example.com
+ http://lists.example.com/.../RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
+ pipermail
+ http://www.example.com/pipermail/test@example.com
+ None
+ prototype
+ http://lists.example.com
+ http://lists.example.com/RSZCG7IGPHFIRW3EMTVMMDNJMNCVCOLE
+
+
+Sending the message to the archiver
+-----------------------------------
+
+The archiver is also able to archive the message.
+
+ >>> archivers['pipermail'].archive_message(mlist, msg)
+
+ >>> import os
+ >>> from mailman.interfaces.archiver import IPipermailMailingList
+ >>> pckpath = os.path.join(
+ ... IPipermailMailingList(mlist).archive_dir(),
+ ... 'pipermail.pck')
+ >>> os.path.exists(pckpath)
+ True
+
+Note however that the prototype archiver can't archive messages.
+
+ >>> archivers['prototype'].archive_message(mlist, msg)
+ Traceback (most recent call last):
+ ...
+ NotImplementedError
+
+
+The Mail-Archive.com
+--------------------
+
+The Mail-Archive <http://www.mail-archive.com> is a public archiver that can
+be used to archive message for free. Mailman comes with a plugin for this
+archiver; by enabling it messages to public lists will get sent there
+automatically.
+
+ >>> archiver = archivers['mail-archive']
+ >>> print archiver.list_url(mlist)
+ http://go.mail-archive.dev/test%40example.com
+ >>> print archiver.permalink(mlist, msg)
+ http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc=
+
+To archive the message, the archiver actually mails the message to a special
+address at the Mail-Archive.
+
+ >>> archiver.archive_message(mlist, msg)
+
+ >>> from mailman.queue.outgoing import OutgoingRunner
+ >>> from mailman.testing.helpers import make_testable_runner
+ >>> outgoing = make_testable_runner(OutgoingRunner, 'out')
+ >>> outgoing.run()
+
+ >>> from operator import itemgetter
+ >>> messages = list(smtpd.messages)
+ >>> len(messages)
+ 1
+
+ >>> print messages[0].as_string()
+ From: aperson@example.org
+ To: test@example.com
+ Subject: An archived message
+ Message-ID: <12345>
+ X-Message-ID-Hash: ZaXPPxRMM9_hFZL4vTRlQlBx8pc=
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Sender: test-bounces@example.com
+ Errors-To: test-bounces@example.com
+ X-Peer: 127.0.0.1:...
+ X-MailFrom: test-bounces@example.com
+ X-RcptTo: archive@mail-archive.dev
+ <BLANKLINE>
+ Here is an archived message.
+ _______________________________________________
+ Test mailing list
+ test@example.com
+ http://lists.example.com/listinfo/test@example.com
+
+ >>> smtpd.clear()
+
+However, if the mailing list is not public, the message will never be archived
+at this service.
+
+ >>> mlist.archive_private = True
+ >>> print archiver.list_url(mlist)
+ None
+ >>> print archiver.permalink(mlist, msg)
+ None
+ >>> archiver.archive_message(mlist, msg)
+ >>> list(smtpd.messages)
+ []
+
+Additionally, this archiver can handle malformed Message-IDs.
+
+ >>> mlist.archive_private = False
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = '12345>'
+ >>> print archiver.permalink(mlist, msg)
+ http://go.mail-archive.dev/bXvG32YzcDEIVDaDLaUSVQekfo8=
+
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = '<12345'
+ >>> print archiver.permalink(mlist, msg)
+ http://go.mail-archive.dev/9rockPrT1Mm-jOsLWS6_hseR_OY=
+
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = '12345'
+ >>> print archiver.permalink(mlist, msg)
+ http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc=
+
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = ' 12345 '
+ >>> print archiver.permalink(mlist, msg)
+ http://go.mail-archive.dev/ZaXPPxRMM9_hFZL4vTRlQlBx8pc=
+
+
+MHonArc
+-------
+
+The MHonArc archiver <http://www.mhonarc.org> is also available.
+
+ >>> archiver = archivers['mhonarc']
+ >>> print archiver.name
+ mhonarc
+
+Messages sent to a local MHonArc instance are added to its archive via a
+subprocess call.
+
+ >>> archiver.archive_message(mlist, msg)
+ >>> archive_log = open(os.path.join(config.LOG_DIR, 'archiver'))
+ >>> try:
+ ... contents = archive_log.read()
+ ... finally:
+ ... archive_log.close()
+ >>> print 'LOG:', contents
+ LOG: ... /usr/bin/mhonarc -add
+ -dbfile /.../private/test@example.com.mbox/mhonarc.db
+ -outdir /.../mhonarc/test@example.com
+ -stderr /.../logs/mhonarc
+ -stdout /.../logs/mhonarc
+ -spammode -umask 022
+ ...
diff --git a/src/mailman/docs/bounces.txt b/src/mailman/docs/bounces.txt
new file mode 100644
index 000000000..9e8bcd23b
--- /dev/null
+++ b/src/mailman/docs/bounces.txt
@@ -0,0 +1,107 @@
+Bounces
+=======
+
+An important feature of Mailman is automatic bounce process.
+
+XXX Many more converted tests go here.
+
+
+Bounces, or message rejection
+-----------------------------
+
+Mailman can also bounce messages back to the original sender. This is
+essentially equivalent to rejecting the message with notification. Mailing
+lists can bounce a message with an optional error message.
+
+ >>> mlist = config.db.list_manager.create(u'_xtest@example.com')
+ >>> mlist.preferred_language = u'en'
+
+Any message can be bounced.
+
+ >>> msg = message_from_string("""\
+ ... To: _xtest@example.com
+ ... From: aperson@example.com
+ ... Subject: Something important
+ ...
+ ... I sometimes say something important.
+ ... """)
+
+Bounce a message by passing in the original message, and an optional error
+message. The bounced message ends up in the virgin queue, awaiting sending
+to the original messageauthor.
+
+ >>> switchboard = config.switchboards['virgin']
+ >>> from mailman.app.bounces import bounce_message
+ >>> bounce_message(mlist, msg)
+ >>> len(switchboard.files)
+ 1
+ >>> filebase = switchboard.files[0]
+ >>> qmsg, qmsgdata = switchboard.dequeue(filebase)
+ >>> switchboard.finish(filebase)
+ >>> print qmsg.as_string()
+ Subject: Something important
+ From: _xtest-owner@example.com
+ To: aperson@example.com
+ MIME-Version: 1.0
+ Content-Type: multipart/mixed; boundary="..."
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ --...
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ <BLANKLINE>
+ [No bounce details are available]
+ --...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+ <BLANKLINE>
+ To: _xtest@example.com
+ From: aperson@example.com
+ Subject: Something important
+ <BLANKLINE>
+ I sometimes say something important.
+ <BLANKLINE>
+ --...--
+
+An error message can be given when the message is bounced, and this will be
+included in the payload of the text/plain part. The error message must be
+passed in as an instance of a RejectMessage exception.
+
+ >>> from mailman.core.errors import RejectMessage
+ >>> error = RejectMessage("This wasn't very important after all.")
+ >>> bounce_message(mlist, msg, error)
+ >>> len(switchboard.files)
+ 1
+ >>> filebase = switchboard.files[0]
+ >>> qmsg, qmsgdata = switchboard.dequeue(filebase)
+ >>> switchboard.finish(filebase)
+ >>> print qmsg.as_string()
+ Subject: Something important
+ From: _xtest-owner@example.com
+ To: aperson@example.com
+ MIME-Version: 1.0
+ Content-Type: multipart/mixed; boundary="..."
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ --...
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ <BLANKLINE>
+ This wasn't very important after all.
+ --...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+ <BLANKLINE>
+ To: _xtest@example.com
+ From: aperson@example.com
+ Subject: Something important
+ <BLANKLINE>
+ I sometimes say something important.
+ <BLANKLINE>
+ --...--
diff --git a/src/mailman/docs/chains.txt b/src/mailman/docs/chains.txt
new file mode 100644
index 000000000..b6e75e6e1
--- /dev/null
+++ b/src/mailman/docs/chains.txt
@@ -0,0 +1,345 @@
+Chains
+======
+
+When a new message comes into the system, Mailman uses a set of rule chains to
+decide whether the message gets posted to the list, rejected, discarded, or
+held for moderator approval.
+
+There are a number of built-in chains available that act as end-points in the
+processing of messages.
+
+
+The Discard chain
+-----------------
+
+The Discard chain simply throws the message away.
+
+ >>> from zope.interface.verify import verifyObject
+ >>> from mailman.interfaces.chain import IChain
+ >>> chain = config.chains['discard']
+ >>> verifyObject(IChain, chain)
+ True
+ >>> print chain.name
+ discard
+ >>> print chain.description
+ Discard a message and stop processing.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'_xtest@example.com')
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: _xtest@example.com
+ ... Subject: My first post
+ ... Message-ID: <first>
+ ...
+ ... An important message.
+ ... """)
+
+ >>> from mailman.core.chains import process
+
+ # XXX This checks the vette log file because there is no other evidence
+ # that this chain has done anything.
+ >>> import os
+ >>> fp = open(os.path.join(config.LOG_DIR, 'vette'))
+ >>> file_pos = fp.tell()
+ >>> process(mlist, msg, {}, 'discard')
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... DISCARD: <first>
+ <BLANKLINE>
+
+
+The Reject chain
+----------------
+
+The Reject chain bounces the message back to the original sender, and logs
+this action.
+
+ >>> chain = config.chains['reject']
+ >>> verifyObject(IChain, chain)
+ True
+ >>> print chain.name
+ reject
+ >>> print chain.description
+ Reject/bounce a message and stop processing.
+ >>> file_pos = fp.tell()
+ >>> process(mlist, msg, {}, 'reject')
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... REJECT: <first>
+
+The bounce message is now sitting in the Virgin queue.
+
+ >>> virginq = config.switchboards['virgin']
+ >>> len(virginq.files)
+ 1
+ >>> qmsg, qdata = virginq.dequeue(virginq.files[0])
+ >>> print qmsg.as_string()
+ Subject: My first post
+ From: _xtest-owner@example.com
+ To: aperson@example.com
+ ...
+ [No bounce details are available]
+ ...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+ <BLANKLINE>
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID: <first>
+ <BLANKLINE>
+ An important message.
+ <BLANKLINE>
+ ...
+
+
+The Hold Chain
+--------------
+
+The Hold chain places the message into the admin request database and
+depending on the list's settings, sends a notification to both the original
+sender and the list moderators.
+
+ >>> chain = config.chains['hold']
+ >>> verifyObject(IChain, chain)
+ True
+ >>> print chain.name
+ hold
+ >>> print chain.description
+ Hold a message and stop processing.
+
+ >>> file_pos = fp.tell()
+ >>> process(mlist, msg, {}, 'hold')
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... HOLD: _xtest@example.com post from aperson@example.com held,
+ message-id=<first>: n/a
+ <BLANKLINE>
+
+There are now two messages in the Virgin queue, one to the list moderators and
+one to the original author.
+
+ >>> len(virginq.files)
+ 2
+ >>> qfiles = []
+ >>> for filebase in virginq.files:
+ ... qmsg, qdata = virginq.dequeue(filebase)
+ ... virginq.finish(filebase)
+ ... qfiles.append(qmsg)
+ >>> from operator import itemgetter
+ >>> qfiles.sort(key=itemgetter('to'))
+
+This message is addressed to the mailing list moderators.
+
+ >>> print qfiles[0].as_string()
+ Subject: _xtest@example.com post from aperson@example.com requires approval
+ From: _xtest-owner@example.com
+ To: _xtest-owner@example.com
+ MIME-Version: 1.0
+ ...
+ As list administrator, your authorization is requested for the
+ following mailing list posting:
+ <BLANKLINE>
+ List: _xtest@example.com
+ From: aperson@example.com
+ Subject: My first post
+ Reason: XXX
+ <BLANKLINE>
+ At your convenience, visit:
+ <BLANKLINE>
+ http://lists.example.com/admindb/_xtest@example.com
+ <BLANKLINE>
+ to approve or deny the request.
+ <BLANKLINE>
+ ...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+ <BLANKLINE>
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID: <first>
+ X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW
+ <BLANKLINE>
+ An important message.
+ <BLANKLINE>
+ ...
+ Content-Type: message/rfc822
+ MIME-Version: 1.0
+ <BLANKLINE>
+ Content-Type: text/plain; charset="us-ascii"
+ MIME-Version: 1.0
+ Content-Transfer-Encoding: 7bit
+ Subject: confirm ...
+ Sender: _xtest-request@example.com
+ From: _xtest-request@example.com
+ ...
+ <BLANKLINE>
+ If you reply to this message, keeping the Subject: header intact,
+ Mailman will discard the held message. Do this if the message is
+ spam. If you reply to this message and include an Approved: header
+ with the list password in it, the message will be approved for posting
+ to the list. The Approved: header can also appear in the first line
+ of the body of the reply.
+ ...
+
+This message is addressed to the sender of the message.
+
+ >>> print qfiles[1].as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: Your message to _xtest@example.com awaits moderator approval
+ From: _xtest-bounces@example.com
+ To: aperson@example.com
+ ...
+ Your mail to '_xtest@example.com' with the subject
+ <BLANKLINE>
+ My first post
+ <BLANKLINE>
+ Is being held until the list moderator can review it for approval.
+ <BLANKLINE>
+ The reason it is being held:
+ <BLANKLINE>
+ XXX
+ <BLANKLINE>
+ Either the message will get posted to the list, or you will receive
+ notification of the moderator's decision. If you would like to cancel
+ this posting, please visit the following URL:
+ <BLANKLINE>
+ http://lists.example.com/confirm/_xtest@example.com/...
+ <BLANKLINE>
+ <BLANKLINE>
+
+In addition, the pending database is holding the original messages, waiting
+for them to be disposed of by the original author or the list moderators. The
+database is essentially a dictionary, with the keys being the randomly
+selected tokens included in the urls and the values being a 2-tuple where the
+first item is a type code and the second item is a message id.
+
+ >>> import re
+ >>> cookie = None
+ >>> for line in qfiles[1].get_payload().splitlines():
+ ... mo = re.search('confirm/[^/]+/(?P<cookie>.*)$', line)
+ ... if mo:
+ ... cookie = mo.group('cookie')
+ ... break
+ >>> assert cookie is not None, 'No confirmation token found'
+ >>> data = config.db.pendings.confirm(cookie)
+ >>> sorted(data.items())
+ [(u'id', ...), (u'type', u'held message')]
+
+The message itself is held in the message store.
+
+ >>> rkey, rdata = config.db.requests.get_list_requests(mlist).get_request(
+ ... data['id'])
+ >>> msg = config.db.message_store.get_message_by_id(
+ ... rdata['_mod_message_id'])
+ >>> print msg.as_string()
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID: <first>
+ X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW
+ <BLANKLINE>
+ An important message.
+ <BLANKLINE>
+
+
+The Accept chain
+----------------
+
+The Accept chain sends the message on the 'prep' queue, where it will be
+processed and sent on to the list membership.
+
+ >>> chain = config.chains['accept']
+ >>> verifyObject(IChain, chain)
+ True
+ >>> print chain.name
+ accept
+ >>> print chain.description
+ Accept a message.
+ >>> file_pos = fp.tell()
+ >>> process(mlist, msg, {}, 'accept')
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... ACCEPT: <first>
+
+ >>> pipelineq = config.switchboards['pipeline']
+ >>> len(pipelineq.files)
+ 1
+ >>> qmsg, qdata = pipelineq.dequeue(pipelineq.files[0])
+ >>> print qmsg.as_string()
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID: <first>
+ X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW
+ <BLANKLINE>
+ An important message.
+ <BLANKLINE>
+
+
+Run-time chains
+---------------
+
+We can also define chains at run time, and these chains can be mutated.
+Run-time chains are made up of links where each link associates both a rule
+and a 'jump'. The rule is really a rule name, which is looked up when
+needed. The jump names a chain which is jumped to if the rule matches.
+
+There is one built-in run-time chain, called appropriately 'built-in'. This
+is the default chain to use when no other input chain is defined for a mailing
+list. It runs through the default rules, providing functionality similar to
+the Hold handler from previous versions of Mailman.
+
+ >>> chain = config.chains['built-in']
+ >>> verifyObject(IChain, chain)
+ True
+ >>> print chain.name
+ built-in
+ >>> print chain.description
+ The built-in moderation chain.
+
+The previously created message is innocuous enough that it should pass through
+all default rules. This message will end up in the pipeline queue.
+
+ >>> file_pos = fp.tell()
+ >>> process(mlist, msg, {})
+ >>> fp.seek(file_pos)
+ >>> print 'LOG:', fp.read()
+ LOG: ... ACCEPT: <first>
+
+ >>> qmsg, qdata = pipelineq.dequeue(pipelineq.files[0])
+ >>> print qmsg.as_string()
+ From: aperson@example.com
+ To: _xtest@example.com
+ Subject: My first post
+ Message-ID: <first>
+ X-Message-ID-Hash: RXJU4JL6N2OUN3OYMXXPPSCR7P7JE2BW
+ X-Mailman-Rule-Misses: approved; emergency; loop; administrivia;
+ implicit-dest;
+ max-recipients; max-size; news-moderation; no-subject;
+ suspicious-header
+ <BLANKLINE>
+ An important message.
+ <BLANKLINE>
+
+In addition, the message metadata now contains lists of all rules that have
+hit and all rules that have missed.
+
+ >>> sorted(qdata['rule_hits'])
+ []
+ >>> for rule_name in sorted(qdata['rule_misses']):
+ ... print rule_name
+ administrivia
+ approved
+ emergency
+ implicit-dest
+ loop
+ max-recipients
+ max-size
+ news-moderation
+ no-subject
+ suspicious-header
diff --git a/src/mailman/docs/domains.txt b/src/mailman/docs/domains.txt
new file mode 100644
index 000000000..b71689520
--- /dev/null
+++ b/src/mailman/docs/domains.txt
@@ -0,0 +1,46 @@
+Domains
+=======
+
+Domains are how Mailman interacts with email host names and web host names.
+Generally, new domains are registered in the mailman.cfg configuration file.
+We simulate that here by pushing new configurations.
+
+ >>> config.push('example.org', """
+ ... [domain.example_dot_org]
+ ... email_host: example.org
+ ... base_url: https://mail.example.org
+ ... description: The example domain
+ ... contact_address: postmaster@mail.example.org
+ ... """)
+
+ >>> domain = config.domains['example.org']
+ >>> print domain.email_host
+ example.org
+ >>> print domain.base_url
+ https://mail.example.org
+ >>> print domain.description
+ The example domain
+ >>> print domain.contact_address
+ postmaster@mail.example.org
+ >>> print domain.url_host
+ mail.example.org
+
+
+Confirmation tokens
+-------------------
+
+Confirmation tokens can be added to either the email confirmation address...
+
+ >>> print domain.confirm_address('xyz')
+ confirm-xyz@example.org
+
+...or the confirmation url.
+
+ >>> print domain.confirm_url('abc')
+ https://mail.example.org/confirm/abc
+
+
+Clean up
+--------
+
+ >>> config.pop('example.org')
diff --git a/src/mailman/docs/languages.txt b/src/mailman/docs/languages.txt
new file mode 100644
index 000000000..775b933e8
--- /dev/null
+++ b/src/mailman/docs/languages.txt
@@ -0,0 +1,104 @@
+Languages
+=========
+
+Mailman is multilingual. A language manager handles the known set of
+languages at run time, as well as enabling those languages for use in a
+running Mailman instance.
+
+ >>> from zope.interface.verify import verifyObject
+ >>> from mailman.interfaces.languages import ILanguageManager
+ >>> from mailman.languages import LanguageManager
+ >>> mgr = LanguageManager()
+ >>> verifyObject(ILanguageManager, mgr)
+ True
+
+A language manager keeps track of the languages it knows about as well as the
+languages which are enabled. By default, none are known or enabled.
+
+ >>> sorted(mgr.known_codes)
+ []
+ >>> sorted(mgr.enabled_codes)
+ []
+
+The language manager also keeps track of information for each known language,
+but you obviously can't get information for an unknown language.
+
+ >>> mgr.get_description('en')
+ Traceback (most recent call last):
+ ...
+ KeyError: 'en'
+ >>> mgr.get_charset('en')
+ Traceback (most recent call last):
+ ...
+ KeyError: 'en'
+
+
+Adding languages
+----------------
+
+Adding a new language requires three pieces of information, the 2-character
+language code, the English description of the language, and the character set
+used by the language.
+
+ >>> mgr.add_language('en', 'English', 'us-ascii')
+ >>> mgr.add_language('it', 'Italian', 'iso-8859-1')
+
+By default, added languages are also enabled.
+
+ >>> sorted(mgr.known_codes)
+ ['en', 'it']
+ >>> sorted(mgr.enabled_codes)
+ ['en', 'it']
+
+And you can get information for all known languages.
+
+ >>> mgr.get_description('en')
+ 'English'
+ >>> mgr.get_charset('en')
+ 'us-ascii'
+ >>> mgr.get_description('it')
+ 'Italian'
+ >>> mgr.get_charset('it')
+ 'iso-8859-1'
+
+You can also add a language without enabling it.
+
+ >>> mgr.add_language('pl', 'Polish', 'iso-8859-2', enable=False)
+ >>> sorted(mgr.known_codes)
+ ['en', 'it', 'pl']
+ >>> sorted(mgr.enabled_codes)
+ ['en', 'it']
+
+You can get language data for disabled languages.
+
+ >>> mgr.get_description('pl')
+ 'Polish'
+ >>> mgr.get_charset('pl')
+ 'iso-8859-2'
+
+And of course you can enable a known language.
+
+ >>> mgr.enable_language('pl')
+ >>> sorted(mgr.enabled_codes)
+ ['en', 'it', 'pl']
+
+But you cannot enable languages that the manager does not know about.
+
+ >>> mgr.enable_language('xx')
+ Traceback (most recent call last):
+ ...
+ KeyError: 'xx'
+
+
+Other iterations
+----------------
+
+You can iterate over the descriptions (names) of all enabled languages.
+
+ >>> sorted(mgr.enabled_names)
+ ['English', 'Italian', 'Polish']
+
+You can ask whether a particular language code is enabled.
+
+ >>> 'it' in mgr.enabled_codes
+ True
diff --git a/src/mailman/docs/lifecycle.txt b/src/mailman/docs/lifecycle.txt
new file mode 100644
index 000000000..c6c0c0671
--- /dev/null
+++ b/src/mailman/docs/lifecycle.txt
@@ -0,0 +1,136 @@
+Application level list lifecycle
+--------------------------------
+
+The low-level way to create and delete a mailing list is to use the
+IListManager interface. This interface simply adds or removes the appropriate
+database entries to record the list's creation.
+
+There is a higher level interface for creating and deleting mailing lists
+which performs additional tasks such as:
+
+ * validating the list's posting address (which also serves as the list's
+ fully qualified name);
+ * ensuring that the list's domain is registered;
+ * applying all matching styles to the new list;
+ * creating and assigning list owners;
+ * notifying watchers of list creation;
+ * creating ancillary artifacts (such as the list's on-disk directory)
+
+ >>> from mailman.app.lifecycle import create_list
+
+
+Posting address validation
+--------------------------
+
+If you try to use the higher-level interface to create a mailing list with a
+bogus posting address, you get an exception.
+
+ >>> create_list('not a valid address')
+ Traceback (most recent call last):
+ ...
+ InvalidEmailAddress: 'not a valid address'
+
+If the posting address is valid, but the domain has not been registered with
+Mailman yet, you get an exception.
+
+ >>> create_list('test@example.org')
+ Traceback (most recent call last):
+ ...
+ BadDomainSpecificationError: example.org
+
+
+Creating a list applies its styles
+----------------------------------
+
+Start by registering a test style.
+
+ >>> from zope.interface import implements
+ >>> from mailman.interfaces.styles import IStyle
+ >>> class TestStyle(object):
+ ... implements(IStyle)
+ ... name = 'test'
+ ... priority = 10
+ ... def apply(self, mailing_list):
+ ... # Just does something very simple.
+ ... mailing_list.msg_footer = u'test footer'
+ ... def match(self, mailing_list, styles):
+ ... # Applies to any test list
+ ... if 'test' in mailing_list.fqdn_listname:
+ ... styles.append(self)
+
+ >>> config.style_manager.register(TestStyle())
+
+Using the higher level interface for creating a list, applies all matching
+list styles.
+
+ >>> mlist_1 = create_list(u'test_1@example.com')
+ >>> mlist_1.fqdn_listname
+ u'test_1@example.com'
+ >>> mlist_1.msg_footer
+ u'test footer'
+
+
+Creating a list with owners
+---------------------------
+
+You can also specify a list of owner email addresses. If these addresses are
+not yet known, they will be registered, and new users will be linked to them.
+However the addresses are not verified.
+
+ >>> owners = [u'aperson@example.com', u'bperson@example.com',
+ ... u'cperson@example.com', u'dperson@example.com']
+ >>> mlist_2 = create_list(u'test_2@example.com', owners)
+ >>> mlist_2.fqdn_listname
+ u'test_2@example.com'
+ >>> mlist_2.msg_footer
+ u'test footer'
+ >>> sorted(addr.address for addr in mlist_2.owners.addresses)
+ [u'aperson@example.com', u'bperson@example.com',
+ u'cperson@example.com', u'dperson@example.com']
+
+None of the owner addresses are verified.
+
+ >>> any(addr.verified_on is not None for addr in mlist_2.owners.addresses)
+ False
+
+However, all addresses are linked to users.
+
+ >>> # The owners have no names yet
+ >>> len(list(mlist_2.owners.users))
+ 4
+
+If you create a mailing list with owner addresses that are already known to
+the system, they won't be created again.
+
+ >>> usermgr = config.db.user_manager
+ >>> user_a = usermgr.get_user(u'aperson@example.com')
+ >>> user_b = usermgr.get_user(u'bperson@example.com')
+ >>> user_c = usermgr.get_user(u'cperson@example.com')
+ >>> user_d = usermgr.get_user(u'dperson@example.com')
+ >>> user_a.real_name = u'Anne Person'
+ >>> user_b.real_name = u'Bart Person'
+ >>> user_c.real_name = u'Caty Person'
+ >>> user_d.real_name = u'Dirk Person'
+
+ >>> mlist_3 = create_list(u'test_3@example.com', owners)
+ >>> sorted(user.real_name for user in mlist_3.owners.users)
+ [u'Anne Person', u'Bart Person', u'Caty Person', u'Dirk Person']
+
+
+Removing a list
+---------------
+
+Removing a mailing list deletes the list, all its subscribers, and any related
+artifacts.
+
+ >>> from mailman.app.lifecycle import remove_list
+ >>> remove_list(mlist_2.fqdn_listname, mlist_2, True)
+ >>> print config.db.list_manager.get('test_2@example.com')
+ None
+
+We should now be able to completely recreate the mailing list.
+
+ >>> mlist_2a = create_list(u'test_2@example.com', owners)
+ >>> sorted(addr.address for addr in mlist_2a.owners.addresses)
+ [u'aperson@example.com', u'bperson@example.com',
+ u'cperson@example.com', u'dperson@example.com']
diff --git a/src/mailman/docs/listmanager.txt b/src/mailman/docs/listmanager.txt
new file mode 100644
index 000000000..830f6d962
--- /dev/null
+++ b/src/mailman/docs/listmanager.txt
@@ -0,0 +1,88 @@
+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.interfaces.listmanager import IListManager
+ >>> listmgr = config.db.list_manager
+ >>> IListManager.providedBy(listmgr)
+ True
+
+
+Creating a mailing list
+-----------------------
+
+Creating the list returns the newly created IMailList object.
+
+ >>> from mailman.interfaces.mailinglist import IMailingList
+ >>> mlist = listmgr.create(u'_xtest@example.com')
+ >>> IMailingList.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
+ u'_xtest'
+ >>> mlist.host_name
+ u'example.com'
+ >>> mlist.fqdn_listname
+ u'_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 = listmgr.create(u'_xtest@example.com')
+ Traceback (most recent call last):
+ ...
+ ListAlreadyExistsError: _xtest@example.com
+
+
+Deleting a mailing list
+-----------------------
+
+Use the list manager to delete a mailing list.
+
+ >>> listmgr.delete(mlist)
+ >>> sorted(listmgr.names)
+ []
+
+After deleting the list, you can create it again.
+
+ >>> mlist = listmgr.create(u'_xtest@example.com')
+ >>> mlist.fqdn_listname
+ u'_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 = listmgr.get(u'_xtest@example.com')
+ >>> mlist_2 is mlist
+ True
+
+If you try to get a list that doesn't existing yet, you get None.
+
+ >>> print listmgr.get(u'_xtest_2@example.com')
+ None
+
+
+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 = listmgr.create(u'_xtest_3@example.com')
+ >>> mlist_4 = listmgr.create(u'_xtest_4@example.com')
+ >>> sorted(listmgr.names)
+ [u'_xtest@example.com', u'_xtest_3@example.com', u'_xtest_4@example.com']
+ >>> sorted(m.fqdn_listname for m in listmgr.mailing_lists)
+ [u'_xtest@example.com', u'_xtest_3@example.com', u'_xtest_4@example.com']
diff --git a/src/mailman/docs/membership.txt b/src/mailman/docs/membership.txt
new file mode 100644
index 000000000..7f9f16738
--- /dev/null
+++ b/src/mailman/docs/membership.txt
@@ -0,0 +1,230 @@
+List memberships
+================
+
+Users represent people in Mailman. Users control email addresses, and rosters
+are collectons of members. A member gives an email address a role, such as
+'member', 'administrator', or 'moderator'. Roster sets are collections of
+rosters and a mailing list has a single roster set that contains all its
+members, regardless of that member's role.
+
+Mailing lists and roster sets have an indirect relationship, through the
+roster set's name. Roster also have names, but are related to roster sets
+by a more direct containment relationship. This is because it is possible to
+store mailing list data in a different database than user data.
+
+When we create a mailing list, it starts out with no members...
+
+ >>> mlist = config.db.list_manager.create(u'_xtest@example.com')
+ >>> mlist
+ <mailing list "_xtest@example.com" at ...>
+ >>> sorted(member.address.address for member in mlist.members.members)
+ []
+ >>> sorted(user.real_name for user in mlist.members.users)
+ []
+ >>> sorted(address.address for member in mlist.members.addresses)
+ []
+
+...no owners...
+
+ >>> sorted(member.address.address for member in mlist.owners.members)
+ []
+ >>> sorted(user.real_name for user in mlist.owners.users)
+ []
+ >>> sorted(address.address for member in mlist.owners.addresses)
+ []
+
+...no moderators...
+
+ >>> sorted(member.address.address for member in mlist.moderators.members)
+ []
+ >>> sorted(user.real_name for user in mlist.moderators.users)
+ []
+ >>> sorted(address.address for member in mlist.moderators.addresses)
+ []
+
+...and no administrators.
+
+ >>> sorted(member.address.address
+ ... for member in mlist.administrators.members)
+ []
+ >>> sorted(user.real_name for user in mlist.administrators.users)
+ []
+ >>> sorted(address.address for member in mlist.administrators.addresses)
+ []
+
+
+
+Administrators
+--------------
+
+A mailing list's administrators are defined as union of the list's owners and
+the list's moderators. We can add new owners or moderators to this list by
+assigning roles to users. First we have to create the user, because there are
+no users in the user database yet.
+
+ >>> usermgr = config.db.user_manager
+ >>> user_1 = usermgr.create_user(u'aperson@example.com', u'Anne Person')
+ >>> user_1.real_name
+ u'Anne Person'
+ >>> sorted(address.address for address in user_1.addresses)
+ [u'aperson@example.com']
+
+We can add Anne as an owner of the mailing list, by creating a member role for
+her.
+
+ >>> from mailman.interfaces.member import MemberRole
+ >>> address_1 = list(user_1.addresses)[0]
+ >>> address_1.address
+ u'aperson@example.com'
+ >>> address_1.subscribe(mlist, MemberRole.owner)
+ <Member: Anne Person <aperson@example.com> on
+ _xtest@example.com as MemberRole.owner>
+ >>> sorted(member.address.address for member in mlist.owners.members)
+ [u'aperson@example.com']
+ >>> sorted(user.real_name for user in mlist.owners.users)
+ [u'Anne Person']
+ >>> sorted(address.address for address in mlist.owners.addresses)
+ [u'aperson@example.com']
+
+Adding Anne as a list owner also makes her an administrator, but does not make
+her a moderator. Nor does it make her a member of the list.
+
+ >>> sorted(user.real_name for user in mlist.administrators.users)
+ [u'Anne Person']
+ >>> sorted(user.real_name for user in mlist.moderators.users)
+ []
+ >>> sorted(user.real_name for user in mlist.members.users)
+ []
+
+We can add Ben as a moderator of the list, by creating a different member role
+for him.
+
+ >>> user_2 = usermgr.create_user(u'bperson@example.com', u'Ben Person')
+ >>> user_2.real_name
+ u'Ben Person'
+ >>> address_2 = list(user_2.addresses)[0]
+ >>> address_2.address
+ u'bperson@example.com'
+ >>> address_2.subscribe(mlist, MemberRole.moderator)
+ <Member: Ben Person <bperson@example.com>
+ on _xtest@example.com as MemberRole.moderator>
+ >>> sorted(member.address.address for member in mlist.moderators.members)
+ [u'bperson@example.com']
+ >>> sorted(user.real_name for user in mlist.moderators.users)
+ [u'Ben Person']
+ >>> sorted(address.address for address in mlist.moderators.addresses)
+ [u'bperson@example.com']
+
+Now, both Anne and Ben are list administrators.
+
+ >>> sorted(member.address.address
+ ... for member in mlist.administrators.members)
+ [u'aperson@example.com', u'bperson@example.com']
+ >>> sorted(user.real_name for user in mlist.administrators.users)
+ [u'Anne Person', u'Ben Person']
+ >>> sorted(address.address for address in mlist.administrators.addresses)
+ [u'aperson@example.com', u'bperson@example.com']
+
+
+Members
+-------
+
+Similarly, list members are born of users being given the proper role. It's
+more interesting here because these roles should have a preference which can
+be used to decide whether the member is to get regular delivery or digest
+delivery. Without a preference, Mailman will fall back first to the address's
+preference, then the user's preference, then the list's preference. Start
+without any member preference to see the system defaults.
+
+ >>> user_3 = usermgr.create_user(u'cperson@example.com', u'Claire Person')
+ >>> user_3.real_name
+ u'Claire Person'
+ >>> address_3 = list(user_3.addresses)[0]
+ >>> address_3.address
+ u'cperson@example.com'
+ >>> address_3.subscribe(mlist, MemberRole.member)
+ <Member: Claire Person <cperson@example.com>
+ on _xtest@example.com as MemberRole.member>
+
+Claire will be a regular delivery member but not a digest member.
+
+ >>> sorted(address.address for address in mlist.members.addresses)
+ [u'cperson@example.com']
+ >>> sorted(address.address for address in mlist.regular_members.addresses)
+ [u'cperson@example.com']
+ >>> sorted(address.address for address in mlist.digest_members.addresses)
+ []
+
+It's easy to make the list administrators members of the mailing list too.
+
+ >>> members = []
+ >>> for address in mlist.administrators.addresses:
+ ... member = address.subscribe(mlist, MemberRole.member)
+ ... members.append(member)
+ >>> sorted(members, key=lambda m: m.address.address)
+ [<Member: Anne Person <aperson@example.com> on
+ _xtest@example.com as MemberRole.member>,
+ <Member: Ben Person <bperson@example.com> on
+ _xtest@example.com as MemberRole.member>]
+ >>> sorted(address.address for address in mlist.members.addresses)
+ [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com']
+ >>> sorted(address.address for address in mlist.regular_members.addresses)
+ [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com']
+ >>> sorted(address.address for address in mlist.digest_members.addresses)
+ []
+
+
+Finding members
+---------------
+
+You can find the IMember object that is a member of a roster for a given text
+email address by using an IRoster's .get_member() method.
+
+ >>> mlist.owners.get_member(u'aperson@example.com')
+ <Member: Anne Person <aperson@example.com> on
+ _xtest@example.com as MemberRole.owner>
+ >>> mlist.administrators.get_member(u'aperson@example.com')
+ <Member: Anne Person <aperson@example.com> on
+ _xtest@example.com as MemberRole.owner>
+ >>> mlist.members.get_member(u'aperson@example.com')
+ <Member: Anne Person <aperson@example.com> on
+ _xtest@example.com as MemberRole.member>
+
+However, if the address is not subscribed with the appropriate role, then None
+is returned.
+
+ >>> print mlist.administrators.get_member(u'zperson@example.com')
+ None
+ >>> print mlist.moderators.get_member(u'aperson@example.com')
+ None
+ >>> print mlist.members.get_member(u'zperson@example.com')
+ None
+
+
+All subscribers
+---------------
+
+There is also a roster containing all the subscribers of a mailing list,
+regardless of their role.
+
+ >>> def sortkey(member):
+ ... return (member.address.address, int(member.role))
+ >>> [(member.address.address, str(member.role))
+ ... for member in sorted(mlist.subscribers.members, key=sortkey)]
+ [(u'aperson@example.com', 'MemberRole.member'),
+ (u'aperson@example.com', 'MemberRole.owner'),
+ (u'bperson@example.com', 'MemberRole.member'),
+ (u'bperson@example.com', 'MemberRole.moderator'),
+ (u'cperson@example.com', 'MemberRole.member')]
+
+
+Double subscriptions
+--------------------
+
+It is an error to subscribe someone to a list with the same role twice.
+
+ >>> address_1.subscribe(mlist, MemberRole.owner)
+ Traceback (most recent call last):
+ ...
+ AlreadySubscribedError: aperson@example.com is already a MemberRole.owner
+ of mailing list _xtest@example.com
diff --git a/src/mailman/docs/message.txt b/src/mailman/docs/message.txt
new file mode 100644
index 000000000..dab9ddf0e
--- /dev/null
+++ b/src/mailman/docs/message.txt
@@ -0,0 +1,48 @@
+Messages
+========
+
+Mailman has its own Message classes, derived from the standard
+email.message.Message class, but providing additional useful methods.
+
+
+User notifications
+------------------
+
+When Mailman needs to send a message to a user, it creates a UserNotification
+instance, and then calls the .send() method on this object. This method
+requires a mailing list instance.
+
+ >>> mlist = config.db.list_manager.create(u'_xtest@example.com')
+ >>> mlist.preferred_language = u'en'
+
+The UserNotification constructor takes the recipient address, the sender
+address, an optional subject, optional body text, and optional language.
+
+ >>> from mailman.Message import UserNotification
+ >>> msg = UserNotification(
+ ... 'aperson@example.com',
+ ... '_xtest@example.com',
+ ... 'Something you need to know',
+ ... 'I needed to tell you this.')
+ >>> msg.send(mlist)
+
+The message will end up in the virgin queue.
+
+ >>> switchboard = config.switchboards['virgin']
+ >>> len(switchboard.files)
+ 1
+ >>> filebase = switchboard.files[0]
+ >>> qmsg, qmsgdata = 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: Something you need to know
+ From: _xtest@example.com
+ To: aperson@example.com
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ I needed to tell you this.
diff --git a/src/mailman/docs/messagestore.txt b/src/mailman/docs/messagestore.txt
new file mode 100644
index 000000000..6e04568c5
--- /dev/null
+++ b/src/mailman/docs/messagestore.txt
@@ -0,0 +1,113 @@
+The message store
+=================
+
+The message store is a collection of messages keyed off of Message-ID and
+X-Message-ID-Hash headers. Either of these values can be combined with the
+message's List-Archive header to create a globally unique URI to the message
+object in the internet facing interface of the message store. The
+X-Message-ID-Hash is the Base32 SHA1 hash of the Message-ID.
+
+ >>> store = config.db.message_store
+
+If you try to add a message to the store which is missing the Message-ID
+header, you will get an exception.
+
+ >>> msg = message_from_string("""\
+ ... Subject: An important message
+ ...
+ ... This message is very important.
+ ... """)
+ >>> store.add(msg)
+ Traceback (most recent call last):
+ ...
+ ValueError: Exactly one Message-ID header required
+
+However, if the message has a Message-ID header, it can be stored.
+
+ >>> msg['Message-ID'] = '<87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp>'
+ >>> store.add(msg)
+ 'AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35'
+ >>> print msg.as_string()
+ Subject: An important message
+ Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp>
+ X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35
+ <BLANKLINE>
+ This message is very important.
+ <BLANKLINE>
+
+
+Finding messages
+----------------
+
+There are several ways to find a message given either the Message-ID or
+X-Message-ID-Hash headers. In either case, if no matching message is found,
+None is returned.
+
+ >>> print store.get_message_by_id(u'nothing')
+ None
+ >>> print store.get_message_by_hash(u'nothing')
+ None
+
+Given an existing Message-ID, the message can be found.
+
+ >>> message = store.get_message_by_id(msg['message-id'])
+ >>> print message.as_string()
+ Subject: An important message
+ Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp>
+ X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35
+ <BLANKLINE>
+ This message is very important.
+ <BLANKLINE>
+
+Similarly, we can find messages by the X-Message-ID-Hash:
+
+ >>> message = store.get_message_by_hash(msg['x-message-id-hash'])
+ >>> print message.as_string()
+ Subject: An important message
+ Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp>
+ X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35
+ <BLANKLINE>
+ This message is very important.
+ <BLANKLINE>
+
+
+Iterating over all messages
+---------------------------
+
+The message store provides a means to iterate over all the messages it
+contains.
+
+ >>> messages = list(store.messages)
+ >>> len(messages)
+ 1
+ >>> print messages[0].as_string()
+ Subject: An important message
+ Message-ID: <87myycy5eh.fsf@uwakimon.sk.tsukuba.ac.jp>
+ X-Message-ID-Hash: AGDWSNXXKCWEILKKNYTBOHRDQGOX3Y35
+ <BLANKLINE>
+ This message is very important.
+ <BLANKLINE>
+
+
+Deleting messages from the store
+--------------------------------
+
+You delete a message from the storage service by providing the Message-ID for
+the message you want to delete. If you try to delete a Message-ID that isn't
+in the store, you get an exception.
+
+ >>> store.delete_message(u'nothing')
+ Traceback (most recent call last):
+ ...
+ LookupError: nothing
+
+But if you delete an existing message, it really gets deleted.
+
+ >>> message_id = message['message-id']
+ >>> store.delete_message(message_id)
+ >>> list(store.messages)
+ []
+ >>> print store.get_message_by_id(message_id)
+ None
+ >>> print store.get_message_by_hash(message['x-message-id-hash'])
+ None
diff --git a/src/mailman/docs/mlist-addresses.txt b/src/mailman/docs/mlist-addresses.txt
new file mode 100644
index 000000000..75ec3df37
--- /dev/null
+++ b/src/mailman/docs/mlist-addresses.txt
@@ -0,0 +1,76 @@
+Mailing list addresses
+======================
+
+Every mailing list has a number of addresses which are publicly available.
+These are defined in the IMailingListAddresses interface.
+
+ >>> mlist = config.db.list_manager.create(u'_xtest@example.com')
+
+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
+ u'_xtest@example.com'
+ >>> mlist.posting_address
+ u'_xtest@example.com'
+
+Messages to the mailing list's 'no reply' address always get discarded without
+prejudice.
+
+ >>> mlist.no_reply_address
+ u'noreply@example.com'
+
+The mailing list's owner address reaches the human moderators.
+
+ >>> mlist.owner_address
+ u'_xtest-owner@example.com'
+
+The request address goes to the list's email command robot.
+
+ >>> mlist.request_address
+ u'_xtest-request@example.com'
+
+The bounces address accepts and processes all potential bounces.
+
+ >>> mlist.bounces_address
+ u'_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
+ u'_xtest-join@example.com'
+ >>> mlist.subscribe_address
+ u'_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
+ u'_xtest-leave@example.com'
+ >>> mlist.unsubscribe_address
+ u'_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')
+ u'_xtest-confirm+cookie@example.com'
+ >>> mlist.confirm_address('wookie')
+ u'_xtest-confirm+wookie@example.com'
+
+ >>> config.push('test config', """
+ ... [mta]
+ ... verp_confirm_format: $address---$cookie
+ ... """)
+ >>> mlist.confirm_address('cookie')
+ u'_xtest-confirm---cookie@example.com'
+ >>> config.pop('test config')
diff --git a/src/mailman/docs/pending.txt b/src/mailman/docs/pending.txt
new file mode 100644
index 000000000..abfba4885
--- /dev/null
+++ b/src/mailman/docs/pending.txt
@@ -0,0 +1,94 @@
+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 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.
+
+ >>> from mailman.interfaces.pending import IPendings
+ >>> pendingdb = config.db.pendings
+ >>> verifyObject(IPendings, pendingdb)
+ True
+
+The pending database can add any IPendable to the database, returning a token
+that can be used in urls and such.
+
+ >>> from mailman.interfaces.pending import IPendable
+ >>> class SimplePendable(dict):
+ ... implements(IPendable)
+ >>> subscription = SimplePendable(
+ ... type='subscription',
+ ... address='aperson@example.com',
+ ... realname='Anne Person',
+ ... language='en',
+ ... password='xyz')
+ >>> token = pendingdb.add(subscription)
+ >>> 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)
+ >>> sorted(pendable.items())
+ [(u'address', u'aperson@example.com'),
+ (u'language', u'en'),
+ (u'password', u'xyz'),
+ (u'realname', u'Anne Person'),
+ (u'type', u'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)
+ >>> pendable = pendingdb.confirm(token_1, expunge=False)
+ >>> pendable.items()
+ [(u'type', u'one')]
+ >>> pendable = pendingdb.confirm(token_1, expunge=True)
+ >>> pendable.items()
+ [(u'type', u'one')]
+ >>> pendable = pendingdb.confirm(token_1)
+ >>> 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)
+
+Every once in a while the pending database is cleared of old records.
+
+ >>> pendingdb.evict()
+ >>> pendable = pendingdb.confirm(token_4)
+ >>> print pendable
+ None
+ >>> pendable = pendingdb.confirm(token_2)
+ >>> pendable.items()
+ [(u'type', u'two')]
diff --git a/src/mailman/docs/pipelines.txt b/src/mailman/docs/pipelines.txt
new file mode 100644
index 000000000..0e6dad8e8
--- /dev/null
+++ b/src/mailman/docs/pipelines.txt
@@ -0,0 +1,186 @@
+Pipelines
+=========
+
+This runner's purpose in life is to process messages that have been accepted
+for posting, applying any modifications and also sending copies of the message
+to the archives, digests, nntp, and outgoing queues. Pipelines are named and
+consist of a sequence of handlers, each of which is applied in turn. Unlike
+rules and chains, there is no way to stop a pipeline from processing the
+message once it's started.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'xtest@example.com')
+ >>> print mlist.pipeline
+ built-in
+ >>> from mailman.core.pipelines import process
+
+
+Processing a message
+--------------------
+
+Messages hit the pipeline after they've been accepted for posting.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.com
+ ... To: xtest@example.com
+ ... Subject: My first post
+ ... Message-ID: <first>
+ ...
+ ... First post!
+ ... """)
+ >>> msgdata = {}
+ >>> process(mlist, msg, msgdata, mlist.pipeline)
+
+The message has been modified with additional headers, footer decorations,
+etc.
+
+ >>> print msg.as_string()
+ From: aperson@example.com
+ To: xtest@example.com
+ Message-ID: <first>
+ Subject: [Xtest] My first post
+ X-BeenThere: xtest@example.com
+ X-Mailman-Version: ...
+ Precedence: list
+ List-Id: <xtest.example.com>
+ X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
+ List-Post: <mailto:xtest@example.com>
+ List-Subscribe:
+ <http://lists.example.com/listinfo/xtest@example.com>,
+ <mailto:xtest-join@example.com>
+ Archived-At:
+ http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
+ List-Unsubscribe:
+ <http://lists.example.com/listinfo/xtest@example.com>,
+ <mailto:xtest-leave@example.com>
+ List-Archive: <http://lists.example.com/archives/xtest@example.com>
+ List-Help: <mailto:xtest-request@example.com?subject=help>
+ <BLANKLINE>
+ First post!
+ <BLANKLINE>
+
+And the message metadata has information about recipients and other stuff.
+However there are currently no recipients for this message.
+
+ >>> dump_msgdata(msgdata)
+ original_sender : aperson@example.com
+ origsubj : My first post
+ recips : set([])
+ stripped_subject: My first post
+
+And the message is now sitting in various other processing queues.
+
+ >>> from mailman.testing.helpers import get_queue_messages
+ >>> messages = get_queue_messages('archive')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ From: aperson@example.com
+ To: xtest@example.com
+ Message-ID: <first>
+ Subject: [Xtest] My first post
+ X-BeenThere: xtest@example.com
+ X-Mailman-Version: ...
+ Precedence: list
+ List-Id: <xtest.example.com>
+ X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
+ List-Post: <mailto:xtest@example.com>
+ List-Subscribe:
+ <http://lists.example.com/listinfo/xtest@example.com>,
+ <mailto:xtest-join@example.com>
+ Archived-At:
+ http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
+ List-Unsubscribe:
+ <http://lists.example.com/listinfo/xtest@example.com>,
+ <mailto:xtest-leave@example.com>
+ List-Archive: <http://lists.example.com/archives/xtest@example.com>
+ List-Help: <mailto:xtest-request@example.com?subject=help>
+ <BLANKLINE>
+ First post!
+ <BLANKLINE>
+ >>> dump_msgdata(messages[0].msgdata)
+ _parsemsg : False
+ original_sender : aperson@example.com
+ origsubj : My first post
+ recips : set([])
+ stripped_subject: My first post
+ version : 3
+
+This mailing list is not linked to an NNTP newsgroup, so there's nothing in
+the outgoing nntp queue.
+
+ >>> messages = get_queue_messages('news')
+ >>> len(messages)
+ 0
+
+This is the message that will actually get delivered to end recipients.
+
+ >>> messages = get_queue_messages('out')
+ >>> len(messages)
+ 1
+ >>> print messages[0].msg.as_string()
+ From: aperson@example.com
+ To: xtest@example.com
+ Message-ID: <first>
+ Subject: [Xtest] My first post
+ X-BeenThere: xtest@example.com
+ X-Mailman-Version: ...
+ Precedence: list
+ List-Id: <xtest.example.com>
+ X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
+ List-Post: <mailto:xtest@example.com>
+ List-Subscribe:
+ <http://lists.example.com/listinfo/xtest@example.com>,
+ <mailto:xtest-join@example.com>
+ Archived-At:
+ http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
+ List-Unsubscribe:
+ <http://lists.example.com/listinfo/xtest@example.com>,
+ <mailto:xtest-leave@example.com>
+ List-Archive: <http://lists.example.com/archives/xtest@example.com>
+ List-Help: <mailto:xtest-request@example.com?subject=help>
+ <BLANKLINE>
+ First post!
+ <BLANKLINE>
+ >>> dump_msgdata(messages[0].msgdata)
+ _parsemsg : False
+ listname : xtest@example.com
+ original_sender : aperson@example.com
+ origsubj : My first post
+ recips : set([])
+ stripped_subject: My first post
+ version : 3
+
+There's now one message in the digest mailbox, getting ready to be sent.
+
+ >>> from mailman.testing.helpers import digest_mbox
+ >>> digest = digest_mbox(mlist)
+ >>> sum(1 for mboxmsg in digest)
+ 1
+ >>> print list(digest)[0].as_string()
+ From: aperson@example.com
+ To: xtest@example.com
+ Message-ID: <first>
+ Subject: [Xtest] My first post
+ X-BeenThere: xtest@example.com
+ X-Mailman-Version: ...
+ Precedence: list
+ List-Id: <xtest.example.com>
+ X-Message-ID-Hash: 4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
+ List-Post: <mailto:xtest@example.com>
+ List-Subscribe:
+ <http://lists.example.com/listinfo/xtest@example.com>,
+ <mailto:xtest-join@example.com>
+ Archived-At:
+ http://lists.example.com/archives/4CMWUN6BHVCMHMDAOSJZ2Q72G5M32MWB
+ List-Unsubscribe:
+ <http://lists.example.com/listinfo/xtest@example.com>,
+ <mailto:xtest-leave@example.com>
+ List-Archive: <http://lists.example.com/archives/xtest@example.com>
+ List-Help: <mailto:xtest-request@example.com?subject=help>
+ <BLANKLINE>
+ First post!
+ <BLANKLINE>
+ <BLANKLINE>
+
+ >>> digest.clear()
diff --git a/src/mailman/docs/registration.txt b/src/mailman/docs/registration.txt
new file mode 100644
index 000000000..d243188bc
--- /dev/null
+++ b/src/mailman/docs/registration.txt
@@ -0,0 +1,362 @@
+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.interfaces.registrar 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.
+
+Add a domain, which will provide the context for the verification email
+message.
+
+ >>> config.push('mail', """
+ ... [domain.mail_example_dot_com]
+ ... email_host: mail.example.com
+ ... base_url: http://mail.example.com
+ ... contact_address: postmaster@mail.example.com
+ ... """)
+
+ >>> domain = config.domains['mail.example.com']
+
+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(u'aperson@example.com', u'Anne Person')
+ >>> check_token(token)
+ ok
+
+There should be no records in the user manager for this address yet.
+
+ >>> usermgr = config.db.user_manager
+ >>> print usermgr.get_user(u'aperson@example.com')
+ None
+ >>> print usermgr.get_address(u'aperson@example.com')
+ None
+
+But this address is waiting for confirmation.
+
+ >>> pendingdb = config.db.pendings
+ >>> sorted(pendingdb.confirm(token, expunge=False).items())
+ [(u'address', u'aperson@example.com'),
+ (u'real_name', u'Anne Person'),
+ (u'type', u'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.
+
+ >>> switchboard = config.switchboards['virgin']
+ >>> 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-...@mail.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 mail.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>
+ >>> dump_msgdata(qdata)
+ _parsemsg : False
+ nodecorate : True
+ recips : [u'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 + '@mail.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
+
+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(u'aperson@example.com')
+ >>> found_address
+ <Address: Anne Person <aperson@example.com> [verified] at ...>
+ >>> found_user = usermgr.get_user(u'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(u'bperson@example.com')
+ >>> 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
+ >>> 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(u'cperson@example.com')
+ <Address: cperson@example.com [not verified] at ...>
+ >>> token = registrar.register(u'cperson@example.com', u'Claire Person')
+ >>> print usermgr.get_user(u'cperson@example.com')
+ None
+ >>> filebase = switchboard.files[0]
+ >>> qmsg, qdata = switchboard.dequeue(filebase)
+ >>> switchboard.finish(filebase)
+ >>> registrar.confirm(token)
+ True
+ >>> usermgr.get_user(u'cperson@example.com')
+ <User "Claire Person" at ...>
+ >>> usermgr.get_address(u'cperson@example.com')
+ <Address: cperson@example.com [verified] at ...>
+
+Even if the address being registered has already been verified, the
+registration sends a confirmation.
+
+ >>> token = registrar.register(u'cperson@example.com')
+ >>> token is not None
+ True
+
+
+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(u'eperson@example.com', u'Elly Person')
+ >>> check_token(token)
+ ok
+ >>> registrar.discard(token)
+ >>> print pendingdb.confirm(token)
+ None
+ >>> print usermgr.get_address(u'eperson@example.com')
+ None
+ >>> print usermgr.get_user(u'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.create_user(u'dperson@example.com', u'Dave Person')
+ >>> dperson
+ <User "Dave Person" at ...>
+ >>> address = usermgr.get_address(u'dperson@example.com')
+ >>> address.verified_on = datetime.now()
+
+ >>> from operator import attrgetter
+ >>> sorted((addr for addr in dperson.addresses), key=attrgetter('address'))
+ [<Address: Dave Person <dperson@example.com> [verified] at ...>]
+ >>> dperson.register(u'david.person@example.com', u'David Person')
+ <Address: David Person <david.person@example.com> [not verified] at ...>
+ >>> token = registrar.register(u'david.person@example.com')
+ >>> filebase = switchboard.files[0]
+ >>> qmsg, qdata = switchboard.dequeue(filebase)
+ >>> switchboard.finish(filebase)
+ >>> registrar.confirm(token)
+ True
+ >>> user = usermgr.get_user(u'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.pending import IPendable
+ >>> from zope.interface import implements
+
+ >>> class SimplePendable(dict):
+ ... implements(IPendable)
+ >>> pendable = SimplePendable(type='foo', bar='baz')
+ >>> token = pendingdb.add(pendable)
+ >>> registrar.confirm(token)
+ False
+ >>> print pendingdb.confirm(token)
+ None
+
+
+Registration and subscription
+-----------------------------
+
+Fred registers with Mailman at the same time that he subscribes to a mailing
+list.
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist = create_list(u'alpha@example.com')
+ >>> token = registrar.register(
+ ... u'fred.person@example.com', 'Fred Person', mlist)
+
+Before confirmation, Fred is not a member of the mailing list.
+
+ >>> print mlist.members.get_member(u'fred.person@example.com')
+ None
+
+But after confirmation, he is.
+
+ >>> registrar.confirm(token)
+ True
+ >>> print mlist.members.get_member(u'fred.person@example.com')
+ <Member: Fred Person <fred.person@example.com>
+ on alpha@example.com as MemberRole.member>
+
+
+Clean up
+--------
+
+ >>> config.pop('mail')
diff --git a/src/mailman/docs/requests.txt b/src/mailman/docs/requests.txt
new file mode 100644
index 000000000..87b835fb8
--- /dev/null
+++ b/src/mailman/docs/requests.txt
@@ -0,0 +1,883 @@
+Moderator requests
+==================
+
+Various actions will be held for moderator approval, such as subscriptions to
+closed lists, or postings by non-members. The requests database is the low
+level interface to these actions requiring approval.
+
+Here is a helper function for printing out held requests.
+
+ >>> def show_holds(requests):
+ ... for request in requests.held_requests:
+ ... key, data = requests.get_request(request.id)
+ ... print request.id, str(request.request_type), key
+ ... if data is not None:
+ ... for key in sorted(data):
+ ... print ' {0}: {1}'.format(key, data[key])
+
+And another helper for displaying messages in the virgin queue.
+
+ >>> virginq = config.switchboards['virgin']
+ >>> def dequeue(whichq=None, expected_count=1):
+ ... if whichq is None:
+ ... whichq = virginq
+ ... assert len(whichq.files) == expected_count, (
+ ... 'Unexpected file count: %d' % len(whichq.files))
+ ... filebase = whichq.files[0]
+ ... qmsg, qdata = whichq.dequeue(filebase)
+ ... whichq.finish(filebase)
+ ... return qmsg, qdata
+
+
+Mailing list centric
+--------------------
+
+A set of requests are always related to a particular mailing list, so given a
+mailing list you need to get its requests object.
+
+ >>> from mailman.interfaces.requests import IListRequests, IRequests
+ >>> from zope.interface.verify import verifyObject
+ >>> verifyObject(IRequests, config.db.requests)
+ True
+ >>> mlist = config.db.list_manager.create(u'test@example.com')
+ >>> requests = config.db.requests.get_list_requests(mlist)
+ >>> verifyObject(IListRequests, requests)
+ True
+ >>> requests.mailing_list
+ <mailing list "test@example.com" at ...>
+
+
+Holding requests
+----------------
+
+The list's requests database starts out empty.
+
+ >>> requests.count
+ 0
+ >>> list(requests.held_requests)
+ []
+
+At the lowest level, the requests database is very simple. Holding a request
+requires a request type (as an enum value), a key, and an optional dictionary
+of associated data. The request database assigns no semantics to the held
+data, except for the request type. Here we hold some simple bits of data.
+
+ >>> from mailman.interfaces.requests import RequestType
+ >>> id_1 = requests.hold_request(RequestType.held_message, u'hold_1')
+ >>> id_2 = requests.hold_request(RequestType.subscription, u'hold_2')
+ >>> id_3 = requests.hold_request(RequestType.unsubscription, u'hold_3')
+ >>> id_4 = requests.hold_request(RequestType.held_message, u'hold_4')
+ >>> id_1, id_2, id_3, id_4
+ (1, 2, 3, 4)
+
+And of course, now we can see that there are four requests being held.
+
+ >>> requests.count
+ 4
+ >>> requests.count_of(RequestType.held_message)
+ 2
+ >>> requests.count_of(RequestType.subscription)
+ 1
+ >>> requests.count_of(RequestType.unsubscription)
+ 1
+ >>> show_holds(requests)
+ 1 RequestType.held_message hold_1
+ 2 RequestType.subscription hold_2
+ 3 RequestType.unsubscription hold_3
+ 4 RequestType.held_message hold_4
+
+If we try to hold a request with a bogus type, we get an exception.
+
+ >>> requests.hold_request(5, 'foo')
+ Traceback (most recent call last):
+ ...
+ TypeError: 5
+
+We can hold requests with additional data.
+
+ >>> data = dict(foo='yes', bar='no')
+ >>> id_5 = requests.hold_request(RequestType.held_message, u'hold_5', data)
+ >>> id_5
+ 5
+ >>> requests.count
+ 5
+ >>> show_holds(requests)
+ 1 RequestType.held_message hold_1
+ 2 RequestType.subscription hold_2
+ 3 RequestType.unsubscription hold_3
+ 4 RequestType.held_message hold_4
+ 5 RequestType.held_message hold_5
+ bar: no
+ foo: yes
+
+
+Getting requests
+----------------
+
+We can ask the requests database for a specific request, by providing the id
+of the request data we want. This returns a 2-tuple of the key and data we
+originally held.
+
+ >>> key, data = requests.get_request(2)
+ >>> print key
+ hold_2
+
+Because we did not store additional data with request 2, it comes back as None
+now.
+
+ >>> print data
+ None
+
+However, if we ask for a request that had data, we'd get it back now.
+
+ >>> key, data = requests.get_request(5)
+ >>> print key
+ hold_5
+ >>> dump_msgdata(data)
+ bar: no
+ foo: yes
+
+If we ask for a request that is not in the database, we get None back.
+
+ >>> print requests.get_request(801)
+ None
+
+
+Iterating over requests
+-----------------------
+
+To make it easier to find specific requests, the list requests can be iterated
+over by type.
+
+ >>> requests.count_of(RequestType.held_message)
+ 3
+ >>> for request in requests.of_type(RequestType.held_message):
+ ... assert request.request_type is RequestType.held_message
+ ... key, data = requests.get_request(request.id)
+ ... print request.id, key
+ ... if data is not None:
+ ... for key in sorted(data):
+ ... print ' {0}: {1}'.format(key, data[key])
+ 1 hold_1
+ 4 hold_4
+ 5 hold_5
+ bar: no
+ foo: yes
+
+
+Deleting requests
+-----------------
+
+Once a specific request has been handled, it will be deleted from the requests
+database.
+
+ >>> requests.delete_request(2)
+ >>> requests.count
+ 4
+ >>> show_holds(requests)
+ 1 RequestType.held_message hold_1
+ 3 RequestType.unsubscription hold_3
+ 4 RequestType.held_message hold_4
+ 5 RequestType.held_message hold_5
+ bar: no
+ foo: yes
+ >>> print requests.get_request(2)
+ None
+
+We get an exception if we ask to delete a request that isn't in the database.
+
+ >>> requests.delete_request(801)
+ Traceback (most recent call last):
+ ...
+ KeyError: 801
+
+For the next section, we first clean up all the current requests.
+
+ >>> for request in requests.held_requests:
+ ... requests.delete_request(request.id)
+ >>> requests.count
+ 0
+
+
+Application support
+-------------------
+
+There are several higher level interfaces available in the mailman.app package
+which can be used to hold messages, subscription, and unsubscriptions. There
+are also interfaces for disposing of these requests in an application specific
+and consistent way.
+
+ >>> from mailman.app import moderator
+
+
+Holding messages
+----------------
+
+For this section, we need a mailing list and at least one message.
+
+ >>> mlist = config.db.list_manager.create(u'alist@example.com')
+ >>> mlist.preferred_language = u'en'
+ >>> mlist.real_name = u'A Test List'
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.org
+ ... To: alist@example.com
+ ... Subject: Something important
+ ...
+ ... Here's something important about our mailing list.
+ ... """)
+
+Holding a message means keeping a copy of it that a moderator must approve
+before the message is posted to the mailing list. To hold the message, you
+must supply the message, message metadata, and a text reason for the hold. In
+this case, we won't include any additional metadata.
+
+ >>> id_1 = moderator.hold_message(mlist, msg, {}, 'Needs approval')
+ >>> requests.get_request(id_1) is not None
+ True
+
+We can also hold a message with some additional metadata.
+
+ # Delete the Message-ID from the previous hold so we don't try to store
+ # collisions in the message storage.
+ >>> del msg['message-id']
+ >>> msgdata = dict(sender='aperson@example.com',
+ ... approved=True,
+ ... received_time=123.45)
+ >>> id_2 = moderator.hold_message(mlist, msg, msgdata, u'Feeling ornery')
+ >>> requests.get_request(id_2) is not None
+ True
+
+Once held, the moderator can select one of several dispositions. The most
+trivial is to simply defer a decision for now.
+
+ >>> from mailman.interfaces import Action
+ >>> moderator.handle_message(mlist, id_1, Action.defer)
+ >>> requests.get_request(id_1) is not None
+ True
+
+The moderator can also discard the message. This is often done with spam.
+Bye bye message!
+
+ >>> moderator.handle_message(mlist, id_1, Action.discard)
+ >>> print requests.get_request(id_1)
+ None
+ >>> virginq.files
+ []
+
+The message can be rejected, meaning it is bounced back to the sender.
+
+ >>> moderator.handle_message(mlist, id_2, Action.reject, 'Off topic')
+ >>> print requests.get_request(id_2)
+ None
+ >>> qmsg, qdata = dequeue()
+ >>> print qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: Request to mailing list "A Test List" rejected
+ From: alist-bounces@example.com
+ To: aperson@example.org
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ Your request to the alist@example.com mailing list
+ <BLANKLINE>
+ Posting of your message titled "Something important"
+ <BLANKLINE>
+ has been rejected by the list moderator. The moderator gave the
+ following reason for rejecting your request:
+ <BLANKLINE>
+ "Off topic"
+ <BLANKLINE>
+ Any questions or comments should be directed to the list administrator
+ at:
+ <BLANKLINE>
+ alist-owner@example.com
+ <BLANKLINE>
+ >>> dump_msgdata(qdata)
+ _parsemsg : False
+ listname : alist@example.com
+ nodecorate : True
+ recips : [u'aperson@example.org']
+ reduced_list_headers: True
+ version : 3
+
+Or the message can be approved. This actually places the message back into
+the incoming queue for further processing, however the message metadata
+indicates that the message has been approved.
+
+ >>> id_3 = moderator.hold_message(mlist, msg, msgdata, 'Needs approval')
+ >>> moderator.handle_message(mlist, id_3, Action.accept)
+ >>> inq = config.switchboards['in']
+ >>> qmsg, qdata = dequeue(inq)
+ >>> print qmsg.as_string()
+ From: aperson@example.org
+ To: alist@example.com
+ Subject: Something important
+ Message-ID: ...
+ X-Message-ID-Hash: ...
+ X-Mailman-Approved-At: ...
+ <BLANKLINE>
+ Here's something important about our mailing list.
+ <BLANKLINE>
+ >>> dump_msgdata(qdata)
+ _parsemsg : False
+ approved : True
+ moderator_approved: True
+ sender : aperson@example.com
+ version : 3
+
+In addition to any of the above dispositions, the message can also be
+preserved for further study. Ordinarily the message is removed from the
+global message store after its disposition (though approved messages may be
+re-added to the message store). When handling a message, we can tell the
+moderator interface to also preserve a copy, essentially telling it not to
+delete the message from the storage. First, without the switch, the message
+is deleted.
+
+ >>> msg = message_from_string("""\
+ ... From: aperson@example.org
+ ... To: alist@example.com
+ ... Subject: Something important
+ ... Message-ID: <12345>
+ ...
+ ... Here's something important about our mailing list.
+ ... """)
+ >>> id_4 = moderator.hold_message(mlist, msg, {}, 'Needs approval')
+ >>> moderator.handle_message(mlist, id_4, Action.discard)
+ >>> print config.db.message_store.get_message_by_id(u'<12345>')
+ None
+
+But if we ask to preserve the message when we discard it, it will be held in
+the message store after disposition.
+
+ >>> id_4 = moderator.hold_message(mlist, msg, {}, 'Needs approval')
+ >>> moderator.handle_message(mlist, id_4, Action.discard, preserve=True)
+ >>> stored_msg = config.db.message_store.get_message_by_id(u'<12345>')
+ >>> print stored_msg.as_string()
+ From: aperson@example.org
+ To: alist@example.com
+ Subject: Something important
+ Message-ID: <12345>
+ X-Message-ID-Hash: 4CF7EAU3SIXBPXBB5S6PEUMO62MWGQN6
+ <BLANKLINE>
+ Here's something important about our mailing list.
+ <BLANKLINE>
+
+Orthogonal to preservation, the message can also be forwarded to another
+address. This is helpful for getting the message into the inbox of one of the
+moderators.
+
+ # Set a new Message-ID from the previous hold so we don't try to store
+ # collisions in the message storage.
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = u'<abcde>'
+ >>> id_4 = moderator.hold_message(mlist, msg, {}, 'Needs approval')
+ >>> moderator.handle_message(mlist, id_4, Action.discard,
+ ... forward=[u'zperson@example.com'])
+ >>> qmsg, qdata = dequeue()
+ >>> print qmsg.as_string()
+ Subject: Forward of moderated message
+ From: alist-bounces@example.com
+ To: zperson@example.com
+ MIME-Version: 1.0
+ Content-Type: message/rfc822
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ From: aperson@example.org
+ To: alist@example.com
+ Subject: Something important
+ Message-ID: <abcde>
+ X-Message-ID-Hash: EN2R5UQFMOUTCL44FLNNPLSXBIZW62ER
+ <BLANKLINE>
+ Here's something important about our mailing list.
+ <BLANKLINE>
+ >>> dump_msgdata(qdata)
+ _parsemsg : False
+ listname : alist@example.com
+ nodecorate : True
+ recips : [u'zperson@example.com']
+ reduced_list_headers: True
+ version : 3
+
+
+Holding subscription requests
+-----------------------------
+
+For closed lists, subscription requests will also be held for moderator
+approval. In this case, several pieces of information related to the
+subscription must be provided, including the subscriber's address and real
+name, their password (possibly hashed), what kind of delivery option they are
+chosing and their preferred language.
+
+ >>> from mailman.interfaces.member import DeliveryMode
+ >>> mlist.admin_immed_notify = False
+ >>> id_3 = moderator.hold_subscription(mlist,
+ ... u'bperson@example.org', u'Ben Person',
+ ... u'{NONE}abcxyz', DeliveryMode.regular, u'en')
+ >>> requests.get_request(id_3) is not None
+ True
+
+In the above case the mailing list was not configured to send the list
+moderators a notice about the hold, so no email message is in the virgin
+queue.
+
+ >>> virginq.files
+ []
+
+But if we set the list up to notify the list moderators immediately when a
+message is held for approval, there will be a message placed in the virgin
+queue when the message is held.
+
+ >>> mlist.admin_immed_notify = True
+ >>> # XXX This will almost certainly change once we've worked out the web
+ >>> # space layout for mailing lists now.
+ >>> id_4 = moderator.hold_subscription(mlist,
+ ... u'cperson@example.org', u'Claire Person',
+ ... u'{NONE}zyxcba', DeliveryMode.regular, u'en')
+ >>> requests.get_request(id_4) is not None
+ True
+ >>> qmsg, qdata = dequeue()
+ >>> print qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: New subscription request to list A Test List from
+ cperson@example.org
+ From: alist-owner@example.com
+ To: alist-owner@example.com
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ Your authorization is required for a mailing list subscription request
+ approval:
+ <BLANKLINE>
+ For: cperson@example.org
+ List: alist@example.com
+ <BLANKLINE>
+ At your convenience, visit:
+ <BLANKLINE>
+ http://lists.example.com/admindb/alist@example.com
+ <BLANKLINE>
+ to process the request.
+ <BLANKLINE>
+ >>> dump_msgdata(qdata)
+ _parsemsg : False
+ listname : alist@example.com
+ nodecorate : True
+ recips : [u'alist-owner@example.com']
+ reduced_list_headers: True
+ tomoderators : True
+ version : 3
+
+Once held, the moderator can select one of several dispositions. The most
+trivial is to simply defer a decision for now.
+
+ >>> moderator.handle_subscription(mlist, id_3, Action.defer)
+ >>> requests.get_request(id_3) is not None
+ True
+
+The held subscription can also be discarded.
+
+ >>> moderator.handle_subscription(mlist, id_3, Action.discard)
+ >>> print requests.get_request(id_3)
+ None
+
+The request can be rejected, in which case a message is sent to the
+subscriber.
+
+ >>> moderator.handle_subscription(mlist, id_4, Action.reject,
+ ... 'This is a closed list')
+ >>> print requests.get_request(id_4)
+ None
+ >>> qmsg, qdata = dequeue()
+ >>> print qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: Request to mailing list "A Test List" rejected
+ From: alist-bounces@example.com
+ To: cperson@example.org
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ Your request to the alist@example.com mailing list
+ <BLANKLINE>
+ Subscription request
+ <BLANKLINE>
+ has been rejected by the list moderator. The moderator gave the
+ following reason for rejecting your request:
+ <BLANKLINE>
+ "This is a closed list"
+ <BLANKLINE>
+ Any questions or comments should be directed to the list administrator
+ at:
+ <BLANKLINE>
+ alist-owner@example.com
+ <BLANKLINE>
+ >>> dump_msgdata(qdata)
+ _parsemsg : False
+ listname : alist@example.com
+ nodecorate : True
+ recips : [u'cperson@example.org']
+ reduced_list_headers: True
+ version : 3
+
+The subscription can also be accepted. This subscribes the address to the
+mailing list.
+
+ >>> mlist.send_welcome_msg = True
+ >>> id_4 = moderator.hold_subscription(mlist,
+ ... u'fperson@example.org', u'Frank Person',
+ ... u'{NONE}abcxyz', DeliveryMode.regular, u'en')
+
+A message will be sent to the moderators telling them about the held
+subscription and the fact that they may need to approve it.
+
+ >>> qmsg, qdata = dequeue()
+ >>> print qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: New subscription request to list A Test List from
+ fperson@example.org
+ From: alist-owner@example.com
+ To: alist-owner@example.com
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ Your authorization is required for a mailing list subscription request
+ approval:
+ <BLANKLINE>
+ For: fperson@example.org
+ List: alist@example.com
+ <BLANKLINE>
+ At your convenience, visit:
+ <BLANKLINE>
+ http://lists.example.com/admindb/alist@example.com
+ <BLANKLINE>
+ to process the request.
+ <BLANKLINE>
+ >>> dump_msgdata(qdata)
+ _parsemsg : False
+ listname : alist@example.com
+ nodecorate : True
+ recips : [u'alist-owner@example.com']
+ reduced_list_headers: True
+ tomoderators : True
+ version : 3
+
+Accept the subscription request.
+
+ >>> mlist.admin_notify_mchanges = True
+ >>> moderator.handle_subscription(mlist, id_4, Action.accept)
+
+There are now two messages in the virgin queue. One is a welcome message
+being sent to the user and the other is a subscription notification that is
+sent to the moderators. The only good way to tell which is which is to look
+at the recipient list.
+
+ >>> qmsg_1, qdata_1 = dequeue(expected_count=2)
+ >>> qmsg_2, qdata_2 = dequeue()
+ >>> if 'fperson@example.org' in qdata_1['recips']:
+ ... # The first message is the welcome message
+ ... welcome_qmsg = qmsg_1
+ ... welcome_qdata = qdata_1
+ ... admin_qmsg = qmsg_2
+ ... admin_qdata = qdata_2
+ ... else:
+ ... welcome_qmsg = qmsg_2
+ ... welcome_qdata = qdata_2
+ ... admin_qmsg = qmsg_1
+ ... admin_qdata = qdata_1
+
+The welcome message is sent to the person who just subscribed.
+
+ >>> print welcome_qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: Welcome to the "A Test List" mailing list
+ From: alist-request@example.com
+ To: fperson@example.org
+ X-No-Archive: yes
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ Welcome to the "A Test List" mailing list!
+ <BLANKLINE>
+ To post to this list, send your email to:
+ <BLANKLINE>
+ alist@example.com
+ <BLANKLINE>
+ General information about the mailing list is at:
+ <BLANKLINE>
+ http://lists.example.com/listinfo/alist@example.com
+ <BLANKLINE>
+ If you ever want to unsubscribe or change your options (eg, switch to
+ or from digest mode, change your password, etc.), visit your
+ subscription page at:
+ <BLANKLINE>
+ http://example.com/fperson@example.org
+ <BLANKLINE>
+ You can also make such adjustments via email by sending a message to:
+ <BLANKLINE>
+ alist-request@example.com
+ <BLANKLINE>
+ with the word 'help' in the subject or body (don't include the
+ quotes), and you will get back a message with instructions. You will
+ need your password to change your options, but for security purposes,
+ this email is not included here. There is also a button on your
+ options page that will send your current password to you.
+ <BLANKLINE>
+ >>> dump_msgdata(welcome_qdata)
+ _parsemsg : False
+ listname : alist@example.com
+ nodecorate : True
+ recips : [u'fperson@example.org']
+ reduced_list_headers: True
+ verp : False
+ version : 3
+
+The admin message is sent to the moderators.
+
+ >>> print admin_qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: A Test List subscription notification
+ From: changeme@example.com
+ To: alist-owner@example.com
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ Frank Person <fperson@example.org> has been successfully subscribed to
+ A Test List.
+ <BLANKLINE>
+ >>> dump_msgdata(admin_qdata)
+ _parsemsg : False
+ envsender : changeme@example.com
+ listname : alist@example.com
+ nodecorate : True
+ recips : []
+ reduced_list_headers: True
+ version : 3
+
+Frank Person is now a member of the mailing list.
+
+ >>> member = mlist.members.get_member(u'fperson@example.org')
+ >>> member
+ <Member: Frank Person <fperson@example.org>
+ on alist@example.com as MemberRole.member>
+ >>> member.preferred_language
+ u'en'
+ >>> print member.delivery_mode
+ DeliveryMode.regular
+ >>> user = config.db.user_manager.get_user(member.address.address)
+ >>> user.real_name
+ u'Frank Person'
+ >>> user.password
+ u'{NONE}abcxyz'
+
+
+Holding unsubscription requests
+-------------------------------
+
+Some lists, though it is rare, require moderator approval for unsubscriptions.
+In this case, only the unsubscribing address is required. Like subscriptions,
+unsubscription holds can send the list's moderators an immediate notification.
+
+ >>> mlist.admin_immed_notify = False
+ >>> from mailman.interfaces.member import MemberRole
+ >>> user_1 = config.db.user_manager.create_user(u'gperson@example.com')
+ >>> address_1 = list(user_1.addresses)[0]
+ >>> address_1.subscribe(mlist, MemberRole.member)
+ <Member: gperson@example.com on alist@example.com as MemberRole.member>
+ >>> user_2 = config.db.user_manager.create_user(u'hperson@example.com')
+ >>> address_2 = list(user_2.addresses)[0]
+ >>> address_2.subscribe(mlist, MemberRole.member)
+ <Member: hperson@example.com on alist@example.com as MemberRole.member>
+ >>> id_5 = moderator.hold_unsubscription(mlist, u'gperson@example.com')
+ >>> requests.get_request(id_5) is not None
+ True
+ >>> virginq.files
+ []
+ >>> mlist.admin_immed_notify = True
+ >>> id_6 = moderator.hold_unsubscription(mlist, u'hperson@example.com')
+ >>> qmsg, qdata = dequeue()
+ >>> print qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: New unsubscription request from A Test List by hperson@example.com
+ From: alist-owner@example.com
+ To: alist-owner@example.com
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ Your authorization is required for a mailing list unsubscription
+ request approval:
+ <BLANKLINE>
+ By: hperson@example.com
+ From: alist@example.com
+ <BLANKLINE>
+ At your convenience, visit:
+ <BLANKLINE>
+ http://lists.example.com/admindb/alist@example.com
+ <BLANKLINE>
+ to process the request.
+ <BLANKLINE>
+ >>> dump_msgdata(qdata)
+ _parsemsg : False
+ listname : alist@example.com
+ nodecorate : True
+ recips : [u'alist-owner@example.com']
+ reduced_list_headers: True
+ tomoderators : True
+ version : 3
+
+There are now two addresses with held unsubscription requests. As above, one
+of the actions we can take is to defer to the decision.
+
+ >>> moderator.handle_unsubscription(mlist, id_5, Action.defer)
+ >>> requests.get_request(id_5) is not None
+ True
+
+The held unsubscription can also be discarded, and the member will remain
+subscribed.
+
+ >>> moderator.handle_unsubscription(mlist, id_5, Action.discard)
+ >>> print requests.get_request(id_5)
+ None
+ >>> mlist.members.get_member(u'gperson@example.com')
+ <Member: gperson@example.com on alist@example.com as MemberRole.member>
+
+The request can be rejected, in which case a message is sent to the member,
+and the person remains a member of the mailing list.
+
+ >>> moderator.handle_unsubscription(mlist, id_6, Action.reject,
+ ... 'This list is a prison.')
+ >>> print requests.get_request(id_6)
+ None
+ >>> qmsg, qdata = dequeue()
+ >>> print qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: Request to mailing list "A Test List" rejected
+ From: alist-bounces@example.com
+ To: hperson@example.com
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ Your request to the alist@example.com mailing list
+ <BLANKLINE>
+ Unsubscription request
+ <BLANKLINE>
+ has been rejected by the list moderator. The moderator gave the
+ following reason for rejecting your request:
+ <BLANKLINE>
+ "This list is a prison."
+ <BLANKLINE>
+ Any questions or comments should be directed to the list administrator
+ at:
+ <BLANKLINE>
+ alist-owner@example.com
+ <BLANKLINE>
+ >>> dump_msgdata(qdata)
+ _parsemsg : False
+ listname : alist@example.com
+ nodecorate : True
+ recips : [u'hperson@example.com']
+ reduced_list_headers: True
+ version : 3
+
+ >>> mlist.members.get_member(u'hperson@example.com')
+ <Member: hperson@example.com on alist@example.com as MemberRole.member>
+
+The unsubscription request can also be accepted. This removes the member from
+the mailing list.
+
+ >>> mlist.send_goodbye_msg = True
+ >>> mlist.goodbye_msg = u'So long!'
+ >>> mlist.admin_immed_notify = False
+ >>> id_7 = moderator.hold_unsubscription(mlist, u'gperson@example.com')
+ >>> moderator.handle_unsubscription(mlist, id_7, Action.accept)
+ >>> print mlist.members.get_member(u'gperson@example.com')
+ None
+
+There are now two messages in the virgin queue, one to the member who was just
+unsubscribed and another to the moderators informing them of this membership
+change.
+
+ >>> qmsg_1, qdata_1 = dequeue(expected_count=2)
+ >>> qmsg_2, qdata_2 = dequeue()
+ >>> if 'gperson@example.com' in qdata_1['recips']:
+ ... # The first message is the goodbye message
+ ... goodbye_qmsg = qmsg_1
+ ... goodbye_qdata = qdata_1
+ ... admin_qmsg = qmsg_2
+ ... admin_qdata = qdata_2
+ ... else:
+ ... goodbye_qmsg = qmsg_2
+ ... goodbye_qdata = qdata_2
+ ... admin_qmsg = qmsg_1
+ ... admin_qdata = qdata_1
+
+The goodbye message...
+
+ >>> print goodbye_qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: You have been unsubscribed from the A Test List mailing list
+ From: alist-bounces@example.com
+ To: gperson@example.com
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ So long!
+ <BLANKLINE>
+ >>> dump_msgdata(goodbye_qdata)
+ _parsemsg : False
+ listname : alist@example.com
+ nodecorate : True
+ recips : [u'gperson@example.com']
+ reduced_list_headers: True
+ verp : False
+ version : 3
+
+...and the admin message.
+
+ >>> print admin_qmsg.as_string()
+ MIME-Version: 1.0
+ Content-Type: text/plain; charset="us-ascii"
+ Content-Transfer-Encoding: 7bit
+ Subject: A Test List unsubscription notification
+ From: changeme@example.com
+ To: alist-owner@example.com
+ Message-ID: ...
+ Date: ...
+ Precedence: bulk
+ <BLANKLINE>
+ gperson@example.com has been removed from A Test List.
+ <BLANKLINE>
+ >>> dump_msgdata(admin_qdata)
+ _parsemsg : False
+ envsender : changeme@example.com
+ listname : alist@example.com
+ nodecorate : True
+ recips : []
+ reduced_list_headers: True
+ version : 3
diff --git a/src/mailman/docs/styles.txt b/src/mailman/docs/styles.txt
new file mode 100644
index 000000000..d12e57b08
--- /dev/null
+++ b/src/mailman/docs/styles.txt
@@ -0,0 +1,156 @@
+List styles
+===========
+
+List styles are a way to name and apply a canned collection of attribute
+settings. Every style has a name, which must be unique within the context of
+a specific style manager. There is usually only one global style manager.
+
+Styles also have a priority, which allows you to specify the order in which
+multiple styles will be applied. A style has a `match` function which is used
+to determine whether the style should be applied to a particular mailing list
+or not. And finally, application of a style to a mailing list can really
+modify the mailing list any way it wants.
+
+Let's start with a vanilla mailing list and a default style manager.
+
+ >>> mlist = config.db.list_manager.create(u'_xtest@example.com')
+ >>> from mailman.styles.manager import StyleManager
+ >>> style_manager = StyleManager()
+ >>> style_manager.populate()
+ >>> sorted(style.name for style in style_manager.styles)
+ ['default']
+
+
+The default style
+-----------------
+
+There is a default style which implements the legacy application of list
+defaults from previous versions of Mailman. This style only matching a
+mailing list when no other styles match, and it has the lowest priority. The
+low priority means that it is matched last and if it matches, it is applied
+last.
+
+ >>> default_style = style_manager.get('default')
+ >>> default_style.name
+ 'default'
+ >>> default_style.priority
+ 0
+ >>> sorted(style.name for style in style_manager.styles)
+ ['default']
+
+Given a mailing list, you can ask the style manager to find all the styles
+that match the list. The registered styles will be sorted by decreasing
+priority and each style's `match()` method will be called in turn. The sorted
+list of matching styles will be returned -- but not applied -- by the style
+manager's `lookup()` method.
+
+ >>> [style.name for style in style_manager.lookup(mlist)]
+ ['default']
+
+
+Registering styles
+------------------
+
+New styles must implement the IStyle interface.
+
+ >>> from zope.interface import implements
+ >>> from mailman.interfaces.styles import IStyle
+ >>> class TestStyle(object):
+ ... implements(IStyle)
+ ... name = 'test'
+ ... priority = 10
+ ... def apply(self, mailing_list):
+ ... # Just does something very simple.
+ ... mailing_list.msg_footer = u'test footer'
+ ... def match(self, mailing_list, styles):
+ ... # Applies to any test list
+ ... if 'test' in mailing_list.fqdn_listname:
+ ... styles.append(self)
+
+You can register a new style with the style manager.
+
+ >>> style_manager.register(TestStyle())
+
+And now if you lookup matching styles, you should find only the new test
+style. This is because the default style only gets applied when no other
+styles match the mailing list.
+
+ >>> sorted(style.name for style in style_manager.lookup(mlist))
+ ['test']
+ >>> for style in style_manager.lookup(mlist):
+ ... style.apply(mlist)
+ >>> mlist.msg_footer
+ u'test footer'
+
+
+Style priority
+--------------
+
+When multiple styles match a particular mailing list, they are applied in
+descending order of priority. In other words, a priority zero style would be
+applied last.
+
+ >>> class AnotherTestStyle(TestStyle):
+ ... name = 'another'
+ ... priority = 5
+ ... # Use the base class's match() method.
+ ... def apply(self, mailing_list):
+ ... mailing_list.msg_footer = u'another footer'
+
+ >>> mlist.msg_footer = u''
+ >>> mlist.msg_footer
+ u''
+ >>> style_manager.register(AnotherTestStyle())
+ >>> for style in style_manager.lookup(mlist):
+ ... style.apply(mlist)
+ >>> mlist.msg_footer
+ u'another footer'
+
+You can change the priority of a style, and if you reapply the styles, they
+will take effect in the new priority order.
+
+ >>> style_1 = style_manager.get('test')
+ >>> style_1.priority = 5
+ >>> style_2 = style_manager.get('another')
+ >>> style_2.priority = 10
+ >>> for style in style_manager.lookup(mlist):
+ ... style.apply(mlist)
+ >>> mlist.msg_footer
+ u'test footer'
+
+
+Unregistering styles
+--------------------
+
+You can unregister a style, making it unavailable in the future.
+
+ >>> style_manager.unregister(style_2)
+ >>> sorted(style.name for style in style_manager.lookup(mlist))
+ ['test']
+
+
+Corner cases
+------------
+
+If you register a style with the same name as an already registered style, you
+get an exception.
+
+ >>> style_manager.register(TestStyle())
+ Traceback (most recent call last):
+ ...
+ DuplicateStyleError: test
+
+If you try to register an object that isn't a style, you get an exception.
+
+ >>> style_manager.register(object())
+ Traceback (most recent call last):
+ ...
+ DoesNotImplement: An object does not implement interface
+ <InterfaceClass mailman.interfaces.styles.IStyle>
+
+If you try to unregister a style that isn't registered, you get an exception.
+
+ >>> style_manager.unregister(style_2)
+ Traceback (most recent call last):
+ ...
+ KeyError: 'another'
diff --git a/src/mailman/docs/usermanager.txt b/src/mailman/docs/usermanager.txt
new file mode 100644
index 000000000..f8cbfeef2
--- /dev/null
+++ b/src/mailman/docs/usermanager.txt
@@ -0,0 +1,124 @@
+The user manager
+================
+
+The IUserManager is how you create, delete, and manage 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.interfaces.usermanager import IUserManager
+ >>> from zope.interface.verify import verifyObject
+ >>> usermgr = config.db.user_manager
+ >>> verifyObject(IUserManager, usermgr)
+ True
+
+
+Creating users
+--------------
+
+There are several ways you can create a user object. The simplest is to
+create a 'blank' user by not providing an address or real name at creation
+time. This user will have an empty string as their real name, but will not
+have a password.
+
+ >>> from mailman.interfaces.user import IUser
+ >>> user = usermgr.create_user()
+ >>> verifyObject(IUser, user)
+ True
+ >>> sorted(address.address for address in user.addresses)
+ []
+ >>> user.real_name
+ u''
+ >>> print user.password
+ None
+
+The user has preferences, but none of them will be specified.
+
+ >>> print user.preferences
+ <Preferences ...>
+
+A user can be assigned a real name.
+
+ >>> user.real_name = u'Anne Person'
+ >>> sorted(user.real_name for user in usermgr.users)
+ [u'Anne Person']
+
+A user can be assigned a password.
+
+ >>> user.password = u'secret'
+ >>> sorted(user.password for user in usermgr.users)
+ [u'secret']
+
+You can also create a user with an address to start out with.
+
+ >>> user_2 = usermgr.create_user(u'bperson@example.com')
+ >>> verifyObject(IUser, user_2)
+ True
+ >>> sorted(address.address for address in user_2.addresses)
+ [u'bperson@example.com']
+ >>> sorted(user.real_name for user in usermgr.users)
+ [u'', u'Anne Person']
+
+As above, you can assign a real name to such users.
+
+ >>> user_2.real_name = u'Ben Person'
+ >>> sorted(user.real_name for user in usermgr.users)
+ [u'Anne Person', u'Ben Person']
+
+You can also create a user with just a real name.
+
+ >>> user_3 = usermgr.create_user(real_name=u'Claire Person')
+ >>> verifyObject(IUser, user_3)
+ True
+ >>> sorted(address.address for address in user.addresses)
+ []
+ >>> sorted(user.real_name for user in usermgr.users)
+ [u'Anne Person', u'Ben Person', u'Claire Person']
+
+Finally, you can create a user with both an address and a real name.
+
+ >>> user_4 = usermgr.create_user(u'dperson@example.com', u'Dan Person')
+ >>> verifyObject(IUser, user_3)
+ True
+ >>> sorted(address.address for address in user_4.addresses)
+ [u'dperson@example.com']
+ >>> sorted(address.real_name for address in user_4.addresses)
+ [u'Dan Person']
+ >>> sorted(user.real_name for user in usermgr.users)
+ [u'Anne Person', u'Ben Person', u'Claire Person', u'Dan Person']
+
+
+Deleting users
+--------------
+
+You delete users by going through the user manager. The deleted user is no
+longer available through the user manager iterator.
+
+ >>> usermgr.delete_user(user)
+ >>> sorted(user.real_name for user in usermgr.users)
+ [u'Ben Person', u'Claire Person', u'Dan Person']
+
+
+Finding users
+-------------
+
+You can ask the user manager to find the IUser that controls a particular
+email address. You'll get back the original user object if it's found. Note
+that the .get_user() method takes a string email address, not an IAddress
+object.
+
+ >>> address = list(user_4.addresses)[0]
+ >>> found_user = usermgr.get_user(address.address)
+ >>> found_user
+ <User "Dan Person" at ...>
+ >>> found_user is user_4
+ True
+
+If the address is not in the user database or does not have a user associated
+with it, you will get None back.
+
+ >>> print usermgr.get_user(u'zperson@example.com')
+ None
+ >>> user_4.unlink(address)
+ >>> print usermgr.get_user(address.address)
+ None
diff --git a/src/mailman/docs/users.txt b/src/mailman/docs/users.txt
new file mode 100644
index 000000000..ff7b9ca84
--- /dev/null
+++ b/src/mailman/docs/users.txt
@@ -0,0 +1,195 @@
+Users
+=====
+
+Users are entities that represent people. A user has a real name and a
+password. Optionally a user may have some preferences and a set of addresses
+they control. A user also knows which mailing lists they are subscribed to.
+
+See usermanager.txt for examples of how to create, delete, and find users.
+
+ >>> usermgr = config.db.user_manager
+
+
+User data
+---------
+
+Users may have a real name and a password.
+
+ >>> user_1 = usermgr.create_user()
+ >>> user_1.password = u'my password'
+ >>> user_1.real_name = u'Zoe Person'
+ >>> sorted(user.real_name for user in usermgr.users)
+ [u'Zoe Person']
+ >>> sorted(user.password for user in usermgr.users)
+ [u'my password']
+
+The password and real name can be changed at any time.
+
+ >>> user_1.real_name = u'Zoe X. Person'
+ >>> user_1.password = u'another password'
+ >>> sorted(user.real_name for user in usermgr.users)
+ [u'Zoe X. Person']
+ >>> sorted(user.password for user in usermgr.users)
+ [u'another password']
+
+
+Users addresses
+---------------
+
+One of the pieces of information that a user links to is a set of email
+addresses they control, in the form of IAddress objects. A user can control
+many addresses, but addresses may be controlled by only one user.
+
+The easiest way to link a user to an address is to just register the new
+address on a user object.
+
+ >>> user_1.register(u'zperson@example.com', u'Zoe Person')
+ <Address: Zoe Person <zperson@example.com> [not verified] at 0x...>
+ >>> user_1.register(u'zperson@example.org')
+ <Address: zperson@example.org [not verified] at 0x...>
+ >>> sorted(address.address for address in user_1.addresses)
+ [u'zperson@example.com', u'zperson@example.org']
+ >>> sorted(address.real_name for address in user_1.addresses)
+ [u'', u'Zoe Person']
+
+You can also create the address separately and then link it to the user.
+
+ >>> address_1 = usermgr.create_address(u'zperson@example.net')
+ >>> user_1.link(address_1)
+ >>> sorted(address.address for address in user_1.addresses)
+ [u'zperson@example.com', u'zperson@example.net', u'zperson@example.org']
+ >>> sorted(address.real_name for address in user_1.addresses)
+ [u'', u'', u'Zoe Person']
+
+But don't try to link an address to more than one user.
+
+ >>> another_user = usermgr.create_user()
+ >>> another_user.link(address_1)
+ Traceback (most recent call last):
+ ...
+ AddressAlreadyLinkedError: zperson@example.net
+
+You can also ask whether a given user controls a given address.
+
+ >>> user_1.controls(address_1.address)
+ True
+ >>> user_1.controls(u'bperson@example.com')
+ False
+
+Given a text email address, the user manager can find the user that controls
+that address.
+
+ >>> usermgr.get_user(u'zperson@example.com') is user_1
+ True
+ >>> usermgr.get_user(u'zperson@example.net') is user_1
+ True
+ >>> usermgr.get_user(u'zperson@example.org') is user_1
+ True
+ >>> print usermgr.get_user(u'bperson@example.com')
+ None
+
+Addresses can also be unlinked from a user.
+
+ >>> user_1.unlink(address_1)
+ >>> user_1.controls(u'zperson@example.net')
+ False
+ >>> print usermgr.get_user(u'aperson@example.net')
+ None
+
+But don't try to unlink the address from a user it's not linked to.
+
+ >>> user_1.unlink(address_1)
+ Traceback (most recent call last):
+ ...
+ AddressNotLinkedError: zperson@example.net
+ >>> another_user.unlink(address_1)
+ Traceback (most recent call last):
+ ...
+ AddressNotLinkedError: zperson@example.net
+
+
+Users and preferences
+---------------------
+
+This is a helper function for the following section.
+
+ >>> def show_prefs(prefs):
+ ... print 'acknowledge_posts :', prefs.acknowledge_posts
+ ... print 'preferred_language :', prefs.preferred_language
+ ... print 'receive_list_copy :', prefs.receive_list_copy
+ ... print 'receive_own_postings :', prefs.receive_own_postings
+ ... print 'delivery_mode :', prefs.delivery_mode
+
+Users have preferences, but these preferences have no default settings.
+
+ >>> from mailman.interfaces.preferences import IPreferences
+ >>> show_prefs(user_1.preferences)
+ acknowledge_posts : None
+ preferred_language : None
+ receive_list_copy : None
+ receive_own_postings : None
+ delivery_mode : None
+
+Some of these preferences are booleans and they can be set to True or False.
+
+ >>> from mailman.constants import DeliveryMode
+ >>> prefs = user_1.preferences
+ >>> prefs.acknowledge_posts = True
+ >>> prefs.preferred_language = u'it'
+ >>> prefs.receive_list_copy = False
+ >>> prefs.receive_own_postings = False
+ >>> prefs.delivery_mode = DeliveryMode.regular
+ >>> show_prefs(user_1.preferences)
+ acknowledge_posts : True
+ preferred_language : it
+ receive_list_copy : False
+ receive_own_postings : False
+ delivery_mode : DeliveryMode.regular
+
+
+Subscriptions
+-------------
+
+Users know which mailing lists they are subscribed to, regardless of
+membership role.
+
+ >>> user_1.link(address_1)
+ >>> sorted(address.address for address in user_1.addresses)
+ [u'zperson@example.com', u'zperson@example.net', u'zperson@example.org']
+ >>> com = usermgr.get_address(u'zperson@example.com')
+ >>> org = usermgr.get_address(u'zperson@example.org')
+ >>> net = usermgr.get_address(u'zperson@example.net')
+
+ >>> from mailman.app.lifecycle import create_list
+ >>> mlist_1 = create_list(u'xtest_1@example.com')
+ >>> mlist_2 = create_list(u'xtest_2@example.com')
+ >>> mlist_3 = create_list(u'xtest_3@example.com')
+ >>> from mailman.interfaces.member import MemberRole
+
+ >>> com.subscribe(mlist_1, MemberRole.member)
+ <Member: Zoe Person <zperson@example.com> on xtest_1@example.com as
+ MemberRole.member>
+ >>> org.subscribe(mlist_2, MemberRole.member)
+ <Member: zperson@example.org on xtest_2@example.com as MemberRole.member>
+ >>> org.subscribe(mlist_2, MemberRole.owner)
+ <Member: zperson@example.org on xtest_2@example.com as MemberRole.owner>
+ >>> net.subscribe(mlist_3, MemberRole.moderator)
+ <Member: zperson@example.net on xtest_3@example.com as
+ MemberRole.moderator>
+
+ >>> memberships = user_1.memberships
+ >>> from mailman.interfaces.roster import IRoster
+ >>> from zope.interface.verify import verifyObject
+ >>> verifyObject(IRoster, memberships)
+ True
+ >>> members = sorted(memberships.members)
+ >>> len(members)
+ 4
+ >>> def sortkey(member):
+ ... return member.address.address, member.mailing_list, int(member.role)
+ >>> for member in sorted(members, key=sortkey):
+ ... print member.address.address, member.mailing_list, member.role
+ zperson@example.com xtest_1@example.com MemberRole.member
+ zperson@example.net xtest_3@example.com MemberRole.moderator
+ zperson@example.org xtest_2@example.com MemberRole.member
+ zperson@example.org xtest_2@example.com MemberRole.owner