summaryrefslogtreecommitdiff
path: root/src/mailman/model/docs/requests.rst
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman/model/docs/requests.rst')
-rw-r--r--src/mailman/model/docs/requests.rst903
1 files changed, 903 insertions, 0 deletions
diff --git a/src/mailman/model/docs/requests.rst b/src/mailman/model/docs/requests.rst
new file mode 100644
index 000000000..e01544490
--- /dev/null
+++ b/src/mailman/model/docs/requests.rst
@@ -0,0 +1,903 @@
+==================
+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
+ >>> dump_list(requests.held_requests)
+ *Empty*
+
+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 : set([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['pipeline']
+ >>> 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 : set([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 : set([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 : set([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 : set([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 : set([])
+ 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
+ >>> print member.user.real_name
+ Frank Person
+ >>> print member.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.
+::
+
+
+ >>> from mailman.interfaces.usermanager import IUserManager
+ >>> from zope.component import getUtility
+ >>> user_manager = getUtility(IUserManager)
+
+ >>> 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]
+ >>> mlist.subscribe(address_1, 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]
+ >>> mlist.subscribe(address_2, 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 : set([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 : set([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 : set([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 : set([])
+ reduced_list_headers: True
+ version : 3