diff options
| author | Barry Warsaw | 2016-08-23 21:52:53 -0400 |
|---|---|---|
| committer | Barry Warsaw | 2016-08-29 12:06:02 -0400 |
| commit | 5db5eb3417983ff6ea4c7979028bb525c4d1e332 (patch) | |
| tree | bd354ea42eb073e9f0db94809edd4d89760c5a2f /src | |
| parent | 63de6706811936070b4979353c371a849b46af85 (diff) | |
| download | mailman-5db5eb3417983ff6ea4c7979028bb525c4d1e332.tar.gz mailman-5db5eb3417983ff6ea4c7979028bb525c4d1e332.tar.zst mailman-5db5eb3417983ff6ea4c7979028bb525c4d1e332.zip | |
Checkpointing.
Diffstat (limited to 'src')
| -rw-r--r-- | src/mailman/app/subscriptions.py | 301 | ||||
| -rw-r--r-- | src/mailman/interfaces/subscriptions.py | 90 | ||||
| -rw-r--r-- | src/mailman/interfaces/workflowmanager.py | 113 |
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.""" |
