summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAbhilash Raj2016-07-30 21:06:57 -0700
committerBarry Warsaw2016-09-01 06:55:09 -0400
commit88f349900ea9afea30186c3c9f0f399ff69f6c4a (patch)
tree984a31ab8be58871bacf4a5821ddc611ac4586fe /src
parentc23519b5262fd264ed0f9e5bc23b901ed7eee632 (diff)
downloadmailman-88f349900ea9afea30186c3c9f0f399ff69f6c4a.tar.gz
mailman-88f349900ea9afea30186c3c9f0f399ff69f6c4a.tar.zst
mailman-88f349900ea9afea30186c3c9f0f399ff69f6c4a.zip
Diffstat (limited to 'src')
-rw-r--r--src/mailman/app/events.py4
-rw-r--r--src/mailman/app/subscriptions.py2
-rw-r--r--src/mailman/app/tests/test_moderation.py7
-rw-r--r--src/mailman/app/tests/test_subscriptions.py4
-rw-r--r--src/mailman/app/tests/test_unsubscriptions.py412
-rw-r--r--src/mailman/app/tests/test_workflowmanager.py (renamed from src/mailman/app/tests/test_registrar.py)8
-rw-r--r--src/mailman/app/unsubscriptions.py255
-rw-r--r--src/mailman/app/workflowmanager.py (renamed from src/mailman/app/registrar.py)84
-rw-r--r--src/mailman/commands/eml_confirm.py6
-rw-r--r--src/mailman/commands/eml_membership.py18
-rw-r--r--src/mailman/commands/tests/test_confirm.py12
-rw-r--r--src/mailman/config/configure.zcml12
-rw-r--r--src/mailman/database/alembic/versions/448a93984c35_unsubscription_workflow.py42
-rw-r--r--src/mailman/interfaces/mailinglist.py2
-rw-r--r--src/mailman/interfaces/workflowmanager.py (renamed from src/mailman/interfaces/registrar.py)11
-rw-r--r--src/mailman/model/mailinglist.py31
-rw-r--r--src/mailman/model/tests/test_mailinglist.py13
-rw-r--r--src/mailman/model/tests/test_workflow.py2
-rw-r--r--src/mailman/rest/members.py6
-rw-r--r--src/mailman/rest/sub_moderation.py8
-rw-r--r--src/mailman/rest/tests/test_membership.py8
-rw-r--r--src/mailman/rest/tests/test_moderation.py7
-rw-r--r--src/mailman/runners/tests/test_confirm.py7
-rw-r--r--src/mailman/runners/tests/test_join.py7
-rw-r--r--src/mailman/runners/tests/test_leave.py81
-rw-r--r--src/mailman/styles/base.py1
26 files changed, 965 insertions, 85 deletions
diff --git a/src/mailman/app/events.py b/src/mailman/app/events.py
index 8b95bd4be..f3a4d2f3b 100644
--- a/src/mailman/app/events.py
+++ b/src/mailman/app/events.py
@@ -19,7 +19,7 @@
from mailman import public
from mailman.app import (
- domain, membership, moderator, registrar, subscriptions)
+ domain, membership, moderator, workflowmanager, subscriptions)
from mailman.core import i18n, switchboard
from mailman.languages import manager as language_manager
from mailman.styles import manager as style_manager
@@ -37,7 +37,7 @@ def initialize():
membership.handle_SubscriptionEvent,
moderator.handle_ListDeletingEvent,
passwords.handle_ConfigurationUpdatedEvent,
- registrar.handle_ConfirmationNeededEvent,
+ workflowmanager.handle_ConfirmationNeededEvent,
style_manager.handle_ConfigurationUpdatedEvent,
subscriptions.handle_ListDeletingEvent,
switchboard.handle_ConfigurationUpdatedEvent,
diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py
index a397c4fde..0cd056b99 100644
--- a/src/mailman/app/subscriptions.py
+++ b/src/mailman/app/subscriptions.py
@@ -33,7 +33,7 @@ from mailman.interfaces.listmanager import ListDeletingEvent
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.workflowmanager import ConfirmationNeededEvent
from mailman.interfaces.subscriptions import (
ISubscriptionService, SubscriptionPendingError, TokenOwner)
from mailman.interfaces.template import ITemplateLoader
diff --git a/src/mailman/app/tests/test_moderation.py b/src/mailman/app/tests/test_moderation.py
index 931b85fd0..a7fc9e1b4 100644
--- a/src/mailman/app/tests/test_moderation.py
+++ b/src/mailman/app/tests/test_moderation.py
@@ -24,7 +24,7 @@ from mailman.app.moderator import (
handle_message, handle_unsubscription, hold_message, hold_unsubscription)
from mailman.interfaces.action import Action
from mailman.interfaces.messages import IMessageStore
-from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.workflowmanager import IWorkflowManager
from mailman.interfaces.requests import IListRequests
from mailman.interfaces.usermanager import IUserManager
from mailman.runners.incoming import IncomingRunner
@@ -35,7 +35,7 @@ from mailman.testing.helpers import (
specialized_message_from_string as mfs)
from mailman.testing.layers import SMTPLayer
from mailman.utilities.datetime import now
-from zope.component import getUtility
+from zope.component import getUtility, getAdapter
class TestModeration(unittest.TestCase):
@@ -153,7 +153,8 @@ class TestUnsubscription(unittest.TestCase):
def setUp(self):
self._mlist = create_list('test@example.com')
- self._registrar = IRegistrar(self._mlist)
+ self._registrar = getAdapter(
+ self._mlist, IWorkflowManager, name='subscribe')
def test_unsubscribe_defer(self):
# When unsubscriptions must be approved by the moderator, but the
diff --git a/src/mailman/app/tests/test_subscriptions.py b/src/mailman/app/tests/test_subscriptions.py
index 9f02593a9..6a3a1aa77 100644
--- a/src/mailman/app/tests/test_subscriptions.py
+++ b/src/mailman/app/tests/test_subscriptions.py
@@ -287,7 +287,7 @@ class TestSubscriptionWorkflow(unittest.TestCase):
# 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 = SubscriptionWorkflow(self._mlist, anne)
workflow.run_thru('moderation_checks')
with patch.object(workflow, '_step_get_moderator_approval') as step:
next(workflow)
@@ -299,7 +299,7 @@ class TestSubscriptionWorkflow(unittest.TestCase):
# 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)
+ workflow = SubscriptionWorkflow(self._mlist, anne)
# Consume the entire state machine.
list(workflow)
# Anne is now a member of the mailing list.
diff --git a/src/mailman/app/tests/test_unsubscriptions.py b/src/mailman/app/tests/test_unsubscriptions.py
new file mode 100644
index 000000000..9c7b27b87
--- /dev/null
+++ b/src/mailman/app/tests/test_unsubscriptions.py
@@ -0,0 +1,412 @@
+# Copyright (C) 2016 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 for un-subscription service."""
+
+import unittest
+
+
+from contextlib import suppress
+from mailman.app.lifecycle import create_list
+from mailman.app.unsubscriptions import UnSubscriptionWorkflow
+from mailman.interfaces.bans import IBanManager
+from mailman.interfaces.mailinglist import SubscriptionPolicy
+from mailman.interfaces.member import MembershipIsBannedError
+from mailman.interfaces.pending import IPendings
+from mailman.interfaces.subscriptions import TokenOwner
+from mailman.interfaces.usermanager import IUserManager
+from mailman.testing.helpers import (
+ LogFileMark, get_queue_messages, set_preferred)
+from mailman.testing.layers import ConfigLayer
+from mailman.utilities.datetime import now
+from unittest.mock import patch
+from zope.component import getUtility
+
+
+class TestUnSubscriptionWorkflow(unittest.TestCase):
+
+ layer = ConfigLayer
+ maxDiff = None
+
+ def setUp(self):
+ self._mlist = create_list('test@example.com')
+ self._mlist.admin_immed_notify = False
+ self._mlist.subscription_policy = SubscriptionPolicy.open
+ self._mlist.send_welcome_message = False
+ self._anne = 'anne@example.com'
+ self._user_manager = getUtility(IUserManager)
+ self.anne = self._user_manager.create_user(self._anne)
+ self.anne.addresses[0].verified_on = now()
+ self.anne.preferred_address = self.anne.addresses[0]
+ self._mlist.subscribe(self.anne)
+
+ def test_start_state(self):
+ # Test the workflow starts with no tokens or members.
+ workflow = UnSubscriptionWorkflow(self._mlist)
+ self.assertEqual(workflow.token_owner, TokenOwner.no_one)
+ self.assertIsNone(workflow.token)
+ self.assertIsNone(workflow.member)
+
+ def test_pended_data(self):
+ # Test there is a Pendable object associated with a held un-subscription
+ # request and it has some valid data associated with it.
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
+ with suppress(StopIteration):
+ workflow.run_thru('send_confirmation')
+ 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_user_or_address_required(self):
+ # The `subscriber` attribute must be a user or address that is provided
+ # to the workflow.
+ workflow = UnSubscriptionWorkflow(self._mlist)
+ self.assertRaises(AssertionError, list, workflow)
+
+ def test_user_is_subscribed_to_unsubscribe(self):
+ # A user must be subscribed to a list when trying to unsubscribe.
+ addr = self._user_manager.create_address('aperson@example.org')
+ addr.verfied_on = now()
+ workflow = UnSubscriptionWorkflow(self._mlist, addr)
+ self.assertRaises(AssertionError,
+ workflow.run_thru, 'subscription_checks')
+
+ def test_confirmation_checks_open_list(self):
+ # An un-subscription from an open list does not need to be confirmed or
+ # moderated.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.open
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_do_unsubscription') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_confirmation_checks_no_user_confirmation_needed(self):
+ # An un-subscription from a list which does not need user confirmation
+ # skips to the moderation checks.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
+ 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_pre_confirmed(self):
+ # The unsubscription policy requires user-confirmation, but their
+ # un-subscription is pre-confirmed. Since moderation is not reuqired,
+ # the user will be immediately un-subscribed.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
+ pre_confirmed=True)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_do_unsubscription') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_confirmation_checks_confirm_then_moderate_pre_confirmed(self):
+ # The un-subscription policy requires user confirmation, but their
+ # un-subscription is pre-confirmed. Since moderation is required, that
+ # check will be performed.
+ self._mlist.unsubscription_policy = (
+ SubscriptionPolicy.confirm_then_moderate)
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
+ pre_confirmed=True)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_do_unsubscription') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_send_confirmation_checks_confirm_list(self):
+ # The un-subscription policy requires user confirmation and the
+ # un-subscription is not pre-confirmed.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
+ 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_moderated_list(self):
+ # The un-subscription policy requires moderation.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
+ workflow.run_thru('confirmation_checks')
+ with patch.object(workflow, '_step_moderation_checks') as step:
+ next(workflow)
+ step.assert_called_once_with()
+
+ def test_moderation_checks_approval_required(self):
+ # The moderator must approve the subscription request.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
+ 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_unsusbcription(self):
+ # An open un-subscription policy means the user gets un-subscribed to
+ # the mailing list without any further confirmations or approvals.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.open
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
+ list(workflow)
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(member)
+
+ def test_do_unsubscription_pre_approved(self):
+ # A moderation-requiring subscription policy plus a pre-approved address
+ # means the user gets un-subscribed from the mailing list without any
+ # further confirmation or approvals.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
+ pre_approved=True)
+ list(workflow)
+ # Anne is now unsubscribed form the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(member)
+ # No further token is needed.
+ self.assertIsNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.no_one)
+
+ def test_do_unsubscription_pre_approved_pre_onfirmed(self):
+ # A moderation-requiring un-subscription policy plus a pre-appvoed
+ # address means the user gets un-subscribed to the mailing list without
+ # any further confirmations or approvals.
+ self._mlist.unsubscription_policy = (
+ SubscriptionPolicy.confirm_then_moderate)
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
+ pre_approved=True,
+ pre_confirmed=True)
+ list(workflow)
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(member)
+ # No further token is needed.
+ self.assertIsNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.no_one)
+
+ def test_do_unsubscription_cleanups(self):
+ # Once the user is un-subscribed, the token and its associated pending
+ # database record will be removed from the database.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.open
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
+ pre_approved=True,
+ pre_confirmed=True)
+ # Cache the token.
+ token = workflow.token
+ # Run the workflow.
+ list(workflow)
+ # Anne is now un-subscribed from the list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(member)
+ # 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 as been evicted.
+ self.assertIsNone(getUtility(IPendings).confirm(token, expunge=False))
+ # There is no workflow associated with the token. This shows up as an
+ # exception when trying to restore the workflow.
+ new_workflow = UnSubscriptionWorkflow(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 un-subscribed.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
+ pre_confirmed=True)
+ # Run the entire workflow.
+ list(workflow)
+ # The user is currently subscribed to the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNotNone(member)
+ self.assertIsNotNone(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 un-sunscription request
+ # and should result in the user getting subscribed.
+ approved_workflow = UnSubscriptionWorkflow(self._mlist)
+ approved_workflow.token = workflow.token
+ approved_workflow.restore()
+ list(approved_workflow)
+ # Now the user is un-subscribed from the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(member)
+ 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 un-subscription is held for moderator approval, a message is
+ # logged.
+ mark = LogFileMark('mailman.subscribe')
+ self._mlist.unsubscription_policy = SubscriptionPolicy.moderate
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
+ pre_confirmed=True)
+ # Run the entire workflow.
+ list(workflow)
+ self.assertIn(
+ 'test@example.com: held unsubscription request from anne@example.com',
+ mark.readline()
+ )
+
+ def test_get_moderator_approval_notifies_moderators(self):
+ # When the un-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.unsubscription_policy = SubscriptionPolicy.moderate
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
+ pre_confirmed=True)
+ # Consume the entire state machine.
+ list(workflow)
+ items = get_queue_messages('virgin', expected_count=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 unsubscription request to Test from anne@example.com')
+ self.assertEqual(message.get_payload(), """\
+Your authorization is required for a mailing list unsubscription
+request approval:
+
+ For: anne@example.com
+ List: test@example.com""")
+
+ def test_get_moderator_approval_no_notifications(self):
+ # When the un-subscription request 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.unsubscription_policy = SubscriptionPolicy.moderate
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne,
+ pre_confirmed=True)
+ # Consume the entire state machine.
+ list(workflow)
+ get_queue_messages('virgin', expected_count=0)
+
+ def test_send_confirmation(self):
+ # A confirmation message gets sent when the un-subscription must be
+ # confirmed.
+ self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
+ # Run the workflow to model the confirmation step.
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
+ list(workflow)
+ items = get_queue_messages('virgin', expected_count=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_confirmation_unsubscribes_user(self):
+ # Un-subscriptions to the mailing list must be confirmed. Once that's
+ # done, the user's address is unsubscribed
+ self._mlist.unsubscription_policy = SubscriptionPolicy.confirm
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
+ list(workflow)
+ # Anne is a member.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNotNone(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 = UnSubscriptionWorkflow(self._mlist)
+ confirm_workflow.token = workflow.token
+ confirm_workflow.restore()
+ list(confirm_workflow)
+ # Anne is now un-subscribed.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(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.unsubscription_policy = (
+ SubscriptionPolicy.confirm_then_moderate)
+ workflow = UnSubscriptionWorkflow(self._mlist, self.anne)
+ # Run the state machine up to the first confirmation, and cache the
+ # confirmation token.
+ list(workflow)
+ token = workflow.token
+ # Anne is still a member of the mailing list.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNotNone(member)
+ self.assertIsNotNone(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 = UnSubscriptionWorkflow(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 = UnSubscriptionWorkflow(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 un-subscribed.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNotNone(member)
+ self.assertIsNotNone(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 un-subscribed.
+ member = self._mlist.regular_members.get_member(self._anne)
+ self.assertIsNone(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.unsubscription_policy = SubscriptionPolicy.confirm
+ workflow = UnSubscriptionWorkflow(
+ self._mlist, self.anne, pre_confirmed=True, pre_approved=True)
+ list(workflow)
+ # Anne was un-subscribed.
+ self.assertIsNone(workflow.token)
+ self.assertEqual(workflow.token_owner, TokenOwner.no_one)
+ self.assertIsNone(workflow.member)
diff --git a/src/mailman/app/tests/test_registrar.py b/src/mailman/app/tests/test_workflowmanager.py
index 2107f9648..a6c5f92ca 100644
--- a/src/mailman/app/tests/test_registrar.py
+++ b/src/mailman/app/tests/test_workflowmanager.py
@@ -18,18 +18,19 @@
"""Test email address registration."""
import unittest
+import pdb
from mailman.app.lifecycle import create_list
from mailman.interfaces.mailinglist import SubscriptionPolicy
from mailman.interfaces.member import MemberRole
from mailman.interfaces.pending import IPendings
-from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.workflowmanager import IWorkflowManager
from mailman.interfaces.subscriptions import TokenOwner
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import get_queue_messages
from mailman.testing.layers import ConfigLayer
from mailman.utilities.datetime import now
-from zope.component import getUtility
+from zope.component import getUtility, getAdapter
class TestRegistrar(unittest.TestCase):
@@ -39,7 +40,8 @@ class TestRegistrar(unittest.TestCase):
def setUp(self):
self._mlist = create_list('ant@example.com')
- self._registrar = IRegistrar(self._mlist)
+ self._registrar = getAdapter(
+ self._mlist, IWorkflowManager, name='subscribe')
self._pendings = getUtility(IPendings)
self._anne = getUtility(IUserManager).create_address(
'anne@example.com')
diff --git a/src/mailman/app/unsubscriptions.py b/src/mailman/app/unsubscriptions.py
new file mode 100644
index 000000000..1a88b121f
--- /dev/null
+++ b/src/mailman/app/unsubscriptions.py
@@ -0,0 +1,255 @@
+# Copyright (C) 2016 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/>.
+
+"""Handle un-subscriptions."""
+
+import uuid
+import logging
+
+from datetime import timedelta
+from email.utils import formataddr
+from mailman import public
+from mailman.app.membership import delete_member
+from mailman.app.subscriptions import WhichSubscriber
+from mailman.app.workflow import Workflow
+from mailman.core.i18n import _
+from mailman.email.message import UserNotification
+from mailman.interfaces.address import IAddress
+from mailman.interfaces.mailinglist import SubscriptionPolicy
+from mailman.interfaces.workflowmanager import ConfirmationNeededEvent
+from mailman.interfaces.user import IUser
+from mailman.interfaces.pending import IPendings, IPendable
+from mailman.interfaces.subscriptions import TokenOwner
+from mailman.interfaces.usermanager import IUserManager
+from mailman.interfaces.workflow import IWorkflowStateManager
+from mailman.utilities.datetime import now
+from mailman.utilities.i18n import make
+from zope.component import getUtility
+from zope.event import notify
+from zope.interface import implementer
+
+
+log = logging.getLogger('mailman.subscribe')
+
+
+@implementer(IPendable)
+class Pendable(dict):
+ PEND_TYPE = 'unsubscription'
+
+
+class UnSubscriptionWorkflow(Workflow):
+ """Workflow of a un-subscription request
+ """
+
+ INITIAL_STATE = 'subscription_checks'
+ SAVE_ATTRIBUTES = (
+ 'pre_approved',
+ 'pre_confirmed',
+ 'address_key',
+ 'user_key',
+ 'subscriber_key',
+ 'token_owner_key',
+ )
+
+ def __init__(self, mlist, subscriber=None, *,
+ pre_approved=False, pre_confirmed = False):
+ super().__init__()
+ self.mlist = mlist
+ self.address = None
+ self.user = None
+ self.which = None
+ self._set_token(TokenOwner.no_one)
+ # `subscriber` should be an implementer of IAddress.
+ if IAddress.providedBy(subscriber):
+ self.address = subscriber
+ self.user = self.address.user
+ self.which = WhichSubscriber.address
+ self.member = self.mlist.regular_members.get_member(
+ self.address.email)
+ elif IUser.providedBy(subscriber):
+ self.address = subscriber.preferred_address
+ self.user = subscriber
+ self.which = WhichSubscriber.address
+ self.member = self.mlist.regular_members.get_member(
+ self.address.email)
+ self.subscriber = subscriber
+ 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_subscription_checks(self):
+ assert self.mlist.is_subscribed(self.subscriber)
+ self.push('confirmation_checks')
+
+ def _step_confirmation_checks(self):
+ # If list's unsubscription policy is open, the user can unsubscribe
+ # right now.
+ if self.mlist.unsubscription_policy is SubscriptionPolicy.open:
+ self.push('do_unsubscription')
+ return
+ # If we don't need the user's confirmation, then skip to the moderation
+ # checks
+ if self.mlist.unsubscription_policy is SubscriptionPolicy.moderate:
+ self.push('moderation_checks')
+ return
+
+ if self.pre_confirmed:
+ next_step = ('moderation_checks'
+ if self.mlist.subscription_policy is
+ SubscriptionPolicy.confirm_then_moderate # noqa
+ else 'do_subscription')
+ self.push(next_step)
+ return
+ # The user must confirm their un-subsbcription.
+ self.push('send_confirmation')
+
+ def _step_send_confirmation(self):
+ self._set_token(TokenOwner.subscriber)
+ self.push('do_confirm_verify')
+ self.save()
+ notify(ConfirmationNeededEvent(
+ self.mlist, self.token, self.address.email))
+ raise StopIteration
+
+ def _step_moderation_checks(self):
+ # Does the moderator need to approve the unsubscription request.
+ assert self.mlist.unsubscription_policy in (
+ SubscriptionPolicy.moderate,
+ SubscriptionPolicy.confirm_then_moderate,
+ ), self.mlist.unsubscription_policy
+ if self.pre_approved:
+ self.push('do_unsubscription')
+ else:
+ self.push('get_moderator_approval')
+
+ def _step_get_moderator_approval(self):
+ self._set_token(TokenOwner.moderator)
+ self.push('unsubscribe_from_restored')
+ self.save()
+ log.info('{}: held unsubscription request from {}'.format(
+ self.mlist.fqdn_listname, self.address.email))
+ if self.mlist.admin_immed_notify:
+ subject = _(
+ 'New unsubscription request to $self.mlist.display_name '
+ 'from $self.address.email')
+ username = formataddr(
+ (self.subscriber.display_name, self.address.email))
+ text = make('unsubauth.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_do_confirm_verify(self):
+ 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)
+ next_step = ('moderation_checks'
+ if self.mlist.unsubscription_policy in (
+ SubscriptionPolicy.moderate,
+ SubscriptionPolicy.confirm_then_moderate,
+ )
+ else 'do_unsubscription')
+ self.push('do_unsubscription')
+
+ def _step_do_unsubscription(self):
+ delete_member(self.mlist, self.address.email)
+ self.member = None
+ # This workflow is done so throw away any associated state.
+ getUtility(IWorkflowStateManager).restore(self.name, self.token)
+
+ def _step_unsubscribe_from_restored(self):
+ # Prevent replay attacks.
+ self._set_token(TokenOwner.no_one)
+ if self.which is WhichSubscriber.address:
+ self.subscriber = self.address
+ else:
+ assert self.which is WhichSubsriber.user
+ self.subscriber = self.user
+ self.push('do_unsubscription')
diff --git a/src/mailman/app/registrar.py b/src/mailman/app/workflowmanager.py
index 7cefbd518..deb15ea76 100644
--- a/src/mailman/app/registrar.py
+++ b/src/mailman/app/workflowmanager.py
@@ -15,17 +15,19 @@
# 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 IRegistrar interface."""
+"""Implementation of the IWorkflowManager interface."""
import logging
from mailman import public
from mailman.app.subscriptions import SubscriptionWorkflow
+from mailman.app.unsubscriptions import UnSubscriptionWorkflow
from mailman.database.transaction import flush
from mailman.email.message import UserNotification
from mailman.interfaces.pending import IPendable, IPendings
-from mailman.interfaces.registrar import ConfirmationNeededEvent, IRegistrar
-from mailman.interfaces.template import ITemplateLoader
+from mailman.interfaces.workflowmanager import (
+ ConfirmationNeededEvent, IWorkflowManager)
+from mailman.interfaces.templates import ITemplateLoader
from mailman.interfaces.workflow import IWorkflowStateManager
from mailman.utilities.string import expand
from zope.component import getUtility
@@ -40,17 +42,56 @@ class PendableRegistration(dict):
PEND_TYPE = 'registration'
-@public
-@implementer(IRegistrar)
-class Registrar:
- """Handle registrations and confirmations for subscriptions."""
+class BaseWorkflowManager:
+ """Base class to handle registration and un-registration workflow. """
+
+ # Workflow type is the type of the workflow and could be either 'register'
+ # or 'unregister' depending on if it is for subscription workflow or
+ # unsubscription workflow.
+
+ WORKFLOW_TYPE = None
def __init__(self, mlist):
self._mlist = mlist
+ def confirm(self, token):
+ workflow = self.workflowClass(self._mlist)
+ workflow.token = token
+ workflow.restore()
+ # In order to just run the whole workflow, all we need to do
+ # is iterate over the workflow object. On calling the __next__
+ # over the workflow iterator it automatically executes the steps
+ # that needs to be done.
+ list(workflow)
+ return workflow.token, workflow.token_owner, workflow.member
+
+ @property
+ def workflowClass(self):
+ if self.WORKFLOW_TYPE == 'subscribe':
+ return SubscriptionWorkflow
+ elif self.WORKFLOW_TYPE == 'unsubscribe':
+ return UnSubscriptionWorkflow
+ else:
+ raise ValueError('Invalid workflow type {}'.format(
+ self.WORKFLOW_TYPE))
+
+ def discard(self, token):
+ with flush():
+ getUtility(IPendings).confirm(token)
+ getUtility(IWorkflowStateManager).discard(
+ self.workflowClass.__name__, token)
+
+
+@public
+@implementer(IWorkflowManager)
+class SubscriptionWorkflowManager(BaseWorkflowManager):
+ """Handle registrations and confirmations for subscriptions."""
+
+ WORKFLOW_TYPE = 'subscribe'
+
def register(self, subscriber=None, *,
pre_verified=False, pre_confirmed=False, pre_approved=False):
- """See `IRegistrar`."""
+ """See `IWorkflowManager`."""
workflow = SubscriptionWorkflow(
self._mlist, subscriber,
pre_verified=pre_verified,
@@ -59,20 +100,21 @@ class Registrar:
list(workflow)
return workflow.token, workflow.token_owner, workflow.member
- def confirm(self, token):
- """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):
- """See `IRegistrar`."""
- with flush():
- getUtility(IPendings).confirm(token)
- getUtility(IWorkflowStateManager).discard(
- SubscriptionWorkflow.__name__, token)
+@public
+@implementer(IWorkflowManager)
+class UnsubscriptionWorkflowManager(BaseWorkflowManager):
+ """Handle un-subscriptions and confirmations for un-subscriptions."""
+
+ WORKFLOW_TYPE = 'unsubscribe'
+
+ def unregister(self, subscriber=None, *,
+ pre_confirmed=False, pre_approved=False):
+ workflow = UnSubscriptionWorkflow(
+ self._mlist, subscriber,
+ pre_confirmed=pre_confirmed,
+ pre_approved=pre_approved)
+ list(workflow)
@public
diff --git a/src/mailman/commands/eml_confirm.py b/src/mailman/commands/eml_confirm.py
index 6a3e389bd..8522f8fa0 100644
--- a/src/mailman/commands/eml_confirm.py
+++ b/src/mailman/commands/eml_confirm.py
@@ -20,8 +20,9 @@
from mailman import public
from mailman.core.i18n import _
from mailman.interfaces.command import ContinueProcessing, IEmailCommand
-from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.workflowmanager import IWorkflowManager
from mailman.interfaces.subscriptions import TokenOwner
+from zope.component import getAdapter
from zope.interface import implementer
@@ -50,7 +51,8 @@ class Confirm:
tokens.add(token)
results.confirms = tokens
try:
- new_token, token_owner, member = IRegistrar(mlist).confirm(token)
+ new_token, token_owner, member = getAdapter(
+ mlist, IWorkflowManager, name='subscribe').confirm(token)
if new_token is None:
assert token_owner is TokenOwner.no_one, token_owner
assert member is not None, member
diff --git a/src/mailman/commands/eml_membership.py b/src/mailman/commands/eml_membership.py
index 7d3704e14..af391fd5b 100644
--- a/src/mailman/commands/eml_membership.py
+++ b/src/mailman/commands/eml_membership.py
@@ -22,16 +22,16 @@ from mailman import public
from mailman.core.i18n import _
from mailman.interfaces.command import ContinueProcessing, IEmailCommand
from mailman.interfaces.member import DeliveryMode, MemberRole
-from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.workflowmanager import IWorkflowManager
from mailman.interfaces.subscriptions import ISubscriptionService
from mailman.interfaces.usermanager import IUserManager
-from zope.component import getUtility
+from zope.component import getUtility, getAdapter
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.
+ # subscriber by the IWorkflowManager interface.
manager = getUtility(IUserManager)
# Is there a user with a preferred address matching the email?
user = manager.get_user(email)
@@ -101,7 +101,8 @@ used.
print(_('$person is already a member'), file=results)
return ContinueProcessing.yes
subscriber = match_subscriber(email, display_name)
- IRegistrar(mlist).register(subscriber)
+ getAdapter(mlist,
+ IWorkflowManager, name='subscribe').register(subscriber)
print(_('Confirmation email sent to $person'), file=results)
return ContinueProcessing.yes
@@ -186,9 +187,12 @@ You may be asked to confirm your request.""")
'$self.name: $email is not a member of $mlist.fqdn_listname'),
file=results)
return ContinueProcessing.no
- member.unsubscribe()
- person = formataddr((user.display_name, email)) # noqa: F841
- print(_('$person left $mlist.fqdn_listname'), file=results)
+ getAdapter(mlist,
+ IWorkflowManager, name='unsubscribe').register(user_address)
+ # member.unsubscribe()
+ person = formataddr((user.display_name, email)) # noqa
+ print(_('Confirmation email sent to $person to leave'
+ ' $mlist.fqdn_listname'), file=results)
return ContinueProcessing.yes
diff --git a/src/mailman/commands/tests/test_confirm.py b/src/mailman/commands/tests/test_confirm.py
index 7cce4c3c7..d7414fbfa 100644
--- a/src/mailman/commands/tests/test_confirm.py
+++ b/src/mailman/commands/tests/test_confirm.py
@@ -25,12 +25,12 @@ from mailman.config import config
from mailman.email.message import Message
from mailman.interfaces.command import ContinueProcessing
from mailman.interfaces.mailinglist import SubscriptionPolicy
-from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.workflowmanager import IWorkflowManager
from mailman.interfaces.usermanager import IUserManager
from mailman.runners.command import CommandRunner, Results
from mailman.testing.helpers import get_queue_messages, make_testable_runner
from mailman.testing.layers import ConfigLayer
-from zope.component import getUtility
+from zope.component import getUtility, getAdapter
class TestConfirm(unittest.TestCase):
@@ -42,8 +42,8 @@ class TestConfirm(unittest.TestCase):
self._mlist = create_list('test@example.com')
anne = getUtility(IUserManager).create_address(
'anne@example.com', 'Anne Person')
- self._token, token_owner, member = IRegistrar(self._mlist).register(
- anne)
+ self._token, token_owner, member = getAdapter(
+ self._mlist, IWorkflowManager, name='subscribe').register(anne)
self._command = Confirm()
# Clear the virgin queue.
get_queue_messages('virgin')
@@ -88,8 +88,8 @@ class TestEmailResponses(unittest.TestCase):
'bart@example.com', 'Bart Person')
# Clear any previously queued confirmation messages.
get_queue_messages('virgin')
- self._token, token_owner, member = IRegistrar(self._mlist).register(
- bart)
+ self._token, token_owner, member = getAdapter(
+ self._mlist, IWorkflowManager, name='subscribe').register(bart)
# There should now be one email message in the virgin queue, i.e. the
# confirmation message sent to Bart.
items = get_queue_messages('virgin', expected_count=1)
diff --git a/src/mailman/config/configure.zcml b/src/mailman/config/configure.zcml
index f31e79525..153a8b9bd 100644
--- a/src/mailman/config/configure.zcml
+++ b/src/mailman/config/configure.zcml
@@ -48,8 +48,16 @@
<adapter
for="mailman.interfaces.mailinglist.IMailingList"
- provides="mailman.interfaces.registrar.IRegistrar"
- factory="mailman.app.registrar.Registrar"
+ provides="mailman.interfaces.workflowmanager.IWorkflowManager"
+ factory="mailman.app.workflowmanager.SubscriptionWorkflowManager"
+ name='subscribe'
+ />
+
+ <adapter
+ for="mailman.interfaces.mailinglist.IMailingList"
+ provides="mailman.interfaces.workflowmanager.IWorkflowManager"
+ factory="mailman.app.workflowmanager.UnsubscriptionWorkflowManager"
+ name='unsubscribe'
/>
<utility
diff --git a/src/mailman/database/alembic/versions/448a93984c35_unsubscription_workflow.py b/src/mailman/database/alembic/versions/448a93984c35_unsubscription_workflow.py
new file mode 100644
index 000000000..51be76051
--- /dev/null
+++ b/src/mailman/database/alembic/versions/448a93984c35_unsubscription_workflow.py
@@ -0,0 +1,42 @@
+"""unsubscription_workflow
+
+Revision ID: 448a93984c35
+Revises: 7b254d88f122
+Create Date: 2016-06-02 14:34:24.154723
+
+"""
+
+# revision identifiers, used by Alembic.
+revision = '448a93984c35'
+down_revision = '7b254d88f122'
+
+import sqlalchemy as sa
+from alembic import op
+from mailman.database.helpers import is_sqlite, exists_in_db
+from mailman.database.types import Enum
+from mailman.interfaces.mailinglist import SubscriptionPolicy
+
+def upgrade():
+ if not exists_in_db(op.get_bind(), 'mailinglist', 'unsubscription_policy'):
+ # SQLite may not have removed it when downgrading.
+ op.add_column('mailinglist', sa.Column(
+ 'unsubscription_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('unsubscription_policy', Enum(SubscriptionPolicy))
+ )
+ # There were no enforced subscription policy before, so all lists are
+ # considered open.
+ op.execute(mlist.update().values(
+ {'unsubscription_policy':
+ op.inline_literal(SubscriptionPolicy.open)}))
+
+
+def downgrade():
+ if not is_sqlite(op.get_bind()):
+ # SQLite does not support dropping columns.
+ op.drop_column('mailinglist', 'unsubscription_policy')
diff --git a/src/mailman/interfaces/mailinglist.py b/src/mailman/interfaces/mailinglist.py
index 19db025d1..40ee95187 100644
--- a/src/mailman/interfaces/mailinglist.py
+++ b/src/mailman/interfaces/mailinglist.py
@@ -46,6 +46,7 @@ class ReplyToMunging(Enum):
@public
class SubscriptionPolicy(Enum):
+ """All subscription/unsubscription policies for a mailing list."""
# Neither confirmation, nor moderator approval is required.
open = 0
# The user must confirm the subscription.
@@ -57,6 +58,7 @@ class SubscriptionPolicy(Enum):
confirm_then_moderate = 3
+
@public
class IMailingList(Interface):
"""A mailing list."""
diff --git a/src/mailman/interfaces/registrar.py b/src/mailman/interfaces/workflowmanager.py
index e2cec5fbb..906246e6d 100644
--- a/src/mailman/interfaces/registrar.py
+++ b/src/mailman/interfaces/workflowmanager.py
@@ -41,12 +41,13 @@ class ConfirmationNeededEvent:
@public
-class IRegistrar(Interface):
- """Interface for subscribing addresses and users.
+class IWorkflowManager(Interface):
+ """Interface for handling subscription and un-subscription of 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.
+ This is a higher level interface to user registration and un-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.
"""
diff --git a/src/mailman/model/mailinglist.py b/src/mailman/model/mailinglist.py
index 933384797..396b8ed3d 100644
--- a/src/mailman/model/mailinglist.py
+++ b/src/mailman/model/mailinglist.py
@@ -178,6 +178,7 @@ class MailingList(Model):
topics = Column(PickleType)
topics_bodylines_limit = Column(Integer)
topics_enabled = Column(Boolean)
+ unsubscription_policy = Column(Enum(SubscriptionPolicy))
# ORM relationships.
header_matches = relationship(
'HeaderMatch', backref='mailing_list',
@@ -441,16 +442,14 @@ class MailingList(Model):
raise ValueError('Undefined MemberRole: {}'.format(role))
@dbconnection
- def subscribe(self, store, subscriber, role=MemberRole.member):
- """See `IMailingList`."""
+ def is_subscribed(self, store, subscriber, role=MemberRole.member):
+ """Check if a user/address is subscribed to this list."""
+ member = None
if IAddress.providedBy(subscriber):
member = store.query(Member).filter(
Member.role == role,
Member.list_id == self._list_id,
Member._address == subscriber).first()
- if member:
- raise AlreadySubscribedError(
- self.fqdn_listname, subscriber.email, role)
elif IUser.providedBy(subscriber):
if subscriber.preferred_address is None:
raise MissingPreferredAddressError(subscriber)
@@ -458,13 +457,23 @@ class MailingList(Model):
Member.role == role,
Member.list_id == self._list_id,
Member._user == subscriber).first()
- if member:
- raise AlreadySubscribedError(
- self.fqdn_listname,
- subscriber.preferred_address.email,
- role)
+
+ if member:
+ return True
else:
- raise ValueError('subscriber must be an address or user')
+ return False
+
+ @dbconnection
+ def subscribe(self, store, subscriber, role=MemberRole.member):
+ """See `IMailingList`."""
+ if IAddress.providedBy(subscriber):
+ email = subscriber.email
+ elif IUser.providedBy(subscriber):
+ email = subscriber.preferred_address.email
+
+ if self.is_subscribed(subscriber, role):
+ raise AlreadySubscribedError(self.fqdn_listname, email, role)
+
member = Member(role=role,
list_id=self._list_id,
subscriber=subscriber)
diff --git a/src/mailman/model/tests/test_mailinglist.py b/src/mailman/model/tests/test_mailinglist.py
index c9aecc93b..3e3c5814c 100644
--- a/src/mailman/model/tests/test_mailinglist.py
+++ b/src/mailman/model/tests/test_mailinglist.py
@@ -17,6 +17,7 @@
"""Test MailingLists and related model objects.."""
+import pdb
import unittest
from mailman.app.lifecycle import create_list
@@ -92,6 +93,18 @@ class TestMailingList(unittest.TestCase):
self.assertIn('Anne Person <aperson@example.com>',
items[0].msg.get_payload())
+ def test_is_subscribed(self):
+ manager = getUtility(IUserManager)
+ user = manager.create_user('anne@example.com', 'Anne Person')
+ set_preferred(user)
+ self.assertEqual(False, self._mlist.is_subscribed(user))
+ self._mlist.subscribe(user)
+ self.assertEqual(True, self._mlist.is_subscribed(user))
+ address = manager.create_address('anne2@example.com', 'Anne Person')
+ address.verfied_on = now()
+ self.assertEqual(False, self._mlist.is_subscribed(address))
+ self._mlist.subscribe(address)
+ self.assertEqual(True, self._mlist.is_subscribed(address))
class TestListArchiver(unittest.TestCase):
layer = ConfigLayer
diff --git a/src/mailman/model/tests/test_workflow.py b/src/mailman/model/tests/test_workflow.py
index 4c8c6776f..afcba613b 100644
--- a/src/mailman/model/tests/test_workflow.py
+++ b/src/mailman/model/tests/test_workflow.py
@@ -124,7 +124,7 @@ class TestWorkflow(unittest.TestCase):
self.assertEqual(self._manager.count, 1)
def test_discard(self):
- # Discard some workflow state. This is use by IRegistrar.discard().
+ # Discard some workflow state. This is use by IWorkflowManager.discard().
self._manager.save('ant', 'token', 'one')
self._manager.save('bee', 'token', 'two')
self._manager.save('ant', 'nekot', 'three')
diff --git a/src/mailman/rest/members.py b/src/mailman/rest/members.py
index 11ad17c14..ac3fd96b4 100644
--- a/src/mailman/rest/members.py
+++ b/src/mailman/rest/members.py
@@ -25,7 +25,7 @@ from mailman.interfaces.listmanager import IListManager
from mailman.interfaces.member import (
AlreadySubscribedError, DeliveryMode, MemberRole, MembershipError,
MembershipIsBannedError, MissingPreferredAddressError)
-from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.workflowmanager import IWorkflowManager
from mailman.interfaces.subscriptions import (
ISubscriptionService, RequestRecord, SubscriptionPendingError, TokenOwner)
from mailman.interfaces.user import IUser, UnverifiedAddressError
@@ -37,7 +37,7 @@ from mailman.rest.preferences import Preferences, ReadOnlyPreferences
from mailman.rest.validator import (
Validator, enum_validator, subscriber_validator)
from uuid import UUID
-from zope.component import getUtility
+from zope.component import getUtility, getAdapter
class _MemberBase(CollectionMixin):
@@ -253,7 +253,7 @@ class AllMembers(_MemberBase):
# 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)
+ registrar = getAdapter(mlist, IWorkflowManager, name='subscribe')
try:
token, token_owner, member = registrar.register(
subscriber,
diff --git a/src/mailman/rest/sub_moderation.py b/src/mailman/rest/sub_moderation.py
index f5ef072ff..a6ef2ad35 100644
--- a/src/mailman/rest/sub_moderation.py
+++ b/src/mailman/rest/sub_moderation.py
@@ -23,12 +23,13 @@ from mailman.core.i18n import _
from mailman.interfaces.action import Action
from mailman.interfaces.member import AlreadySubscribedError
from mailman.interfaces.pending import IPendings
-from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.workflowmanager import IWorkflowManager
from mailman.rest.helpers import (
CollectionMixin, bad_request, child, conflict, etag, no_content,
not_found, okay)
from mailman.rest.validator import Validator, enum_validator
-from zope.component import getUtility
+from mailman.utilities.i18n import _
+from zope.component import getUtility, getAdapter
class _ModerationBase:
@@ -54,7 +55,8 @@ class IndividualRequest(_ModerationBase):
def __init__(self, mlist, token):
super().__init__()
self._mlist = mlist
- self._registrar = IRegistrar(self._mlist)
+ self._registrar = getAdapter(
+ self._mlist, IWorkflowManager, name='subscribe')
self._token = token
def on_get(self, request, response):
diff --git a/src/mailman/rest/tests/test_membership.py b/src/mailman/rest/tests/test_membership.py
index 1ea70e90b..9dec17fc8 100644
--- a/src/mailman/rest/tests/test_membership.py
+++ b/src/mailman/rest/tests/test_membership.py
@@ -25,7 +25,7 @@ from mailman.database.transaction import transaction
from mailman.interfaces.bans import IBanManager
from mailman.interfaces.mailinglist import SubscriptionPolicy
from mailman.interfaces.member import DeliveryMode, MemberRole
-from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.workflowmanager import IWorkflowManager
from mailman.interfaces.subscriptions import TokenOwner
from mailman.interfaces.usermanager import IUserManager
from mailman.runners.incoming import IncomingRunner
@@ -35,7 +35,7 @@ from mailman.testing.helpers import (
from mailman.testing.layers import ConfigLayer, RESTLayer
from mailman.utilities.datetime import now
from urllib.error import HTTPError
-from zope.component import getUtility
+from zope.component import getUtility, getAdapter
class TestMembership(unittest.TestCase):
@@ -215,7 +215,7 @@ class TestMembership(unittest.TestCase):
def test_duplicate_pending_subscription(self):
# Issue #199 - a member's subscription is already pending and they try
# to subscribe again.
- registrar = IRegistrar(self._mlist)
+ registrar = getAdapter(self._mlist, IWorkflowManager, name='subscribe')
with transaction():
self._mlist.subscription_policy = SubscriptionPolicy.moderate
anne = self._usermanager.create_address('anne@example.com')
@@ -238,7 +238,7 @@ class TestMembership(unittest.TestCase):
# Issue #199 - a member's subscription is already pending and they try
# to subscribe again. Unlike above, this pend is waiting for the user
# to confirm their subscription.
- registrar = IRegistrar(self._mlist)
+ registrar = getAdapter(self._mlist, IWorkflowManager, name='subscribe')
with transaction():
self._mlist.subscription_policy = (
SubscriptionPolicy.confirm_then_moderate)
diff --git a/src/mailman/rest/tests/test_moderation.py b/src/mailman/rest/tests/test_moderation.py
index 9f9da6b18..1b9febdce 100644
--- a/src/mailman/rest/tests/test_moderation.py
+++ b/src/mailman/rest/tests/test_moderation.py
@@ -24,7 +24,7 @@ from mailman.app.moderator import hold_message
from mailman.database.transaction import transaction
from mailman.interfaces.bans import IBanManager
from mailman.interfaces.mailinglist import SubscriptionPolicy
-from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.workflowmanager import IWorkflowManager
from mailman.interfaces.requests import IListRequests, RequestType
from mailman.interfaces.usermanager import IUserManager
from mailman.testing.helpers import (
@@ -32,7 +32,7 @@ from mailman.testing.helpers import (
specialized_message_from_string as mfs)
from mailman.testing.layers import RESTLayer
from urllib.error import HTTPError
-from zope.component import getUtility
+from zope.component import getUtility, getAdapter
class TestPostModeration(unittest.TestCase):
@@ -150,7 +150,8 @@ class TestSubscriptionModeration(unittest.TestCase):
def setUp(self):
with transaction():
self._mlist = create_list('ant@example.com')
- self._registrar = IRegistrar(self._mlist)
+ self._registrar = getAdapter(
+ self._mlist, IWorkflowManager, name='subscribe')
manager = getUtility(IUserManager)
self._anne = manager.create_address(
'anne@example.com', 'Anne Person')
diff --git a/src/mailman/runners/tests/test_confirm.py b/src/mailman/runners/tests/test_confirm.py
index 7dc2403fc..d49ba217b 100644
--- a/src/mailman/runners/tests/test_confirm.py
+++ b/src/mailman/runners/tests/test_confirm.py
@@ -24,14 +24,14 @@ from email.iterators import body_line_iterator
from mailman.app.lifecycle import create_list
from mailman.config import config
from mailman.database.transaction import transaction
-from mailman.interfaces.registrar import IRegistrar
+from mailman.interfaces.workflowmanager import IWorkflowManager
from mailman.interfaces.usermanager import IUserManager
from mailman.runners.command import CommandRunner
from mailman.testing.helpers import (
get_queue_messages, make_testable_runner,
specialized_message_from_string as mfs)
from mailman.testing.layers import ConfigLayer
-from zope.component import getUtility
+from zope.component import getUtility, getAdapter
class TestConfirm(unittest.TestCase):
@@ -47,7 +47,8 @@ class TestConfirm(unittest.TestCase):
self._mlist = create_list('test@example.com')
self._mlist.send_welcome_message = False
anne = getUtility(IUserManager).create_address('anne@example.org')
- registrar = IRegistrar(self._mlist)
+ registrar = getAdapter(
+ self._mlist, IWorkflowManager, name='subscribe')
self._token, token_owner, member = registrar.register(anne)
def test_confirm_with_re_prefix(self):
diff --git a/src/mailman/runners/tests/test_join.py b/src/mailman/runners/tests/test_join.py
index 0f24f3847..7409c8e89 100644
--- a/src/mailman/runners/tests/test_join.py
+++ b/src/mailman/runners/tests/test_join.py
@@ -23,7 +23,7 @@ from email.iterators import body_line_iterator
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.workflowmanager import IWorkflowManager
from mailman.interfaces.subscriptions import ISubscriptionService, TokenOwner
from mailman.interfaces.usermanager import IUserManager
from mailman.runners.command import CommandRunner
@@ -31,7 +31,7 @@ from mailman.testing.helpers import (
get_queue_messages, make_testable_runner,
specialized_message_from_string as mfs)
from mailman.testing.layers import ConfigLayer
-from zope.component import getUtility
+from zope.component import getUtility, getAdapter
class TestJoin(unittest.TestCase):
@@ -145,7 +145,8 @@ class TestJoinWithDigests(unittest.TestCase):
subject_words = str(items[1].msg['subject']).split()
self.assertEqual(subject_words[0], 'confirm')
token = subject_words[1]
- token, token_owner, rmember = IRegistrar(self._mlist).confirm(token)
+ token, token_owner, rmember = getAdapter(
+ self._mlist, IWorkflowManager, name='subscribe').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
diff --git a/src/mailman/runners/tests/test_leave.py b/src/mailman/runners/tests/test_leave.py
new file mode 100644
index 000000000..23257124a
--- /dev/null
+++ b/src/mailman/runners/tests/test_leave.py
@@ -0,0 +1,81 @@
+# Copyright (C) 2016 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 mailing list un-subscriptions."""
+
+import pdb
+import unittest
+
+from mailman.app.lifecycle import create_list
+from mailman.config import config
+from mailman.database.transaction import transaction
+from mailman.interfaces.usermanager import IUserManager
+from mailman.runners.command import CommandRunner
+from mailman.testing.helpers import (
+ get_queue_messages, make_testable_runner,
+ specialized_message_from_string as mfs)
+from mailman.testing.layers import ConfigLayer
+from mailman.testing.helpers import set_preferred
+from mailman.utilities.datetime import now
+from zope.component import getUtility, getAdapter
+
+class TestLeave(unittest.TestCase):
+ """Test mailing list un-subscriptions"""
+
+ layer = ConfigLayer
+
+ def setUp(self):
+ self._mlist = create_list('test@example.com')
+ self._mlist.send_welcome_message = False
+ self._commandq = config.switchboards['command']
+ self._runner = make_testable_runner(CommandRunner, 'command')
+
+ def test_leave(self):
+ with transaction():
+ anne = getUtility(IUserManager).create_user('anne@example.org')
+ set_preferred(anne)
+ self._mlist.subscribe(list(anne.addresses)[0])
+ msg = mfs("""\
+From: anne@example.org
+To: test-leave@example.com
+
+leave
+""")
+ self._commandq.enqueue(msg, dict(listid='test.example.com',
+ subaddress='leave'))
+ self._runner.run()
+ items = get_queue_messages('virgin', sort_on='subject',
+ expected_count=1)
+ print(items[0].msg)
+ print(anne.addresses)
+ pdb.set_trace()
+ self.assertTrue(str(items[0].msg['subject']).startswith('confirm'))
+ confirmation_lines = []
+ in_results = False
+ for line in body_line_iterator(items[0].msg):
+ line = line.strip()
+ if in_results:
+ if line.startswith('- Done'):
+ break
+ if len(line) > 0:
+ confirmation_lines.append(line)
+ if line.strip() == '- Results:':
+ in_results = True
+ # There should be exactly one confirmation line.
+ self.assertEqual(len(confirmation_lines), 1)
+ # And the confirmation line should name Anne's email address.
+ self.assertIn('anne@example.org', confirmation_lines[0])
diff --git a/src/mailman/styles/base.py b/src/mailman/styles/base.py
index 6620e85f2..7226d762a 100644
--- a/src/mailman/styles/base.py
+++ b/src/mailman/styles/base.py
@@ -67,6 +67,7 @@ class BasicOperation:
mlist.default_member_action = Action.defer
mlist.default_nonmember_action = Action.hold
mlist.subscription_policy = SubscriptionPolicy.confirm
+ mlist.unsubscription_policy = SubscriptionPolicy.confirm
# Notify the administrator of pending requests and membership changes.
mlist.admin_immed_notify = True
mlist.admin_notify_mchanges = False