summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--MANIFEST.in2
-rw-r--r--README.rst1
-rw-r--r--TODO.rst10
-rwxr-xr-xcopybump.py1
-rw-r--r--port_me/bumpdigests.py (renamed from src/mailman/bin/bumpdigests.py)0
-rw-r--r--port_me/checkdbs.py (renamed from src/mailman/bin/checkdbs.py)0
-rw-r--r--port_me/config_list.py (renamed from src/mailman/bin/config_list.py)0
-rw-r--r--port_me/disabled.py (renamed from src/mailman/bin/disabled.py)0
-rw-r--r--port_me/export.py (renamed from src/mailman/bin/export.py)0
-rw-r--r--port_me/find_member.py (renamed from src/mailman/bin/find_member.py)0
-rw-r--r--port_me/gate_news.py (renamed from src/mailman/bin/gate_news.py)0
-rw-r--r--port_me/list_owners.py (renamed from src/mailman/bin/list_owners.py)0
-rw-r--r--port_me/senddigests.py (renamed from src/mailman/bin/senddigests.py)0
-rw-r--r--port_me/show_config.py (renamed from src/mailman/bin/show_config.py)0
-rw-r--r--src/mailman/app/docs/moderator.rst31
-rw-r--r--src/mailman/app/docs/subscriptions.rst262
-rw-r--r--src/mailman/app/membership.py88
-rw-r--r--src/mailman/app/moderator.py75
-rw-r--r--src/mailman/app/registrar.py138
-rw-r--r--src/mailman/app/subscriptions.py341
-rw-r--r--src/mailman/app/tests/test_bounces.py18
-rw-r--r--src/mailman/app/tests/test_membership.py157
-rw-r--r--src/mailman/app/tests/test_moderation.py30
-rw-r--r--src/mailman/app/tests/test_notifications.py32
-rw-r--r--src/mailman/app/tests/test_registrar.py236
-rw-r--r--src/mailman/app/tests/test_registration.py128
-rw-r--r--src/mailman/app/tests/test_subscriptions.py632
-rw-r--r--src/mailman/app/tests/test_workflow.py128
-rw-r--r--src/mailman/app/workflow.py156
-rw-r--r--src/mailman/archiving/mailarchive.py1
-rw-r--r--src/mailman/archiving/mhonarc.py3
-rw-r--r--src/mailman/archiving/prototype.py2
-rw-r--r--src/mailman/bin/docs/master.rst8
-rw-r--r--src/mailman/bin/mailman.py2
-rw-r--r--src/mailman/bin/master.py2
-rw-r--r--src/mailman/bin/runner.py21
-rw-r--r--src/mailman/chains/docs/moderation.rst10
-rw-r--r--src/mailman/chains/hold.py2
-rw-r--r--src/mailman/commands/cli_import.py27
-rw-r--r--src/mailman/commands/cli_inject.py2
-rw-r--r--src/mailman/commands/cli_lists.py2
-rw-r--r--src/mailman/commands/cli_members.py12
-rw-r--r--src/mailman/commands/cli_status.py2
-rw-r--r--src/mailman/commands/cli_withlist.py10
-rw-r--r--src/mailman/commands/docs/aliases.rst2
-rw-r--r--src/mailman/commands/docs/conf.rst6
-rw-r--r--src/mailman/commands/docs/create.rst3
-rw-r--r--src/mailman/commands/docs/lists.rst8
-rw-r--r--src/mailman/commands/docs/members.rst49
-rw-r--r--src/mailman/commands/docs/membership.rst52
-rw-r--r--src/mailman/commands/eml_confirm.py16
-rw-r--r--src/mailman/commands/eml_membership.py50
-rw-r--r--src/mailman/commands/tests/test_confirm.py12
-rw-r--r--src/mailman/commands/tests/test_create.py4
-rw-r--r--src/mailman/commands/tests/test_import.py61
-rw-r--r--src/mailman/commands/tests/test_lists.py67
-rw-r--r--src/mailman/config/config.py8
-rw-r--r--src/mailman/config/configure.zcml16
-rw-r--r--src/mailman/config/schema.cfg10
-rw-r--r--src/mailman/core/docs/chains.rst (renamed from src/mailman/app/docs/chains.rst)12
-rw-r--r--src/mailman/core/initialize.py4
-rw-r--r--src/mailman/core/logging.py4
-rw-r--r--src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py41
-rw-r--r--src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py28
-rw-r--r--src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py56
-rw-r--r--src/mailman/database/tests/test_factory.py11
-rw-r--r--src/mailman/database/transaction.py24
-rw-r--r--src/mailman/docs/8-miles-high.rst6
-rw-r--r--src/mailman/docs/ACKNOWLEDGMENTS.rst35
-rw-r--r--src/mailman/docs/DATABASE.rst16
-rw-r--r--src/mailman/docs/DEVELOP.rst4
-rw-r--r--src/mailman/docs/INTRODUCTION.rst18
-rw-r--r--src/mailman/docs/MTA.rst2
-rw-r--r--src/mailman/docs/NEWS.rst49
-rw-r--r--src/mailman/docs/RELEASENOTES.rst23
-rw-r--r--src/mailman/docs/START.rst92
-rw-r--r--src/mailman/docs/WebUIin5.rst4
-rw-r--r--src/mailman/interfaces/domain.py16
-rw-r--r--src/mailman/interfaces/mailinglist.py16
-rw-r--r--src/mailman/interfaces/member.py10
-rw-r--r--src/mailman/interfaces/pending.py12
-rw-r--r--src/mailman/interfaces/registrar.py100
-rw-r--r--src/mailman/interfaces/roster.py21
-rw-r--r--src/mailman/interfaces/subscriptions.py66
-rw-r--r--src/mailman/interfaces/usermanager.py16
-rw-r--r--src/mailman/interfaces/workflow.py80
-rw-r--r--src/mailman/model/docs/domains.rst58
-rw-r--r--src/mailman/model/docs/membership.rst38
-rw-r--r--src/mailman/model/docs/pending.rst29
-rw-r--r--src/mailman/model/docs/registration.rst363
-rw-r--r--src/mailman/model/docs/usermanager.rst22
-rw-r--r--src/mailman/model/docs/users.rst15
-rw-r--r--src/mailman/model/domain.py57
-rw-r--r--src/mailman/model/listmanager.py4
-rw-r--r--src/mailman/model/mailinglist.py8
-rw-r--r--src/mailman/model/member.py4
-rw-r--r--src/mailman/model/pending.py12
-rw-r--r--src/mailman/model/roster.py64
-rw-r--r--src/mailman/model/tests/test_domain.py93
-rw-r--r--src/mailman/model/tests/test_mailinglist.py24
-rw-r--r--src/mailman/model/tests/test_registrar.py64
-rw-r--r--src/mailman/model/tests/test_roster.py52
-rw-r--r--src/mailman/model/tests/test_uid.py41
-rw-r--r--src/mailman/model/tests/test_user.py32
-rw-r--r--src/mailman/model/tests/test_usermanager.py88
-rw-r--r--src/mailman/model/tests/test_workflow.py148
-rw-r--r--src/mailman/model/uid.py19
-rw-r--r--src/mailman/model/user.py18
-rw-r--r--src/mailman/model/usermanager.py24
-rw-r--r--src/mailman/model/workflow.py76
-rw-r--r--src/mailman/mta/tests/test_delivery.py8
-rw-r--r--src/mailman/rest/addresses.py7
-rw-r--r--src/mailman/rest/docs/addresses.rst67
-rw-r--r--src/mailman/rest/docs/domains.rst123
-rw-r--r--src/mailman/rest/docs/listconf.rst3
-rw-r--r--src/mailman/rest/docs/membership.rst181
-rw-r--r--src/mailman/rest/docs/moderation.rst362
-rw-r--r--src/mailman/rest/docs/post-moderation.rst193
-rw-r--r--src/mailman/rest/docs/queues.rst2
-rw-r--r--src/mailman/rest/docs/sub-moderation.rst110
-rw-r--r--src/mailman/rest/docs/users.rst99
-rw-r--r--src/mailman/rest/domains.py33
-rw-r--r--src/mailman/rest/helpers.py6
-rw-r--r--src/mailman/rest/listconf.py17
-rw-r--r--src/mailman/rest/lists.py3
-rw-r--r--src/mailman/rest/members.py152
-rw-r--r--src/mailman/rest/post_moderation.py (renamed from src/mailman/rest/moderation.py)109
-rw-r--r--src/mailman/rest/root.py31
-rw-r--r--src/mailman/rest/sub_moderation.py148
-rw-r--r--src/mailman/rest/tests/test_addresses.py9
-rw-r--r--src/mailman/rest/tests/test_domains.py72
-rw-r--r--src/mailman/rest/tests/test_listconf.py21
-rw-r--r--src/mailman/rest/tests/test_lists.py16
-rw-r--r--src/mailman/rest/tests/test_membership.py56
-rw-r--r--src/mailman/rest/tests/test_moderation.py293
-rw-r--r--src/mailman/rest/tests/test_root.py8
-rw-r--r--src/mailman/rest/tests/test_uids.py76
-rw-r--r--src/mailman/rest/tests/test_users.py229
-rw-r--r--src/mailman/rest/tests/test_validator.py64
-rw-r--r--src/mailman/rest/users.py107
-rw-r--r--src/mailman/rest/validator.py17
-rw-r--r--src/mailman/rules/docs/moderation.rst7
-rw-r--r--src/mailman/runners/docs/command.rst7
-rw-r--r--src/mailman/runners/docs/incoming.rst4
-rw-r--r--src/mailman/runners/docs/outgoing.rst14
-rw-r--r--src/mailman/runners/tests/test_confirm.py5
-rw-r--r--src/mailman/runners/tests/test_join.py14
-rw-r--r--src/mailman/styles/base.py4
-rw-r--r--src/mailman/templates/en/confirm.txt4
-rw-r--r--src/mailman/templates/en/postauth.txt7
-rw-r--r--src/mailman/templates/en/subauth.txt6
-rw-r--r--src/mailman/templates/en/unsubauth.txt2
-rw-r--r--src/mailman/testing/config-with-instances.pckbin0 -> 4667 bytes
-rw-r--r--src/mailman/testing/helpers.py21
-rw-r--r--src/mailman/testing/layers.py2
-rw-r--r--src/mailman/utilities/i18n.py2
-rw-r--r--src/mailman/utilities/importer.py26
-rw-r--r--src/mailman/utilities/tests/test_import.py74
-rw-r--r--src/mailman/version.py6
159 files changed, 5599 insertions, 2283 deletions
diff --git a/MANIFEST.in b/MANIFEST.in
index e4c64eedc..093f1bd38 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -11,6 +11,6 @@ prune src/web
prune eggs
prune parts
prune .tox
-include src/mailman/testing/config.pck
+include src/mailman/testing/config*.pck
include src/mailman/database/alembic/script.py.mako
include src/mailman/database/alembic/versions/*.py
diff --git a/README.rst b/README.rst
index de4938df8..7ff7535e1 100644
--- a/README.rst
+++ b/README.rst
@@ -35,6 +35,7 @@ Table of Contents
src/mailman/docs/INTRODUCTION
src/mailman/docs/START
+ src/mailman/docs/RELEASENOTES
src/mailman/docs/DATABASE
src/mailman/docs/MTA
src/mailman/docs/8-miles-high
diff --git a/TODO.rst b/TODO.rst
new file mode 100644
index 000000000..0c1bfa969
--- /dev/null
+++ b/TODO.rst
@@ -0,0 +1,10 @@
+* TO DO:
+ - rename ISubscriptionService to IMemberSearch or somesuch
+ - get rid of hold_subscription
+ - subsume handle_subscription
+ - workflow for unsubscription
+ - make sure registration checks IEmailValidator
+ - Test all the various options in eml_membership's get_subscriber()
+ - Admin notification on membership changes?
+ + admin_notify_mchanges
+ + admin_immed_notify
diff --git a/copybump.py b/copybump.py
index 72160d7f9..a4eea59a3 100755
--- a/copybump.py
+++ b/copybump.py
@@ -57,6 +57,7 @@ def do_walk():
for root, dirs, files in os.walk('.'):
if root == '.':
remove(dirs, '.bzr')
+ remove(dirs, '.git')
remove(dirs, 'bin')
remove(dirs, 'contrib')
remove(dirs, 'develop-eggs')
diff --git a/src/mailman/bin/bumpdigests.py b/port_me/bumpdigests.py
index f30772ca8..f30772ca8 100644
--- a/src/mailman/bin/bumpdigests.py
+++ b/port_me/bumpdigests.py
diff --git a/src/mailman/bin/checkdbs.py b/port_me/checkdbs.py
index 61aa6b6f1..61aa6b6f1 100644
--- a/src/mailman/bin/checkdbs.py
+++ b/port_me/checkdbs.py
diff --git a/src/mailman/bin/config_list.py b/port_me/config_list.py
index a0b2a54f4..a0b2a54f4 100644
--- a/src/mailman/bin/config_list.py
+++ b/port_me/config_list.py
diff --git a/src/mailman/bin/disabled.py b/port_me/disabled.py
index b190556c2..b190556c2 100644
--- a/src/mailman/bin/disabled.py
+++ b/port_me/disabled.py
diff --git a/src/mailman/bin/export.py b/port_me/export.py
index 279abc36f..279abc36f 100644
--- a/src/mailman/bin/export.py
+++ b/port_me/export.py
diff --git a/src/mailman/bin/find_member.py b/port_me/find_member.py
index 349af8247..349af8247 100644
--- a/src/mailman/bin/find_member.py
+++ b/port_me/find_member.py
diff --git a/src/mailman/bin/gate_news.py b/port_me/gate_news.py
index 72568cd1b..72568cd1b 100644
--- a/src/mailman/bin/gate_news.py
+++ b/port_me/gate_news.py
diff --git a/src/mailman/bin/list_owners.py b/port_me/list_owners.py
index 5b5fca2bf..5b5fca2bf 100644
--- a/src/mailman/bin/list_owners.py
+++ b/port_me/list_owners.py
diff --git a/src/mailman/bin/senddigests.py b/port_me/senddigests.py
index 59c03de2d..59c03de2d 100644
--- a/src/mailman/bin/senddigests.py
+++ b/port_me/senddigests.py
diff --git a/src/mailman/bin/show_config.py b/port_me/show_config.py
index 290840ae3..290840ae3 100644
--- a/src/mailman/bin/show_config.py
+++ b/port_me/show_config.py
diff --git a/src/mailman/app/docs/moderator.rst b/src/mailman/app/docs/moderator.rst
index 490c9630a..43dc7688f 100644
--- a/src/mailman/app/docs/moderator.rst
+++ b/src/mailman/app/docs/moderator.rst
@@ -238,14 +238,16 @@ 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.
+name, 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
+ >>> from mailman.interfaces.subscriptions import RequestRecord
>>> req_id = hold_subscription(
- ... mlist, 'fred@example.org', 'Fred Person',
- ... '{NONE}abcxyz', DeliveryMode.regular, 'en')
+ ... mlist,
+ ... RequestRecord('fred@example.org', 'Fred Person',
+ ... DeliveryMode.regular, 'en'))
Disposing of membership change requests
@@ -269,8 +271,9 @@ The held subscription can also be discarded.
Gwen tries to subscribe to the mailing list, but...
>>> req_id = hold_subscription(
- ... mlist, 'gwen@example.org', 'Gwen Person',
- ... '{NONE}zyxcba', DeliveryMode.regular, 'en')
+ ... mlist,
+ ... RequestRecord('gwen@example.org', 'Gwen Person',
+ ... DeliveryMode.regular, 'en'))
...her request is rejected...
@@ -305,8 +308,9 @@ mailing list.
>>> mlist.send_welcome_message = False
>>> req_id = hold_subscription(
- ... mlist, 'herb@example.org', 'Herb Person',
- ... 'abcxyz', DeliveryMode.regular, 'en')
+ ... mlist,
+ ... RequestRecord('herb@example.org', 'Herb Person',
+ ... DeliveryMode.regular, 'en'))
The moderators accept the subscription request.
@@ -399,8 +403,9 @@ list is configured to send them.
Iris tries to subscribe to the mailing list.
- >>> req_id = hold_subscription(mlist, 'iris@example.org', 'Iris Person',
- ... 'password', DeliveryMode.regular, 'en')
+ >>> req_id = hold_subscription(mlist,
+ ... RequestRecord('iris@example.org', 'Iris Person',
+ ... DeliveryMode.regular, 'en'))
There's now a message in the virgin queue, destined for the list owner.
@@ -419,7 +424,6 @@ There's now a message in the virgin queue, destined for the list owner.
<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.
@@ -491,8 +495,9 @@ can get a welcome message.
>>> mlist.admin_notify_mchanges = False
>>> mlist.send_welcome_message = True
- >>> req_id = hold_subscription(mlist, 'kate@example.org', 'Kate Person',
- ... 'password', DeliveryMode.regular, 'en')
+ >>> req_id = hold_subscription(mlist,
+ ... RequestRecord('kate@example.org', 'Kate Person',
+ ... DeliveryMode.regular, 'en'))
>>> handle_subscription(mlist, req_id, Action.accept)
>>> messages = get_queue_messages('virgin')
>>> len(messages)
diff --git a/src/mailman/app/docs/subscriptions.rst b/src/mailman/app/docs/subscriptions.rst
index eaccdc3cc..2fc59d9a7 100644
--- a/src/mailman/app/docs/subscriptions.rst
+++ b/src/mailman/app/docs/subscriptions.rst
@@ -2,9 +2,10 @@
Subscription services
=====================
-The `ISubscriptionService` utility provides higher level convenience methods
-useful for searching, retrieving, iterating, adding, and removing
-memberships.
+The ``ISubscriptionService`` utility provides higher level convenience methods
+useful for searching, retrieving, iterating, and removing memberships across
+all mailing lists on th esystem. Adding new users is handled by the
+``IRegistrar`` interface.
>>> from mailman.interfaces.subscriptions import ISubscriptionService
>>> from zope.component import getUtility
@@ -22,183 +23,154 @@ membership role. At first, there are no memberships.
None
-Adding new members
-==================
-
-The service can be used to subscribe new members, by default with the `member`
-role. At a minimum, a mailing list and an address for the new user is
-required.
-
- >>> mlist = create_list('test@example.com')
- >>> anne = service.join('test.example.com', 'anne@example.com')
- >>> anne
- <Member: anne <anne@example.com> on test@example.com as MemberRole.member>
+Listing members
+===============
-The real name of the new member can be given.
+When there are some members, of any role on any mailing list, they can be
+retrieved through the subscription service.
- >>> bart = service.join('test.example.com', 'bart@example.com',
- ... 'Bart Person')
- >>> bart
- <Member: Bart Person <bart@example.com>
- on test@example.com as MemberRole.member>
+ >>> from mailman.app.lifecycle import create_list
+ >>> ant = create_list('ant@example.com')
+ >>> bee = create_list('bee@example.com')
+ >>> cat = create_list('cat@example.com')
-Other roles can also be subscribed.
+Some people become members.
>>> from mailman.interfaces.member import MemberRole
- >>> anne_owner = service.join('test.example.com', 'anne@example.com',
- ... role=MemberRole.owner)
- >>> anne_owner
- <Member: anne <anne@example.com> on test@example.com as MemberRole.owner>
+ >>> from mailman.testing.helpers import subscribe
+ >>> anne_1 = subscribe(ant, 'Anne')
+ >>> anne_2 = subscribe(ant, 'Anne', MemberRole.owner)
+ >>> bart_1 = subscribe(ant, 'Bart', MemberRole.moderator)
+ >>> bart_2 = subscribe(bee, 'Bart', MemberRole.owner)
+ >>> anne_3 = subscribe(cat, 'Anne', email='anne@example.com')
+ >>> cris_1 = subscribe(cat, 'Cris')
-And all the subscribed members can now be displayed.
+The service can be used to iterate over them.
- >>> service.get_members()
- [<Member: anne <anne@example.com> on test@example.com as MemberRole.owner>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.member>,
- <Member: Bart Person <bart@example.com> on test@example.com
- as MemberRole.member>]
- >>> sum(1 for member in service)
- 3
- >>> print(service.get_member(UUID(int=3)))
- <Member: anne <anne@example.com> on test@example.com as MemberRole.owner>
+ >>> for member in service.get_members():
+ ... print(member)
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.owner>
+ <Member: Bart Person <bperson@example.com>
+ on ant@example.com as MemberRole.moderator>
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.member>
+ <Member: Bart Person <bperson@example.com>
+ on bee@example.com as MemberRole.owner>
+ <Member: Anne Person <anne@example.com>
+ on cat@example.com as MemberRole.member>
+ <Member: Cris Person <cperson@example.com>
+ on cat@example.com as MemberRole.member>
-New members can also be added by providing an existing user id instead of an
-email address. However, the user must have a preferred email address.
-::
+The service can also be used to get the information about a single member.
- >>> from mailman.utilities.datetime import now
- >>> address = list(bart.user.addresses)[0]
- >>> address.verified_on = now()
- >>> bart.user.preferred_address = address
- >>> service.join('test.example.com', bart.user.user_id,
- ... role=MemberRole.owner)
- <Member: Bart Person <bart@example.com>
- on test@example.com as MemberRole.owner>
+ >>> print(service.get_member(bart_2.member_id))
+ <Member: Bart Person <bperson@example.com>
+ on bee@example.com as MemberRole.owner>
+There is an iteration shorthand for getting all the members.
-Removing members
-================
-
-Regular members can also be removed.
-
- >>> cris = service.join('test.example.com', 'cris@example.com')
- >>> service.get_members()
- [<Member: anne <anne@example.com> on test@example.com
- as MemberRole.owner>,
- <Member: Bart Person <bart@example.com> on test@example.com
- as MemberRole.owner>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.member>,
- <Member: Bart Person <bart@example.com> on test@example.com
- as MemberRole.member>,
- <Member: cris <cris@example.com> on test@example.com
- as MemberRole.member>]
- >>> sum(1 for member in service)
- 5
- >>> service.leave('test.example.com', 'cris@example.com')
- >>> service.get_members()
- [<Member: anne <anne@example.com> on test@example.com
- as MemberRole.owner>,
- <Member: Bart Person <bart@example.com> on test@example.com
- as MemberRole.owner>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.member>,
- <Member: Bart Person <bart@example.com> on test@example.com
- as MemberRole.member>]
- >>> sum(1 for member in service)
- 4
+ >>> for member in service:
+ ... print(member)
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.owner>
+ <Member: Bart Person <bperson@example.com>
+ on ant@example.com as MemberRole.moderator>
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.member>
+ <Member: Bart Person <bperson@example.com>
+ on bee@example.com as MemberRole.owner>
+ <Member: Anne Person <anne@example.com>
+ on cat@example.com as MemberRole.member>
+ <Member: Cris Person <cperson@example.com>
+ on cat@example.com as MemberRole.member>
Finding members
===============
-If you know the member id for a specific member, you can get that member.
-
- >>> service.get_member(UUID(int=3))
- <Member: anne <anne@example.com> on test@example.com as MemberRole.owner>
-
-If you know the member's address, you can find all their memberships, based on
-specific search criteria. We start by subscribing Anne to a couple of new
-mailing lists.
+The subscription service can be used to find memberships based on specific
+search criteria. For example, we can find all the mailing lists that Anne is
+a member of with her ``aperson@example.com`` address.
- >>> mlist2 = create_list('foo@example.com')
- >>> mlist3 = create_list('bar@example.com')
- >>> address = list(anne.user.addresses)[0]
- >>> address.verified_on = now()
- >>> anne.user.preferred_address = address
- >>> mlist.subscribe(anne.user, MemberRole.moderator)
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.moderator>
- >>> mlist2.subscribe(anne.user, MemberRole.member)
- <Member: anne <anne@example.com> on foo@example.com as MemberRole.member>
- >>> mlist3.subscribe(anne.user, MemberRole.owner)
- <Member: anne <anne@example.com> on bar@example.com as MemberRole.owner>
-
-And now we can find all of Anne's memberships.
-
- >>> service.find_members('anne@example.com')
- [<Member: anne <anne@example.com> on bar@example.com as MemberRole.owner>,
- <Member: anne <anne@example.com> on foo@example.com as MemberRole.member>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.member>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.owner>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.moderator>]
+ >>> for member in service.find_members('aperson@example.com'):
+ ... print(member)
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.member>
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.owner>
There may be no matching memberships.
- >>> service.find_members('cris@example.com')
+ >>> service.find_members('dave@example.com')
[]
Memberships can also be searched for by user id.
- >>> service.find_members(UUID(int=1))
- [<Member: anne <anne@example.com> on bar@example.com as MemberRole.owner>,
- <Member: anne <anne@example.com> on foo@example.com as MemberRole.member>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.member>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.owner>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.moderator>]
+ >>> for member in service.find_members(anne_1.user.user_id):
+ ... print(member)
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.member>
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.owner>
You can find all the memberships for a specific mailing list.
- >>> service.find_members(list_id='test.example.com')
- [<Member: anne <anne@example.com> on test@example.com
- as MemberRole.member>,
- <Member: anne <anne@example.com> on test@example.com as MemberRole.owner>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.moderator>,
- <Member: Bart Person <bart@example.com> on test@example.com
- as MemberRole.member>,
- <Member: Bart Person <bart@example.com> on test@example.com
- as MemberRole.owner>]
+ >>> for member in service.find_members(list_id='ant.example.com'):
+ ... print(member)
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.member>
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.owner>
+ <Member: Bart Person <bperson@example.com>
+ on ant@example.com as MemberRole.moderator>
You can find all the memberships for an address on a specific mailing list,
but you have to give it the list id, not the fqdn listname since the former is
stable but the latter could change if the list is moved.
- >>> service.find_members('anne@example.com', 'test.example.com')
- [<Member: anne <anne@example.com> on test@example.com
- as MemberRole.member>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.owner>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.moderator>]
+ >>> for member in service.find_members(
+ ... 'bperson@example.com', 'ant.example.com'):
+ ... print(member)
+ <Member: Bart Person <bperson@example.com>
+ on ant@example.com as MemberRole.moderator>
You can find all the memberships for an address with a specific role.
- >>> service.find_members('anne@example.com', role=MemberRole.owner)
- [<Member: anne <anne@example.com> on bar@example.com as MemberRole.owner>,
- <Member: anne <anne@example.com> on test@example.com
- as MemberRole.owner>]
+ >>> for member in service.find_members(
+ ... list_id='ant.example.com', role=MemberRole.owner):
+ ... print(member)
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.owner>
You can also find a specific membership by all three criteria.
- >>> service.find_members('anne@example.com', 'test.example.com',
- ... MemberRole.owner)
- [<Member: anne <anne@example.com> on test@example.com
- as MemberRole.owner>]
+ >>> for member in service.find_members(
+ ... 'bperson@example.com', 'bee.example.com', MemberRole.owner):
+ ... print(member)
+ <Member: Bart Person <bperson@example.com>
+ on bee@example.com as MemberRole.owner>
+
+
+Removing members
+================
+
+Members can be removed via this service.
+
+ >>> len(service.get_members())
+ 6
+ >>> service.leave('cat.example.com', 'cperson@example.com')
+ >>> len(service.get_members())
+ 5
+ >>> for member in service:
+ ... print(member)
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.owner>
+ <Member: Bart Person <bperson@example.com>
+ on ant@example.com as MemberRole.moderator>
+ <Member: Anne Person <aperson@example.com>
+ on ant@example.com as MemberRole.member>
+ <Member: Bart Person <bperson@example.com>
+ on bee@example.com as MemberRole.owner>
+ <Member: Anne Person <anne@example.com>
+ on cat@example.com as MemberRole.member>
diff --git a/src/mailman/app/membership.py b/src/mailman/app/membership.py
index c50169a7c..85a6cba08 100644
--- a/src/mailman/app/membership.py
+++ b/src/mailman/app/membership.py
@@ -27,20 +27,19 @@ __all__ = [
from email.utils import formataddr
from mailman.app.notifications import (
send_goodbye_message, send_welcome_message)
-from mailman.config import config
from mailman.core.i18n import _
from mailman.email.message import OwnerNotification
from mailman.interfaces.bans import IBanManager
from mailman.interfaces.member import (
- MemberRole, MembershipIsBannedError, NotAMemberError, SubscriptionEvent)
+ AlreadySubscribedError, MemberRole, MembershipIsBannedError,
+ NotAMemberError, SubscriptionEvent)
from mailman.interfaces.usermanager import IUserManager
from mailman.utilities.i18n import make
from zope.component import getUtility
-def add_member(mlist, email, display_name, password, delivery_mode, language,
- role=MemberRole.member):
+def add_member(mlist, record, role=MemberRole.member):
"""Add a member right now.
The member's subscription must be approved by whatever policy the list
@@ -48,16 +47,8 @@ def add_member(mlist, email, display_name, password, delivery_mode, language,
:param mlist: The mailing list to add the member to.
:type mlist: `IMailingList`
- :param email: The email address to subscribe.
- :type email: str
- :param display_name: The subscriber's full name.
- :type display_name: str
- :param password: The subscriber's plain text password.
- :type password: str
- :param delivery_mode: The delivery mode the subscriber has chosen.
- :type delivery_mode: DeliveryMode
- :param language: The language that the subscriber is going to use.
- :type language: str
+ :param record: a subscription request record.
+ :type record: RequestRecord
:param role: The membership role for this subscription.
:type role: `MemberRole`
:return: The just created member.
@@ -68,45 +59,40 @@ def add_member(mlist, email, display_name, password, delivery_mode, language,
:raises MembershipIsBannedError: if the membership is not allowed.
"""
# Check to see if the email address is banned.
- if IBanManager(mlist).is_banned(email):
- raise MembershipIsBannedError(mlist, email)
- # See if there's already a user linked with the given address.
+ if IBanManager(mlist).is_banned(record.email):
+ raise MembershipIsBannedError(mlist, record.email)
+ # Make sure there is a user linked with the given address.
user_manager = getUtility(IUserManager)
- user = user_manager.get_user(email)
- if user is None:
- # A user linked to this address does not yet exist. Is the address
- # itself known but just not linked to a user?
- address = user_manager.get_address(email)
- if address is None:
- # Nope, we don't even know about this address, so create both the
- # user and address now.
- user = user_manager.create_user(email, display_name)
- # Do it this way so we don't have to flush the previous change.
- address = list(user.addresses)[0]
- else:
- # The address object exists, but it's not linked to a user.
- # Create the user and link it now.
- user = user_manager.create_user()
- user.display_name = (
- display_name if display_name else address.display_name)
- user.link(address)
- # Encrypt the password using the currently selected hash scheme.
- user.password = config.password_context.encrypt(password)
- user.preferences.preferred_language = language
+ user = user_manager.make_user(record.email, record.display_name)
+ user.preferences.preferred_language = record.language
+ # Subscribe the address, not the user.
+ # We're looking for two versions of the email address, the case
+ # preserved version and the case insensitive version. We'll
+ # subscribe the version with matching case if it exists, otherwise
+ # we'll use one of the matching case-insensitively ones. It's
+ # undefined which one we pick.
+ case_preserved = None
+ case_insensitive = None
+ for address in user.addresses:
+ if address.original_email == record.email:
+ case_preserved = address
+ if address.email == record.email.lower():
+ case_insensitive = address
+ assert case_preserved is not None or case_insensitive is not None, (
+ 'Could not find a linked address for: {}'.format(record.email))
+ address = (case_preserved if case_preserved is not None
+ else case_insensitive)
+ # Create the member and set the appropriate preferences. It's
+ # possible we're subscribing the lower cased version of the address;
+ # if that's already subscribed re-issue the exception with the correct
+ # email address (i.e. the one passed in here).
+ try:
member = mlist.subscribe(address, role)
- member.preferences.delivery_mode = delivery_mode
- else:
- # The user exists and is linked to the address.
- for address in user.addresses:
- if address.email == email:
- break
- else:
- raise AssertionError(
- 'User should have had linked address: {0}'.format(address))
- # Create the member and set the appropriate preferences.
- member = mlist.subscribe(address, role)
- member.preferences.preferred_language = language
- member.preferences.delivery_mode = delivery_mode
+ except AlreadySubscribedError as error:
+ raise AlreadySubscribedError(
+ error.fqdn_listname, record.email, error.role)
+ member.preferences.preferred_language = record.language
+ member.preferences.delivery_mode = record.delivery_mode
return member
diff --git a/src/mailman/app/moderator.py b/src/mailman/app/moderator.py
index b55388e6a..eb848ea08 100644
--- a/src/mailman/app/moderator.py
+++ b/src/mailman/app/moderator.py
@@ -25,6 +25,7 @@ __all__ = [
'hold_message',
'hold_subscription',
'hold_unsubscription',
+ 'send_rejection',
]
@@ -44,6 +45,7 @@ from mailman.interfaces.member import (
AlreadySubscribedError, DeliveryMode, NotAMemberError)
from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.requests import IListRequests, RequestType
+from mailman.interfaces.subscriptions import RequestRecord
from mailman.utilities.datetime import now
from mailman.utilities.i18n import make
from zope.component import getUtility
@@ -124,8 +126,9 @@ def handle_message(mlist, id, action,
language = member.preferred_language
else:
language = None
- _refuse(mlist, _('Posting of your message titled "$subject"'),
- sender, comment or _('[No reason given]'), language)
+ send_rejection(
+ mlist, _('Posting of your message titled "$subject"'),
+ sender, comment or _('[No reason given]'), language)
elif action is Action.accept:
# Start by getting the message from the message store.
msg = message_store.get_message_by_id(message_id)
@@ -192,26 +195,26 @@ def handle_message(mlist, id, action,
-def hold_subscription(mlist, address, display_name, password, mode, language):
+def hold_subscription(mlist, record):
data = dict(when=now().isoformat(),
- address=address,
- display_name=display_name,
- password=password,
- delivery_mode=mode.name,
- language=language)
- # Now hold this request. We'll use the address as the key.
+ email=record.email,
+ display_name=record.display_name,
+ delivery_mode=record.delivery_mode.name,
+ language=record.language)
+ # Now hold this request. We'll use the email address as the key.
requestsdb = IListRequests(mlist)
request_id = requestsdb.hold_request(
- RequestType.subscription, address, data)
+ RequestType.subscription, record.email, data)
vlog.info('%s: held subscription request from %s',
- mlist.fqdn_listname, address)
+ mlist.fqdn_listname, record.email)
# Possibly notify the administrator in default list language
if mlist.admin_immed_notify:
+ email = record.email # XXX: seems unnecessary
subject = _(
- 'New subscription request to $mlist.display_name from $address')
+ 'New subscription request to $mlist.display_name from $email')
text = make('subauth.txt',
mailing_list=mlist,
- username=address,
+ username=record.email,
listname=mlist.fqdn_listname,
admindb_url=mlist.script_url('admindb'),
)
@@ -235,20 +238,21 @@ def handle_subscription(mlist, id, action, comment=None):
pass
elif action is Action.reject:
key, data = requestdb.get_request(id)
- _refuse(mlist, _('Subscription request'),
- data['address'],
- comment or _('[No reason given]'),
- lang=getUtility(ILanguageManager)[data['language']])
+ send_rejection(
+ mlist, _('Subscription request'),
+ data['email'],
+ comment or _('[No reason given]'),
+ lang=getUtility(ILanguageManager)[data['language']])
elif action is Action.accept:
key, data = requestdb.get_request(id)
delivery_mode = DeliveryMode[data['delivery_mode']]
- address = data['address']
+ email = data['email']
display_name = data['display_name']
language = getUtility(ILanguageManager)[data['language']]
- password = data['password']
try:
- add_member(mlist, address, display_name, password,
- delivery_mode, language)
+ add_member(
+ mlist,
+ RequestRecord(email, display_name, delivery_mode, language))
except AlreadySubscribedError:
# The address got subscribed in some other way after the original
# request was made and accepted.
@@ -256,9 +260,9 @@ def handle_subscription(mlist, id, action, comment=None):
else:
if mlist.admin_notify_mchanges:
send_admin_subscription_notice(
- mlist, address, display_name, language)
+ mlist, email, display_name, language)
slog.info('%s: new %s, %s %s', mlist.fqdn_listname,
- delivery_mode, formataddr((display_name, address)),
+ delivery_mode, formataddr((display_name, email)),
'via admin approval')
else:
raise AssertionError('Unexpected action: {0}'.format(action))
@@ -267,20 +271,20 @@ def handle_subscription(mlist, id, action, comment=None):
-def hold_unsubscription(mlist, address):
- data = dict(address=address)
+def hold_unsubscription(mlist, email):
+ data = dict(email=email)
requestsdb = IListRequests(mlist)
request_id = requestsdb.hold_request(
- RequestType.unsubscription, address, data)
+ RequestType.unsubscription, email, data)
vlog.info('%s: held unsubscription request from %s',
- mlist.fqdn_listname, address)
+ mlist.fqdn_listname, email)
# Possibly notify the administrator of the hold
if mlist.admin_immed_notify:
subject = _(
- 'New unsubscription request from $mlist.display_name by $address')
+ 'New unsubscription request from $mlist.display_name by $email')
text = make('unsubauth.txt',
mailing_list=mlist,
- address=address,
+ email=email,
listname=mlist.fqdn_listname,
admindb_url=mlist.script_url('admindb'),
)
@@ -297,7 +301,7 @@ def hold_unsubscription(mlist, address):
def handle_unsubscription(mlist, id, action, comment=None):
requestdb = IListRequests(mlist)
key, data = requestdb.get_request(id)
- address = data['address']
+ email = data['email']
if action is Action.defer:
# Nothing to do.
return
@@ -306,16 +310,17 @@ def handle_unsubscription(mlist, id, action, comment=None):
pass
elif action is Action.reject:
key, data = requestdb.get_request(id)
- _refuse(mlist, _('Unsubscription request'), address,
- comment or _('[No reason given]'))
+ send_rejection(
+ mlist, _('Unsubscription request'), email,
+ comment or _('[No reason given]'))
elif action is Action.accept:
key, data = requestdb.get_request(id)
try:
- delete_member(mlist, address)
+ delete_member(mlist, email)
except NotAMemberError:
# User has already been unsubscribed.
pass
- slog.info('%s: deleted %s', mlist.fqdn_listname, address)
+ slog.info('%s: deleted %s', mlist.fqdn_listname, email)
else:
raise AssertionError('Unexpected action: {0}'.format(action))
# Delete the request from the database.
@@ -323,7 +328,7 @@ def handle_unsubscription(mlist, id, action, comment=None):
-def _refuse(mlist, request, recip, comment, origmsg=None, lang=None):
+def send_rejection(mlist, request, recip, comment, origmsg=None, lang=None):
# As this message is going to the requester, try to set the language to
# his/her language choice, if they are a member. Otherwise use the list's
# preferred language.
diff --git a/src/mailman/app/registrar.py b/src/mailman/app/registrar.py
index 252a7eb9b..240742bc0 100644
--- a/src/mailman/app/registrar.py
+++ b/src/mailman/app/registrar.py
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
-"""Implementation of the IUserRegistrar interface."""
+"""Implementation of the IRegistrar interface."""
__all__ = [
'Registrar',
@@ -25,18 +25,15 @@ __all__ = [
import logging
+from mailman.app.subscriptions import SubscriptionWorkflow
from mailman.core.i18n import _
+from mailman.database.transaction import flush
from mailman.email.message import UserNotification
-from mailman.interfaces.address import IEmailValidator
-from mailman.interfaces.listmanager import IListManager
-from mailman.interfaces.member import DeliveryMode, MemberRole
from mailman.interfaces.pending import IPendable, IPendings
from mailman.interfaces.registrar import ConfirmationNeededEvent, IRegistrar
from mailman.interfaces.templates import ITemplateLoader
-from mailman.interfaces.usermanager import IUserManager
-from mailman.utilities.datetime import now
+from mailman.interfaces.workflow import IWorkflowStateManager
from zope.component import getUtility
-from zope.event import notify
from zope.interface import implementer
@@ -54,96 +51,34 @@ class PendableRegistration(dict):
class Registrar:
"""Handle registrations and confirmations for subscriptions."""
- def register(self, mlist, email, display_name=None, delivery_mode=None):
- """See `IUserRegistrar`."""
- if delivery_mode is None:
- delivery_mode = DeliveryMode.regular
- # First, do validation on the email address. If the address is
- # invalid, it will raise an exception, otherwise it just returns.
- getUtility(IEmailValidator).validate(email)
- # Create a pendable for the registration.
- pendable = PendableRegistration(
- type=PendableRegistration.PEND_KEY,
- email=email,
- display_name=display_name,
- delivery_mode=delivery_mode.name,
- list_id=mlist.list_id)
- token = getUtility(IPendings).add(pendable)
- # We now have everything we need to begin the confirmation dance.
- # Trigger the event to start the ball rolling, and return the
- # generated token.
- notify(ConfirmationNeededEvent(mlist, pendable, token))
- return token
+ def __init__(self, mlist):
+ self._mlist = mlist
+
+ def register(self, subscriber=None, *,
+ pre_verified=False, pre_confirmed=False, pre_approved=False):
+ """See `IRegistrar`."""
+ workflow = SubscriptionWorkflow(
+ self._mlist, subscriber,
+ pre_verified=pre_verified,
+ pre_confirmed=pre_confirmed,
+ pre_approved=pre_approved)
+ list(workflow)
+ return workflow.token, workflow.token_owner, workflow.member
def confirm(self, token):
- """See `IUserRegistrar`."""
- # For convenience
- pendable = getUtility(IPendings).confirm(token)
- if pendable is None:
- return False
- missing = object()
- email = pendable.get('email', missing)
- display_name = pendable.get('display_name', missing)
- pended_delivery_mode = pendable.get('delivery_mode', 'regular')
- try:
- delivery_mode = DeliveryMode[pended_delivery_mode]
- except ValueError:
- log.error('Invalid pended delivery_mode for {0}: {1}',
- email, pended_delivery_mode)
- delivery_mode = DeliveryMode.regular
- if pendable.get('type') != PendableRegistration.PEND_KEY:
- # It seems like it would be very difficult to accurately guess
- # tokens, or brute force an attack on the SHA1 hash, so we'll just
- # throw the pendable away in that case. It's possible we'll need
- # to repend the event or adjust the API to handle this case
- # better, but for now, the simpler the better.
- return False
- # We are going to end up with an IAddress for the verified address
- # and an IUser linked to this IAddress. See if any of these objects
- # currently exist in our database.
- user_manager = getUtility(IUserManager)
- address = (user_manager.get_address(email)
- if email is not missing else None)
- user = (user_manager.get_user(email)
- if email is not missing else None)
- # If there is neither an address nor a user matching the confirmed
- # record, then create the user, which will in turn create the address
- # and link the two together
- if address is None:
- assert user is None, 'How did we get a user but not an address?'
- user = user_manager.create_user(email, display_name)
- # Because the database changes haven't been flushed, we can't use
- # IUserManager.get_address() to find the IAddress just created
- # under the hood. Instead, iterate through the IUser's addresses,
- # of which really there should be only one.
- for address in user.addresses:
- if address.email == email:
- break
- else:
- raise AssertionError('Could not find expected IAddress')
- elif user is None:
- user = user_manager.create_user()
- user.display_name = display_name
- user.link(address)
- else:
- # The IAddress and linked IUser already exist, so all we need to
- # do is verify the address.
- pass
- address.verified_on = now()
- # If this registration is tied to a mailing list, subscribe the person
- # to the list right now. That will generate a SubscriptionEvent,
- # which can be used to send a welcome message.
- list_id = pendable.get('list_id')
- if list_id is not None:
- mlist = getUtility(IListManager).get_by_list_id(list_id)
- if mlist is not None:
- member = mlist.subscribe(address, MemberRole.member)
- member.preferences.delivery_mode = delivery_mode
- return True
+ """See `IRegistrar`."""
+ workflow = SubscriptionWorkflow(self._mlist)
+ workflow.token = token
+ workflow.restore()
+ list(workflow)
+ return workflow.token, workflow.token_owner, workflow.member
def discard(self, token):
- # Throw the record away.
- getUtility(IPendings).confirm(token)
+ """See `IRegistrar`."""
+ with flush():
+ getUtility(IPendings).confirm(token)
+ getUtility(IWorkflowStateManager).discard(
+ SubscriptionWorkflow.__name__, token)
@@ -156,18 +91,17 @@ def handle_ConfirmationNeededEvent(event):
# the Subject header, or they can click on the URL in the body of the
# message and confirm through the web.
subject = 'confirm ' + event.token
- mlist = getUtility(IListManager).get_by_list_id(event.pendable['list_id'])
- confirm_address = mlist.confirm_address(event.token)
+ confirm_address = event.mlist.confirm_address(event.token)
# For i18n interpolation.
- confirm_url = mlist.domain.confirm_url(event.token)
- email_address = event.pendable['email']
- domain_name = mlist.domain.mail_host
- contact_address = mlist.domain.contact_address
+ confirm_url = event.mlist.domain.confirm_url(event.token)
+ email_address = event.email
+ domain_name = event.mlist.domain.mail_host
+ contact_address = event.mlist.owner_address
# Send a verification email to the address.
template = getUtility(ITemplateLoader).get(
'mailman:///{0}/{1}/confirm.txt'.format(
- mlist.fqdn_listname,
- mlist.preferred_language.code))
+ event.mlist.fqdn_listname,
+ event.mlist.preferred_language.code))
text = _(template)
msg = UserNotification(email_address, confirm_address, subject, text)
- msg.send(mlist)
+ msg.send(event.mlist)
diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py
index bcb9e6585..1593b4d58 100644
--- a/src/mailman/app/subscriptions.py
+++ b/src/mailman/app/subscriptions.py
@@ -19,28 +19,45 @@
__all__ = [
'SubscriptionService',
+ 'SubscriptionWorkflow',
'handle_ListDeletingEvent',
]
-from operator import attrgetter
-from passlib.utils import generate_password as generate
-from sqlalchemy import and_, or_
-from uuid import UUID
-from zope.component import getUtility
-from zope.interface import implementer
+import uuid
+import logging
-from mailman.app.membership import add_member, delete_member
-from mailman.config import config
-from mailman.core.constants import system_preferences
+from email.utils import formataddr
+from enum import Enum
+from datetime import timedelta
+from mailman.app.membership import delete_member
+from mailman.app.workflow import Workflow
+from mailman.core.i18n import _
from mailman.database.transaction import dbconnection
+from mailman.email.message import UserNotification
+from mailman.interfaces.address import IAddress
+from mailman.interfaces.bans import IBanManager
from mailman.interfaces.listmanager import (
IListManager, ListDeletingEvent, NoSuchListError)
-from mailman.interfaces.member import DeliveryMode, MemberRole
-from mailman.interfaces.subscriptions import (
- ISubscriptionService, MissingUserError)
+from mailman.interfaces.mailinglist import SubscriptionPolicy
+from mailman.interfaces.member import MembershipIsBannedError
+from mailman.interfaces.pending import IPendable, IPendings
+from mailman.interfaces.registrar import ConfirmationNeededEvent
+from mailman.interfaces.subscriptions import ISubscriptionService, TokenOwner
+from mailman.interfaces.user import IUser
from mailman.interfaces.usermanager import IUserManager
+from mailman.interfaces.workflow import IWorkflowStateManager
from mailman.model.member import Member
+from mailman.utilities.datetime import now
+from mailman.utilities.i18n import make
+from operator import attrgetter
+from sqlalchemy import and_, or_
+from zope.component import getUtility
+from zope.event import notify
+from zope.interface import implementer
+
+
+log = logging.getLogger('mailman.subscribe')
@@ -53,6 +70,276 @@ def _membership_sort_key(member):
return (member.list_id, member.address.email, member.role.value)
+class WhichSubscriber(Enum):
+ address = 1
+ user = 2
+
+
+@implementer(IPendable)
+class Pendable(dict):
+ pass
+
+
+
+class SubscriptionWorkflow(Workflow):
+ """Workflow of a subscription request."""
+
+ INITIAL_STATE = 'sanity_checks'
+ SAVE_ATTRIBUTES = (
+ 'pre_approved',
+ 'pre_confirmed',
+ 'pre_verified',
+ 'address_key',
+ 'subscriber_key',
+ 'user_key',
+ 'token_owner_key',
+ )
+
+ def __init__(self, mlist, subscriber=None, *,
+ pre_verified=False, pre_confirmed=False, pre_approved=False):
+ super().__init__()
+ self.mlist = mlist
+ self.address = None
+ self.user = None
+ self.which = None
+ self.member = None
+ self._set_token(TokenOwner.no_one)
+ # The subscriber must be either an IUser or IAddress.
+ if IAddress.providedBy(subscriber):
+ self.address = subscriber
+ self.user = self.address.user
+ self.which = WhichSubscriber.address
+ elif IUser.providedBy(subscriber):
+ self.address = subscriber.preferred_address
+ self.user = subscriber
+ self.which = WhichSubscriber.user
+ self.subscriber = subscriber
+ self.pre_verified = pre_verified
+ self.pre_confirmed = pre_confirmed
+ self.pre_approved = pre_approved
+
+ @property
+ def user_key(self):
+ # For save.
+ return self.user.user_id.hex
+
+ @user_key.setter
+ def user_key(self, hex_key):
+ # For restore.
+ uid = uuid.UUID(hex_key)
+ self.user = getUtility(IUserManager).get_user_by_id(uid)
+ assert self.user is not None
+
+ @property
+ def address_key(self):
+ # For save.
+ return self.address.email
+
+ @address_key.setter
+ def address_key(self, email):
+ # For restore.
+ self.address = getUtility(IUserManager).get_address(email)
+ assert self.address is not None
+
+ @property
+ def subscriber_key(self):
+ return self.which.value
+
+ @subscriber_key.setter
+ def subscriber_key(self, key):
+ self.which = WhichSubscriber(key)
+
+ @property
+ def token_owner_key(self):
+ return self.token_owner.value
+
+ @token_owner_key.setter
+ def token_owner_key(self, value):
+ self.token_owner = TokenOwner(value)
+
+ def _set_token(self, token_owner):
+ assert isinstance(token_owner, TokenOwner)
+ pendings = getUtility(IPendings)
+ # Clear out the previous pending token if there is one.
+ if self.token is not None:
+ pendings.confirm(self.token)
+ # Create a new token to prevent replay attacks. It seems like this
+ # would produce the same token, but it won't because the pending adds a
+ # bit of randomization.
+ self.token_owner = token_owner
+ if token_owner is TokenOwner.no_one:
+ self.token = None
+ return
+ pendable = Pendable(
+ list_id=self.mlist.list_id,
+ email=self.address.email,
+ display_name=self.address.display_name,
+ when=now().replace(microsecond=0).isoformat(),
+ token_owner=token_owner.name,
+ )
+ self.token = pendings.add(pendable, timedelta(days=3650))
+
+ def _step_sanity_checks(self):
+ # Ensure that we have both an address and a user, even if the address
+ # is not verified. We can't set the preferred address until it is
+ # verified.
+ if self.user is None:
+ # The address has no linked user so create one, link it, and set
+ # the user's preferred address.
+ assert self.address is not None, 'No address or user'
+ self.user = getUtility(IUserManager).make_user(self.address.email)
+ if self.address is None:
+ assert self.user.preferred_address is None, (
+ "Preferred address exists, but wasn't used in constructor")
+ addresses = list(self.user.addresses)
+ if len(addresses) == 0:
+ raise AssertionError('User has no addresses: {}'.format(
+ self.user))
+ # This is rather arbitrary, but we have no choice.
+ self.address = addresses[0]
+ assert self.user is not None and self.address is not None, (
+ 'Insane sanity check results')
+ # Is this email address banned?
+ if IBanManager(self.mlist).is_banned(self.address.email):
+ raise MembershipIsBannedError(self.mlist, self.address.email)
+ # Start out with the subscriber being the token owner.
+ self.push('verification_checks')
+
+ def _step_verification_checks(self):
+ # Is the address already verified, or is the pre-verified flag set?
+ if self.address.verified_on is None:
+ if self.pre_verified:
+ self.address.verified_on = now()
+ else:
+ # The address being subscribed is not yet verified, so we need
+ # to send a validation email that will also confirm that the
+ # user wants to be subscribed to this mailing list.
+ self.push('send_confirmation')
+ return
+ self.push('confirmation_checks')
+
+ def _step_confirmation_checks(self):
+ # If the list's subscription policy is open, then the user can be
+ # subscribed right here and now.
+ if self.mlist.subscription_policy is SubscriptionPolicy.open:
+ self.push('do_subscription')
+ return
+ # If we do not need the user's confirmation, then skip to the
+ # moderation checks.
+ if self.mlist.subscription_policy is SubscriptionPolicy.moderate:
+ self.push('moderation_checks')
+ return
+ # If the subscription has been pre-confirmed, then we can skip the
+ # confirmation check can be skipped. If moderator approval is
+ # required we need to check that, otherwise we can go straight to
+ # subscription.
+ if self.pre_confirmed:
+ next_step = ('moderation_checks'
+ if self.mlist.subscription_policy is
+ SubscriptionPolicy.confirm_then_moderate
+ else 'do_subscription')
+ self.push(next_step)
+ return
+ # The user must confirm their subscription.
+ self.push('send_confirmation')
+
+ def _step_moderation_checks(self):
+ # Does the moderator need to approve the subscription request?
+ assert self.mlist.subscription_policy in (
+ SubscriptionPolicy.moderate,
+ SubscriptionPolicy.confirm_then_moderate,
+ ), self.mlist.subscription_policy
+ if self.pre_approved:
+ self.push('do_subscription')
+ else:
+ self.push('get_moderator_approval')
+
+ def _step_get_moderator_approval(self):
+ # Here's the next step in the workflow, assuming the moderator
+ # approves of the subscription. If they don't, the workflow and
+ # subscription request will just be thrown away.
+ self._set_token(TokenOwner.moderator)
+ self.push('subscribe_from_restored')
+ self.save()
+ log.info('{}: held subscription request from {}'.format(
+ self.mlist.fqdn_listname, self.address.email))
+ # Possibly send a notification to the list moderators.
+ if self.mlist.admin_immed_notify:
+ subject = _(
+ 'New subscription request to $self.mlist.display_name '
+ 'from $self.address.email')
+ username = formataddr(
+ (self.subscriber.display_name, self.address.email))
+ text = make('subauth.txt',
+ mailing_list=self.mlist,
+ username=username,
+ listname=self.mlist.fqdn_listname,
+ )
+ # This message should appear to come from the <list>-owner so as
+ # to avoid any useless bounce processing.
+ msg = UserNotification(
+ self.mlist.owner_address, self.mlist.owner_address,
+ subject, text, self.mlist.preferred_language)
+ msg.send(self.mlist, tomoderators=True)
+ # The workflow must stop running here.
+ raise StopIteration
+
+ def _step_subscribe_from_restored(self):
+ # Prevent replay attacks.
+ self._set_token(TokenOwner.no_one)
+ # Restore a little extra state that can't be stored in the database
+ # (because the order of setattr() on restore is indeterminate), then
+ # subscribe the user.
+ if self.which is WhichSubscriber.address:
+ self.subscriber = self.address
+ else:
+ assert self.which is WhichSubscriber.user
+ self.subscriber = self.user
+ self.push('do_subscription')
+
+ def _step_do_subscription(self):
+ # We can immediately subscribe the user to the mailing list.
+ self.member = self.mlist.subscribe(self.subscriber)
+ # This workflow is done so throw away any associated state.
+ getUtility(IWorkflowStateManager).restore(self.name, self.token)
+
+ def _step_send_confirmation(self):
+ self._set_token(TokenOwner.subscriber)
+ self.push('do_confirm_verify')
+ self.save()
+ # Triggering this event causes the confirmation message to be sent.
+ notify(ConfirmationNeededEvent(
+ self.mlist, self.token, self.address.email))
+ # Now we wait for the confirmation.
+ raise StopIteration
+
+ def _step_do_confirm_verify(self):
+ # Restore a little extra state that can't be stored in the database
+ # (because the order of setattr() on restore is indeterminate), then
+ # continue with the confirmation/verification step.
+ if self.which is WhichSubscriber.address:
+ self.subscriber = self.address
+ else:
+ assert self.which is WhichSubscriber.user
+ self.subscriber = self.user
+ # Reset the token so it can't be used in a replay attack.
+ self._set_token(TokenOwner.no_one)
+ # The user has confirmed their subscription request, and also verified
+ # their email address if necessary. This latter needs to be set on the
+ # IAddress, but there's nothing more to do about the confirmation step.
+ # We just continue along with the workflow.
+ if self.address.verified_on is None:
+ self.address.verified_on = now()
+ # The next step depends on the mailing list's subscription policy.
+ next_step = ('moderation_checks'
+ if self.mlist.subscription_policy in (
+ SubscriptionPolicy.moderate,
+ SubscriptionPolicy.confirm_then_moderate,
+ )
+ else 'do_subscription')
+ self.push(next_step)
+
+
@implementer(ISubscriptionService)
class SubscriptionService:
@@ -136,36 +423,6 @@ class SubscriptionService:
for member in self.get_members():
yield member
- def join(self, list_id, subscriber,
- display_name=None,
- delivery_mode=DeliveryMode.regular,
- role=MemberRole.member):
- """See `ISubscriptionService`."""
- mlist = getUtility(IListManager).get_by_list_id(list_id)
- if mlist is None:
- raise NoSuchListError(list_id)
- # Is the subscriber an email address or user id?
- if isinstance(subscriber, str):
- if display_name is None:
- display_name, at, domain = subscriber.partition('@')
- # Because we want to keep the REST API simple, there is no
- # password or language given to us. We'll use the system's
- # default language for the user's default language. We'll set the
- # password to a system default. This will have to get reset since
- # it can't be retrieved. Note that none of these are used unless
- # the address is completely new to us.
- password = generate(int(config.passwords.password_length))
- return add_member(mlist, subscriber, display_name, password,
- delivery_mode,
- system_preferences.preferred_language, role)
- else:
- # We have to assume it's a UUID.
- assert isinstance(subscriber, UUID), 'Not a UUID'
- user = getUtility(IUserManager).get_user_by_id(subscriber)
- if user is None:
- raise MissingUserError(subscriber)
- return mlist.subscribe(user, role)
-
def leave(self, list_id, email):
"""See `ISubscriptionService`."""
mlist = getUtility(IListManager).get_by_list_id(list_id)
diff --git a/src/mailman/app/tests/test_bounces.py b/src/mailman/app/tests/test_bounces.py
index a84ef24c2..ef77d88a0 100644
--- a/src/mailman/app/tests/test_bounces.py
+++ b/src/mailman/app/tests/test_bounces.py
@@ -36,15 +36,15 @@ import unittest
from mailman.app.bounces import (
ProbeVERP, StandardVERP, bounce_message, maybe_forward, send_probe)
from mailman.app.lifecycle import create_list
-from mailman.app.membership import add_member
from mailman.config import config
from mailman.interfaces.bounce import UnrecognizedBounceDisposition
from mailman.interfaces.languages import ILanguageManager
-from mailman.interfaces.member import DeliveryMode, MemberRole
+from mailman.interfaces.member import MemberRole
from mailman.interfaces.pending import IPendings
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import (
- LogFileMark, get_queue_messages, specialized_message_from_string as mfs)
+ LogFileMark, get_queue_messages, specialized_message_from_string as mfs,
+ subscribe)
from mailman.testing.layers import ConfigLayer
from zope.component import getUtility
@@ -193,9 +193,7 @@ class TestSendProbe(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
self._mlist.send_welcome_message = False
- self._member = add_member(self._mlist, 'anne@example.com',
- 'Anne Person', 'xxx',
- DeliveryMode.regular, 'en')
+ self._member = subscribe(self._mlist, 'Anne', email='anne@example.com')
self._msg = mfs("""\
From: bouncer@example.com
To: anne@example.com
@@ -285,9 +283,7 @@ class TestSendProbeNonEnglish(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
- self._member = add_member(self._mlist, 'anne@example.com',
- 'Anne Person', 'xxx',
- DeliveryMode.regular, 'en')
+ self._member = subscribe(self._mlist, 'Anne', email='anne@example.com')
self._msg = mfs("""\
From: bouncer@example.com
To: anne@example.com
@@ -351,9 +347,7 @@ class TestProbe(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
self._mlist.send_welcome_message = False
- self._member = add_member(self._mlist, 'anne@example.com',
- 'Anne Person', 'xxx',
- DeliveryMode.regular, 'en')
+ self._member = subscribe(self._mlist, 'Anne', email='anne@example.com')
self._msg = mfs("""\
From: bouncer@example.com
To: anne@example.com
diff --git a/src/mailman/app/tests/test_membership.py b/src/mailman/app/tests/test_membership.py
index 9b42c21d6..481de2bb8 100644
--- a/src/mailman/app/tests/test_membership.py
+++ b/src/mailman/app/tests/test_membership.py
@@ -19,7 +19,6 @@
__all__ = [
'TestAddMember',
- 'TestAddMemberPassword',
'TestDeleteMember',
]
@@ -33,6 +32,7 @@ from mailman.interfaces.bans import IBanManager
from mailman.interfaces.member import (
AlreadySubscribedError, DeliveryMode, MemberRole, MembershipIsBannedError,
NotAMemberError)
+from mailman.interfaces.subscriptions import RequestRecord
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.layers import ConfigLayer
from zope.component import getUtility
@@ -48,9 +48,11 @@ class TestAddMember(unittest.TestCase):
def test_add_member_new_user(self):
# Test subscribing a user to a mailing list when the email address has
# not yet been associated with a user.
- member = add_member(self._mlist, 'aperson@example.com',
- 'Anne Person', '123', DeliveryMode.regular,
- system_preferences.preferred_language)
+ member = add_member(
+ self._mlist,
+ RequestRecord('aperson@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
self.assertEqual(member.address.email, 'aperson@example.com')
self.assertEqual(member.list_id, 'test.example.com')
self.assertEqual(member.role, MemberRole.member)
@@ -60,9 +62,11 @@ class TestAddMember(unittest.TestCase):
# already been associated with a user.
user_manager = getUtility(IUserManager)
user_manager.create_user('aperson@example.com', 'Anne Person')
- member = add_member(self._mlist, 'aperson@example.com',
- 'Anne Person', '123', DeliveryMode.regular,
- system_preferences.preferred_language)
+ member = add_member(
+ self._mlist,
+ RequestRecord('aperson@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
self.assertEqual(member.address.email, 'aperson@example.com')
self.assertEqual(member.list_id, 'test.example.com')
@@ -71,9 +75,11 @@ class TestAddMember(unittest.TestCase):
# subscribe to the mailing list.
IBanManager(self._mlist).ban('anne@example.com')
with self.assertRaises(MembershipIsBannedError) as cm:
- add_member(self._mlist, 'anne@example.com', 'Anne Person',
- '123', DeliveryMode.regular,
- system_preferences.preferred_language)
+ add_member(
+ self._mlist,
+ RequestRecord('anne@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
self.assertEqual(
str(cm.exception),
'anne@example.com is not allowed to subscribe to test@example.com')
@@ -84,17 +90,21 @@ class TestAddMember(unittest.TestCase):
IBanManager(None).ban('anne@example.com')
self.assertRaises(
MembershipIsBannedError,
- add_member, self._mlist, 'anne@example.com', 'Anne Person',
- '123', DeliveryMode.regular, system_preferences.preferred_language)
+ add_member, self._mlist,
+ RequestRecord('anne@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
def test_add_member_banned_from_different_list(self):
# Test that members who are banned by on a different list can still be
# subscribed to other mlists.
sample_list = create_list('sample@example.com')
IBanManager(sample_list).ban('anne@example.com')
- member = add_member(self._mlist, 'anne@example.com',
- 'Anne Person', '123', DeliveryMode.regular,
- system_preferences.preferred_language)
+ member = add_member(
+ self._mlist,
+ RequestRecord('anne@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
self.assertEqual(member.address.email, 'anne@example.com')
def test_add_member_banned_by_pattern(self):
@@ -102,33 +112,41 @@ class TestAddMember(unittest.TestCase):
IBanManager(self._mlist).ban('^.*@example.com')
self.assertRaises(
MembershipIsBannedError,
- add_member, self._mlist, 'anne@example.com', 'Anne Person',
- '123', DeliveryMode.regular, system_preferences.preferred_language)
+ add_member, self._mlist,
+ RequestRecord('anne@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
def test_add_member_globally_banned_by_pattern(self):
# Addresses matching global regexp ban patterns cannot subscribe.
IBanManager(None).ban('^.*@example.com')
self.assertRaises(
MembershipIsBannedError,
- add_member, self._mlist, 'anne@example.com', 'Anne Person',
- '123', DeliveryMode.regular, system_preferences.preferred_language)
+ add_member, self._mlist,
+ RequestRecord('anne@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
def test_add_member_banned_from_different_list_by_pattern(self):
# Addresses matching regexp ban patterns on one list can still
# subscribe to other mailing lists.
sample_list = create_list('sample@example.com')
IBanManager(sample_list).ban('^.*@example.com')
- member = add_member(self._mlist, 'anne@example.com',
- 'Anne Person', '123', DeliveryMode.regular,
- system_preferences.preferred_language)
+ member = add_member(
+ self._mlist,
+ RequestRecord('anne@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
self.assertEqual(member.address.email, 'anne@example.com')
def test_add_member_moderator(self):
# Test adding a moderator to a mailing list.
- member = add_member(self._mlist, 'aperson@example.com',
- 'Anne Person', '123', DeliveryMode.regular,
- system_preferences.preferred_language,
- MemberRole.moderator)
+ member = add_member(
+ self._mlist,
+ RequestRecord('aperson@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language),
+ MemberRole.moderator)
self.assertEqual(member.address.email, 'aperson@example.com')
self.assertEqual(member.list_id, 'test.example.com')
self.assertEqual(member.role, MemberRole.moderator)
@@ -136,29 +154,37 @@ class TestAddMember(unittest.TestCase):
def test_add_member_twice(self):
# Adding a member with the same role twice causes an
# AlreadySubscribedError to be raised.
- add_member(self._mlist, 'aperson@example.com',
- 'Anne Person', '123', DeliveryMode.regular,
- system_preferences.preferred_language,
- MemberRole.member)
+ add_member(
+ self._mlist,
+ RequestRecord('aperson@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language),
+ MemberRole.member)
with self.assertRaises(AlreadySubscribedError) as cm:
- add_member(self._mlist, 'aperson@example.com',
- 'Anne Person', '123', DeliveryMode.regular,
- system_preferences.preferred_language,
- MemberRole.member)
+ add_member(
+ self._mlist,
+ RequestRecord('aperson@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language),
+ MemberRole.member)
self.assertEqual(cm.exception.fqdn_listname, 'test@example.com')
self.assertEqual(cm.exception.email, 'aperson@example.com')
self.assertEqual(cm.exception.role, MemberRole.member)
def test_add_member_with_different_roles(self):
# Adding a member twice with different roles is okay.
- member_1 = add_member(self._mlist, 'aperson@example.com',
- 'Anne Person', '123', DeliveryMode.regular,
- system_preferences.preferred_language,
- MemberRole.member)
- member_2 = add_member(self._mlist, 'aperson@example.com',
- 'Anne Person', '123', DeliveryMode.regular,
- system_preferences.preferred_language,
- MemberRole.owner)
+ member_1 = add_member(
+ self._mlist,
+ RequestRecord('aperson@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language),
+ MemberRole.member)
+ member_2 = add_member(
+ self._mlist,
+ RequestRecord('aperson@example.com', 'Anne Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language),
+ MemberRole.owner)
self.assertEqual(member_1.list_id, member_2.list_id)
self.assertEqual(member_1.address, member_2.address)
self.assertEqual(member_1.user, member_2.user)
@@ -166,20 +192,41 @@ class TestAddMember(unittest.TestCase):
self.assertEqual(member_1.role, MemberRole.member)
self.assertEqual(member_2.role, MemberRole.owner)
+ def test_add_member_with_mixed_case_email(self):
+ # LP: #1425359 - Mailman is case-perserving, case-insensitive. This
+ # test subscribes the lower case address and ensures the original
+ # mixed case address can't be subscribed.
+ email = 'APerson@example.com'
+ add_member(
+ self._mlist,
+ RequestRecord(email.lower(), 'Ann Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
+ with self.assertRaises(AlreadySubscribedError) as cm:
+ add_member(
+ self._mlist,
+ RequestRecord(email, 'Ann Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
+ self.assertEqual(cm.exception.email, email)
-
-class TestAddMemberPassword(unittest.TestCase):
- layer = ConfigLayer
-
- def setUp(self):
- self._mlist = create_list('test@example.com')
-
- def test_add_member_password(self):
- # Test that the password stored with the new user is encrypted.
- member = add_member(self._mlist, 'anne@example.com',
- 'Anne Person', 'abc', DeliveryMode.regular,
- system_preferences.preferred_language)
- self.assertEqual(member.user.password, '{plaintext}abc')
+ def test_add_member_with_lower_case_email(self):
+ # LP: #1425359 - Mailman is case-perserving, case-insensitive. This
+ # test subscribes the mixed case address and ensures the lower cased
+ # address can't be added.
+ email = 'APerson@example.com'
+ add_member(
+ self._mlist,
+ RequestRecord(email, 'Ann Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
+ with self.assertRaises(AlreadySubscribedError) as cm:
+ add_member(
+ self._mlist,
+ RequestRecord(email.lower(), 'Ann Person',
+ DeliveryMode.regular,
+ system_preferences.preferred_language))
+ self.assertEqual(cm.exception.email, email.lower())
diff --git a/src/mailman/app/tests/test_moderation.py b/src/mailman/app/tests/test_moderation.py
index 72a25253e..a371d8520 100644
--- a/src/mailman/app/tests/test_moderation.py
+++ b/src/mailman/app/tests/test_moderation.py
@@ -19,16 +19,21 @@
__all__ = [
'TestModeration',
+ 'TestUnsubscription',
]
import unittest
from mailman.app.lifecycle import create_list
-from mailman.app.moderator import handle_message, hold_message
+from mailman.app.moderator import (
+ handle_message, handle_subscription, handle_unsubscription, hold_message,
+ hold_subscription, hold_unsubscription)
from mailman.interfaces.action import Action
+from mailman.interfaces.member import DeliveryMode
from mailman.interfaces.messages import IMessageStore
from mailman.interfaces.requests import IListRequests
+from mailman.interfaces.subscriptions import RequestRecord
from mailman.runners.incoming import IncomingRunner
from mailman.runners.outgoing import OutgoingRunner
from mailman.runners.pipeline import PipelineRunner
@@ -148,3 +153,26 @@ Message-ID: <alpha>
'Forward of moderated message')
self.assertEqual(messages[0].msgdata['recipients'],
['zack@example.com'])
+
+
+
+class TestUnsubscription(unittest.TestCase):
+ """Test unsubscription requests."""
+
+ layer = SMTPLayer
+
+ def setUp(self):
+ self._mlist = create_list('test@example.com')
+ self._request_db = IListRequests(self._mlist)
+
+ def test_unsubscribe_defer(self):
+ # When unsubscriptions must be approved by the moderator, but the
+ # moderator defers this decision.
+ token = hold_subscription(
+ self._mlist,
+ RequestRecord('anne@example.org', 'Anne Person',
+ DeliveryMode.regular, 'en'))
+ handle_subscription(self._mlist, token, Action.accept)
+ # Now hold and handle an unsubscription request.
+ token = hold_unsubscription(self._mlist, 'anne@example.org')
+ handle_unsubscription(self._mlist, token, Action.defer)
diff --git a/src/mailman/app/tests/test_notifications.py b/src/mailman/app/tests/test_notifications.py
index e46a50ebd..e01969201 100644
--- a/src/mailman/app/tests/test_notifications.py
+++ b/src/mailman/app/tests/test_notifications.py
@@ -28,11 +28,11 @@ import tempfile
import unittest
from mailman.app.lifecycle import create_list
-from mailman.app.membership import add_member
from mailman.config import config
from mailman.interfaces.languages import ILanguageManager
-from mailman.interfaces.member import DeliveryMode, MemberRole
-from mailman.testing.helpers import get_queue_messages
+from mailman.interfaces.member import MemberRole
+from mailman.interfaces.usermanager import IUserManager
+from mailman.testing.helpers import get_queue_messages, subscribe
from mailman.testing.layers import ConfigLayer
from zope.component import getUtility
@@ -42,6 +42,7 @@ class TestNotifications(unittest.TestCase):
"""Test notifications."""
layer = ConfigLayer
+ maxDiff = None
def setUp(self):
self._mlist = create_list('test@example.com')
@@ -78,8 +79,7 @@ Welcome to the $list_name mailing list.
shutil.rmtree(self.var_dir)
def test_welcome_message(self):
- add_member(self._mlist, 'anne@example.com', 'Anne Person',
- 'password', DeliveryMode.regular, 'en')
+ subscribe(self._mlist, 'Anne', email='anne@example.com')
# Now there's one message in the virgin queue.
messages = get_queue_messages('virgin')
self.assertEqual(len(messages), 1)
@@ -104,8 +104,12 @@ Welcome to the Test List mailing list.
# Add the xx language and subscribe Anne using it.
manager = getUtility(ILanguageManager)
manager.add('xx', 'us-ascii', 'Xlandia')
- add_member(self._mlist, 'anne@example.com', 'Anne Person',
- 'password', DeliveryMode.regular, 'xx')
+ # We can't use the subscribe() helper because that would send the
+ # welcome message before we set the member's preferred language.
+ address = getUtility(IUserManager).create_address(
+ 'anne@example.com', 'Anne Person')
+ address.preferences.preferred_language = 'xx'
+ self._mlist.subscribe(address)
# Now there's one message in the virgin queue.
messages = get_queue_messages('virgin')
self.assertEqual(len(messages), 1)
@@ -118,27 +122,23 @@ Welcome to the Test List mailing list.
def test_no_welcome_message_to_owners(self):
# Welcome messages go only to mailing list members, not to owners.
- add_member(self._mlist, 'anne@example.com', 'Anne Person',
- 'password', DeliveryMode.regular, 'xx',
- MemberRole.owner)
+ subscribe(self._mlist, 'Anne', MemberRole.owner, 'anne@example.com')
# There is no welcome message in the virgin queue.
messages = get_queue_messages('virgin')
self.assertEqual(len(messages), 0)
def test_no_welcome_message_to_nonmembers(self):
# Welcome messages go only to mailing list members, not to nonmembers.
- add_member(self._mlist, 'anne@example.com', 'Anne Person',
- 'password', DeliveryMode.regular, 'xx',
- MemberRole.nonmember)
+ subscribe(self._mlist, 'Anne', MemberRole.nonmember,
+ 'anne@example.com')
# There is no welcome message in the virgin queue.
messages = get_queue_messages('virgin')
self.assertEqual(len(messages), 0)
def test_no_welcome_message_to_moderators(self):
# Welcome messages go only to mailing list members, not to moderators.
- add_member(self._mlist, 'anne@example.com', 'Anne Person',
- 'password', DeliveryMode.regular, 'xx',
- MemberRole.moderator)
+ subscribe(self._mlist, 'Anne', MemberRole.moderator,
+ 'anne@example.com')
# There is no welcome message in the virgin queue.
messages = get_queue_messages('virgin')
self.assertEqual(len(messages), 0)
diff --git a/src/mailman/app/tests/test_registrar.py b/src/mailman/app/tests/test_registrar.py
new file mode 100644
index 000000000..e76009454
--- /dev/null
+++ b/src/mailman/app/tests/test_registrar.py
@@ -0,0 +1,236 @@
+# 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/>.
+
+"""Test email address registration."""
+
+__all__ = [
+ 'TestRegistrar',
+ ]
+
+
+import unittest
+
+from mailman.app.lifecycle import create_list
+from mailman.interfaces.mailinglist import SubscriptionPolicy
+from mailman.interfaces.pending import IPendings
+from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.subscriptions import TokenOwner
+from mailman.interfaces.usermanager import IUserManager
+from mailman.testing.layers import ConfigLayer
+from mailman.utilities.datetime import now
+from zope.component import getUtility
+
+
+
+class TestRegistrar(unittest.TestCase):
+ """Test registration."""
+
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._mlist = create_list('ant@example.com')
+ self._registrar = IRegistrar(self._mlist)
+ self._pendings = getUtility(IPendings)
+ self._anne = getUtility(IUserManager).create_address(
+ 'anne@example.com')
+
+ def test_initial_conditions(self):
+ # Registering a subscription request provides a unique token associated
+ # with a pendable, and the owner of the token.
+ self.assertEqual(self._pendings.count, 0)
+ token, token_owner, member = self._registrar.register(self._anne)
+ self.assertIsNotNone(token)
+ self.assertEqual(token_owner, TokenOwner.subscriber)
+ self.assertIsNone(member)
+ self.assertEqual(self._pendings.count, 1)
+ record = self._pendings.confirm(token, expunge=False)
+ self.assertEqual(record['list_id'], self._mlist.list_id)
+ self.assertEqual(record['email'], 'anne@example.com')
+
+ def test_subscribe(self):
+ # Registering a subscription request where no confirmation or
+ # moderation steps are needed, leaves us with no token or owner, since
+ # there's nothing more to do.
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ self._anne.verified_on = now()
+ token, token_owner, rmember = self._registrar.register(self._anne)
+ self.assertIsNone(token)
+ self.assertEqual(token_owner, TokenOwner.no_one)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertEqual(rmember, member)
+ self.assertEqual(member.address, self._anne)
+ # There's nothing to confirm.
+ record = self._pendings.confirm(token, expunge=False)
+ self.assertIsNone(record)
+
+ def test_no_such_token(self):
+ # Given a token which is not in the database, a LookupError is raised.
+ self._registrar.register(self._anne)
+ self.assertRaises(LookupError, self._registrar.confirm, 'not-a-token')
+
+ def test_confirm_because_verify(self):
+ # We have a subscription request which requires the user to confirm
+ # (because she does not have a verified address), but not the moderator
+ # to approve. Running the workflow gives us a token. Confirming the
+ # token subscribes the user.
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ token, token_owner, rmember = self._registrar.register(self._anne)
+ self.assertIsNotNone(token)
+ self.assertEqual(token_owner, TokenOwner.subscriber)
+ self.assertIsNone(rmember)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertIsNone(member)
+ # Now confirm the subscription.
+ token, token_owner, rmember = self._registrar.confirm(token)
+ self.assertIsNone(token)
+ self.assertEqual(token_owner, TokenOwner.no_one)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertEqual(rmember, member)
+ self.assertEqual(member.address, self._anne)
+
+ def test_confirm_because_confirm(self):
+ # We have a subscription request which requires the user to confirm
+ # (because of list policy), but not the moderator to approve. Running
+ # the workflow gives us a token. Confirming the token subscribes the
+ # user.
+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
+ self._anne.verified_on = now()
+ token, token_owner, rmember = self._registrar.register(self._anne)
+ self.assertIsNotNone(token)
+ self.assertEqual(token_owner, TokenOwner.subscriber)
+ self.assertIsNone(rmember)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertIsNone(member)
+ # Now confirm the subscription.
+ token, token_owner, rmember = self._registrar.confirm(token)
+ self.assertIsNone(token)
+ self.assertEqual(token_owner, TokenOwner.no_one)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertEqual(rmember, member)
+ self.assertEqual(member.address, self._anne)
+
+ def test_confirm_because_moderation(self):
+ # We have a subscription request which requires the moderator to
+ # approve. Running the workflow gives us a token. Confirming the
+ # token subscribes the user.
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ self._anne.verified_on = now()
+ token, token_owner, rmember = self._registrar.register(self._anne)
+ self.assertIsNotNone(token)
+ self.assertEqual(token_owner, TokenOwner.moderator)
+ self.assertIsNone(rmember)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertIsNone(member)
+ # Now confirm the subscription.
+ token, token_owner, rmember = self._registrar.confirm(token)
+ self.assertIsNone(token)
+ self.assertEqual(token_owner, TokenOwner.no_one)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertEqual(rmember, member)
+ self.assertEqual(member.address, self._anne)
+
+ def test_confirm_because_confirm_then_moderation(self):
+ # We have a subscription request which requires the user to confirm
+ # (because she does not have a verified address) and the moderator to
+ # approve. Running the workflow gives us a token. Confirming the
+ # token runs the workflow a little farther, but still gives us a
+ # token. Confirming again subscribes the user.
+ self._mlist.subscription_policy = \
+ SubscriptionPolicy.confirm_then_moderate
+ self._anne.verified_on = now()
+ # Runs until subscription confirmation.
+ token, token_owner, rmember = self._registrar.register(self._anne)
+ self.assertIsNotNone(token)
+ self.assertEqual(token_owner, TokenOwner.subscriber)
+ self.assertIsNone(rmember)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertIsNone(member)
+ # Now confirm the subscription, and wait for the moderator to approve
+ # the subscription. She is still not subscribed.
+ new_token, token_owner, rmember = self._registrar.confirm(token)
+ # The new token, used for the moderator to approve the message, is not
+ # the same as the old token.
+ self.assertNotEqual(new_token, token)
+ self.assertIsNotNone(new_token)
+ self.assertEqual(token_owner, TokenOwner.moderator)
+ self.assertIsNone(rmember)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertIsNone(member)
+ # Confirm once more, this time as the moderator approving the
+ # subscription. Now she's a member.
+ token, token_owner, rmember = self._registrar.confirm(new_token)
+ self.assertIsNone(token)
+ self.assertEqual(token_owner, TokenOwner.no_one)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertEqual(rmember, member)
+ self.assertEqual(member.address, self._anne)
+
+ def test_confirm_then_moderate_with_different_tokens(self):
+ # Ensure that the confirmation token the user sees when they have to
+ # confirm their subscription is different than the token the moderator
+ # sees when they approve the subscription. This prevents the user
+ # from using a replay attack to subvert moderator approval.
+ self._mlist.subscription_policy = \
+ SubscriptionPolicy.confirm_then_moderate
+ self._anne.verified_on = now()
+ # Runs until subscription confirmation.
+ token, token_owner, rmember = self._registrar.register(self._anne)
+ self.assertIsNotNone(token)
+ self.assertEqual(token_owner, TokenOwner.subscriber)
+ self.assertIsNone(rmember)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertIsNone(member)
+ # Now confirm the subscription, and wait for the moderator to approve
+ # the subscription. She is still not subscribed.
+ new_token, token_owner, rmember = self._registrar.confirm(token)
+ # The status is not true because the user has not yet been subscribed
+ # to the mailing list.
+ self.assertIsNotNone(new_token)
+ self.assertEqual(token_owner, TokenOwner.moderator)
+ self.assertIsNone(rmember)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertIsNone(member)
+ # The new token is different than the old token.
+ self.assertNotEqual(token, new_token)
+ # Trying to confirm with the old token does not work.
+ self.assertRaises(LookupError, self._registrar.confirm, token)
+ # Confirm once more, this time with the new token, as the moderator
+ # approving the subscription. Now she's a member.
+ done_token, token_owner, rmember = self._registrar.confirm(new_token)
+ # The token is None, signifying that the member has been subscribed.
+ self.assertIsNone(done_token)
+ self.assertEqual(token_owner, TokenOwner.no_one)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertEqual(rmember, member)
+ self.assertEqual(member.address, self._anne)
+
+ def test_discard_waiting_for_confirmation(self):
+ # While waiting for a user to confirm their subscription, we discard
+ # the workflow.
+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
+ self._anne.verified_on = now()
+ # Runs until subscription confirmation.
+ token, token_owner, rmember = self._registrar.register(self._anne)
+ self.assertIsNotNone(token)
+ self.assertEqual(token_owner, TokenOwner.subscriber)
+ self.assertIsNone(rmember)
+ member = self._mlist.regular_members.get_member('anne@example.com')
+ self.assertIsNone(member)
+ # Now discard the subscription request.
+ self._registrar.discard(token)
+ # Trying to confirm the token now results in an exception.
+ self.assertRaises(LookupError, self._registrar.confirm, token)
diff --git a/src/mailman/app/tests/test_registration.py b/src/mailman/app/tests/test_registration.py
deleted file mode 100644
index ccc485492..000000000
--- a/src/mailman/app/tests/test_registration.py
+++ /dev/null
@@ -1,128 +0,0 @@
-# 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/>.
-
-"""Test email address registration."""
-
-__all__ = [
- 'TestEmailValidation',
- 'TestRegistration',
- ]
-
-
-import unittest
-
-from mailman.app.lifecycle import create_list
-from mailman.interfaces.address import InvalidEmailAddressError
-from mailman.interfaces.pending import IPendings
-from mailman.interfaces.registrar import ConfirmationNeededEvent, IRegistrar
-from mailman.testing.helpers import event_subscribers
-from mailman.testing.layers import ConfigLayer
-from zope.component import getUtility
-
-
-
-class TestEmailValidation(unittest.TestCase):
- """Test basic email validation."""
-
- layer = ConfigLayer
-
- def setUp(self):
- self.registrar = getUtility(IRegistrar)
- self.mlist = create_list('alpha@example.com')
-
- def test_empty_string_is_invalid(self):
- self.assertRaises(InvalidEmailAddressError,
- self.registrar.register, self.mlist,
- '')
-
- def test_no_spaces_allowed(self):
- self.assertRaises(InvalidEmailAddressError,
- self.registrar.register, self.mlist,
- 'some name@example.com')
-
- def test_no_angle_brackets(self):
- self.assertRaises(InvalidEmailAddressError,
- self.registrar.register, self.mlist,
- '<script>@example.com')
-
- def test_ascii_only(self):
- self.assertRaises(InvalidEmailAddressError,
- self.registrar.register, self.mlist,
- '\xa0@example.com')
-
- def test_domain_required(self):
- self.assertRaises(InvalidEmailAddressError,
- self.registrar.register, self.mlist,
- 'noatsign')
-
- def test_full_domain_required(self):
- self.assertRaises(InvalidEmailAddressError,
- self.registrar.register, self.mlist,
- 'nodom@ain')
-
-
-
-class TestRegistration(unittest.TestCase):
- """Test registration."""
-
- layer = ConfigLayer
-
- def setUp(self):
- self.registrar = getUtility(IRegistrar)
- self.mlist = create_list('alpha@example.com')
-
- def test_confirmation_event_received(self):
- # Registering an email address generates an event.
- def capture_event(event):
- self.assertIsInstance(event, ConfirmationNeededEvent)
- with event_subscribers(capture_event):
- self.registrar.register(self.mlist, 'anne@example.com')
-
- def test_event_mlist(self):
- # The event has a reference to the mailing list being subscribed to.
- def capture_event(event):
- self.assertIs(event.mlist, self.mlist)
- with event_subscribers(capture_event):
- self.registrar.register(self.mlist, 'anne@example.com')
-
- def test_event_pendable(self):
- # The event has an IPendable which contains additional information.
- def capture_event(event):
- pendable = event.pendable
- self.assertEqual(pendable['type'], 'registration')
- self.assertEqual(pendable['email'], 'anne@example.com')
- # The key is present, but the value is None.
- self.assertIsNone(pendable['display_name'])
- # The default is regular delivery.
- self.assertEqual(pendable['delivery_mode'], 'regular')
- self.assertEqual(pendable['list_id'], 'alpha.example.com')
- with event_subscribers(capture_event):
- self.registrar.register(self.mlist, 'anne@example.com')
-
- def test_token(self):
- # Registering the email address returns a token, and this token links
- # back to the pendable.
- captured_events = []
- def capture_event(event):
- captured_events.append(event)
- with event_subscribers(capture_event):
- token = self.registrar.register(self.mlist, 'anne@example.com')
- self.assertEqual(len(captured_events), 1)
- event = captured_events[0]
- self.assertEqual(event.token, token)
- pending = getUtility(IPendings).confirm(token)
- self.assertEqual(pending, event.pendable)
diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py
index 8ba5f52ff..2d5a3733b 100644
--- a/src/mailman/app/tests/test_subscriptions.py
+++ b/src/mailman/app/tests/test_subscriptions.py
@@ -18,50 +18,624 @@
"""Tests for the subscription service."""
__all__ = [
- 'TestJoin'
+ 'TestSubscriptionWorkflow',
]
-import uuid
import unittest
from mailman.app.lifecycle import create_list
-from mailman.interfaces.address import InvalidEmailAddressError
-from mailman.interfaces.member import MemberRole, MissingPreferredAddressError
-from mailman.interfaces.subscriptions import (
- MissingUserError, ISubscriptionService)
+from mailman.app.subscriptions import SubscriptionWorkflow
+from mailman.interfaces.bans import IBanManager
+from mailman.interfaces.member import MembershipIsBannedError
+from mailman.interfaces.pending import IPendings
+from mailman.interfaces.subscriptions import TokenOwner
+from mailman.testing.helpers import LogFileMark, get_queue_messages
from mailman.testing.layers import ConfigLayer
+from mailman.interfaces.mailinglist import SubscriptionPolicy
+from mailman.interfaces.usermanager import IUserManager
+from mailman.utilities.datetime import now
+from unittest.mock import patch
from zope.component import getUtility
-class TestJoin(unittest.TestCase):
+class TestSubscriptionWorkflow(unittest.TestCase):
layer = ConfigLayer
+ maxDiff = None
def setUp(self):
self._mlist = create_list('test@example.com')
- self._service = getUtility(ISubscriptionService)
+ self._mlist.admin_immed_notify = False
+ self._anne = 'anne@example.com'
+ self._user_manager = getUtility(IUserManager)
- def test_join_user_with_bogus_id(self):
- # When `subscriber` is a missing user id, an exception is raised.
- with self.assertRaises(MissingUserError) as cm:
- self._service.join('test.example.com', uuid.UUID(int=99))
- self.assertEqual(cm.exception.user_id, uuid.UUID(int=99))
+ def test_start_state(self):
+ # The workflow starts with no tokens or member.
+ workflow = SubscriptionWorkflow(self._mlist)
+ self.assertIsNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.no_one)
+ self.assertIsNone(workflow.member)
- def test_join_user_with_invalid_email_address(self):
- # When `subscriber` is a string that is not an email address, an
- # exception is raised.
- with self.assertRaises(InvalidEmailAddressError) as cm:
- self._service.join('test.example.com', 'bogus')
- self.assertEqual(cm.exception.email, 'bogus')
+ def test_pended_data(self):
+ # There is a Pendable associated with the held request, and it has
+ # some data associated with it.
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ try:
+ workflow.run_thru('send_confirmation')
+ except StopIteration:
+ pass
+ self.assertIsNotNone(workflow.token)
+ pendable = getUtility(IPendings).confirm(workflow.token, expunge=False)
+ self.assertEqual(pendable['list_id'], 'test.example.com')
+ self.assertEqual(pendable['email'], 'anne@example.com')
+ self.assertEqual(pendable['display_name'], '')
+ self.assertEqual(pendable['when'], '2005-08-01T07:49:23')
+ self.assertEqual(pendable['token_owner'], 'subscriber')
- def test_missing_preferred_address(self):
- # A user cannot join a mailing list if they have no preferred address.
- anne = self._service.join(
- 'test.example.com', 'anne@example.com', 'Anne Person')
- # Try to join Anne as a user with a different role. Her user has no
- # preferred address, so this will fail.
- self.assertRaises(MissingPreferredAddressError,
- self._service.join,
- 'test.example.com', anne.user.user_id,
- role=MemberRole.owner)
+ def test_user_or_address_required(self):
+ # The `subscriber` attribute must be a user or address.
+ workflow = SubscriptionWorkflow(self._mlist)
+ self.assertRaises(AssertionError, list, workflow)
+
+ def test_sanity_checks_address(self):
+ # Ensure that the sanity check phase, when given an IAddress, ends up
+ # with a linked user.
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ self.assertIsNotNone(workflow.address)
+ self.assertIsNone(workflow.user)
+ workflow.run_thru('sanity_checks')
+ self.assertIsNotNone(workflow.address)
+ self.assertIsNotNone(workflow.user)
+ self.assertEqual(list(workflow.user.addresses)[0].email, self._anne)
+
+ def test_sanity_checks_user_with_preferred_address(self):
+ # Ensure that the sanity check phase, when given an IUser with a
+ # preferred address, ends up with an address.
+ anne = self._user_manager.make_user(self._anne)
+ address = list(anne.addresses)[0]
+ address.verified_on = now()
+ anne.preferred_address = address
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ # The constructor sets workflow.address because the user has a
+ # preferred address.
+ self.assertEqual(workflow.address, address)
+ self.assertEqual(workflow.user, anne)
+ workflow.run_thru('sanity_checks')
+ self.assertEqual(workflow.address, address)
+ self.assertEqual(workflow.user, anne)
+
+ def test_sanity_checks_user_without_preferred_address(self):
+ # Ensure that the sanity check phase, when given a user without a
+ # preferred address, but with at least one linked address, gets an
+ # address.
+ anne = self._user_manager.make_user(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ self.assertIsNone(workflow.address)
+ self.assertEqual(workflow.user, anne)
+ workflow.run_thru('sanity_checks')
+ self.assertIsNotNone(workflow.address)
+ self.assertEqual(workflow.user, anne)
+
+ def test_sanity_checks_user_with_multiple_linked_addresses(self):
+ # Ensure that the santiy check phase, when given a user without a
+ # preferred address, but with multiple linked addresses, gets of of
+ # those addresses (exactly which one is undefined).
+ anne = self._user_manager.make_user(self._anne)
+ anne.link(self._user_manager.create_address('anne@example.net'))
+ anne.link(self._user_manager.create_address('anne@example.org'))
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ self.assertIsNone(workflow.address)
+ self.assertEqual(workflow.user, anne)
+ workflow.run_thru('sanity_checks')
+ self.assertIn(workflow.address.email, ['anne@example.com',
+ 'anne@example.net',
+ 'anne@example.org'])
+ self.assertEqual(workflow.user, anne)
+
+ def test_sanity_checks_user_without_addresses(self):
+ # It is an error to try to subscribe a user with no linked addresses.
+ user = self._user_manager.create_user()
+ workflow = SubscriptionWorkflow(self._mlist, user)
+ self.assertRaises(AssertionError, workflow.run_thru, 'sanity_checks')
+
+ def test_sanity_checks_globally_banned_address(self):
+ # An exception is raised if the address is globally banned.
+ anne = self._user_manager.create_address(self._anne)
+ IBanManager(None).ban(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ self.assertRaises(MembershipIsBannedError, list, workflow)
+
+ def test_sanity_checks_banned_address(self):
+ # An exception is raised if the address is banned by the mailing list.
+ anne = self._user_manager.create_address(self._anne)
+ IBanManager(self._mlist).ban(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ self.assertRaises(MembershipIsBannedError, list, workflow)
+
+ def test_verification_checks_with_verified_address(self):
+ # When the address is already verified, we skip straight to the
+ # confirmation checks.
+ anne = self._user_manager.create_address(self._anne)
+ anne.verified_on = now()
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ workflow.run_thru('verification_checks')
+ with patch.object(workflow, '_step_confirmation_checks') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_verification_checks_with_pre_verified_address(self):
+ # When the address is not yet verified, but the pre-verified flag is
+ # passed to the workflow, we skip to the confirmation checks.
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
+ workflow.run_thru('verification_checks')
+ with patch.object(workflow, '_step_confirmation_checks') as step:
+ next(workflow)
+ step.assert_called_once_with()
+ # And now the address is verified.
+ self.assertIsNotNone(anne.verified_on)
+
+ def test_verification_checks_confirmation_needed(self):
+ # The address is neither verified, nor is the pre-verified flag set.
+ # A confirmation message must be sent to the user which will also
+ # verify their address.
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ workflow.run_thru('verification_checks')
+ with patch.object(workflow, '_step_send_confirmation') as step:
+ next(workflow)
+ step.assert_called_once_with()
+ # The address still hasn't been verified.
+ self.assertIsNone(anne.verified_on)
+
+ def test_confirmation_checks_open_list(self):
+ # A subscription to an open list does not need to be confirmed or
+ # moderated.
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_do_subscription') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_confirmation_checks_no_user_confirmation_needed(self):
+ # A subscription to a list which does not need user confirmation skips
+ # to the moderation checks.
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_moderation_checks') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_confirmation_checks_confirm_pre_confirmed(self):
+ # The subscription policy requires user confirmation, but their
+ # subscription is pre-confirmed. Since moderation is not required,
+ # the user will be immediately subscribed.
+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_confirmed=True)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_do_subscription') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_confirmation_checks_confirm_then_moderate_pre_confirmed(self):
+ # The subscription policy requires user confirmation, but their
+ # subscription is pre-confirmed. Since moderation is required, that
+ # check will be performed.
+ self._mlist.subscription_policy = \
+ SubscriptionPolicy.confirm_then_moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_confirmed=True)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_moderation_checks') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_confirmation_checks_confirm_and_moderate_pre_confirmed(self):
+ # The subscription policy requires user confirmation and moderation,
+ # but their subscription is pre-confirmed.
+ self._mlist.subscription_policy = \
+ SubscriptionPolicy.confirm_then_moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_confirmed=True)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_moderation_checks') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_confirmation_checks_confirmation_needed(self):
+ # The subscription policy requires confirmation and the subscription
+ # is not pre-confirmed.
+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_send_confirmation') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_confirmation_checks_moderate_confirmation_needed(self):
+ # The subscription policy requires confirmation and moderation, and the
+ # subscription is not pre-confirmed.
+ self._mlist.subscription_policy = \
+ SubscriptionPolicy.confirm_then_moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_send_confirmation') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_moderation_checks_pre_approved(self):
+ # The subscription is pre-approved by the moderator.
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_approved=True)
+ workflow.run_thru('moderation_checks')
+ with patch.object(workflow, '_step_do_subscription') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_moderation_checks_approval_required(self):
+ # The moderator must approve the subscription.
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
+ workflow.run_thru('moderation_checks')
+ with patch.object(workflow, '_step_get_moderator_approval') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_do_subscription(self):
+ # An open subscription policy plus a pre-verified address means the
+ # user gets subscribed to the mailing list without any further
+ # confirmations or approvals.
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
+ # Consume the entire state machine.
+ list(workflow)
+ # Anne is now a member of the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertEqual(member.address, anne)
+ self.assertEqual(workflow.member, member)
+ # No further token is needed.
+ self.assertIsNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.no_one)
+
+ def test_do_subscription_pre_approved(self):
+ # An moderation-requiring subscription policy plus a pre-verified and
+ # pre-approved address means the user gets subscribed to the mailing
+ # list without any further confirmations or approvals.
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_approved=True)
+ # Consume the entire state machine.
+ list(workflow)
+ # Anne is now a member of the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertEqual(member.address, anne)
+ self.assertEqual(workflow.member, member)
+ # No further token is needed.
+ self.assertIsNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.no_one)
+
+ def test_do_subscription_pre_approved_pre_confirmed(self):
+ # An moderation-requiring subscription policy plus a pre-verified and
+ # pre-approved address means the user gets subscribed to the mailing
+ # list without any further confirmations or approvals.
+ self._mlist.subscription_policy = \
+ SubscriptionPolicy.confirm_then_moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_confirmed=True,
+ pre_approved=True)
+ # Consume the entire state machine.
+ list(workflow)
+ # Anne is now a member of the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertEqual(member.address, anne)
+ self.assertEqual(workflow.member, member)
+ # No further token is needed.
+ self.assertIsNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.no_one)
+
+ def test_do_subscription_cleanups(self):
+ # Once the user is subscribed, the token, and its associated pending
+ # database record will be removed from the database.
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_confirmed=True,
+ pre_approved=True)
+ # Cache the token.
+ token = workflow.token
+ # Consume the entire state machine.
+ list(workflow)
+ # Anne is now a member of the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertEqual(member.address, anne)
+ self.assertEqual(workflow.member, member)
+ # The workflow is done, so it has no token.
+ self.assertIsNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.no_one)
+ # The pendable associated with the token has been evicted.
+ self.assertIsNone(getUtility(IPendings).confirm(token, expunge=False))
+ # There is no saved workflow associated with the token. This shows up
+ # as an exception when we try to restore the workflow.
+ new_workflow = SubscriptionWorkflow(self._mlist)
+ new_workflow.token = token
+ self.assertRaises(LookupError, new_workflow.restore)
+
+ def test_moderator_approves(self):
+ # The workflow runs until moderator approval is required, at which
+ # point the workflow is saved. Once the moderator approves, the
+ # workflow resumes and the user is subscribed.
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_confirmed=True)
+ # Consume the entire state machine.
+ list(workflow)
+ # The user is not currently subscribed to the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(member)
+ self.assertIsNone(workflow.member)
+ # The token is owned by the moderator.
+ self.assertIsNotNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.moderator)
+ # Create a new workflow with the previous workflow's save token, and
+ # restore its state. This models an approved subscription and should
+ # result in the user getting subscribed.
+ approved_workflow = SubscriptionWorkflow(self._mlist)
+ approved_workflow.token = workflow.token
+ approved_workflow.restore()
+ list(approved_workflow)
+ # Now the user is subscribed to the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertEqual(member.address, anne)
+ self.assertEqual(approved_workflow.member, member)
+ # No further token is needed.
+ self.assertIsNone(approved_workflow.token)
+ self.assertEqual(approved_workflow.token_owner, TokenOwner.no_one)
+
+ def test_get_moderator_approval_log_on_hold(self):
+ # When the subscription is held for moderator approval, a message is
+ # logged.
+ mark = LogFileMark('mailman.subscribe')
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_confirmed=True)
+ # Consume the entire state machine.
+ list(workflow)
+ self.assertIn(
+ 'test@example.com: held subscription request from anne@example.com',
+ mark.readline()
+ )
+
+ def test_get_moderator_approval_notifies_moderators(self):
+ # When the subscription is held for moderator approval, and the list
+ # is so configured, a notification is sent to the list moderators.
+ self._mlist.admin_immed_notify = True
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_confirmed=True)
+ # Consume the entire state machine.
+ list(workflow)
+ items = get_queue_messages('virgin')
+ self.assertEqual(len(items), 1)
+ message = items[0].msg
+ self.assertEqual(message['From'], 'test-owner@example.com')
+ self.assertEqual(message['To'], 'test-owner@example.com')
+ self.assertEqual(
+ message['Subject'],
+ 'New subscription request to Test from anne@example.com')
+ self.assertEqual(message.get_payload(), """\
+Your authorization is required for a mailing list subscription request
+approval:
+
+ For: anne@example.com
+ List: test@example.com""")
+
+ def test_get_moderator_approval_no_notifications(self):
+ # When the subscription is held for moderator approval, and the list
+ # is so configured, a notification is sent to the list moderators.
+ self._mlist.admin_immed_notify = False
+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne,
+ pre_verified=True,
+ pre_confirmed=True)
+ # Consume the entire state machine.
+ list(workflow)
+ items = get_queue_messages('virgin')
+ self.assertEqual(len(items), 0)
+
+ def test_send_confirmation(self):
+ # A confirmation message gets sent when the address is not verified.
+ anne = self._user_manager.create_address(self._anne)
+ self.assertIsNone(anne.verified_on)
+ # Run the workflow to model the confirmation step.
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ list(workflow)
+ items = get_queue_messages('virgin')
+ self.assertEqual(len(items), 1)
+ message = items[0].msg
+ token = workflow.token
+ self.assertEqual(message['Subject'], 'confirm {}'.format(token))
+ self.assertEqual(
+ message['From'], 'test-confirm+{}@example.com'.format(token))
+
+ def test_send_confirmation_pre_confirmed(self):
+ # A confirmation message gets sent when the address is not verified
+ # but the subscription is pre-confirmed.
+ anne = self._user_manager.create_address(self._anne)
+ self.assertIsNone(anne.verified_on)
+ # Run the workflow to model the confirmation step.
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_confirmed=True)
+ list(workflow)
+ items = get_queue_messages('virgin')
+ self.assertEqual(len(items), 1)
+ message = items[0].msg
+ token = workflow.token
+ self.assertEqual(
+ message['Subject'], 'confirm {}'.format(workflow.token))
+ self.assertEqual(
+ message['From'], 'test-confirm+{}@example.com'.format(token))
+
+ def test_send_confirmation_pre_verified(self):
+ # A confirmation message gets sent even when the address is verified
+ # when the subscription must be confirmed.
+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
+ anne = self._user_manager.create_address(self._anne)
+ self.assertIsNone(anne.verified_on)
+ # Run the workflow to model the confirmation step.
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
+ list(workflow)
+ items = get_queue_messages('virgin')
+ self.assertEqual(len(items), 1)
+ message = items[0].msg
+ token = workflow.token
+ self.assertEqual(
+ message['Subject'], 'confirm {}'.format(workflow.token))
+ self.assertEqual(
+ message['From'], 'test-confirm+{}@example.com'.format(token))
+
+ def test_do_confirm_verify_address(self):
+ # The address is not yet verified, nor are we pre-verifying. A
+ # confirmation message will be sent. When the user confirms their
+ # subscription request, the address will end up being verified.
+ anne = self._user_manager.create_address(self._anne)
+ self.assertIsNone(anne.verified_on)
+ # Run the workflow to model the confirmation step.
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ list(workflow)
+ # The address is still not verified.
+ self.assertIsNone(anne.verified_on)
+ confirm_workflow = SubscriptionWorkflow(self._mlist)
+ confirm_workflow.token = workflow.token
+ confirm_workflow.restore()
+ confirm_workflow.run_thru('do_confirm_verify')
+ # The address is now verified.
+ self.assertIsNotNone(anne.verified_on)
+
+ def test_do_confirmation_subscribes_user(self):
+ # Subscriptions to the mailing list must be confirmed. Once that's
+ # done, the user's address (which is not initially verified) gets
+ # subscribed to the mailing list.
+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
+ anne = self._user_manager.create_address(self._anne)
+ self.assertIsNone(anne.verified_on)
+ workflow = SubscriptionWorkflow(self._mlist, anne)
+ list(workflow)
+ # Anne is not yet a member.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(member)
+ self.assertIsNone(workflow.member)
+ # The token is owned by the subscriber.
+ self.assertIsNotNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.subscriber)
+ # Confirm.
+ confirm_workflow = SubscriptionWorkflow(self._mlist)
+ confirm_workflow.token = workflow.token
+ confirm_workflow.restore()
+ list(confirm_workflow)
+ self.assertIsNotNone(anne.verified_on)
+ # Anne is now a member.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertEqual(member.address, anne)
+ self.assertEqual(confirm_workflow.member, member)
+ # No further token is needed.
+ self.assertIsNone(confirm_workflow.token)
+ self.assertEqual(confirm_workflow.token_owner, TokenOwner.no_one)
+
+ def test_prevent_confirmation_replay_attacks(self):
+ # Ensure that if the workflow requires two confirmations, e.g. first
+ # the user confirming their subscription, and then the moderator
+ # approving it, that different tokens are used in these two cases.
+ self._mlist.subscription_policy = \
+ SubscriptionPolicy.confirm_then_moderate
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
+ # Run the state machine up to the first confirmation, and cache the
+ # confirmation token.
+ list(workflow)
+ token = workflow.token
+ # Anne is not yet a member of the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(member)
+ self.assertIsNone(workflow.member)
+ # The token is owned by the subscriber.
+ self.assertIsNotNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.subscriber)
+ # The old token will not work for moderator approval.
+ moderator_workflow = SubscriptionWorkflow(self._mlist)
+ moderator_workflow.token = token
+ moderator_workflow.restore()
+ list(moderator_workflow)
+ # The token is owned by the moderator.
+ self.assertIsNotNone(moderator_workflow.token)
+ self.assertEqual(moderator_workflow.token_owner, TokenOwner.moderator)
+ # While we wait for the moderator to approve the subscription, note
+ # that there's a new token for the next steps.
+ self.assertNotEqual(token, moderator_workflow.token)
+ # The old token won't work.
+ final_workflow = SubscriptionWorkflow(self._mlist)
+ final_workflow.token = token
+ self.assertRaises(LookupError, final_workflow.restore)
+ # Running this workflow will fail.
+ self.assertRaises(AssertionError, list, final_workflow)
+ # Anne is still not subscribed.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(member)
+ self.assertIsNone(final_workflow.member)
+ # However, if we use the new token, her subscription request will be
+ # approved by the moderator.
+ final_workflow.token = moderator_workflow.token
+ final_workflow.restore()
+ list(final_workflow)
+ # And now Anne is a member.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertEqual(member.address.email, self._anne)
+ self.assertEqual(final_workflow.member, member)
+ # No further token is needed.
+ self.assertIsNone(final_workflow.token)
+ self.assertEqual(final_workflow.token_owner, TokenOwner.no_one)
+
+ def test_confirmation_needed_and_pre_confirmed(self):
+ # The subscription policy is 'confirm' but the subscription is
+ # pre-confirmed so the moderation checks can be skipped.
+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
+ anne = self._user_manager.create_address(self._anne)
+ workflow = SubscriptionWorkflow(
+ self._mlist, anne,
+ pre_verified=True, pre_confirmed=True, pre_approved=True)
+ list(workflow)
+ # Anne was subscribed.
+ self.assertIsNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.no_one)
+ self.assertEqual(workflow.member.address, anne)
diff --git a/src/mailman/app/tests/test_workflow.py b/src/mailman/app/tests/test_workflow.py
new file mode 100644
index 000000000..51beceb86
--- /dev/null
+++ b/src/mailman/app/tests/test_workflow.py
@@ -0,0 +1,128 @@
+# Copyright (C) 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/>.
+
+"""App-level workflow tests."""
+
+__all__ = [
+ 'TestWorkflow',
+ ]
+
+
+import unittest
+
+from mailman.app.workflow import Workflow
+from mailman.testing.layers import ConfigLayer
+
+
+class MyWorkflow(Workflow):
+ INITIAL_STATE = 'first'
+ SAVE_ATTRIBUTES = ('ant', 'bee', 'cat')
+
+ def __init__(self):
+ super().__init__()
+ self.token = 'test-workflow'
+ self.ant = 1
+ self.bee = 2
+ self.cat = 3
+ self.dog = 4
+
+ def _step_first(self):
+ self.push('second')
+ return 'one'
+
+ def _step_second(self):
+ self.push('third')
+ return 'two'
+
+ def _step_third(self):
+ return 'three'
+
+
+
+class TestWorkflow(unittest.TestCase):
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._workflow = iter(MyWorkflow())
+
+ def test_basic_workflow(self):
+ # The work flows from one state to the next.
+ results = list(self._workflow)
+ self.assertEqual(results, ['one', 'two', 'three'])
+
+ def test_partial_workflow(self):
+ # You don't have to flow through every step.
+ results = next(self._workflow)
+ self.assertEqual(results, 'one')
+
+ def test_exhaust_workflow(self):
+ # Manually flow through a few steps, then consume the whole thing.
+ results = [next(self._workflow)]
+ results.extend(self._workflow)
+ self.assertEqual(results, ['one', 'two', 'three'])
+
+ def test_save_and_restore_workflow(self):
+ # Without running any steps, save and restore the workflow. Then
+ # consume the restored workflow.
+ self._workflow.save()
+ new_workflow = MyWorkflow()
+ new_workflow.restore()
+ results = list(new_workflow)
+ self.assertEqual(results, ['one', 'two', 'three'])
+
+ def test_save_and_restore_partial_workflow(self):
+ # After running a few steps, save and restore the workflow. Then
+ # consume the restored workflow.
+ next(self._workflow)
+ self._workflow.save()
+ new_workflow = MyWorkflow()
+ new_workflow.restore()
+ results = list(new_workflow)
+ self.assertEqual(results, ['two', 'three'])
+
+ def test_save_and_restore_exhausted_workflow(self):
+ # After consuming the entire workflow, save and restore it.
+ list(self._workflow)
+ self._workflow.save()
+ new_workflow = MyWorkflow()
+ new_workflow.restore()
+ results = list(new_workflow)
+ self.assertEqual(len(results), 0)
+
+ def test_save_and_restore_attributes(self):
+ # Saved attributes are restored.
+ self._workflow.ant = 9
+ self._workflow.bee = 8
+ self._workflow.cat = 7
+ # Don't save .dog.
+ self._workflow.save()
+ new_workflow = MyWorkflow()
+ new_workflow.restore()
+ self.assertEqual(new_workflow.ant, 9)
+ self.assertEqual(new_workflow.bee, 8)
+ self.assertEqual(new_workflow.cat, 7)
+ self.assertEqual(new_workflow.dog, 4)
+
+ def test_run_thru(self):
+ # Run all steps through the given one.
+ results = self._workflow.run_thru('second')
+ self.assertEqual(results, ['one', 'two'])
+
+ def test_run_until(self):
+ # Run until (but not including) the given step.
+ results = self._workflow.run_until('second')
+ self.assertEqual(results, ['one'])
diff --git a/src/mailman/app/workflow.py b/src/mailman/app/workflow.py
new file mode 100644
index 000000000..b83d1c3aa
--- /dev/null
+++ b/src/mailman/app/workflow.py
@@ -0,0 +1,156 @@
+# Copyright (C) 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/>.
+
+"""Generic workflow."""
+
+__all__ = [
+ 'Workflow',
+ ]
+
+
+import sys
+import json
+import logging
+
+from collections import deque
+from mailman.interfaces.workflow import IWorkflowStateManager
+from zope.component import getUtility
+
+
+COMMASPACE = ', '
+log = logging.getLogger('mailman.error')
+
+
+
+class Workflow:
+ """Generic workflow."""
+
+ SAVE_ATTRIBUTES = ()
+ INITIAL_STATE = None
+
+ def __init__(self):
+ self.token = None
+ self._next = deque()
+ self.push(self.INITIAL_STATE)
+ self.debug = False
+ self._count = 0
+
+ @property
+ def name(self):
+ return self.__class__.__name__
+
+ def __iter__(self):
+ return self
+
+ def push(self, step):
+ self._next.append(step)
+
+ def _pop(self):
+ name = self._next.popleft()
+ step = getattr(self, '_step_{}'.format(name))
+ self._count += 1
+ if self.debug:
+ print('[{:02d}] -> {}'.format(self._count, name), file=sys.stderr)
+ return name, step
+
+ def __next__(self):
+ try:
+ name, step = self._pop()
+ return step()
+ except IndexError:
+ raise StopIteration
+ except:
+ log.exception('deque: {}'.format(COMMASPACE.join(self._next)))
+ raise
+
+ def run_thru(self, stop_after):
+ """Run the state machine through and including the given step.
+
+ :param stop_after: Name of method, sans prefix to run the
+ state machine through. In other words, the state machine runs
+ until the named method completes.
+ """
+ results = []
+ while True:
+ try:
+ name, step = self._pop()
+ except (StopIteration, IndexError):
+ # We're done.
+ break
+ results.append(step())
+ if name == stop_after:
+ break
+ return results
+
+ def run_until(self, stop_before):
+ """Trun the state machine until (not including) the given step.
+
+ :param stop_before: Name of method, sans prefix that the
+ state machine is run until the method is reached. Unlike
+ `run_thru()` the named method is not run.
+ """
+ results = []
+ while True:
+ try:
+ name, step = self._pop()
+ except (StopIteration, IndexError):
+ # We're done.
+ break
+ if name == stop_before:
+ # Stop executing, but not before we push the last state back
+ # onto the deque. Otherwise, resuming the state machine would
+ # skip this step.
+ self._next.appendleft(step)
+ break
+ results.append(step())
+ return results
+
+ def save(self):
+ assert self.token, 'Workflow token must be set'
+ state_manager = getUtility(IWorkflowStateManager)
+ data = {attr: getattr(self, attr) for attr in self.SAVE_ATTRIBUTES}
+ # Note: only the next step is saved, not the whole stack. This is not
+ # an issue in practice, since there's never more than a single step in
+ # the queue anyway. If we want to support more than a single step in
+ # the queue *and* want to support state saving/restoring, change this
+ # method and the restore() method.
+ if len(self._next) == 0:
+ step = None
+ elif len(self._next) == 1:
+ step = self._next[0]
+ else:
+ raise AssertionError(
+ "Can't save a workflow state with more than one step "
+ "in the queue")
+ state_manager.save(
+ self.__class__.__name__,
+ self.token,
+ step,
+ json.dumps(data))
+
+ def restore(self):
+ state_manager = getUtility(IWorkflowStateManager)
+ state = state_manager.restore(self.__class__.__name__, self.token)
+ if state is None:
+ # The token doesn't exist in the database.
+ raise LookupError(self.token)
+ self._next.clear()
+ if state.step:
+ self._next.append(state.step)
+ if state.data is not None:
+ for attr, value in json.loads(state.data).items():
+ setattr(self, attr, value)
diff --git a/src/mailman/archiving/mailarchive.py b/src/mailman/archiving/mailarchive.py
index b7f3847f0..c5dd8c8e5 100644
--- a/src/mailman/archiving/mailarchive.py
+++ b/src/mailman/archiving/mailarchive.py
@@ -74,3 +74,4 @@ class MailArchive:
msg,
listid=mlist.list_id,
recipients=[self.recipient])
+ return None
diff --git a/src/mailman/archiving/mhonarc.py b/src/mailman/archiving/mhonarc.py
index ff8baf079..8d19c6f64 100644
--- a/src/mailman/archiving/mhonarc.py
+++ b/src/mailman/archiving/mhonarc.py
@@ -87,3 +87,6 @@ class MHonArc:
(msg['message-id'], proc.returncode))
log.info(stdout)
log.error(stderr)
+ # Can we get more information, such as the url to the message just
+ # archived, out of MHonArc?
+ return None
diff --git a/src/mailman/archiving/prototype.py b/src/mailman/archiving/prototype.py
index b0328211e..b5df11f78 100644
--- a/src/mailman/archiving/prototype.py
+++ b/src/mailman/archiving/prototype.py
@@ -115,3 +115,5 @@ class Prototype:
message.get('message-id', 'n/a')))
finally:
lock.unlock(unconditionally=True)
+ # Can we get return the URL of the archived message?
+ return None
diff --git a/src/mailman/bin/docs/master.rst b/src/mailman/bin/docs/master.rst
index 3d10b69ac..5a3a94da6 100644
--- a/src/mailman/bin/docs/master.rst
+++ b/src/mailman/bin/docs/master.rst
@@ -4,10 +4,10 @@ Mailman runner control
Mailman has a number of *runner subprocesses* which perform long-running tasks
such as listening on an LMTP port, processing REST API requests, or processing
-messages in a queue directory. In normal operation, the ``bin/mailman``
-command is used to start, stop and manage the runners. This is just a wrapper
-around the real master watcher, which handles runner starting, stopping,
-exiting, and log file reopening.
+messages in a queue directory. In normal operation, the ``mailman`` command
+is used to start, stop and manage the runners. This is just a wrapper around
+the real master watcher, which handles runner starting, stopping, exiting, and
+log file reopening.
>>> from mailman.testing.helpers import TestableMaster
diff --git a/src/mailman/bin/mailman.py b/src/mailman/bin/mailman.py
index 8814cdfc4..3865fef19 100644
--- a/src/mailman/bin/mailman.py
+++ b/src/mailman/bin/mailman.py
@@ -36,7 +36,7 @@ from zope.interface.verify import verifyObject
def main():
- """bin/mailman"""
+ """The `mailman` command dispatcher."""
# Create the basic parser and add all globally common options.
parser = argparse.ArgumentParser(
description=_("""\
diff --git a/src/mailman/bin/master.py b/src/mailman/bin/master.py
index 492a6b138..5ffe59647 100644
--- a/src/mailman/bin/master.py
+++ b/src/mailman/bin/master.py
@@ -320,7 +320,7 @@ class Loop:
log.info('Master watcher caught SIGUSR1. Exiting.')
signal.signal(signal.SIGUSR1, sigusr1_handler)
# SIGTERM is what init will kill this process with when changing run
- # levels. It's also the signal 'bin/mailman stop' uses.
+ # levels. It's also the signal 'mailman stop' uses.
def sigterm_handler(signum, frame):
for pid in self._kids:
os.kill(pid, signal.SIGTERM)
diff --git a/src/mailman/bin/runner.py b/src/mailman/bin/runner.py
index e8c68dad9..87d11dbe9 100644
--- a/src/mailman/bin/runner.py
+++ b/src/mailman/bin/runner.py
@@ -108,19 +108,18 @@ def main():
description=_("""\
Start a runner
- The runner named on the command line is started, and it can
- either run through its main loop once (for those runners that
- support this) or continuously. The latter is how the master
- runner starts all its subprocesses.
+ The runner named on the command line is started, and it can either run
+ through its main loop once (for those runners that support this) or
+ continuously. The latter is how the master runner starts all its
+ subprocesses.
- -r is required unless -l or -h is given, and its argument must
- be one of the names displayed by the -l switch.
+ -r is required unless -l or -h is given, and its argument must be one
+ of the names displayed by the -l switch.
- Normally, this script should be started from 'bin/mailman
- start'. Running it separately or with -o is generally useful
- only for debugging. When run this way, the environment variable
- $MAILMAN_UNDER_MASTER_CONTROL will be set which subtly changes
- some error handling behavior.
+ Normally, this script should be started from 'mailman start'. Running
+ it separately or with -o is generally useful only for debugging. When
+ run this way, the environment variable $MAILMAN_UNDER_MASTER_CONTROL
+ will be set which subtly changes some error handling behavior.
"""))
parser.add_argument(
'--version',
diff --git a/src/mailman/chains/docs/moderation.rst b/src/mailman/chains/docs/moderation.rst
index 1fe7e40cb..c239ec7a6 100644
--- a/src/mailman/chains/docs/moderation.rst
+++ b/src/mailman/chains/docs/moderation.rst
@@ -31,13 +31,13 @@ Posts by list members are moderated if the member's moderation action is not
deferred. The default setting for the moderation action of new members is
determined by the mailing list's settings. By default, a mailing list is not
set to moderate new member postings.
+::
- >>> from mailman.app.membership import add_member
- >>> from mailman.interfaces.member import DeliveryMode
- >>> member = add_member(mlist, 'anne@example.com', 'Anne', 'aaa',
- ... DeliveryMode.regular, 'en')
+ >>> from mailman.testing.helpers import subscribe
+ >>> member = subscribe(mlist, 'Anne', email='anne@example.com')
>>> member
- <Member: Anne <anne@example.com> on test@example.com as MemberRole.member>
+ <Member: Anne Person <anne@example.com> on test@example.com
+ as MemberRole.member>
>>> print(member.moderation_action)
Action.defer
diff --git a/src/mailman/chains/hold.py b/src/mailman/chains/hold.py
index ee1762442..3773aa3e5 100644
--- a/src/mailman/chains/hold.py
+++ b/src/mailman/chains/hold.py
@@ -158,7 +158,7 @@ class HoldChain(TerminalChainBase):
listname = mlist.fqdn_listname,
subject = original_subject,
sender = msg.sender,
- reason = 'XXX', #reason,
+ reason = 'N/A', #reason,
confirmurl = '{0}/{1}'.format(mlist.script_url('confirm'), token),
admindb_url = mlist.script_url('admindb'),
)
diff --git a/src/mailman/commands/cli_import.py b/src/mailman/commands/cli_import.py
index 30aeb7894..344d5baee 100644
--- a/src/mailman/commands/cli_import.py
+++ b/src/mailman/commands/cli_import.py
@@ -25,6 +25,7 @@ __all__ = [
import sys
import pickle
+from contextlib import ExitStack, contextmanager
from mailman.core.i18n import _
from mailman.database.transaction import transactional
from mailman.interfaces.command import ICLISubCommand
@@ -35,6 +36,25 @@ from zope.interface import implementer
+# A fake Bouncer class from Mailman 2.1, we don't use it but there are
+# instances in the .pck files.
+class Bouncer:
+ class _BounceInfo:
+ pass
+
+
+@contextmanager
+def hacked_sys_modules():
+ assert 'Mailman.Bouncer' not in sys.modules
+ sys.modules['Mailman.Bouncer'] = Bouncer
+ try:
+ yield
+ finally:
+ del sys.modules['Mailman.Bouncer']
+
+
+
+
@implementer(ICLISubCommand)
class Import21:
"""Import Mailman 2.1 list data."""
@@ -74,10 +94,13 @@ class Import21:
assert len(args.pickle_file) == 1, (
'Unexpected positional arguments: %s' % args.pickle_file)
filename = args.pickle_file[0]
- with open(filename, 'rb') as fp:
+ with ExitStack() as resources:
+ fp = resources.enter_context(open(filename, 'rb'))
+ resources.enter_context(hacked_sys_modules())
while True:
try:
- config_dict = pickle.load(fp)
+ config_dict = pickle.load(
+ fp, encoding='utf-8', errors='ignore')
except EOFError:
break
except pickle.UnpicklingError:
diff --git a/src/mailman/commands/cli_inject.py b/src/mailman/commands/cli_inject.py
index c467c2508..1b7f15f7b 100644
--- a/src/mailman/commands/cli_inject.py
+++ b/src/mailman/commands/cli_inject.py
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
-"""bin/mailman inject"""
+"""The `mailman inject` subcommand."""
__all__ = [
'Inject',
diff --git a/src/mailman/commands/cli_lists.py b/src/mailman/commands/cli_lists.py
index 05aa7b7ca..3d5fcd634 100644
--- a/src/mailman/commands/cli_lists.py
+++ b/src/mailman/commands/cli_lists.py
@@ -86,7 +86,7 @@ class Lists:
mlist = list_manager.get(fqdn_name)
if args.advertised and not mlist.advertised:
continue
- domains = getattr(args, 'domains', None)
+ domains = getattr(args, 'domain', None)
if domains and mlist.mail_host not in domains:
continue
mailing_lists.append(mlist)
diff --git a/src/mailman/commands/cli_members.py b/src/mailman/commands/cli_members.py
index e4cad5966..ccacbeeb8 100644
--- a/src/mailman/commands/cli_members.py
+++ b/src/mailman/commands/cli_members.py
@@ -27,15 +27,14 @@ import codecs
from email.utils import formataddr, parseaddr
from mailman.app.membership import add_member
-from mailman.config import config
from mailman.core.i18n import _
from mailman.database.transaction import transactional
from mailman.interfaces.command import ICLISubCommand
from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import (
AlreadySubscribedError, DeliveryMode, DeliveryStatus)
+from mailman.interfaces.subscriptions import RequestRecord
from operator import attrgetter
-from passlib.utils import generate_password as generate
from zope.component import getUtility
from zope.interface import implementer
@@ -193,12 +192,11 @@ class Members:
continue
# Parse the line and ensure that the values are unicodes.
display_name, email = parseaddr(line)
- # Give the user a default, user-friendly password.
- password = generate(int(config.passwords.password_length))
try:
- add_member(mlist, email, display_name, password,
- DeliveryMode.regular,
- mlist.preferred_language.code)
+ add_member(mlist,
+ RequestRecord(email, display_name,
+ DeliveryMode.regular,
+ mlist.preferred_language.code))
except AlreadySubscribedError:
# It's okay if the address is already subscribed, just
# print a warning and continue.
diff --git a/src/mailman/commands/cli_status.py b/src/mailman/commands/cli_status.py
index 34420954b..7faab7941 100644
--- a/src/mailman/commands/cli_status.py
+++ b/src/mailman/commands/cli_status.py
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
-"""bin/mailman status."""
+"""The `mailman status` subcommand."""
__all__ = [
'Status',
diff --git a/src/mailman/commands/cli_withlist.py b/src/mailman/commands/cli_withlist.py
index c0c9b3202..e3307d7b4 100644
--- a/src/mailman/commands/cli_withlist.py
+++ b/src/mailman/commands/cli_withlist.py
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
-"""bin/mailman withlist"""
+"""The `mailman shell` subcommand."""
__all__ = [
'Shell',
@@ -190,7 +190,7 @@ Programmatically, you can write a function to operate on a mailing list, and
this script will take care of the housekeeping (see below for examples). In
that case, the general usage syntax is:
- % bin/mailman withlist [options] listname [args ...]"""))
+ % mailman withlist [options] listname [args ...]"""))
print()
print(_("""\
Here's an example of how to use the --run option. Say you have a file in the
@@ -207,7 +207,7 @@ functions:
You can print the list's posting address by running the following from the
command line:
- % bin/mailman withlist -r listaddr mylist@example.com
+ % mailman withlist -r listaddr mylist@example.com
Importing listaddr ...
Running listaddr.listaddr() ...
mylist@example.com"""))
@@ -215,7 +215,7 @@ command line:
print(_("""\
And you can print the list's request address by running:
- % bin/mailman withlist -r listaddr.requestaddr mylist
+ % mailman withlist -r listaddr.requestaddr mylist
Importing listaddr ...
Running listaddr.requestaddr() ...
mylist-request@example.com"""))
@@ -232,7 +232,7 @@ mailing list. You could put the following function in a file called
and run this from the command line:
- % bin/mailman withlist -r change mylist@example.com 'My List'"""))
+ % mailman withlist -r change mylist@example.com 'My List'"""))
diff --git a/src/mailman/commands/docs/aliases.rst b/src/mailman/commands/docs/aliases.rst
index 528a77770..75a9d3c11 100644
--- a/src/mailman/commands/docs/aliases.rst
+++ b/src/mailman/commands/docs/aliases.rst
@@ -6,7 +6,7 @@ For some mail servers, Mailman must generate data files that are used to hook
Mailman up to the mail server. The details of this differ for each mail
server. Generally these files are automatically kept up-to-date when mailing
lists are created or removed, but you might occasionally need to manually
-regenerate the file. The ``bin/mailman aliases`` command does this.
+regenerate the file. The ``mailman aliases`` command does this.
>>> class FakeArgs:
... directory = None
diff --git a/src/mailman/commands/docs/conf.rst b/src/mailman/commands/docs/conf.rst
index 0ff5064bb..1db85918f 100644
--- a/src/mailman/commands/docs/conf.rst
+++ b/src/mailman/commands/docs/conf.rst
@@ -2,13 +2,13 @@
Display configuration values
============================
-Just like the `Postfix command postconf(1)`_, the ``bin/mailman conf`` command
+Just like the `Postfix command postconf(1)`_, the ``mailman conf`` command
lets you dump one or more Mailman configuration variables to standard output
or a file.
Mailman's configuration is divided in multiple sections which contain multiple
-key-value pairs. The ``bin/mailman conf`` command allows you to display
-a specific key-value pair, or several key-value pairs.
+key-value pairs. The ``mailman conf`` command allows you to display a
+specific key-value pair, or several key-value pairs.
>>> class FakeArgs:
... key = None
diff --git a/src/mailman/commands/docs/create.rst b/src/mailman/commands/docs/create.rst
index bec4ea8b6..1a5d2a3ab 100644
--- a/src/mailman/commands/docs/create.rst
+++ b/src/mailman/commands/docs/create.rst
@@ -44,8 +44,7 @@ Now both the domain and the mailing list exist in the database.
>>> from mailman.interfaces.domain import IDomainManager
>>> getUtility(IDomainManager).get('example.xx')
- <Domain example.xx, base_url: http://example.xx,
- contact_address: postmaster@example.xx>
+ <Domain example.xx, base_url: http://example.xx>
You can also create mailing lists in existing domains without the
auto-creation flag.
diff --git a/src/mailman/commands/docs/lists.rst b/src/mailman/commands/docs/lists.rst
index 036147a23..04e0d744d 100644
--- a/src/mailman/commands/docs/lists.rst
+++ b/src/mailman/commands/docs/lists.rst
@@ -100,14 +100,14 @@ You can narrow the search down to a specific domain with the --domain option.
A helpful message is displayed if no matching domains are given.
>>> FakeArgs.quiet = False
- >>> FakeArgs.domains = ['example.org']
+ >>> FakeArgs.domain = ['example.org']
>>> command.process(FakeArgs)
No matching mailing lists found
But if a matching domain is given, only mailing lists in that domain are
shown.
- >>> FakeArgs.domains = ['example.net']
+ >>> FakeArgs.domain = ['example.net']
>>> command.process(FakeArgs)
1 matching mailing lists found:
list-one@example.net
@@ -115,7 +115,7 @@ shown.
More than one --domain argument can be given; then all mailing lists in
matching domains are shown.
- >>> FakeArgs.domains = ['example.com', 'example.net']
+ >>> FakeArgs.domain = ['example.com', 'example.net']
>>> command.process(FakeArgs)
3 matching mailing lists found:
list-one@example.com
@@ -131,7 +131,7 @@ knowledge. Non-advertised lists are considered private. Display through the
command line can select on this attribute.
::
- >>> FakeArgs.domains = []
+ >>> FakeArgs.domain = []
>>> FakeArgs.advertised = True
>>> mlist_1.advertised = False
diff --git a/src/mailman/commands/docs/members.rst b/src/mailman/commands/docs/members.rst
index 490287235..86e5c3ceb 100644
--- a/src/mailman/commands/docs/members.rst
+++ b/src/mailman/commands/docs/members.rst
@@ -2,8 +2,8 @@
Managing members
================
-The ``bin/mailman members`` command allows a site administrator to display,
-add, and remove members from a mailing list.
+The ``mailman members`` command allows a site administrator to display, add,
+and remove members from a mailing list.
::
>>> mlist1 = create_list('test1@example.com')
@@ -34,17 +34,13 @@ options. To start with, there are no members of the mailing list.
Once the mailing list add some members, they will be displayed.
::
- >>> from mailman.interfaces.member import DeliveryMode
- >>> from mailman.app.membership import add_member
- >>> add_member(mlist1, 'anne@example.com', 'Anne Person', 'xxx',
- ... DeliveryMode.regular, mlist1.preferred_language.code)
- <Member: Anne Person <anne@example.com>
- on test1@example.com as MemberRole.member>
- >>> add_member(mlist1, 'bart@example.com', 'Bart Person', 'xxx',
- ... DeliveryMode.regular, mlist1.preferred_language.code)
- <Member: Bart Person <bart@example.com>
- on test1@example.com as MemberRole.member>
-
+ >>> from mailman.testing.helpers import subscribe
+ >>> subscribe(mlist1, 'Anne', email='anne@example.com')
+ <Member: Anne Person <anne@example.com> on test1@example.com
+ as MemberRole.member>
+ >>> subscribe(mlist1, 'Bart', email='bart@example.com')
+ <Member: Bart Person <bart@example.com> on test1@example.com
+ as MemberRole.member>
>>> command.process(args)
Anne Person <anne@example.com>
Bart Person <bart@example.com>
@@ -52,11 +48,9 @@ Once the mailing list add some members, they will be displayed.
Members are displayed in alphabetical order based on their address.
::
- >>> add_member(mlist1, 'anne@aaaxample.com', 'Anne Person', 'xxx',
- ... DeliveryMode.regular, mlist1.preferred_language.code)
- <Member: Anne Person <anne@aaaxample.com>
- on test1@example.com as MemberRole.member>
-
+ >>> subscribe(mlist1, 'Anne', email='anne@aaaxample.com')
+ <Member: Anne Person <anne@aaaxample.com> on test1@example.com
+ as MemberRole.member>
>>> command.process(args)
Anne Person <anne@aaaxample.com>
Anne Person <anne@example.com>
@@ -92,6 +86,7 @@ Filtering on delivery mode
You can limit output to just the regular non-digest members...
+ >>> from mailman.interfaces.member import DeliveryMode
>>> args.regular = True
>>> member = mlist1.members.get_member('anne@example.com')
>>> member.preferences.delivery_mode = DeliveryMode.plaintext_digests
@@ -136,21 +131,17 @@ status is enabled...
::
>>> from mailman.interfaces.member import DeliveryStatus
+
>>> member = mlist1.members.get_member('anne@aaaxample.com')
>>> member.preferences.delivery_status = DeliveryStatus.by_moderator
>>> member = mlist1.members.get_member('bart@example.com')
>>> member.preferences.delivery_status = DeliveryStatus.by_user
- >>> member = add_member(
- ... mlist1, 'cris@example.com', 'Cris Person', 'xxx',
- ... DeliveryMode.regular, mlist1.preferred_language.code)
+
+ >>> member = subscribe(mlist1, 'Cris', email='cris@example.com')
>>> member.preferences.delivery_status = DeliveryStatus.unknown
- >>> member = add_member(
- ... mlist1, 'dave@example.com', 'Dave Person', 'xxx',
- ... DeliveryMode.regular, mlist1.preferred_language.code)
+ >>> member = subscribe(mlist1, 'Dave', email='dave@example.com')
>>> member.preferences.delivery_status = DeliveryStatus.enabled
- >>> member = add_member(
- ... mlist1, 'elly@example.com', 'Elly Person', 'xxx',
- ... DeliveryMode.regular, mlist1.preferred_language.code)
+ >>> member = subscribe(mlist1, 'Elle', email='elle@example.com')
>>> member.preferences.delivery_status = DeliveryStatus.by_bounces
>>> args.nomail = 'enabled'
@@ -174,7 +165,7 @@ status is enabled...
>>> args.nomail = 'bybounces'
>>> command.process(args)
- Elly Person <elly@example.com>
+ Elle Person <elle@example.com>
...or for unknown (legacy) reasons.
@@ -190,7 +181,7 @@ You can also display all members who have delivery disabled for any reason.
Anne Person <anne@aaaxample.com>
Bart Person <bart@example.com>
Cris Person <cris@example.com>
- Elly Person <elly@example.com>
+ Elle Person <elle@example.com>
# Reset for following tests.
>>> args.nomail = None
diff --git a/src/mailman/commands/docs/membership.rst b/src/mailman/commands/docs/membership.rst
index a260e930a..49e80511d 100644
--- a/src/mailman/commands/docs/membership.rst
+++ b/src/mailman/commands/docs/membership.rst
@@ -70,7 +70,7 @@ The ``subscribe`` command is an alias.
Joining the sender
------------------
-When the message has a From field, that address will be subscribed.
+When the message has a ``From`` field, that address will be subscribed.
>>> msg = message_from_string("""\
... From: Anne Person <anne@example.com>
@@ -85,13 +85,10 @@ When the message has a From field, that address will be subscribed.
Confirmation email sent to Anne Person <anne@example.com>
<BLANKLINE>
-Anne is not yet a member because she must confirm her subscription request
-first.
+Anne is not yet a member of the mailing list because she must confirm her
+subscription request first.
- >>> from mailman.interfaces.usermanager import IUserManager
- >>> from zope.component import getUtility
- >>> user_manager = getUtility(IUserManager)
- >>> print(user_manager.get_user('anne@example.com'))
+ >>> print(mlist.members.get_member('anne@example.com'))
None
Mailman has sent her the confirmation message.
@@ -118,20 +115,16 @@ Mailman has sent her the confirmation message.
<BLANKLINE>
Before you can start using GNU Mailman at this site, you must first
confirm that this is your email address. You can do this by replying to
- this message, keeping the Subject header intact. Or you can visit this
- web page
- <BLANKLINE>
- http://lists.example.com/confirm/...
+ this message, keeping the Subject header intact.
<BLANKLINE>
If you do not wish to register this email address simply disregard this
message. If you think you are being maliciously subscribed to the list, or
have any other questions, you may contact
<BLANKLINE>
- postmaster@example.com
+ alpha-owner@example.com
<BLANKLINE>
-Once Anne confirms her registration, she will be made a member of the mailing
-list.
+Anne confirms her registration.
::
>>> def extract_token(message):
@@ -156,13 +149,7 @@ list.
Confirmed
<BLANKLINE>
- >>> user = user_manager.get_user('anne@example.com')
- >>> print(user.display_name)
- Anne Person
- >>> list(user.addresses)
- [<Address: Anne Person <anne@example.com> [verified] at ...>]
-
-Anne is also now a member of the mailing list.
+Anne is now a member of the mailing list.
>>> mlist.members.get_member('anne@example.com')
<Member: Anne Person <anne@example.com>
@@ -180,12 +167,7 @@ Joining a second list
>>> print(join.process(mlist_2, msg, {}, (), Results()))
ContinueProcessing.yes
-Anne of course, is still registered.
-
- >>> print(user_manager.get_user('anne@example.com'))
- <User "Anne Person" (...) at ...>
-
-But she is not a member of the mailing list.
+Anne is not a member of the mailing list.
>>> print(mlist_2.members.get_member('anne@example.com'))
None
@@ -257,7 +239,9 @@ subscribe with. Any of her registered, linked, and validated email addresses
will do.
::
- >>> anne = user_manager.get_user('anne@example.com')
+ >>> from mailman.interfaces.usermanager import IUserManager
+ >>> from zope.component import getUtility
+ >>> anne = getUtility(IUserManager).get_user('anne@example.com')
>>> address = anne.register('anne.person@example.org')
>>> results = Results()
@@ -333,11 +317,6 @@ message.
... raise AssertionError('No confirmation message')
>>> token = extract_token(item.msg)
-Bart is still not a user.
-
- >>> print(user_manager.get_user('bart@example.com'))
- None
-
Bart replies to the original message, specifically keeping the Subject header
intact except for any prefix. Mailman matches the token and confirms Bart as
a user of the system.
@@ -360,12 +339,7 @@ a user of the system.
Confirmed
<BLANKLINE>
-Now Bart is a user...
-
- >>> print(user_manager.get_user('bart@example.com'))
- <User "Bart Person" (...) at ...>
-
-...and a member of the mailing list.
+Now Bart is now a member of the mailing list.
>>> print(mlist.members.get_member('bart@example.com'))
<Member: Bart Person <bart@example.com>
diff --git a/src/mailman/commands/eml_confirm.py b/src/mailman/commands/eml_confirm.py
index a28a3f728..ddf0db0e2 100644
--- a/src/mailman/commands/eml_confirm.py
+++ b/src/mailman/commands/eml_confirm.py
@@ -25,7 +25,7 @@ __all__ = [
from mailman.core.i18n import _
from mailman.interfaces.command import ContinueProcessing, IEmailCommand
from mailman.interfaces.registrar import IRegistrar
-from zope.component import getUtility
+from mailman.interfaces.subscriptions import TokenOwner
from zope.interface import implementer
@@ -53,7 +53,19 @@ class Confirm:
return ContinueProcessing.yes
tokens.add(token)
results.confirms = tokens
- succeeded = getUtility(IRegistrar).confirm(token)
+ try:
+ token, token_owner, member = IRegistrar(mlist).confirm(token)
+ if token is None:
+ assert token_owner is TokenOwner.no_one, token_owner
+ assert member is not None, member
+ succeeded = True
+ else:
+ assert token_owner is not TokenOwner.no_one, token_owner
+ assert member is None, member
+ succeeded = False
+ except LookupError:
+ # The token must not exist in the database.
+ succeeded = False
if succeeded:
print(_('Confirmed'), file=results)
return ContinueProcessing.yes
diff --git a/src/mailman/commands/eml_membership.py b/src/mailman/commands/eml_membership.py
index 059b9b634..970fd4429 100644
--- a/src/mailman/commands/eml_membership.py
+++ b/src/mailman/commands/eml_membership.py
@@ -37,6 +37,28 @@ from zope.interface import implementer
+def match_subscriber(email, display_name):
+ # Return something matching the email which should be used as the
+ # subscriber by the IRegistrar interface.
+ manager = getUtility(IUserManager)
+ # Is there a user with a preferred address matching the email?
+ user = manager.get_user(email)
+ if user is not None:
+ preferred = user.preferred_address
+ if preferred is not None and preferred.email == email.lower():
+ return user
+ # Is there an address matching the email?
+ address = manager.get_address(email)
+ if address is not None:
+ return address
+ # Make a new user and subscribe their first (and only) address. We can't
+ # make the first address their preferred address because it hasn't been
+ # verified yet.
+ user = manager.make_user(email, display_name)
+ return list(user.addresses)[0]
+
+
+
@implementer(IEmailCommand)
class Join:
"""The email 'join' command."""
@@ -60,35 +82,35 @@ used.
delivery_mode = self._parse_arguments(arguments, results)
if delivery_mode is ContinueProcessing.no:
return ContinueProcessing.no
- display_name, address = parseaddr(msg['from'])
+ display_name, email = parseaddr(msg['from'])
# Address could be None or the empty string.
- if not address:
- address = msg.sender
- if not address:
+ if not email:
+ email = msg.sender
+ if not email:
print(_('$self.name: No valid address found to subscribe'),
file=results)
return ContinueProcessing.no
- if isinstance(address, bytes):
- address = address.decode('ascii')
+ if isinstance(email, bytes):
+ email = email.decode('ascii')
# Have we already seen one join request from this user during the
# processing of this email?
joins = getattr(results, 'joins', set())
- if address in joins:
+ if email in joins:
# Do not register this join.
return ContinueProcessing.yes
- joins.add(address)
+ joins.add(email)
results.joins = joins
- person = formataddr((display_name, address))
+ person = formataddr((display_name, email))
# Is this person already a member of the list? Search for all
# matching memberships.
members = getUtility(ISubscriptionService).find_members(
- address, mlist.list_id, MemberRole.member)
+ email, mlist.list_id, MemberRole.member)
if len(members) > 0:
print(_('$person is already a member'), file=results)
- else:
- getUtility(IRegistrar).register(mlist, address,
- display_name, delivery_mode)
- print(_('Confirmation email sent to $person'), file=results)
+ return ContinueProcessing.yes
+ subscriber = match_subscriber(email, display_name)
+ IRegistrar(mlist).register(subscriber)
+ print(_('Confirmation email sent to $person'), file=results)
return ContinueProcessing.yes
def _parse_arguments(self, arguments, results):
diff --git a/src/mailman/commands/tests/test_confirm.py b/src/mailman/commands/tests/test_confirm.py
index dd168454f..e980141b0 100644
--- a/src/mailman/commands/tests/test_confirm.py
+++ b/src/mailman/commands/tests/test_confirm.py
@@ -29,8 +29,9 @@ from mailman.commands.eml_confirm import Confirm
from mailman.email.message import Message
from mailman.interfaces.command import ContinueProcessing
from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.usermanager import IUserManager
from mailman.runners.command import Results
-from mailman.testing.helpers import get_queue_messages, reset_the_world
+from mailman.testing.helpers import get_queue_messages
from mailman.testing.layers import ConfigLayer
from zope.component import getUtility
@@ -43,15 +44,14 @@ class TestConfirm(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
- self._token = getUtility(IRegistrar).register(
- self._mlist, 'anne@example.com', 'Anne Person')
+ anne = getUtility(IUserManager).create_address(
+ 'anne@example.com', 'Anne Person')
+ self._token, token_owner, member = IRegistrar(self._mlist).register(
+ anne)
self._command = Confirm()
# Clear the virgin queue.
get_queue_messages('virgin')
- def tearDown(self):
- reset_the_world()
-
def test_welcome_message(self):
# A confirmation causes a welcome message to be sent to the member, if
# enabled by the mailing list.
diff --git a/src/mailman/commands/tests/test_create.py b/src/mailman/commands/tests/test_create.py
index d9e90df26..d7e17e5d2 100644
--- a/src/mailman/commands/tests/test_create.py
+++ b/src/mailman/commands/tests/test_create.py
@@ -15,7 +15,7 @@
# You should have received a copy of the GNU General Public License along with
# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
-"""Test `bin/mailman create`."""
+"""Test the `mailman create` subcommand."""
__all__ = [
'TestCreate',
@@ -51,8 +51,6 @@ class FakeParser:
class TestCreate(unittest.TestCase):
- """Test `bin/mailman create`."""
-
layer = ConfigLayer
def setUp(self):
diff --git a/src/mailman/commands/tests/test_import.py b/src/mailman/commands/tests/test_import.py
new file mode 100644
index 000000000..96f955e52
--- /dev/null
+++ b/src/mailman/commands/tests/test_import.py
@@ -0,0 +1,61 @@
+# Copyright (C) 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/>.
+
+"""Test the `mailman import21` subcommand."""
+
+__all__ = [
+ 'TestImport',
+ ]
+
+
+import unittest
+
+from mailman.app.lifecycle import create_list
+from mailman.commands.cli_import import Import21
+from mailman.testing.layers import ConfigLayer
+from mock import patch
+from pkg_resources import resource_filename
+
+
+
+class FakeArgs:
+ listname = ['test@example.com']
+ pickle_file = [
+ resource_filename('mailman.testing', 'config-with-instances.pck'),
+ ]
+
+
+
+class TestImport(unittest.TestCase):
+ layer = ConfigLayer
+
+ def setUp(self):
+ self.command = Import21()
+ self.args = FakeArgs()
+ self.mlist = create_list('test@example.com')
+
+ @patch('mailman.commands.cli_import.import_config_pck')
+ def test_process_pickle_with_bounce_info(self, import_config_pck):
+ # The sample data contains Mailman 2 bounce info, represented as
+ # _BounceInfo instances. We throw these away when importing to
+ # Mailman 3, but we have to fake the instance's classes, otherwise
+ # unpickling the dictionaries will fail.
+ try:
+ self.command.process(self.args)
+ except ImportError as error:
+ self.fail('The pickle failed loading: {}'.format(error))
+ self.assertTrue(import_config_pck.called)
diff --git a/src/mailman/commands/tests/test_lists.py b/src/mailman/commands/tests/test_lists.py
new file mode 100644
index 000000000..229e7c96d
--- /dev/null
+++ b/src/mailman/commands/tests/test_lists.py
@@ -0,0 +1,67 @@
+# Copyright (C) 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/>.
+
+"""Additional tests for the `lists` command line subcommand."""
+
+__all__ = [
+ 'TestLists',
+ ]
+
+
+import unittest
+
+from io import StringIO
+from mailman.app.lifecycle import create_list
+from mailman.commands.cli_lists import Lists
+from mailman.interfaces.domain import IDomainManager
+from mailman.testing.layers import ConfigLayer
+from unittest.mock import patch
+from zope.component import getUtility
+
+
+class FakeArgs:
+ advertised = False
+ names = False
+ descriptions = False
+ quiet = False
+ domain = []
+
+
+class TestLists(unittest.TestCase):
+ layer = ConfigLayer
+
+ def test_lists_with_domain_option(self):
+ # LP: #1166911 - non-matching lists were returned.
+ getUtility(IDomainManager).add(
+ 'example.net', 'An example domain.',
+ 'http://lists.example.net')
+ create_list('test1@example.com')
+ create_list('test2@example.com')
+ # Only this one should show up.
+ create_list('test3@example.net')
+ create_list('test4@example.com')
+ command = Lists()
+ args = FakeArgs()
+ args.domain.append('example.net')
+ output = StringIO()
+ with patch('sys.stdout', output):
+ command.process(args)
+ lines = output.getvalue().splitlines()
+ # The first line is the heading, so skip that.
+ lines.pop(0)
+ self.assertEqual(len(lines), 1, lines)
+ self.assertEqual(lines[0], 'test3@example.net')
diff --git a/src/mailman/config/config.py b/src/mailman/config/config.py
index 2b14419a2..d23bdda13 100644
--- a/src/mailman/config/config.py
+++ b/src/mailman/config/config.py
@@ -149,10 +149,10 @@ class Configuration:
# First, collect all variables in a substitution dictionary. $VAR_DIR
# is taken from the environment or from the configuration file if the
# environment is not set. Because the var_dir setting in the config
- # file could be a relative path, and because 'bin/mailman start'
- # chdirs to $VAR_DIR, without this subprocesses bin/master and
- # bin/runner will create $VAR_DIR hierarchies under $VAR_DIR when that
- # path is relative.
+ # file could be a relative path, and because 'mailman start' chdirs to
+ # $VAR_DIR, without this subprocesses bin/master and bin/runner will
+ # create $VAR_DIR hierarchies under $VAR_DIR when that path is
+ # relative.
var_dir = os.environ.get('MAILMAN_VAR_DIR', category.var_dir)
substitutions = dict(
cwd = os.getcwd(),
diff --git a/src/mailman/config/configure.zcml b/src/mailman/config/configure.zcml
index 24061f0f0..632771d42 100644
--- a/src/mailman/config/configure.zcml
+++ b/src/mailman/config/configure.zcml
@@ -40,6 +40,12 @@
factory="mailman.model.requests.ListRequests"
/>
+ <adapter
+ for="mailman.interfaces.mailinglist.IMailingList"
+ provides="mailman.interfaces.registrar.IRegistrar"
+ factory="mailman.app.registrar.Registrar"
+ />
+
<utility
provides="mailman.interfaces.bounce.IBounceProcessor"
factory="mailman.model.bounce.BounceProcessor"
@@ -88,11 +94,6 @@
/>
<utility
- provides="mailman.interfaces.registrar.IRegistrar"
- factory="mailman.app.registrar.Registrar"
- />
-
- <utility
provides="mailman.interfaces.styles.IStyleManager"
factory="mailman.styles.manager.StyleManager"
/>
@@ -117,4 +118,9 @@
factory="mailman.app.templates.TemplateLoader"
/>
+ <utility
+ provides="mailman.interfaces.workflow.IWorkflowStateManager"
+ factory="mailman.model.workflow.WorkflowStateManager"
+ />
+
</configure>
diff --git a/src/mailman/config/schema.cfg b/src/mailman/config/schema.cfg
index 61b65fac4..f8c3a117e 100644
--- a/src/mailman/config/schema.cfg
+++ b/src/mailman/config/schema.cfg
@@ -66,10 +66,10 @@ filtered_messages_are_preservable: no
[shell]
-# `bin/mailman shell` (also `withlist`) gives you an interactive prompt that
-# you can use to interact with an initialized and configured Mailman system.
-# Use --help for more information. This section allows you to configure
-# certain aspects of this interactive shell.
+# `mailman shell` (also `withlist`) gives you an interactive prompt that you
+# can use to interact with an initialized and configured Mailman system. Use
+# --help for more information. This section allows you to configure certain
+# aspects of this interactive shell.
# Customize the interpreter prompt.
prompt: >>>
@@ -100,7 +100,7 @@ var_dir: /var/tmp/mailman
queue_dir: $var_dir/queue
# This is the directory containing the Mailman 'runner' and 'master' commands
# if set to the string '$argv', it will be taken as the directory containing
-# the 'bin/mailman' command.
+# the 'mailman' command.
bin_dir: $argv
# All list-specific data.
list_data_dir: $var_dir/lists
diff --git a/src/mailman/app/docs/chains.rst b/src/mailman/core/docs/chains.rst
index 1feecbd68..328d0b624 100644
--- a/src/mailman/app/docs/chains.rst
+++ b/src/mailman/core/docs/chains.rst
@@ -132,13 +132,10 @@ This one is addressed to the list moderators.
List: test@example.com
From: aperson@example.com
Subject: My first post
- Reason: XXX
+ Reason: N/A
<BLANKLINE>
- At your convenience, visit:
- <BLANKLINE>
- http://lists.example.com/admindb/test@example.com
- <BLANKLINE>
- to approve or deny the request.
+ At your convenience, visit your dashboard to approve or deny the
+ request.
<BLANKLINE>
...
Content-Type: message/rfc822
@@ -189,7 +186,7 @@ This message is addressed to the sender of the message.
<BLANKLINE>
The reason it is being held:
<BLANKLINE>
- XXX
+ N/A
<BLANKLINE>
Either the message will get posted to the list, or you will receive
notification of the moderator's decision. If you would like to cancel
@@ -302,6 +299,7 @@ This message will end up in the `pipeline` queue.
>>> from mailman.testing.helpers import subscribe
>>> subscribe(mlist, 'Anne')
+ <Member: aperson@example.com on test@example.com as MemberRole.member>
>>> with event_subscribers(print_msgid):
... process(mlist, msg, {})
diff --git a/src/mailman/core/initialize.py b/src/mailman/core/initialize.py
index 044efea2f..d5b5a6a5f 100644
--- a/src/mailman/core/initialize.py
+++ b/src/mailman/core/initialize.py
@@ -130,8 +130,8 @@ def initialize_1(config_path=None):
# PostgreSQL.
extra_cfg_path = os.environ.get('MAILMAN_EXTRA_TESTING_CFG')
if extra_cfg_path is not None:
- with open(extra_cfg_path) as fp:
- extra_cfg = fp.read().decode('utf-8')
+ with open(extra_cfg_path, 'r', encoding='utf-8') as fp:
+ extra_cfg = fp.read()
mailman.config.config.push('extra testing config', extra_cfg)
diff --git a/src/mailman/core/logging.py b/src/mailman/core/logging.py
index c1db8a55e..7c80037f6 100644
--- a/src/mailman/core/logging.py
+++ b/src/mailman/core/logging.py
@@ -103,8 +103,8 @@ def _init_logger(propagate, sub_name, log, logger_config):
# Get settings from log configuration file (or defaults).
log_format = logger_config.format
log_datefmt = logger_config.datefmt
- # Propagation to the root logger is how we handle logging to stderr
- # when the runners are not run as a subprocess of 'bin/mailman start'.
+ # Propagation to the root logger is how we handle logging to stderr when
+ # the runners are not run as a subprocess of 'mailman start'.
log.propagate = (as_boolean(logger_config.propagate)
if propagate is None else propagate)
# Set the logger's level.
diff --git a/src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py b/src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py
new file mode 100644
index 000000000..8534f4b73
--- /dev/null
+++ b/src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py
@@ -0,0 +1,41 @@
+"""List subscription policy
+
+Revision ID: 16c2b25c7b
+Revises: 46e92facee7
+Create Date: 2015-03-21 11:00:44.634883
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '16c2b25c7b'
+down_revision = '46e92facee7'
+
+from alembic import op
+import sqlalchemy as sa
+
+from mailman.database.types import Enum
+from mailman.interfaces.mailinglist import SubscriptionPolicy
+
+
+def upgrade():
+
+ ### Update the schema
+ op.add_column('mailinglist', sa.Column(
+ 'subscription_policy', Enum(SubscriptionPolicy), nullable=True))
+
+ ### Now migrate the data
+ # don't import the table definition from the models, it may break this
+ # migration when the model is updated in the future (see the Alembic doc)
+ mlist = sa.sql.table('mailinglist',
+ sa.sql.column('subscription_policy', Enum(SubscriptionPolicy))
+ )
+ # there were no enforced subscription policy before, so all lists are
+ # considered open
+ op.execute(mlist.update().values(
+ {'subscription_policy': op.inline_literal(SubscriptionPolicy.open)}))
+
+
+def downgrade():
+ if op.get_bind().dialect.name != 'sqlite':
+ # SQLite does not support dropping columns.
+ op.drop_column('mailinglist', 'subscription_policy')
diff --git a/src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py b/src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py
new file mode 100644
index 000000000..59cb1121e
--- /dev/null
+++ b/src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py
@@ -0,0 +1,28 @@
+"""Workflow state table
+
+Revision ID: 2bb9b382198
+Revises: 16c2b25c7b
+Create Date: 2015-03-25 18:09:18.338790
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '2bb9b382198'
+down_revision = '16c2b25c7b'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ op.create_table('workflowstate',
+ sa.Column('name', sa.Unicode(), nullable=False),
+ sa.Column('key', sa.Unicode(), nullable=False),
+ sa.Column('step', sa.Unicode(), nullable=True),
+ sa.Column('data', sa.Unicode(), nullable=True),
+ sa.PrimaryKeyConstraint('name', 'key')
+ )
+
+
+def downgrade():
+ op.drop_table('workflowstate')
diff --git a/src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py b/src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py
new file mode 100644
index 000000000..fc489cae5
--- /dev/null
+++ b/src/mailman/database/alembic/versions/46e92facee7_add_serverowner_domainowner.py
@@ -0,0 +1,56 @@
+# Copyright (C) 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/>.
+
+"""add_serverowner_domainowner
+
+Revision ID: 46e92facee7
+Revises: 33e1f5f6fa8
+Create Date: 2015-03-20 16:01:25.007242
+
+"""
+
+# Revision identifiers, used by Alembic.
+revision = '46e92facee7'
+down_revision = '33e1f5f6fa8'
+
+from alembic import op
+import sqlalchemy as sa
+
+
+def upgrade():
+ op.create_table(
+ 'domain_owner',
+ sa.Column('user_id', sa.Integer(), nullable=False),
+ sa.Column('domain_id', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['domain_id'], ['domain.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
+ sa.PrimaryKeyConstraint('user_id', 'domain_id')
+ )
+ op.add_column(
+ 'user',
+ sa.Column('is_server_owner', sa.Boolean(), nullable=True))
+ if op.get_bind().dialect.name != 'sqlite':
+ op.drop_column('domain', 'contact_address')
+
+
+def downgrade():
+ if op.get_bind().dialect.name != 'sqlite':
+ op.drop_column('user', 'is_server_owner')
+ op.add_column(
+ 'domain',
+ sa.Column('contact_address', sa.VARCHAR(), nullable=True))
+ op.drop_table('domain_owner')
diff --git a/src/mailman/database/tests/test_factory.py b/src/mailman/database/tests/test_factory.py
index 6a16c74ab..acb7c32a4 100644
--- a/src/mailman/database/tests/test_factory.py
+++ b/src/mailman/database/tests/test_factory.py
@@ -129,17 +129,14 @@ class TestSchemaManager(unittest.TestCase):
md.tables['alembic_version'].select()).scalar()
self.assertEqual(current_rev, head_rev)
- @patch('alembic.command.stamp')
- def test_storm(self, alembic_command_stamp):
+ @patch('alembic.command')
+ def test_storm(self, alembic_command):
# Existing Storm database.
Model.metadata.create_all(config.db.engine)
self._create_storm_database(LAST_STORM_SCHEMA_VERSION)
self.schema_mgr.setup_database()
- self.assertFalse(alembic_command_stamp.called)
- self.assertTrue(
- self._table_exists('mailinglist')
- and self._table_exists('alembic_version')
- and not self._table_exists('version'))
+ self.assertFalse(alembic_command.stamp.called)
+ self.assertTrue(alembic_command.upgrade.called)
@patch('alembic.command')
def test_old_storm(self, alembic_command):
diff --git a/src/mailman/database/transaction.py b/src/mailman/database/transaction.py
index 30710017e..fef76b73c 100644
--- a/src/mailman/database/transaction.py
+++ b/src/mailman/database/transaction.py
@@ -19,6 +19,7 @@
__all__ = [
'dbconnection',
+ 'flush',
'transaction',
'transactional',
]
@@ -63,6 +64,22 @@ def transactional(function):
+@contextmanager
+def flush():
+ """Context manager for flushing SQLAlchemy.
+
+ We need this for SA whereas we didn't need it for Storm because the latter
+ did auto-reloads. However, in SA this is needed when we add or delete
+ objects from the database. Use it when you need the id after adding, or
+ when you want to be sure the object won't be found after a delete.
+
+ This is lighter weight than committing the transaction.
+ """
+ yield
+ config.db.store.flush()
+
+
+
def dbconnection(function):
"""Decorator for getting at the database connection.
@@ -70,6 +87,9 @@ def dbconnection(function):
attribute. This calls the function with `store` as the first argument.
"""
def wrapper(*args, **kws):
- # args[0] is self.
- return function(args[0], config.db.store, *args[1:], **kws)
+ # args[0] is self, if there is one.
+ if len(args) > 0:
+ return function(args[0], config.db.store, *args[1:], **kws)
+ else:
+ return function(config.db.store, **kws)
return wrapper
diff --git a/src/mailman/docs/8-miles-high.rst b/src/mailman/docs/8-miles-high.rst
index 85b186fc5..ae3074e1c 100644
--- a/src/mailman/docs/8-miles-high.rst
+++ b/src/mailman/docs/8-miles-high.rst
@@ -162,9 +162,9 @@ when the Mailman daemon starts, and what queue the Runner manages.
Shell Commands
==============
-`bin/mailman`: This is an ubercommand, with subcommands for all the various
-things admins might want to do, similar to Mailman 2's mailmanctl, but with
-more functionality.
+`mailman`: This is an ubercommand, with subcommands for all the various things
+admins might want to do, similar to Mailman 2's mailmanctl, but with more
+functionality.
`bin/master`: The runner manager: starts, watches, stops the runner
daemons.
diff --git a/src/mailman/docs/ACKNOWLEDGMENTS.rst b/src/mailman/docs/ACKNOWLEDGMENTS.rst
index 8ee5b5dd1..4577a26ab 100644
--- a/src/mailman/docs/ACKNOWLEDGMENTS.rst
+++ b/src/mailman/docs/ACKNOWLEDGMENTS.rst
@@ -7,27 +7,37 @@ GNU Mailman Acknowledgments
Copyright (C) 1998-2015 by the Free Software Foundation, Inc.
+Governance
+==========
+
+GNU Mailman was invented by John Viega. Barry Warsaw is the current project
+leader. Aurélien Bompard leads HyperKitty and bundler development. Florian
+Fuchs leads Postorious development. Development of mailman.client is a group
+effort.
+
+All project decisions are made by consensus via the Mailman Cabal, er,
+Steering Committee which can be contacted directly via mailman-cabal@python.org
+
+
Core Developers
===============
The following folks are or have been core developers of Mailman (in reverse
alphabetical order):
+* Abhilash Raj
+* Aurélien Bompard
* Barry Warsaw, Mailman's yappy guard dog
-* Mark Sapiro, Mailman's compulsive responder
-* Tokio Kikuchi, Mailman's weatherman
-* John Viega, Mailman's inventor
-* Thomas Wouters, Mailman's Dutch treat
+* Florian Fuchs
* Harald Meland, Norse Mailman
+* John Viega, Mailman's inventor
* Ken Manheimer, Mailman's savior
+* Mark Sapiro, Mailman's compulsive responder
* Scott Cotton, Cookie-Monster
-
-
-Steering Committee
-==================
-
-The Mailman Steering Committee can be contacted directly via
-mailman-cabal@python.org
+* Stephen J. Turnbull
+* Terri Oda
+* Thomas Wouters, Mailman's Dutch treat
+* Tokio Kikuchi (RIP), Mailman's weatherman
Copyright Assignees
@@ -44,7 +54,6 @@ code, and have assigned copyright for contributions to the FSF:
* Norbert Bollow
* Joe Dugan
* Ethan Mindlace Fremen
-* Florian Fuchs
* Ben Gertzfield
* Victoriano Giralt
* Stephen Goss
@@ -58,7 +67,6 @@ code, and have assigned copyright for contributions to the FSF:
* Simone Piunno
* Claudia Schmidt
* Andreas Schosser
-* Stephen J. Turnbull
* Richard Wackerbarth
@@ -101,7 +109,6 @@ left off the list!
* Stuart Bishop
* David Blomquist
* Bojan
-* Aurélien Bompard
* Søren Bondrup
* Grant Bowman
* Alessio Bragadini
diff --git a/src/mailman/docs/DATABASE.rst b/src/mailman/docs/DATABASE.rst
index f6ef45f44..a6d1877ed 100644
--- a/src/mailman/docs/DATABASE.rst
+++ b/src/mailman/docs/DATABASE.rst
@@ -71,7 +71,7 @@ Mailman into that, and then run the ``alembic`` command. For example::
$ source /tmp/mm3/bin/activate
$ python setup.py develop
$ alembic -c src/mailman/config/alembic.cfg revision --autogenerate -m
- "<migration_name>"
+ "<migration_name>"
This would create a new migration which would automatically be migrated to the
database on the next run of Mailman. Note that the database needs to be in
@@ -85,13 +85,13 @@ People upgrading Mailman from previous versions need not do anything manually,
as soon as a new migration is added in the sources, it will be automatically
reflected in the schema on first-run post-update.
-'''Note:''' When autogenerating migrations using alembic, be sure to check the
-created migration before adding it to the version control. For some of the
-special datatypes defined in ``mailman.database.types``, you will have to
-manually change the datatype. For example, ``mailman.database.types.Enum()``
-needs to be changed to ``sa.Integer()`` ,as Enum type stores just the integer in
-the database. A more complex migration would be needed for ``UUID`` depending
-upon the database layer to be used.
+**Note:** When auto-generating migrations using Alembic, be sure to check
+the created migration before adding it to the version control. You will have
+to manually change some of the special data types defined in
+``mailman.database.types``. For example, ``mailman.database.types.Enum()``
+needs to be changed to ``sa.Integer()``, as the ``Enum`` type stores just the
+integer in the database. A more complex migration would be needed for
+``UUID`` depending upon the database layer to be used.
.. _SQLAlchemy: http://www.sqlalchemy.org/
diff --git a/src/mailman/docs/DEVELOP.rst b/src/mailman/docs/DEVELOP.rst
index f1225658e..5b3ee602a 100644
--- a/src/mailman/docs/DEVELOP.rst
+++ b/src/mailman/docs/DEVELOP.rst
@@ -72,10 +72,10 @@ queue. You can think of these as fairly typical server process, and examples
include the LMTP server, and the HTTP server for processing REST commands.
All of the runners are managed by a *master watcher* process. When you type
-``bin/mailman start`` you are actually starting the master. Based on
+``mailman start`` you are actually starting the master. Based on
configuration options, the master will start the appropriate runners as
subprocesses, and it will watch for the clean exiting of these subprocesses
-when ``bin/mailman stop`` is called.
+when ``mailman stop`` is called.
Rules and chains
diff --git a/src/mailman/docs/INTRODUCTION.rst b/src/mailman/docs/INTRODUCTION.rst
index 7257044ca..f6f9f2df2 100644
--- a/src/mailman/docs/INTRODUCTION.rst
+++ b/src/mailman/docs/INTRODUCTION.rst
@@ -51,7 +51,7 @@ Mailman was originally developed by John Viega. Subsequent development
final release was a group effort, with the core contributors being: Barry
Warsaw, Ken Manheimer, Scott Cotton, Harald Meland, and John Viega. Version
1.0 and beyond have been primarily maintained by Barry Warsaw with
-contributions from many; see the ACKNOWLEDGMENTS file for details. Jeremy
+contributions from many; see the `ACKNOWLEDGMENTS`_ file for details. Jeremy
Hylton helped considerably with the Pipermail code in Mailman 2.0. Mailman
2.1 is primarily maintained by Mark Sapiro, with previous help by Tokio
Kikuchi. Barry Warsaw is the lead developer on Mailman 3.
@@ -79,10 +79,18 @@ lists and archives, etc., are available at:
http://www.list.org/help.html
-Requirements
-============
+Bits and pieces
+===============
-Mailman 3 requires `Python 3.4`_ or newer.
+Mailman 3 is really a suite of 5 projects:
+
+ * Core - the core message processing and delivery system, exposing a REST API
+ for administrative control. Requires `Python 3.4`_ or newer.
+ * Postorious - the new web user interfaces built on `Django`_.
+ * HyperKitty - the new archiver, also built on `Django`_.
+ * mailman.client - a Python binding to the core's REST API. Compatible with
+ both Python 2 and Python 3.
+ * Bundler - a convenient installer.
.. _`GNU Mailman`: http://www.list.org
@@ -91,3 +99,5 @@ Mailman 3 requires `Python 3.4`_ or newer.
.. _Python: http://www.python.org
.. _FAQ: http://wiki.list.org/display/DOC/Frequently+Asked+Questions
.. _`Python 3.4`: https://www.python.org/downloads/release/python-342/
+.. _`ACKNOWLEDGMENTS`: ACKNOWLEDGMENTS.html
+.. _`Django`: https://www.djangoproject.com/
diff --git a/src/mailman/docs/MTA.rst b/src/mailman/docs/MTA.rst
index 1bc9c6c13..a10c8f3cf 100644
--- a/src/mailman/docs/MTA.rst
+++ b/src/mailman/docs/MTA.rst
@@ -143,7 +143,7 @@ Transport maps
By default, Mailman works well with Postfix transport maps as a way to deliver
incoming messages to Mailman's LMTP server. Mailman will automatically write
-the correct transport map when its ``bin/mailman aliases`` command is run, or
+the correct transport map when its ``mailman aliases`` command is run, or
whenever a mailing list is created or removed via other commands. To connect
Postfix to Mailman's LMTP server, add the following to Postfix's ``main.cf``
file::
diff --git a/src/mailman/docs/NEWS.rst b/src/mailman/docs/NEWS.rst
index ba8fa61bd..6248d0d57 100644
--- a/src/mailman/docs/NEWS.rst
+++ b/src/mailman/docs/NEWS.rst
@@ -8,14 +8,42 @@ Copyright (C) 1998-2015 by the Free Software Foundation, Inc.
Here is a history of user visible changes to Mailman.
-3.0 beta 6 -- "Show Don't Tell"
-===============================
+3.0 rc 1 -- "Show Don't Tell"
+=============================
(2015-XX-XX)
+Architecture
+------------
+ * Domains now have a list of owners, which are ``IUser`` objects, instead of
+ the single ``contact_address`` they used to have. ``IUser`` objects now
+ also have a ``is_server_owner`` flag (defaulting to False) to indicate
+ whether they have superuser privileges. Give by Abhliash Raj, with fixes
+ and refinements by Barry Warsaw. (LP: #1423756)
+ * Mailing list subscription policy work flow has been completely rewritten.
+ It now properly supports email verification and subscription confirmation
+ by the user, and approval by the moderator using unique tokens.
+ ``IMailingList`` objects now have a ``subscription_policy`` attribute.
+
Bugs
----
* Fix calculation of default configuration file to use when the ``$var_dir``
is created by ``mailman start``. (LP: #1411435)
+ * When creating a user with an email address, do not create the user record
+ if the email address already exists. Given by Andrew Stuart.
+ (LP: #1418280)
+ * When deleting a user via REST, make sure all linked addresses are deleted.
+ Found by Andrew Stuart. (LP: #1419519)
+ * When trying to subscribe an address to a mailing list through the REST API
+ where a case-differing version of the address is already subscribed, return
+ a 409 error instead of a 500 error. Found by Ankush Sharma. (LP: #1425359)
+ * ``mailman lists --domain`` was not properly handling its arguments. Given
+ by Manish Gill. (LP: #1166911)
+ * When deleting a user object, make sure their preferences are also deleted.
+ Given by Abhishek. (LP: #1418276)
+ * Be sure a mailing list's acceptable aliases are deleted when the mailing
+ list itself is deleted. (LP: #1432239)
+ * The built-in example ``IArchiver`` implementations now explicitly return
+ None. (LP: #1203359)
Configuration
-------------
@@ -35,8 +63,24 @@ Documentation
REST
----
+ * **Backward incompatible change**: The JSON representation for pending
+ mailing list subscription hold now no longer includes the ``password``
+ key. Also, the ``address`` key has been renamed ``email`` for consistent
+ terminology and other usage.
* You can now view the contents of, inject messages into, and delete messages
from the various queue directories via the ``<api>/queues`` resource.
+ * You can now DELETE an address. If the address is linked to a user, the
+ user is not delete, it is just unlinked.
+ * A new API is provided to support non-production testing infrastructures,
+ allowing a client to cull all orphaned UIDs via ``DELETE`` on
+ ``<api>/reserved/uids/orphans``. Note that *no guarantees* of API
+ stability will ever be made for resources under ``reserved``.
+ (LP: #1420083)
+ * Domains can now optionally be created with owners; domain owners can be
+ added after the fact; domain owners can be deleted. Also, users now have
+ an ``is_server_owner`` flag as part of their representation, which defaults
+ to False, and can be PUT and PATCH'd. Given by Abhilash Raj, with fixes
+ and refinements by Barry Warsaw. (LP: #1423756)
3.0 beta 5 -- "Carve Away The Stone"
@@ -115,6 +159,7 @@ REST
section names via ``/3.0/system/configuration`` which returns a dictionary
containing the ``http_etag`` and the section names as a sorted list under
the ``sections`` key. The system configuration resource is read-only.
+ * Member resource JSON now include the ``member_id`` as a separate key.
3.0 beta 4 -- "Time and Motion"
diff --git a/src/mailman/docs/RELEASENOTES.rst b/src/mailman/docs/RELEASENOTES.rst
new file mode 100644
index 000000000..226b256a2
--- /dev/null
+++ b/src/mailman/docs/RELEASENOTES.rst
@@ -0,0 +1,23 @@
+===============
+ Release notes
+===============
+
+Mailman 3 is a fully rewritten code base. The developers believe it has
+sufficient functionality to provide full mailing list services. It should be
+ready for production use by experienced system developers, but it may not be
+easy to install or run by novices.
+
+We expect it to be possible to migrate Mailman 2.1 mailing lists to Mailman 3,
+but sufficient caution, backups, and testing should be performed.
+
+We expect it to be possible to run Mailman 3 and Mailman 2.1 together on the
+same systems, but you may need to be quite experienced with configuring your
+mail server and web infrastructure.
+
+Mailman 3 may have bugs.
+
+Mailman 3 is not yet feature complete with Mailman 2.1.
+
+The documentation here describes the Mailman Core in great detail.
+Postorious, Hyperkitty, mailman.client, and the bundler are described and
+developed elsewhere.
diff --git a/src/mailman/docs/START.rst b/src/mailman/docs/START.rst
index ae4fe43a5..688f453d0 100644
--- a/src/mailman/docs/START.rst
+++ b/src/mailman/docs/START.rst
@@ -7,25 +7,6 @@ Getting started with GNU Mailman
Copyright (C) 2008-2015 by the Free Software Foundation, Inc.
-Beta Release
-============
-
-This is a beta release. The developers believe it has sufficient
-functionality to provide full mailing list services, but it is not yet ready
-for production use.
-
-The Mailman 3 beta releases are being provided to give developers and other
-interested people an early look at the next major version, and site
-administrators a chance to prepare for an eventual upgrade. The core list
-management and post distribution functionality is now complete. However,
-unlike Mailman 2 whose web interface and archives were tightly integrated with
-the core, Mailman 3 exposes a REST administrative interface to the web,
-communicates with archivers via decoupled interfaces, and leaves summary,
-search, and retrieval of archived messages to a separate application (a simple
-implementation is provided). The web interface (known as `Postorius`_) and
-archiver (known as `Hyperkitty`_) are developed separately.
-
-
Contact Us
==========
@@ -39,11 +20,12 @@ list, or ask on IRC channel ``#mailman`` on Freenode.
Requirements
============
-Python 3.4 or newer is required. It can either be the default 'python3' on
-your ``$PATH`` or it can be accessible via the ``python3.4`` binary. If your
-operating system does not include Python, see http://www.python.org for
-information about downloading installers (where available) and installing it
-from source (when necessary or preferred). Python 2 is not supported.
+For the Core, Python 3.4 or newer is required. It can either be the default
+'python3' on your ``$PATH`` or it can be accessible via the ``python3.4``
+binary. If your operating system does not include Python, see
+http://www.python.org for information about downloading installers (where
+available) and installing it from source (when necessary or preferred).
+Python 2 is not supported.
You may need some additional dependencies, which are either available from
your OS vendor, or can be downloaded automatically from the `Python
@@ -53,12 +35,12 @@ Cheeseshop`_.
Documentation
=============
-The documentation for Mailman 3 is distributed throughout the sources.
-The core documentation (such as this file, ``START.rst``) is found in
-the ``src/mailman/docs`` directory, but much of the documentation is
-in module-specific places. A prebuilt HTML version of `Mailman 3
-documentation`_ is available at pythonhosted.org, as is `Postorius
-documentation`_. `HyperKitty documentation`_ is available at ReadTheDocs.
+The documentation for Mailman 3 is distributed throughout the sources. The
+core documentation (such as this file) is found in the ``src/mailman/docs``
+directory, but much of the documentation is in module-specific places. A
+prebuilt HTML version of `Mailman 3 documentation`_ is available at
+pythonhosted.org, as is `Postorius documentation`_. `HyperKitty
+documentation`_ is available at ReadTheDocs.
The `Development Setup Guide`_ is a recent step-by-step explanation of
how to set up a complete Mailman 3 system including the Mailman 3 core
@@ -107,23 +89,13 @@ Building for development
To build Mailman for development purposes, you can create a virtual
environment outside of tox. You need to have the `virtualenv`_ program
-installed.
+installed, or you can use Python 3's built-in `pyvenv`_ command.
-First, create a virtual environment. By default ``virtualenv`` uses the
-``python`` executable it finds first on your ``$PATH``. Make sure this is
-Python 3.4 (just start the interactive interpreter and check the version in
-the startup banner). The directory you install the virtualenv into is up to
-you, but for purposes of this document, we'll install it into ``/tmp/mm3``::
+First, create a virtual environment (venv). The directory you install the
+venv into is up to you, but for purposes of this document, we'll install it
+into ``/tmp/mm3``::
- % virtualenv -p python3 --system-site-packages /tmp/mm3
-
-If your default Python is not version 3.4, use the ``--python`` option to
-specify the Python executable. You can use the command name if this version
-is on your ``PATH``::
-
- % virtualenv --system-site-packages --python=python3.4 /tmp/mm3
-
-or you may specify the full path to any Python 3.4 executable.
+ % pyvenv /tmp/mm3
Now, activate the virtual environment and set it up for development::
@@ -188,14 +160,13 @@ The first existing file found wins.
* ``/etc/mailman.cfg``
* ``argv[0]/../../etc/mailman.cfg``
-Run the ``bin/mailman info`` command to see which configuration file Mailman
-will use, and where it will put its database file. The first time you run
-this, Mailman will also create any necessary run-time directories and log
-files.
+Run the ``mailman info`` command to see which configuration file Mailman will
+use, and where it will put its database file. The first time you run this,
+Mailman will also create any necessary run-time directories and log files.
-Try ``bin/mailman --help`` for more details. You can use the commands
-``bin/mailman start`` to start the runner subprocess daemons, and of course
-``bin/mailman stop`` to stop them.
+Try ``mailman --help`` for more details. You can use the commands
+``mailman start`` to start the runner subprocess daemons, and of course
+``mailman stop`` to stop them.
Postorius, a web UI for administration and subscriber settings, is being
developed as a separate, Django-based project. For now, the most flexible
@@ -212,13 +183,6 @@ entirely, with a reasonable amount of effort. However, as a core feature of
Mailman, the web UI will emphasize usability over modularity at first, so most
users should use the web UI described here.
-Postorius was prototyped at the `Pycon 2012 sprint`_, so it is "very alpha" as
-of Mailman 3 beta 1, and comes in several components. In particular, it
-requires a `Django`_ installation, and Bazaar checkouts of the `REST client
-module`_ and `Postorius`_ itself. Building it is fairly straightforward,
-based on Florian Fuchs' `Five Minute Guide` from his `blog post`_ on the
-Mailman wiki. (Check the `blog post`_ for the most recent version!)
-
The Archiver
------------
@@ -231,12 +195,9 @@ is appropriate for that archiver. Summary, search, and retrieval of archived
posts are handled by a separate application.
A new archive UI called `Hyperkitty`_, based on the `notmuch mail indexer`_
-and `Django`_, was prototyped at the PyCon 2012 sprint by Toshio Kuratomi, and
-like the web UI it is also in early alpha as of Mailman 3 beta 1. The
-Hyperkitty archiver is very loosely coupled to Mailman 3 core. In fact, any
-email application that speaks LMTP or SMTP will be able to use Hyperkitty.
-
-A `five minute guide to Hyperkitty`_ is based on Toshio Kuratomi's README.
+and `Django`_, was prototyped at the PyCon 2012 sprint by Toshio Kuratomi.
+The Hyperkitty archiver is very loosely coupled to Mailman 3 core. In fact,
+any email application that speaks LMTP or SMTP will be able to use Hyperkitty.
.. _`Postorius`: https://launchpad.net/postorius
@@ -250,6 +211,7 @@ A `five minute guide to Hyperkitty`_ is based on Toshio Kuratomi's README.
.. _`Pycon 2012 sprint`: https://us.pycon.org/2012/community/sprints/projects/
.. _`Python Cheeseshop`: http://pypi.python.org/pypi
.. _`virtualenv`: http://www.virtualenv.org/en/latest/
+.. _`pyvenv`: https://docs.python.org/3/library/venv.html
.. _`Mailman 3 documentation`: http://www.pythonhosted.org/mailman/
.. _`Postorius documentation`: http://www.pythonhosted.org/postorius/
.. _`HyperKitty documentation`: https://hyperkitty.readthedocs.org/en/latest/development.html
diff --git a/src/mailman/docs/WebUIin5.rst b/src/mailman/docs/WebUIin5.rst
index 135f50484..bbcd7f194 100644
--- a/src/mailman/docs/WebUIin5.rst
+++ b/src/mailman/docs/WebUIin5.rst
@@ -56,7 +56,7 @@ directly on the PYTHONPATH.
::
$(py2) cd mailman.client
- $(py2) sudo python setup.py develop
+ $(py2) python setup.py develop
$(py2) cd ..
@@ -67,7 +67,7 @@ Postorius
$(py2) bzr branch lp:postorius
$(py2) cd postorius
- $(py2) sudo python setup.py develop
+ $(py2) python setup.py develop
Start the development server
diff --git a/src/mailman/interfaces/domain.py b/src/mailman/interfaces/domain.py
index 3ebfb0d6e..d5cbf8e2b 100644
--- a/src/mailman/interfaces/domain.py
+++ b/src/mailman/interfaces/domain.py
@@ -88,9 +88,8 @@ class IDomain(Interface):
description = Attribute(
'The human readable description of the domain name.')
- contact_address = Attribute("""\
- The contact address for the human at this domain.
- E.g. postmaster@example.com""")
+ owners = Attribute("""\
+ The relationship with the user database representing domain owners.""")
mailing_lists = Attribute(
"""All mailing lists for this domain.
@@ -112,7 +111,7 @@ class IDomain(Interface):
class IDomainManager(Interface):
"""The manager of domains."""
- def add(mail_host, description=None, base_url=None, contact_address=None):
+ def add(mail_host, description=None, base_url=None, owners=None):
"""Add a new domain.
:param mail_host: The email host name for the domain.
@@ -123,11 +122,10 @@ class IDomainManager(Interface):
interface of the domain. If not given, it defaults to
http://`mail_host`/
:type base_url: string
- :param contact_address: The email contact address for the human
- managing the domain. If not given, defaults to
- postmaster@`mail_host`
- :type contact_address: string
- :return: The new domain object
+ :param owners: Sequence of owners of the domain, defaults to None,
+ meaning the domain does not have owners.
+ :type owners: sequence of `IUser` or string emails.
+ :return: The new domain object.
:rtype: `IDomain`
:raises `BadDomainSpecificationError`: when the `mail_host` is
already registered.
diff --git a/src/mailman/interfaces/mailinglist.py b/src/mailman/interfaces/mailinglist.py
index 23d2fadf4..f112b2a11 100644
--- a/src/mailman/interfaces/mailinglist.py
+++ b/src/mailman/interfaces/mailinglist.py
@@ -25,6 +25,7 @@ __all__ = [
'IMailingList',
'Personalization',
'ReplyToMunging',
+ 'SubscriptionPolicy',
]
@@ -53,6 +54,18 @@ class ReplyToMunging(Enum):
explicit_header = 2
+class SubscriptionPolicy(Enum):
+ # Neither confirmation, nor moderator approval is required.
+ open = 0
+ # The user must confirm the subscription.
+ confirm = 1
+ # The moderator must approve the subscription.
+ moderate = 2
+ # The user must first confirm their subscription, and then if that is
+ # successful, the moderator must also approve it.
+ confirm_then_moderate = 3
+
+
class IMailingList(Interface):
"""A mailing list."""
@@ -234,6 +247,9 @@ class IMailingList(Interface):
deliver disabled or not, or of the type of digest they are to
receive.""")
+ subscription_policy = Attribute(
+ """The policy for subscribing new members to the list.""")
+
subscribers = Attribute(
"""An iterator over all IMembers subscribed to this list, with any
role.
diff --git a/src/mailman/interfaces/member.py b/src/mailman/interfaces/member.py
index c06cc95b1..d863e1ef1 100644
--- a/src/mailman/interfaces/member.py
+++ b/src/mailman/interfaces/member.py
@@ -123,7 +123,7 @@ class MembershipIsBannedError(MembershipError):
"""The address is not allowed to subscribe to the mailing list."""
def __init__(self, mlist, address):
- super(MembershipIsBannedError, self).__init__()
+ super().__init__()
self._mlist = mlist
self._address = address
@@ -175,6 +175,14 @@ class IMember(Interface):
user = Attribute(
"""The user associated with this member.""")
+ subscriber = Attribute(
+ """The object representing how this member is subscribed.
+
+ This will be an ``IAddress`` if the user is subscribed via an explicit
+ address, otherwise if the the user is subscribed via their preferred
+ address, it will be an ``IUser``.
+ """)
+
preferences = Attribute(
"""This member's preferences.""")
diff --git a/src/mailman/interfaces/pending.py b/src/mailman/interfaces/pending.py
index 9907aa779..c921123de 100644
--- a/src/mailman/interfaces/pending.py
+++ b/src/mailman/interfaces/pending.py
@@ -82,11 +82,11 @@ class IPendings(Interface):
:return: A token string for inclusion in urls and email confirmations.
"""
- def confirm(token, expunge=True):
+ def confirm(token, *, expunge=True):
"""Return the IPendable matching the token.
:param token: The token string for the IPendable given by the `.add()`
- method.
+ method, or None if there is no record associated with the token.
:param expunge: A flag indicating whether the pendable record should
also be removed from the database or not.
:return: The matching IPendable or None if no match was found.
@@ -94,3 +94,11 @@ class IPendings(Interface):
def evict():
"""Remove all pended items whose lifetime has expired."""
+
+ def __iter__():
+ """An iterator over all pendables.
+
+ Each element is a 2-tuple of the form (token, dict).
+ """
+
+ count = Attribute('The number of pendables in the pendings database.')
diff --git a/src/mailman/interfaces/registrar.py b/src/mailman/interfaces/registrar.py
index 7d3cf9c25..959e0bf6a 100644
--- a/src/mailman/interfaces/registrar.py
+++ b/src/mailman/interfaces/registrar.py
@@ -35,79 +35,83 @@ from zope.interface import Interface
class ConfirmationNeededEvent:
"""Triggered when an address needs confirmation.
- Addresses must be verified before they can receive messages or post to
- mailing list. When an address is registered with Mailman, via the
- `IRegistrar` interface, an `IPendable` is created which represents the
- pending registration. This pending registration is stored in the
- database, keyed by a token. Then this event is triggered.
-
- There may be several ways to confirm an email address. On some sites,
- registration may immediately produce a verification, e.g. because it is on
- a known intranet. Or verification may occur via external database lookup
- (e.g. LDAP). On most public mailing lists, a mail-back confirmation is
- sent to the address, and only if they reply to the mail-back, or click on
- an embedded link, is the registered address confirmed.
+ Addresses must be verified before they can receive messages or post
+ to mailing list. The confirmation message is sent to the user when
+ this event is triggered.
"""
- def __init__(self, mlist, pendable, token):
+ def __init__(self, mlist, token, email):
self.mlist = mlist
- self.pendable = pendable
self.token = token
+ self.email = email
class IRegistrar(Interface):
- """Interface for registering and verifying email addresses and users.
+ """Interface for subscribing addresses and users.
This is a higher level interface to user registration, email address
confirmation, etc. than the IUserManager. The latter does no validation,
syntax checking, or confirmation, while this interface does.
+
+ To use this, adapt an ``IMailingList`` to this interface.
"""
- def register(mlist, email, display_name=None, delivery_mode=None):
- """Register the email address, requesting verification.
+ def register(subscriber=None, *,
+ pre_verified=False, pre_confirmed=False, pre_approved=False):
+ """Subscribe an address or user according to subscription policies.
- No `IAddress` or `IUser` is created during this step, but after
- successful confirmation, it is guaranteed that an `IAddress` with a
- linked `IUser` will exist. When a verified `IAddress` matching
- `email` already exists, this method will do nothing, except link a new
- `IUser` to the `IAddress` if one is not yet associated with the
- email address.
+ The mailing list's subscription policy is used to subscribe
+ `subscriber` to the given mailing list. The subscriber can be
+ an ``IUser``, in which case the user must have a preferred
+ address, and that preferred address will be subscribed. The
+ subscriber can also be an ``IAddress``, in which case the
+ address will be subscribed.
- In all cases, the email address is sanity checked for validity first.
+ The workflow may pause (i.e. be serialized, saved, and
+ suspended) when some out-of-band confirmation step is required.
+ For example, if the user must confirm, or the moderator must
+ approve the subscription. Use the ``confirm(token)`` method to
+ resume the workflow.
- :param mlist: The mailing list that is the focus of this registration.
- :type mlist: `IMailingList`
- :param email: The email address to register.
- :type email: str
- :param display_name: The optional display name of the user.
- :type display_name: str
- :param delivery_mode: The optional delivery mode for this
- registration. If not given, regular delivery is used.
- :type delivery_mode: `DeliveryMode`
- :return: The confirmation token string.
- :rtype: str
- :raises InvalidEmailAddressError: if the address is not allowed.
+ :param subscriber: The user or address to subscribe.
+ :type email: ``IUser`` or ``IAddress``
+ :return: A 3-tuple is returned where the first element is the token
+ hash, the second element is a ``TokenOwner`, and the third element
+ is the subscribed member. If the subscriber got subscribed
+ immediately, the token will be None and the member will be
+ an ``IMember``. If the subscription got held, the token
+ will be a hash and the member will be None.
+ :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None)
+ :raises MembershipIsBannedError: when the address being subscribed
+ appears in the global or list-centric bans.
"""
def confirm(token):
- """Confirm the pending registration matched to the given `token`.
+ """Continue any paused workflow.
- Confirmation ensures that the IAddress exists and is linked to an
- IUser, with the latter being created and linked if necessary.
+ Confirmation may occur after the user confirms their
+ subscription request, or their email address must be verified,
+ or the moderator must approve the subscription request.
- :param token: A token matching a pending event with a type of
- 'registration'.
- :return: Boolean indicating whether the confirmation succeeded or
- not. It may fail if the token is no longer in the database, or if
- the token did not match a registration event.
+ :param token: A token matching a workflow.
+ :type token: string
+ :return: A 3-tuple is returned where the first element is the token
+ hash, the second element is a ``TokenOwner`, and the third element
+ is the subscribed member. If the subscriber got subscribed
+ immediately, the token will be None and the member will be
+ an ``IMember``. If the subscription is still being held, the token
+ will be a hash and the member will be None.
+ :rtype: (str-or-None, ``TokenOwner``, ``IMember``-or-None)
+ :raises LookupError: when no workflow is associated with the token.
"""
def discard(token):
- """Discard the pending registration matched to the given `token`.
-
- The event record is discarded and the IAddress is not verified. No
- IUser is created.
+ """Discard the workflow matched to the given `token`.
:param token: A token matching a pending event with a type of
'registration'.
+ :raises LookupError: when no workflow is associated with the token.
"""
+
+ def evict():
+ """Evict all saved workflows which have expired."""
diff --git a/src/mailman/interfaces/roster.py b/src/mailman/interfaces/roster.py
index 5d0b9d6c2..af473a553 100644
--- a/src/mailman/interfaces/roster.py
+++ b/src/mailman/interfaces/roster.py
@@ -53,11 +53,26 @@ class IRoster(Interface):
managed by this roster.
""")
- def get_member(address):
+ def get_member(email):
"""Get the member for the given address.
- :param address: The email address to search for.
- :type address: text
+ *Note* that it is possible for an email to be subscribed to a
+ mailing list twice, once through its explicit address and once
+ indirectly through a user's preferred address. In this case,
+ this API always returns the explicit address. Use
+ ``get_memberships()`` to return them all.
+
+ :param email: The email address to search for.
+ :type email: string
:return: The member if found, otherwise None
:rtype: `IMember` or None
"""
+
+ def get_memberships(email):
+ """Get the memberships for the given address.
+
+ :param email: The email address to search for.
+ :type email: string
+ :return: All the memberships associated with this email address.
+ :rtype: sequence of length 0, 1, or 2 of ``IMember``
+ """
diff --git a/src/mailman/interfaces/subscriptions.py b/src/mailman/interfaces/subscriptions.py
index c72a902cb..e6ffd29ce 100644
--- a/src/mailman/interfaces/subscriptions.py
+++ b/src/mailman/interfaces/subscriptions.py
@@ -19,11 +19,16 @@
__all__ = [
'ISubscriptionService',
+ 'MissingUserError',
+ 'RequestRecord',
+ 'TokenOwner',
]
+from collections import namedtuple
+from enum import Enum
from mailman.interfaces.errors import MailmanError
-from mailman.interfaces.member import DeliveryMode, MemberRole
+from mailman.interfaces.member import DeliveryMode
from zope.interface import Interface
@@ -40,6 +45,27 @@ class MissingUserError(MailmanError):
+_RequestRecord = namedtuple(
+ 'RequestRecord',
+ 'email display_name delivery_mode, language')
+def RequestRecord(email, display_name='',
+ delivery_mode=DeliveryMode.regular,
+ language=None):
+ if language is None:
+ from mailman.core.constants import system_preferences
+ language = system_preferences.preferred_language
+ return _RequestRecord(email, display_name, delivery_mode, language)
+
+
+
+class TokenOwner(Enum):
+ """Who 'owns' the token returned from the registrar?"""
+ no_one = 0
+ subscriber = 1
+ moderator = 2
+
+
+
class ISubscriptionService(Interface):
"""General Subscription services."""
@@ -88,44 +114,6 @@ class ISubscriptionService(Interface):
def __iter__():
"""See `get_members()`."""
- def join(list_id, subscriber, display_name=None,
- delivery_mode=DeliveryMode.regular,
- role=MemberRole.member):
- """Subscribe to a mailing list.
-
- A user for the address is created if it is not yet known to Mailman,
- however newly registered addresses will not yet be validated. No
- confirmation message will be sent to the address, and the approval of
- the subscription request is still dependent on the policy of the
- mailing list.
-
- :param list_id: The list id of the mailing list the user is
- subscribing to.
- :type list_id: string
- :param subscriber: The email address or user id of the user getting
- subscribed.
- :type subscriber: string or int
- :param display_name: The name of the user. This is only used if a new
- user is created, and it defaults to the local part of the email
- address if not given.
- :type display_name: string
- :param delivery_mode: The delivery mode for this subscription. This
- can be one of the enum values of `DeliveryMode`. If not given,
- regular delivery is assumed.
- :type delivery_mode: string
- :param role: The membership role for this subscription.
- :type role: `MemberRole`
- :return: The just created member.
- :rtype: `IMember`
- :raises AlreadySubscribedError: if the user is already subscribed to
- the mailing list.
- :raises InvalidEmailAddressError: if the email address is not valid.
- :raises MembershipIsBannedError: if the membership is not allowed.
- :raises MissingUserError: when a bogus user id is given.
- :raises NoSuchListError: if the named mailing list does not exist.
- :raises ValueError: when `delivery_mode` is invalid.
- """
-
def leave(list_id, email):
"""Unsubscribe from a mailing list.
diff --git a/src/mailman/interfaces/usermanager.py b/src/mailman/interfaces/usermanager.py
index 798d1d127..5f3a324cc 100644
--- a/src/mailman/interfaces/usermanager.py
+++ b/src/mailman/interfaces/usermanager.py
@@ -43,6 +43,22 @@ class IUserManager(Interface):
registered.
"""
+ def make_user(email, display_name=None):
+ """Create a new user linked to an address object.
+
+ If ``email`` is already associated with an existing `IAddress`
+ object, use that, otherwise create a new `IAddress`. If the
+ address object already points to an `IUser` return it. If a new
+ `IUser` is created, link the address to the user.
+
+ :param email: The email address.
+ :type email: str
+ :param display_name: The display name.
+ :type display_name: str
+ :return: the IUser object that exists or was created.
+ :rtype: IUser
+ """
+
def delete_user(user):
"""Delete the given user.
diff --git a/src/mailman/interfaces/workflow.py b/src/mailman/interfaces/workflow.py
new file mode 100644
index 000000000..f80e38547
--- /dev/null
+++ b/src/mailman/interfaces/workflow.py
@@ -0,0 +1,80 @@
+# Copyright (C) 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/>.
+
+"""Interfaces describing the state of a workflow."""
+
+__all__ = [
+ 'IWorkflowState',
+ 'IWorkflowStateManager',
+ ]
+
+
+from zope.interface import Attribute, Interface
+
+
+
+class IWorkflowState(Interface):
+ """The state of a workflow."""
+
+ name = Attribute('The name of the workflow.')
+
+ token = Attribute('A unique key identifying the workflow instance.')
+
+ step = Attribute("This workflow's next step.")
+
+ data = Attribute('Additional data (may be JSON-encoded).')
+
+
+
+class IWorkflowStateManager(Interface):
+ """The workflow states manager."""
+
+ def save(name, token, step, data=None):
+ """Save the state of a workflow.
+
+ :param name: The name of the workflow.
+ :type name: str
+ :param token: A unique token identifying this workflow instance.
+ :type token: str
+ :param step: The next step for this workflow.
+ :type step: str
+ :param data: Additional data (workflow-specific).
+ :type data: str
+ """
+
+ def restore(name, token):
+ """Get the saved state for a workflow or None if nothing was saved.
+
+ :param name: The name of the workflow.
+ :type name: str
+ :param token: A unique token identifying this workflow instance.
+ :type token: str
+ :return: The saved state associated with this name/token pair, or None
+ if the pair isn't in the database.
+ :rtype: ``IWorkflowState``
+ """
+
+ def discard(name, token):
+ """Throw away the saved state for a workflow.
+
+ :param name: The name of the workflow.
+ :type name: str
+ :param token: A unique token identifying this workflow instance.
+ :type token: str
+ """
+
+ count = Attribute('The number of saved workflows in the database.')
diff --git a/src/mailman/model/docs/domains.rst b/src/mailman/model/docs/domains.rst
index abb594a62..c0d65cee7 100644
--- a/src/mailman/model/docs/domains.rst
+++ b/src/mailman/model/docs/domains.rst
@@ -14,12 +14,16 @@ Domains are how Mailman interacts with email host names and web host names.
::
>>> from operator import attrgetter
- >>> def show_domains():
+ >>> def show_domains(*, with_owners=False):
... if len(manager) == 0:
... print('no domains')
... return
... for domain in sorted(manager, key=attrgetter('mail_host')):
... print(domain)
+ ... owners = sorted(owner.addresses[0].email
+ ... for owner in domain.owners)
+ ... for owner in owners:
+ ... print('- owner:', owner)
>>> show_domains()
no domains
@@ -28,17 +32,14 @@ Adding a domain requires some basic information, of which the email host name
is the only required piece. The other parts are inferred from that.
>>> manager.add('example.org')
- <Domain example.org, base_url: http://example.org,
- contact_address: postmaster@example.org>
+ <Domain example.org, base_url: http://example.org>
>>> show_domains()
- <Domain example.org, base_url: http://example.org,
- contact_address: postmaster@example.org>
+ <Domain example.org, base_url: http://example.org>
We can remove domains too.
>>> manager.remove('example.org')
- <Domain example.org, base_url: http://example.org,
- contact_address: postmaster@example.org>
+ <Domain example.org, base_url: http://example.org>
>>> show_domains()
no domains
@@ -46,30 +47,39 @@ Sometimes the email host name is different than the base url for hitting the
web interface for the domain.
>>> manager.add('example.com', base_url='https://mail.example.com')
- <Domain example.com, base_url: https://mail.example.com,
- contact_address: postmaster@example.com>
+ <Domain example.com, base_url: https://mail.example.com>
>>> show_domains()
- <Domain example.com, base_url: https://mail.example.com,
- contact_address: postmaster@example.com>
+ <Domain example.com, base_url: https://mail.example.com>
-Domains can have explicit descriptions and contact addresses.
+Domains can have explicit descriptions, and can be created with one or more
+owners.
::
>>> manager.add(
... 'example.net',
... base_url='http://lists.example.net',
- ... contact_address='postmaster@example.com',
- ... description='The example domain')
+ ... description='The example domain',
+ ... owners=['anne@example.com'])
<Domain example.net, The example domain,
- base_url: http://lists.example.net,
- contact_address: postmaster@example.com>
+ base_url: http://lists.example.net>
- >>> show_domains()
- <Domain example.com, base_url: https://mail.example.com,
- contact_address: postmaster@example.com>
+ >>> show_domains(with_owners=True)
+ <Domain example.com, base_url: https://mail.example.com>
<Domain example.net, The example domain,
- base_url: http://lists.example.net,
- contact_address: postmaster@example.com>
+ base_url: http://lists.example.net>
+ - owner: anne@example.com
+
+Domains can have multiple owners, ideally one of the owners should have a
+verified preferred address. However this is not checked right now and the
+configuration's default contact address may be used as a fallback.
+
+ >>> net_domain = manager['example.net']
+ >>> net_domain.add_owner('bart@example.org')
+ >>> show_domains(with_owners=True)
+ <Domain example.com, base_url: https://mail.example.com>
+ <Domain example.net, The example domain, base_url: http://lists.example.net>
+ - owner: anne@example.com
+ - owner: bart@example.org
Domains can list all associated mailing lists with the mailing_lists property.
::
@@ -105,8 +115,7 @@ In the global domain manager, domains are indexed by their email host name.
>>> print(manager['example.net'])
<Domain example.net, The example domain,
- base_url: http://lists.example.net,
- contact_address: postmaster@example.com>
+ base_url: http://lists.example.net>
As with dictionaries, you can also get the domain. If the domain does not
exist, ``None`` or a default is returned.
@@ -114,8 +123,7 @@ exist, ``None`` or a default is returned.
>>> print(manager.get('example.net'))
<Domain example.net, The example domain,
- base_url: http://lists.example.net,
- contact_address: postmaster@example.com>
+ base_url: http://lists.example.net>
>>> print(manager.get('doesnotexist.com'))
None
diff --git a/src/mailman/model/docs/membership.rst b/src/mailman/model/docs/membership.rst
index 60ccd1ac1..0fd748d6a 100644
--- a/src/mailman/model/docs/membership.rst
+++ b/src/mailman/model/docs/membership.rst
@@ -228,6 +228,38 @@ regardless of their role.
fperson@example.com MemberRole.nonmember
+Subscriber type
+===============
+
+Members can be subscribed to a mailing list either via an explicit address, or
+indirectly through a user's preferred address. Sometimes you want to know
+which one it is.
+
+Herb subscribes to the mailing list via an explicit address.
+
+ >>> herb = user_manager.create_address(
+ ... 'hperson@example.com', 'Herb Person')
+ >>> herb_member = mlist.subscribe(herb)
+
+Iris subscribes to the mailing list via her preferred address.
+
+ >>> iris = user_manager.make_user(
+ ... 'iperson@example.com', 'Iris Person')
+ >>> preferred = list(iris.addresses)[0]
+ >>> from mailman.utilities.datetime import now
+ >>> preferred.verified_on = now()
+ >>> iris.preferred_address = preferred
+ >>> iris_member = mlist.subscribe(iris)
+
+When we need to know which way a member is subscribed, we can look at the this
+attribute.
+
+ >>> herb_member.subscriber
+ <Address: Herb Person <hperson@example.com> [not verified] at ...>
+ >>> iris_member.subscriber
+ <User "Iris Person" (5) at ...>
+
+
Moderation actions
==================
@@ -250,6 +282,8 @@ should go through the normal moderation checks.
aperson@example.com MemberRole.member Action.defer
bperson@example.com MemberRole.member Action.defer
cperson@example.com MemberRole.member Action.defer
+ hperson@example.com MemberRole.member Action.defer
+ iperson@example.com MemberRole.member Action.defer
Postings by nonmembers are held for moderator approval by default.
@@ -272,7 +306,7 @@ though that the address they're changing to must be verified.
>>> gwen_member = bee.subscribe(gwen_address)
>>> for m in bee.members.members:
... print(m.member_id.int, m.mailing_list.list_id, m.address.email)
- 7 bee.example.com gwen@example.com
+ 9 bee.example.com gwen@example.com
Gwen gets a email address.
@@ -288,7 +322,7 @@ Now her membership reflects the new address.
>>> for m in bee.members.members:
... print(m.member_id.int, m.mailing_list.list_id, m.address.email)
- 7 bee.example.com gperson@example.com
+ 9 bee.example.com gperson@example.com
Events
diff --git a/src/mailman/model/docs/pending.rst b/src/mailman/model/docs/pending.rst
index a634322a1..03eee3772 100644
--- a/src/mailman/model/docs/pending.rst
+++ b/src/mailman/model/docs/pending.rst
@@ -13,6 +13,11 @@ In order to pend an event, you first need a pending database.
>>> from zope.component import getUtility
>>> pendingdb = getUtility(IPendings)
+There are nothing in the pendings database.
+
+ >>> pendingdb.count
+ 0
+
The pending database can add any ``IPendable`` to the database, returning a
token that can be used in urls and such.
::
@@ -33,10 +38,14 @@ token that can be used in urls and such.
>>> len(token)
40
-There's not much you can do with tokens except to *confirm* them, which
-basically means returning the `IPendable` structure (as a dictionary) from the
-database that matches the token. If the token isn't in the database, None is
-returned.
+There's exactly one entry in the pendings database now.
+
+ >>> pendingdb.count
+ 1
+
+You can *confirm* the pending, which means returning the `IPendable` structure
+(as a dictionary) from the database that matches the token. If the token
+isn't in the database, None is returned.
>>> pendable = pendingdb.confirm(b'missing')
>>> print(pendable)
@@ -73,6 +82,18 @@ expunge it.
>>> print(pendingdb.confirm(token_1))
None
+You can iterate over all the pendings in the database.
+
+ >>> pendables = list(pendingdb)
+ >>> def sort_key(item):
+ ... token, pendable = item
+ ... return pendable['type']
+ >>> sorted_pendables = sorted(pendables, key=sort_key)
+ >>> for token, pendable in sorted_pendables:
+ ... print(pendable['type'])
+ three
+ two
+
An event can be given a lifetime when it is pended, otherwise it just uses a
default lifetime.
diff --git a/src/mailman/model/docs/registration.rst b/src/mailman/model/docs/registration.rst
index 55e99f23a..4b1e13520 100644
--- a/src/mailman/model/docs/registration.rst
+++ b/src/mailman/model/docs/registration.rst
@@ -1,333 +1,100 @@
-====================
-Address registration
-====================
-
-Before users can join a mailing list, they must first register with Mailman.
-The only thing they must supply is an email address, although there is
-additional information they may supply. All registered email addresses must
-be verified before Mailman will send them any list traffic.
+============
+Registration
+============
-The ``IUserManager`` manages users, but it does so at a fairly low level.
-Specifically, it does not handle verification, email address syntax validity
-checks, etc. The ``IRegistrar`` is the interface to the object handling all
-this stuff.
+When a user wants to join a mailing list, they must register and verify their
+email address. Then depending on how the mailing list is configured, they may
+need to confirm their subscription and have it approved by the list
+moderator. The ``IRegistrar`` interface manages this work flow.
>>> from mailman.interfaces.registrar import IRegistrar
- >>> from zope.component import getUtility
- >>> registrar = getUtility(IRegistrar)
-
-Here is a helper function to check the token strings.
-
- >>> def check_token(token):
- ... assert isinstance(token, str), 'Not a string'
- ... assert len(token) == 40, 'Unexpected length: %d' % len(token)
- ... assert token.isalnum(), 'Not alphanumeric'
- ... print('ok')
-
-Here is a helper function to extract tokens from confirmation messages.
-
- >>> import re
- >>> cre = re.compile('http://lists.example.com/confirm/(.*)')
- >>> def extract_token(msg):
- ... mo = cre.search(msg.get_payload())
- ... return mo.group(1)
+Registrars adapt mailing lists.
-Invalid email addresses
-=======================
-
-Addresses are registered within the context of a mailing list, mostly so that
-confirmation emails can come from some place. You also need the email
-address of the user who is registering.
-
- >>> mlist = create_list('alpha@example.com')
+ >>> from mailman.interfaces.mailinglist import SubscriptionPolicy
+ >>> mlist = create_list('ant@example.com')
>>> mlist.send_welcome_message = False
+ >>> mlist.subscription_policy = SubscriptionPolicy.open
+ >>> registrar = IRegistrar(mlist)
-Some amount of sanity checks are performed on the email address, although
-honestly, not as much as probably should be done. Still, some patently bad
-addresses are rejected outright.
-
-
-Register an email address
-=========================
-
-Registration of an unknown address creates nothing until the confirmation step
-is complete. No ``IUser`` or ``IAddress`` is created at registration time,
-but a record is added to the pending database, and the token for that record
-is returned.
-
- >>> token = registrar.register(mlist, 'aperson@example.com', 'Anne Person')
- >>> check_token(token)
- ok
-
-There should be no records in the user manager for this address yet.
+Usually, addresses are registered, but users with preferred addresses can be
+registered too.
>>> from mailman.interfaces.usermanager import IUserManager
>>> from zope.component import getUtility
- >>> user_manager = getUtility(IUserManager)
- >>> print(user_manager.get_user('aperson@example.com'))
- None
- >>> print(user_manager.get_address('aperson@example.com'))
- None
-
-But this address is waiting for confirmation.
-
- >>> from mailman.interfaces.pending import IPendings
- >>> pendingdb = getUtility(IPendings)
-
- >>> dump_msgdata(pendingdb.confirm(token, expunge=False))
- delivery_mode: regular
- display_name : Anne Person
- email : aperson@example.com
- list_id : alpha.example.com
- type : registration
-
-
-Verification by email
-=====================
-
-There is also a verification email sitting in the virgin queue now. This
-message is sent to the user in order to verify the registered address.
-
- >>> from mailman.testing.helpers import get_queue_messages
- >>> items = get_queue_messages('virgin')
- >>> len(items)
- 1
- >>> print(items[0].msg.as_string())
- MIME-Version: 1.0
- ...
- Subject: confirm ...
- From: alpha-confirm+...@example.com
- To: aperson@example.com
- ...
- <BLANKLINE>
- Email Address Registration Confirmation
- <BLANKLINE>
- Hello, this is the GNU Mailman server at example.com.
- <BLANKLINE>
- We have received a registration request for the email address
- <BLANKLINE>
- aperson@example.com
- <BLANKLINE>
- Before you can start using GNU Mailman at this site, you must first
- confirm that this is your email address. You can do this by replying to
- this message, keeping the Subject header intact. Or you can visit this
- web page
- <BLANKLINE>
- http://lists.example.com/confirm/...
- <BLANKLINE>
- If you do not wish to register this email address simply disregard this
- message. If you think you are being maliciously subscribed to the list,
- or have any other questions, you may contact
- <BLANKLINE>
- postmaster@example.com
- <BLANKLINE>
- >>> dump_msgdata(items[0].msgdata)
- _parsemsg : False
- listid : alpha.example.com
- nodecorate : True
- recipients : {'aperson@example.com'}
- reduced_list_headers: True
- version : 3
-
-The confirmation token shows up in several places, each of which provides an
-easy way for the user to complete the confirmation. The token will always
-appear in a URL in the body of the message.
-
- >>> sent_token = extract_token(items[0].msg)
- >>> sent_token == token
- True
-
-The same token will appear in the ``From`` header.
+ >>> anne = getUtility(IUserManager).create_address(
+ ... 'anne@example.com', 'Anne Person')
- >>> items[0].msg['from'] == 'alpha-confirm+' + token + '@example.com'
- True
-It will also appear in the ``Subject`` header.
-
- >>> items[0].msg['subject'] == 'confirm ' + token
- True
-
-The user would then validate their registered address by clicking on a url or
-responding to the message. Either way, the confirmation process extracts the
-token and uses that to confirm the pending registration.
-
- >>> registrar.confirm(token)
- True
-
-Now, there is an `IAddress` in the database matching the address, as well as
-an `IUser` linked to this address. The `IAddress` is verified.
-
- >>> found_address = user_manager.get_address('aperson@example.com')
- >>> found_address
- <Address: Anne Person <aperson@example.com> [verified] at ...>
- >>> found_user = user_manager.get_user('aperson@example.com')
- >>> found_user
- <User "Anne Person" (...) at ...>
- >>> found_user.controls(found_address.email)
- True
- >>> from datetime import datetime
- >>> isinstance(found_address.verified_on, datetime)
- True
-
-
-Non-standard registrations
-==========================
-
-If you try to confirm a registration token twice, of course only the first one
-will work. The second one is ignored.
-
- >>> token = registrar.register(mlist, 'bperson@example.com')
- >>> check_token(token)
- ok
- >>> items = get_queue_messages('virgin')
- >>> len(items)
- 1
- >>> sent_token = extract_token(items[0].msg)
- >>> token == sent_token
- True
- >>> registrar.confirm(token)
- True
- >>> registrar.confirm(token)
- False
-
-If an address is in the system, but that address is not linked to a user yet
-and the address is not yet validated, then no user is created until the
-confirmation step is completed.
-
- >>> user_manager.create_address('cperson@example.com')
- <Address: cperson@example.com [not verified] at ...>
- >>> token = registrar.register(
- ... mlist, 'cperson@example.com', 'Claire Person')
- >>> print(user_manager.get_user('cperson@example.com'))
- None
- >>> items = get_queue_messages('virgin')
- >>> len(items)
- 1
- >>> sent_token = extract_token(items[0].msg)
- >>> registrar.confirm(sent_token)
- True
- >>> user_manager.get_user('cperson@example.com')
- <User "Claire Person" (...) at ...>
- >>> user_manager.get_address('cperson@example.com')
- <Address: cperson@example.com [verified] at ...>
-
-Even if the address being registered has already been verified, the
-registration sends a confirmation.
+Register an email address
+=========================
- >>> token = registrar.register(mlist, 'cperson@example.com')
- >>> token is not None
- True
+When the registration steps involve confirmation or moderator approval, the
+process will pause until these steps are completed. A unique token is created
+which represents this work flow.
+Anne attempts to join the mailing list.
-Discarding
-==========
+ >>> token, token_owner, member = registrar.register(anne)
-A confirmation token can also be discarded, say if the user changes his or her
-mind about registering. When discarded, no `IAddress` or `IUser` is created.
-::
+Because her email address has not yet been verified, she has not yet become a
+member of the mailing list.
- >>> token = registrar.register(mlist, 'eperson@example.com', 'Elly Person')
- >>> check_token(token)
- ok
- >>> registrar.discard(token)
- >>> print(pendingdb.confirm(token))
- None
- >>> print(user_manager.get_address('eperson@example.com'))
+ >>> print(member)
None
- >>> print(user_manager.get_user('eperson@example.com'))
+ >>> print(mlist.members.get_member('anne@example.com'))
None
- # Clear the virgin queue of all the preceding confirmation messages.
- >>> ignore = get_queue_messages('virgin')
+Once she verifies her email address, she will become a member of the mailing
+list. In this case, verifying implies that she also confirms her wish to join
+the mailing list.
+ >>> token, token_owner, member = registrar.confirm(token)
+ >>> member
+ <Member: Anne Person <anne@example.com> on ant@example.com
+ as MemberRole.member>
+ >>> mlist.members.get_member('anne@example.com')
+ <Member: Anne Person <anne@example.com> on ant@example.com
+ as MemberRole.member>
-Registering a new address for an existing user
-==============================================
-When a new address for an existing user is registered, there isn't too much
-different except that the new address will still need to be verified before it
-can be used.
-::
-
- >>> from mailman.utilities.datetime import now
- >>> dperson = user_manager.create_user(
- ... 'dperson@example.com', 'Dave Person')
- >>> dperson
- <User "Dave Person" (...) at ...>
- >>> address = user_manager.get_address('dperson@example.com')
- >>> address.verified_on = now()
+Register a user
+===============
- >>> from operator import attrgetter
- >>> dump_list(repr(address) for address in dperson.addresses)
- <Address: Dave Person <dperson@example.com> [verified] at ...>
- >>> dperson.register('david.person@example.com', 'David Person')
- <Address: David Person <david.person@example.com> [not verified] at ...>
- >>> token = registrar.register(mlist, 'david.person@example.com')
+Users can also register, but they must have a preferred address. The mailing
+list will deliver messages to this preferred address.
- >>> items = get_queue_messages('virgin')
- >>> len(items)
- 1
- >>> sent_token = extract_token(items[0].msg)
- >>> registrar.confirm(sent_token)
- True
- >>> user = user_manager.get_user('david.person@example.com')
- >>> user is dperson
- True
- >>> user
- <User "Dave Person" (...) at ...>
- >>> dump_list(repr(address) for address in user.addresses)
- <Address: Dave Person <dperson@example.com> [verified] at ...>
- <Address: David Person <david.person@example.com> [verified] at ...>
-
-
-Corner cases
-============
+ >>> bart = getUtility(IUserManager).make_user(
+ ... 'bart@example.com', 'Bart Person')
-If you try to confirm a token that doesn't exist in the pending database, the
-confirm method will just return False.
+Bart verifies his address and makes it his preferred address.
- >>> registrar.confirm(bytes(b'no token'))
- False
+ >>> from mailman.utilities.datetime import now
+ >>> preferred = list(bart.addresses)[0]
+ >>> preferred.verified_on = now()
+ >>> bart.preferred_address = preferred
-Likewise, if you try to confirm, through the `IUserRegistrar` interface, a
-token that doesn't match a registration event, you will get ``None``.
-However, the pending event matched with that token will still be removed.
-::
+The mailing list's subscription policy does not require Bart to confirm his
+subscription, but the moderate does want to approve all subscriptions.
- >>> from mailman.interfaces.pending import IPendable
- >>> from zope.interface import implementer
+ >>> mlist.subscription_policy = SubscriptionPolicy.moderate
- >>> @implementer(IPendable)
- ... class SimplePendable(dict):
- ... pass
+Now when Bart registers as a user for the mailing list, a token will still be
+generated, but this is only used by the moderator. At first, Bart is not
+subscribed to the mailing list.
- >>> pendable = SimplePendable(type='foo', bar='baz')
- >>> token = pendingdb.add(pendable)
- >>> registrar.confirm(token)
- False
- >>> print(pendingdb.confirm(token))
+ >>> token, token_owner, member = registrar.register(bart)
+ >>> print(member)
None
-
-
-Registration and subscription
-=============================
-
-Fred registers with Mailman at the same time that he subscribes to a mailing
-list.
-
- >>> token = registrar.register(
- ... mlist, 'fred.person@example.com', 'Fred Person')
-
-Before confirmation, Fred is not a member of the mailing list.
-
- >>> print(mlist.members.get_member('fred.person@example.com'))
+ >>> print(mlist.members.get_member('bart@example.com'))
None
-But after confirmation, he is.
+When the moderator confirms Bart's subscription, he joins the mailing list.
- >>> registrar.confirm(token)
- True
- >>> print(mlist.members.get_member('fred.person@example.com'))
- <Member: Fred Person <fred.person@example.com>
- on alpha@example.com as MemberRole.member>
+ >>> token, token_owner, member = registrar.confirm(token)
+ >>> member
+ <Member: Bart Person <bart@example.com> on ant@example.com
+ as MemberRole.member>
+ >>> mlist.members.get_member('bart@example.com')
+ <Member: Bart Person <bart@example.com> on ant@example.com
+ as MemberRole.member>
diff --git a/src/mailman/model/docs/usermanager.rst b/src/mailman/model/docs/usermanager.rst
index ba328b54b..8e40b621e 100644
--- a/src/mailman/model/docs/usermanager.rst
+++ b/src/mailman/model/docs/usermanager.rst
@@ -179,3 +179,25 @@ There are now four members in the system. Sort them by address then role.
test.example.com bperson@example.com MemberRole.owner
test.example.com eperson@example.com MemberRole.member
test.example.com fperson@example.com MemberRole.member
+
+
+Creating a new user
+===================
+
+A common situation (especially during the subscription life cycle) is to
+create a user linked to an address, with a preferred address. Say for
+example, we are asked to subscribe a new address we have never seen before.
+
+ >>> cris = user_manager.make_user('cris@example.com', 'Cris Person')
+
+Since we've never seen ``cris@example.com`` before, this call creates a new
+user with the given email and display name.
+
+ >>> cris
+ <User "Cris Person" (5) at ...>
+
+The user has a single unverified address object.
+
+ >>> for address in cris.addresses:
+ ... print(repr(address))
+ <Address: Cris Person <cris@example.com> [not verified] at ...>
diff --git a/src/mailman/model/docs/users.rst b/src/mailman/model/docs/users.rst
index 0b926d6a7..0d6a0f368 100644
--- a/src/mailman/model/docs/users.rst
+++ b/src/mailman/model/docs/users.rst
@@ -295,4 +295,19 @@ membership role.
zperson@example.org xtest_2.example.com MemberRole.owner
+Server owners
+=============
+
+Some users are server owners. Zoe is not yet a server owner.
+
+ >>> user_1.is_server_owner
+ False
+
+So, let's make her one.
+
+ >>> user_1.is_server_owner = True
+ >>> user_1.is_server_owner
+ True
+
+
.. _`usermanager.txt`: usermanager.html
diff --git a/src/mailman/model/domain.py b/src/mailman/model/domain.py
index 9e627c119..40298c719 100644
--- a/src/mailman/model/domain.py
+++ b/src/mailman/model/domain.py
@@ -28,11 +28,15 @@ from mailman.database.transaction import dbconnection
from mailman.interfaces.domain import (
BadDomainSpecificationError, DomainCreatedEvent, DomainCreatingEvent,
DomainDeletedEvent, DomainDeletingEvent, IDomain, IDomainManager)
+from mailman.interfaces.user import IUser
+from mailman.interfaces.usermanager import IUserManager
from mailman.model.mailinglist import MailingList
from urllib.parse import urljoin, urlparse
from sqlalchemy import Column, Integer, Unicode
+from sqlalchemy.orm import relationship
from zope.event import notify
from zope.interface import implementer
+from zope.component import getUtility
@@ -44,15 +48,17 @@ class Domain(Model):
id = Column(Integer, primary_key=True)
- mail_host = Column(Unicode) # TODO: add index?
+ mail_host = Column(Unicode)
base_url = Column(Unicode)
description = Column(Unicode)
- contact_address = Column(Unicode)
+ owners = relationship('User',
+ secondary='domain_owner',
+ backref='domains')
def __init__(self, mail_host,
description=None,
base_url=None,
- contact_address=None):
+ owners=None):
"""Create and register a domain.
:param mail_host: The host name for the email interface.
@@ -63,18 +69,16 @@ class Domain(Model):
scheme. If not given, it will be constructed from the
`mail_host` using the http protocol.
:type base_url: string
- :param contact_address: The email address to contact a human for this
- domain. If not given, postmaster@`mail_host` will be used.
- :type contact_address: string
+ :param owners: Optional owners of this domain.
+ :type owners: sequence of `IUser` or string emails.
"""
self.mail_host = mail_host
self.base_url = (base_url
if base_url is not None
else 'http://' + mail_host)
self.description = description
- self.contact_address = (contact_address
- if contact_address is not None
- else 'postmaster@' + mail_host)
+ if owners is not None:
+ self.add_owners(owners)
@property
def url_host(self):
@@ -103,12 +107,35 @@ class Domain(Model):
def __repr__(self):
"""repr(a_domain)"""
if self.description is None:
- return ('<Domain {0.mail_host}, base_url: {0.base_url}, '
- 'contact_address: {0.contact_address}>').format(self)
+ return ('<Domain {0.mail_host}, base_url: {0.base_url}>').format(
+ self)
else:
return ('<Domain {0.mail_host}, {0.description}, '
- 'base_url: {0.base_url}, '
- 'contact_address: {0.contact_address}>').format(self)
+ 'base_url: {0.base_url}>').format(self)
+
+ def add_owner(self, owner):
+ """See `IDomain`."""
+ user_manager = getUtility(IUserManager)
+ if IUser.providedBy(owner):
+ user = owner
+ else:
+ user = user_manager.get_user(owner)
+ # BAW 2015-04-06: Make sure this path is tested.
+ if user is None:
+ user = user_manager.create_user(owner)
+ self.owners.append(user)
+
+ def add_owners(self, owners):
+ """See `IDomain`."""
+ # BAW 2015-04-06: This should probably be more efficient by inlining
+ # add_owner().
+ for owner in owners:
+ self.add_owner(owner)
+
+ def remove_owner(self, owner):
+ """See `IDomain`."""
+ user_manager = getUtility(IUserManager)
+ self.owners.remove(user_manager.get_user(owner))
@@ -121,7 +148,7 @@ class DomainManager:
mail_host,
description=None,
base_url=None,
- contact_address=None):
+ owners=None):
"""See `IDomainManager`."""
# Be sure the mail_host is not already registered. This is probably
# a constraint that should (also) be maintained in the database.
@@ -129,7 +156,7 @@ class DomainManager:
raise BadDomainSpecificationError(
'Duplicate email host: %s' % mail_host)
notify(DomainCreatingEvent(mail_host))
- domain = Domain(mail_host, description, base_url, contact_address)
+ domain = Domain(mail_host, description, base_url, owners)
store.add(domain)
notify(DomainCreatedEvent(domain))
return domain
diff --git a/src/mailman/model/listmanager.py b/src/mailman/model/listmanager.py
index be0e153a3..8fc739543 100644
--- a/src/mailman/model/listmanager.py
+++ b/src/mailman/model/listmanager.py
@@ -27,7 +27,7 @@ from mailman.interfaces.address import InvalidEmailAddressError
from mailman.interfaces.listmanager import (
IListManager, ListAlreadyExistsError, ListCreatedEvent, ListCreatingEvent,
ListDeletedEvent, ListDeletingEvent)
-from mailman.model.mailinglist import MailingList
+from mailman.model.mailinglist import IAcceptableAliasSet, MailingList
from mailman.model.mime import ContentFilter
from mailman.utilities.datetime import now
from zope.event import notify
@@ -74,6 +74,8 @@ class ListManager:
"""See `IListManager`."""
fqdn_listname = mlist.fqdn_listname
notify(ListDeletingEvent(mlist))
+ # First delete information associated with the mailing list.
+ IAcceptableAliasSet(mlist).clear()
store.query(ContentFilter).filter_by(mailing_list=mlist).delete()
store.delete(mlist)
notify(ListDeletedEvent(fqdn_listname))
diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py
index a204d54cd..f04c534e1 100644
--- a/src/mailman/model/mailinglist.py
+++ b/src/mailman/model/mailinglist.py
@@ -38,7 +38,7 @@ from mailman.interfaces.domain import IDomainManager
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.mailinglist import (
IAcceptableAlias, IAcceptableAliasSet, IListArchiver, IListArchiverSet,
- IMailingList, Personalization, ReplyToMunging)
+ IMailingList, Personalization, ReplyToMunging, SubscriptionPolicy)
from mailman.interfaces.member import (
AlreadySubscribedError, MemberRole, MissingPreferredAddressError,
SubscriptionEvent)
@@ -183,6 +183,7 @@ class MailingList(Model):
send_goodbye_message = Column(Boolean)
send_welcome_message = Column(Boolean)
subject_prefix = Column(Unicode)
+ subscription_policy = Column(Enum(SubscriptionPolicy))
topics = Column(PickleType)
topics_bodylines_limit = Column(Integer)
topics_enabled = Column(Boolean)
@@ -455,6 +456,8 @@ class MailingList(Model):
return self.owners
elif role is MemberRole.moderator:
return self.moderators
+ elif role is MemberRole.nonmember:
+ return self.nonmembers
else:
raise TypeError('Undefined MemberRole: {}'.format(role))
@@ -504,10 +507,11 @@ class AcceptableAlias(Model):
mailing_list_id = Column(
Integer, ForeignKey('mailinglist.id'),
index=True, nullable=False)
- mailing_list = relationship('MailingList', backref='acceptable_alias')
+ mailing_list = relationship('MailingList', backref='acceptablealias')
alias = Column(Unicode, index=True, nullable=False)
def __init__(self, mailing_list, alias):
+ super(AcceptableAlias, self).__init__()
self.mailing_list = mailing_list
self.alias = alias
diff --git a/src/mailman/model/member.py b/src/mailman/model/member.py
index ee6d246f5..e6e4933f9 100644
--- a/src/mailman/model/member.py
+++ b/src/mailman/model/member.py
@@ -135,6 +135,10 @@ class Member(Model):
if self._address is None
else getUtility(IUserManager).get_user(self._address.email))
+ @property
+ def subscriber(self):
+ return (self._user if self._address is None else self._address)
+
def _lookup(self, preference, default=None):
pref = getattr(self.preferences, preference)
if pref is not None:
diff --git a/src/mailman/model/pending.py b/src/mailman/model/pending.py
index 77b68bd2d..04b63f2ca 100644
--- a/src/mailman/model/pending.py
+++ b/src/mailman/model/pending.py
@@ -128,7 +128,7 @@ class Pendings:
return token
@dbconnection
- def confirm(self, store, token, expunge=True):
+ def confirm(self, store, token, *, expunge=True):
# Token can come in as a unicode, but it's stored in the database as
# bytes. They must be ascii.
pendings = store.query(Pended).filter_by(token=str(token))
@@ -165,3 +165,13 @@ class Pendings:
for keyvalue in q:
store.delete(keyvalue)
store.delete(pending)
+
+ @dbconnection
+ def __iter__(self, store):
+ for pending in store.query(Pended).all():
+ yield pending.token, self.confirm(pending.token, expunge=False)
+
+ @property
+ @dbconnection
+ def count(self, store):
+ return store.query(Pended).count()
diff --git a/src/mailman/model/roster.py b/src/mailman/model/roster.py
index 91211c665..da2ed4582 100644
--- a/src/mailman/model/roster.py
+++ b/src/mailman/model/roster.py
@@ -97,21 +97,48 @@ class AbstractRoster:
yield member.address
@dbconnection
- def get_member(self, store, address):
- """See `IRoster`."""
- results = store.query(Member).filter(
+ def _get_all_memberships(self, store, email):
+ # Avoid circular imports.
+ from mailman.model.user import User
+ # Here's a query that finds all members subscribed with an explicit
+ # email address.
+ members_a = store.query(Member).filter(
Member.list_id == self._mlist.list_id,
Member.role == self.role,
- Address.email == address,
+ Address.email == email,
Member.address_id == Address.id)
- if results.count() == 0:
+ # Here's a query that finds all members subscribed with their
+ # preferred address.
+ members_u = store.query(Member).filter(
+ Member.list_id == self._mlist.list_id,
+ Member.role == self.role,
+ Address.email==email,
+ Member.user_id == User.id)
+ return members_a.union(members_u).all()
+
+ def get_member(self, email):
+ """See ``IRoster``."""
+ memberships = self._get_all_memberships(email)
+ count = len(memberships)
+ if count == 0:
return None
- elif results.count() == 1:
- return results[0]
- else:
- raise AssertionError(
- 'Too many matching member results: {0}'.format(
- results.count()))
+ elif count == 1:
+ return memberships[0]
+ assert count == 2, 'Unexpected membership count: {}'.format(count)
+ # This is the case where the email address is subscribed both
+ # explicitly and indirectly through the preferred address. By
+ # definition, we return the explicit address membership only.
+ return (memberships[0]
+ if memberships[0]._address is not None
+ else memberships[1])
+
+ def get_memberships(self, email):
+ """See ``IRoster``."""
+ memberships = self._get_all_memberships(email)
+ count = len(memberships)
+ assert 0 <= count <= 2, 'Unexpected membership count: {}'.format(
+ count)
+ return memberships
@@ -160,13 +187,13 @@ class AdministratorRoster(AbstractRoster):
Member.role == MemberRole.moderator))
@dbconnection
- def get_member(self, store, address):
+ def get_member(self, store, email):
"""See `IRoster`."""
results = store.query(Member).filter(
Member.list_id == self._mlist.list_id,
or_(Member.role == MemberRole.moderator,
Member.role == MemberRole.owner),
- Address.email == address,
+ Address.email == email,
Member.address_id == Address.id)
if results.count() == 0:
return None
@@ -181,6 +208,8 @@ class AdministratorRoster(AbstractRoster):
class DeliveryMemberRoster(AbstractRoster):
"""Return all the members having a particular kind of delivery."""
+ role = MemberRole.member
+
@property
def member_count(self):
"""See `IRoster`."""
@@ -285,7 +314,7 @@ class Memberships:
yield address
@dbconnection
- def get_member(self, store, address):
+ def get_member(self, store, email):
"""See `IRoster`."""
results = store.query(Member).filter(
Member.address_id == Address.id,
@@ -298,3 +327,10 @@ class Memberships:
raise AssertionError(
'Too many matching member results: {0}'.format(
results.count()))
+
+ @dbconnection
+ def get_memberships(self, store, address):
+ """See `IRoster`."""
+ # 2015-04-14 BAW: See LP: #1444055 -- this currently exists just to
+ # pass a test.
+ raise NotImplementedError
diff --git a/src/mailman/model/tests/test_domain.py b/src/mailman/model/tests/test_domain.py
index b4a6dd75c..afde6cd53 100644
--- a/src/mailman/model/tests/test_domain.py
+++ b/src/mailman/model/tests/test_domain.py
@@ -30,6 +30,7 @@ from mailman.interfaces.domain import (
DomainCreatedEvent, DomainCreatingEvent, DomainDeletedEvent,
DomainDeletingEvent, IDomainManager)
from mailman.interfaces.listmanager import IListManager
+from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import event_subscribers
from mailman.testing.layers import ConfigLayer
from zope.component import getUtility
@@ -78,6 +79,98 @@ class TestDomainManager(unittest.TestCase):
# Trying to delete a missing domain gives you a KeyError.
self.assertRaises(KeyError, self._manager.remove, 'doesnotexist.com')
+ def test_domain_creation_no_default_owners(self):
+ # If a domain is created without owners, then it has none.
+ domain = self._manager.add('example.org')
+ self.assertEqual(len(domain.owners), 0)
+
+ def test_domain_creation_with_owner(self):
+ # You can create a new domain with a single owner.
+ domain = self._manager.add('example.org', owners=['anne@example.org'])
+ self.assertEqual(len(domain.owners), 1)
+ self.assertEqual(domain.owners[0].addresses[0].email,
+ 'anne@example.org')
+
+ def test_domain_creation_with_owners(self):
+ # You can create a new domain with multiple owners.
+ domain = self._manager.add(
+ 'example.org', owners=['anne@example.org',
+ 'bart@example.net'])
+ self.assertEqual(len(domain.owners), 2)
+ self.assertEqual(
+ sorted(owner.addresses[0].email for owner in domain.owners),
+ ['anne@example.org', 'bart@example.net'])
+
+ def test_domain_creation_creates_new_users(self):
+ # Domain creation with existing users does not create new users, but
+ # any user which doesn't yet exist (and is linked to the given
+ # address), gets created.
+ user_manager = getUtility(IUserManager)
+ user_manager.make_user('anne@example.com')
+ user_manager.make_user('bart@example.com')
+ domain = self._manager.add(
+ 'example.org', owners=['anne@example.com',
+ 'bart@example.com',
+ 'cris@example.com'])
+ self.assertEqual(len(domain.owners), 3)
+ self.assertEqual(
+ sorted(owner.addresses[0].email for owner in domain.owners),
+ ['anne@example.com', 'bart@example.com', 'cris@example.com'])
+ # Now cris exists as a user.
+ self.assertIsNotNone(user_manager.get_user('cris@example.com'))
+
+ def test_domain_creation_with_users(self):
+ # Domains can be created with IUser objects.
+ user_manager = getUtility(IUserManager)
+ anne = user_manager.make_user('anne@example.com')
+ bart = user_manager.make_user('bart@example.com')
+ domain = self._manager.add('example.org', owners=[anne, bart])
+ self.assertEqual(len(domain.owners), 2)
+ self.assertEqual(
+ sorted(owner.addresses[0].email for owner in domain.owners),
+ ['anne@example.com', 'bart@example.com'])
+ def sort_key(owner):
+ return owner.addresses[0].email
+ self.assertEqual(sorted(domain.owners, key=sort_key), [anne, bart])
+
+ def test_add_domain_owner(self):
+ # Domain owners can be added after the domain is created.
+ domain = self._manager.add('example.org')
+ self.assertEqual(len(domain.owners), 0)
+ domain.add_owner('anne@example.org')
+ self.assertEqual(len(domain.owners), 1)
+ self.assertEqual(domain.owners[0].addresses[0].email,
+ 'anne@example.org')
+
+ def test_add_multiple_domain_owners(self):
+ # Multiple domain owners can be added after the domain is created.
+ domain = self._manager.add('example.org')
+ self.assertEqual(len(domain.owners), 0)
+ domain.add_owners(['anne@example.org', 'bart@example.net'])
+ self.assertEqual(len(domain.owners), 2)
+ self.assertEqual([owner.addresses[0].email for owner in domain.owners],
+ ['anne@example.org', 'bart@example.net'])
+
+ def test_remove_domain_owner(self):
+ # Domain onwers can be removed.
+ domain = self._manager.add(
+ 'example.org', owners=['anne@example.org',
+ 'bart@example.net'])
+ domain.remove_owner('anne@example.org')
+ self.assertEqual(len(domain.owners), 1)
+ self.assertEqual([owner.addresses[0].email for owner in domain.owners],
+ ['bart@example.net'])
+
+ def test_remove_missing_owner(self):
+ # Users which aren't owners can't be removed.
+ domain = self._manager.add(
+ 'example.org', owners=['anne@example.org',
+ 'bart@example.net'])
+ self.assertRaises(ValueError, domain.remove_owner, 'cris@example.org')
+ self.assertEqual(len(domain.owners), 2)
+ self.assertEqual([owner.addresses[0].email for owner in domain.owners],
+ ['anne@example.org', 'bart@example.net'])
+
class TestDomainLifecycleEvents(unittest.TestCase):
diff --git a/src/mailman/model/tests/test_mailinglist.py b/src/mailman/model/tests/test_mailinglist.py
index 843918e5e..745096b4b 100644
--- a/src/mailman/model/tests/test_mailinglist.py
+++ b/src/mailman/model/tests/test_mailinglist.py
@@ -18,6 +18,7 @@
"""Test MailingLists and related model objects.."""
__all__ = [
+ 'TestAcceptableAliases',
'TestDisabledListArchiver',
'TestListArchiver',
'TestMailingList',
@@ -28,7 +29,10 @@ import unittest
from mailman.app.lifecycle import create_list
from mailman.config import config
-from mailman.interfaces.mailinglist import IListArchiverSet
+from mailman.database.transaction import transaction
+from mailman.interfaces.listmanager import IListManager
+from mailman.interfaces.mailinglist import (
+ IAcceptableAliasSet, IListArchiverSet)
from mailman.interfaces.member import (
AlreadySubscribedError, MemberRole, MissingPreferredAddressError)
from mailman.interfaces.usermanager import IUserManager
@@ -141,3 +145,21 @@ class TestDisabledListArchiver(unittest.TestCase):
archiver = archiver_set.get('prototype')
self.assertTrue(archiver.is_enabled)
config.pop('enable prototype')
+
+
+
+class TestAcceptableAliases(unittest.TestCase):
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._mlist = create_list('ant@example.com')
+
+ def test_delete_list_with_acceptable_aliases(self):
+ # LP: #1432239 - deleting a mailing list with acceptable aliases
+ # causes a SQLAlchemy error. The aliases must be deleted first.
+ with transaction():
+ alias_set = IAcceptableAliasSet(self._mlist)
+ alias_set.add('bee@example.com')
+ self.assertEqual(['bee@example.com'], list(alias_set.aliases))
+ getUtility(IListManager).delete(self._mlist)
+ self.assertEqual(len(list(alias_set.aliases)), 0)
diff --git a/src/mailman/model/tests/test_registrar.py b/src/mailman/model/tests/test_registrar.py
deleted file mode 100644
index e6df7f0d1..000000000
--- a/src/mailman/model/tests/test_registrar.py
+++ /dev/null
@@ -1,64 +0,0 @@
-# Copyright (C) 2014-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/>.
-
-"""Test `IRegistrar`."""
-
-__all__ = [
- 'TestRegistrar',
- ]
-
-
-import unittest
-
-from functools import partial
-from mailman.app.lifecycle import create_list
-from mailman.interfaces.address import InvalidEmailAddressError
-from mailman.interfaces.registrar import IRegistrar
-from mailman.testing.layers import ConfigLayer
-from zope.component import getUtility
-
-
-
-class TestRegistrar(unittest.TestCase):
- layer = ConfigLayer
-
- def setUp(self):
- mlist = create_list('test@example.com')
- self._register = partial(getUtility(IRegistrar).register, mlist)
-
- def test_invalid_empty_string(self):
- self.assertRaises(InvalidEmailAddressError, self._register, '')
-
- def test_invalid_space_in_name(self):
- self.assertRaises(InvalidEmailAddressError, self._register,
- 'some name@example.com')
-
- def test_invalid_funky_characters(self):
- self.assertRaises(InvalidEmailAddressError, self._register,
- '<script>@example.com')
-
- def test_invalid_nonascii(self):
- self.assertRaises(InvalidEmailAddressError, self._register,
- '\xa0@example.com')
-
- def test_invalid_no_at_sign(self):
- self.assertRaises(InvalidEmailAddressError, self._register,
- 'noatsign')
-
- def test_invalid_no_domain(self):
- self.assertRaises(InvalidEmailAddressError, self._register,
- 'nodom@ain')
diff --git a/src/mailman/model/tests/test_roster.py b/src/mailman/model/tests/test_roster.py
index 44735cf4b..ca950cfc5 100644
--- a/src/mailman/model/tests/test_roster.py
+++ b/src/mailman/model/tests/test_roster.py
@@ -26,7 +26,9 @@ __all__ = [
import unittest
from mailman.app.lifecycle import create_list
+from mailman.interfaces.address import IAddress
from mailman.interfaces.member import DeliveryMode, MemberRole
+from mailman.interfaces.user import IUser
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import now
@@ -136,7 +138,8 @@ class TestMembershipsRoster(unittest.TestCase):
self._ant = create_list('ant@example.com')
self._bee = create_list('bee@example.com')
user_manager = getUtility(IUserManager)
- self._anne = user_manager.create_user('anne@example.com')
+ self._anne = user_manager.make_user(
+ 'anne@example.com', 'Anne Person')
preferred = list(self._anne.addresses)[0]
preferred.verified_on = now()
self._anne.preferred_address = preferred
@@ -144,9 +147,56 @@ class TestMembershipsRoster(unittest.TestCase):
def test_no_memberships(self):
# An unsubscribed user has no memberships.
self.assertEqual(self._anne.memberships.member_count, 0)
+ self.assertIsNone(self._ant.members.get_member('anne@example.com'))
+ self.assertEqual(
+ self._ant.members.get_memberships('anne@example.com'),
+ [])
def test_subscriptions(self):
# Anne subscribes to a couple of mailing lists.
self._ant.subscribe(self._anne)
self._bee.subscribe(self._anne)
self.assertEqual(self._anne.memberships.member_count, 2)
+
+ def test_subscribed_as_user(self):
+ # Anne subscribes to a mailing list as a user and the member roster
+ # contains her membership.
+ self._ant.subscribe(self._anne)
+ self.assertEqual(
+ self._ant.members.get_member('anne@example.com').user,
+ self._anne)
+ memberships = self._ant.members.get_memberships('anne@example.com')
+ self.assertEqual(
+ [member.address.email for member in memberships],
+ ['anne@example.com'])
+
+ def test_subscribed_as_user_and_address(self):
+ # Anne subscribes to a mailing list twice, once as a user and once
+ # with an explicit address. She has two memberships.
+ self._ant.subscribe(self._anne)
+ self._ant.subscribe(self._anne.preferred_address)
+ self.assertEqual(self._anne.memberships.member_count, 2)
+ self.assertEqual(self._ant.members.member_count, 2)
+ self.assertEqual(
+ [member.address.email for member in self._ant.members.members],
+ ['anne@example.com', 'anne@example.com'])
+ # get_member() is defined to return the explicit address.
+ member = self._ant.members.get_member('anne@example.com')
+ subscriber = member.subscriber
+ self.assertTrue(IAddress.providedBy(subscriber))
+ self.assertFalse(IUser.providedBy(subscriber))
+ # get_memberships() returns them all.
+ memberships = self._ant.members.get_memberships('anne@example.com')
+ self.assertEqual(len(memberships), 2)
+ as_address = (memberships[0]
+ if IAddress.providedBy(memberships[0].subscriber)
+ else memberships[1])
+ as_user = (memberships[1]
+ if IUser.providedBy(memberships[1].subscriber)
+ else memberships[0])
+ self.assertEqual(as_address.subscriber, self._anne.preferred_address)
+ self.assertEqual(as_user.subscriber, self._anne)
+ # All the email addresses match.
+ self.assertEqual(
+ [record.address.email for record in memberships],
+ ['anne@example.com', 'anne@example.com'])
diff --git a/src/mailman/model/tests/test_uid.py b/src/mailman/model/tests/test_uid.py
index d36fa4c3b..8f3b4af70 100644
--- a/src/mailman/model/tests/test_uid.py
+++ b/src/mailman/model/tests/test_uid.py
@@ -25,8 +25,11 @@ __all__ = [
import uuid
import unittest
+from mailman.config import config
+from mailman.interfaces.usermanager import IUserManager
from mailman.model.uid import UID
from mailman.testing.layers import ConfigLayer
+from zope.component import getUtility
@@ -44,3 +47,41 @@ class TestUID(unittest.TestCase):
my_uuid = uuid.uuid4()
UID.record(my_uuid)
self.assertRaises(ValueError, UID.record, my_uuid)
+
+ def test_get_total_uid_count(self):
+ # The reserved REST API needs this.
+ for i in range(10):
+ UID.record(uuid.uuid4())
+ self.assertEqual(UID.get_total_uid_count(), 10)
+
+ def test_cull_orphan_uids(self):
+ # The reserved REST API needs to cull entries from the uid table that
+ # are not associated with actual entries in the user table.
+ manager = getUtility(IUserManager)
+ uids = set()
+ for i in range(10):
+ user = manager.create_user()
+ uids.add(user.user_id)
+ # The testing infrastructure does not record the UIDs for new user
+ # objects, so do that now to mimic the real system.
+ UID.record(user.user_id)
+ self.assertEqual(len(uids), 10)
+ # Now add some orphan uids.
+ orphans = set()
+ for i in range(100, 113):
+ uid = UID.record(uuid.UUID(int=i))
+ orphans.add(uid.uid)
+ self.assertEqual(len(orphans), 13)
+ # Normally we wouldn't do a query in a test, since we'd want the model
+ # object to expose this, but we actually don't support exposing all
+ # the UIDs to the rest of Mailman.
+ all_uids = set(row[0] for row in config.db.store.query(UID.uid))
+ self.assertEqual(all_uids, uids | orphans)
+ # Now, cull all the UIDs that aren't associated with users. Do use
+ # the model API for this.
+ UID.cull_orphans()
+ non_orphans = set(row[0] for row in config.db.store.query(UID.uid))
+ self.assertEqual(uids, non_orphans)
+ # And all the users still exist.
+ non_orphans = set(user.user_id for user in manager.users)
+ self.assertEqual(uids, non_orphans)
diff --git a/src/mailman/model/tests/test_user.py b/src/mailman/model/tests/test_user.py
index a05b69644..3cdac106b 100644
--- a/src/mailman/model/tests/test_user.py
+++ b/src/mailman/model/tests/test_user.py
@@ -25,10 +25,13 @@ __all__ = [
import unittest
from mailman.app.lifecycle import create_list
+from mailman.config import config
+from mailman.database.transaction import transaction
from mailman.interfaces.address import (
AddressAlreadyLinkedError, AddressNotLinkedError)
from mailman.interfaces.user import UnverifiedAddressError
from mailman.interfaces.usermanager import IUserManager
+from mailman.model.preferences import Preferences
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import now
from zope.component import getUtility
@@ -41,8 +44,9 @@ class TestUser(unittest.TestCase):
layer = ConfigLayer
def setUp(self):
+ self._manager = getUtility(IUserManager)
self._mlist = create_list('test@example.com')
- self._anne = getUtility(IUserManager).create_user(
+ self._anne = self._manager.create_user(
'anne@example.com', 'Anne Person')
preferred = list(self._anne.addresses)[0]
preferred.verified_on = now()
@@ -79,7 +83,7 @@ class TestUser(unittest.TestCase):
self._anne.user_id = 'foo'
def test_addresses_may_only_be_linked_to_one_user(self):
- user = getUtility(IUserManager).create_user()
+ user = self._manager.create_user()
# Anne's preferred address is already linked to her.
with self.assertRaises(AddressAlreadyLinkedError) as cm:
user.link(self._anne.preferred_address)
@@ -88,23 +92,39 @@ class TestUser(unittest.TestCase):
def test_unlink_from_address_not_linked_to(self):
# You cannot unlink an address from a user if that address is not
# already linked to the user.
- user = getUtility(IUserManager).create_user()
+ user = self._manager.create_user()
with self.assertRaises(AddressNotLinkedError) as cm:
user.unlink(self._anne.preferred_address)
self.assertEqual(cm.exception.address, self._anne.preferred_address)
def test_unlink_address_which_is_not_linked(self):
# You cannot unlink an address which is not linked to any user.
- address = getUtility(IUserManager).create_address('bart@example.com')
- user = getUtility(IUserManager).create_user()
+ address = self._manager.create_address('bart@example.com')
+ user = self._manager.create_user()
with self.assertRaises(AddressNotLinkedError) as cm:
user.unlink(address)
self.assertEqual(cm.exception.address, address)
def test_set_unverified_preferred_address(self):
# A user's preferred address cannot be set to an unverified address.
- new_preferred = getUtility(IUserManager).create_address(
+ new_preferred = self._manager.create_address(
'anne.person@example.com')
with self.assertRaises(UnverifiedAddressError) as cm:
self._anne.preferred_address = new_preferred
self.assertEqual(cm.exception.address, new_preferred)
+
+ def test_preferences_deletion_on_user_deletion(self):
+ # LP: #1418276 - deleting a user did not delete their preferences.
+ with transaction():
+ # This has to happen in a transaction so that both the user and
+ # the preferences objects get valid ids.
+ user = self._manager.create_user()
+ # The user's preference is in the database.
+ preferences = config.db.store.query(Preferences).filter_by(
+ id=user.preferences.id)
+ self.assertEqual(preferences.count(), 1)
+ self._manager.delete_user(user)
+ # The user's preference has been deleted.
+ preferences = config.db.store.query(Preferences).filter_by(
+ id=user.preferences.id)
+ self.assertEqual(preferences.count(), 0)
diff --git a/src/mailman/model/tests/test_usermanager.py b/src/mailman/model/tests/test_usermanager.py
new file mode 100644
index 000000000..f4643f031
--- /dev/null
+++ b/src/mailman/model/tests/test_usermanager.py
@@ -0,0 +1,88 @@
+# Copyright (C) 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/>.
+
+"""Test the IUserManager implementation."""
+
+__all__ = [
+ 'TestUserManager',
+ ]
+
+
+import unittest
+
+from mailman.interfaces.address import ExistingAddressError
+from mailman.interfaces.usermanager import IUserManager
+from mailman.testing.layers import ConfigLayer
+from zope.component import getUtility
+
+
+
+class TestUserManager(unittest.TestCase):
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._usermanager = getUtility(IUserManager)
+
+ def test_create_user_with_existing_address(self):
+ # LP: #1418280. If a user is created when an email address is passed
+ # in, and that address already exists, the user object should not get
+ # created.
+ # Create the address we're going to try to duplicate.
+ self._usermanager.create_address('anne@example.com')
+ # There are no users.
+ self.assertEqual(len(list(self._usermanager.users)), 0)
+ # Now create the user with an already existing address.
+ with self.assertRaises(ExistingAddressError) as cm:
+ self._usermanager.create_user('anne@example.com')
+ self.assertEqual(cm.exception.address, 'anne@example.com')
+ # There are still no users.
+ self.assertEqual(len(list(self._usermanager.users)), 0)
+
+ def test_make_new_user(self):
+ # Neither the user nor address objects exist yet.
+ self.assertIsNone(self._usermanager.get_user('anne@example.com'))
+ self.assertIsNone(self._usermanager.get_address('anne@example.com'))
+ user = self._usermanager.make_user('anne@example.com', 'Anne Person')
+ self.assertIn('anne@example.com',
+ [address.email for address in user.addresses])
+ addresses = list(user.addresses)
+ self.assertEqual(len(addresses), 1)
+ address = addresses[0]
+ self.assertEqual(address.email, 'anne@example.com')
+ self.assertEqual(address.display_name, 'Anne Person')
+ self.assertEqual(address.user.display_name, 'Anne Person')
+ self.assertIs(address.user, user)
+
+ def test_make_linked_user(self):
+ # The address exists, but there is no linked user.
+ self.assertIsNone(self._usermanager.get_user('anne@example.com'))
+ address = self._usermanager.create_address('anne@example.com')
+ user = self._usermanager.make_user('anne@example.com', 'Anne Person')
+ self.assertIsNotNone(address.user)
+ self.assertIs(user, address.user)
+ self.assertIn(address, user.addresses)
+ self.assertEqual(user.display_name, 'Anne Person')
+
+ def test_make_user_exists(self):
+ user = self._usermanager.create_user('anne@example.com', 'Anne Person')
+ other_user = self._usermanager.make_user('anne@example.com')
+ self.assertIs(user, other_user)
+
+ def test_get_user_by_id(self):
+ original = self._usermanager.make_user('anne@example.com')
+ copy = self._usermanager.get_user_by_id(original.user_id)
+ self.assertEqual(original, copy)
diff --git a/src/mailman/model/tests/test_workflow.py b/src/mailman/model/tests/test_workflow.py
new file mode 100644
index 000000000..88ed506bd
--- /dev/null
+++ b/src/mailman/model/tests/test_workflow.py
@@ -0,0 +1,148 @@
+# Copyright (C) 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/>.
+
+"""Test the workflow model."""
+
+__all__ = [
+ 'TestWorkflow',
+ ]
+
+
+import unittest
+
+from mailman.interfaces.workflow import IWorkflowStateManager
+from mailman.testing.layers import ConfigLayer
+from zope.component import getUtility
+
+
+
+class TestWorkflow(unittest.TestCase):
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._manager = getUtility(IWorkflowStateManager)
+
+ def test_save_restore_workflow(self):
+ # Save and restore a workflow.
+ name = 'ant'
+ token = 'bee'
+ step = 'cat'
+ data = 'dog'
+ self._manager.save(name, token, step, data)
+ state = self._manager.restore(name, token)
+ self.assertEqual(state.name, name)
+ self.assertEqual(state.token, token)
+ self.assertEqual(state.step, step)
+ self.assertEqual(state.data, data)
+
+ def test_save_restore_workflow_without_step(self):
+ # Save and restore a workflow that contains no step.
+ name = 'ant'
+ token = 'bee'
+ data = 'dog'
+ self._manager.save(name, token, data=data)
+ state = self._manager.restore(name, token)
+ self.assertEqual(state.name, name)
+ self.assertEqual(state.token, token)
+ self.assertIsNone(state.step)
+ self.assertEqual(state.data, data)
+
+ def test_save_restore_workflow_without_data(self):
+ # Save and restore a workflow that contains no data.
+ name = 'ant'
+ token = 'bee'
+ step = 'cat'
+ self._manager.save(name, token, step)
+ state = self._manager.restore(name, token)
+ self.assertEqual(state.name, name)
+ self.assertEqual(state.token, token)
+ self.assertEqual(state.step, step)
+ self.assertIsNone(state.data)
+
+ def test_save_restore_workflow_without_step_or_data(self):
+ # Save and restore a workflow that contains no step or data.
+ name = 'ant'
+ token = 'bee'
+ self._manager.save(name, token)
+ state = self._manager.restore(name, token)
+ self.assertEqual(state.name, name)
+ self.assertEqual(state.token, token)
+ self.assertIsNone(state.step)
+ self.assertIsNone(state.data)
+
+ def test_restore_workflow_with_no_matching_name(self):
+ # Try to restore a workflow that has no matching name in the database.
+ name = 'ant'
+ token = 'bee'
+ self._manager.save(name, token)
+ state = self._manager.restore('ewe', token)
+ self.assertIsNone(state)
+
+ def test_restore_workflow_with_no_matching_token(self):
+ # Try to restore a workflow that has no matching token in the database.
+ name = 'ant'
+ token = 'bee'
+ self._manager.save(name, token)
+ state = self._manager.restore(name, 'fly')
+ self.assertIsNone(state)
+
+ def test_restore_workflow_with_no_matching_token_or_name(self):
+ # Try to restore a workflow that has no matching token or name in the
+ # database.
+ name = 'ant'
+ token = 'bee'
+ self._manager.save(name, token)
+ state = self._manager.restore('ewe', 'fly')
+ self.assertIsNone(state)
+
+ def test_restore_removes_record(self):
+ name = 'ant'
+ token = 'bee'
+ self.assertEqual(self._manager.count, 0)
+ self._manager.save(name, token)
+ self.assertEqual(self._manager.count, 1)
+ self._manager.restore(name, token)
+ self.assertEqual(self._manager.count, 0)
+
+ def test_save_after_restore(self):
+ name = 'ant'
+ token = 'bee'
+ self.assertEqual(self._manager.count, 0)
+ self._manager.save(name, token)
+ self.assertEqual(self._manager.count, 1)
+ self._manager.restore(name, token)
+ self.assertEqual(self._manager.count, 0)
+ self._manager.save(name, token)
+ self.assertEqual(self._manager.count, 1)
+
+ def test_discard(self):
+ # Discard some workflow state. This is use by IRegistrar.discard().
+ self._manager.save('ant', 'token', 'one')
+ self._manager.save('bee', 'token', 'two')
+ self._manager.save('ant', 'nekot', 'three')
+ self._manager.save('bee', 'nekot', 'four')
+ self.assertEqual(self._manager.count, 4)
+ self._manager.discard('bee', 'token')
+ self.assertEqual(self._manager.count, 3)
+ state = self._manager.restore('ant', 'token')
+ self.assertEqual(state.step, 'one')
+ state = self._manager.restore('bee', 'token')
+ self.assertIsNone(state)
+ state = self._manager.restore('ant', 'nekot')
+ self.assertEqual(state.step, 'three')
+ state = self._manager.restore('bee', 'nekot')
+ self.assertEqual(state.step, 'four')
diff --git a/src/mailman/model/uid.py b/src/mailman/model/uid.py
index 5fcb20d53..0ff22438c 100644
--- a/src/mailman/model/uid.py
+++ b/src/mailman/model/uid.py
@@ -59,7 +59,7 @@ class UID(Model):
@staticmethod
@dbconnection
- # Note that the parameter order is deliberate reversed here. Normally,
+ # Note that the parameter order is deliberately reversed here. Normally,
# `store` is the first parameter after `self`, but since this is a
# staticmethod and there is no self, the decorator will see the uid in
# arg[0].
@@ -74,3 +74,20 @@ class UID(Model):
if existing.count() != 0:
raise ValueError(uid)
return UID(uid)
+
+ @staticmethod
+ @dbconnection
+ def get_total_uid_count(store):
+ return store.query(UID).count()
+
+ @staticmethod
+ @dbconnection
+ def cull_orphans(store):
+ # Avoid circular imports.
+ from mailman.model.user import User
+ # Delete all uids in this table that are not associated with user
+ # rows.
+ results = store.query(UID).filter(
+ ~UID.uid.in_(store.query(User._user_id)))
+ for uid in results.all():
+ store.delete(uid)
diff --git a/src/mailman/model/user.py b/src/mailman/model/user.py
index b74ea6d06..f6aedd132 100644
--- a/src/mailman/model/user.py
+++ b/src/mailman/model/user.py
@@ -18,6 +18,7 @@
"""Model for users."""
__all__ = [
+ 'DomainOwner',
'User',
]
@@ -34,7 +35,7 @@ from mailman.model.preferences import Preferences
from mailman.model.roster import Memberships
from mailman.utilities.datetime import factory as date_factory
from mailman.utilities.uid import UniqueIDFactory
-from sqlalchemy import Column, DateTime, ForeignKey, Integer, Unicode
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, Unicode
from sqlalchemy.orm import relationship, backref
from zope.event import notify
from zope.interface import implementer
@@ -55,6 +56,7 @@ class User(Model):
_password = Column('password', Unicode)
_user_id = Column(UUID, index=True)
_created_on = Column(DateTime)
+ is_server_owner = Column(Boolean, default=False)
addresses = relationship(
'Address', backref='user',
@@ -83,7 +85,9 @@ class User(Model):
'Duplicate user id {0}'.format(user_id))
self._user_id = user_id
self.display_name = ('' if display_name is None else display_name)
- self.preferences = preferences
+ if preferences is not None:
+ store.add(preferences)
+ self.preferences = preferences
store.add(self)
def __repr__(self):
@@ -174,3 +178,13 @@ class User(Model):
@property
def memberships(self):
return Memberships(self)
+
+
+
+class DomainOwner(Model):
+ """Internal table for associating domains to their owners."""
+
+ __tablename__ = 'domain_owner'
+
+ user_id = Column(Integer, ForeignKey('user.id'), primary_key=True)
+ domain_id = Column(Integer, ForeignKey('domain.id'), primary_key=True)
diff --git a/src/mailman/model/usermanager.py b/src/mailman/model/usermanager.py
index 2ad259693..3d7777099 100644
--- a/src/mailman/model/usermanager.py
+++ b/src/mailman/model/usermanager.py
@@ -39,15 +39,37 @@ class UserManager:
def create_user(self, email=None, display_name=None):
"""See `IUserManager`."""
- user = User(display_name, Preferences())
if email:
address = self.create_address(email, display_name)
+ user = User(display_name, Preferences())
+ if email:
+ user.link(address)
+ return user
+
+ def make_user(self, email, display_name=None):
+ """See `IUserManager`."""
+ # See if there's already a user linked with the given address.
+ user = self.get_user(email)
+ if user is None:
+ # A user linked to this address does not yet exist. Is the
+ # address itself known but just not linked to a user?
+ address = self.get_address(email)
+ if address is None:
+ # Nope, we don't even know about this address, so create both
+ # the user and address now.
+ return self.create_user(email, display_name)
+ # The address exists, but it's not yet linked to a user. Create
+ # the empty user object and link them together.
+ user = self.create_user()
+ user.display_name = (
+ display_name if display_name else address.display_name)
user.link(address)
return user
@dbconnection
def delete_user(self, store, user):
"""See `IUserManager`."""
+ store.delete(user.preferences)
store.delete(user)
@dbconnection
diff --git a/src/mailman/model/workflow.py b/src/mailman/model/workflow.py
new file mode 100644
index 000000000..392ab0798
--- /dev/null
+++ b/src/mailman/model/workflow.py
@@ -0,0 +1,76 @@
+# Copyright (C) 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/>.
+
+"""Model for workflow states."""
+
+__all__ = [
+ 'WorkflowState',
+ 'WorkflowStateManager',
+ ]
+
+
+from mailman.database.model import Model
+from mailman.database.transaction import dbconnection
+from mailman.interfaces.workflow import IWorkflowState, IWorkflowStateManager
+from sqlalchemy import Column, Unicode
+from zope.interface import implementer
+
+
+
+@implementer(IWorkflowState)
+class WorkflowState(Model):
+ """Workflow states."""
+
+ __tablename__ = 'workflowstate'
+
+ name = Column(Unicode, primary_key=True)
+ token = Column(Unicode, primary_key=True)
+ step = Column(Unicode)
+ data = Column(Unicode)
+
+
+
+@implementer(IWorkflowStateManager)
+class WorkflowStateManager:
+ """See `IWorkflowStateManager`."""
+
+ @dbconnection
+ def save(self, store, name, token, step=None, data=None):
+ """See `IWorkflowStateManager`."""
+ state = WorkflowState(name=name, token=token, step=step, data=data)
+ store.add(state)
+
+ @dbconnection
+ def restore(self, store, name, token):
+ """See `IWorkflowStateManager`."""
+ state = store.query(WorkflowState).get((name, token))
+ if state is not None:
+ store.delete(state)
+ return state
+
+ @dbconnection
+ def discard(self, store, name, token):
+ """See `IWorkflowStateManager`."""
+ state = store.query(WorkflowState).get((name, token))
+ if state is not None:
+ store.delete(state)
+
+ @property
+ @dbconnection
+ def count(self, store):
+ """See `IWorkflowStateManager`."""
+ return store.query(WorkflowState).count()
diff --git a/src/mailman/mta/tests/test_delivery.py b/src/mailman/mta/tests/test_delivery.py
index 77d31d3a3..2c744f21d 100644
--- a/src/mailman/mta/tests/test_delivery.py
+++ b/src/mailman/mta/tests/test_delivery.py
@@ -28,13 +28,11 @@ import tempfile
import unittest
from mailman.app.lifecycle import create_list
-from mailman.app.membership import add_member
from mailman.config import config
from mailman.interfaces.mailinglist import Personalization
-from mailman.interfaces.member import DeliveryMode
from mailman.mta.deliver import Deliver
from mailman.testing.helpers import (
- specialized_message_from_string as mfs)
+ specialized_message_from_string as mfs, subscribe)
from mailman.testing.layers import ConfigLayer
@@ -63,9 +61,7 @@ class TestIndividualDelivery(unittest.TestCase):
self._mlist = create_list('test@example.com')
self._mlist.personalize = Personalization.individual
# Make Anne a member of this mailing list.
- self._anne = add_member(self._mlist,
- 'anne@example.org', 'Anne Person',
- 'xyz', DeliveryMode.regular, 'en')
+ self._anne = subscribe(self._mlist, 'Anne', email='anne@example.org')
# Clear out any results from the previous test.
del _deliveries[:]
self._msg = mfs("""\
diff --git a/src/mailman/rest/addresses.py b/src/mailman/rest/addresses.py
index 2ce456b98..ca00938ba 100644
--- a/src/mailman/rest/addresses.py
+++ b/src/mailman/rest/addresses.py
@@ -114,6 +114,13 @@ class AnAddress(_AddressBase):
else:
okay(response, self._resource_as_json(self._address))
+ def on_delete(self, request, response):
+ if self._address is None:
+ not_found(response)
+ else:
+ getUtility(IUserManager).delete_address(self._address)
+ no_content(response)
+
@child()
def memberships(self, request, segments):
"""/addresses/<email>/memberships"""
diff --git a/src/mailman/rest/docs/addresses.rst b/src/mailman/rest/docs/addresses.rst
index bcffd6830..f70b64a39 100644
--- a/src/mailman/rest/docs/addresses.rst
+++ b/src/mailman/rest/docs/addresses.rst
@@ -190,6 +190,7 @@ representation:
created_on: 2005-08-01T07:49:23
display_name: Cris X. Person
http_etag: "..."
+ is_server_owner: False
password: ...
self_link: http://localhost:9001/3.0/users/1
user_id: 1
@@ -377,6 +378,7 @@ Elle can get her memberships for each of her email addresses.
email: elle@example.com
http_etag: "..."
list_id: ant.example.com
+ member_id: 1
role: member
self_link: http://localhost:9001/3.0/members/1
user: http://localhost:9001/3.0/users/4
@@ -386,6 +388,7 @@ Elle can get her memberships for each of her email addresses.
email: elle@example.com
http_etag: "..."
list_id: bee.example.com
+ member_id: 2
role: member
self_link: http://localhost:9001/3.0/members/2
user: http://localhost:9001/3.0/users/4
@@ -416,6 +419,7 @@ does not show up in the list of memberships for his other address.
email: elle@example.com
http_etag: "..."
list_id: ant.example.com
+ member_id: 1
role: member
self_link: http://localhost:9001/3.0/members/1
user: http://localhost:9001/3.0/users/4
@@ -425,6 +429,7 @@ does not show up in the list of memberships for his other address.
email: elle@example.com
http_etag: "..."
list_id: bee.example.com
+ member_id: 2
role: member
self_link: http://localhost:9001/3.0/members/2
user: http://localhost:9001/3.0/users/4
@@ -440,9 +445,71 @@ does not show up in the list of memberships for his other address.
email: eperson@example.com
http_etag: "..."
list_id: bee.example.com
+ member_id: 3
role: member
self_link: http://localhost:9001/3.0/members/3
user: http://localhost:9001/3.0/users/4
http_etag: "..."
start: 0
total_size: 1
+
+
+
+
+Deleting
+========
+
+Addresses can be deleted via the REST API.
+::
+
+ >>> fred = user_manager.create_address('fred@example.com', 'Fred Person')
+ >>> transaction.commit()
+ >>> dump_json('http://localhost:9001/3.0/addresses/fred@example.com')
+ display_name: Fred Person
+ email: fred@example.com
+ http_etag: "..."
+ original_email: fred@example.com
+ registered_on: 2005-08-01T07:49:23
+ self_link: http://localhost:9001/3.0/addresses/fred@example.com
+
+ >>> dump_json('http://localhost:9001/3.0/addresses/fred@example.com',
+ ... method='DELETE')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+ >>> transaction.abort()
+
+ >>> print(user_manager.get_address('fred@example.com'))
+ None
+
+If an address is linked to a user, deleting the address does not delete the
+user, it just unlinks it.
+::
+
+ >>> gwen = user_manager.create_user('gwen@example.com', 'Gwen Person')
+ >>> transaction.commit()
+ >>> dump_json('http://localhost:9001/3.0/users/5/addresses')
+ entry 0:
+ display_name: Gwen Person
+ email: gwen@example.com
+ http_etag: "..."
+ original_email: gwen@example.com
+ registered_on: 2005-08-01T07:49:23
+ self_link: http://localhost:9001/3.0/addresses/gwen@example.com
+ user: http://localhost:9001/3.0/users/5
+ http_etag: "795b0680c57ec2df3dceb68ccce2619fecdc7225"
+ start: 0
+ total_size: 1
+
+ >>> dump_json('http://localhost:9001/3.0/addresses/gwen@example.com',
+ ... method='DELETE')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> dump_json('http://localhost:9001/3.0/users/5/addresses')
+ http_etag: "..."
+ start: 0
+ total_size: 0
diff --git a/src/mailman/rest/docs/domains.rst b/src/mailman/rest/docs/domains.rst
index a78dacd85..34e3b9a18 100644
--- a/src/mailman/rest/docs/domains.rst
+++ b/src/mailman/rest/docs/domains.rst
@@ -28,15 +28,12 @@ Once a domain is added, it is accessible through the API.
>>> domain_manager.add(
... 'example.com', 'An example domain', 'http://lists.example.com')
- <Domain example.com, An example domain,
- base_url: http://lists.example.com,
- contact_address: postmaster@example.com>
+ <Domain example.com, An example domain, base_url: http://lists.example.com>
>>> transaction.commit()
>>> dump_json('http://localhost:9001/3.0/domains')
entry 0:
base_url: http://lists.example.com
- contact_address: postmaster@example.com
description: An example domain
http_etag: "..."
mail_host: example.com
@@ -51,24 +48,18 @@ At the top level, all domains are returned as separate entries.
>>> domain_manager.add(
... 'example.org',
- ... base_url='http://mail.example.org',
- ... contact_address='listmaster@example.org')
- <Domain example.org, base_url: http://mail.example.org,
- contact_address: listmaster@example.org>
+ ... base_url='http://mail.example.org')
+ <Domain example.org, base_url: http://mail.example.org>
>>> domain_manager.add(
... 'lists.example.net',
... 'Porkmasters',
- ... 'http://example.net',
- ... 'porkmaster@example.net')
- <Domain lists.example.net, Porkmasters,
- base_url: http://example.net,
- contact_address: porkmaster@example.net>
+ ... 'http://example.net')
+ <Domain lists.example.net, Porkmasters, base_url: http://example.net>
>>> transaction.commit()
>>> dump_json('http://localhost:9001/3.0/domains')
entry 0:
base_url: http://lists.example.com
- contact_address: postmaster@example.com
description: An example domain
http_etag: "..."
mail_host: example.com
@@ -76,7 +67,6 @@ At the top level, all domains are returned as separate entries.
url_host: lists.example.com
entry 1:
base_url: http://mail.example.org
- contact_address: listmaster@example.org
description: None
http_etag: "..."
mail_host: example.org
@@ -84,7 +74,6 @@ At the top level, all domains are returned as separate entries.
url_host: mail.example.org
entry 2:
base_url: http://example.net
- contact_address: porkmaster@example.net
description: Porkmasters
http_etag: "..."
mail_host: lists.example.net
@@ -103,7 +92,6 @@ The information for a single domain is available by following one of the
>>> dump_json('http://localhost:9001/3.0/domains/lists.example.net')
base_url: http://example.net
- contact_address: porkmaster@example.net
description: Porkmasters
http_etag: "..."
mail_host: lists.example.net
@@ -165,7 +153,6 @@ Now the web service knows about our new domain.
>>> dump_json('http://localhost:9001/3.0/domains/lists.example.com')
base_url: http://lists.example.com
- contact_address: postmaster@lists.example.com
description: None
http_etag: "..."
mail_host: lists.example.com
@@ -176,9 +163,7 @@ And the new domain is in our database.
::
>>> domain_manager['lists.example.com']
- <Domain lists.example.com,
- base_url: http://lists.example.com,
- contact_address: postmaster@lists.example.com>
+ <Domain lists.example.com, base_url: http://lists.example.com>
# Unlock the database.
>>> transaction.abort()
@@ -190,8 +175,7 @@ address.
>>> dump_json('http://localhost:9001/3.0/domains', {
... 'mail_host': 'my.example.com',
... 'description': 'My new domain',
- ... 'base_url': 'http://allmy.example.com',
- ... 'contact_address': 'helpme@example.com'
+ ... 'base_url': 'http://allmy.example.com'
... })
content-length: 0
date: ...
@@ -200,7 +184,6 @@ address.
>>> dump_json('http://localhost:9001/3.0/domains/my.example.com')
base_url: http://allmy.example.com
- contact_address: helpme@example.com
description: My new domain
http_etag: "..."
mail_host: my.example.com
@@ -208,9 +191,7 @@ address.
url_host: allmy.example.com
>>> domain_manager['my.example.com']
- <Domain my.example.com, My new domain,
- base_url: http://allmy.example.com,
- contact_address: helpme@example.com>
+ <Domain my.example.com, My new domain, base_url: http://allmy.example.com>
# Unlock the database.
>>> transaction.abort()
@@ -229,4 +210,92 @@ Domains can also be deleted via the API.
status: 204
+Domain owners
+=============
+
+Domains can have owners. By posting some addresses to the owners resource,
+you can add some domain owners. Currently our domain has no owners:
+
+ >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
+ http_etag: ...
+ start: 0
+ total_size: 0
+
+Anne and Bart volunteer to be a domain owners.
+::
+
+ >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners', (
+ ... ('owner', 'anne@example.com'), ('owner', 'bart@example.com')
+ ... ))
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
+ entry 0:
+ created_on: 2005-08-01T07:49:23
+ http_etag: ...
+ is_server_owner: False
+ self_link: http://localhost:9001/3.0/users/1
+ user_id: 1
+ entry 1:
+ created_on: 2005-08-01T07:49:23
+ http_etag: ...
+ is_server_owner: False
+ self_link: http://localhost:9001/3.0/users/2
+ user_id: 2
+ http_etag: ...
+ start: 0
+ total_size: 2
+
+We can delete all the domain owners.
+
+ >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners',
+ ... method='DELETE')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+Now there are no owners.
+
+ >>> dump_json('http://localhost:9001/3.0/domains/my.example.com/owners')
+ http_etag: ...
+ start: 0
+ total_size: 0
+
+New domains can be created with owners.
+
+ >>> dump_json('http://localhost:9001/3.0/domains', (
+ ... ('mail_host', 'your.example.com'),
+ ... ('owner', 'anne@example.com'),
+ ... ('owner', 'bart@example.com'),
+ ... ))
+ content-length: 0
+ date: ...
+ location: http://localhost:9001/3.0/domains/your.example.com
+ server: ...
+ status: 201
+
+The new domain has the expected owners.
+
+ >>> dump_json('http://localhost:9001/3.0/domains/your.example.com/owners')
+ entry 0:
+ created_on: 2005-08-01T07:49:23
+ http_etag: ...
+ is_server_owner: False
+ self_link: http://localhost:9001/3.0/users/1
+ user_id: 1
+ entry 1:
+ created_on: 2005-08-01T07:49:23
+ http_etag: ...
+ is_server_owner: False
+ self_link: http://localhost:9001/3.0/users/2
+ user_id: 2
+ http_etag: ...
+ start: 0
+ total_size: 2
+
+
.. _Domains: ../../model/docs/domains.html
diff --git a/src/mailman/rest/docs/listconf.rst b/src/mailman/rest/docs/listconf.rst
index 841ab3c27..bcf4f856e 100644
--- a/src/mailman/rest/docs/listconf.rst
+++ b/src/mailman/rest/docs/listconf.rst
@@ -61,6 +61,7 @@ All readable attributes for a list are available on a sub-resource.
scheme: http
send_welcome_message: True
subject_prefix: [Ant]
+ subscription_policy: confirm
volume: 1
web_host: lists.example.com
welcome_message_uri: mailman:///welcome.txt
@@ -106,6 +107,7 @@ When using ``PUT``, all writable attributes must be included.
... reply_to_address='bee@example.com',
... send_welcome_message=False,
... subject_prefix='[ant]',
+ ... subscription_policy='moderate',
... welcome_message_uri='mailman:///welcome.txt',
... default_member_action='hold',
... default_nonmember_action='discard',
@@ -156,6 +158,7 @@ These values are changed permanently.
...
send_welcome_message: False
subject_prefix: [ant]
+ subscription_policy: moderate
...
welcome_message_uri: mailman:///welcome.txt
diff --git a/src/mailman/rest/docs/membership.rst b/src/mailman/rest/docs/membership.rst
index b0b884d51..f3a09cfe9 100644
--- a/src/mailman/rest/docs/membership.rst
+++ b/src/mailman/rest/docs/membership.rst
@@ -39,6 +39,9 @@ the REST interface.
>>> from mailman.testing.helpers import subscribe
>>> subscribe(bee, 'Bart')
+ <Member: Bart Person <bperson@example.com> on bee@example.com
+ as MemberRole.member>
+
>>> dump_json('http://localhost:9001/3.0/members')
entry 0:
address: http://localhost:9001/3.0/addresses/bperson@example.com
@@ -46,6 +49,7 @@ the REST interface.
email: bperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 1
role: member
self_link: http://localhost:9001/3.0/members/1
user: http://localhost:9001/3.0/users/1
@@ -61,14 +65,19 @@ Bart's specific membership can be accessed directly:
email: bperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 1
role: member
self_link: http://localhost:9001/3.0/members/1
user: http://localhost:9001/3.0/users/1
When Cris also joins the mailing list, her subscription is also available via
the REST interface.
+::
>>> subscribe(bee, 'Cris')
+ <Member: Cris Person <cperson@example.com> on bee@example.com
+ as MemberRole.member>
+
>>> dump_json('http://localhost:9001/3.0/members')
entry 0:
address: http://localhost:9001/3.0/addresses/bperson@example.com
@@ -76,6 +85,7 @@ the REST interface.
email: bperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 1
role: member
self_link: http://localhost:9001/3.0/members/1
user: http://localhost:9001/3.0/users/1
@@ -85,6 +95,7 @@ the REST interface.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 2
role: member
self_link: http://localhost:9001/3.0/members/2
user: http://localhost:9001/3.0/users/2
@@ -97,6 +108,8 @@ subscribes, she is returned first.
::
>>> subscribe(bee, 'Anna')
+ <Member: Anna Person <aperson@example.com> on bee@example.com
+ as MemberRole.member>
>>> dump_json('http://localhost:9001/3.0/members')
entry 0:
@@ -105,6 +118,7 @@ subscribes, she is returned first.
email: aperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 3
role: member
self_link: http://localhost:9001/3.0/members/3
user: http://localhost:9001/3.0/users/3
@@ -114,6 +128,7 @@ subscribes, she is returned first.
email: bperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 1
role: member
self_link: http://localhost:9001/3.0/members/1
user: http://localhost:9001/3.0/users/1
@@ -123,6 +138,7 @@ subscribes, she is returned first.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 2
role: member
self_link: http://localhost:9001/3.0/members/2
user: http://localhost:9001/3.0/users/2
@@ -136,7 +152,11 @@ address. Anna and Cris subscribe to this new mailing list.
>>> ant = create_list('ant@example.com')
>>> subscribe(ant, 'Anna')
+ <Member: Anna Person <aperson@example.com> on ant@example.com
+ as MemberRole.member>
>>> subscribe(ant, 'Cris')
+ <Member: Cris Person <cperson@example.com> on ant@example.com
+ as MemberRole.member>
User ids are different than member ids.
@@ -147,6 +167,7 @@ User ids are different than member ids.
email: aperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 4
role: member
self_link: http://localhost:9001/3.0/members/4
user: http://localhost:9001/3.0/users/3
@@ -156,6 +177,7 @@ User ids are different than member ids.
email: cperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 5
role: member
self_link: http://localhost:9001/3.0/members/5
user: http://localhost:9001/3.0/users/2
@@ -165,6 +187,7 @@ User ids are different than member ids.
email: aperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 3
role: member
self_link: http://localhost:9001/3.0/members/3
user: http://localhost:9001/3.0/users/3
@@ -174,6 +197,7 @@ User ids are different than member ids.
email: bperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 1
role: member
self_link: http://localhost:9001/3.0/members/1
user: http://localhost:9001/3.0/users/1
@@ -183,6 +207,7 @@ User ids are different than member ids.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 2
role: member
self_link: http://localhost:9001/3.0/members/2
user: http://localhost:9001/3.0/users/2
@@ -200,6 +225,7 @@ We can also get just the members of a single mailing list.
email: aperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 4
role: member
self_link: http://localhost:9001/3.0/members/4
user: http://localhost:9001/3.0/users/3
@@ -209,6 +235,7 @@ We can also get just the members of a single mailing list.
email: cperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 5
role: member
self_link: http://localhost:9001/3.0/members/5
user: http://localhost:9001/3.0/users/2
@@ -234,6 +261,7 @@ page.
email: aperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 4
role: member
self_link: http://localhost:9001/3.0/members/4
user: http://localhost:9001/3.0/users/3
@@ -251,6 +279,7 @@ This works with members of a single list as well as with all members.
email: aperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 4
role: member
self_link: http://localhost:9001/3.0/members/4
user: http://localhost:9001/3.0/users/3
@@ -296,6 +325,7 @@ mailing list.
email: dperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 6
role: moderator
self_link: http://localhost:9001/3.0/members/6
user: http://localhost:9001/3.0/users/4
@@ -305,6 +335,7 @@ mailing list.
email: aperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 4
role: member
self_link: http://localhost:9001/3.0/members/4
user: http://localhost:9001/3.0/users/3
@@ -314,6 +345,7 @@ mailing list.
email: cperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 5
role: member
self_link: http://localhost:9001/3.0/members/5
user: http://localhost:9001/3.0/users/2
@@ -323,6 +355,7 @@ mailing list.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 7
role: owner
self_link: http://localhost:9001/3.0/members/7
user: http://localhost:9001/3.0/users/2
@@ -332,6 +365,7 @@ mailing list.
email: aperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 3
role: member
self_link: http://localhost:9001/3.0/members/3
user: http://localhost:9001/3.0/users/3
@@ -341,6 +375,7 @@ mailing list.
email: bperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 1
role: member
self_link: http://localhost:9001/3.0/members/1
user: http://localhost:9001/3.0/users/1
@@ -350,6 +385,7 @@ mailing list.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 2
role: member
self_link: http://localhost:9001/3.0/members/2
user: http://localhost:9001/3.0/users/2
@@ -367,6 +403,7 @@ We can access all the owners of a list.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 7
role: owner
self_link: http://localhost:9001/3.0/members/7
user: http://localhost:9001/3.0/users/2
@@ -387,6 +424,7 @@ A specific member can always be referenced by their role and address.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 7
role: owner
self_link: http://localhost:9001/3.0/members/7
user: http://localhost:9001/3.0/users/2
@@ -403,6 +441,7 @@ example, we can search for all the memberships of a particular address.
email: aperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 4
role: member
self_link: http://localhost:9001/3.0/members/4
user: http://localhost:9001/3.0/users/3
@@ -412,6 +451,7 @@ example, we can search for all the memberships of a particular address.
email: aperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 3
role: member
self_link: http://localhost:9001/3.0/members/3
user: http://localhost:9001/3.0/users/3
@@ -430,6 +470,7 @@ Or, we can find all the memberships for a particular mailing list.
email: aperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 3
role: member
self_link: http://localhost:9001/3.0/members/3
user: http://localhost:9001/3.0/users/3
@@ -439,6 +480,7 @@ Or, we can find all the memberships for a particular mailing list.
email: bperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 1
role: member
self_link: http://localhost:9001/3.0/members/1
user: http://localhost:9001/3.0/users/1
@@ -448,6 +490,7 @@ Or, we can find all the memberships for a particular mailing list.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 2
role: member
self_link: http://localhost:9001/3.0/members/2
user: http://localhost:9001/3.0/users/2
@@ -457,6 +500,7 @@ Or, we can find all the memberships for a particular mailing list.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 7
role: owner
self_link: http://localhost:9001/3.0/members/7
user: http://localhost:9001/3.0/users/2
@@ -477,6 +521,7 @@ list.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 2
role: member
self_link: http://localhost:9001/3.0/members/2
user: http://localhost:9001/3.0/users/2
@@ -486,6 +531,7 @@ list.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 7
role: owner
self_link: http://localhost:9001/3.0/members/7
user: http://localhost:9001/3.0/users/2
@@ -505,6 +551,7 @@ Or, we can find all the memberships for an address with a specific role.
email: cperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 5
role: member
self_link: http://localhost:9001/3.0/members/5
user: http://localhost:9001/3.0/users/2
@@ -514,6 +561,7 @@ Or, we can find all the memberships for an address with a specific role.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 2
role: member
self_link: http://localhost:9001/3.0/members/2
user: http://localhost:9001/3.0/users/2
@@ -534,6 +582,7 @@ Finally, we can search for a specific member given all three criteria.
email: cperson@example.com
http_etag: ...
list_id: bee.example.com
+ member_id: 2
role: member
self_link: http://localhost:9001/3.0/members/2
user: http://localhost:9001/3.0/users/2
@@ -549,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: ...
@@ -583,6 +635,7 @@ Elly is now a known user, and a member of the mailing list.
email: eperson@example.com
http_etag: ...
list_id: ant.example.com
+ member_id: 8
role: member
self_link: http://localhost:9001/3.0/members/8
user: http://localhost:9001/3.0/users/5
@@ -609,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: ...
@@ -625,6 +681,7 @@ list with her preferred address.
email: gwen@example.com
http_etag: "..."
list_id: ant.example.com
+ member_id: 9
role: member
self_link: http://localhost:9001/3.0/members/9
user: http://localhost:9001/3.0/users/6
@@ -649,6 +706,7 @@ the new address.
email: gwen.person@example.com
http_etag: "..."
list_id: ant.example.com
+ member_id: 9
role: member
self_link: http://localhost:9001/3.0/members/9
user: http://localhost:9001/3.0/users/6
@@ -677,91 +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
- 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
- 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
- role: member
- self_link: http://localhost:9001/3.0/members/10
- user: http://localhost:9001/3.0/users/7
-
-
Changing delivery address
=========================
@@ -794,28 +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: 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: 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.
@@ -826,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
@@ -840,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
@@ -867,18 +842,20 @@ his membership ids have not changed.
email: hperson@example.com
http_etag: "..."
list_id: ant.example.com
+ 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: 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 6aec921f0..000000000
--- a/src/mailman/rest/docs/moderation.rst
+++ /dev/null
@@ -1,362 +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
- >>> sub_req_id = hold_subscription(
- ... ant, 'anne@example.com', 'Anne Person',
- ... 'password', 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:
- address: anne@example.com
- delivery_mode: regular
- display_name: Anne Person
- http_etag: "..."
- language: en
- password: password
- 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.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')
- >>> 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:
- address: anne@example.com
- delivery_mode: regular
- display_name: Anne Person
- http_etag: "..."
- language: en
- password: password
- request_id: ...
- type: subscription
- when: 2005-08-01T07:49:23
- entry 1:
- address: 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))
- address: anne@example.com
- delivery_mode: regular
- display_name: Anne Person
- http_etag: "..."
- language: en
- password: password
- 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))
- address: 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/queues.rst b/src/mailman/rest/docs/queues.rst
index 861b6806f..df2753dc4 100644
--- a/src/mailman/rest/docs/queues.rst
+++ b/src/mailman/rest/docs/queues.rst
@@ -120,7 +120,7 @@ existing mailing list.
content-length: 0
date: ...
location: http://localhost:9001/3.0/lists/ant.example.com
- server: WSGIServer/0.2 CPython/3.4.2
+ server: WSGIServer/0.2 CPython/...
status: 201
While list creation takes an FQDN list name, injecting a message to the queue
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/docs/users.rst b/src/mailman/rest/docs/users.rst
index 824492333..13390a00f 100644
--- a/src/mailman/rest/docs/users.rst
+++ b/src/mailman/rest/docs/users.rst
@@ -34,6 +34,7 @@ Anne's user record is returned as an entry into the collection of all users.
created_on: 2005-08-01T07:49:23
display_name: Anne Person
http_etag: "..."
+ is_server_owner: False
self_link: http://localhost:9001/3.0/users/1
user_id: 1
http_etag: "..."
@@ -50,11 +51,13 @@ returned in the REST API.
created_on: 2005-08-01T07:49:23
display_name: Anne Person
http_etag: "..."
+ is_server_owner: False
self_link: http://localhost:9001/3.0/users/1
user_id: 1
entry 1:
created_on: 2005-08-01T07:49:23
http_etag: "..."
+ is_server_owner: False
self_link: http://localhost:9001/3.0/users/2
user_id: 2
http_etag: "..."
@@ -76,6 +79,7 @@ page.
created_on: 2005-08-01T07:49:23
display_name: Anne Person
http_etag: "..."
+ is_server_owner: False
self_link: http://localhost:9001/3.0/users/1
user_id: 1
http_etag: "..."
@@ -86,6 +90,7 @@ page.
entry 0:
created_on: 2005-08-01T07:49:23
http_etag: "..."
+ is_server_owner: False
self_link: http://localhost:9001/3.0/users/2
user_id: 2
http_etag: "..."
@@ -120,6 +125,7 @@ one was assigned to her.
>>> dump_json('http://localhost:9001/3.0/users/3')
created_on: 2005-08-01T07:49:23
http_etag: "..."
+ is_server_owner: False
password: {plaintext}...
self_link: http://localhost:9001/3.0/users/3
user_id: 3
@@ -131,6 +137,7 @@ address.
>>> dump_json('http://localhost:9001/3.0/users/cris@example.com')
created_on: 2005-08-01T07:49:23
http_etag: "..."
+ is_server_owner: False
password: {plaintext}...
self_link: http://localhost:9001/3.0/users/3
user_id: 3
@@ -158,6 +165,7 @@ Dave's user record includes his display name.
created_on: 2005-08-01T07:49:23
display_name: Dave Person
http_etag: "..."
+ is_server_owner: False
password: {plaintext}...
self_link: http://localhost:9001/3.0/users/4
user_id: 4
@@ -190,6 +198,7 @@ because it has the hash algorithm prefix (i.e. the *{plaintext}* marker).
created_on: 2005-08-01T07:49:23
display_name: Elly Person
http_etag: "..."
+ is_server_owner: False
password: {plaintext}supersekrit
self_link: http://localhost:9001/3.0/users/5
user_id: 5
@@ -214,6 +223,7 @@ Dave's display name has been updated.
created_on: 2005-08-01T07:49:23
display_name: David Person
http_etag: "..."
+ is_server_owner: False
password: {plaintext}...
self_link: http://localhost:9001/3.0/users/4
user_id: 4
@@ -238,6 +248,7 @@ addition of the algorithm prefix.
created_on: 2005-08-01T07:49:23
display_name: David Person
http_etag: "..."
+ is_server_owner: False
password: {plaintext}clockwork angels
self_link: http://localhost:9001/3.0/users/4
user_id: 4
@@ -246,8 +257,9 @@ You can change both the display name and the password by PUTing the full
resource.
>>> dump_json('http://localhost:9001/3.0/users/4', {
- ... 'display_name': 'David Personhood',
... 'cleartext_password': 'the garden',
+ ... 'display_name': 'David Personhood',
+ ... 'is_server_owner': False,
... }, method='PUT')
content-length: 0
date: ...
@@ -260,6 +272,7 @@ Dave's user record has been updated.
created_on: 2005-08-01T07:49:23
display_name: David Personhood
http_etag: "..."
+ is_server_owner: False
password: {plaintext}the garden
self_link: http://localhost:9001/3.0/users/4
user_id: 4
@@ -343,6 +356,7 @@ addresses can be used to look up Fred's user record.
created_on: 2005-08-01T07:49:23
display_name: Fred Person
http_etag: "..."
+ is_server_owner: False
self_link: http://localhost:9001/3.0/users/6
user_id: 6
@@ -350,6 +364,7 @@ addresses can be used to look up Fred's user record.
created_on: 2005-08-01T07:49:23
display_name: Fred Person
http_etag: "..."
+ is_server_owner: False
self_link: http://localhost:9001/3.0/users/6
user_id: 6
@@ -357,6 +372,7 @@ addresses can be used to look up Fred's user record.
created_on: 2005-08-01T07:49:23
display_name: Fred Person
http_etag: "..."
+ is_server_owner: False
self_link: http://localhost:9001/3.0/users/6
user_id: 6
@@ -364,6 +380,7 @@ addresses can be used to look up Fred's user record.
created_on: 2005-08-01T07:49:23
display_name: Fred Person
http_etag: "..."
+ is_server_owner: False
self_link: http://localhost:9001/3.0/users/6
user_id: 6
@@ -382,6 +399,7 @@ password is hashed and getting her user record returns the hashed password.
created_on: 2005-08-01T07:49:23
display_name: Elly Person
http_etag: "..."
+ is_server_owner: False
password: {plaintext}supersekrit
self_link: http://localhost:9001/3.0/users/5
user_id: 5
@@ -399,3 +417,82 @@ This time, Elly successfully logs into Mailman.
date: ...
server: ...
status: 204
+
+
+Server owners
+=============
+
+Users can be designated as server owners. Elly is not currently a server
+owner.
+
+ >>> dump_json('http://localhost:9001/3.0/users/5')
+ created_on: 2005-08-01T07:49:23
+ display_name: Elly Person
+ http_etag: "..."
+ is_server_owner: False
+ password: {plaintext}supersekrit
+ self_link: http://localhost:9001/3.0/users/5
+ user_id: 5
+
+Let's make her a server owner.
+::
+
+ >>> dump_json('http://localhost:9001/3.0/users/5', {
+ ... 'is_server_owner': True,
+ ... }, method='PATCH')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> dump_json('http://localhost:9001/3.0/users/5')
+ created_on: 2005-08-01T07:49:23
+ display_name: Elly Person
+ http_etag: "..."
+ is_server_owner: True
+ password: {plaintext}supersekrit
+ self_link: http://localhost:9001/3.0/users/5
+ user_id: 5
+
+Elly later retires as server owner.
+::
+
+ >>> dump_json('http://localhost:9001/3.0/users/5', {
+ ... 'is_server_owner': False,
+ ... }, method='PATCH')
+ content-length: 0
+ date: ...
+ server: ...
+ status: 204
+
+ >>> dump_json('http://localhost:9001/3.0/users/5')
+ created_on: 2005-08-01T07:49:23
+ display_name: Elly Person
+ http_etag: "..."
+ is_server_owner: False
+ password: {plaintext}...
+ self_link: http://localhost:9001/3.0/users/5
+ user_id: 5
+
+Gwen, a new users, takes over as a server owner.
+::
+
+ >>> dump_json('http://localhost:9001/3.0/users', {
+ ... 'display_name': 'Gwen Person',
+ ... 'email': 'gwen@example.com',
+ ... 'is_server_owner': True,
+ ... })
+ content-length: 0
+ date: ...
+ location: http://localhost:9001/3.0/users/7
+ server: ...
+ status: 201
+
+ >>> dump_json('http://localhost:9001/3.0/users/7')
+ created_on: 2005-08-01T07:49:23
+ display_name: Gwen Person
+ http_etag: "..."
+ is_server_owner: True
+ password: {plaintext}...
+ self_link: http://localhost:9001/3.0/users/7
+ user_id: 7
diff --git a/src/mailman/rest/domains.py b/src/mailman/rest/domains.py
index 345e8327d..bf6fc5ca5 100644
--- a/src/mailman/rest/domains.py
+++ b/src/mailman/rest/domains.py
@@ -29,7 +29,8 @@ from mailman.rest.helpers import (
BadRequest, CollectionMixin, NotFound, bad_request, child, created, etag,
no_content, not_found, okay, path_to)
from mailman.rest.lists import ListsForDomain
-from mailman.rest.validator import Validator
+from mailman.rest.users import OwnersForDomain
+from mailman.rest.validator import Validator, list_of_strings_validator
from zope.component import getUtility
@@ -41,7 +42,6 @@ class _DomainBase(CollectionMixin):
"""See `CollectionMixin`."""
return dict(
base_url=domain.base_url,
- contact_address=domain.contact_address,
description=domain.description,
mail_host=domain.mail_host,
self_link=path_to('domains/{0}'.format(domain.mail_host)),
@@ -88,6 +88,17 @@ class ADomain(_DomainBase):
else:
return BadRequest(), []
+ @child()
+ def owners(self, request, segments):
+ """/domains/<domain>/owners"""
+ if len(segments) == 0:
+ domain = getUtility(IDomainManager).get(self._domain)
+ if domain is None:
+ return NotFound()
+ return OwnersForDomain(domain)
+ else:
+ return BadRequest(), []
+
class AllDomains(_DomainBase):
"""The domains."""
@@ -99,12 +110,18 @@ class AllDomains(_DomainBase):
validator = Validator(mail_host=str,
description=str,
base_url=str,
- contact_address=str,
- _optional=('description', 'base_url',
- 'contact_address'))
- domain = domain_manager.add(**validator(request))
- except BadDomainSpecificationError:
- bad_request(response, b'Domain exists')
+ owner=list_of_strings_validator,
+ _optional=(
+ 'description', 'base_url', 'owner'))
+ values = validator(request)
+ # For consistency, owners are passed in as multiple `owner` keys,
+ # but .add() requires an `owners` keyword. Match impedence.
+ owners = values.pop('owner', None)
+ if owners is not None:
+ values['owners'] = owners
+ domain = domain_manager.add(**values)
+ except BadDomainSpecificationError as error:
+ bad_request(response, str(error))
except ValueError as error:
bad_request(response, str(error))
else:
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/listconf.py b/src/mailman/rest/listconf.py
index e83f52833..04dea996f 100644
--- a/src/mailman/rest/listconf.py
+++ b/src/mailman/rest/listconf.py
@@ -29,10 +29,12 @@ from mailman.core.errors import (
from mailman.interfaces.action import Action
from mailman.interfaces.archiver import ArchivePolicy
from mailman.interfaces.autorespond import ResponseAction
-from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging
+from mailman.interfaces.mailinglist import (
+ IAcceptableAliasSet, ReplyToMunging, SubscriptionPolicy)
from mailman.rest.helpers import (
GetterSetter, bad_request, etag, no_content, okay)
-from mailman.rest.validator import PatchValidator, Validator, enum_validator
+from mailman.rest.validator import (
+ PatchValidator, Validator, enum_validator, list_of_strings_validator)
@@ -72,14 +74,6 @@ def pipeline_validator(pipeline_name):
raise ValueError('Unknown pipeline: {}'.format(pipeline_name))
-def list_of_str(values):
- """Turn a list of things into a list of unicodes."""
- for value in values:
- if not isinstance(value, str):
- raise ValueError('Expected str, got {!r}'.format(value))
- return values
-
-
# This is the list of IMailingList attributes that are exposed through the
# REST API. The values of the keys are the GetterSetter instance holding the
@@ -96,7 +90,7 @@ def list_of_str(values):
# (e.g. datetimes, timedeltas, enums).
ATTRIBUTES = dict(
- acceptable_aliases=AcceptableAliases(list_of_str),
+ acceptable_aliases=AcceptableAliases(list_of_strings_validator),
admin_immed_notify=GetterSetter(as_boolean),
admin_notify_mchanges=GetterSetter(as_boolean),
administrivia=GetterSetter(as_boolean),
@@ -142,6 +136,7 @@ ATTRIBUTES = dict(
scheme=GetterSetter(None),
send_welcome_message=GetterSetter(as_boolean),
subject_prefix=GetterSetter(str),
+ subscription_policy=GetterSetter(enum_validator(SubscriptionPolicy)),
volume=GetterSetter(None),
web_host=GetterSetter(None),
welcome_message_uri=GetterSetter(str),
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 925cd6718..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)
@@ -58,13 +60,15 @@ class _MemberBase(CollectionMixin):
# subscribed to will not have a user id. The user_id and the
# member_id are UUIDs. We need to use the integer equivalent in the
# URL.
+ member_id = member.member_id.int
response = dict(
list_id=member.list_id,
email=member.address.email,
role=role,
address=path_to('addresses/{}'.format(member.address.email)),
- self_link=path_to('members/{}'.format(member.member_id.int)),
+ self_link=path_to('members/{}'.format(member_id)),
delivery_mode=member.delivery_mode,
+ member_id=member_id,
)
# Add the user link if there is one.
user = member.user
@@ -200,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,
@@ -208,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:
+ # 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:
- # 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)
+ 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/root.py b/src/mailman/rest/root.py
index d4dca146e..9ec84da68 100644
--- a/src/mailman/rest/root.py
+++ b/src/mailman/rest/root.py
@@ -29,10 +29,11 @@ from mailman.config import config
from mailman.core.constants import system_preferences
from mailman.core.system import system
from mailman.interfaces.listmanager import IListManager
+from mailman.model.uid import UID
from mailman.rest.addresses import AllAddresses, AnAddress
from mailman.rest.domains import ADomain, AllDomains
from mailman.rest.helpers import (
- BadRequest, NotFound, child, etag, not_found, okay, path_to)
+ BadRequest, NotFound, child, etag, no_content, not_found, okay, path_to)
from mailman.rest.lists import AList, AllLists, Styles
from mailman.rest.members import AMember, AllMembers, FindMembers
from mailman.rest.preferences import ReadOnlyPreferences
@@ -42,6 +43,9 @@ from mailman.rest.users import AUser, AllUsers
from zope.component import getUtility
+SLASH = '/'
+
+
class Root:
"""The RESTful root resource.
@@ -110,6 +114,25 @@ class SystemConfiguration:
okay(response, etag(resource))
+class Reserved:
+ """Top level API for reserved operations.
+
+ Nothing under this resource should be considered part of the stable API.
+ The resources that appear here are purely for the support of external
+ non-production systems, such as testing infrastructures for cooperating
+ components. Use at your own risk.
+ """
+ def __init__(self, segments):
+ self._resource_path = SLASH.join(segments)
+
+ def on_delete(self, request, response):
+ if self._resource_path != 'uids/orphans':
+ not_found(response)
+ return
+ UID.cull_orphans()
+ no_content(response)
+
+
class TopLevel:
"""Top level collections and entries."""
@@ -159,6 +182,7 @@ class TopLevel:
@child()
def lists(self, request, segments):
"""/<api>/lists
+ /<api>/lists/styles
/<api>/lists/<list>
/<api>/lists/<list>/...
"""
@@ -226,3 +250,8 @@ class TopLevel:
return AQueueFile(segments[0], segments[1]), []
else:
return BadRequest(), []
+
+ @child()
+ def reserved(self, request, segments):
+ """/<api>/reserved/[...]"""
+ return Reserved(segments), []
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_addresses.py b/src/mailman/rest/tests/test_addresses.py
index 584b81695..d03dc79d7 100644
--- a/src/mailman/rest/tests/test_addresses.py
+++ b/src/mailman/rest/tests/test_addresses.py
@@ -382,3 +382,12 @@ class TestAddresses(unittest.TestCase):
anne_addr = user_manager.get_address('anne@example.com')
self.assertIsNotNone(anne_addr)
self.assertEqual(anne_addr.user, anne_person)
+
+ def test_delete_missing_address(self):
+ # DELETEing an address through the REST API that doesn't exist returns
+ # a 404 error.
+ with self.assertRaises(HTTPError) as cm:
+ response, headers = call_api(
+ 'http://localhost:9001/3.0/addresses/anne@example.com',
+ method='DELETE')
+ self.assertEqual(cm.exception.code, 404)
diff --git a/src/mailman/rest/tests/test_domains.py b/src/mailman/rest/tests/test_domains.py
index bf53c8e70..9d03859ef 100644
--- a/src/mailman/rest/tests/test_domains.py
+++ b/src/mailman/rest/tests/test_domains.py
@@ -18,6 +18,7 @@
"""REST domain tests."""
__all__ = [
+ 'TestDomainOwners',
'TestDomains',
]
@@ -26,6 +27,7 @@ import unittest
from mailman.app.lifecycle import create_list
from mailman.database.transaction import transaction
+from mailman.interfaces.domain import IDomainManager
from mailman.interfaces.listmanager import IListManager
from mailman.testing.helpers import call_api
from mailman.testing.layers import RESTLayer
@@ -41,6 +43,33 @@ class TestDomains(unittest.TestCase):
with transaction():
self._mlist = create_list('test@example.com')
+ def test_create_domains(self):
+ # Create a domain with owners.
+ data = dict(
+ mail_host='example.org',
+ description='Example domain',
+ base_url='http://example.org',
+ owner=['someone@example.com', 'secondowner@example.com'],
+ )
+ content, response = call_api(
+ 'http://localhost:9001/3.0/domains', data, method="POST")
+ self.assertEqual(response.status, 201)
+
+ def test_domain_create_with_single_owner(self):
+ # Creating domain with single owner should not raise InvalidEmailError.
+ content, response = call_api(
+ 'http://localhost:9001/3.0/domains', dict(
+ mail_host='example.net',
+ owner='anne@example.com',
+ ),
+ method='POST')
+ self.assertEqual(response.status, 201)
+ # The domain has the expected owner.
+ domain = getUtility(IDomainManager).get('example.net')
+ self.assertEqual(
+ [list(owner.addresses)[0].email for owner in domain.owners],
+ ['anne@example.com'])
+
def test_bogus_endpoint_extension(self):
# /domains/<domain>/lists/<anything> is not a valid endpoint.
with self.assertRaises(HTTPError) as cm:
@@ -87,3 +116,46 @@ class TestDomains(unittest.TestCase):
call_api('http://localhost:9001/3.0/domains/example.com',
method='DELETE')
self.assertEqual(cm.exception.code, 404)
+
+
+
+
+class TestDomainOwners(unittest.TestCase):
+ layer = RESTLayer
+
+ def test_get_missing_domain_owners(self):
+ # Try to get the owners of a missing domain.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/domains/example.net/owners')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_post_to_missing_domain_owners(self):
+ # Try to add owners to a missing domain.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/domains/example.net/owners', (
+ ('owner', 'dave@example.com'), ('owner', 'elle@example.com'),
+ ))
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_delete_missing_domain_owners(self):
+ # Try to delete the owners of a missing domain.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/domains/example.net/owners',
+ method='DELETE')
+ self.assertEqual(cm.exception.code, 404)
+
+ def test_bad_post(self):
+ # Send POST data with an invalid attribute.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/domains/example.com/owners', (
+ ('guy', 'dave@example.com'), ('gal', 'elle@example.com'),
+ ))
+ self.assertEqual(cm.exception.code, 400)
+
+ def test_bad_delete(self):
+ # Send DELETE with any data.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/domains/example.com/owners', {
+ 'owner': 'dave@example.com',
+ }, method='DELETE')
+ self.assertEqual(cm.exception.code, 400)
diff --git a/src/mailman/rest/tests/test_listconf.py b/src/mailman/rest/tests/test_listconf.py
index b0107b199..860adac57 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
@@ -79,6 +80,7 @@ class TestConfiguration(unittest.TestCase):
reply_to_address='bee@example.com',
send_welcome_message=False,
subject_prefix='[ant]',
+ subscription_policy='confirm_then_moderate',
welcome_message_uri='mailman:///welcome.txt',
default_member_action='hold',
default_nonmember_action='discard',
@@ -89,3 +91,20 @@ class TestConfiguration(unittest.TestCase):
# All three acceptable aliases were set.
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)
diff --git a/src/mailman/rest/tests/test_lists.py b/src/mailman/rest/tests/test_lists.py
index a365db969..8e89f423c 100644
--- a/src/mailman/rest/tests/test_lists.py
+++ b/src/mailman/rest/tests/test_lists.py
@@ -28,8 +28,12 @@ __all__ = [
import unittest
from mailman.app.lifecycle import create_list
+from mailman.config import config
from mailman.database.transaction import transaction
+from mailman.interfaces.listmanager import IListManager
+from mailman.interfaces.mailinglist import IAcceptableAliasSet
from mailman.interfaces.usermanager import IUserManager
+from mailman.model.mailinglist import AcceptableAlias
from mailman.testing.helpers import call_api
from mailman.testing.layers import RESTLayer
from urllib.error import HTTPError
@@ -176,6 +180,18 @@ class TestLists(unittest.TestCase):
self.assertEqual(member['email'], 'bart@example.com')
self.assertEqual(member['role'], 'member')
+ def test_delete_list_with_acceptable_aliases(self):
+ # LP: #1432239 - deleting a mailing list with acceptable aliases
+ # causes a SQLAlchemy error. The aliases must be deleted first.
+ with transaction():
+ alias_set = IAcceptableAliasSet(self._mlist)
+ alias_set.add('bee@example.com')
+ call_api('http://localhost:9001/3.0/lists/test.example.com',
+ method='DELETE')
+ # Neither the mailing list, nor the aliases are present.
+ self.assertIsNone(getUtility(IListManager).get('test@example.com'))
+ self.assertEqual(config.db.store.query(AcceptableAlias).count(), 0)
+
class TestListArchivers(unittest.TestCase):
diff --git a/src/mailman/rest/tests/test_membership.py b/src/mailman/rest/tests/test_membership.py
index e1bff833b..4542677b6 100644
--- a/src/mailman/rest/tests/test_membership.py
+++ b/src/mailman/rest/tests/test_membership.py
@@ -39,6 +39,12 @@ from urllib.error import HTTPError
from zope.component import getUtility
+def _set_preferred(user):
+ preferred = list(user.addresses)[0]
+ preferred.verified_on = now()
+ user.preferred_address = preferred
+
+
class TestMembership(unittest.TestCase):
layer = RESTLayer
@@ -94,6 +100,45 @@ 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')
+
+ def test_add_member_with_mixed_case_email(self):
+ # LP: #1425359 - Mailman is case-perserving, case-insensitive. This
+ # test subscribes the lower case address and ensures the original mixed
+ # case address can't be subscribed.
+ with transaction():
+ anne = self._usermanager.create_address('anne@example.com')
+ self._mlist.subscribe(anne)
+ with self.assertRaises(HTTPError) as cm:
+ 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')
+
+ def test_add_member_with_lower_case_email(self):
+ # LP: #1425359 - Mailman is case-perserving, case-insensitive. This
+ # test subscribes the mixed case address and ensures the lower cased
+ # address can't be added.
+ with transaction():
+ anne = self._usermanager.create_address('ANNE@example.com')
+ self._mlist.subscribe(anne)
+ with self.assertRaises(HTTPError) as cm:
+ 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 +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)
@@ -129,9 +177,7 @@ class TestMembership(unittest.TestCase):
def test_join_as_user_with_preferred_address(self):
with transaction():
anne = self._usermanager.create_user('anne@example.com')
- preferred = list(anne.addresses)[0]
- preferred.verified_on = now()
- anne.preferred_address = preferred
+ _set_preferred(anne)
self._mlist.subscribe(anne)
content, response = call_api('http://localhost:9001/3.0/members')
self.assertEqual(response.status, 200)
@@ -150,9 +196,7 @@ class TestMembership(unittest.TestCase):
def test_member_changes_preferred_address(self):
with transaction():
anne = self._usermanager.create_user('anne@example.com')
- preferred = list(anne.addresses)[0]
- preferred.verified_on = now()
- anne.preferred_address = preferred
+ _set_preferred(anne)
self._mlist.subscribe(anne)
# Take a look at Anne's current membership.
content, response = call_api('http://localhost:9001/3.0/members')
diff --git a/src/mailman/rest/tests/test_moderation.py b/src/mailman/rest/tests/test_moderation.py
index 262a7ec60..e1d1f9ab3 100644
--- a/src/mailman/rest/tests/test_moderation.py
+++ b/src/mailman/rest/tests/test_moderation.py
@@ -18,25 +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.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):
@@ -70,23 +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, 'bperson@example.net', 'Bart Person', 'xyz',
- 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)
@@ -97,42 +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, 'cperson@example.net', 'Cris Person', 'xyz',
- 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_root.py b/src/mailman/rest/tests/test_root.py
index 6d10fc635..905461e46 100644
--- a/src/mailman/rest/tests/test_root.py
+++ b/src/mailman/rest/tests/test_root.py
@@ -123,3 +123,11 @@ class TestRoot(unittest.TestCase):
self.assertEqual(content['title'], '401 Unauthorized')
self.assertEqual(content['description'],
'User is not authorized for the REST API')
+
+ def test_reserved_bad_subpath(self):
+ # Only <api>/reserved/uids/orphans is a defined resource. DELETEing
+ # anything else gives a 404.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/reserved/uids/assigned',
+ method='DELETE')
+ self.assertEqual(cm.exception.code, 404)
diff --git a/src/mailman/rest/tests/test_uids.py b/src/mailman/rest/tests/test_uids.py
new file mode 100644
index 000000000..6c31a8aa4
--- /dev/null
+++ b/src/mailman/rest/tests/test_uids.py
@@ -0,0 +1,76 @@
+# Copyright (C) 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/>.
+
+"""Test deletion of orphaned UIDs.
+
+There is no doctest for this functionality, since it's only useful for testing
+of external clients of the REST API.
+"""
+
+__all__ = [
+ 'TestUIDs',
+ ]
+
+
+import unittest
+
+from mailman.config import config
+from mailman.database.transaction import transaction
+from mailman.interfaces.usermanager import IUserManager
+from mailman.model.uid import UID
+from mailman.testing.helpers import call_api
+from mailman.testing.layers import RESTLayer
+from zope.component import getUtility
+
+
+
+class TestUIDs(unittest.TestCase):
+ layer = RESTLayer
+
+ def test_delete_orphans(self):
+ # When users are deleted, their UIDs are generally not deleted. We
+ # never delete rows from that table in order to guarantee no
+ # duplicates. However, some external testing frameworks want to be
+ # able to reset the UID table, so they can use this interface to do
+ # so. See LP: #1420083.
+ #
+ # Create some users.
+ manager = getUtility(IUserManager)
+ users_by_uid = {}
+ with transaction():
+ for i in range(10):
+ user = manager.create_user()
+ users_by_uid[user.user_id] = user
+ # The testing infrastructure does not record the UIDs for new
+ # user options, so do that now to mimic the real system.
+ UID.record(user.user_id)
+ # We now have 10 unique uids.
+ self.assertEqual(len(users_by_uid), 10)
+ # Now delete all the users.
+ with transaction():
+ for user in list(users_by_uid.values()):
+ manager.delete_user(user)
+ # There are still 10 unique uids in the database.
+ self.assertEqual(UID.get_total_uid_count(), 10)
+ # Cull the orphan UIDs.
+ content, response = call_api(
+ 'http://localhost:9001/3.0/reserved/uids/orphans',
+ method='DELETE')
+ self.assertEqual(response.status, 204)
+ # Now there are no uids in the table.
+ config.db.abort()
+ self.assertEqual(UID.get_total_uid_count(), 0)
diff --git a/src/mailman/rest/tests/test_users.py b/src/mailman/rest/tests/test_users.py
index e009f63c1..ac8d018e8 100644
--- a/src/mailman/rest/tests/test_users.py
+++ b/src/mailman/rest/tests/test_users.py
@@ -19,6 +19,7 @@
__all__ = [
'TestLP1074374',
+ 'TestLP1419519',
'TestLogin',
'TestUsers',
]
@@ -35,6 +36,7 @@ from mailman.testing.helpers import call_api, configuration
from mailman.testing.layers import RESTLayer
from urllib.error import HTTPError
from zope.component import getUtility
+from mailman.model.preferences import Preferences
@@ -188,6 +190,127 @@ class TestUsers(unittest.TestCase):
})
self.assertEqual(cm.exception.code, 404)
+ def test_create_user_twice(self):
+ # LP: #1418280. No additional users should be created when an address
+ # that already exists is given.
+ content, response = call_api('http://localhost:9001/3.0/users')
+ self.assertEqual(content['total_size'], 0)
+ # Create the user.
+ call_api('http://localhost:9001/3.0/users', dict(
+ email='anne@example.com'))
+ # There is now one user.
+ content, response = call_api('http://localhost:9001/3.0/users')
+ self.assertEqual(content['total_size'], 1)
+ # Trying to create the user with the same address results in an error.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users', dict(
+ email='anne@example.com'))
+ self.assertEqual(cm.exception.code, 400)
+ self.assertEqual(cm.exception.reason,
+ b'Address already exists: anne@example.com')
+ # But at least no new users was created.
+ content, response = call_api('http://localhost:9001/3.0/users')
+ self.assertEqual(content['total_size'], 1)
+
+ def test_preferences_deletion_on_user_deletion(self):
+ # LP: #1418276 - deleting a user did not delete their preferences.
+ with transaction():
+ anne = getUtility(IUserManager).create_user(
+ 'anne@example.com', 'Anne Person')
+ # Anne's preference is in the database.
+ preferences = config.db.store.query(Preferences).filter_by(
+ id=anne.preferences.id)
+ self.assertEqual(preferences.count(), 1)
+ # Delete the user via REST.
+ content, response = call_api(
+ 'http://localhost:9001/3.0/users/anne@example.com',
+ method='DELETE')
+ self.assertEqual(response.status, 204)
+ # The user's preference has been deleted.
+ with transaction():
+ preferences = config.db.store.query(Preferences).filter_by(
+ id=anne.preferences.id)
+ self.assertEqual(preferences.count(), 0)
+
+
+
+class TestLogin(unittest.TestCase):
+ """Test user 'login' (really just password verification)."""
+
+ layer = RESTLayer
+
+ def setUp(self):
+ user_manager = getUtility(IUserManager)
+ with transaction():
+ self.anne = user_manager.create_user(
+ 'anne@example.com', 'Anne Person')
+ self.anne.password = config.password_context.encrypt('abc123')
+
+ def test_login_with_cleartext_password(self):
+ # A user can log in with the correct clear text password.
+ content, response = call_api(
+ 'http://localhost:9001/3.0/users/anne@example.com/login', {
+ 'cleartext_password': 'abc123',
+ }, method='POST')
+ self.assertEqual(response.status, 204)
+ # But the user cannot log in with an incorrect password.
+ with self.assertRaises(HTTPError) as cm:
+ call_api(
+ 'http://localhost:9001/3.0/users/anne@example.com/login', {
+ 'cleartext_password': 'not-the-password',
+ }, method='POST')
+ self.assertEqual(cm.exception.code, 403)
+
+ def test_wrong_parameter(self):
+ # A bad request because it is mistyped the required attribute.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/1/login', {
+ 'hashed_password': 'bad hash',
+ })
+ self.assertEqual(cm.exception.code, 400)
+
+ def test_not_enough_parameters(self):
+ # A bad request because it is missing the required attribute.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/1/login', {
+ })
+ self.assertEqual(cm.exception.code, 400)
+
+ def test_too_many_parameters(self):
+ # A bad request because it has too many attributes.
+ with self.assertRaises(HTTPError) as cm:
+ call_api('http://localhost:9001/3.0/users/1/login', {
+ 'cleartext_password': 'abc123',
+ 'display_name': 'Annie Personhood',
+ })
+ self.assertEqual(cm.exception.code, 400)
+
+ def test_successful_login_updates_password(self):
+ # Passlib supports updating the hash when the hash algorithm changes.
+ # When a user logs in successfully, the password will be updated if
+ # necessary.
+ #
+ # Start by hashing Anne's password with a different hashing algorithm
+ # than the one that the REST runner uses by default during testing.
+ config_file = os.path.join(config.VAR_DIR, 'passlib-tmp.config')
+ with open(config_file, 'w') as fp:
+ print("""\
+[passlib]
+schemes = hex_md5
+""", file=fp)
+ with configuration('passwords', configuration=config_file):
+ with transaction():
+ self.anne.password = config.password_context.encrypt('abc123')
+ # Just ensure Anne's password is hashed correctly.
+ self.assertEqual(self.anne.password,
+ 'e99a18c428cb38d5f260853678922e03')
+ # Now, Anne logs in with a successful password. This should change it
+ # back to the plaintext hash.
+ call_api('http://localhost:9001/3.0/users/1/login', {
+ 'cleartext_password': 'abc123',
+ })
+ self.assertEqual(self.anne.password, '{plaintext}abc123')
+
class TestLP1074374(unittest.TestCase):
@@ -253,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(
@@ -277,79 +401,44 @@ class TestLP1074374(unittest.TestCase):
-class TestLogin(unittest.TestCase):
- """Test user 'login' (really just password verification)."""
-
+class TestLP1419519(unittest.TestCase):
+ # LP: #1419519 - deleting a user with many linked addresses does not delete
+ # all address records.
layer = RESTLayer
def setUp(self):
- user_manager = getUtility(IUserManager)
+ # Create a user and link 10 addresses to that user.
+ self.manager = getUtility(IUserManager)
with transaction():
- self.anne = user_manager.create_user(
- 'anne@example.com', 'Anne Person')
- self.anne.password = config.password_context.encrypt('abc123')
+ anne = self.manager.create_user('anne@example.com', 'Anne Person')
+ for i in range(10):
+ email = 'a{:02d}@example.com'.format(i)
+ address = self.manager.create_address(email)
+ anne.link(address)
- def test_login_with_cleartext_password(self):
- # A user can log in with the correct clear text password.
+ def test_delete_user(self):
+ # Deleting the user deletes all their linked addresses.
+ #
+ # We start with 11 addresses in the database.
+ emails = sorted(address.email for address in self.manager.addresses)
+ self.assertEqual(emails, [
+ 'a00@example.com',
+ 'a01@example.com',
+ 'a02@example.com',
+ 'a03@example.com',
+ 'a04@example.com',
+ 'a05@example.com',
+ 'a06@example.com',
+ 'a07@example.com',
+ 'a08@example.com',
+ 'a09@example.com',
+ 'anne@example.com',
+ ])
content, response = call_api(
- 'http://localhost:9001/3.0/users/anne@example.com/login', {
- 'cleartext_password': 'abc123',
- }, method='POST')
+ 'http://localhost:9001/3.0/users/anne@example.com',
+ method='DELETE')
self.assertEqual(response.status, 204)
- # But the user cannot log in with an incorrect password.
- with self.assertRaises(HTTPError) as cm:
- call_api(
- 'http://localhost:9001/3.0/users/anne@example.com/login', {
- 'cleartext_password': 'not-the-password',
- }, method='POST')
- self.assertEqual(cm.exception.code, 403)
-
- def test_wrong_parameter(self):
- # A bad request because it is mistyped the required attribute.
- with self.assertRaises(HTTPError) as cm:
- call_api('http://localhost:9001/3.0/users/1/login', {
- 'hashed_password': 'bad hash',
- })
- self.assertEqual(cm.exception.code, 400)
-
- def test_not_enough_parameters(self):
- # A bad request because it is missing the required attribute.
- with self.assertRaises(HTTPError) as cm:
- call_api('http://localhost:9001/3.0/users/1/login', {
- })
- self.assertEqual(cm.exception.code, 400)
-
- def test_too_many_parameters(self):
- # A bad request because it has too many attributes.
- with self.assertRaises(HTTPError) as cm:
- call_api('http://localhost:9001/3.0/users/1/login', {
- 'cleartext_password': 'abc123',
- 'display_name': 'Annie Personhood',
- })
- self.assertEqual(cm.exception.code, 400)
-
- def test_successful_login_updates_password(self):
- # Passlib supports updating the hash when the hash algorithm changes.
- # When a user logs in successfully, the password will be updated if
- # necessary.
- #
- # Start by hashing Anne's password with a different hashing algorithm
- # than the one that the REST runner uses by default during testing.
- config_file = os.path.join(config.VAR_DIR, 'passlib-tmp.config')
- with open(config_file, 'w') as fp:
- print("""\
-[passlib]
-schemes = hex_md5
-""", file=fp)
- with configuration('passwords', configuration=config_file):
- with transaction():
- self.anne.password = config.password_context.encrypt('abc123')
- # Just ensure Anne's password is hashed correctly.
- self.assertEqual(self.anne.password,
- 'e99a18c428cb38d5f260853678922e03')
- # Now, Anne logs in with a successful password. This should change it
- # back to the plaintext hash.
- call_api('http://localhost:9001/3.0/users/1/login', {
- 'cleartext_password': 'abc123',
- })
- self.assertEqual(self.anne.password, '{plaintext}abc123')
+ # Now there should be no addresses in the database.
+ config.db.abort()
+ emails = sorted(address.email for address in self.manager.addresses)
+ self.assertEqual(len(emails), 0)
diff --git a/src/mailman/rest/tests/test_validator.py b/src/mailman/rest/tests/test_validator.py
new file mode 100644
index 000000000..2d515f828
--- /dev/null
+++ b/src/mailman/rest/tests/test_validator.py
@@ -0,0 +1,64 @@
+# Copyright (C) 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/>.
+
+"""Test REST validators."""
+
+__all__ = [
+ 'TestValidators',
+ ]
+
+
+import unittest
+
+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
+
+
+
+class TestValidators(unittest.TestCase):
+ layer = RESTLayer
+
+ def test_list_of_strings_validator_single(self):
+ # This validator should turn a single key into a list of keys.
+ self.assertEqual(list_of_strings_validator('ant'), ['ant'])
+
+ def test_list_of_strings_validator_multiple(self):
+ # This validator should turn a single key into a list of keys.
+ self.assertEqual(
+ list_of_strings_validator(['ant', 'bee', 'cat']),
+ ['ant', 'bee', 'cat'])
+
+ def test_list_of_strings_validator_invalid(self):
+ # 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/users.py b/src/mailman/rest/users.py
index 6856798d2..7b1ec8040 100644
--- a/src/mailman/rest/users.py
+++ b/src/mailman/rest/users.py
@@ -22,6 +22,7 @@ __all__ = [
'AddressUser',
'AllUsers',
'Login',
+ 'OwnersForDomain',
]
@@ -37,7 +38,8 @@ from mailman.rest.helpers import (
conflict, created, etag, forbidden, no_content, not_found, okay, paginate,
path_to)
from mailman.rest.preferences import Preferences
-from mailman.rest.validator import PatchValidator, Validator
+from mailman.rest.validator import (
+ PatchValidator, Validator, list_of_strings_validator)
from passlib.utils import generate_password as generate
from uuid import UUID
from zope.component import getUtility
@@ -47,27 +49,42 @@ from zope.component import getUtility
# Attributes of a user which can be changed via the REST API.
class PasswordEncrypterGetterSetter(GetterSetter):
def __init__(self):
- super(PasswordEncrypterGetterSetter, self).__init__(
- config.password_context.encrypt)
+ super().__init__(config.password_context.encrypt)
def get(self, obj, attribute):
assert attribute == 'cleartext_password'
- super(PasswordEncrypterGetterSetter, self).get(obj, 'password')
+ super().get(obj, 'password')
def put(self, obj, attribute, value):
assert attribute == 'cleartext_password'
- super(PasswordEncrypterGetterSetter, self).put(obj, 'password', value)
+ super().put(obj, 'password', value)
+
+
+class ListOfDomainOwners(GetterSetter):
+ def get(self, domain, attribute):
+ assert attribute == 'owner', (
+ 'Unexpected attribute: {}'.format(attribute))
+ def sort_key(owner):
+ return owner.addresses[0].email
+ return sorted(domain.owners, key=sort_key)
+
+ def put(self, domain, attribute, value):
+ assert attribute == 'owner', (
+ 'Unexpected attribute: {}'.format(attribute))
+ domain.add_owners(value)
ATTRIBUTES = dict(
- display_name=GetterSetter(str),
cleartext_password=PasswordEncrypterGetterSetter(),
+ display_name=GetterSetter(str),
+ is_server_owner=GetterSetter(as_boolean),
)
CREATION_FIELDS = dict(
- email=str,
display_name=str,
+ email=str,
+ is_server_owner=bool,
password=str,
- _optional=('display_name', 'password'),
+ _optional=('display_name', 'password', 'is_server_owner'),
)
@@ -78,6 +95,7 @@ def create_user(arguments, response):
# strip that out (if it exists), then create the user, adding the password
# after the fact if successful.
password = arguments.pop('password', None)
+ is_server_owner = arguments.pop('is_server_owner', False)
try:
user = getUtility(IUserManager).create_user(**arguments)
except ExistingAddressError as error:
@@ -88,6 +106,7 @@ def create_user(arguments, response):
# This will have to be reset since it cannot be retrieved.
password = generate(int(config.passwords.password_length))
user.password = config.password_context.encrypt(password)
+ user.is_server_owner = is_server_owner
location = path_to('users/{}'.format(user.user_id.int))
created(response, location)
return user
@@ -105,10 +124,11 @@ class _UserBase(CollectionMixin):
# but we serialize its integer equivalent.
user_id = user.user_id.int
resource = dict(
- user_id=user_id,
created_on=user.created_on,
+ is_server_owner=user.is_server_owner,
self_link=path_to('users/{}'.format(user_id)),
- )
+ user_id=user_id,
+ )
# Add the password attribute, only if the user has a password. Same
# with the real name. These could be None or the empty string.
if user.password:
@@ -185,14 +205,18 @@ class AUser(_UserBase):
return UserAddresses(self._user)
def on_delete(self, request, response):
- """Delete the named user, all her memberships, and addresses."""
+ """Delete the named user and all associated resources."""
if self._user is None:
not_found(response)
return
for member in self._user.memberships.members:
member.unsubscribe()
user_manager = getUtility(IUserManager)
- for address in self._user.addresses:
+ # SQLAlchemy is susceptable to delete-elements-while-iterating bugs so
+ # first figure out all the addresses we want to delete, then in a
+ # separate pass, delete those addresses. (See LP: #1419519)
+ delete = list(self._user.addresses)
+ for address in delete:
user_manager.delete_address(address)
user_manager.delete_user(self._user)
no_content(response)
@@ -289,7 +313,8 @@ class AddressUser(_UserBase):
del fields['email']
fields['user_id'] = int
fields['auto_create'] = as_boolean
- fields['_optional'] = fields['_optional'] + ('user_id', 'auto_create')
+ fields['_optional'] = fields['_optional'] + (
+ 'user_id', 'auto_create', 'is_server_owner')
try:
validator = Validator(**fields)
arguments = validator(request)
@@ -324,7 +349,8 @@ class AddressUser(_UserBase):
# Process post data and check for an existing user.
fields = CREATION_FIELDS.copy()
fields['user_id'] = int
- fields['_optional'] = fields['_optional'] + ('user_id', 'email')
+ fields['_optional'] = fields['_optional'] + (
+ 'user_id', 'email', 'is_server_owner')
try:
validator = Validator(**fields)
arguments = validator(request)
@@ -373,3 +399,56 @@ class Login:
no_content(response)
else:
forbidden(response)
+
+
+
+class OwnersForDomain(_UserBase):
+ """Owners for a particular domain."""
+
+ def __init__(self, domain):
+ self._domain = domain
+
+ def on_get(self, request, response):
+ """/domains/<domain>/owners"""
+ if self._domain is None:
+ not_found(response)
+ return
+ resource = self._make_collection(request)
+ okay(response, etag(resource))
+
+ def on_post(self, request, response):
+ """POST to /domains/<domain>/owners """
+ if self._domain is None:
+ not_found(response)
+ return
+ validator = Validator(
+ owner=ListOfDomainOwners(list_of_strings_validator))
+ try:
+ validator.update(self._domain, request)
+ except ValueError as error:
+ bad_request(response, str(error))
+ return
+ return no_content(response)
+
+ def on_delete(self, request, response):
+ """DELETE to /domains/<domain>/owners"""
+ if self._domain is None:
+ not_found(response)
+ try:
+ # No arguments.
+ Validator()(request)
+ except ValueError as error:
+ bad_request(response, str(error))
+ return
+ owner_email = [
+ owner.addresses[0].email
+ for owner in self._domain.owners
+ ]
+ for email in owner_email:
+ self._domain.remove_owner(email)
+ return no_content(response)
+
+ @paginate
+ def _get_collection(self, request):
+ """See `CollectionMixin`."""
+ return list(self._domain.owners)
diff --git a/src/mailman/rest/validator.py b/src/mailman/rest/validator.py
index 867991a36..1d5ad4ef9 100644
--- a/src/mailman/rest/validator.py
+++ b/src/mailman/rest/validator.py
@@ -22,12 +22,14 @@ __all__ = [
'Validator',
'enum_validator',
'language_validator',
+ 'list_of_strings_validator',
'subscriber_validator',
]
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
@@ -58,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):
@@ -66,6 +71,16 @@ def language_validator(code):
return getUtility(ILanguageManager)[code]
+def list_of_strings_validator(values):
+ """Turn a list of things, or a single thing, into a list of unicodes."""
+ if not isinstance(values, (list, tuple)):
+ values = [values]
+ for value in values:
+ if not isinstance(value, str):
+ raise ValueError('Expected str, got {!r}'.format(value))
+ return values
+
+
class Validator:
"""A validator of parameter input."""
diff --git a/src/mailman/rules/docs/moderation.rst b/src/mailman/rules/docs/moderation.rst
index 5631c882d..401004f34 100644
--- a/src/mailman/rules/docs/moderation.rst
+++ b/src/mailman/rules/docs/moderation.rst
@@ -26,6 +26,9 @@ postings are not moderated.
>>> from mailman.testing.helpers import subscribe
>>> subscribe(mlist, 'Anne')
+ <Member: Anne Person <aperson@example.com> on test@example.com
+ as MemberRole.member>
+
>>> member = mlist.members.get_member('aperson@example.com')
>>> print(member.moderation_action)
Action.defer
@@ -66,9 +69,13 @@ postings are held for moderator approval.
nonmember-moderation
Bart, who is not a member of the mailing list, sends a message to the list.
+::
>>> from mailman.interfaces.member import MemberRole
>>> subscribe(mlist, 'Bart', MemberRole.nonmember)
+ <Member: Bart Person <bperson@example.com> on test@example.com
+ as MemberRole.nonmember>
+
>>> nonmember = mlist.nonmembers.get_member('bperson@example.com')
>>> print(nonmember.moderation_action)
Action.hold
diff --git a/src/mailman/runners/docs/command.rst b/src/mailman/runners/docs/command.rst
index 82ee33fbc..c97e6454c 100644
--- a/src/mailman/runners/docs/command.rst
+++ b/src/mailman/runners/docs/command.rst
@@ -141,15 +141,14 @@ address, and the other is the results of his email command.
2
>>> from mailman.interfaces.registrar import IRegistrar
- >>> from zope.component import getUtility
- >>> registrar = getUtility(IRegistrar)
+ >>> registrar = IRegistrar(mlist)
>>> for item in messages:
... subject = item.msg['subject']
... print('Subject:', subject)
... if 'confirm' in str(subject):
... token = str(subject).split()[1].strip()
- ... status = registrar.confirm(token)
- ... assert status, 'Confirmation failed'
+ ... new_token, token_owner, member = registrar.confirm(token)
+ ... assert new_token is None, 'Confirmation failed'
Subject: The results of your email commands
Subject: confirm ...
diff --git a/src/mailman/runners/docs/incoming.rst b/src/mailman/runners/docs/incoming.rst
index d4fb65c85..fa425980b 100644
--- a/src/mailman/runners/docs/incoming.rst
+++ b/src/mailman/runners/docs/incoming.rst
@@ -89,9 +89,13 @@ Accepted messages
We have a message that is going to be sent to the mailing list. Once Anne is
a member of the mailing list, this message is so perfectly fine for posting
that it will be accepted and forward to the pipeline queue.
+::
>>> from mailman.testing.helpers import subscribe
>>> subscribe(mlist, 'Anne')
+ <Member: Anne Person <aperson@example.com> on test@example.com
+ as MemberRole.member>
+
>>> msg = message_from_string("""\
... From: aperson@example.com
... To: test@example.com
diff --git a/src/mailman/runners/docs/outgoing.rst b/src/mailman/runners/docs/outgoing.rst
index 7c3d1a989..8888aee5e 100644
--- a/src/mailman/runners/docs/outgoing.rst
+++ b/src/mailman/runners/docs/outgoing.rst
@@ -15,18 +15,16 @@ move messages to the 'retry queue' for handling delivery failures.
>>> mlist = create_list('test@example.com')
- >>> from mailman.app.membership import add_member
- >>> from mailman.interfaces.member import DeliveryMode
- >>> add_member(mlist, 'aperson@example.com', 'Anne Person',
- ... 'password', DeliveryMode.regular, 'en')
+ >>> from mailman.testing.helpers import subscribe
+ >>> subscribe(mlist, 'Anne')
<Member: Anne Person <aperson@example.com>
on test@example.com as MemberRole.member>
- >>> add_member(mlist, 'bperson@example.com', 'Bart Person',
- ... 'password', DeliveryMode.regular, 'en')
+
+ >>> subscribe(mlist, 'Bart')
<Member: Bart Person <bperson@example.com>
on test@example.com as MemberRole.member>
- >>> add_member(mlist, 'cperson@example.com', 'Cris Person',
- ... 'password', DeliveryMode.regular, 'en')
+
+ >>> subscribe(mlist, 'Cris')
<Member: Cris Person <cperson@example.com>
on test@example.com as MemberRole.member>
diff --git a/src/mailman/runners/tests/test_confirm.py b/src/mailman/runners/tests/test_confirm.py
index 090451ce7..b24d14a52 100644
--- a/src/mailman/runners/tests/test_confirm.py
+++ b/src/mailman/runners/tests/test_confirm.py
@@ -46,14 +46,15 @@ class TestConfirm(unittest.TestCase):
layer = ConfigLayer
def setUp(self):
- registrar = getUtility(IRegistrar)
self._commandq = config.switchboards['command']
self._runner = make_testable_runner(CommandRunner, 'command')
with transaction():
# Register a subscription requiring confirmation.
self._mlist = create_list('test@example.com')
self._mlist.send_welcome_message = False
- self._token = registrar.register(self._mlist, 'anne@example.org')
+ anne = getUtility(IUserManager).create_address('anne@example.org')
+ registrar = IRegistrar(self._mlist)
+ self._token, token_owner, member = registrar.register(anne)
def test_confirm_with_re_prefix(self):
subject = 'Re: confirm {0}'.format(self._token)
diff --git a/src/mailman/runners/tests/test_join.py b/src/mailman/runners/tests/test_join.py
index 4006675e4..1067517e2 100644
--- a/src/mailman/runners/tests/test_join.py
+++ b/src/mailman/runners/tests/test_join.py
@@ -30,7 +30,7 @@ from mailman.app.lifecycle import create_list
from mailman.config import config
from mailman.interfaces.member import DeliveryMode
from mailman.interfaces.registrar import IRegistrar
-from mailman.interfaces.subscriptions import ISubscriptionService
+from mailman.interfaces.subscriptions import ISubscriptionService, TokenOwner
from mailman.interfaces.usermanager import IUserManager
from mailman.runners.command import CommandRunner
from mailman.testing.helpers import (
@@ -160,14 +160,16 @@ class TestJoinWithDigests(unittest.TestCase):
subject_words = str(messages[1].msg['subject']).split()
self.assertEqual(subject_words[0], 'confirm')
token = subject_words[1]
- status = getUtility(IRegistrar).confirm(token)
- self.assertTrue(status, 'Confirmation failed')
+ token, token_owner, rmember = IRegistrar(self._mlist).confirm(token)
+ self.assertIsNone(token)
+ self.assertEqual(token_owner, TokenOwner.no_one)
# Now, make sure that Anne is a member of the list and is receiving
# digest deliveries.
members = getUtility(ISubscriptionService).find_members(
'anne@example.org')
self.assertEqual(len(members), 1)
- return members[0]
+ self.assertEqual(rmember, members[0])
+ return rmember
def test_join_with_implicit_no_digests(self):
# Test the digest=mime argument to the join command.
@@ -197,6 +199,8 @@ join digest=no
self.assertEqual(anne.address.email, 'anne@example.org')
self.assertEqual(anne.delivery_mode, DeliveryMode.regular)
+ # LP: #1444184 - digest=mime is not currently supported.
+ @unittest.expectedFailure
def test_join_with_mime_digests(self):
# Test the digest=mime argument to the join command.
msg = mfs("""\
@@ -211,6 +215,8 @@ join digest=mime
self.assertEqual(anne.address.email, 'anne@example.org')
self.assertEqual(anne.delivery_mode, DeliveryMode.mime_digests)
+ # LP: #1444184 - digest=mime is not currently supported.
+ @unittest.expectedFailure
def test_join_with_plain_digests(self):
# Test the digest=mime argument to the join command.
msg = mfs("""\
diff --git a/src/mailman/styles/base.py b/src/mailman/styles/base.py
index 50cddbc32..7a77af609 100644
--- a/src/mailman/styles/base.py
+++ b/src/mailman/styles/base.py
@@ -41,7 +41,8 @@ from mailman.interfaces.archiver import ArchivePolicy
from mailman.interfaces.autorespond import ResponseAction
from mailman.interfaces.bounce import UnrecognizedBounceDisposition
from mailman.interfaces.digests import DigestFrequency
-from mailman.interfaces.mailinglist import Personalization, ReplyToMunging
+from mailman.interfaces.mailinglist import (
+ Personalization, ReplyToMunging, SubscriptionPolicy)
from mailman.interfaces.nntp import NewsgroupModeration
@@ -75,6 +76,7 @@ class BasicOperation:
mlist.personalize = Personalization.none
mlist.default_member_action = Action.defer
mlist.default_nonmember_action = Action.hold
+ mlist.subscription_policy = SubscriptionPolicy.confirm
# Notify the administrator of pending requests and membership changes.
mlist.admin_immed_notify = True
mlist.admin_notify_mchanges = False
diff --git a/src/mailman/templates/en/confirm.txt b/src/mailman/templates/en/confirm.txt
index d02cb462b..7c8bee75f 100644
--- a/src/mailman/templates/en/confirm.txt
+++ b/src/mailman/templates/en/confirm.txt
@@ -8,9 +8,7 @@ We have received a registration request for the email address
Before you can start using GNU Mailman at this site, you must first confirm
that this is your email address. You can do this by replying to this message,
-keeping the Subject header intact. Or you can visit this web page
-
- $confirm_url
+keeping the Subject header intact.
If you do not wish to register this email address simply disregard this
message. If you think you are being maliciously subscribed to the list, or
diff --git a/src/mailman/templates/en/postauth.txt b/src/mailman/templates/en/postauth.txt
index fce85f52f..472ed32b4 100644
--- a/src/mailman/templates/en/postauth.txt
+++ b/src/mailman/templates/en/postauth.txt
@@ -6,8 +6,5 @@ following mailing list posting:
Subject: $subject
Reason: $reason
-At your convenience, visit:
-
- $admindb_url
-
-to approve or deny the request.
+At your convenience, visit your dashboard to approve or deny the
+request.
diff --git a/src/mailman/templates/en/subauth.txt b/src/mailman/templates/en/subauth.txt
index 1b13ebaeb..041be5e55 100644
--- a/src/mailman/templates/en/subauth.txt
+++ b/src/mailman/templates/en/subauth.txt
@@ -3,9 +3,3 @@ approval:
For: $username
List: $listname
-
-At your convenience, visit:
-
- $admindb_url
-
-to process the request.
diff --git a/src/mailman/templates/en/unsubauth.txt b/src/mailman/templates/en/unsubauth.txt
index 5975a2ce8..e6a6a82f2 100644
--- a/src/mailman/templates/en/unsubauth.txt
+++ b/src/mailman/templates/en/unsubauth.txt
@@ -1,7 +1,7 @@
Your authorization is required for a mailing list unsubscription
request approval:
- By: $address
+ By: $email
From: $listname
At your convenience, visit:
diff --git a/src/mailman/testing/config-with-instances.pck b/src/mailman/testing/config-with-instances.pck
new file mode 100644
index 000000000..b5173f58f
--- /dev/null
+++ b/src/mailman/testing/config-with-instances.pck
Binary files differ
diff --git a/src/mailman/testing/helpers.py b/src/mailman/testing/helpers.py
index a869c8d55..8fa4fbd2f 100644
--- a/src/mailman/testing/helpers.py
+++ b/src/mailman/testing/helpers.py
@@ -435,10 +435,14 @@ class chdir:
-def subscribe(mlist, first_name, role=MemberRole.member):
- """Helper for subscribing a sample person to a mailing list."""
+def subscribe(mlist, first_name, role=MemberRole.member, email=None):
+ """Helper for subscribing a sample person to a mailing list.
+
+ Returns the newly created member object.
+ """
user_manager = getUtility(IUserManager)
- email = '{0}person@example.com'.format(first_name[0].lower())
+ email = ('{0}person@example.com'.format(first_name[0].lower())
+ if email is None else email)
full_name = '{0} Person'.format(first_name)
with transaction():
person = user_manager.get_user(email)
@@ -446,13 +450,14 @@ def subscribe(mlist, first_name, role=MemberRole.member):
address = user_manager.get_address(email)
if address is None:
person = user_manager.create_user(email, full_name)
- preferred_address = list(person.addresses)[0]
- mlist.subscribe(preferred_address, role)
+ subscription_address = list(person.addresses)[0]
else:
- mlist.subscribe(address, role)
+ subscription_address = address
else:
- preferred_address = list(person.addresses)[0]
- mlist.subscribe(preferred_address, role)
+ subscription_address = list(person.addresses)[0]
+ mlist.subscribe(subscription_address, role)
+ roster = mlist.get_roster(role)
+ return roster.get_member(email)
diff --git a/src/mailman/testing/layers.py b/src/mailman/testing/layers.py
index 8618f39d3..3328efefc 100644
--- a/src/mailman/testing/layers.py
+++ b/src/mailman/testing/layers.py
@@ -200,7 +200,7 @@ class ConfigLayer(MockAndMonkeyLayer):
with transaction():
getUtility(IDomainManager).add(
'example.com', 'An example domain.',
- 'http://lists.example.com', 'postmaster@example.com')
+ 'http://lists.example.com')
@classmethod
def testTearDown(cls):
diff --git a/src/mailman/utilities/i18n.py b/src/mailman/utilities/i18n.py
index 1d3fbca6f..ba3923e06 100644
--- a/src/mailman/utilities/i18n.py
+++ b/src/mailman/utilities/i18n.py
@@ -150,7 +150,7 @@ def find(template_file, mlist=None, language=None, _trace=False):
try:
if _trace:
print('@@@', path, end='', file=sys.stderr)
- fp = open(path)
+ fp = open(path, 'r', encoding='utf-8')
except IOError as error:
if error.errno == errno.ENOENT:
if _trace:
diff --git a/src/mailman/utilities/importer.py b/src/mailman/utilities/importer.py
index 8590d9b1b..66a23123c 100644
--- a/src/mailman/utilities/importer.py
+++ b/src/mailman/utilities/importer.py
@@ -32,6 +32,7 @@ from mailman.config import config
from mailman.core.errors import MailmanError
from mailman.handlers.decorate import decorate, decorate_template
from mailman.interfaces.action import Action, FilterAction
+from mailman.interfaces.address import IEmailValidator
from mailman.interfaces.archiver import ArchivePolicy
from mailman.interfaces.autorespond import ResponseAction
from mailman.interfaces.bans import IBanManager
@@ -40,6 +41,7 @@ from mailman.interfaces.digests import DigestFrequency
from mailman.interfaces.languages import ILanguageManager
from mailman.interfaces.mailinglist import IAcceptableAliasSet
from mailman.interfaces.mailinglist import Personalization, ReplyToMunging
+from mailman.interfaces.mailinglist import SubscriptionPolicy
from mailman.interfaces.member import DeliveryMode, DeliveryStatus, MemberRole
from mailman.interfaces.nntp import NewsgroupModeration
from mailman.interfaces.usermanager import IUserManager
@@ -177,6 +179,7 @@ TYPES = dict(
personalize=Personalization,
preferred_language=check_language_code,
reply_goes_to_list=ReplyToMunging,
+ subscription_policy=SubscriptionPolicy,
topics_enabled=bool,
)
@@ -201,6 +204,7 @@ NAME_MAPPINGS = dict(
real_name='display_name',
send_goodbye_msg='send_goodbye_message',
send_welcome_msg='send_welcome_message',
+ subscribe_policy='subscription_policy',
)
# These DateTime fields of the mailinglist table need a type conversion to
@@ -387,11 +391,17 @@ def import_config_pck(mlist, config_dict):
regulars_set = set(config_dict.get('members', {}))
digesters_set = set(config_dict.get('digest_members', {}))
members = regulars_set.union(digesters_set)
- import_roster(mlist, config_dict, members, MemberRole.member)
- import_roster(mlist, config_dict, config_dict.get('owner', []),
- MemberRole.owner)
- import_roster(mlist, config_dict, config_dict.get('moderator', []),
- MemberRole.moderator)
+ # Don't send welcome messages when we import the rosters.
+ send_welcome_message = mlist.send_welcome_message
+ mlist.send_welcome_message = False
+ try:
+ import_roster(mlist, config_dict, members, MemberRole.member)
+ import_roster(mlist, config_dict, config_dict.get('owner', []),
+ MemberRole.owner)
+ import_roster(mlist, config_dict, config_dict.get('moderator', []),
+ MemberRole.moderator)
+ finally:
+ mlist.send_welcome_message = send_welcome_message
@@ -408,6 +418,7 @@ def import_roster(mlist, config_dict, members, role):
:type role: MemberRole enum
"""
usermanager = getUtility(IUserManager)
+ validator = getUtility(IEmailValidator)
roster = mlist.get_roster(role)
for email in members:
# For owners and members, the emails can have a mixed case, so
@@ -427,8 +438,13 @@ def import_roster(mlist, config_dict, members, role):
merged_members.update(config_dict.get('digest_members', {}))
if merged_members.get(email, 0) != 0:
original_email = bytes_to_str(merged_members[email])
+ if not validator.is_valid(original_email):
+ original_email = email
else:
original_email = email
+ if not validator.is_valid(original_email):
+ # Skip this one entirely.
+ continue
address = usermanager.create_address(original_email)
address.verified_on = datetime.datetime.now()
user.link(address)
diff --git a/src/mailman/utilities/tests/test_import.py b/src/mailman/utilities/tests/test_import.py
index b0ab9938d..9f3d59d5a 100644
--- a/src/mailman/utilities/tests/test_import.py
+++ b/src/mailman/utilities/tests/test_import.py
@@ -38,12 +38,14 @@ from mailman.app.lifecycle import create_list
from mailman.config import config
from mailman.handlers.decorate import decorate
from mailman.interfaces.action import Action, FilterAction
+from mailman.interfaces.address import InvalidEmailAddressError
from mailman.interfaces.archiver import ArchivePolicy
from mailman.interfaces.autorespond import ResponseAction
from mailman.interfaces.bans import IBanManager
from mailman.interfaces.bounce import UnrecognizedBounceDisposition
from mailman.interfaces.languages import ILanguageManager
-from mailman.interfaces.mailinglist import IAcceptableAliasSet
+from mailman.interfaces.mailinglist import (
+ IAcceptableAliasSet, SubscriptionPolicy)
from mailman.interfaces.member import DeliveryMode, DeliveryStatus
from mailman.interfaces.nntp import NewsgroupModeration
from mailman.interfaces.templates import ITemplateLoader
@@ -300,6 +302,34 @@ class TestBasicImport(unittest.TestCase):
self._import()
self.assertEqual(self._mlist.encode_ascii_prefixes, True)
+ def test_subscription_policy_open(self):
+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
+ self._pckdict['subscribe_policy'] = 0
+ self._import()
+ self.assertEqual(self._mlist.subscription_policy,
+ SubscriptionPolicy.open)
+
+ def test_subscription_policy_confirm(self):
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ self._pckdict['subscribe_policy'] = 1
+ self._import()
+ self.assertEqual(self._mlist.subscription_policy,
+ SubscriptionPolicy.confirm)
+
+ def test_subscription_policy_moderate(self):
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ self._pckdict['subscribe_policy'] = 2
+ self._import()
+ self.assertEqual(self._mlist.subscription_policy,
+ SubscriptionPolicy.moderate)
+
+ def test_subscription_policy_confirm_then_moderate(self):
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ self._pckdict['subscribe_policy'] = 3
+ self._import()
+ self.assertEqual(self._mlist.subscription_policy,
+ SubscriptionPolicy.confirm_then_moderate)
+
class TestArchiveImport(unittest.TestCase):
@@ -747,6 +777,48 @@ class TestRosterImport(unittest.TestCase):
anne = self._usermanager.get_user('anne@example.com')
self.assertTrue(anne.controls('anne@example.com'))
+ def test_invalid_original_email(self):
+ # When the member has an original email address (i.e. the
+ # case-preserved version) that is invalid, their new address record's
+ # original_email attribute will only be the case insensitive version.
+ self._pckdict['members']['anne@example.com'] = b'invalid email address'
+ try:
+ import_config_pck(self._mlist, self._pckdict)
+ except InvalidEmailAddressError as error:
+ self.fail(error)
+ self.assertIn('anne@example.com',
+ [a.email for a in self._mlist.members.addresses])
+ anne = self._usermanager.get_address('anne@example.com')
+ self.assertEqual(anne.original_email, 'anne@example.com')
+
+ def test_invalid_email(self):
+ # When a member's email address is invalid, that member is skipped
+ # during the import.
+ self._pckdict['members'] = {
+ 'anne@example.com': 0,
+ 'invalid email address': b'invalid email address'
+ }
+ self._pckdict['digest_members'] = {}
+ try:
+ import_config_pck(self._mlist, self._pckdict)
+ except InvalidEmailAddressError as error:
+ self.fail(error)
+ self.assertEqual(['anne@example.com'],
+ [a.email for a in self._mlist.members.addresses])
+
+ def test_no_email_sent(self):
+ # No welcome message is sent to newly imported members.
+ self.assertTrue(self._mlist.send_welcome_message)
+ import_config_pck(self._mlist, self._pckdict)
+ self.assertIn('anne@example.com',
+ [a.email for a in self._mlist.members.addresses])
+ # There are no messages in any of the queues.
+ for queue, switchboard in config.switchboards.items():
+ file_count = len(switchboard.files)
+ self.assertEqual(file_count, 0,
+ "Unexpected queue '{}' file count: {}".format(
+ queue, file_count))
+ self.assertTrue(self._mlist.send_welcome_message)
diff --git a/src/mailman/version.py b/src/mailman/version.py
index 42089aff5..4904454ad 100644
--- a/src/mailman/version.py
+++ b/src/mailman/version.py
@@ -18,7 +18,7 @@
"""Mailman version strings."""
# Mailman version.
-VERSION = '3.0.0b6'
+VERSION = '3.0.0rc1'
CODENAME = "Show Don't Tell"
# And as a hex number in the manner of PY_VERSION_HEX.
@@ -32,9 +32,9 @@ FINAL = 0xf
MAJOR_REV = 3
MINOR_REV = 0
MICRO_REV = 0
-REL_LEVEL = BETA
+REL_LEVEL = RC
# At most 15 beta releases!
-REL_SERIAL = 6
+REL_SERIAL = 1
HEX_VERSION = ((MAJOR_REV << 24) | (MINOR_REV << 16) | (MICRO_REV << 8) |
(REL_LEVEL << 4) | (REL_SERIAL << 0))