diff options
| -rw-r--r-- | src/mailman/app/docs/moderator.rst | 538 | ||||
| -rw-r--r-- | src/mailman/app/membership.py | 2 | ||||
| -rw-r--r-- | src/mailman/app/moderator.py | 5 | ||||
| -rw-r--r-- | src/mailman/app/notifications.py | 45 | ||||
| -rw-r--r-- | src/mailman/app/tests/test_moderation.py | 46 | ||||
| -rw-r--r-- | src/mailman/docs/NEWS.rst | 8 | ||||
| -rw-r--r-- | src/mailman/model/docs/requests.rst | 873 | ||||
| -rw-r--r-- | src/mailman/model/requests.py | 7 | ||||
| -rw-r--r-- | src/mailman/model/tests/test_requests.py | 21 | ||||
| -rw-r--r-- | src/mailman/rest/docs/moderation.rst | 253 | ||||
| -rw-r--r-- | src/mailman/rest/lists.py | 11 | ||||
| -rw-r--r-- | src/mailman/rest/moderation.py | 179 | ||||
| -rw-r--r-- | src/mailman/rest/tests/test_moderation.py | 2 |
13 files changed, 1090 insertions, 900 deletions
diff --git a/src/mailman/app/docs/moderator.rst b/src/mailman/app/docs/moderator.rst new file mode 100644 index 000000000..ec48f6e88 --- /dev/null +++ b/src/mailman/app/docs/moderator.rst @@ -0,0 +1,538 @@ +.. _app-moderator: + +============================ +Application level moderation +============================ + +At an application level, moderation involves holding messages and membership +changes for moderator approval. This utilizes the :ref:`lower level interface +<model-requests>` for list-centric moderation requests. + +Moderation is always mailing list-centric. + + >>> mlist = create_list('ant@example.com') + >>> mlist.preferred_language = 'en' + >>> mlist.display_name = 'A Test List' + >>> mlist.admin_immed_notify = False + +We'll use the lower level API for diagnostic purposes. + + >>> from mailman.interfaces.requests import IListRequests + >>> requests = IListRequests(mlist) + + +Message moderation +================== + +Holding messages +---------------- + +Anne posts a message to the mailing list, but she is not a member of the list, +so the message is held for moderator approval. + + >>> msg = message_from_string("""\ + ... From: anne@example.org + ... To: ant@example.com + ... Subject: Something important + ... Message-ID: <aardvark> + ... + ... 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, the +message, its metadata, and a reason for the hold must be provided. In this +case, we won't include any additional metadata. + + >>> from mailman.app.moderator import hold_message + >>> hold_message(mlist, msg, {}, 'Needs approval') + 1 + +We can also hold a message with some additional metadata. +:: + + >>> msg = message_from_string("""\ + ... From: bart@example.org + ... To: ant@example.com + ... Subject: Something important + ... Message-ID: <badger> + ... + ... Here's something important about our mailing list. + ... """) + >>> msgdata = dict(sender='anne@example.com', approved=True) + + >>> hold_message(mlist, msg, msgdata, 'Feeling ornery') + 2 + + +Disposing of messages +--------------------- + +The moderator can select one of several dispositions: + + * discard - throw the message away. + * reject - bounces the message back to the original author. + * defer - defer any action on the message (continue to hold it) + * accept - accept the message for posting. + +The most trivial is to simply defer a decision for now. + + >>> from mailman.interfaces.action import Action + >>> from mailman.app.moderator import handle_message + >>> handle_message(mlist, 1, Action.defer) + +This leaves the message in the requests database. + + >>> key, data = requests.get_request(1) + >>> print key + <aardvark> + +The moderator can also discard the message. + + >>> handle_message(mlist, 1, Action.discard) + >>> print requests.get_request(1) + None + +The message can be rejected, which bounces the message back to the original +sender. + + >>> handle_message(mlist, 2, Action.reject, 'Off topic') + +The message is no longer available in the requests database. + + >>> print requests.get_request(2) + None + +And there is one message in the *virgin* queue - the rejection notice. + + >>> from mailman.testing.helpers import get_queue_messages + >>> messages = get_queue_messages('virgin') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + MIME-Version: 1.0 + ... + Subject: Request to mailing list "A Test List" rejected + From: ant-bounces@example.com + To: bart@example.org + ... + <BLANKLINE> + Your request to the ant@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> + ant-owner@example.com + <BLANKLINE> + +The bounce gets sent to the original sender. + + >>> for recipient in sorted(messages[0].msgdata['recipients']): + ... print recipient + bart@example.org + +Or the message can be approved. + + >>> msg = message_from_string("""\ + ... From: cris@example.org + ... To: ant@example.com + ... Subject: Something important + ... Message-ID: <caribou> + ... + ... Here's something important about our mailing list. + ... """) + >>> id = hold_message(mlist, msg, {}, 'Needs approval') + >>> handle_message(mlist, id, Action.accept) + +This places the message back into the incoming queue for further processing, +however the message metadata indicates that the message has been approved. +:: + + >>> messages = get_queue_messages('pipeline') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + From: cris@example.org + To: ant@example.com + Subject: Something important + ... + + >>> dump_msgdata(messages[0].msgdata) + _parsemsg : False + approved : True + moderator_approved: True + version : 3 + + +Preserving and forwarding the message +------------------------------------- + +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 later). When handling a message, we can ask for +a copy to be preserve, which skips deleting the message from the storage. +:: + + >>> msg = message_from_string("""\ + ... From: dave@example.org + ... To: ant@example.com + ... Subject: Something important + ... Message-ID: <dolphin> + ... + ... Here's something important about our mailing list. + ... """) + >>> id = hold_message(mlist, msg, {}, 'Needs approval') + >>> handle_message(mlist, id, Action.discard, preserve=True) + + >>> from mailman.interfaces.messages import IMessageStore + >>> from zope.component import getUtility + >>> message_store = getUtility(IMessageStore) + >>> print message_store.get_message_by_id('<dolphin>')['message-id'] + <dolphin> + +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. +:: + + >>> msg = message_from_string("""\ + ... From: elly@example.org + ... To: ant@example.com + ... Subject: Something important + ... Message-ID: <elephant> + ... + ... Here's something important about our mailing list. + ... """) + >>> hold_message(mlist, msg, {}, 'Needs approval') + 2 + >>> handle_message(mlist, 2, Action.discard, forward=['zack@example.com']) + +The forwarded message is in the virgin queue, destined for the moderator. +:: + + >>> messages = get_queue_messages('virgin') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + Subject: Forward of moderated message + From: ant-bounces@example.com + To: zack@example.com + ... + + >>> for recipient in sorted(messages[0].msgdata['recipients']): + ... print recipient + zack@example.com + + +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.app.moderator import hold_subscription + >>> from mailman.interfaces.member import DeliveryMode + >>> hold_subscription(mlist, + ... 'fred@example.org', 'Fred Person', + ... '{NONE}abcxyz', DeliveryMode.regular, 'en') + 2 + + +Disposing of membership change requests +--------------------------------------- + +Just as with held messages, the moderator can select one of several +dispositions for this membership change request. The most trivial is to +simply defer a decision for now. + + >>> from mailman.app.moderator import handle_subscription + >>> handle_subscription(mlist, 2, Action.defer) + >>> requests.get_request(2) is not None + True + +The held subscription can also be discarded. + + >>> handle_subscription(mlist, 2, Action.discard) + >>> print requests.get_request(2) + None + +Gwen tries to subscribe to the mailing list, but... + + >>> hold_subscription(mlist, + ... 'gwen@example.org', 'Gwen Person', + ... '{NONE}zyxcba', DeliveryMode.regular, 'en') + 2 + +...her request is rejected... + + >>> handle_subscription(mlist, 2, Action.reject, 'This is a closed list') + >>> messages = get_queue_messages('virgin') + >>> len(messages) + 1 + +...and she receives a rejection notice. + + >>> print messages[0].msg.as_string() + MIME-Version: 1.0 + ... + Subject: Request to mailing list "A Test List" rejected + From: ant-bounces@example.com + To: gwen@example.org + ... + Your request to the ant@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" + ... + +The subscription can also be accepted. This subscribes the address to the +mailing list. + + >>> mlist.send_welcome_message = False + >>> hold_subscription(mlist, + ... 'herb@example.org', 'Herb Person', + ... 'abcxyz', DeliveryMode.regular, 'en') + 2 + +The moderators accept the subscription request. + + >>> handle_subscription(mlist, 2, Action.accept) + +And now Herb is a member of the mailing list. + + >>> print mlist.members.get_member('herb@example.org').address + Herb Person <herb@example.org> + + +Holding unsubscription requests +=============================== + +Some lists require moderator approval for unsubscriptions. In this case, only +the unsubscribing address is required. + +Herb now wants to leave the mailing list, but his request must be approved. + + >>> from mailman.app.moderator import hold_unsubscription + >>> hold_unsubscription(mlist, 'herb@example.org') + 2 + +As with subscription requests, the unsubscription request can be deferred. + + >>> from mailman.app.moderator import handle_unsubscription + >>> handle_unsubscription(mlist, 2, Action.defer) + >>> print mlist.members.get_member('herb@example.org').address + Herb Person <herb@example.org> + +The held unsubscription can also be discarded, and the member will remain +subscribed. + + >>> handle_unsubscription(mlist, 2, Action.discard) + >>> print mlist.members.get_member('herb@example.org').address + Herb Person <herb@example.org> + +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. + + >>> hold_unsubscription(mlist, 'herb@example.org') + 2 + >>> handle_unsubscription(mlist, 2, Action.reject, 'No can do') + >>> print mlist.members.get_member('herb@example.org').address + Herb Person <herb@example.org> + +Herb gets a rejection notice. +:: + + >>> messages = get_queue_messages('virgin') + >>> len(messages) + 1 + + >>> print messages[0].msg.as_string() + MIME-Version: 1.0 + ... + Subject: Request to mailing list "A Test List" rejected + From: ant-bounces@example.com + To: herb@example.org + ... + Your request to the ant@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> + "No can do" + ... + +The unsubscription request can also be accepted. This removes the member from +the mailing list. + + >>> hold_unsubscription(mlist, 'herb@example.org') + 2 + >>> mlist.send_goodbye_message = False + >>> handle_unsubscription(mlist, 2, Action.accept) + >>> print mlist.members.get_member('herb@example.org') + None + + +Notifications +============= + +Membership change requests +-------------------------- + +Usually, the list administrators want to be notified when there are membership +change requests they need to moderate. These notifications are sent when the +list is configured to send them. + + >>> mlist.admin_immed_notify = True + +Iris tries to subscribe to the mailing list. + + >>> hold_subscription(mlist, 'iris@example.org', 'Iris Person', + ... 'password', DeliveryMode.regular, 'en') + 2 + +There's now a message in the virgin queue, destined for the list owner. + + >>> messages = get_queue_messages('virgin') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + MIME-Version: 1.0 + ... + Subject: New subscription request to A Test List from iris@example.org + From: ant-owner@example.com + To: ant-owner@example.com + ... + Your authorization is required for a mailing list subscription request + approval: + <BLANKLINE> + For: iris@example.org + List: ant@example.com + ... + +Similarly, the administrator gets notifications on unsubscription requests. +Jeff is a member of the mailing list, and chooses to unsubscribe. + + >>> hold_unsubscription(mlist, 'jeff@example.org') + 3 + >>> messages = get_queue_messages('virgin') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + MIME-Version: 1.0 + ... + Subject: New unsubscription request from A Test List by jeff@example.org + From: ant-owner@example.com + To: ant-owner@example.com + ... + Your authorization is required for a mailing list unsubscription + request approval: + <BLANKLINE> + By: jeff@example.org + From: ant@example.com + ... + + +Membership changes +------------------ + +When a new member request is accepted, the mailing list administrators can +receive a membership change notice. + + >>> mlist.admin_notify_mchanges = True + >>> mlist.admin_immed_notify = False + >>> handle_subscription(mlist, 2, Action.accept) + >>> messages = get_queue_messages('virgin') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + MIME-Version: 1.0 + ... + Subject: A Test List subscription notification + From: noreply@example.com + To: ant-owner@example.com + ... + Iris Person <iris@example.org> has been successfully subscribed to A + Test List. + +Similarly when an unsubscription request is accepted, the administrators can +get a notification. + + >>> hold_unsubscription(mlist, 'iris@example.org') + 4 + >>> handle_unsubscription(mlist, 4, Action.accept) + >>> messages = get_queue_messages('virgin') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + MIME-Version: 1.0 + ... + Subject: A Test List unsubscription notification + From: noreply@example.com + To: ant-owner@example.com + ... + Iris Person <iris@example.org> has been removed from A Test List. + + +Welcome messages +---------------- + +When a member is subscribed to the mailing list via moderator approval, she +can get a welcome message. + + >>> mlist.admin_notify_mchanges = False + >>> mlist.send_welcome_message = True + >>> hold_subscription(mlist, 'kate@example.org', 'Kate Person', + ... 'password', DeliveryMode.regular, 'en') + 4 + >>> handle_subscription(mlist, 4, Action.accept) + >>> messages = get_queue_messages('virgin') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + MIME-Version: 1.0 + ... + Subject: Welcome to the "A Test List" mailing list + From: ant-request@example.com + To: Kate Person <kate@example.org> + ... + Welcome to the "A Test List" mailing list! + ... + + +Goodbye messages +---------------- + +Similarly, when the member's unsubscription request is approved, she'll get a +goodbye message. + + >>> mlist.send_goodbye_message = True + >>> hold_unsubscription(mlist, 'kate@example.org') + 4 + >>> handle_unsubscription(mlist, 4, Action.accept) + >>> messages = get_queue_messages('virgin') + >>> len(messages) + 1 + >>> print messages[0].msg.as_string() + MIME-Version: 1.0 + ... + Subject: You have been unsubscribed from the A Test List mailing list + From: ant-bounces@example.com + To: kate@example.org + ... diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py index 3f012e5a5..dfe794610 100644 --- a/src/mailman/app/membership.py +++ b/src/mailman/app/membership.py @@ -131,7 +131,7 @@ def delete_member(mlist, email, admin_notif=None, userack=None): mailing list. """ if userack is None: - userack = mlist.send_goodbye_msg + userack = mlist.send_goodbye_message if admin_notif is None: admin_notif = mlist.admin_notify_mchanges # Delete a member, for which we know the approval has been made. diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py index 2e2711809..7e6f4758e 100644 --- a/src/mailman/app/moderator.py +++ b/src/mailman/app/moderator.py @@ -202,7 +202,7 @@ def hold_subscription(mlist, address, display_name, password, mode, language): address=address, display_name=display_name, password=password, - delivery_mode=str(mode), + delivery_mode=mode.name, language=language) # Now hold this request. We'll use the address as the key. requestsdb = IListRequests(mlist) @@ -246,8 +246,7 @@ def handle_subscription(mlist, id, action, comment=None): lang=getUtility(ILanguageManager)[data['language']]) elif action is Action.accept: key, data = requestdb.get_request(id) - enum_value = data['delivery_mode'].split('.')[-1] - delivery_mode = DeliveryMode(enum_value) + delivery_mode = DeliveryMode(data['delivery_mode']) address = data['address'] display_name = data['display_name'] language = getUtility(ILanguageManager)[data['language']] diff --git a/src/mailman/app/notifications.py b/src/mailman/app/notifications.py index bc5b326ac..841822c84 100644 --- a/src/mailman/app/notifications.py +++ b/src/mailman/app/notifications.py @@ -47,6 +47,24 @@ log = logging.getLogger('mailman.error') +def _get_message(uri_template, mlist, language): + if not uri_template: + return '' + try: + uri = expand(uri_template, dict( + listname=mlist.fqdn_listname, + language=language.code, + )) + message = getUtility(ITemplateLoader).get(uri) + except URLError: + log.exception('Message URI not found ({0}): {1}'.format( + mlist.fqdn_listname, uri_template)) + return '' + else: + return wrap(message) + + + def send_welcome_message(mlist, address, language, delivery_mode, text=''): """Send a welcome message to a subscriber. @@ -62,28 +80,15 @@ def send_welcome_message(mlist, address, language, delivery_mode, text=''): :param delivery_mode: the type of delivery the subscriber is getting :type delivery_mode: DeliveryMode """ - if mlist.welcome_message_uri: - try: - uri = expand(mlist.welcome_message_uri, dict( - listname=mlist.fqdn_listname, - language=language.code, - )) - welcome_message = getUtility(ITemplateLoader).get(uri) - except URLError: - log.exception('Welcome message URI not found ({0}): {1}'.format( - mlist.fqdn_listname, mlist.welcome_message_uri)) - welcome = '' - else: - welcome = wrap(welcome_message) - else: - welcome = '' + welcome_message = _get_message(mlist.welcome_message_uri, + mlist, language) # Find the IMember object which is subscribed to the mailing list, because # from there, we can get the member's options url. member = mlist.members.get_member(address) user_name = member.user.display_name options_url = member.options_url # Get the text from the template. - text = expand(welcome, dict( + text = expand(welcome_message, dict( fqdn_listname=mlist.fqdn_listname, list_name=mlist.display_name, listinfo_uri=mlist.script_url('listinfo'), @@ -119,15 +124,13 @@ def send_goodbye_message(mlist, address, language): :param language: the language of the response :type language: string """ - if mlist.goodbye_msg: - goodbye = wrap(mlist.goodbye_msg) + '\n' - else: - goodbye = '' + goodbye_message = _get_message(mlist.goodbye_message_uri, + mlist, language) msg = UserNotification( address, mlist.bounces_address, _('You have been unsubscribed from the $mlist.display_name ' 'mailing list'), - goodbye, language) + goodbye_message, language) msg.send(mlist, verp=as_boolean(config.mta.verp_personalized_deliveries)) diff --git a/src/mailman/app/tests/test_moderation.py b/src/mailman/app/tests/test_moderation.py index dc1217d67..8efffb48b 100644 --- a/src/mailman/app/tests/test_moderation.py +++ b/src/mailman/app/tests/test_moderation.py @@ -27,15 +27,18 @@ __all__ = [ import unittest +from zope.component import getUtility + from mailman.app.lifecycle import create_list from mailman.app.moderator import handle_message, hold_message from mailman.interfaces.action import Action +from mailman.interfaces.messages import IMessageStore from mailman.interfaces.requests import IListRequests from mailman.runners.incoming import IncomingRunner from mailman.runners.outgoing import OutgoingRunner from mailman.runners.pipeline import PipelineRunner from mailman.testing.helpers import ( - make_testable_runner, specialized_message_from_string) + get_queue_messages, make_testable_runner, specialized_message_from_string) from mailman.testing.layers import SMTPLayer from mailman.utilities.datetime import now @@ -48,6 +51,7 @@ class TestModeration(unittest.TestCase): def setUp(self): self._mlist = create_list('test@example.com') + self._request_db = IListRequests(self._mlist) self._msg = specialized_message_from_string("""\ From: anne@example.com To: test@example.com @@ -101,11 +105,10 @@ Message-ID: <alpha> request_id = hold_message(self._mlist, self._msg) handle_message(self._mlist, request_id, Action.defer) # The message is still in the pending requests. - requests_db = IListRequests(self._mlist) - key, data = requests_db.get_request(request_id) + key, data = self._request_db.get_request(request_id) self.assertEqual(key, '<alpha>') handle_message(self._mlist, request_id, Action.hold) - key, data = requests_db.get_request(request_id) + key, data = self._request_db.get_request(request_id) self.assertEqual(key, '<alpha>') def test_lp_1031391(self): @@ -115,6 +118,37 @@ Message-ID: <alpha> received_time = now() msgdata = dict(received_time=received_time) request_id = hold_message(self._mlist, self._msg, msgdata) - requests_db = IListRequests(self._mlist) - key, data = requests_db.get_request(request_id) + key, data = self._request_db.get_request(request_id) self.assertEqual(data['received_time'], received_time) + + def test_non_preserving_disposition(self): + # By default, disposed messages are not preserved. + request_id = hold_message(self._mlist, self._msg) + handle_message(self._mlist, request_id, Action.discard) + message_store = getUtility(IMessageStore) + self.assertIsNone(message_store.get_message_by_id('<alpha>')) + + def test_preserving_disposition(self): + # Preserving a message keeps it in the store. + request_id = hold_message(self._mlist, self._msg) + handle_message(self._mlist, request_id, Action.discard, preserve=True) + message_store = getUtility(IMessageStore) + preserved_message = message_store.get_message_by_id('<alpha>') + self.assertEqual(preserved_message['message-id'], '<alpha>') + + def test_preserve_and_forward(self): + # We can both preserve and forward the message. + request_id = hold_message(self._mlist, self._msg) + handle_message(self._mlist, request_id, Action.discard, + preserve=True, forward=['zack@example.com']) + # The message is preserved in the store. + message_store = getUtility(IMessageStore) + preserved_message = message_store.get_message_by_id('<alpha>') + self.assertEqual(preserved_message['message-id'], '<alpha>') + # And the forwarded message lives in the virgin queue. + messages = get_queue_messages('virgin') + self.assertEqual(len(messages), 1) + self.assertEqual(str(messages[0].msg['subject']), + 'Forward of moderated message') + self.assertEqual(messages[0].msgdata['recipients'], + ['zack@example.com']) diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst index 4c660fffd..f24f2f7d1 100644 --- a/src/mailman/docs/NEWS.rst +++ b/src/mailman/docs/NEWS.rst @@ -16,12 +16,14 @@ Compatibility ------------- * Python 2.7 is not required. Python 2.6 is no longer officially supported. The code base is now also `python2.7 -3` clean, although there are still - some warnings in 3rd party dependencies. LP: #1073506 + some warnings in 3rd party dependencies. (LP: #1073506) REST ---- * Allow the getting/setting of IMailingList.subject_prefix via the REST API (given by Terri Oda). (LP: #1062893) + * Expose a REST API for membership change (subscriptions and unsubscriptions) + moderation. (LP: #1090753) * Add list_id to JSON representation for a mailing list (given by Jimmy Bergman). * The canonical resource for a mailing list (and thus its self_link) is now @@ -71,6 +73,10 @@ Commands * `bin/mailman start` was passing the wrong relative path to its runner subprocesses when -C was given. LP: #982551 +Bugs +---- + * Fixed `send_goodbye_message()`. (LP: #1091321) + 3.0 beta 2 -- "Freeze" ====================== diff --git a/src/mailman/model/docs/requests.rst b/src/mailman/model/docs/requests.rst index a51cbc099..f911e8fbb 100644 --- a/src/mailman/model/docs/requests.rst +++ b/src/mailman/model/docs/requests.rst @@ -1,3 +1,5 @@ +.. _model-requests: + ================== Moderator requests ================== @@ -6,22 +8,15 @@ 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]) +An :ref:`application level interface <app-moderator>` for holding messages and +membership changes is also available. 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. +A set of requests are always related to a particular mailing list. Adapt the +mailing list to get its requests. :: >>> from mailman.interfaces.requests import IListRequests @@ -48,60 +43,48 @@ The list's requests database starts out 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. +data, except for the request type. >>> 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. +We can hold messages for moderator approval. - >>> requests.count - 4 - >>> requests.count_of(RequestType.held_message) - 2 - >>> requests.count_of(RequestType.subscription) + >>> requests.hold_request(RequestType.held_message, 'hold_1') 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. +We can hold subscription requests for moderator approval. - >>> requests.hold_request(5, 'foo') - Traceback (most recent call last): - ... - TypeError: 5 + >>> requests.hold_request(RequestType.subscription, 'hold_2') + 2 -We can hold requests with additional data. +We can hold unsubscription requests for moderator approval. - >>> 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 + >>> requests.hold_request(RequestType.unsubscription, 'hold_3') + 3 Getting requests ================ +We can see the total number of requests being held. + + >>> requests.count + 3 + +We can also see the number of requests being held by request type. + + >>> requests.count_of(RequestType.subscription) + 1 + >>> requests.count_of(RequestType.unsubscription) + 1 + +We can also see when there are multiple held requests of a particular type. + + >>> requests.hold_request(RequestType.held_message, 'hold_4') + 4 + >>> requests.count_of(RequestType.held_message) + 2 + 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. @@ -110,25 +93,37 @@ originally held. >>> print key hold_2 -Because we did not store additional data with request 2, it comes back as None -now. +There was no additional data associated with request 2. >>> print data None -However, if we ask for a request that had data, we'd get it back now. +If we ask for a request that is not in the database, we get None back. + + >>> print requests.get_request(801) + None + + +Additional data +=============== + +When a request is held, additional data can be associated with it, in the form +of a dictionary with string values. + + >>> data = dict(foo='yes', bar='no') + >>> requests.hold_request(RequestType.held_message, 'hold_5', data) + 5 + +The data is returned when the request is retrieved. The dictionary will have +an additional key which holds the name of the request type. >>> 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 + _request_type: held_message + bar : no + foo : yes Iterating over requests @@ -140,767 +135,37 @@ 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 + ... print request.id, request.request_type, 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 + 1 RequestType.held_message hold_1 + 4 RequestType.held_message hold_4 + 5 RequestType.held_message hold_5 + _request_type: held_message + bar: no + foo: yes Deleting requests ================= -Once a specific request has been handled, it will be deleted from the requests +Once a specific request has been handled, it can be deleted from the requests database. + >>> requests.count + 5 >>> 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. +Request 2 is no longer 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. + >>> print requests.get_request(2) + None >>> 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.display_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) - >>> 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 - >>> from mailman.testing.helpers import get_queue_messages - >>> get_queue_messages('virgin') - [] - -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 - >>> messages = get_queue_messages('virgin') - >>> len(messages) - 1 - >>> print messages[0].msg.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(messages[0].msgdata) - _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) - >>> messages = get_queue_messages('pipeline') - >>> len(messages) - 1 - >>> print messages[0].msg.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(messages[0].msgdata) - _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']) - - >>> messages = get_queue_messages('virgin') - >>> len(messages) - 1 - >>> print messages[0].msg.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(messages[0].msgdata) - _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. - - >>> get_queue_messages('virgin') - [] - -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 - >>> messages = get_queue_messages('virgin') - >>> len(messages) - 1 - - >>> print messages[0].msg.as_string() - MIME-Version: 1.0 - Content-Type: text/plain; charset="us-ascii" - Content-Transfer-Encoding: 7bit - Subject: New subscription request to 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(messages[0].msgdata) - _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 - >>> messages = get_queue_messages('virgin') - >>> len(messages) - 1 - - >>> print messages[0].msg.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(messages[0].msgdata) - _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_message = True - >>> mlist.welcome_message_uri = 'mailman:///welcome.txt' - >>> id_4 = moderator.hold_subscription(mlist, - ... 'fperson@example.org', 'Frank Person', - ... '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. -:: - - >>> messages = get_queue_messages('virgin') - >>> len(messages) - 1 - - >>> print messages[0].msg.as_string() - MIME-Version: 1.0 - Content-Type: text/plain; charset="us-ascii" - Content-Transfer-Encoding: 7bit - Subject: New subscription request to 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(messages[0].msgdata) - _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. - - >>> messages = get_queue_messages('virgin', sort_on='subject') - >>> len(messages) - 2 - -The welcome message is sent to the person who just subscribed. -:: - - >>> print messages[1].msg.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: Frank Person <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(messages[1].msgdata) - _parsemsg : False - listname : alist@example.com - nodecorate : True - recipients : set([u'Frank Person <fperson@example.org>']) - reduced_list_headers: True - verp : False - version : 3 - -The admin message is sent to the moderators. -:: - - >>> print messages[0].msg.as_string() - MIME-Version: 1.0 - Content-Type: text/plain; charset="us-ascii" - Content-Transfer-Encoding: 7bit - Subject: A Test List subscription notification - From: noreply@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(messages[0].msgdata) - _parsemsg : False - envsender : noreply@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.display_name - Frank Person - >>> print member.user.password - {plaintext}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 - >>> get_queue_messages('virgin') - [] - >>> mlist.admin_immed_notify = True - >>> id_6 = moderator.hold_unsubscription(mlist, 'hperson@example.com') - - >>> messages = get_queue_messages('virgin') - >>> len(messages) - 1 - >>> print messages[0].msg.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(messages[0].msgdata) - _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 - >>> messages = get_queue_messages('virgin') - >>> len(messages) - 1 - - >>> print messages[0].msg.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(messages[0].msgdata) - _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. - - >>> messages = get_queue_messages('virgin') - >>> len(messages) - 2 - -The goodbye message... -:: - - >>> print messages[0].msg.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(messages[0].msgdata) - _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 messages[1].msg.as_string() - MIME-Version: 1.0 - Content-Type: text/plain; charset="us-ascii" - Content-Transfer-Encoding: 7bit - Subject: A Test List unsubscription notification - From: noreply@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(messages[1].msgdata) - _parsemsg : False - envsender : noreply@example.com - listname : alist@example.com - nodecorate : True - recipients : set([]) - reduced_list_headers: True - version : 3 diff --git a/src/mailman/model/requests.py b/src/mailman/model/requests.py index 5eb940233..58f2f2d4c 100644 --- a/src/mailman/model/requests.py +++ b/src/mailman/model/requests.py @@ -40,6 +40,8 @@ from mailman.interfaces.requests import IListRequests, RequestType @implementer(IPendable) class DataPendable(dict): + """See `IPendable`.""" + def update(self, mapping): # Keys and values must be strings (unicodes, but bytes values are # accepted for now). Any other types for keys are a programming @@ -58,6 +60,7 @@ class DataPendable(dict): @implementer(IListRequests) class ListRequests: + """See `IListRequests`.""" def __init__(self, mailing_list): self.mailing_list = mailing_list @@ -111,7 +114,7 @@ class ListRequests: if request_type is not None and result.request_type != request_type: return None if result.data_hash is None: - return result.key, result.data_hash + return result.key, None pendable = getUtility(IPendings).confirm( result.data_hash, expunge=False) data = dict() @@ -121,6 +124,8 @@ class ListRequests: data[key[5:]] = loads(value.encode('raw-unicode-escape')) else: data[key] = value + # Some APIs need the request type. + data['_request_type'] = result.request_type.name return result.key, data @dbconnection diff --git a/src/mailman/model/tests/test_requests.py b/src/mailman/model/tests/test_requests.py index a85add748..5e4d614fb 100644 --- a/src/mailman/model/tests/test_requests.py +++ b/src/mailman/model/tests/test_requests.py @@ -21,6 +21,7 @@ from __future__ import absolute_import, print_function, unicode_literals __metaclass__ = type __all__ = [ + 'TestRequests', ] @@ -39,6 +40,7 @@ class TestRequests(unittest.TestCase): def setUp(self): self._mlist = create_list('ant@example.com') + self._requests_db = IListRequests(self._mlist) self._msg = mfs("""\ From: anne@example.com To: ant@example.com @@ -51,16 +53,27 @@ Something else. def test_get_request_with_type(self): # get_request() takes an optional request type. request_id = hold_message(self._mlist, self._msg) - requests_db = IListRequests(self._mlist) # Submit a request with a non-matching type. This should return None # as if there were no matches. - response = requests_db.get_request( + response = self._requests_db.get_request( request_id, RequestType.subscription) self.assertEqual(response, None) # Submit the same request with a matching type. - key, data = requests_db.get_request( + key, data = self._requests_db.get_request( request_id, RequestType.held_message) self.assertEqual(key, '<alpha>') # It should also succeed with no optional request type given. - key, data = requests_db.get_request(request_id) + key, data = self._requests_db.get_request(request_id) self.assertEqual(key, '<alpha>') + + def test_hold_with_bogus_type(self): + # Calling hold_request() with a bogus request type is an error. + with self.assertRaises(TypeError) as cm: + self._requests_db.hold_request(5, 'foo') + self.assertEqual(cm.exception.message, 5) + + def test_delete_missing_request(self): + # Trying to delete a missing request is an error. + with self.assertRaises(KeyError) as cm: + self._requests_db.delete_request(801) + self.assertEqual(cm.exception.message, 801) diff --git a/src/mailman/rest/docs/moderation.rst b/src/mailman/rest/docs/moderation.rst index e4c298dc4..1b08b8d06 100644 --- a/src/mailman/rest/docs/moderation.rst +++ b/src/mailman/rest/docs/moderation.rst @@ -1,9 +1,21 @@ -======================= -Held message moderation -======================= +========== +Moderation +========== + +There are two kinds of moderation tasks a list administrator may need to +perform. Messages which are held for approval can be accepted, rejected, +discarded, or deferred. Subscription (and sometimes unsubscription) requests +can similarly be accepted, discarded, rejected, or deferred. + + +Message moderation +================== + +Viewing the list of held messages +--------------------------------- Held messages can be moderated through the REST API. A mailing list starts -out with no held messages. +with no held messages. >>> ant = create_list('ant@example.com') >>> transaction.commit() @@ -30,16 +42,22 @@ When a message gets held for moderator approval, it shows up in this list. >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/held') entry 0: - data: {u'_mod_subject': u'Something', - u'_mod_message_id': u'<alpha>', - u'extra': 7, - u'_mod_fqdn_listname': u'ant@example.com', - u'_mod_hold_date': u'2005-08-01T07:49:23', - u'_mod_reason': u'Because', - u'_mod_sender': u'anne@example.com'} + extra: 7 + hold_date: 2005-08-01T07:49:23 http_etag: "..." - id: ... - key: <alpha> + message_id: <alpha> + msg: From: anne@example.com + To: ant@example.com + Subject: Something + Message-ID: <alpha> + X-Message-ID-Hash: GCSMSG43GYWWVUMO6F7FBUSSPNXQCJ6M + <BLANKLINE> + Something else. + <BLANKLINE> + reason: Because + request_id: 1 + sender: anne@example.com + subject: Something http_etag: "..." start: 0 total_size: 1 @@ -53,18 +71,11 @@ message. This will include the text of the message. ... 'ant@example.com/held/{0}'.format(request_id)) >>> dump_json(url(request_id)) - data: {u'_mod_subject': u'Something', - u'_mod_message_id': u'<alpha>', - u'extra': 7, - u'_mod_fqdn_listname': u'ant@example.com', - u'_mod_hold_date': u'2005-08-01T07:49:23', - u'_mod_reason': u'Because', - u'_mod_sender': u'anne@example.com'} + extra: 7 + hold_date: 2005-08-01T07:49:23 http_etag: "..." - id: 1 - key: <alpha> - msg: - From: anne@example.com + message_id: <alpha> + msg: From: anne@example.com To: ant@example.com Subject: Something Message-ID: <alpha> @@ -72,6 +83,14 @@ message. This will include the text of the message. <BLANKLINE> Something else. <BLANKLINE> + reason: Because + request_id: 1 + sender: anne@example.com + subject: Something + + +Disposing of held messages +-------------------------- Individual messages can be moderated through the API by POSTing back to the held message's resource. The POST data requires an action of one of the @@ -95,16 +114,10 @@ Let's see what happens when the above message is deferred. The message is still in the moderation queue. >>> dump_json(url(request_id)) - data: {u'_mod_subject': u'Something', - u'_mod_message_id': u'<alpha>', - u'extra': 7, - u'_mod_fqdn_listname': u'ant@example.com', - u'_mod_hold_date': u'2005-08-01T07:49:23', - u'_mod_reason': u'Because', - u'_mod_sender': u'anne@example.com'} + extra: 7 + hold_date: 2005-08-01T07:49:23 http_etag: "..." - id: 1 - key: <alpha> + message_id: <alpha> msg: From: anne@example.com To: ant@example.com Subject: Something @@ -113,6 +126,10 @@ The message is still in the moderation queue. <BLANKLINE> Something else. <BLANKLINE> + reason: Because + request_id: 1 + sender: anne@example.com + subject: Something The held message can be discarded. @@ -141,7 +158,7 @@ moderation. >>> transaction.commit() >>> results = call_http(url(request_id)) - >>> print results['key'] + >>> print results['message_id'] <bravo> >>> dump_json(url(request_id), { @@ -169,7 +186,7 @@ to the original author. >>> transaction.commit() >>> results = call_http(url(request_id)) - >>> print results['key'] + >>> print results['message_id'] <charlie> >>> dump_json(url(request_id), { @@ -186,3 +203,169 @@ to the original author. 1 >>> print messages[0].msg['subject'] Request to mailing list "Ant" rejected + + +Subscription moderation +======================= + +Viewing subscription requests +----------------------------- + +Subscription and unsubscription requests can be moderated via the REST API as +well. A mailing list starts with no pending subscription or unsubscription +requests. + + >>> ant.admin_immed_notify = False + >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') + http_etag: "..." + start: 0 + total_size: 0 + +When Anne tries to subscribe to the Ant list, her subscription is held for +moderator approval. + + >>> from mailman.app.moderator import hold_subscription + >>> from mailman.interfaces.member import DeliveryMode + >>> hold_subscription( + ... ant, 'anne@example.com', 'Anne Person', + ... 'password', DeliveryMode.regular, 'en') + 1 + >>> transaction.commit() + +The subscription request is available from the mailing list. + + >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') + entry 0: + address: anne@example.com + delivery_mode: regular + display_name: Anne Person + http_etag: "..." + language: en + password: password + request_id: 1 + type: subscription + when: 2005-08-01T07:49:23 + http_etag: "..." + start: 0 + total_size: 1 + + +Viewing unsubscription requests +------------------------------- + +Bart tries to leave a mailing list, but he may not be allowed to. + + >>> from mailman.app.membership import add_member + >>> from mailman.app.moderator import hold_unsubscription + >>> bart = add_member(ant, 'bart@example.com', 'Bart Person', + ... 'password', DeliveryMode.regular, 'en') + >>> hold_unsubscription(ant, 'bart@example.com') + 2 + >>> transaction.commit() + +The unsubscription request is also available from the mailing list. + + >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') + entry 0: + address: anne@example.com + delivery_mode: regular + display_name: Anne Person + http_etag: "..." + language: en + password: password + request_id: 1 + type: subscription + when: 2005-08-01T07:49:23 + entry 1: + address: bart@example.com + http_etag: "..." + request_id: 2 + type: unsubscription + http_etag: "..." + start: 0 + total_size: 2 + + +Viewing individual requests +--------------------------- + +You can view an individual membership change request by providing the +request id. Anne's subscription request looks like this. + + >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests/1') + address: anne@example.com + delivery_mode: regular + display_name: Anne Person + http_etag: "..." + language: en + password: password + request_id: 1 + type: subscription + when: 2005-08-01T07:49:23 + +Bart's unsubscription request looks like this. + + >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests/2') + address: bart@example.com + http_etag: "..." + request_id: 2 + type: unsubscription + + +Disposing of subscription requests +---------------------------------- + +Similar to held messages, you can dispose of held subscription and +unsubscription requests by POSTing back to the request's resource. The POST +data requires an action of one of the following: + + * discard - throw the request away. + * reject - the request is denied and a notification is sent to the email + address requesting the membership change. + * defer - defer any action on this membership change (continue to hold it). + * accept - accept the membership change. + +Anne's subscription request is accepted. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'ant@example.com/requests/1', { + ... 'action': 'accept', + ... }) + content-length: 0 + date: ... + server: ... + status: 204 + +Anne is now a member of the mailing list. + + >>> transaction.abort() + >>> ant.members.get_member('anne@example.com') + <Member: Anne Person <anne@example.com> on ant@example.com + as MemberRole.member> + >>> transaction.abort() + +Bart's unsubscription request is discarded. + + >>> dump_json('http://localhost:9001/3.0/lists/' + ... 'ant@example.com/requests/2', { + ... 'action': 'discard', + ... }) + content-length: 0 + date: ... + server: ... + status: 204 + +Bart is still a member of the mailing list. + + >>> transaction.abort() + >>> print ant.members.get_member('bart@example.com') + <Member: Bart Person <bart@example.com> on ant@example.com + as MemberRole.member> + >>> transaction.abort() + +There are no more membership change requests. + + >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests') + http_etag: "..." + start: 0 + total_size: 0 diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py index 4e9de6905..4a4b243b3 100644 --- a/src/mailman/rest/lists.py +++ b/src/mailman/rest/lists.py @@ -42,7 +42,7 @@ from mailman.rest.configuration import ListConfiguration from mailman.rest.helpers import ( CollectionMixin, etag, no_content, path_to, restish_matcher) from mailman.rest.members import AMember, MemberCollection -from mailman.rest.moderation import HeldMessages +from mailman.rest.moderation import HeldMessages, SubscriptionRequests from mailman.rest.validator import Validator @@ -176,11 +176,18 @@ class AList(_ListBase): @resource.child() def held(self, request, segments): - """Return a list of held messages for the mailign list.""" + """Return a list of held messages for the mailing list.""" if self._mlist is None: return http.not_found() return HeldMessages(self._mlist) + @resource.child() + def requests(self, request, segments): + """Return a list of subscription/unsubscription requests.""" + if self._mlist is None: + return http.not_found() + return SubscriptionRequests(self._mlist) + class AllLists(_ListBase): diff --git a/src/mailman/rest/moderation.py b/src/mailman/rest/moderation.py index 7075a75be..8fc519996 100644 --- a/src/mailman/rest/moderation.py +++ b/src/mailman/rest/moderation.py @@ -23,13 +23,16 @@ __metaclass__ = type __all__ = [ 'HeldMessage', 'HeldMessages', + 'MembershipChangeRequest', + 'SubscriptionRequests', ] from restish import http, resource from zope.component import getUtility -from mailman.app.moderator import handle_message +from mailman.app.moderator import ( + handle_message, handle_subscription, handle_unsubscription) from mailman.interfaces.action import Action from mailman.interfaces.messages import IMessageStore from mailman.interfaces.requests import IListRequests, RequestType @@ -37,8 +40,68 @@ from mailman.rest.helpers import CollectionMixin, etag, no_content from mailman.rest.validator import Validator, enum_validator +HELD_MESSAGE_REQUESTS = (RequestType.held_message,) +MEMBERSHIP_CHANGE_REQUESTS = (RequestType.subscription, + RequestType.unsubscription) + + + +class _ModerationBase: + """Common base class.""" + + def _make_resource(self, request_id, expected_request_types): + requests = IListRequests(self._mlist) + results = requests.get_request(request_id) + if results is None: + return None + key, data = results + resource = dict(key=key, request_id=request_id) + # Flatten the IRequest payload into the JSON representation. + resource.update(data) + # Check for a matching request type, and insert the type name into the + # resource. + request_type = RequestType(resource.pop('_request_type')) + if request_type not in expected_request_types: + return None + resource['type'] = request_type.name + # This key isn't what you think it is. Usually, it's the Pendable + # record's row id, which isn't helpful at all. If it's not there, + # that's fine too. + resource.pop('id', None) + return resource + + -class HeldMessage(resource.Resource, CollectionMixin): +class _HeldMessageBase(_ModerationBase): + """Held messages are a little different.""" + + def _make_resource(self, request_id): + resource = super(_HeldMessageBase, self)._make_resource( + request_id, HELD_MESSAGE_REQUESTS) + if resource is None: + return None + # Grab the message and insert its text representation into the + # resource. XXX See LP: #967954 + key = resource.pop('key') + msg = getUtility(IMessageStore).get_message_by_id(key) + resource['msg'] = msg.as_string() + # Some of the _mod_* keys we want to rename and place into the JSON + # resource. Others we can drop. Since we're mutating the dictionary, + # we need to make a copy of the keys. When you port this to Python 3, + # you'll need to list()-ify the .keys() dictionary view. + for key in resource.keys(): + if key in ('_mod_subject', '_mod_hold_date', '_mod_reason', + '_mod_sender', '_mod_message_id'): + resource[key[5:]] = resource.pop(key) + elif key.startswith('_mod_'): + del resource[key] + # Also, held message resources will always be this type, so ignore + # this key value. + del resource['type'] + return resource + + +class HeldMessage(_HeldMessageBase, resource.Resource): """Resource for moderating a held message.""" def __init__(self, mlist, request_id): @@ -47,22 +110,13 @@ class HeldMessage(resource.Resource, CollectionMixin): @resource.GET() def details(self, request): - requests = IListRequests(self._mlist) try: request_id = int(self._request_id) except ValueError: return http.bad_request() - results = requests.get_request(request_id, RequestType.held_message) - if results is None: + resource = self._make_resource(request_id) + if resource is None: return http.not_found() - key, data = results - msg = getUtility(IMessageStore).get_message_by_id(key) - resource = dict( - key=key, - data=data, - msg=msg.as_string(), - id=request_id, - ) return http.ok([], etag(resource)) @resource.POST() @@ -85,20 +139,16 @@ class HeldMessage(resource.Resource, CollectionMixin): -class HeldMessages(resource.Resource, CollectionMixin): +class HeldMessages(_HeldMessageBase, resource.Resource, CollectionMixin): """Resource for messages held for moderation.""" def __init__(self, mlist): self._mlist = mlist + self._requests = None - def _resource_as_dict(self, req): + def _resource_as_dict(self, request): """See `CollectionMixin`.""" - key, data = self._requests.get_request(req.id) - return dict( - key=key, - data=data, - id=req.id, - ) + return self._make_resource(request.id) def _get_collection(self, request): requests = IListRequests(self._mlist) @@ -108,9 +158,96 @@ class HeldMessages(resource.Resource, CollectionMixin): @resource.GET() def requests(self, request): """/lists/listname/held""" + # `request` is a restish.http.Request object. resource = self._make_collection(request) return http.ok([], etag(resource)) @resource.child('{id}') def message(self, request, segments, **kw): return HeldMessage(self._mlist, kw['id']) + + + +class MembershipChangeRequest(resource.Resource, _ModerationBase): + """Resource for moderating a membership change.""" + + def __init__(self, mlist, request_id): + self._mlist = mlist + self._request_id = request_id + + @resource.GET() + def details(self, request): + try: + request_id = int(self._request_id) + except ValueError: + return http.bad_request() + resource = self._make_resource(request_id, MEMBERSHIP_CHANGE_REQUESTS) + if resource is None: + return http.not_found() + # Remove unnecessary keys. + del resource['key'] + return http.ok([], etag(resource)) + + @resource.POST() + def moderate(self, request): + try: + validator = Validator(action=enum_validator(Action)) + arguments = validator(request) + except ValueError as error: + return http.bad_request([], str(error)) + requests = IListRequests(self._mlist) + try: + request_id = int(self._request_id) + except ValueError: + return http.bad_request() + results = requests.get_request(request_id) + if results is None: + return http.not_found() + key, data = results + try: + request_type = RequestType(data['_request_type']) + except ValueError: + return http.bad_request() + if request_type is RequestType.subscription: + handle_subscription(self._mlist, request_id, **arguments) + elif request_type is RequestType.unsubscription: + handle_unsubscription(self._mlist, request_id, **arguments) + else: + return http.bad_request() + return no_content() + + +class SubscriptionRequests( + _ModerationBase, resource.Resource, CollectionMixin): + """Resource for membership change requests.""" + + def __init__(self, mlist): + self._mlist = mlist + self._requests = None + + def _resource_as_dict(self, request): + """See `CollectionMixin`.""" + resource = self._make_resource(request.id, MEMBERSHIP_CHANGE_REQUESTS) + # Remove unnecessary keys. + del resource['key'] + return resource + + def _get_collection(self, request): + requests = IListRequests(self._mlist) + self._requests = requests + items = [] + for request_type in MEMBERSHIP_CHANGE_REQUESTS: + for request in requests.of_type(request_type): + items.append(request) + return items + + @resource.GET() + def requests(self, request): + """/lists/listname/requests""" + # `request` is a restish.http.Request object. + resource = self._make_collection(request) + return http.ok([], etag(resource)) + + @resource.child('{id}') + def subscription(self, request, segments, **kw): + return MembershipChangeRequest(self._mlist, kw['id']) diff --git a/src/mailman/rest/tests/test_moderation.py b/src/mailman/rest/tests/test_moderation.py index 2ee796c87..46cc86d60 100644 --- a/src/mailman/rest/tests/test_moderation.py +++ b/src/mailman/rest/tests/test_moderation.py @@ -82,7 +82,7 @@ Something else. self.assertEqual(cm.exception.code, 404) # But using the held_id returns a valid response. response, content = call_api(url.format(held_id)) - self.assertEqual(response['key'], '<alpha>') + self.assertEqual(response['message_id'], '<alpha>') def test_bad_action(self): # POSTing to a held message with a bad action. |
