diff options
| author | J08nY | 2017-08-07 19:00:49 +0200 |
|---|---|---|
| committer | J08nY | 2017-08-07 19:00:49 +0200 |
| commit | ee9da27283ffb7adc836f764f1442cd06e3fb2a5 (patch) | |
| tree | 2b687f39714580b1de70baf9e3dd9957326c4989 /src/mailman/workflows/common.py | |
| parent | d107fd41f03b57f7731b60bb7ba921febc3ce3b9 (diff) | |
| parent | b902d7858d8302d248add89a5983c521c3581c4c (diff) | |
| download | mailman-plugin.tar.gz mailman-plugin.tar.zst mailman-plugin.zip | |
Diffstat (limited to 'src/mailman/workflows/common.py')
| -rw-r--r-- | src/mailman/workflows/common.py | 401 |
1 files changed, 401 insertions, 0 deletions
diff --git a/src/mailman/workflows/common.py b/src/mailman/workflows/common.py new file mode 100644 index 000000000..c250785c2 --- /dev/null +++ b/src/mailman/workflows/common.py @@ -0,0 +1,401 @@ +# Copyright (C) 2015-2017 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/>. + +"""Common support between subscription and unsubscription.""" + +import uuid +import logging + +from datetime import timedelta +from email.utils import formataddr +from enum import Enum +from itertools import chain + +from mailman.app.membership import delete_member +from mailman.config import config +from mailman.core.i18n import _ +from mailman.email.message import UserNotification +from mailman.interfaces.address import IAddress +from mailman.interfaces.bans import IBanManager +from mailman.interfaces.member import (AlreadySubscribedError, MemberRole, + MembershipIsBannedError, + NotAMemberError) +from mailman.interfaces.pending import IPendable, IPendings +from mailman.interfaces.subscriptions import (SubscriptionPendingError, + TokenOwner) +from mailman.interfaces.template import ITemplateLoader +from mailman.interfaces.user import IUser +from mailman.interfaces.usermanager import IUserManager +from mailman.interfaces.workflows import ISubscriptionWorkflow +from mailman.utilities.datetime import now +from mailman.utilities.string import expand, wrap +from mailman.workflows.base import Workflow +from zope.component import getUtility +from zope.interface import implementer + + +log = logging.getLogger('mailman.subscribe') + + +class WhichSubscriber(Enum): + address = 1 + user = 2 + + +class WhichWorkflow(Enum): + subscription = 1 + unsubscription = 2 + + +@implementer(IPendable) +class PendableSubscription(dict): + PEND_TYPE = 'subscription' + + +@implementer(IPendable) +class PendableUnsubscription(dict): + PEND_TYPE = 'unsubscription' + + +class SubscriptionWorkflowCommon(Workflow): + """Common support between subscription and unsubscription.""" + + def __init__(self, mlist, subscriber): + super().__init__() + self.mlist = mlist + self.address = None + self.user = None + self.which = None + self.member = None + self._set_token(TokenOwner.no_one) + # The subscriber must be either an IUser or IAddress. + if IAddress.providedBy(subscriber): + self.address = subscriber + self.user = self.address.user + self.which = WhichSubscriber.address + elif IUser.providedBy(subscriber): + self.address = subscriber.preferred_address + self.user = subscriber + self.which = WhichSubscriber.user + self.subscriber = subscriber + + @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) + if self.user is None: + self.user = self.address.user + + @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 _restore_subscriber(self): + # Restore a little extra state that can't be stored in the database + # (because the order of setattr() on restore is indeterminate), then + # continue with the confirmation/verification step. + if self.which is WhichSubscriber.address: + self.subscriber = self.address + else: + assert self.which is WhichSubscriber.user + self.subscriber = self.user + + 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 = self.pendable_class()( + 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)) + + @classmethod + def pendable_class(cls): + @implementer(IPendable) + class Pendable(dict): + PEND_TYPE = cls.name + return Pendable + + +class SubscriptionBase(SubscriptionWorkflowCommon): + + def __init__(self, mlist, subscriber): + super().__init__(mlist, subscriber) + self._workflow = WhichWorkflow.subscription + + def _step_sanity_checks(self): + # Ensure that we have both an address and a user, even if the address + # is not verified. We can't set the preferred address until it is + # verified. + if self.user is None: + # The address has no linked user so create one, link it, and set + # the user's preferred address. + assert self.address is not None, 'No address or user' + self.user = getUtility(IUserManager).make_user(self.address.email) + if self.address is None: + assert self.user.preferred_address is None, ( + "Preferred address exists, but wasn't used in constructor") + addresses = list(self.user.addresses) + if len(addresses) == 0: + raise AssertionError('User has no addresses: {}'.format( + self.user)) + # This is rather arbitrary, but we have no choice. + self.address = addresses[0] + assert self.user is not None and self.address is not None, ( + 'Insane sanity check results') + # Is this subscriber already a member? + if (self.which is WhichSubscriber.user and + self.user.preferred_address is not None): + subscriber = self.user + else: + subscriber = self.address + if self.mlist.is_subscribed(subscriber): + # 2017-04-22 BAW: This branch actually *does* get covered, as I've + # verified by a full coverage run, but diffcov for some reason + # claims that the test added in the branch that added this code + # does not cover the change. That seems like a bug in diffcov. + raise AlreadySubscribedError( # pragma: nocover + self.mlist.fqdn_listname, + self.address.email, + MemberRole.member) + # Is this email address banned? + if IBanManager(self.mlist).is_banned(self.address.email): + raise MembershipIsBannedError(self.mlist, self.address.email) + # Check if there is already a subscription request for this email. + # Look at all known subscription workflows, because any pending + # subscription workflow is exclusive. + sub_workflows = [workflow_class + for workflow_class in config.workflows.values() + if ISubscriptionWorkflow.implementedBy(workflow_class) + ] + generators = [getUtility(IPendings).find(mlist=self.mlist, + pend_type=sub_workflow.name) + for + sub_workflow in sub_workflows] + pendings = chain.from_iterable(generators) + for token, pendable in pendings: + if pendable['email'] == self.address.email: + raise SubscriptionPendingError(self.mlist, self.address.email) + # Start out with the subscriber being the token owner. + + def _step_do_subscription(self): + # We can immediately subscribe the user to the mailing list. + self.member = self.mlist.subscribe(self.subscriber) + assert self.token is None and self.token_owner is TokenOwner.no_one, ( + 'Unexpected active token at end of subscription workflow') + + +class UnsubscriptionBase(SubscriptionWorkflowCommon): + + def __init__(self, mlist, subscriber): + super().__init__(mlist, subscriber) + if IAddress.providedBy(subscriber) or IUser.providedBy(subscriber): + self.member = self.mlist.regular_members.get_member( + self.address.email) + self._workflow = WhichWorkflow.unsubscription + + def _step_subscription_checks(self): + assert self.mlist.is_subscribed(self.subscriber) + + def _step_do_unsubscription(self): + try: + delete_member(self.mlist, self.address.email) + except NotAMemberError: + # The member has already been unsubscribed. + pass + self.member = None + assert self.token is None and self.token_owner is TokenOwner.no_one, ( + 'Unexpected active token at end of subscription workflow') + + +class RequestMixin: + + def _step_send_confirmation(self): + self._set_token(TokenOwner.subscriber) + self.push('do_confirm_verify') + self.save() + if self._workflow is WhichWorkflow.subscription: + template_name = 'list:user:action:subscribe' + else: + template_name = 'list:user:action:unsubscribe' + + subject = 'confirm {}'.format(self.token) + confirm_address = self.mlist.confirm_address(self.token) + email_address = self.address.email + # Send a verification email to the address. + template = getUtility(ITemplateLoader).get(template_name, self.mlist) + text = expand(template, self.mlist, dict( + token=self.token, + subject=subject, + confirm_email=confirm_address, + user_email=email_address, + # For backward compatibility. + confirm_address=confirm_address, + email_address=email_address, + domain_name=self.mlist.domain.mail_host, + contact_address=self.mlist.owner_address, + )) + msg = UserNotification(email_address, confirm_address, subject, text) + msg.send(self.mlist, add_precedence=False) + # Now we wait for the confirmation. + raise StopIteration + + def _step_do_confirm_verify(self): + # Restore a little extra state that can't be stored in the database. + self._restore_subscriber() + # Reset the token so it can't be used in a replay attack. + self._set_token(TokenOwner.no_one) + # The user has confirmed their subscription request, and also verified + # their email address if necessary. This latter needs to be set on the + # IAddress, but there's nothing more to do about the confirmation step. + # We just continue along with the workflow. + if self.address.verified_on is None: + self.address.verified_on = now() + self.verified = True + self.confirmed = True + + if self._workflow is WhichWorkflow.unsubscription: + self.member = self.mlist.regular_members.get_member( + self.address.email) + + +class VerificationMixin(RequestMixin): + + def __init__(self, pre_verified=False): + self.verified = pre_verified + + def _step_verification_checks(self): + # Is the address already verified, or is the pre-verified flag set? + if self.address.verified_on is None: + if self.verified: + self.address.verified_on = now() + else: + # The address being subscribed is not yet verified, so we need + # to send a validation email that will also confirm that the + # user wants to be subscribed to this mailing list. + self.push('send_confirmation') + + +class ConfirmationMixin(RequestMixin): + + def __init__(self, pre_confirmed=False): + self.confirmed = pre_confirmed + + def _step_confirmation_checks(self): + # If the subscription has been pre-confirmed, then we can skip the + # confirmation check. + if not self.confirmed: + # The user must confirm their subscription. + self.push('send_confirmation') + + +class ModerationMixin: + + def __init__(self, pre_approved=False): + self.approved = pre_approved + + def _step_moderation_checks(self): + # Does the moderator need to approve the request? + if not self.approved: + self.push('get_moderator_approval') + + def _step_get_moderator_approval(self): + # Here's the next step in the workflow, assuming the moderator + # approves of the request. If they don't, the workflow and + # request will just be thrown away. + self._set_token(TokenOwner.moderator) + self.push('restore') + self.save() + + if self._workflow is WhichWorkflow.subscription: + workflow_name = 'subscription' + template_name = 'list:admin:action:subscribe' + else: + workflow_name = 'unsubscription' + template_name = 'list:admin:action:unsubscribe' + + log.info('{}: held {} request from {}'.format( + self.mlist.fqdn_listname, workflow_name, self.address.email)) + # Possibly send a notification to the list moderators. + if self.mlist.admin_immed_notify: + subject = _( + 'New $workflow_name request to $self.mlist.display_name ' + 'from $self.address.email') + username = formataddr( + (self.subscriber.display_name, self.address.email)) + template = getUtility(ITemplateLoader).get( + template_name, self.mlist) + text = wrap(expand(template, self.mlist, dict( + member=username, + ))) + # 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) + # The workflow must stop running here. + raise StopIteration + + def _step_restore(self): + # Prevent replay attacks. + self._set_token(TokenOwner.no_one) + # Restore a little extra state that can't be stored in the database. + self._restore_subscriber() |
