summaryrefslogtreecommitdiff
path: root/src/mailman/rest
diff options
context:
space:
mode:
authorAbhilash Raj2015-04-20 15:16:15 +0530
committerAbhilash Raj2015-04-20 15:16:15 +0530
commit58ea970fa0f9064ae052d2b9ae1371ef00bd23e6 (patch)
tree4d21000f8ad772377a655ff332288b1c753f5be1 /src/mailman/rest
parentec053e7682b14181147d0b7bedb1e5b19a46b56b (diff)
parent3eb81bf5078868b0fc44f991b0b4536a2a3f4b47 (diff)
downloadmailman-58ea970fa0f9064ae052d2b9ae1371ef00bd23e6.tar.gz
mailman-58ea970fa0f9064ae052d2b9ae1371ef00bd23e6.tar.zst
mailman-58ea970fa0f9064ae052d2b9ae1371ef00bd23e6.zip
merge trunk and fix merge conflicts
Diffstat (limited to 'src/mailman/rest')
-rw-r--r--src/mailman/rest/docs/membership.rst136
-rw-r--r--src/mailman/rest/docs/moderation.rst361
-rw-r--r--src/mailman/rest/docs/post-moderation.rst193
-rw-r--r--src/mailman/rest/docs/sub-moderation.rst110
-rw-r--r--src/mailman/rest/helpers.py6
-rw-r--r--src/mailman/rest/lists.py3
-rw-r--r--src/mailman/rest/members.py148
-rw-r--r--src/mailman/rest/post_moderation.py (renamed from src/mailman/rest/moderation.py)109
-rw-r--r--src/mailman/rest/sub_moderation.py148
-rw-r--r--src/mailman/rest/tests/test_listconf.py22
-rw-r--r--src/mailman/rest/tests/test_membership.py12
-rw-r--r--src/mailman/rest/tests/test_moderation.py296
-rw-r--r--src/mailman/rest/tests/test_users.py3
-rw-r--r--src/mailman/rest/tests/test_validator.py18
-rw-r--r--src/mailman/rest/validator.py6
15 files changed, 923 insertions, 648 deletions
diff --git a/src/mailman/rest/docs/membership.rst b/src/mailman/rest/docs/membership.rst
index 3cb83a0c8..f3a09cfe9 100644
--- a/src/mailman/rest/docs/membership.rst
+++ b/src/mailman/rest/docs/membership.rst
@@ -598,14 +598,17 @@ A user can be subscribed to a mailing list via the REST API, either by a
specific address, or more generally by their preferred address. A subscribed
user is called a member.
-Elly wants to subscribes to the `ant` mailing list. Since Elly's email
-address is not yet known to Mailman, a user is created for her. By default,
-get gets a regular delivery.
+The list owner wants to subscribe Elly to the `ant` mailing list. Since
+Elly's email address is not yet known to Mailman, a user is created for her.
+By default, get gets a regular delivery.
>>> dump_json('http://localhost:9001/3.0/members', {
... 'list_id': 'ant.example.com',
... 'subscriber': 'eperson@example.com',
... 'display_name': 'Elly Person',
+ ... 'pre_verified': True,
+ ... 'pre_confirmed': True,
+ ... 'pre_approved': True,
... })
content-length: 0
date: ...
@@ -659,6 +662,9 @@ list with her preferred address.
>>> dump_json('http://localhost:9001/3.0/members', {
... 'list_id': 'ant.example.com',
... 'subscriber': user_id,
+ ... 'pre_verified': True,
+ ... 'pre_confirmed': True,
+ ... 'pre_approved': True,
... })
content-length: 0
date: ...
@@ -729,94 +735,6 @@ Elly is no longer a member of the mailing list.
set()
-Digest delivery
-===============
-
-Fred joins the `ant` mailing list but wants MIME digest delivery.
-::
-
- >>> transaction.abort()
- >>> dump_json('http://localhost:9001/3.0/members', {
- ... 'list_id': 'ant.example.com',
- ... 'subscriber': 'fperson@example.com',
- ... 'display_name': 'Fred Person',
- ... 'delivery_mode': 'mime_digests',
- ... })
- content-length: 0
- date: ...
- location: http://localhost:9001/3.0/members/10
- server: ...
- status: 201
-
- >>> fred = user_manager.get_user('fperson@example.com')
- >>> memberships = list(fred.memberships.members)
- >>> len(memberships)
- 1
-
-Fred is getting MIME deliveries.
-
- >>> memberships[0]
- <Member: Fred Person <fperson@example.com>
- on ant@example.com as MemberRole.member>
- >>> print(memberships[0].delivery_mode)
- DeliveryMode.mime_digests
-
- >>> dump_json('http://localhost:9001/3.0/members/10')
- address: http://localhost:9001/3.0/addresses/fperson@example.com
- delivery_mode: mime_digests
- email: fperson@example.com
- http_etag: "..."
- list_id: ant.example.com
- member_id: 10
- role: member
- self_link: http://localhost:9001/3.0/members/10
- user: http://localhost:9001/3.0/users/7
-
-Fred wants to change his delivery from MIME digest back to regular delivery.
-This can be done by PATCH'ing his member with the `delivery_mode` parameter.
-::
-
- >>> transaction.abort()
- >>> dump_json('http://localhost:9001/3.0/members/10', {
- ... 'delivery_mode': 'regular',
- ... }, method='PATCH')
- content-length: 0
- date: ...
- server: ...
- status: 204
-
- >>> dump_json('http://localhost:9001/3.0/members/10')
- address: http://localhost:9001/3.0/addresses/fperson@example.com
- delivery_mode: regular
- email: fperson@example.com
- http_etag: "..."
- list_id: ant.example.com
- member_id: 10
- role: member
- self_link: http://localhost:9001/3.0/members/10
- user: http://localhost:9001/3.0/users/7
-
-If a PATCH request changes no attributes, nothing happens.
-::
-
- >>> dump_json('http://localhost:9001/3.0/members/10', {}, method='PATCH')
- content-length: 0
- date: ...
- server: ...
- status: 204
-
- >>> dump_json('http://localhost:9001/3.0/members/10')
- address: http://localhost:9001/3.0/addresses/fperson@example.com
- delivery_mode: regular
- email: fperson@example.com
- http_etag: "..."
- list_id: ant.example.com
- member_id: 10
- role: member
- self_link: http://localhost:9001/3.0/members/10
- user: http://localhost:9001/3.0/users/7
-
-
Changing delivery address
=========================
@@ -849,30 +767,30 @@ addresses.
>>> dump_json('http://localhost:9001/3.0/members')
entry 0:
...
- entry 5:
+ entry 4:
address: http://localhost:9001/3.0/addresses/herb@example.com
delivery_mode: regular
email: herb@example.com
http_etag: "..."
list_id: ant.example.com
- member_id: 11
+ member_id: 10
role: member
- self_link: http://localhost:9001/3.0/members/11
- user: http://localhost:9001/3.0/users/8
+ self_link: http://localhost:9001/3.0/members/10
+ user: http://localhost:9001/3.0/users/7
...
- entry 10:
+ entry 9:
address: http://localhost:9001/3.0/addresses/herb@example.com
delivery_mode: regular
email: herb@example.com
http_etag: "..."
list_id: bee.example.com
- member_id: 12
+ member_id: 11
role: member
- self_link: http://localhost:9001/3.0/members/12
- user: http://localhost:9001/3.0/users/8
+ self_link: http://localhost:9001/3.0/members/11
+ user: http://localhost:9001/3.0/users/7
http_etag: "..."
start: 0
- total_size: 11
+ total_size: 10
In order to change all of his subscriptions to use a different email address,
Herb must iterate through his memberships explicitly.
@@ -883,13 +801,13 @@ Herb must iterate through his memberships explicitly.
>>> memberships = [entry['self_link'] for entry in content['entries']]
>>> for url in sorted(memberships):
... print(url)
+ http://localhost:9001/3.0/members/10
http://localhost:9001/3.0/members/11
- http://localhost:9001/3.0/members/12
For each membership resource, the subscription address is changed by PATCH'ing
the `address` attribute.
- >>> dump_json('http://localhost:9001/3.0/members/11', {
+ >>> dump_json('http://localhost:9001/3.0/members/10', {
... 'address': 'hperson@example.com',
... }, method='PATCH')
content-length: 0
@@ -897,7 +815,7 @@ the `address` attribute.
server: ...
status: 204
- >>> dump_json('http://localhost:9001/3.0/members/12', {
+ >>> dump_json('http://localhost:9001/3.0/members/11', {
... 'address': 'hperson@example.com',
... }, method='PATCH')
content-length: 0
@@ -924,20 +842,20 @@ his membership ids have not changed.
email: hperson@example.com
http_etag: "..."
list_id: ant.example.com
- member_id: 11
+ member_id: 10
role: member
- self_link: http://localhost:9001/3.0/members/11
- user: http://localhost:9001/3.0/users/8
+ self_link: http://localhost:9001/3.0/members/10
+ user: http://localhost:9001/3.0/users/7
entry 1:
address: http://localhost:9001/3.0/addresses/hperson@example.com
delivery_mode: regular
email: hperson@example.com
http_etag: "..."
list_id: bee.example.com
- member_id: 12
+ member_id: 11
role: member
- self_link: http://localhost:9001/3.0/members/12
- user: http://localhost:9001/3.0/users/8
+ self_link: http://localhost:9001/3.0/members/11
+ user: http://localhost:9001/3.0/users/7
http_etag: "..."
start: 0
total_size: 2
diff --git a/src/mailman/rest/docs/moderation.rst b/src/mailman/rest/docs/moderation.rst
deleted file mode 100644
index 0a9fd59a8..000000000
--- a/src/mailman/rest/docs/moderation.rst
+++ /dev/null
@@ -1,361 +0,0 @@
-==========
-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
-with no held messages.
-
- >>> ant = create_list('ant@example.com')
- >>> transaction.commit()
- >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/held')
- http_etag: "..."
- start: 0
- total_size: 0
-
-When a message gets held for moderator approval, it shows up in this list.
-::
-
- >>> msg = message_from_string("""\
- ... From: anne@example.com
- ... To: ant@example.com
- ... Subject: Something
- ... Message-ID: <alpha>
- ...
- ... Something else.
- ... """)
-
- >>> from mailman.app.moderator import hold_message
- >>> request_id = hold_message(ant, msg, {'extra': 7}, 'Because')
- >>> transaction.commit()
-
- >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/held')
- entry 0:
- extra: 7
- hold_date: 2005-08-01T07:49:23
- http_etag: "..."
- 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
-
-You can get an individual held message by providing the *request id* for that
-message. This will include the text of the message.
-::
-
- >>> def url(request_id):
- ... return ('http://localhost:9001/3.0/lists/'
- ... 'ant@example.com/held/{0}'.format(request_id))
-
- >>> dump_json(url(request_id))
- extra: 7
- hold_date: 2005-08-01T07:49:23
- http_etag: "..."
- 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
-
-
-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
-following:
-
- * 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.
-
-Let's see what happens when the above message is deferred.
-
- >>> dump_json(url(request_id), {
- ... 'action': 'defer',
- ... })
- content-length: 0
- date: ...
- server: ...
- status: 204
-
-The message is still in the moderation queue.
-
- >>> dump_json(url(request_id))
- extra: 7
- hold_date: 2005-08-01T07:49:23
- http_etag: "..."
- 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
-
-The held message can be discarded.
-
- >>> dump_json(url(request_id), {
- ... 'action': 'discard',
- ... })
- content-length: 0
- date: ...
- server: ...
- status: 204
-
-Messages can also be accepted via the REST API. Let's hold a new message for
-moderation.
-::
-
- >>> del msg['message-id']
- >>> msg['Message-ID'] = '<bravo>'
- >>> request_id = hold_message(ant, msg)
- >>> transaction.commit()
-
- >>> results = call_http(url(request_id))
- >>> print(results['message_id'])
- <bravo>
-
- >>> dump_json(url(request_id), {
- ... 'action': 'accept',
- ... })
- content-length: 0
- date: ...
- server: ...
- status: 204
-
- >>> from mailman.testing.helpers import get_queue_messages
- >>> messages = get_queue_messages('pipeline')
- >>> len(messages)
- 1
- >>> print(messages[0].msg['message-id'])
- <bravo>
-
-Messages can be rejected via the REST API too. These bounce the message back
-to the original author.
-::
-
- >>> del msg['message-id']
- >>> msg['Message-ID'] = '<charlie>'
- >>> request_id = hold_message(ant, msg)
- >>> transaction.commit()
-
- >>> results = call_http(url(request_id))
- >>> print(results['message_id'])
- <charlie>
-
- >>> dump_json(url(request_id), {
- ... 'action': 'reject',
- ... })
- content-length: 0
- date: ...
- server: ...
- status: 204
-
- >>> from mailman.testing.helpers import get_queue_messages
- >>> messages = get_queue_messages('virgin')
- >>> len(messages)
- 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
- >>> from mailman.interfaces.subscriptions import RequestRecord
-
- >>> sub_req_id = hold_subscription(
- ... ant, RequestRecord('anne@example.com', 'Anne Person',
- ... DeliveryMode.regular, 'en'))
- >>> 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:
- delivery_mode: regular
- display_name: Anne Person
- email: anne@example.com
- http_etag: "..."
- language: en
- request_id: ...
- 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.testing.helpers import subscribe
- >>> from mailman.app.moderator import hold_unsubscription
- >>> bart = subscribe(ant, 'Bart', email='bart@example.com')
- >>> unsub_req_id = hold_unsubscription(ant, 'bart@example.com')
- >>> 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:
- delivery_mode: regular
- display_name: Anne Person
- email: anne@example.com
- http_etag: "..."
- language: en
- request_id: ...
- type: subscription
- when: 2005-08-01T07:49:23
- entry 1:
- email: bart@example.com
- http_etag: "..."
- request_id: ...
- 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/{}'.format(sub_req_id))
- delivery_mode: regular
- display_name: Anne Person
- email: anne@example.com
- http_etag: "..."
- language: en
- request_id: ...
- 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/{}'.format(unsub_req_id))
- email: bart@example.com
- http_etag: "..."
- request_id: ...
- 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/{}'.format(sub_req_id),
- ... {'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/{}'.format(unsub_req_id),
- ... {'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/docs/post-moderation.rst b/src/mailman/rest/docs/post-moderation.rst
new file mode 100644
index 000000000..6dd96e71a
--- /dev/null
+++ b/src/mailman/rest/docs/post-moderation.rst
@@ -0,0 +1,193 @@
+===============
+Post Moderation
+===============
+
+Messages which are held for approval can be accepted, rejected, discarded, or
+deferred by the list moderators.
+
+
+Viewing the list of held messages
+=================================
+
+Held messages can be moderated through the REST API. A mailing list starts
+with no held messages.
+
+ >>> ant = create_list('ant@example.com')
+ >>> transaction.commit()
+ >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/held')
+ http_etag: "..."
+ start: 0
+ total_size: 0
+
+When a message gets held for moderator approval, it shows up in this list.
+::
+
+ >>> msg = message_from_string("""\
+ ... From: anne@example.com
+ ... To: ant@example.com
+ ... Subject: Something
+ ... Message-ID: <alpha>
+ ...
+ ... Something else.
+ ... """)
+
+ >>> from mailman.app.moderator import hold_message
+ >>> request_id = hold_message(ant, msg, {'extra': 7}, 'Because')
+ >>> transaction.commit()
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/held')
+ entry 0:
+ extra: 7
+ hold_date: 2005-08-01T07:49:23
+ http_etag: "..."
+ 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
+
+You can get an individual held message by providing the *request id* for that
+message. This will include the text of the message.
+::
+
+ >>> def url(request_id):
+ ... return ('http://localhost:9001/3.0/lists/'
+ ... 'ant@example.com/held/{0}'.format(request_id))
+
+ >>> dump_json(url(request_id))
+ extra: 7
+ hold_date: 2005-08-01T07:49:23
+ http_etag: "..."
+ 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
+
+
+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
+following:
+
+ * 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.
+
+Let's see what happens when the above message is deferred.
+
+ >>> dump_json(url(request_id), {
+ ... 'action': 'defer',
+ ... })
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+The message is still in the moderation queue.
+
+ >>> dump_json(url(request_id))
+ extra: 7
+ hold_date: 2005-08-01T07:49:23
+ http_etag: "..."
+ 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
+
+The held message can be discarded.
+
+ >>> dump_json(url(request_id), {
+ ... 'action': 'discard',
+ ... })
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+Messages can also be accepted via the REST API. Let's hold a new message for
+moderation.
+::
+
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = '<bravo>'
+ >>> request_id = hold_message(ant, msg)
+ >>> transaction.commit()
+
+ >>> results = call_http(url(request_id))
+ >>> print(results['message_id'])
+ <bravo>
+
+ >>> dump_json(url(request_id), {
+ ... 'action': 'accept',
+ ... })
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> from mailman.testing.helpers import get_queue_messages
+ >>> messages = get_queue_messages('pipeline')
+ >>> len(messages)
+ 1
+ >>> print(messages[0].msg['message-id'])
+ <bravo>
+
+Messages can be rejected via the REST API too. These bounce the message back
+to the original author.
+::
+
+ >>> del msg['message-id']
+ >>> msg['Message-ID'] = '<charlie>'
+ >>> request_id = hold_message(ant, msg)
+ >>> transaction.commit()
+
+ >>> results = call_http(url(request_id))
+ >>> print(results['message_id'])
+ <charlie>
+
+ >>> dump_json(url(request_id), {
+ ... 'action': 'reject',
+ ... })
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> from mailman.testing.helpers import get_queue_messages
+ >>> messages = get_queue_messages('virgin')
+ >>> len(messages)
+ 1
+ >>> print(messages[0].msg['subject'])
+ Request to mailing list "Ant" rejected
diff --git a/src/mailman/rest/docs/sub-moderation.rst b/src/mailman/rest/docs/sub-moderation.rst
new file mode 100644
index 000000000..52b2b89fd
--- /dev/null
+++ b/src/mailman/rest/docs/sub-moderation.rst
@@ -0,0 +1,110 @@
+=========================
+ Subscription moderation
+=========================
+
+Subscription (and sometimes unsubscription) requests can similarly be
+accepted, discarded, rejected, or deferred by the list moderators.
+
+
+Viewing subscription requests
+=============================
+
+A mailing list starts with no pending subscription or unsubscription requests.
+
+ >>> ant = create_list('ant@example.com')
+ >>> ant.admin_immed_notify = False
+ >>> from mailman.interfaces.mailinglist import SubscriptionPolicy
+ >>> ant.subscription_policy = SubscriptionPolicy.moderate
+ >>> transaction.commit()
+ >>> 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.interfaces.registrar import IRegistrar
+ >>> from mailman.interfaces.usermanager import IUserManager
+ >>> from zope.component import getUtility
+ >>> registrar = IRegistrar(ant)
+ >>> manager = getUtility(IUserManager)
+ >>> anne = manager.create_address('anne@example.com', 'Anne Person')
+ >>> token, token_owner, member = registrar.register(
+ ... anne, pre_verified=True, pre_confirmed=True)
+ >>> print(member)
+ None
+
+The message is being held for moderator approval.
+
+ >>> print(token_owner.name)
+ moderator
+
+The subscription request can be viewed in the REST API.
+
+ >>> transaction.commit()
+ >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/requests')
+ entry 0:
+ display_name: Anne Person
+ email: anne@example.com
+ http_etag: "..."
+ list_id: ant.example.com
+ token: ...
+ token_owner: moderator
+ when: 2005-08-01T07:49:23
+ http_etag: "..."
+ start: 0
+ total_size: 1
+
+
+Viewing individual requests
+===========================
+
+You can view an individual membership change request by providing the token
+(a.k.a. request id). Anne's subscription request looks like this.
+
+ >>> dump_json('http://localhost:9001/3.0/lists/ant@example.com/'
+ ... 'requests/{}'.format(token))
+ display_name: Anne Person
+ email: anne@example.com
+ http_etag: "..."
+ list_id: ant.example.com
+ token: ...
+ token_owner: moderator
+ when: 2005-08-01T07:49:23
+
+
+Disposing of subscription requests
+==================================
+
+Moderators can dispose of held subscription 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/{}'.format(token),
+ ... {'action': 'accept'})
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+Anne is now a member of the mailing list.
+
+ >>> ant.members.get_member('anne@example.com')
+ <Member: Anne Person <anne@example.com> on ant@example.com
+ as MemberRole.member>
+
+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/helpers.py b/src/mailman/rest/helpers.py
index edd57b76b..c737fcbc7 100644
--- a/src/mailman/rest/helpers.py
+++ b/src/mailman/rest/helpers.py
@@ -300,6 +300,12 @@ def not_found(response, body=b'404 Not Found'):
response.body = body
+def accepted(response, body=None):
+ response.status = falcon.HTTP_202
+ if body is not None:
+ response.body = body
+
+
def bad_request(response, body='400 Bad Request'):
response.status = falcon.HTTP_400
if body is not None:
diff --git a/src/mailman/rest/lists.py b/src/mailman/rest/lists.py
index f6bc27917..0607102cb 100644
--- a/src/mailman/rest/lists.py
+++ b/src/mailman/rest/lists.py
@@ -42,7 +42,8 @@ from mailman.rest.helpers import (
CollectionMixin, GetterSetter, NotFound, bad_request, child, created,
etag, no_content, not_found, okay, paginate, path_to)
from mailman.rest.members import AMember, MemberCollection
-from mailman.rest.moderation import HeldMessages, SubscriptionRequests
+from mailman.rest.post_moderation import HeldMessages
+from mailman.rest.sub_moderation import SubscriptionRequests
from mailman.rest.validator import Validator
from operator import attrgetter
from zope.component import getUtility
diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py
index a0b5d4f4e..d6a57a673 100644
--- a/src/mailman/rest/members.py
+++ b/src/mailman/rest/members.py
@@ -25,18 +25,20 @@ __all__ = [
]
-from mailman.app.membership import delete_member
-from mailman.interfaces.address import InvalidEmailAddressError
-from mailman.interfaces.listmanager import IListManager, NoSuchListError
+from mailman.app.membership import add_member, delete_member
+from mailman.interfaces.address import IAddress, InvalidEmailAddressError
+from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import (
AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError,
- NotAMemberError)
-from mailman.interfaces.subscriptions import ISubscriptionService
-from mailman.interfaces.user import UnverifiedAddressError
+ MembershipIsBannedError, NotAMemberError)
+from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.subscriptions import (
+ ISubscriptionService, RequestRecord, TokenOwner)
+from mailman.interfaces.user import IUser, UnverifiedAddressError
from mailman.interfaces.usermanager import IUserManager
from mailman.rest.helpers import (
- CollectionMixin, NotFound, bad_request, child, conflict, created, etag,
- no_content, not_found, okay, paginate, path_to)
+ CollectionMixin, NotFound, accepted, bad_request, child, conflict,
+ created, etag, no_content, not_found, okay, paginate, path_to)
from mailman.rest.preferences import Preferences, ReadOnlyPreferences
from mailman.rest.validator import (
Validator, enum_validator, subscriber_validator)
@@ -202,7 +204,6 @@ class AllMembers(_MemberBase):
def on_post(self, request, response):
"""Create a new member."""
- service = getUtility(ISubscriptionService)
try:
validator = Validator(
list_id=str,
@@ -210,22 +211,125 @@ class AllMembers(_MemberBase):
display_name=str,
delivery_mode=enum_validator(DeliveryMode),
role=enum_validator(MemberRole),
- _optional=('delivery_mode', 'display_name', 'role'))
- member = service.join(**validator(request))
- except AlreadySubscribedError:
- conflict(response, b'Member already subscribed')
- except NoSuchListError:
- bad_request(response, b'No such list')
- except InvalidEmailAddressError:
- bad_request(response, b'Invalid email address')
+ pre_verified=bool,
+ pre_confirmed=bool,
+ pre_approved=bool,
+ _optional=('delivery_mode', 'display_name', 'role',
+ 'pre_verified', 'pre_confirmed', 'pre_approved'))
+ arguments = validator(request)
except ValueError as error:
bad_request(response, str(error))
+ return
+ # Dig the mailing list out of the arguments.
+ list_id = arguments.pop('list_id')
+ mlist = getUtility(IListManager).get_by_list_id(list_id)
+ if mlist is None:
+ bad_request(response, b'No such list')
+ return
+ # Figure out what kind of subscriber is being registered. Either it's
+ # a user via their preferred email address or it's an explicit address.
+ # If it's a UUID, then it must be associated with an existing user.
+ subscriber = arguments.pop('subscriber')
+ user_manager = getUtility(IUserManager)
+ # We use the display name if there is one.
+ display_name = arguments.pop('display_name', '')
+ if isinstance(subscriber, UUID):
+ user = user_manager.get_user_by_id(subscriber)
+ if user is None:
+ bad_request(response, b'No such user')
+ return
+ subscriber = user
else:
- # The member_id are UUIDs. We need to use the integer equivalent
- # in the URL.
- member_id = member.member_id.int
- location = path_to('members/{0}'.format(member_id))
- created(response, location)
+ # This must be an email address. See if there's an existing
+ # address object associated with this email.
+ address = user_manager.get_address(subscriber)
+ if address is None:
+ # Create a new address, which of course will not be validated.
+ address = user_manager.create_address(
+ subscriber, display_name)
+ subscriber = address
+ # What role are we subscribing? Regular members go through the
+ # subscription policy workflow while owners, moderators, and
+ # nonmembers go through the legacy API for now.
+ role = arguments.pop('role', MemberRole.member)
+ if role is MemberRole.member:
+ # Get the pre_ flags for the subscription workflow.
+ pre_verified = arguments.pop('pre_verified', False)
+ pre_confirmed = arguments.pop('pre_confirmed', False)
+ pre_approved = arguments.pop('pre_approved', False)
+ # Now we can run the registration process until either the
+ # subscriber is subscribed, or the workflow is paused for
+ # verification, confirmation, or approval.
+ registrar = IRegistrar(mlist)
+ try:
+ token, token_owner, member = registrar.register(
+ subscriber,
+ pre_verified=pre_verified,
+ pre_confirmed=pre_confirmed,
+ pre_approved=pre_approved)
+ except AlreadySubscribedError:
+ conflict(response, b'Member already subscribed')
+ return
+ if token is None:
+ assert token_owner is TokenOwner.no_one, token_owner
+ # The subscription completed. Let's get the resulting member
+ # and return the location to the new member. Member ids are
+ # UUIDs and need to be converted to URLs because JSON doesn't
+ # directly support UUIDs.
+ member_id = member.member_id.int
+ location = path_to('members/{0}'.format(member_id))
+ created(response, location)
+ return
+ # The member could not be directly subscribed because there are
+ # some out-of-band steps that need to be completed. E.g. the user
+ # must confirm their subscription or the moderator must approve
+ # it. In this case, an HTTP 202 Accepted is exactly the code that
+ # we should use, and we'll return both the confirmation token and
+ # the "token owner" so the client knows who should confirm it.
+ assert token is not None, token
+ assert token_owner is not TokenOwner.no_one, token_owner
+ assert member is None, member
+ content = dict(token=token, token_owner=token_owner.name)
+ accepted(response, etag(content))
+ return
+ # 2015-04-15 BAW: We're subscribing some role other than a regular
+ # member. Use the legacy API for this for now.
+ assert role in (MemberRole.owner,
+ MemberRole.moderator,
+ MemberRole.nonmember)
+ # 2015-04-15 BAW: We're limited to using an email address with this
+ # legacy API, so if the subscriber is a user, the user must have a
+ # preferred address, which we'll use, even though it will subscribe
+ # the explicit address. It is an error if the user does not have a
+ # preferred address.
+ #
+ # If the subscriber is an address object, just use that.
+ if IUser.providedBy(subscriber):
+ if subscriber.preferred_address is None:
+ bad_request(response, b'User without preferred address')
+ return
+ email = subscriber.preferred_address.email
+ else:
+ assert IAddress.providedBy(subscriber)
+ email = subscriber.email
+ delivery_mode = arguments.pop('delivery_mode', DeliveryMode.regular)
+ record = RequestRecord(email, display_name, delivery_mode)
+ try:
+ member = add_member(mlist, record, role)
+ except InvalidEmailAddressError:
+ bad_request(response, b'Invalid email address')
+ return
+ except MembershipIsBannedError:
+ bad_request(response, b'Membership is banned')
+ return
+ # The subscription completed. Let's get the resulting member
+ # and return the location to the new member. Member ids are
+ # UUIDs and need to be converted to URLs because JSON doesn't
+ # directly support UUIDs.
+ member_id = member.member_id.int
+ location = path_to('members/{0}'.format(member_id))
+ created(response, location)
+ return
def on_get(self, request, response):
"""/members"""
diff --git a/src/mailman/rest/moderation.py b/src/mailman/rest/post_moderation.py
index 984e2d34a..6156fa39f 100644
--- a/src/mailman/rest/moderation.py
+++ b/src/mailman/rest/post_moderation.py
@@ -15,18 +15,15 @@
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
-"""REST API for Message moderation."""
+"""REST API for held message moderation."""
__all__ = [
'HeldMessage',
'HeldMessages',
- 'MembershipChangeRequest',
- 'SubscriptionRequests',
]
-from mailman.app.moderator import (
- handle_message, handle_subscription, handle_unsubscription)
+from mailman.app.moderator import handle_message
from mailman.interfaces.action import Action
from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.requests import IListRequests, RequestType
@@ -36,16 +33,11 @@ from mailman.rest.validator import Validator, enum_validator
from zope.component import getUtility
-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):
+ def _make_resource(self, request_id):
requests = IListRequests(self._mlist)
results = requests.get_request(request_id)
if results is None:
@@ -57,9 +49,9 @@ class _ModerationBase:
# 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:
+ if request_type is not RequestType.held_message:
return None
- resource['type'] = request_type.name
+ resource['type'] = RequestType.held_message.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.
@@ -72,8 +64,7 @@ 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)
+ resource = super(_HeldMessageBase, self)._make_resource(request_id)
if resource is None:
return None
# Grab the message and insert its text representation into the
@@ -162,91 +153,3 @@ class HeldMessages(_HeldMessageBase, CollectionMixin):
@child(r'^(?P<id>[^/]+)')
def message(self, request, segments, **kw):
return HeldMessage(self._mlist, kw['id'])
-
-
-
-class MembershipChangeRequest(_ModerationBase):
- """Resource for moderating a membership change."""
-
- def __init__(self, mlist, request_id):
- self._mlist = mlist
- self._request_id = request_id
-
- def on_get(self, request, response):
- try:
- request_id = int(self._request_id)
- except ValueError:
- bad_request(response)
- return
- resource = self._make_resource(request_id, MEMBERSHIP_CHANGE_REQUESTS)
- if resource is None:
- not_found(response)
- else:
- # Remove unnecessary keys.
- del resource['key']
- okay(response, etag(resource))
-
- def on_post(self, request, response):
- try:
- validator = Validator(action=enum_validator(Action))
- arguments = validator(request)
- except ValueError as error:
- bad_request(response, str(error))
- return
- requests = IListRequests(self._mlist)
- try:
- request_id = int(self._request_id)
- except ValueError:
- bad_request(response)
- return
- results = requests.get_request(request_id)
- if results is None:
- not_found(response)
- return
- key, data = results
- try:
- request_type = RequestType[data['_request_type']]
- except ValueError:
- bad_request(response)
- return
- 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:
- bad_request(response)
- return
- no_content(response)
-
-
-class SubscriptionRequests(_ModerationBase, 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
-
- def on_get(self, request, response):
- """/lists/listname/requests"""
- resource = self._make_collection(request)
- okay(response, etag(resource))
-
- @child(r'^(?P<id>[^/]+)')
- def subscription(self, request, segments, **kw):
- return MembershipChangeRequest(self._mlist, kw['id'])
diff --git a/src/mailman/rest/sub_moderation.py b/src/mailman/rest/sub_moderation.py
new file mode 100644
index 000000000..ebb09b9b3
--- /dev/null
+++ b/src/mailman/rest/sub_moderation.py
@@ -0,0 +1,148 @@
+# Copyright (C) 2012-2015 by the Free Software Foundation, Inc.
+#
+# This file is part of GNU Mailman.
+#
+# GNU Mailman is free software: you can redistribute it and/or modify it under
+# the terms of the GNU General Public License as published by the Free
+# Software Foundation, either version 3 of the License, or (at your option)
+# any later version.
+#
+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+# more details.
+#
+# You should have received a copy of the GNU General Public License along with
+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
+
+"""REST API for held subscription requests."""
+
+__all__ = [
+ 'SubscriptionRequests',
+ ]
+
+
+from mailman.app.moderator import send_rejection
+from mailman.interfaces.action import Action
+from mailman.interfaces.pending import IPendings
+from mailman.interfaces.registrar import IRegistrar
+from mailman.rest.helpers import (
+ CollectionMixin, bad_request, child, etag, no_content, not_found, okay)
+from mailman.rest.validator import Validator, enum_validator
+from mailman.utilities.i18n import _
+from zope.component import getUtility
+
+
+
+class _ModerationBase:
+ """Common base class."""
+
+ def __init__(self):
+ self._pendings = getUtility(IPendings)
+
+ def _resource_as_dict(self, token):
+ pendable = self._pendings.confirm(token, expunge=False)
+ if pendable is None:
+ # This token isn't in the database.
+ raise LookupError
+ resource = dict(token=token)
+ resource.update(pendable)
+ return resource
+
+
+
+class IndividualRequest(_ModerationBase):
+ """Resource for moderating a membership change."""
+
+ def __init__(self, mlist, token):
+ super().__init__()
+ self._mlist = mlist
+ self._registrar = IRegistrar(self._mlist)
+ self._token = token
+
+ def on_get(self, request, response):
+ # Get the pended record associated with this token, if it exists in
+ # the pending table.
+ try:
+ resource = self._resource_as_dict(self._token)
+ except LookupError:
+ not_found(response)
+ return
+ okay(response, etag(resource))
+
+ def on_post(self, request, response):
+ try:
+ validator = Validator(action=enum_validator(Action))
+ arguments = validator(request)
+ except ValueError as error:
+ bad_request(response, str(error))
+ return
+ action = arguments['action']
+ if action is Action.defer:
+ # At least see if the token is in the database.
+ pendable = self._pendings.confirm(self._token, expunge=False)
+ if pendable is None:
+ not_found(response)
+ else:
+ no_content(response)
+ elif action is Action.accept:
+ try:
+ self._registrar.confirm(self._token)
+ except LookupError:
+ not_found(response)
+ else:
+ no_content(response)
+ elif action is Action.discard:
+ # At least see if the token is in the database.
+ pendable = self._pendings.confirm(self._token, expunge=True)
+ if pendable is None:
+ not_found(response)
+ else:
+ no_content(response)
+ elif action is Action.reject:
+ # Like discard but sends a rejection notice to the user.
+ pendable = self._pendings.confirm(self._token, expunge=True)
+ if pendable is None:
+ not_found(response)
+ else:
+ no_content(response)
+ send_rejection(
+ self._mlist, _('Subscription request'),
+ pendable['email'],
+ _('[No reason given]'))
+
+
+
+class SubscriptionRequests(_ModerationBase, CollectionMixin):
+ """Resource for membership change requests."""
+
+ def __init__(self, mlist):
+ super().__init__()
+ self._mlist = mlist
+
+ def _get_collection(self, request):
+ # There's currently no better way to query the pendings database for
+ # all the entries that are associated with subscription holds on this
+ # mailing list. Brute force iterating over all the pendables.
+ collection = []
+ for token, pendable in getUtility(IPendings):
+ if 'token_owner' not in pendable:
+ # This isn't a subscription hold.
+ continue
+ list_id = pendable.get('list_id')
+ if list_id != self._mlist.list_id:
+ # Either there isn't a list_id field, in which case it can't
+ # be a subscription hold, or this is a hold for some other
+ # mailing list.
+ continue
+ collection.append(token)
+ return collection
+
+ def on_get(self, request, response):
+ """/lists/listname/requests"""
+ resource = self._make_collection(request)
+ okay(response, etag(resource))
+
+ @child(r'^(?P<token>[^/]+)')
+ def subscription(self, request, segments, **kw):
+ return IndividualRequest(self._mlist, kw['token'])
diff --git a/src/mailman/rest/tests/test_listconf.py b/src/mailman/rest/tests/test_listconf.py
index 560c3ec47..0c36ef30a 100644
--- a/src/mailman/rest/tests/test_listconf.py
+++ b/src/mailman/rest/tests/test_listconf.py
@@ -26,7 +26,8 @@ import unittest
from mailman.app.lifecycle import create_list
from mailman.database.transaction import transaction
-from mailman.interfaces.mailinglist import IAcceptableAliasSet
+from mailman.interfaces.mailinglist import (
+ IAcceptableAliasSet, SubscriptionPolicy)
from mailman.testing.helpers import call_api
from mailman.testing.layers import RESTLayer
@@ -91,11 +92,28 @@ class TestConfiguration(unittest.TestCase):
self.assertEqual(set(IAcceptableAliasSet(self._mlist).aliases),
set(aliases))
+
+ def test_patch_subscription_policy(self):
+ # The new subscription_policy value can be patched.
+ #
+ # To start with, the subscription policy is confirm by default.
+ resource, response = call_api(
+ 'http://localhost:9001/3.0/lists/test@example.com/config')
+ self.assertEqual(resource['subscription_policy'], 'confirm')
+ # Let's patch it to do some moderation.
+ resource, response = call_api(
+ 'http://localhost:9001/3.0/lists/test@example.com/config', dict(
+ subscription_policy='confirm_then_moderate'),
+ method='PATCH')
+ self.assertEqual(response.status, 204)
+ # And now we verify that it has the requested setting.
+ self.assertEqual(self._mlist.subscription_policy,
+ SubscriptionPolicy.confirm_then_moderate)
+
@unittest.expectedFailure
def test_bad_description_update(self):
resource, response = call_api(
'http://localhost:9001/3.0/lists/test@example.com/config',
dict(description='A description with , to check stuff'), 'PATCH')
- self.assertEqual(response.status, 204)
self.assertEqual(self._mlist.description,
'A description with , to check stuff')
diff --git a/src/mailman/rest/tests/test_membership.py b/src/mailman/rest/tests/test_membership.py
index a77dea3b5..4542677b6 100644
--- a/src/mailman/rest/tests/test_membership.py
+++ b/src/mailman/rest/tests/test_membership.py
@@ -100,6 +100,9 @@ class TestMembership(unittest.TestCase):
call_api('http://localhost:9001/3.0/members', {
'list_id': 'test.example.com',
'subscriber': 'anne@example.com',
+ 'pre_verified': True,
+ 'pre_confirmed': True,
+ 'pre_approved': True,
})
self.assertEqual(cm.exception.code, 409)
self.assertEqual(cm.exception.reason, b'Member already subscribed')
@@ -115,6 +118,9 @@ class TestMembership(unittest.TestCase):
call_api('http://localhost:9001/3.0/members', {
'list_id': 'test.example.com',
'subscriber': 'ANNE@example.com',
+ 'pre_verified': True,
+ 'pre_confirmed': True,
+ 'pre_approved': True,
})
self.assertEqual(cm.exception.code, 409)
self.assertEqual(cm.exception.reason, b'Member already subscribed')
@@ -130,6 +136,9 @@ class TestMembership(unittest.TestCase):
call_api('http://localhost:9001/3.0/members', {
'list_id': 'test.example.com',
'subscriber': 'anne@example.com',
+ 'pre_verified': True,
+ 'pre_confirmed': True,
+ 'pre_approved': True,
})
self.assertEqual(cm.exception.code, 409)
self.assertEqual(cm.exception.reason, b'Member already subscribed')
@@ -151,6 +160,9 @@ class TestMembership(unittest.TestCase):
'list_id': 'test.example.com',
'subscriber': 'hugh/person@example.com',
'display_name': 'Hugh Person',
+ 'pre_verified': True,
+ 'pre_confirmed': True,
+ 'pre_approved': True,
})
self.assertEqual(content, None)
self.assertEqual(response.status, 201)
diff --git a/src/mailman/rest/tests/test_moderation.py b/src/mailman/rest/tests/test_moderation.py
index c77ae2aca..e1d1f9ab3 100644
--- a/src/mailman/rest/tests/test_moderation.py
+++ b/src/mailman/rest/tests/test_moderation.py
@@ -18,26 +18,29 @@
"""REST moderation tests."""
__all__ = [
- 'TestModeration',
+ 'TestPostModeration',
+ 'TestSubscriptionModeration',
]
import unittest
from mailman.app.lifecycle import create_list
-from mailman.app.moderator import hold_message, hold_subscription
-from mailman.config import config
+from mailman.app.moderator import hold_message
from mailman.database.transaction import transaction
-from mailman.interfaces.member import DeliveryMode
-from mailman.interfaces.subscriptions import RequestRecord
+from mailman.interfaces.mailinglist import SubscriptionPolicy
+from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import (
- call_api, specialized_message_from_string as mfs)
+ call_api, get_queue_messages, specialized_message_from_string as mfs)
from mailman.testing.layers import RESTLayer
+from mailman.utilities.datetime import now
from urllib.error import HTTPError
+from zope.component import getUtility
-class TestModeration(unittest.TestCase):
+class TestPostModeration(unittest.TestCase):
layer = RESTLayer
def setUp(self):
@@ -71,24 +74,6 @@ Something else.
call_api('http://localhost:9001/3.0/lists/ant@example.com/held/99')
self.assertEqual(cm.exception.code, 404)
- def test_subscription_request_as_held_message(self):
- # Provide the request id of a subscription request using the held
- # message API returns a not-found even though the request id is
- # in the database.
- held_id = hold_message(self._mlist, self._msg)
- subscribe_id = hold_subscription(
- self._mlist,
- RequestRecord('bperson@example.net', 'Bart Person',
- DeliveryMode.regular, 'en'))
- config.db.store.commit()
- url = 'http://localhost:9001/3.0/lists/ant@example.com/held/{0}'
- with self.assertRaises(HTTPError) as cm:
- call_api(url.format(subscribe_id))
- 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['message_id'], '<alpha>')
-
def test_bad_held_message_action(self):
# POSTing to a held message with a bad action.
held_id = hold_message(self._mlist, self._msg)
@@ -99,43 +84,260 @@ Something else.
self.assertEqual(cm.exception.msg,
b'Cannot convert parameters: action')
- def test_bad_subscription_request_id(self):
- # Bad request when request_id is not an integer.
+ def test_discard(self):
+ # Discarding a message removes it from the moderation queue.
+ with transaction():
+ held_id = hold_message(self._mlist, self._msg)
+ url = 'http://localhost:9001/3.0/lists/ant@example.com/held/{}'.format(
+ held_id)
+ content, response = call_api(url, dict(action='discard'))
+ self.assertEqual(response.status, 204)
+ # Now it's gone.
with self.assertRaises(HTTPError) as cm:
- call_api('http://localhost:9001/3.0/lists/ant@example.com/'
- 'requests/bogus')
- self.assertEqual(cm.exception.code, 400)
+ call_api(url, dict(action='discard'))
+ self.assertEqual(cm.exception.code, 404)
- def test_missing_subscription_request_id(self):
- # Bad request when the request_id is not in the database.
+
+
+class TestSubscriptionModeration(unittest.TestCase):
+ layer = RESTLayer
+ maxDiff = None
+
+ def setUp(self):
+ with transaction():
+ self._mlist = create_list('ant@example.com')
+ self._registrar = IRegistrar(self._mlist)
+ manager = getUtility(IUserManager)
+ self._anne = manager.create_address(
+ 'anne@example.com', 'Anne Person')
+ self._bart = manager.make_user(
+ 'bart@example.com', 'Bart Person')
+ preferred = list(self._bart.addresses)[0]
+ preferred.verified_on = now()
+ self._bart.preferred_address = preferred
+
+ def test_no_such_list(self):
+ # Try to get the requests of a nonexistent list.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists/bee@example.com/'
+ 'requests')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_no_such_subscription_token(self):
+ # Bad request when the token is not in the database.
with self.assertRaises(HTTPError) as cm:
call_api('http://localhost:9001/3.0/lists/ant@example.com/'
- 'requests/99')
+ 'requests/missing')
self.assertEqual(cm.exception.code, 404)
def test_bad_subscription_action(self):
# POSTing to a held message with a bad action.
- held_id = hold_subscription(
- self._mlist,
- RequestRecord('cperson@example.net', 'Cris Person',
- DeliveryMode.regular, 'en'))
- config.db.store.commit()
- url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{0}'
+ token, token_owner, member = self._registrar.register(self._anne)
+ # Anne's subscription request got held.
+ self.assertIsNone(member)
+ # Let's try to handle her request, but with a bogus action.
+ url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
with self.assertRaises(HTTPError) as cm:
- call_api(url.format(held_id), {'action': 'bogus'})
+ call_api(url.format(token), dict(
+ action='bogus',
+ ))
self.assertEqual(cm.exception.code, 400)
self.assertEqual(cm.exception.msg,
b'Cannot convert parameters: action')
+ def test_list_held_requests(self):
+ # We can view all the held requests.
+ with transaction():
+ token_1, token_owner, member = self._registrar.register(self._anne)
+ # Anne's subscription request got held.
+ self.assertIsNotNone(token_1)
+ self.assertIsNone(member)
+ token_2, token_owner, member = self._registrar.register(self._bart)
+ self.assertIsNotNone(token_2)
+ self.assertIsNone(member)
+ content, response = call_api(
+ 'http://localhost:9001/3.0/lists/ant@example.com/requests')
+ self.assertEqual(response.status, 200)
+ self.assertEqual(content['total_size'], 2)
+ tokens = set(json['token'] for json in content['entries'])
+ self.assertEqual(tokens, {token_1, token_2})
+ emails = set(json['email'] for json in content['entries'])
+ self.assertEqual(emails, {'anne@example.com', 'bart@example.com'})
+
+ def test_individual_request(self):
+ # We can view an individual request.
+ with transaction():
+ token, token_owner, member = self._registrar.register(self._anne)
+ # Anne's subscription request got held.
+ self.assertIsNotNone(token)
+ self.assertIsNone(member)
+ url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
+ content, response = call_api(url.format(token))
+ self.assertEqual(response.status, 200)
+ self.assertEqual(content['token'], token)
+ self.assertEqual(content['token_owner'], token_owner.name)
+ self.assertEqual(content['email'], 'anne@example.com')
+
+ def test_accept(self):
+ # POST to the request to accept it.
+ with transaction():
+ token, token_owner, member = self._registrar.register(self._anne)
+ # Anne's subscription request got held.
+ self.assertIsNone(member)
+ url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
+ content, response = call_api(url.format(token), dict(
+ action='accept',
+ ))
+ self.assertEqual(response.status, 204)
+ # Anne is a member.
+ self.assertEqual(
+ self._mlist.members.get_member('anne@example.com').address,
+ self._anne)
+ # The request URL no longer exists.
+ with self.assertRaises(HTTPError) as cm:
+ call_api(url.format(token), dict(
+ action='accept',
+ ))
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_accept_bad_token(self):
+ # Try to accept a request with a bogus token.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists/ant@example.com'
+ '/requests/bogus',
+ dict(action='accept'))
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_accept_by_moderator_clears_request_queue(self):
+ # After accepting a message held for moderator approval, there are no
+ # more requests to handle.
+ #
+ # We start with nothing in the queue.
+ content, response = call_api(
+ 'http://localhost:9001/3.0/lists/ant@example.com/requests')
+ self.assertEqual(content['total_size'], 0)
+ # Anne tries to subscribe to a list that only requests moderator
+ # approval.
+ with transaction():
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ token, token_owner, member = self._registrar.register(
+ self._anne,
+ pre_verified=True, pre_confirmed=True)
+ # There's now one request in the queue, and it's waiting on moderator
+ # approval.
+ content, response = call_api(
+ 'http://localhost:9001/3.0/lists/ant@example.com/requests')
+ self.assertEqual(content['total_size'], 1)
+ json = content['entries'][0]
+ self.assertEqual(json['token_owner'], 'moderator')
+ self.assertEqual(json['email'], 'anne@example.com')
+ # The moderator approves the request.
+ url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
+ content, response = call_api(url.format(token), {'action': 'accept'})
+ self.assertEqual(response.status, 204)
+ # And now the request queue is empty.
+ content, response = call_api(
+ 'http://localhost:9001/3.0/lists/ant@example.com/requests')
+ self.assertEqual(content['total_size'], 0)
+
def test_discard(self):
- # Discarding a message removes it from the moderation queue.
+ # POST to the request to discard it.
with transaction():
- held_id = hold_message(self._mlist, self._msg)
- url = 'http://localhost:9001/3.0/lists/ant@example.com/held/{}'.format(
- held_id)
- content, response = call_api(url, dict(action='discard'))
+ token, token_owner, member = self._registrar.register(self._anne)
+ # Anne's subscription request got held.
+ self.assertIsNone(member)
+ url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
+ content, response = call_api(url.format(token), dict(
+ action='discard',
+ ))
self.assertEqual(response.status, 204)
- # Now it's gone.
+ # Anne is not a member.
+ self.assertIsNone(self._mlist.members.get_member('anne@example.com'))
+ # The request URL no longer exists.
with self.assertRaises(HTTPError) as cm:
- call_api(url, dict(action='discard'))
+ call_api(url.format(token), dict(
+ action='discard',
+ ))
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_defer(self):
+ # Defer the decision for some other moderator.
+ with transaction():
+ token, token_owner, member = self._registrar.register(self._anne)
+ # Anne's subscription request got held.
+ self.assertIsNone(member)
+ url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
+ content, response = call_api(url.format(token), dict(
+ action='defer',
+ ))
+ self.assertEqual(response.status, 204)
+ # Anne is not a member.
+ self.assertIsNone(self._mlist.members.get_member('anne@example.com'))
+ # The request URL still exists.
+ content, response = call_api(url.format(token), dict(
+ action='defer',
+ ))
+ self.assertEqual(response.status, 204)
+ # And now we can accept it.
+ content, response = call_api(url.format(token), dict(
+ action='accept',
+ ))
+ self.assertEqual(response.status, 204)
+ # Anne is a member.
+ self.assertEqual(
+ self._mlist.members.get_member('anne@example.com').address,
+ self._anne)
+ # The request URL no longer exists.
+ with self.assertRaises(HTTPError) as cm:
+ call_api(url.format(token), dict(
+ action='accept',
+ ))
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_defer_bad_token(self):
+ # Try to accept a request with a bogus token.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists/ant@example.com'
+ '/requests/bogus',
+ dict(action='defer'))
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_reject(self):
+ # POST to the request to reject it. This leaves a bounce message in
+ # the virgin queue.
+ with transaction():
+ token, token_owner, member = self._registrar.register(self._anne)
+ # Anne's subscription request got held.
+ self.assertIsNone(member)
+ # Clear out the virgin queue, which currently contains the
+ # confirmation message sent to Anne.
+ get_queue_messages('virgin')
+ url = 'http://localhost:9001/3.0/lists/ant@example.com/requests/{}'
+ content, response = call_api(url.format(token), dict(
+ action='reject',
+ ))
+ self.assertEqual(response.status, 204)
+ # Anne is not a member.
+ self.assertIsNone(self._mlist.members.get_member('anne@example.com'))
+ # The request URL no longer exists.
+ with self.assertRaises(HTTPError) as cm:
+ call_api(url.format(token), dict(
+ action='reject',
+ ))
+ self.assertEqual(cm.exception.code, 404)
+ # And the rejection message to Anne is now in the virgin queue.
+ items = get_queue_messages('virgin')
+ self.assertEqual(len(items), 1)
+ message = items[0].msg
+ self.assertEqual(message['From'], 'ant-bounces@example.com')
+ self.assertEqual(message['To'], 'anne@example.com')
+ self.assertEqual(message['Subject'],
+ 'Request to mailing list "Ant" rejected')
+
+ def test_reject_bad_token(self):
+ # Try to accept a request with a bogus token.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/lists/ant@example.com'
+ '/requests/bogus',
+ dict(action='reject'))
self.assertEqual(cm.exception.code, 404)
diff --git a/src/mailman/rest/tests/test_users.py b/src/mailman/rest/tests/test_users.py
index af2c9f0d1..ac8d018e8 100644
--- a/src/mailman/rest/tests/test_users.py
+++ b/src/mailman/rest/tests/test_users.py
@@ -376,7 +376,8 @@ class TestLP1074374(unittest.TestCase):
call_api('http://localhost:9001/3.0/members', dict(
list_id='test.example.com',
subscriber='anne@example.com',
- role='member'))
+ role='member',
+ pre_verified=True, pre_confirmed=True, pre_approved=True))
# This is not the Anne you're looking for. (IOW, the new Anne is a
# different user).
content, response = call_api(
diff --git a/src/mailman/rest/tests/test_validator.py b/src/mailman/rest/tests/test_validator.py
index c670fc77c..2d515f828 100644
--- a/src/mailman/rest/tests/test_validator.py
+++ b/src/mailman/rest/tests/test_validator.py
@@ -24,8 +24,11 @@ __all__ = [
import unittest
-from mailman.rest.validator import list_of_strings_validator
+from mailman.interfaces.usermanager import IUserManager
+from mailman.rest.validator import (
+ list_of_strings_validator, subscriber_validator)
from mailman.testing.layers import RESTLayer
+from zope.component import getUtility
@@ -46,3 +49,16 @@ class TestValidators(unittest.TestCase):
# Strings are required.
self.assertRaises(ValueError, list_of_strings_validator, 7)
self.assertRaises(ValueError, list_of_strings_validator, ['ant', 7])
+
+ def test_subscriber_validator_uuid(self):
+ # Convert from an existing user id to a UUID.
+ anne = getUtility(IUserManager).make_user('anne@example.com')
+ uuid = subscriber_validator(str(anne.user_id.int))
+ self.assertEqual(anne.user_id, uuid)
+
+ def test_subscriber_validator_bad_uuid(self):
+ self.assertRaises(ValueError, subscriber_validator, 'not-a-thing')
+
+ def test_subscriber_validator_email_address(self):
+ self.assertEqual(subscriber_validator('anne@example.com'),
+ 'anne@example.com')
diff --git a/src/mailman/rest/validator.py b/src/mailman/rest/validator.py
index 720d7adc1..1d5ad4ef9 100644
--- a/src/mailman/rest/validator.py
+++ b/src/mailman/rest/validator.py
@@ -29,6 +29,7 @@ __all__ = [
from mailman.core.errors import (
ReadOnlyPATCHRequestError, UnknownPATCHRequestError)
+from mailman.interfaces.address import IEmailValidator
from mailman.interfaces.languages import ILanguageManager
from uuid import UUID
from zope.component import getUtility
@@ -59,7 +60,10 @@ def subscriber_validator(subscriber):
try:
return UUID(int=int(subscriber))
except ValueError:
- return subscriber
+ # It must be an email address.
+ if getUtility(IEmailValidator).is_valid(subscriber):
+ return subscriber
+ raise ValueError
def language_validator(code):