summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorBarry Warsaw2016-08-23 21:52:53 -0400
committerBarry Warsaw2016-08-29 12:06:02 -0400
commit5db5eb3417983ff6ea4c7979028bb525c4d1e332 (patch)
treebd354ea42eb073e9f0db94809edd4d89760c5a2f /src
parent63de6706811936070b4979353c371a849b46af85 (diff)
downloadmailman-5db5eb3417983ff6ea4c7979028bb525c4d1e332.tar.gz
mailman-5db5eb3417983ff6ea4c7979028bb525c4d1e332.tar.zst
mailman-5db5eb3417983ff6ea4c7979028bb525c4d1e332.zip
Checkpointing.
Diffstat (limited to 'src')
-rw-r--r--src/mailman/app/subscriptions.py301
-rw-r--r--src/mailman/interfaces/subscriptions.py90
-rw-r--r--src/mailman/interfaces/workflowmanager.py113
3 files changed, 386 insertions, 118 deletions
diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py
index 0cd056b99..c7aaef43d 100644
--- a/src/mailman/app/subscriptions.py
+++ b/src/mailman/app/subscriptions.py
@@ -26,6 +26,7 @@ from enum import Enum
from mailman import public
from mailman.app.workflow import Workflow
from mailman.core.i18n import _
+from mailman.database.transaction import flush
from mailman.email.message import UserNotification
from mailman.interfaces.address import IAddress
from mailman.interfaces.bans import IBanManager
@@ -35,7 +36,8 @@ from mailman.interfaces.member import MembershipIsBannedError
from mailman.interfaces.pending import IPendable, IPendings
from mailman.interfaces.workflowmanager import ConfirmationNeededEvent
from mailman.interfaces.subscriptions import (
- ISubscriptionService, SubscriptionPendingError, TokenOwner)
+ ISubscriptionManager, ISubscriptionService, SubscriptionPendingError,
+ TokenOwner)
from mailman.interfaces.template import ITemplateLoader
from mailman.interfaces.user import IUser
from mailman.interfaces.usermanager import IUserManager
@@ -56,10 +58,15 @@ class WhichSubscriber(Enum):
@implementer(IPendable)
-class Pendable(dict):
+class PendableSubscription(dict):
PEND_TYPE = 'subscription'
+@implementer(IPendable)
+class PendableRegistration(dict):
+ PEND_TYPE = 'registration'
+
+
@public
class SubscriptionWorkflow(Workflow):
"""Workflow of a subscription request."""
@@ -150,7 +157,7 @@ class SubscriptionWorkflow(Workflow):
if token_owner is TokenOwner.no_one:
self.token = None
return
- pendable = Pendable(
+ pendable = PendableSubscription(
list_id=self.mlist.list_id,
email=self.address.email,
display_name=self.address.display_name,
@@ -328,6 +335,294 @@ class SubscriptionWorkflow(Workflow):
self.push(next_step)
+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 = PendableRegistration(
+ 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')
+
+
+class BaseSubscriptionManager:
+ """Base class to handle registration and un-registration workflows."""
+
+ def __init__(self, mlist):
+ self._mlist = mlist
+
+ def confirm(self, token):
+ workflow = self.__class__(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
+
+ def discard(self, token):
+ with flush():
+ getUtility(IPendings).confirm(token)
+ getUtility(IWorkflowStateManager).discard(
+ self.WORKFLOW_TYPE.name, token)
+
+
+@public
+@implementer(ISubscriptionManager)
+class SubscriptionWorkflowManager(BaseSubscriptionManager):
+ """Handle registrations and confirmations for subscriptions."""
+
+ def register(self, subscriber=None, *,
+ pre_verified=False, pre_confirmed=False, pre_approved=False):
+ """See `IWorkflowManager`."""
+ 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
+
+
+@public
+@implementer(ISubscriptionManager)
+class UnsubscriptionWorkflowManager(BaseSubscriptionManager):
+ """Handle un-subscriptions and confirmations for un-subscriptions."""
+
+ 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
+def handle_ConfirmationNeededEvent(event):
+ if not isinstance(event, ConfirmationNeededEvent):
+ return
+ # There are three ways for a user to confirm their subscription. They
+ # can reply to the original message and let the VERP'd return address
+ # encode the token, they can reply to the robot and keep the token in
+ # the Subject header, or they can click on the URL in the body of the
+ # message and confirm through the web.
+ subject = 'confirm {}'.format(event.token)
+ confirm_address = event.mlist.confirm_address(event.token)
+ email_address = event.email
+ # Send a verification email to the address.
+ template = getUtility(ITemplateLoader).get(
+ 'list:user:action:confirm', event.mlist)
+ text = expand(template, event.mlist, dict(
+ token=event.token,
+ subject=subject,
+ confirm_email=confirm_address,
+ user_email=email_address,
+ # For backward compatibility.
+ confirm_address=confirm_address,
+ email_address=email_address,
+ domain_name=event.mlist.domain.mail_host,
+ contact_address=event.mlist.owner_address,
+ ))
+ msg = UserNotification(email_address, confirm_address, subject, text)
+ msg.send(event.mlist, add_precedence=False)
+
+
@public
def handle_ListDeletingEvent(event):
"""Delete a mailing list's members when the list is being deleted."""
diff --git a/src/mailman/interfaces/subscriptions.py b/src/mailman/interfaces/subscriptions.py
index 382a23ac0..62f5be627 100644
--- a/src/mailman/interfaces/subscriptions.py
+++ b/src/mailman/interfaces/subscriptions.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/>.
-"""Membership interface for REST."""
+"""Subscription management."""
from collections import namedtuple
from enum import Enum
@@ -78,8 +78,22 @@ class TokenOwner(Enum):
@public
+class ConfirmationNeededEvent:
+ """Triggered when an address needs confirmation.
+
+ 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, token, email):
+ self.mlist = mlist
+ self.token = token
+ self.email = email
+
+
+@public
class ISubscriptionService(Interface):
- """General Subscription services."""
+ """General subscription services."""
def get_members():
"""Return a sequence of all members of all mailing lists.
@@ -178,3 +192,75 @@ class ISubscriptionService(Interface):
:rtype: 2-tuple of (set-of-strings, set-of-strings)
:raises NoSuchListError: if the named mailing list does not exist.
"""
+
+
+@public
+class ISubscriptionManager(Interface):
+ """Handling subscription and unsubscription of addresses and users.
+
+ This is a higher level interface to user registration and
+ unregistration, 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(subscriber=None, *,
+ pre_verified=False, pre_confirmed=False, pre_approved=False):
+ """Subscribe an address or user according to subscription policies.
+
+ 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.
+
+ 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 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):
+ """Continue any paused workflow.
+
+ 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 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 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/workflowmanager.py b/src/mailman/interfaces/workflowmanager.py
deleted file mode 100644
index 906246e6d..000000000
--- a/src/mailman/interfaces/workflowmanager.py
+++ /dev/null
@@ -1,113 +0,0 @@
-# Copyright (C) 2007-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/>.
-
-"""Interface describing a user registration service.
-
-This is a higher level interface to user registration, address confirmation,
-etc. than the IUserManager. The latter does no validation, syntax checking,
-or confirmation, while this interface does.
-"""
-
-from mailman import public
-from zope.interface import Interface
-
-
-@public
-class ConfirmationNeededEvent:
- """Triggered when an address needs confirmation.
-
- 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, token, email):
- self.mlist = mlist
- self.token = token
- self.email = email
-
-
-@public
-class IWorkflowManager(Interface):
- """Interface for handling subscription and un-subscription of addresses and
- users.
-
- 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.
- """
-
- def register(subscriber=None, *,
- pre_verified=False, pre_confirmed=False, pre_approved=False):
- """Subscribe an address or user according to subscription policies.
-
- 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.
-
- 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 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):
- """Continue any paused workflow.
-
- 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 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 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."""