summaryrefslogtreecommitdiff
path: root/src/mailman/model
diff options
context:
space:
mode:
authorBarry Warsaw2010-01-12 08:27:38 -0500
committerBarry Warsaw2010-01-12 08:27:38 -0500
commit41faffef13f11c793c140d7f18d3b0698685b7a2 (patch)
treebce0b307279a9682afeb57e50d16aa646440e22e /src/mailman/model
parentf137d934b0d5b9e37bd24989e7fb613540ca675d (diff)
downloadmailman-41faffef13f11c793c140d7f18d3b0698685b7a2.tar.gz
mailman-41faffef13f11c793c140d7f18d3b0698685b7a2.tar.zst
mailman-41faffef13f11c793c140d7f18d3b0698685b7a2.zip
Documentation reorganization.
Diffstat (limited to 'src/mailman/model')
-rw-r--r--src/mailman/model/docs/__init__.py0
-rw-r--r--src/mailman/model/docs/addresses.txt236
-rw-r--r--src/mailman/model/docs/autorespond.txt112
-rw-r--r--src/mailman/model/docs/domains.txt119
-rw-r--r--src/mailman/model/docs/languages.txt110
-rw-r--r--src/mailman/model/docs/listmanager.txt101
-rw-r--r--src/mailman/model/docs/membership.txt234
-rw-r--r--src/mailman/model/docs/messagestore.txt116
-rw-r--r--src/mailman/model/docs/mlist-addresses.txt77
-rw-r--r--src/mailman/model/docs/pending.txt94
-rw-r--r--src/mailman/model/docs/registration.txt350
-rw-r--r--src/mailman/model/docs/requests.txt896
-rw-r--r--src/mailman/model/docs/usermanager.txt125
-rw-r--r--src/mailman/model/docs/users.txt208
14 files changed, 2778 insertions, 0 deletions
diff --git a/src/mailman/model/docs/__init__.py b/src/mailman/model/docs/__init__.py
new file mode 100644
index 000000000..e69de29bb
--- /dev/null
+++ b/src/mailman/model/docs/__init__.py
diff --git a/src/mailman/model/docs/addresses.txt b/src/mailman/model/docs/addresses.txt
new file mode 100644
index 000000000..5388a3cc8
--- /dev/null
+++ b/src/mailman/model/docs/addresses.txt
@@ -0,0 +1,236 @@
+===============
+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.
+
+ >>> from mailman.interfaces.usermanager import IUserManager
+ >>> from zope.component import getUtility
+ >>> user_manager = getUtility(IUserManager)
+
+
+Creating addresses
+==================
+
+Addresses are created directly through the user manager, which starts out with
+no addresses.
+
+ >>> sorted(address.address for address in user_manager.addresses)
+ []
+
+Creating an unlinked email address is straightforward.
+
+ >>> address_1 = user_manager.create_address('aperson@example.com')
+ >>> sorted(address.address for address in user_manager.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 = user_manager.create_address(
+ ... 'bperson@example.com', 'Ben Person')
+ >>> sorted(address.address for address in user_manager.addresses)
+ [u'aperson@example.com', u'bperson@example.com']
+ >>> sorted(address.real_name for address in user_manager.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 = 'Anne Person'
+ >>> sorted(address.real_name for address in user_manager.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 user_manager.get_user('aperson@example.com')
+ None
+ >>> print user_manager.get_user('bperson@example.com')
+ None
+
+You can create email addresses that are linked to users by using a different
+interface.
+
+ >>> user_1 = user_manager.create_user(
+ ... '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 user_manager.addresses)
+ [u'aperson@example.com', u'bperson@example.com', u'cperson@example.com']
+ >>> sorted(address.real_name for address in user_manager.addresses)
+ [u'Anne Person', u'Ben Person', u'Claire Person']
+
+And now you can find the associated user.
+
+ >>> print user_manager.get_user('aperson@example.com')
+ None
+ >>> print user_manager.get_user('bperson@example.com')
+ None
+ >>> user_manager.get_user('cperson@example.com')
+ <User "Claire Person" at ...>
+
+
+Deleting addresses
+==================
+
+You can remove an unlinked address from the user manager.
+
+ >>> user_manager.delete_address(address_1)
+ >>> sorted(address.address for address in user_manager.addresses)
+ [u'bperson@example.com', u'cperson@example.com']
+ >>> sorted(address.real_name for address in user_manager.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('cperson@example.com')
+ True
+ >>> address_3 = list(user_1.addresses)[0]
+ >>> user_manager.delete_address(address_3)
+ >>> sorted(address.address for address in user_1.addresses)
+ []
+ >>> user_1.controls('cperson@example.com')
+ False
+ >>> sorted(address.address for address in user_manager.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 = user_manager.create_address(
+ ... 'dperson@example.com', '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 = user_manager.create_address(
+ ... 'eperson@example.com', 'Elly Person')
+ >>> mlist = create_list('_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 = user_manager.create_address(
+ ... 'FPERSON@example.com', '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.
+
+ >>> print address_6.address
+ fperson@example.com
+ >>> print address_6.original_address
+ FPERSON@example.com
+
+Because addresses are case-insensitive for all other purposes, you cannot
+create an address that differs only in case.
+
+ >>> user_manager.create_address('fperson@example.com')
+ Traceback (most recent call last):
+ ...
+ ExistingAddressError: FPERSON@example.com
+ >>> user_manager.create_address('fperson@EXAMPLE.COM')
+ Traceback (most recent call last):
+ ...
+ ExistingAddressError: FPERSON@example.com
+ >>> user_manager.create_address('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.
+
+ >>> print user_manager.get_address('fperson@example.com').address
+ fperson@example.com
+ >>> print user_manager.get_address('FPERSON@example.com').address
+ fperson@example.com
diff --git a/src/mailman/model/docs/autorespond.txt b/src/mailman/model/docs/autorespond.txt
new file mode 100644
index 000000000..ba0521a89
--- /dev/null
+++ b/src/mailman/model/docs/autorespond.txt
@@ -0,0 +1,112 @@
+===================
+Automatic responder
+===================
+
+In various situations, Mailman will send an automatic response to the author
+of an email message. For example, if someone sends a command to the -request
+address, Mailman will send a response, but to cut down on third party spam,
+the sender will only get a certain number of responses per day.
+
+First, given a mailing list you need to adapt it to an IAutoResponseSet.
+
+ >>> mlist = create_list('test@example.com')
+ >>> from mailman.interfaces.autorespond import IAutoResponseSet
+ >>> response_set = IAutoResponseSet(mlist)
+
+ >>> from zope.interface.verify import verifyObject
+ >>> verifyObject(IAutoResponseSet, response_set)
+ True
+
+You can't adapt other objects to an IAutoResponseSet.
+
+ >>> IAutoResponseSet(object())
+ Traceback (most recent call last):
+ ...
+ TypeError: ('Could not adapt', ...
+
+There are various kinds of response types. For example, Mailman will send an
+automatic response when messages are held for approval, or when it receives an
+email command. You can find out how many responses for a particular address
+have already been sent today.
+
+ >>> from mailman.interfaces.usermanager import IUserManager
+ >>> from zope.component import getUtility
+ >>> address = getUtility(IUserManager).create_address(
+ ... 'aperson@example.com')
+
+ >>> from mailman.interfaces.autorespond import Response
+ >>> response_set.todays_count(address, Response.hold)
+ 0
+ >>> response_set.todays_count(address, Response.command)
+ 0
+
+Using the response set, we can record that a hold response is sent to the
+address.
+
+ >>> response_set.response_sent(address, Response.hold)
+ >>> response_set.todays_count(address, Response.hold)
+ 1
+ >>> response_set.todays_count(address, Response.command)
+ 0
+
+We can also record that a command response was sent.
+
+ >>> response_set.response_sent(address, Response.command)
+ >>> response_set.todays_count(address, Response.hold)
+ 1
+ >>> response_set.todays_count(address, Response.command)
+ 1
+
+Let's send one more.
+
+ >>> response_set.response_sent(address, Response.command)
+ >>> response_set.todays_count(address, Response.hold)
+ 1
+ >>> response_set.todays_count(address, Response.command)
+ 2
+
+Now the day flips over and all the counts reset.
+
+ >>> from mailman.utilities.datetime import factory
+ >>> factory.fast_forward()
+
+ >>> response_set.todays_count(address, Response.hold)
+ 0
+ >>> response_set.todays_count(address, Response.command)
+ 0
+
+
+Response dates
+==============
+
+You can also use the response set to get the date of the last response sent.
+
+ >>> response = response_set.last_response(address, Response.hold)
+ >>> response.mailing_list
+ <mailing list "test@example.com" at ...>
+ >>> response.address
+ <Address: aperson@example.com [not verified] at ...>
+ >>> response.response_type
+ <EnumValue: Response.hold [int=1]>
+ >>> response.date_sent
+ datetime.date(2005, 8, 1)
+
+When another response is sent today, that becomes the last one sent.
+
+ >>> response_set.response_sent(address, Response.command)
+ >>> response_set.last_response(address, Response.command).date_sent
+ datetime.date(2005, 8, 2)
+
+ >>> factory.fast_forward(days=3)
+ >>> response_set.response_sent(address, Response.command)
+ >>> response_set.last_response(address, Response.command).date_sent
+ datetime.date(2005, 8, 5)
+
+If there's been no response sent to a particular address, None is returned.
+
+ >>> address = getUtility(IUserManager).create_address(
+ ... 'bperson@example.com')
+ >>> response_set.todays_count(address, Response.command)
+ 0
+ >>> print response_set.last_response(address, Response.command)
+ None
diff --git a/src/mailman/model/docs/domains.txt b/src/mailman/model/docs/domains.txt
new file mode 100644
index 000000000..5673e6ee9
--- /dev/null
+++ b/src/mailman/model/docs/domains.txt
@@ -0,0 +1,119 @@
+=======
+Domains
+=======
+
+ # The test framework starts out with an example domain, so let's delete
+ # that first.
+ >>> from mailman.interfaces.domain import IDomainManager
+ >>> from zope.component import getUtility
+ >>> manager = getUtility(IDomainManager)
+ >>> manager.remove('example.com')
+ <Domain example.com...>
+
+Domains are how Mailman interacts with email host names and web host names.
+
+ >>> from operator import attrgetter
+ >>> def show_domains():
+ ... if len(manager) == 0:
+ ... print 'no domains'
+ ... return
+ ... for domain in sorted(manager, key=attrgetter('email_host')):
+ ... print domain
+
+ >>> show_domains()
+ no domains
+
+Adding a domain requires some basic information, of which the email host name
+is the only required piece. The other parts are inferred from that.
+
+ >>> manager.add('example.org')
+ <Domain example.org, base_url: http://example.org,
+ contact_address: postmaster@example.org>
+ >>> show_domains()
+ <Domain example.org, base_url: http://example.org,
+ contact_address: postmaster@example.org>
+
+We can remove domains too.
+
+ >>> manager.remove('example.org')
+ <Domain example.org, base_url: http://example.org,
+ contact_address: postmaster@example.org>
+ >>> show_domains()
+ no domains
+
+Sometimes the email host name is different than the base url for hitting the
+web interface for the domain.
+
+ >>> manager.add('example.com', base_url='https://mail.example.com')
+ <Domain example.com, base_url: https://mail.example.com,
+ contact_address: postmaster@example.com>
+ >>> show_domains()
+ <Domain example.com, base_url: https://mail.example.com,
+ contact_address: postmaster@example.com>
+
+Domains can have explicit descriptions and contact addresses.
+
+ >>> manager.add(
+ ... 'example.net',
+ ... base_url='http://lists.example.net',
+ ... contact_address='postmaster@example.com',
+ ... description='The example domain')
+ <Domain example.net, The example domain,
+ base_url: http://lists.example.net,
+ contact_address: postmaster@example.com>
+
+ >>> show_domains()
+ <Domain example.com, base_url: https://mail.example.com,
+ contact_address: postmaster@example.com>
+ <Domain example.net, The example domain,
+ base_url: http://lists.example.net,
+ contact_address: postmaster@example.com>
+
+In the global domain manager, domains are indexed by their email host name.
+
+ >>> for domain in sorted(manager, key=attrgetter('email_host')):
+ ... print domain.email_host
+ example.com
+ example.net
+
+ >>> print manager['example.net']
+ <Domain example.net, The example domain,
+ base_url: http://lists.example.net,
+ contact_address: postmaster@example.com>
+
+ >>> print manager['doesnotexist.com']
+ Traceback (most recent call last):
+ ...
+ KeyError: u'doesnotexist.com'
+
+As with a dictionary, you can also get the domain. If the domain does not
+exist, None or a default is returned.
+
+ >>> print manager.get('example.net')
+ <Domain example.net, The example domain,
+ base_url: http://lists.example.net,
+ contact_address: postmaster@example.com>
+
+ >>> print manager.get('doesnotexist.com')
+ None
+
+ >>> print manager.get('doesnotexist.com', 'blahdeblah')
+ blahdeblah
+
+Non-existent domains cannot be removed.
+
+ >>> manager.remove('doesnotexist.com')
+ Traceback (most recent call last):
+ ...
+ KeyError: u'doesnotexist.com'
+
+
+Confirmation tokens
+===================
+
+Confirmation tokens can be added to the domain's url to generate the URL to a
+page users can use to confirm their subscriptions.
+
+ >>> domain = manager['example.net']
+ >>> print domain.confirm_url('abc')
+ http://lists.example.net/confirm/abc
diff --git a/src/mailman/model/docs/languages.txt b/src/mailman/model/docs/languages.txt
new file mode 100644
index 000000000..a724a0510
--- /dev/null
+++ b/src/mailman/model/docs/languages.txt
@@ -0,0 +1,110 @@
+=========
+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 mailman.interfaces.languages import ILanguageManager
+ >>> from zope.component import getUtility
+ >>> from zope.interface.verify import verifyObject
+
+ >>> mgr = getUtility(ILanguageManager)
+ >>> verifyObject(ILanguageManager, mgr)
+ True
+
+ # The language manager component comes pre-populated; clear it out.
+ >>> mgr.clear()
+
+A language manager keeps track of the languages it knows about.
+
+ >>> list(mgr.codes)
+ []
+ >>> list(mgr.languages)
+ []
+
+
+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('en', 'us-ascii', 'English')
+ >>> mgr.add('it', 'iso-8859-1', 'Italian')
+
+And you can get information for all known languages.
+
+ >>> print mgr['en'].description
+ English
+ >>> print mgr['en'].charset
+ us-ascii
+ >>> print mgr['it'].description
+ Italian
+ >>> print mgr['it'].charset
+ iso-8859-1
+
+
+Other iterations
+================
+
+You can iterate over all the known language codes.
+
+ >>> mgr.add('pl', 'iso-8859-2', 'Polish')
+ >>> sorted(mgr.codes)
+ [u'en', u'it', u'pl']
+
+You can iterate over all the known languages.
+
+ >>> from operator import attrgetter
+ >>> languages = sorted((language for language in mgr.languages),
+ ... key=attrgetter('code'))
+ >>> for language in languages:
+ ... print language.code, language.charset, language.description
+ en us-ascii English
+ it iso-8859-1 Italian
+ pl iso-8859-2 Polish
+
+You can ask whether a particular language code is known.
+
+ >>> 'it' in mgr
+ True
+ >>> 'xx' in mgr
+ False
+
+You can get a particular language by its code.
+
+ >>> print mgr['it'].description
+ Italian
+ >>> print mgr['xx'].code
+ Traceback (most recent call last):
+ ...
+ KeyError: u'xx'
+ >>> print mgr.get('it').description
+ Italian
+ >>> print mgr.get('xx')
+ None
+ >>> print mgr.get('xx', 'missing')
+ missing
+
+
+Clearing the known languages
+============================
+
+The language manager can forget about all the language codes it knows about.
+
+ >>> 'en' in mgr
+ True
+
+ # Make a copy of the language manager's dictionary, so we can restore it
+ # after the test. Currently the test layer doesn't manage this.
+ >>> saved = mgr._languages.copy()
+
+ >>> mgr.clear()
+ >>> 'en' in mgr
+ False
+
+ # Restore the data.
+ >>> mgr._languages = saved
diff --git a/src/mailman/model/docs/listmanager.txt b/src/mailman/model/docs/listmanager.txt
new file mode 100644
index 000000000..e07659066
--- /dev/null
+++ b/src/mailman/model/docs/listmanager.txt
@@ -0,0 +1,101 @@
+========================
+The mailing list manager
+========================
+
+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
+ >>> from zope.component import getUtility
+ >>> list_manager = getUtility(IListManager)
+
+
+Creating a mailing list
+=======================
+
+Creating the list returns the newly created IMailList object.
+
+ >>> from mailman.interfaces.mailinglist import IMailingList
+ >>> mlist = list_manager.create('_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.
+
+ >>> print mlist.list_name
+ _xtest
+ >>> print mlist.host_name
+ example.com
+ >>> print mlist.fqdn_listname
+ _xtest@example.com
+
+If you try to create a mailing list with the same name as an existing list,
+you will get an exception.
+
+ >>> list_manager.create('_xtest@example.com')
+ Traceback (most recent call last):
+ ...
+ ListAlreadyExistsError: _xtest@example.com
+
+It is an error to create a mailing list that isn't a fully qualified list name
+(i.e. posting address).
+
+ >>> list_manager.create('foo')
+ Traceback (most recent call last):
+ ...
+ InvalidEmailAddressError: foo
+
+
+Deleting a mailing list
+=======================
+
+Use the list manager to delete a mailing list.
+
+ >>> list_manager.delete(mlist)
+ >>> sorted(list_manager.names)
+ []
+
+After deleting the list, you can create it again.
+
+ >>> mlist = list_manager.create('_xtest@example.com')
+ >>> print mlist.fqdn_listname
+ _xtest@example.com
+
+
+Retrieving a mailing list
+=========================
+
+When a mailing list exists, you can ask the list manager for it and you will
+always get the same object back.
+
+ >>> mlist_2 = list_manager.get('_xtest@example.com')
+ >>> mlist_2 is mlist
+ True
+
+If you try to get a list that doesn't existing yet, you get None.
+
+ >>> print list_manager.get('_xtest_2@example.com')
+ None
+
+You also get None if the list name is invalid.
+
+ >>> print list_manager.get('foo')
+ 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 = list_manager.create('_xtest_3@example.com')
+ >>> mlist_4 = list_manager.create('_xtest_4@example.com')
+ >>> sorted(list_manager.names)
+ [u'_xtest@example.com', u'_xtest_3@example.com', u'_xtest_4@example.com']
+ >>> sorted(m.fqdn_listname for m in list_manager.mailing_lists)
+ [u'_xtest@example.com', u'_xtest_3@example.com', u'_xtest_4@example.com']
diff --git a/src/mailman/model/docs/membership.txt b/src/mailman/model/docs/membership.txt
new file mode 100644
index 000000000..27d2d9552
--- /dev/null
+++ b/src/mailman/model/docs/membership.txt
@@ -0,0 +1,234 @@
+================
+List memberships
+================
+
+Users represent people in Mailman. Users control email addresses, and rosters
+are collections 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 = create_list('_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.
+
+ >>> from mailman.interfaces.usermanager import IUserManager
+ >>> from zope.component import getUtility
+ >>> user_manager = getUtility(IUserManager)
+ >>> user_1 = user_manager.create_user('aperson@example.com', 'Anne Person')
+ >>> print user_1.real_name
+ 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]
+ >>> print address_1.address
+ 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 = user_manager.create_user('bperson@example.com', 'Ben Person')
+ >>> print user_2.real_name
+ Ben Person
+ >>> address_2 = list(user_2.addresses)[0]
+ >>> print address_2.address
+ 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 = user_manager.create_user(
+ ... 'cperson@example.com', 'Claire Person')
+ >>> print user_3.real_name
+ Claire Person
+ >>> address_3 = list(user_3.addresses)[0]
+ >>> print address_3.address
+ 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('aperson@example.com')
+ <Member: Anne Person <aperson@example.com> on
+ _xtest@example.com as MemberRole.owner>
+ >>> mlist.administrators.get_member('aperson@example.com')
+ <Member: Anne Person <aperson@example.com> on
+ _xtest@example.com as MemberRole.owner>
+ >>> mlist.members.get_member('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('zperson@example.com')
+ None
+ >>> print mlist.moderators.get_member('aperson@example.com')
+ None
+ >>> print mlist.members.get_member('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/model/docs/messagestore.txt b/src/mailman/model/docs/messagestore.txt
new file mode 100644
index 000000000..aabfd55fb
--- /dev/null
+++ b/src/mailman/model/docs/messagestore.txt
@@ -0,0 +1,116 @@
+=================
+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.
+
+ >>> from mailman.interfaces.messages import IMessageStore
+ >>> from zope.component import getUtility
+ >>> message_store = getUtility(IMessageStore)
+
+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.
+ ... """)
+ >>> message_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>'
+ >>> message_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 message_store.get_message_by_id('nothing')
+ None
+ >>> print message_store.get_message_by_hash('nothing')
+ None
+
+Given an existing Message-ID, the message can be found.
+
+ >>> message = 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 = 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(message_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.
+
+ >>> message_store.delete_message('nothing')
+ Traceback (most recent call last):
+ ...
+ LookupError: nothing
+
+But if you delete an existing message, it really gets deleted.
+
+ >>> message_id = message['message-id']
+ >>> message_store.delete_message(message_id)
+ >>> list(message_store.messages)
+ []
+ >>> print message_store.get_message_by_id(message_id)
+ None
+ >>> print message_store.get_message_by_hash(message['x-message-id-hash'])
+ None
diff --git a/src/mailman/model/docs/mlist-addresses.txt b/src/mailman/model/docs/mlist-addresses.txt
new file mode 100644
index 000000000..3f44008fb
--- /dev/null
+++ b/src/mailman/model/docs/mlist-addresses.txt
@@ -0,0 +1,77 @@
+======================
+Mailing list addresses
+======================
+
+Every mailing list has a number of addresses which are publicly available.
+These are defined in the IMailingListAddresses interface.
+
+ >>> mlist = create_list('_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.
+
+ >>> print mlist.fqdn_listname
+ _xtest@example.com
+ >>> print mlist.posting_address
+ _xtest@example.com
+
+Messages to the mailing list's 'no reply' address always get discarded without
+prejudice.
+
+ >>> print mlist.no_reply_address
+ noreply@example.com
+
+The mailing list's owner address reaches the human moderators.
+
+ >>> print mlist.owner_address
+ _xtest-owner@example.com
+
+The request address goes to the list's email command robot.
+
+ >>> print mlist.request_address
+ _xtest-request@example.com
+
+The bounces address accepts and processes all potential bounces.
+
+ >>> print mlist.bounces_address
+ _xtest-bounces@example.com
+
+The join (a.k.a. subscribe) address is where someone can email to get added to
+the mailing list. The subscribe alias is a synonym for join, but it's
+deprecated.
+
+ >>> print mlist.join_address
+ _xtest-join@example.com
+ >>> print mlist.subscribe_address
+ _xtest-subscribe@example.com
+
+The leave (a.k.a. unsubscribe) address is where someone can email to get added
+to the mailing list. The unsubscribe alias is a synonym for leave, but it's
+deprecated.
+
+ >>> print mlist.leave_address
+ _xtest-leave@example.com
+ >>> print mlist.unsubscribe_address
+ _xtest-unsubscribe@example.com
+
+
+Email confirmations
+===================
+
+Email confirmation messages are sent when actions such as subscriptions need
+to be confirmed. It requires that a cookie be provided, which will be
+included in the local part of the email address. The exact format of this is
+dependent on the VERP_CONFIRM_FORMAT configuration variable.
+
+ >>> print mlist.confirm_address('cookie')
+ _xtest-confirm+cookie@example.com
+ >>> print mlist.confirm_address('wookie')
+ _xtest-confirm+wookie@example.com
+
+ >>> config.push('test config', """
+ ... [mta]
+ ... verp_confirm_format: $address---$cookie
+ ... """)
+ >>> print mlist.confirm_address('cookie')
+ _xtest-confirm---cookie@example.com
+ >>> config.pop('test config')
diff --git a/src/mailman/model/docs/pending.txt b/src/mailman/model/docs/pending.txt
new file mode 100644
index 000000000..dc27b6bee
--- /dev/null
+++ b/src/mailman/model/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
+ >>> from zope.component import getUtility
+ >>> pendingdb = getUtility(IPendings)
+
+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(bytes('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/model/docs/registration.txt b/src/mailman/model/docs/registration.txt
new file mode 100644
index 000000000..abc7f2c93
--- /dev/null
+++ b/src/mailman/model/docs/registration.txt
@@ -0,0 +1,350 @@
+====================
+Address registration
+====================
+
+Before users can join a mailing list, they 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.
+
+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.
+
+ >>> from mailman.interfaces.registrar import IRegistrar
+ >>> from zope.component import getUtility
+ >>> registrar = getUtility(IRegistrar)
+
+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://lists.example.com/confirm/(.*)')
+ >>> def extract_token(msg):
+ ... mo = cre.search(msg.get_payload())
+ ... return mo.group(1)
+
+
+Invalid email addresses
+=======================
+
+Addresses are registered within the context of a mailing list, mostly so that
+confirmation emails can come from some place. You also need the email
+address of the user who is registering.
+
+ >>> mlist = create_list('alpha@example.com')
+
+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(mlist, '')
+ Traceback (most recent call last):
+ ...
+ InvalidEmailAddressError: u''
+ >>> registrar.register(mlist, 'some name@example.com')
+ Traceback (most recent call last):
+ ...
+ InvalidEmailAddressError: u'some name@example.com'
+ >>> registrar.register(mlist, '<script>@example.com')
+ Traceback (most recent call last):
+ ...
+ InvalidEmailAddressError: u'<script>@example.com'
+ >>> registrar.register(mlist, '\xa0@example.com')
+ Traceback (most recent call last):
+ ...
+ InvalidEmailAddressError: u'\xa0@example.com'
+ >>> registrar.register(mlist, 'noatsign')
+ Traceback (most recent call last):
+ ...
+ InvalidEmailAddressError: u'noatsign'
+ >>> registrar.register(mlist, 'nodom@ain')
+ Traceback (most recent call last):
+ ...
+ InvalidEmailAddressError: u'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(mlist, 'aperson@example.com', 'Anne Person')
+ >>> check_token(token)
+ ok
+
+There should be no records in the user manager for this address yet.
+
+ >>> from mailman.interfaces.usermanager import IUserManager
+ >>> from zope.component import getUtility
+ >>> user_manager = getUtility(IUserManager)
+ >>> print user_manager.get_user('aperson@example.com')
+ None
+ >>> print user_manager.get_address('aperson@example.com')
+ None
+
+But this address is waiting for confirmation.
+
+ >>> from mailman.interfaces.pending import IPendings
+ >>> pendingdb = getUtility(IPendings)
+
+ >>> dump_msgdata(pendingdb.confirm(token, expunge=False))
+ address : aperson@example.com
+ list_name: alpha@example.com
+ real_name: Anne Person
+ type : registration
+
+
+Verification by email
+=====================
+
+There is also a verification email sitting in the virgin queue now. This
+message is sent to the user in order to verify the registered address.
+
+ >>> from mailman.testing.helpers import get_queue_messages
+ >>> items = get_queue_messages('virgin')
+ >>> len(items)
+ 1
+ >>> print items[0].msg.as_string()
+ MIME-Version: 1.0
+ ...
+ Subject: confirm ...
+ From: alpha-confirm+...@example.com
+ To: aperson@example.com
+ ...
+ <BLANKLINE>
+ Email Address Registration Confirmation
+ <BLANKLINE>
+ Hello, this is the GNU Mailman server at example.com.
+ <BLANKLINE>
+ We have received a registration request for the email address
+ <BLANKLINE>
+ aperson@example.com
+ <BLANKLINE>
+ Before you can start using GNU Mailman at this site, you must first
+ confirm that this is your email address. You can do this by replying to
+ this message, keeping the Subject header intact. Or you can visit this
+ web page
+ <BLANKLINE>
+ http://lists.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@example.com
+ <BLANKLINE>
+ >>> dump_msgdata(items[0].msgdata)
+ _parsemsg : False
+ listname : alpha@example.com
+ nodecorate : True
+ recipients : [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(items[0].msg)
+ >>> sent_token == token
+ True
+
+The same token will appear in the From header.
+
+ >>> items[0].msg['from'] == 'alpha-confirm+' + token + '@example.com'
+ True
+
+It will also appear in the Subject header.
+
+ >>> items[0].msg['subject'] == 'confirm ' + token
+ True
+
+The user would then validate their 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 = user_manager.get_address('aperson@example.com')
+ >>> found_address
+ <Address: Anne Person <aperson@example.com> [verified] at ...>
+ >>> found_user = user_manager.get_user('aperson@example.com')
+ >>> found_user
+ <User "Anne Person" at ...>
+ >>> found_user.controls(found_address.address)
+ True
+ >>> from datetime import datetime
+ >>> isinstance(found_address.verified_on, datetime)
+ True
+
+
+Non-standard registrations
+==========================
+
+If you try to confirm a registration token twice, of course only the first one
+will work. The second one is ignored.
+
+ >>> token = registrar.register(mlist, 'bperson@example.com')
+ >>> check_token(token)
+ ok
+ >>> items = get_queue_messages('virgin')
+ >>> len(items)
+ 1
+ >>> sent_token = extract_token(items[0].msg)
+ >>> 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.
+
+ >>> user_manager.create_address('cperson@example.com')
+ <Address: cperson@example.com [not verified] at ...>
+ >>> token = registrar.register(
+ ... mlist, 'cperson@example.com', 'Claire Person')
+ >>> print user_manager.get_user('cperson@example.com')
+ None
+ >>> items = get_queue_messages('virgin')
+ >>> len(items)
+ 1
+ >>> sent_token = extract_token(items[0].msg)
+ >>> registrar.confirm(sent_token)
+ True
+ >>> user_manager.get_user('cperson@example.com')
+ <User "Claire Person" at ...>
+ >>> user_manager.get_address('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(mlist, '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(mlist, 'eperson@example.com', 'Elly Person')
+ >>> check_token(token)
+ ok
+ >>> registrar.discard(token)
+ >>> print pendingdb.confirm(token)
+ None
+ >>> print user_manager.get_address('eperson@example.com')
+ None
+ >>> print user_manager.get_user('eperson@example.com')
+ None
+
+ # Clear the virgin queue of all the preceding confirmation messages.
+ >>> ignore = get_queue_messages('virgin')
+
+
+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 = user_manager.create_user(
+ ... 'dperson@example.com', 'Dave Person')
+ >>> dperson
+ <User "Dave Person" at ...>
+ >>> address = user_manager.get_address('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('david.person@example.com', 'David Person')
+ <Address: David Person <david.person@example.com> [not verified] at ...>
+ >>> token = registrar.register(mlist, 'david.person@example.com')
+
+ >>> items = get_queue_messages('virgin')
+ >>> len(items)
+ 1
+ >>> sent_token = extract_token(items[0].msg)
+ >>> registrar.confirm(sent_token)
+ True
+ >>> user = user_manager.get_user('david.person@example.com')
+ >>> user is dperson
+ True
+ >>> user
+ <User "Dave Person" at ...>
+ >>> sorted((addr for addr in user.addresses), key=attrgetter('address'))
+ [<Address: David Person <david.person@example.com> [verified] at ...>,
+ <Address: Dave Person <dperson@example.com> [verified] at ...>]
+
+
+Corner cases
+============
+
+If you try to confirm a token that doesn't exist in the pending database, the
+confirm method will just return False.
+
+ >>> registrar.confirm(bytes('no token'))
+ False
+
+Likewise, if you try to confirm, through the IUserRegistrar interface, a token
+that doesn't match a registration event, you will get None. However, the
+pending event 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.
+
+ >>> token = registrar.register(
+ ... mlist, 'fred.person@example.com', 'Fred Person')
+
+Before confirmation, Fred is not a member of the mailing list.
+
+ >>> print mlist.members.get_member('fred.person@example.com')
+ None
+
+But after confirmation, he is.
+
+ >>> registrar.confirm(token)
+ True
+ >>> print mlist.members.get_member('fred.person@example.com')
+ <Member: Fred Person <fred.person@example.com>
+ on alpha@example.com as MemberRole.member>
diff --git a/src/mailman/model/docs/requests.txt b/src/mailman/model/docs/requests.txt
new file mode 100644
index 000000000..8cd027297
--- /dev/null
+++ b/src/mailman/model/docs/requests.txt
@@ -0,0 +1,896 @@
+==================
+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.component import getUtility
+ >>> from zope.interface.verify import verifyObject
+
+ >>> mlist = create_list('test@example.com')
+ >>> requests = getUtility(IRequests).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, 'hold_1')
+ >>> id_2 = requests.hold_request(RequestType.subscription, 'hold_2')
+ >>> id_3 = requests.hold_request(RequestType.unsubscription, 'hold_3')
+ >>> id_4 = requests.hold_request(RequestType.held_message, '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, '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 = create_list('alist@example.com')
+ >>> mlist.preferred_language = 'en'
+ >>> mlist.real_name = '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, '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.action 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
+ recipients : [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)
+
+ >>> from mailman.interfaces.messages import IMessageStore
+ >>> from zope.component import getUtility
+ >>> message_store = getUtility(IMessageStore)
+
+ >>> print message_store.get_message_by_id('<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 = message_store.get_message_by_id('<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'] = '<abcde>'
+ >>> id_4 = moderator.hold_message(mlist, msg, {}, 'Needs approval')
+ >>> moderator.handle_message(mlist, id_4, Action.discard,
+ ... forward=['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
+ recipients : [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
+choosing and their preferred language.
+
+ >>> from mailman.interfaces.member import DeliveryMode
+ >>> mlist.admin_immed_notify = False
+ >>> id_3 = moderator.hold_subscription(mlist,
+ ... 'bperson@example.org', 'Ben Person',
+ ... '{NONE}abcxyz', DeliveryMode.regular, '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,
+ ... 'cperson@example.org', 'Claire Person',
+ ... '{NONE}zyxcba', DeliveryMode.regular, '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
+ recipients : [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
+ recipients : [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,
+ ... 'fperson@example.org', 'Frank Person',
+ ... '{NONE}abcxyz', DeliveryMode.regular, '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
+ recipients : [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['recipients']:
+ ... # 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
+ recipients : [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
+ recipients : []
+ reduced_list_headers: True
+ version : 3
+
+Frank Person is now a member of the mailing list.
+
+ >>> member = mlist.members.get_member('fperson@example.org')
+ >>> member
+ <Member: Frank Person <fperson@example.org>
+ on alist@example.com as MemberRole.member>
+ >>> member.preferred_language
+ <Language [en] English (USA)>
+ >>> print member.delivery_mode
+ DeliveryMode.regular
+
+ >>> from mailman.interfaces.usermanager import IUserManager
+ >>> from zope.component import getUtility
+ >>> user_manager = getUtility(IUserManager)
+
+ >>> user = user_manager.get_user(member.address.address)
+ >>> print user.real_name
+ Frank Person
+ >>> print user.password
+ {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 = user_manager.create_user('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 = user_manager.create_user('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, '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, '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
+ recipients : [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('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
+ recipients : [u'hperson@example.com']
+ reduced_list_headers: True
+ version : 3
+
+ >>> mlist.members.get_member('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 = 'So long!'
+ >>> mlist.admin_immed_notify = False
+ >>> id_7 = moderator.hold_unsubscription(mlist, 'gperson@example.com')
+ >>> moderator.handle_unsubscription(mlist, id_7, Action.accept)
+ >>> print mlist.members.get_member('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['recipients']:
+ ... # 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
+ recipients : [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
+ recipients : []
+ reduced_list_headers: True
+ version : 3
diff --git a/src/mailman/model/docs/usermanager.txt b/src/mailman/model/docs/usermanager.txt
new file mode 100644
index 000000000..856221952
--- /dev/null
+++ b/src/mailman/model/docs/usermanager.txt
@@ -0,0 +1,125 @@
+================
+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.component import getUtility
+ >>> user_manager = getUtility(IUserManager)
+
+
+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
+ >>> from zope.interface.verify import verifyObject
+ >>> user = user_manager.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 = 'Anne Person'
+ >>> sorted(user.real_name for user in user_manager.users)
+ [u'Anne Person']
+
+A user can be assigned a password.
+
+ >>> user.password = 'secret'
+ >>> sorted(user.password for user in user_manager.users)
+ [u'secret']
+
+You can also create a user with an address to start out with.
+
+ >>> user_2 = user_manager.create_user('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 user_manager.users)
+ [u'', u'Anne Person']
+
+As above, you can assign a real name to such users.
+
+ >>> user_2.real_name = 'Ben Person'
+ >>> sorted(user.real_name for user in user_manager.users)
+ [u'Anne Person', u'Ben Person']
+
+You can also create a user with just a real name.
+
+ >>> user_3 = user_manager.create_user(real_name='Claire Person')
+ >>> verifyObject(IUser, user_3)
+ True
+ >>> sorted(address.address for address in user.addresses)
+ []
+ >>> sorted(user.real_name for user in user_manager.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 = user_manager.create_user('dperson@example.com', '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 user_manager.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.
+
+ >>> user_manager.delete_user(user)
+ >>> sorted(user.real_name for user in user_manager.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 = user_manager.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 user_manager.get_user('zperson@example.com')
+ None
+ >>> user_4.unlink(address)
+ >>> print user_manager.get_user(address.address)
+ None
diff --git a/src/mailman/model/docs/users.txt b/src/mailman/model/docs/users.txt
new file mode 100644
index 000000000..bb0301772
--- /dev/null
+++ b/src/mailman/model/docs/users.txt
@@ -0,0 +1,208 @@
+=====
+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.
+
+ >>> from mailman.interfaces.usermanager import IUserManager
+ >>> from zope.component import getUtility
+ >>> user_manager = getUtility(IUserManager)
+
+
+User data
+=========
+
+Users may have a real name and a password.
+
+ >>> user_1 = user_manager.create_user()
+ >>> user_1.password = 'my password'
+ >>> user_1.real_name = 'Zoe Person'
+ >>> sorted(user.real_name for user in user_manager.users)
+ [u'Zoe Person']
+ >>> sorted(user.password for user in user_manager.users)
+ [u'my password']
+
+The password and real name can be changed at any time.
+
+ >>> user_1.real_name = 'Zoe X. Person'
+ >>> user_1.password = 'another password'
+ >>> sorted(user.real_name for user in user_manager.users)
+ [u'Zoe X. Person']
+ >>> sorted(user.password for user in user_manager.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('zperson@example.com', 'Zoe Person')
+ <Address: Zoe Person <zperson@example.com> [not verified] at 0x...>
+ >>> user_1.register('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 = user_manager.create_address('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 = user_manager.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('bperson@example.com')
+ False
+
+Given a text email address, the user manager can find the user that controls
+that address.
+
+ >>> user_manager.get_user('zperson@example.com') is user_1
+ True
+ >>> user_manager.get_user('zperson@example.net') is user_1
+ True
+ >>> user_manager.get_user('zperson@example.org') is user_1
+ True
+ >>> print user_manager.get_user('bperson@example.com')
+ None
+
+Addresses can also be unlinked from a user.
+
+ >>> user_1.unlink(address_1)
+ >>> user_1.controls('zperson@example.net')
+ False
+ >>> print user_manager.get_user('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.interfaces.languages import ILanguageManager
+ >>> getUtility(ILanguageManager).add('it', 'iso-8859-1', 'Italian')
+
+ >>> from mailman.core.constants import DeliveryMode
+ >>> prefs = user_1.preferences
+ >>> prefs.acknowledge_posts = True
+ >>> prefs.preferred_language = '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 : <Language [it] Italian>
+ 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 = user_manager.get_address('zperson@example.com')
+ >>> org = user_manager.get_address('zperson@example.org')
+ >>> net = user_manager.get_address('zperson@example.net')
+
+ >>> mlist_1 = create_list('xtest_1@example.com')
+ >>> mlist_2 = create_list('xtest_2@example.com')
+ >>> mlist_3 = create_list('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
+
+
+Cross references
+================
+
+.. _`usermanager.txt`: usermanager.html
+