diff options
| -rw-r--r-- | src/mailman/workflows/common.py | 121 | ||||
| -rw-r--r-- | src/mailman/workflows/unsubscription.py | 190 |
2 files changed, 309 insertions, 2 deletions
diff --git a/src/mailman/workflows/common.py b/src/mailman/workflows/common.py index 79d57d0ae..d6c372b1e 100644 --- a/src/mailman/workflows/common.py +++ b/src/mailman/workflows/common.py @@ -23,15 +23,19 @@ import logging from datetime import timedelta from email.utils import formataddr from enum import Enum + +from mailman.app.membership import delete_member 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) + MembershipIsBannedError, + NotAMemberError) from mailman.interfaces.pending import IPendable, IPendings from mailman.interfaces.subscriptions import ( - SubscriptionConfirmationNeededEvent, SubscriptionPendingError, TokenOwner) + SubscriptionConfirmationNeededEvent, SubscriptionPendingError, TokenOwner, + UnsubscriptionConfirmationNeededEvent) from mailman.interfaces.template import ITemplateLoader from mailman.interfaces.user import IUser from mailman.interfaces.usermanager import IUserManager @@ -331,3 +335,116 @@ class ModerationMixin: else: assert self.which is WhichSubscriber.user self.subscriber = self.user + + +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) + + 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 UnRequestMixin: + + def _step_send_confirmation(self): + self._set_token(TokenOwner.subscriber) + self.push('do_confirm_verify') + self.save() + notify(UnsubscriptionConfirmationNeededEvent( + self.mlist, self.token, self.address.email)) + raise StopIteration + + def _step_do_confirm_verify(self): + # Restore a little extra state that can't be stored in the database + # (because the order of setattr() on restore is indeterminate), then + # continue with the confirmation/verification step. + if self.which is WhichSubscriber.address: + self.subscriber = self.address + else: + assert self.which is WhichSubscriber.user + self.subscriber = self.user + # Reset the token so it can't be used in a replay attack. + self._set_token(TokenOwner.no_one) + # Restore the member object. + self.member = self.mlist.regular_members.get_member(self.address.email) + # It's possible the member was already unsubscribed while we were + # waiting for the confirmation. + if self.member is None: + return + # The user has confirmed their unsubscription request + self.confirmed = True + + +class UnConfirmationMixin(UnRequestMixin): + + def __init__(self, pre_confirmed=False): + self.confirmed = pre_confirmed + + def _step_confirmation_checks(self): + # If the unsubscription has been pre-confirmed, then we can skip the + # confirmation check can be skipped. + if self.confirmed: + return + # The user must confirm their unsubscription. + self.push('send_confirmation') + + +class UnModerationMixin(UnRequestMixin): + + def __init__(self, pre_approved=False): + self.approved = pre_approved + + def _step_moderation_checks(self): + # Does the moderator need to approve the unsubscription request? + if not self.approved: + 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)) + template = getUtility(ITemplateLoader).get( + 'list:admin:action:unsubscribe', 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_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 WhichSubscriber.user + self.subscriber = self.user diff --git a/src/mailman/workflows/unsubscription.py b/src/mailman/workflows/unsubscription.py new file mode 100644 index 000000000..3c4ad39de --- /dev/null +++ b/src/mailman/workflows/unsubscription.py @@ -0,0 +1,190 @@ +# 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/>. + +"""""" + +from mailman.core.i18n import _ +from mailman.interfaces.workflows import IUnsubscriptionWorkflow +from mailman.workflows.common import (UnConfirmationMixin, UnModerationMixin, + UnsubscriptionBase) +from public import public +from zope.interface import implementer + + +@public +@implementer(IUnsubscriptionWorkflow) +class OpenUnsubscriptionPolicy(UnsubscriptionBase): + """""" + + name = 'unsub-policy-open' + description = _( + 'An open unsubscription policy, only requires verification.') + initial_state = 'prepare' + save_attributes = ( + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None): + """ + + :param mlist: + :param subscriber: The user or address to unsubscribe. + :type subscriber: ``IUser`` or ``IAddress`` + """ + UnsubscriptionBase.__init__(self, mlist, subscriber) + + def _step_prepare(self): + self.push('do_unsubscription') + self.push('subscription_checks') + + +@public +@implementer(IUnsubscriptionWorkflow) +class ConfirmUnsubscriptionPolicy(UnsubscriptionBase, UnConfirmationMixin): + """""" + + name = 'unsub-policy-confirm' + description = _('An unsubscription policy that requires confirmation.') + initial_state = 'prepare' + save_attributes = ( + 'confirmed', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_confirmed=False): + """ + + :param mlist: + :param subscriber: The user or address to unsubscribe. + :type subscriber: ``IUser`` or ``IAddress`` + :param pre_confirmed: A flag indicating whether, when required by the + unsubscription policy, an unsubscription request should be + considered pre-confirmed. Normally in such cases, a mail-back + confirmation message is sent to the subscriber, which must be + positively acknowledged by some manner. Setting this flag to True + automatically confirms the unsubscription request. (A confirmation + message may still be sent under other conditions.) + :type pre_confirmed: bool + """ + UnsubscriptionBase.__init__(self, mlist, subscriber) + UnConfirmationMixin.__init__(self, pre_confirmed=pre_confirmed) + + def _step_prepare(self): + self.push('do_unsubscription') + self.push('confirmation_checks') + self.push('subscription_checks') + + +@public +@implementer(IUnsubscriptionWorkflow) +class ModerationUnsubscriptionPolicy(UnsubscriptionBase, UnModerationMixin): + """""" + + name = 'unsub-policy-moderate' + description = _('An unsubscription policy that requires moderation.') + initial_state = 'prepare' + save_attributes = ( + 'approved', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_approved=False): + """ + + :param mlist: + :param subscriber: The user or address to unsubscribe. + :type subscriber: ``IUser`` or ``IAddress`` + :param pre_approved: A flag indicating whether, when required by the + unsubscription policy, an unsubscription request should be + considered pre-approved. Normally in such cases, the list + administrator is notified that an approval is necessary, which + must be positively acknowledged in some manner. Setting this flag + to True automatically approves the unsubscription request. + :type pre_approved: bool + """ + UnsubscriptionBase.__init__(self, mlist, subscriber) + UnModerationMixin.__init__(self, pre_approved=pre_approved) + + def _step_prepare(self): + self.push('do_unsubscription') + self.push('moderation_checks') + self.push('subscription_checks') + + +@public +@implementer(IUnsubscriptionWorkflow) +class ConfirmModerationUnsubscriptionPolicy(UnsubscriptionBase, + UnConfirmationMixin, + UnModerationMixin): + """""" + + name = 'unsub-policy-confirm-moderate' + description = _( + 'An unsubscription policy, requires moderation after confirmation.') + initial_state = 'prepare' + save_attributes = ( + 'approved', + 'confirmed', + 'address_key', + 'subscriber_key', + 'user_key', + 'token_owner_key', + ) + + def __init__(self, mlist, subscriber=None, *, + pre_confirmed=False, pre_approved=False): + """ + + :param mlist: + :param subscriber: The user or address to unsubscribe. + :type subscriber: ``IUser`` or ``IAddress`` + :param pre_confirmed: A flag indicating whether, when required by the + unsubscription policy, an unsubscription request should be + considered pre-confirmed. Normally in such cases, a mail-back + confirmation message is sent to the subscriber, which must be + positively acknowledged by some manner. Setting this flag to True + automatically confirms the unsubscription request. (A confirmation + message may still be sent under other conditions.) + :type pre_confirmed: bool + :param pre_approved: A flag indicating whether, when required by the + unsubscription policy, an unsubscription request should be + considered pre-approved. Normally in such cases, the list + administrator is notified that an approval is necessary, which + must be positively acknowledged in some manner. Setting this flag + to True automatically approves the unsubscription request. + :type pre_approved: bool + """ + UnsubscriptionBase.__init__(self, mlist, subscriber) + UnConfirmationMixin.__init__(self, pre_confirmed=pre_confirmed) + UnModerationMixin.__init__(self, pre_approved=pre_approved) + + def _step_prepare(self): + self.push('do_unsubscription') + self.push('moderation_checks') + self.push('confirmation_checks') + self.push('subscription_checks') |
