summaryrefslogtreecommitdiff
path: root/src/mailman/app/subscriptions.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/mailman/app/subscriptions.py')
-rw-r--r--src/mailman/app/subscriptions.py552
1 files changed, 20 insertions, 532 deletions
diff --git a/src/mailman/app/subscriptions.py b/src/mailman/app/subscriptions.py
index 0d8176ffb..ba62e9f52 100644
--- a/src/mailman/app/subscriptions.py
+++ b/src/mailman/app/subscriptions.py
@@ -17,515 +17,36 @@
"""Handle subscriptions."""
-import uuid
-import logging
-
-from datetime import timedelta
-from email.utils import formataddr
-from enum import Enum
-from mailman.app.membership import delete_member
-from mailman.app.workflow import Workflow
-from mailman.core.i18n import _
+from mailman.config import config
from mailman.database.transaction import flush
-from mailman.email.message import UserNotification
-from mailman.interfaces.address import IAddress
-from mailman.interfaces.bans import IBanManager
from mailman.interfaces.listmanager import ListDeletingEvent
-from mailman.interfaces.mailinglist import SubscriptionPolicy
-from mailman.interfaces.member import (
- AlreadySubscribedError, MemberRole, MembershipIsBannedError,
- NotAMemberError)
-from mailman.interfaces.pending import IPendable, IPendings
+from mailman.interfaces.pending import IPendings
from mailman.interfaces.subscriptions import (
- ISubscriptionManager, ISubscriptionService,
- SubscriptionConfirmationNeededEvent, SubscriptionPendingError, TokenOwner,
- UnsubscriptionConfirmationNeededEvent)
-from mailman.interfaces.template import ITemplateLoader
-from mailman.interfaces.user import IUser
-from mailman.interfaces.usermanager import IUserManager
-from mailman.interfaces.workflow import IWorkflowStateManager
-from mailman.utilities.datetime import now
-from mailman.utilities.string import expand, wrap
+ ISubscriptionManager, ISubscriptionService)
+from mailman.interfaces.workflows import IWorkflowStateManager
+from mailman.workflows.common import (PendableSubscription,
+ PendableUnsubscription)
from public import public
from zope.component import getUtility
-from zope.event import notify
from zope.interface import implementer
-log = logging.getLogger('mailman.subscribe')
-
-
-class WhichSubscriber(Enum):
- address = 1
- user = 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."""
-
- PENDABLE_CLASS = None
-
- 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 _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))
-
-
-@public
-class SubscriptionWorkflow(_SubscriptionWorkflowCommon):
- """Workflow of a subscription request."""
-
- PENDABLE_CLASS = PendableSubscription
- INITIAL_STATE = 'sanity_checks'
- SAVE_ATTRIBUTES = (
- 'pre_approved',
- 'pre_confirmed',
- 'pre_verified',
- 'address_key',
- 'subscriber_key',
- 'user_key',
- 'token_owner_key',
- )
-
- def __init__(self, mlist, subscriber=None, *,
- pre_verified=False, pre_confirmed=False, pre_approved=False):
- super().__init__(mlist, subscriber)
- self.pre_verified = pre_verified
- self.pre_confirmed = pre_confirmed
- self.pre_approved = pre_approved
-
- 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.
- pendings = getUtility(IPendings).find(
- mlist=self.mlist,
- pend_type='subscription')
- 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.
- self.push('verification_checks')
-
- 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.pre_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')
- return
- self.push('confirmation_checks')
-
- def _step_confirmation_checks(self):
- # If the list's subscription policy is open, then the user can be
- # subscribed right here and now.
- if self.mlist.subscription_policy is SubscriptionPolicy.open:
- self.push('do_subscription')
- return
- # If we do not need the user's confirmation, then skip to the
- # moderation checks.
- if self.mlist.subscription_policy is SubscriptionPolicy.moderate:
- self.push('moderation_checks')
- return
- # If the subscription has been pre-confirmed, then we can skip the
- # confirmation check can be skipped. If moderator approval is
- # required we need to check that, otherwise we can go straight to
- # subscription.
- if self.pre_confirmed:
- next_step = (
- 'moderation_checks'
- if self.mlist.subscription_policy is
- SubscriptionPolicy.confirm_then_moderate # noqa: E131
- else 'do_subscription')
- self.push(next_step)
- return
- # The user must confirm their subscription.
- self.push('send_confirmation')
-
- def _step_moderation_checks(self):
- # Does the moderator need to approve the subscription request?
- assert self.mlist.subscription_policy in (
- SubscriptionPolicy.moderate,
- SubscriptionPolicy.confirm_then_moderate,
- ), self.mlist.subscription_policy
- if self.pre_approved:
- self.push('do_subscription')
- else:
- 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 subscription. If they don't, the workflow and
- # subscription request will just be thrown away.
- self._set_token(TokenOwner.moderator)
- self.push('subscribe_from_restored')
- self.save()
- log.info('{}: held subscription request from {}'.format(
- self.mlist.fqdn_listname, self.address.email))
- # Possibly send a notification to the list moderators.
- if self.mlist.admin_immed_notify:
- subject = _(
- 'New subscription 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:subscribe', 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_subscribe_from_restored(self):
- # Prevent replay attacks.
- self._set_token(TokenOwner.no_one)
- # Restore a little extra state that can't be stored in the database
- # (because the order of setattr() on restore is indeterminate), then
- # subscribe the user.
- if self.which is WhichSubscriber.address:
- self.subscriber = self.address
- else:
- assert self.which is WhichSubscriber.user
- self.subscriber = self.user
- self.push('do_subscription')
-
- 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')
-
- def _step_send_confirmation(self):
- self._set_token(TokenOwner.subscriber)
- self.push('do_confirm_verify')
- self.save()
- # Triggering this event causes the confirmation message to be sent.
- notify(SubscriptionConfirmationNeededEvent(
- self.mlist, self.token, self.address.email))
- # 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
- # (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)
- # 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()
- # The next step depends on the mailing list's subscription policy.
- next_step = ('moderation_checks'
- if self.mlist.subscription_policy in (
- SubscriptionPolicy.moderate,
- SubscriptionPolicy.confirm_then_moderate,
- )
- else 'do_subscription')
- self.push(next_step)
-
-
-@public
-class UnSubscriptionWorkflow(_SubscriptionWorkflowCommon):
- """Workflow of a unsubscription request."""
-
- PENDABLE_CLASS = PendableUnsubscription
- 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__(mlist, subscriber)
- if IAddress.providedBy(subscriber) or IUser.providedBy(subscriber):
- self.member = self.mlist.regular_members.get_member(
- self.address.email)
- self.pre_confirmed = pre_confirmed
- self.pre_approved = pre_approved
-
- 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 the request is pre-confirmed, then the user can unsubscribe right
- # now.
- if self.pre_confirmed:
- self.push('do_unsubscription')
- 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(UnsubscriptionConfirmationNeededEvent(
- 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))
- 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_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
- next_step = ('moderation_checks'
- if self.mlist.unsubscription_policy in (
- SubscriptionPolicy.moderate,
- SubscriptionPolicy.confirm_then_moderate,
- )
- else 'do_unsubscription')
- self.push(next_step)
-
- 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')
-
- 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
- self.push('do_unsubscription')
-
-
@public
@implementer(ISubscriptionManager)
class SubscriptionManager:
def __init__(self, mlist):
self._mlist = mlist
- def register(self, subscriber=None, *,
- pre_verified=False, pre_confirmed=False, pre_approved=False):
+ def register(self, subscriber=None, **kwargs):
"""See `ISubscriptionManager`."""
- workflow = SubscriptionWorkflow(
- self._mlist, subscriber,
- pre_verified=pre_verified,
- pre_confirmed=pre_confirmed,
- pre_approved=pre_approved)
+ workflow = self._mlist.subscription_policy(self._mlist, subscriber,
+ **kwargs)
list(workflow)
return workflow.token, workflow.token_owner, workflow.member
- def unregister(self, subscriber=None, *,
- pre_confirmed=False, pre_approved=False):
- workflow = UnSubscriptionWorkflow(
- self._mlist, subscriber,
- pre_confirmed=pre_confirmed,
- pre_approved=pre_approved)
+ def unregister(self, subscriber=None, **kwargs):
+ workflow = self._mlist.unsubscription_policy(self._mlist, subscriber,
+ **kwargs)
list(workflow)
return workflow.token, workflow.token_owner, workflow.member
@@ -536,11 +57,13 @@ class SubscriptionManager:
if pendable is None:
raise LookupError
workflow_type = pendable.get('type')
- assert workflow_type in (PendableSubscription.PEND_TYPE,
- PendableUnsubscription.PEND_TYPE)
- workflow = (SubscriptionWorkflow
- if workflow_type == PendableSubscription.PEND_TYPE
- else UnSubscriptionWorkflow)(self._mlist)
+ if workflow_type in {PendableSubscription.PEND_TYPE,
+ PendableUnsubscription.PEND_TYPE}:
+ workflow = (self._mlist.subscription_policy # pragma: nocover
+ if workflow_type == PendableSubscription.PEND_TYPE
+ else self._mlist.unsubscription_policy)(self._mlist)
+ else:
+ workflow = config.workflows[workflow_type](self._mlist)
workflow.token = token
workflow.restore()
# In order to just run the whole workflow, all we need to do
@@ -556,41 +79,6 @@ class SubscriptionManager:
getUtility(IWorkflowStateManager).discard(token)
-def _handle_confirmation_needed_events(event, template_name):
- 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(template_name, 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_SubscriptionConfirmationNeededEvent(event):
- if not isinstance(event, SubscriptionConfirmationNeededEvent):
- return
- _handle_confirmation_needed_events(event, 'list:user:action:subscribe')
-
-
-@public
-def handle_UnsubscriptionConfirmationNeededEvent(event):
- if not isinstance(event, UnsubscriptionConfirmationNeededEvent):
- return
- _handle_confirmation_needed_events(event, 'list:user:action:unsubscribe')
-
-
@public
def handle_ListDeletingEvent(event):
"""Delete a mailing list's members when the list is being deleted."""
@@ -599,6 +87,6 @@ def handle_ListDeletingEvent(event):
return
# Find all the members still associated with the mailing list.
members = getUtility(ISubscriptionService).find_members(
- list_id=event.mailing_list.list_id)
+ list_id=event.mailing_list.list_id)
for member in members:
member.unsubscribe()