diff options
| author | Barry Warsaw | 2010-01-12 08:27:38 -0500 |
|---|---|---|
| committer | Barry Warsaw | 2010-01-12 08:27:38 -0500 |
| commit | 41faffef13f11c793c140d7f18d3b0698685b7a2 (patch) | |
| tree | bce0b307279a9682afeb57e50d16aa646440e22e /src/mailman/model | |
| parent | f137d934b0d5b9e37bd24989e7fb613540ca675d (diff) | |
| download | mailman-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__.py | 0 | ||||
| -rw-r--r-- | src/mailman/model/docs/addresses.txt | 236 | ||||
| -rw-r--r-- | src/mailman/model/docs/autorespond.txt | 112 | ||||
| -rw-r--r-- | src/mailman/model/docs/domains.txt | 119 | ||||
| -rw-r--r-- | src/mailman/model/docs/languages.txt | 110 | ||||
| -rw-r--r-- | src/mailman/model/docs/listmanager.txt | 101 | ||||
| -rw-r--r-- | src/mailman/model/docs/membership.txt | 234 | ||||
| -rw-r--r-- | src/mailman/model/docs/messagestore.txt | 116 | ||||
| -rw-r--r-- | src/mailman/model/docs/mlist-addresses.txt | 77 | ||||
| -rw-r--r-- | src/mailman/model/docs/pending.txt | 94 | ||||
| -rw-r--r-- | src/mailman/model/docs/registration.txt | 350 | ||||
| -rw-r--r-- | src/mailman/model/docs/requests.txt | 896 | ||||
| -rw-r--r-- | src/mailman/model/docs/usermanager.txt | 125 | ||||
| -rw-r--r-- | src/mailman/model/docs/users.txt | 208 |
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 + |
