Merge lp:~barry/mailman/subpolicy-2 into lp:mailman

Proposed by Barry Warsaw
Status: Merged
Merged at revision: 7317
Proposed branch: lp:~barry/mailman/subpolicy-2
Merge into: lp:mailman
Diff against target: 3741 lines (+2130/-750)
46 files modified
TODO.rst (+10/-0)
src/mailman/app/docs/moderator.rst (+0/-1)
src/mailman/app/registrar.py (+33/-99)
src/mailman/app/subscriptions.py (+269/-7)
src/mailman/app/tests/test_registrar.py (+170/-91)
src/mailman/app/tests/test_subscriptions.py (+515/-2)
src/mailman/app/tests/test_workflow.py (+128/-0)
src/mailman/app/workflow.py (+156/-0)
src/mailman/commands/docs/membership.rst (+12/-38)
src/mailman/commands/eml_confirm.py (+5/-2)
src/mailman/commands/eml_membership.py (+36/-14)
src/mailman/commands/tests/test_confirm.py (+5/-6)
src/mailman/config/configure.zcml (+11/-5)
src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py (+41/-0)
src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py (+28/-0)
src/mailman/database/transaction.py (+17/-0)
src/mailman/interfaces/mailinglist.py (+16/-0)
src/mailman/interfaces/member.py (+9/-1)
src/mailman/interfaces/pending.py (+5/-2)
src/mailman/interfaces/registrar.py (+48/-52)
src/mailman/interfaces/roster.py (+18/-3)
src/mailman/interfaces/workflow.py (+80/-0)
src/mailman/model/docs/membership.rst (+36/-2)
src/mailman/model/docs/pending.rst (+10/-0)
src/mailman/model/docs/registration.rst (+80/-323)
src/mailman/model/mailinglist.py (+2/-1)
src/mailman/model/member.py (+4/-0)
src/mailman/model/pending.py (+5/-1)
src/mailman/model/roster.py (+50/-14)
src/mailman/model/tests/test_registrar.py (+0/-64)
src/mailman/model/tests/test_roster.py (+51/-1)
src/mailman/model/tests/test_usermanager.py (+5/-0)
src/mailman/model/tests/test_workflow.py (+148/-0)
src/mailman/model/usermanager.py (+0/-1)
src/mailman/model/workflow.py (+76/-0)
src/mailman/rest/docs/listconf.rst (+3/-0)
src/mailman/rest/listconf.py (+3/-1)
src/mailman/rest/tests/test_listconf.py (+1/-0)
src/mailman/runners/docs/command.rst (+3/-4)
src/mailman/runners/tests/test_confirm.py (+2/-2)
src/mailman/runners/tests/test_join.py (+6/-2)
src/mailman/styles/base.py (+3/-1)
src/mailman/templates/en/confirm.txt (+1/-3)
src/mailman/templates/en/subauth.txt (+0/-6)
src/mailman/utilities/importer.py (+3/-0)
src/mailman/utilities/tests/test_import.py (+26/-1)
To merge this branch: bzr merge lp:~barry/mailman/subpolicy-2
Reviewer Review Type Date Requested Status
Abhilash Raj Pending
Aurélien Bompard Pending
Review via email: mp+254511@code.launchpad.net

Description of the change

Here's the subpolicy work, merged from abompard's branch, which is taken from my pre-Python 3 port branch. Here's where the work will continue toward a merge with trunk. All tests pass, and now add_member() usage is reduced to the minimum. I think add_member() is where the subpolicy work will hook into the rest of Mailman.

This is still a WIP.

To post a comment you must log in.
Revision history for this message
Aurélien Bompard (abompard) :
Revision history for this message
Barry Warsaw (barry) wrote :

Good catches! I'll commit fixes momentarily. Thanks!

lp:~barry/mailman/subpolicy-2 updated
7340. By Barry Warsaw

Update TODO.

7341. By Barry Warsaw

Prevent replay attacks with the confirmation token.

Revision history for this message
Barry Warsaw (barry) wrote :

Phew! I think this branch is largely done and ready to be merged into trunk. There are still some things I'd like to clean up, and maybe some functionality to add (and bugs to fix?!) but I think this is the minimal must-do for 3.0.

I can't in good conscious ask you to review it again, given its size, but do let me know if anything jumps out at you. If you have a chance to test it, that would be great too. I will merge this to trunk tomorrow.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'TODO.rst'
2--- TODO.rst 1970-01-01 00:00:00 +0000
3+++ TODO.rst 2015-04-15 04:15:28 +0000
4@@ -0,0 +1,10 @@
5+* TO DO:
6+ - get rid of hold_subscription
7+ - subsume handle_subscription
8+ - workflow for unsubscription
9+ - make sure registration checks IEmailValidator
10+ - Test all the various options in eml_membership's get_subscriber()
11+ - Bump version to 3.0.0
12+ - Admin notification on membership changes?
13+ + admin_notify_mchanges
14+ + admin_immed_notify
15
16=== modified file 'src/mailman/app/docs/moderator.rst'
17--- src/mailman/app/docs/moderator.rst 2015-03-22 01:32:12 +0000
18+++ src/mailman/app/docs/moderator.rst 2015-04-15 04:15:28 +0000
19@@ -424,7 +424,6 @@
20 <BLANKLINE>
21 For: iris@example.org
22 List: ant@example.com
23- ...
24
25 Similarly, the administrator gets notifications on unsubscription requests.
26 Jeff is a member of the mailing list, and chooses to unsubscribe.
27
28=== modified file 'src/mailman/app/registrar.py'
29--- src/mailman/app/registrar.py 2015-04-06 20:07:04 +0000
30+++ src/mailman/app/registrar.py 2015-04-15 04:15:28 +0000
31@@ -25,18 +25,15 @@
32
33 import logging
34
35+from mailman.app.subscriptions import SubscriptionWorkflow
36 from mailman.core.i18n import _
37+from mailman.database.transaction import flush
38 from mailman.email.message import UserNotification
39-from mailman.interfaces.address import IEmailValidator
40-from mailman.interfaces.listmanager import IListManager
41-from mailman.interfaces.member import DeliveryMode, MemberRole
42 from mailman.interfaces.pending import IPendable, IPendings
43 from mailman.interfaces.registrar import ConfirmationNeededEvent, IRegistrar
44 from mailman.interfaces.templates import ITemplateLoader
45-from mailman.interfaces.usermanager import IUserManager
46-from mailman.utilities.datetime import now
47+from mailman.interfaces.workflow import IWorkflowStateManager
48 from zope.component import getUtility
49-from zope.event import notify
50 from zope.interface import implementer
51
52
53@@ -54,96 +51,34 @@
54 class Registrar:
55 """Handle registrations and confirmations for subscriptions."""
56
57- def register(self, mlist, email, display_name=None, delivery_mode=None):
58+ def __init__(self, mlist):
59+ self._mlist = mlist
60+
61+ def register(self, subscriber=None, *,
62+ pre_verified=False, pre_confirmed=False, pre_approved=False):
63 """See `IRegistrar`."""
64- if delivery_mode is None:
65- delivery_mode = DeliveryMode.regular
66- # First, do validation on the email address. If the address is
67- # invalid, it will raise an exception, otherwise it just returns.
68- getUtility(IEmailValidator).validate(email)
69- # Create a pendable for the registration.
70- pendable = PendableRegistration(
71- type=PendableRegistration.PEND_KEY,
72- email=email,
73- display_name=display_name,
74- delivery_mode=delivery_mode.name,
75- list_id=mlist.list_id)
76- token = getUtility(IPendings).add(pendable)
77- # We now have everything we need to begin the confirmation dance.
78- # Trigger the event to start the ball rolling, and return the
79- # generated token.
80- notify(ConfirmationNeededEvent(mlist, pendable, token))
81- return token
82+ workflow = SubscriptionWorkflow(
83+ self._mlist, subscriber,
84+ pre_verified=pre_verified,
85+ pre_confirmed=pre_confirmed,
86+ pre_approved=pre_approved)
87+ list(workflow)
88+ return workflow.token
89
90 def confirm(self, token):
91 """See `IRegistrar`."""
92- # For convenience
93- pendable = getUtility(IPendings).confirm(token)
94- if pendable is None:
95- return False
96- missing = object()
97- email = pendable.get('email', missing)
98- display_name = pendable.get('display_name', missing)
99- pended_delivery_mode = pendable.get('delivery_mode', 'regular')
100- try:
101- delivery_mode = DeliveryMode[pended_delivery_mode]
102- except ValueError:
103- log.error('Invalid pended delivery_mode for {0}: {1}',
104- email, pended_delivery_mode)
105- delivery_mode = DeliveryMode.regular
106- if pendable.get('type') != PendableRegistration.PEND_KEY:
107- # It seems like it would be very difficult to accurately guess
108- # tokens, or brute force an attack on the SHA1 hash, so we'll just
109- # throw the pendable away in that case. It's possible we'll need
110- # to repend the event or adjust the API to handle this case
111- # better, but for now, the simpler the better.
112- return False
113- # We are going to end up with an IAddress for the verified address
114- # and an IUser linked to this IAddress. See if any of these objects
115- # currently exist in our database.
116- user_manager = getUtility(IUserManager)
117- address = (user_manager.get_address(email)
118- if email is not missing else None)
119- user = (user_manager.get_user(email)
120- if email is not missing else None)
121- # If there is neither an address nor a user matching the confirmed
122- # record, then create the user, which will in turn create the address
123- # and link the two together
124- if address is None:
125- assert user is None, 'How did we get a user but not an address?'
126- user = user_manager.create_user(email, display_name)
127- # Because the database changes haven't been flushed, we can't use
128- # IUserManager.get_address() to find the IAddress just created
129- # under the hood. Instead, iterate through the IUser's addresses,
130- # of which really there should be only one.
131- for address in user.addresses:
132- if address.email == email:
133- break
134- else:
135- raise AssertionError('Could not find expected IAddress')
136- elif user is None:
137- user = user_manager.create_user()
138- user.display_name = display_name
139- user.link(address)
140- else:
141- # The IAddress and linked IUser already exist, so all we need to
142- # do is verify the address.
143- pass
144- address.verified_on = now()
145- # If this registration is tied to a mailing list, subscribe the person
146- # to the list right now. That will generate a SubscriptionEvent,
147- # which can be used to send a welcome message.
148- list_id = pendable.get('list_id')
149- if list_id is not None:
150- mlist = getUtility(IListManager).get_by_list_id(list_id)
151- if mlist is not None:
152- member = mlist.subscribe(address, MemberRole.member)
153- member.preferences.delivery_mode = delivery_mode
154- return True
155+ workflow = SubscriptionWorkflow(self._mlist)
156+ workflow.token = token
157+ workflow.restore()
158+ list(workflow)
159+ return workflow.token
160
161 def discard(self, token):
162- # Throw the record away.
163- getUtility(IPendings).confirm(token)
164+ """See `IRegistrar`."""
165+ with flush():
166+ getUtility(IPendings).confirm(token)
167+ getUtility(IWorkflowStateManager).discard(
168+ SubscriptionWorkflow.__name__, token)
169
170
171
172
173@@ -156,18 +91,17 @@
174 # the Subject header, or they can click on the URL in the body of the
175 # message and confirm through the web.
176 subject = 'confirm ' + event.token
177- mlist = getUtility(IListManager).get_by_list_id(event.pendable['list_id'])
178- confirm_address = mlist.confirm_address(event.token)
179+ confirm_address = event.mlist.confirm_address(event.token)
180 # For i18n interpolation.
181- confirm_url = mlist.domain.confirm_url(event.token)
182- email_address = event.pendable['email']
183- domain_name = mlist.domain.mail_host
184- contact_address = mlist.owner_address
185+ confirm_url = event.mlist.domain.confirm_url(event.token)
186+ email_address = event.email
187+ domain_name = event.mlist.domain.mail_host
188+ contact_address = event.mlist.owner_address
189 # Send a verification email to the address.
190 template = getUtility(ITemplateLoader).get(
191 'mailman:///{0}/{1}/confirm.txt'.format(
192- mlist.fqdn_listname,
193- mlist.preferred_language.code))
194+ event.mlist.fqdn_listname,
195+ event.mlist.preferred_language.code))
196 text = _(template)
197 msg = UserNotification(email_address, confirm_address, subject, text)
198- msg.send(mlist)
199+ msg.send(event.mlist)
200
201=== modified file 'src/mailman/app/subscriptions.py'
202--- src/mailman/app/subscriptions.py 2015-03-22 01:32:12 +0000
203+++ src/mailman/app/subscriptions.py 2015-04-15 04:15:28 +0000
204@@ -19,26 +19,50 @@
205
206 __all__ = [
207 'SubscriptionService',
208+ 'SubscriptionWorkflow',
209 'handle_ListDeletingEvent',
210 ]
211
212
213-from operator import attrgetter
214-from sqlalchemy import and_, or_
215-from uuid import UUID
216-from zope.component import getUtility
217-from zope.interface import implementer
218-
219+
220+import uuid
221+import logging
222+
223+from email.utils import formataddr
224+from enum import Enum
225+from datetime import timedelta
226 from mailman.app.membership import add_member, delete_member
227+from mailman.app.workflow import Workflow
228 from mailman.core.constants import system_preferences
229+from mailman.core.i18n import _
230 from mailman.database.transaction import dbconnection
231+from mailman.email.message import UserNotification
232+from mailman.interfaces.address import IAddress
233+from mailman.interfaces.bans import IBanManager
234 from mailman.interfaces.listmanager import (
235 IListManager, ListDeletingEvent, NoSuchListError)
236-from mailman.interfaces.member import DeliveryMode, MemberRole
237+from mailman.interfaces.mailinglist import SubscriptionPolicy
238+from mailman.interfaces.member import (
239+ DeliveryMode, MemberRole, MembershipIsBannedError)
240+from mailman.interfaces.pending import IPendable, IPendings
241+from mailman.interfaces.registrar import ConfirmationNeededEvent
242 from mailman.interfaces.subscriptions import (
243 ISubscriptionService, MissingUserError, RequestRecord)
244+from mailman.interfaces.user import IUser
245 from mailman.interfaces.usermanager import IUserManager
246+from mailman.interfaces.workflow import IWorkflowStateManager
247 from mailman.model.member import Member
248+from mailman.utilities.datetime import now
249+from mailman.utilities.i18n import make
250+from operator import attrgetter
251+from sqlalchemy import and_, or_
252+from uuid import UUID
253+from zope.component import getUtility
254+from zope.event import notify
255+from zope.interface import implementer
256+
257+
258+log = logging.getLogger('mailman.subscribe')
259
260
261
262
263@@ -51,7 +75,245 @@
264 return (member.list_id, member.address.email, member.role.value)
265
266
267+class WhichSubscriber(Enum):
268+ address = 1
269+ user = 2
270+
271+
272+@implementer(IPendable)
273+class Pendable(dict):
274+ pass
275+
276+
277
278
279+class SubscriptionWorkflow(Workflow):
280+ """Workflow of a subscription request."""
281+
282+ INITIAL_STATE = 'sanity_checks'
283+ SAVE_ATTRIBUTES = (
284+ 'pre_approved',
285+ 'pre_confirmed',
286+ 'pre_verified',
287+ 'address_key',
288+ 'subscriber_key',
289+ 'user_key',
290+ )
291+
292+ def __init__(self, mlist, subscriber=None, *,
293+ pre_verified=False, pre_confirmed=False, pre_approved=False):
294+ super().__init__()
295+ self.mlist = mlist
296+ self.address = None
297+ self.user = None
298+ self.which = None
299+ # The subscriber must be either an IUser or IAddress.
300+ if IAddress.providedBy(subscriber):
301+ self.address = subscriber
302+ self.user = self.address.user
303+ self.which = WhichSubscriber.address
304+ elif IUser.providedBy(subscriber):
305+ self.address = subscriber.preferred_address
306+ self.user = subscriber
307+ self.which = WhichSubscriber.user
308+ self.subscriber = subscriber
309+ self.pre_verified = pre_verified
310+ self.pre_confirmed = pre_confirmed
311+ self.pre_approved = pre_approved
312+
313+ @property
314+ def user_key(self):
315+ # For save.
316+ return self.user.user_id.hex
317+
318+ @user_key.setter
319+ def user_key(self, hex_key):
320+ # For restore.
321+ uid = uuid.UUID(hex_key)
322+ self.user = getUtility(IUserManager).get_user_by_id(uid)
323+ assert self.user is not None
324+
325+ @property
326+ def address_key(self):
327+ # For save.
328+ return self.address.email
329+
330+ @address_key.setter
331+ def address_key(self, email):
332+ # For restore.
333+ self.address = getUtility(IUserManager).get_address(email)
334+ assert self.address is not None
335+
336+ @property
337+ def subscriber_key(self):
338+ return self.which.value
339+
340+ @subscriber_key.setter
341+ def subscriber_key(self, key):
342+ self.which = WhichSubscriber(key)
343+
344+ def _step_sanity_checks(self):
345+ # Ensure that we have both an address and a user, even if the address
346+ # is not verified. We can't set the preferred address until it is
347+ # verified.
348+ if self.user is None:
349+ # The address has no linked user so create one, link it, and set
350+ # the user's preferred address.
351+ assert self.address is not None, 'No address or user'
352+ self.user = getUtility(IUserManager).make_user(self.address.email)
353+ if self.address is None:
354+ assert self.user.preferred_address is None, (
355+ "Preferred address exists, but wasn't used in constructor")
356+ addresses = list(self.user.addresses)
357+ if len(addresses) == 0:
358+ raise AssertionError('User has no addresses: {}'.format(
359+ self.user))
360+ # This is rather arbitrary, but we have no choice.
361+ self.address = addresses[0]
362+ assert self.user is not None and self.address is not None, (
363+ 'Insane sanity check results')
364+ # Is this email address banned?
365+ if IBanManager(self.mlist).is_banned(self.address.email):
366+ raise MembershipIsBannedError(self.mlist, self.address.email)
367+ # Create a pending record. This will give us the hash token we can use
368+ # to uniquely name this workflow.
369+ pendable = Pendable(
370+ list_id=self.mlist.list_id,
371+ address=self.address.email,
372+ )
373+ self.token = getUtility(IPendings).add(pendable, timedelta(days=3650))
374+ self.push('verification_checks')
375+
376+ def _step_verification_checks(self):
377+ # Is the address already verified, or is the pre-verified flag set?
378+ if self.address.verified_on is None:
379+ if self.pre_verified:
380+ self.address.verified_on = now()
381+ else:
382+ # The address being subscribed is not yet verified, so we need
383+ # to send a validation email that will also confirm that the
384+ # user wants to be subscribed to this mailing list.
385+ self.push('send_confirmation')
386+ return
387+ self.push('confirmation_checks')
388+
389+ def _step_confirmation_checks(self):
390+ # If the list's subscription policy is open, then the user can be
391+ # subscribed right here and now.
392+ if self.mlist.subscription_policy is SubscriptionPolicy.open:
393+ self.push('do_subscription')
394+ return
395+ # If we do not need the user's confirmation, then skip to the
396+ # moderation checks.
397+ if self.mlist.subscription_policy is SubscriptionPolicy.moderate:
398+ self.push('moderation_checks')
399+ return
400+ # If the subscription has been pre-confirmed, then we can skip to the
401+ # moderation checks.
402+ if self.pre_confirmed:
403+ self.push('moderation_checks')
404+ return
405+ # The user must confirm their subscription.
406+ self.push('send_confirmation')
407+
408+ def _step_moderation_checks(self):
409+ # Does the moderator need to approve the subscription request?
410+ assert self.mlist.subscription_policy in (
411+ SubscriptionPolicy.moderate,
412+ SubscriptionPolicy.confirm_then_moderate)
413+ if self.pre_approved:
414+ self.push('do_subscription')
415+ else:
416+ self.push('get_moderator_approval')
417+
418+ def _step_get_moderator_approval(self):
419+ # Here's the next step in the workflow, assuming the moderator
420+ # approves of the subscription. If they don't, the workflow and
421+ # subscription request will just be thrown away.
422+ self.push('subscribe_from_restored')
423+ self.save()
424+ log.info('{}: held subscription request from {}'.format(
425+ self.mlist.fqdn_listname, self.address.email))
426+ # Possibly send a notification to the list moderators.
427+ if self.mlist.admin_immed_notify:
428+ subject = _(
429+ 'New subscription request to $self.mlist.display_name '
430+ 'from $self.address.email')
431+ username = formataddr(
432+ (self.subscriber.display_name, self.address.email))
433+ text = make('subauth.txt',
434+ mailing_list=self.mlist,
435+ username=username,
436+ listname=self.mlist.fqdn_listname,
437+ )
438+ # This message should appear to come from the <list>-owner so as
439+ # to avoid any useless bounce processing.
440+ msg = UserNotification(
441+ self.mlist.owner_address, self.mlist.owner_address,
442+ subject, text, self.mlist.preferred_language)
443+ msg.send(self.mlist, tomoderators=True)
444+ # The workflow must stop running here.
445+ raise StopIteration
446+
447+ def _step_subscribe_from_restored(self):
448+ # Restore a little extra state that can't be stored in the database
449+ # (because the order of setattr() on restore is indeterminate), then
450+ # subscribe the user.
451+ if self.which is WhichSubscriber.address:
452+ self.subscriber = self.address
453+ else:
454+ assert self.which is WhichSubscriber.user
455+ self.subscriber = self.user
456+ self.push('do_subscription')
457+
458+ def _step_do_subscription(self):
459+ # We can immediately subscribe the user to the mailing list.
460+ self.mlist.subscribe(self.subscriber)
461+ # This workflow is done so throw away any associated state.
462+ getUtility(IWorkflowStateManager).restore(self.name, self.token)
463+ self.token = None
464+
465+ def _step_send_confirmation(self):
466+ self.push('do_confirm_verify')
467+ self.save()
468+ # Triggering this event causes the confirmation message to be sent.
469+ notify(ConfirmationNeededEvent(
470+ self.mlist, self.token, self.address.email))
471+ # Now we wait for the confirmation.
472+ raise StopIteration
473+
474+ def _step_do_confirm_verify(self):
475+ # Restore a little extra state that can't be stored in the database
476+ # (because the order of setattr() on restore is indeterminate), then
477+ # continue with the confirmation/verification step.
478+ if self.which is WhichSubscriber.address:
479+ self.subscriber = self.address
480+ else:
481+ assert self.which is WhichSubscriber.user
482+ self.subscriber = self.user
483+ # Create a new token to prevent replay attacks. It seems like this
484+ # should produce the same token, but it won't because the pending adds
485+ # a bit of randomization.
486+ pendable = Pendable(
487+ list_id=self.mlist.list_id,
488+ address=self.address.email,
489+ )
490+ self.token = getUtility(IPendings).add(pendable, timedelta(days=3650))
491+ # The user has confirmed their subscription request, and also verified
492+ # their email address if necessary. This latter needs to be set on the
493+ # IAddress, but there's nothing more to do about the confirmation step.
494+ # We just continue along with the workflow.
495+ if self.address.verified_on is None:
496+ self.address.verified_on = now()
497+ # The next step depends on the mailing list's subscription policy.
498+ next_step = ('moderation_checks'
499+ if self.mlist.subscription_policy in (
500+ SubscriptionPolicy.moderate,
501+ SubscriptionPolicy.confirm_then_moderate,
502+ )
503+ else 'do_subscription')
504+ self.push(next_step)
505+
506+
507 @implementer(ISubscriptionService)
508 class SubscriptionService:
509 """Subscription services for the REST API."""
510
511=== renamed file 'src/mailman/app/tests/test_registration.py' => 'src/mailman/app/tests/test_registrar.py'
512--- src/mailman/app/tests/test_registration.py 2015-01-05 01:22:39 +0000
513+++ src/mailman/app/tests/test_registrar.py 2015-04-15 04:15:28 +0000
514@@ -18,111 +18,190 @@
515 """Test email address registration."""
516
517 __all__ = [
518- 'TestEmailValidation',
519- 'TestRegistration',
520+ 'TestRegistrar',
521 ]
522
523
524 import unittest
525
526 from mailman.app.lifecycle import create_list
527-from mailman.interfaces.address import InvalidEmailAddressError
528+from mailman.interfaces.mailinglist import SubscriptionPolicy
529 from mailman.interfaces.pending import IPendings
530-from mailman.interfaces.registrar import ConfirmationNeededEvent, IRegistrar
531-from mailman.testing.helpers import event_subscribers
532+from mailman.interfaces.registrar import IRegistrar
533+from mailman.interfaces.usermanager import IUserManager
534 from mailman.testing.layers import ConfigLayer
535+from mailman.utilities.datetime import now
536 from zope.component import getUtility
537
538
539
540
541-class TestEmailValidation(unittest.TestCase):
542- """Test basic email validation."""
543-
544- layer = ConfigLayer
545-
546- def setUp(self):
547- self.registrar = getUtility(IRegistrar)
548- self.mlist = create_list('alpha@example.com')
549-
550- def test_empty_string_is_invalid(self):
551- self.assertRaises(InvalidEmailAddressError,
552- self.registrar.register, self.mlist,
553- '')
554-
555- def test_no_spaces_allowed(self):
556- self.assertRaises(InvalidEmailAddressError,
557- self.registrar.register, self.mlist,
558- 'some name@example.com')
559-
560- def test_no_angle_brackets(self):
561- self.assertRaises(InvalidEmailAddressError,
562- self.registrar.register, self.mlist,
563- '<script>@example.com')
564-
565- def test_ascii_only(self):
566- self.assertRaises(InvalidEmailAddressError,
567- self.registrar.register, self.mlist,
568- '\xa0@example.com')
569-
570- def test_domain_required(self):
571- self.assertRaises(InvalidEmailAddressError,
572- self.registrar.register, self.mlist,
573- 'noatsign')
574-
575- def test_full_domain_required(self):
576- self.assertRaises(InvalidEmailAddressError,
577- self.registrar.register, self.mlist,
578- 'nodom@ain')
579-
580-
581-
582
583-class TestRegistration(unittest.TestCase):
584+class TestRegistrar(unittest.TestCase):
585 """Test registration."""
586
587 layer = ConfigLayer
588
589 def setUp(self):
590- self.registrar = getUtility(IRegistrar)
591- self.mlist = create_list('alpha@example.com')
592-
593- def test_confirmation_event_received(self):
594- # Registering an email address generates an event.
595- def capture_event(event):
596- self.assertIsInstance(event, ConfirmationNeededEvent)
597- with event_subscribers(capture_event):
598- self.registrar.register(self.mlist, 'anne@example.com')
599-
600- def test_event_mlist(self):
601- # The event has a reference to the mailing list being subscribed to.
602- def capture_event(event):
603- self.assertIs(event.mlist, self.mlist)
604- with event_subscribers(capture_event):
605- self.registrar.register(self.mlist, 'anne@example.com')
606-
607- def test_event_pendable(self):
608- # The event has an IPendable which contains additional information.
609- def capture_event(event):
610- pendable = event.pendable
611- self.assertEqual(pendable['type'], 'registration')
612- self.assertEqual(pendable['email'], 'anne@example.com')
613- # The key is present, but the value is None.
614- self.assertIsNone(pendable['display_name'])
615- # The default is regular delivery.
616- self.assertEqual(pendable['delivery_mode'], 'regular')
617- self.assertEqual(pendable['list_id'], 'alpha.example.com')
618- with event_subscribers(capture_event):
619- self.registrar.register(self.mlist, 'anne@example.com')
620-
621- def test_token(self):
622- # Registering the email address returns a token, and this token links
623- # back to the pendable.
624- captured_events = []
625- def capture_event(event):
626- captured_events.append(event)
627- with event_subscribers(capture_event):
628- token = self.registrar.register(self.mlist, 'anne@example.com')
629- self.assertEqual(len(captured_events), 1)
630- event = captured_events[0]
631- self.assertEqual(event.token, token)
632- pending = getUtility(IPendings).confirm(token)
633- self.assertEqual(pending, event.pendable)
634+ self._mlist = create_list('ant@example.com')
635+ self._registrar = IRegistrar(self._mlist)
636+ self._pendings = getUtility(IPendings)
637+ self._anne = getUtility(IUserManager).create_address(
638+ 'anne@example.com')
639+
640+ def test_unique_token(self):
641+ # Registering a subscription request provides a unique token associated
642+ # with a pendable.
643+ self.assertEqual(self._pendings.count(), 0)
644+ token = self._registrar.register(self._anne)
645+ self.assertIsNotNone(token)
646+ self.assertEqual(self._pendings.count(), 1)
647+ record = self._pendings.confirm(token, expunge=False)
648+ self.assertEqual(record['list_id'], self._mlist.list_id)
649+ self.assertEqual(record['address'], 'anne@example.com')
650+
651+ def test_no_token(self):
652+ # Registering a subscription request where no confirmation or
653+ # moderation steps are needed, leaves us with no token, since there's
654+ # nothing more to do.
655+ self._mlist.subscription_policy = SubscriptionPolicy.open
656+ self._anne.verified_on = now()
657+ token = self._registrar.register(self._anne)
658+ self.assertIsNone(token)
659+ record = self._pendings.confirm(token, expunge=False)
660+ self.assertIsNone(record)
661+
662+ def test_is_subscribed(self):
663+ # Where no confirmation or moderation steps are needed, registration
664+ # happens immediately.
665+ self._mlist.subscription_policy = SubscriptionPolicy.open
666+ self._anne.verified_on = now()
667+ status = self._registrar.register(self._anne)
668+ self.assertIsNone(status)
669+ member = self._mlist.regular_members.get_member('anne@example.com')
670+ self.assertEqual(member.address, self._anne)
671+
672+ def test_no_such_token(self):
673+ # Given a token which is not in the database, a LookupError is raised.
674+ self._registrar.register(self._anne)
675+ self.assertRaises(LookupError, self._registrar.confirm, 'not-a-token')
676+
677+ def test_confirm_because_verify(self):
678+ # We have a subscription request which requires the user to confirm
679+ # (because she does not have a verified address), but not the moderator
680+ # to approve. Running the workflow gives us a token. Confirming the
681+ # token subscribes the user.
682+ self._mlist.subscription_policy = SubscriptionPolicy.open
683+ token = self._registrar.register(self._anne)
684+ self.assertIsNotNone(token)
685+ member = self._mlist.regular_members.get_member('anne@example.com')
686+ self.assertIsNone(member)
687+ # Now confirm the subscription.
688+ self._registrar.confirm(token)
689+ member = self._mlist.regular_members.get_member('anne@example.com')
690+ self.assertEqual(member.address, self._anne)
691+
692+ def test_confirm_because_confirm(self):
693+ # We have a subscription request which requires the user to confirm
694+ # (because of list policy), but not the moderator to approve. Running
695+ # the workflow gives us a token. Confirming the token subscribes the
696+ # user.
697+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
698+ self._anne.verified_on = now()
699+ token = self._registrar.register(self._anne)
700+ self.assertIsNotNone(token)
701+ member = self._mlist.regular_members.get_member('anne@example.com')
702+ self.assertIsNone(member)
703+ # Now confirm the subscription.
704+ self._registrar.confirm(token)
705+ member = self._mlist.regular_members.get_member('anne@example.com')
706+ self.assertEqual(member.address, self._anne)
707+
708+ def test_confirm_because_moderation(self):
709+ # We have a subscription request which requires the moderator to
710+ # approve. Running the workflow gives us a token. Confirming the
711+ # token subscribes the user.
712+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
713+ self._anne.verified_on = now()
714+ token = self._registrar.register(self._anne)
715+ self.assertIsNotNone(token)
716+ member = self._mlist.regular_members.get_member('anne@example.com')
717+ self.assertIsNone(member)
718+ # Now confirm the subscription.
719+ self._registrar.confirm(token)
720+ member = self._mlist.regular_members.get_member('anne@example.com')
721+ self.assertEqual(member.address, self._anne)
722+
723+ def test_confirm_because_confirm_then_moderation(self):
724+ # We have a subscription request which requires the user to confirm
725+ # (because she does not have a verified address) and the moderator to
726+ # approve. Running the workflow gives us a token. Confirming the
727+ # token runs the workflow a little farther, but still gives us a
728+ # token. Confirming again subscribes the user.
729+ self._mlist.subscription_policy = \
730+ SubscriptionPolicy.confirm_then_moderate
731+ self._anne.verified_on = now()
732+ # Runs until subscription confirmation.
733+ token = self._registrar.register(self._anne)
734+ self.assertIsNotNone(token)
735+ member = self._mlist.regular_members.get_member('anne@example.com')
736+ self.assertIsNone(member)
737+ # Now confirm the subscription, and wait for the moderator to approve
738+ # the subscription. She is still not subscribed.
739+ new_token = self._registrar.confirm(token)
740+ # The new token, used for the moderator to approve the message, is not
741+ # the same as the old token.
742+ self.assertNotEqual(new_token, token)
743+ member = self._mlist.regular_members.get_member('anne@example.com')
744+ self.assertIsNone(member)
745+ # Confirm once more, this time as the moderator approving the
746+ # subscription. Now she's a member.
747+ self._registrar.confirm(new_token)
748+ member = self._mlist.regular_members.get_member('anne@example.com')
749+ self.assertEqual(member.address, self._anne)
750+
751+ def test_confirm_then_moderate_with_different_tokens(self):
752+ # Ensure that the confirmation token the user sees when they have to
753+ # confirm their subscription is different than the token the moderator
754+ # sees when they approve the subscription. This prevents the user
755+ # from using a replay attack to subvert moderator approval.
756+ self._mlist.subscription_policy = \
757+ SubscriptionPolicy.confirm_then_moderate
758+ self._anne.verified_on = now()
759+ # Runs until subscription confirmation.
760+ token = self._registrar.register(self._anne)
761+ self.assertIsNotNone(token)
762+ member = self._mlist.regular_members.get_member('anne@example.com')
763+ self.assertIsNone(member)
764+ # Now confirm the subscription, and wait for the moderator to approve
765+ # the subscription. She is still not subscribed.
766+ new_token = self._registrar.confirm(token)
767+ # The status is not true because the user has not yet been subscribed
768+ # to the mailing list.
769+ self.assertIsNotNone(new_token)
770+ member = self._mlist.regular_members.get_member('anne@example.com')
771+ self.assertIsNone(member)
772+ # The new token is different than the old token.
773+ self.assertNotEqual(token, new_token)
774+ # Trying to confirm with the old token does not work.
775+ self.assertRaises(LookupError, self._registrar.confirm, token)
776+ # Confirm once more, this time with the new token, as the moderator
777+ # approving the subscription. Now she's a member.
778+ done_token = self._registrar.confirm(new_token)
779+ # The token is None, signifying that the member has been subscribed.
780+ self.assertIsNone(done_token)
781+ member = self._mlist.regular_members.get_member('anne@example.com')
782+ self.assertEqual(member.address, self._anne)
783+
784+ def test_discard_waiting_for_confirmation(self):
785+ # While waiting for a user to confirm their subscription, we discard
786+ # the workflow.
787+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
788+ self._anne.verified_on = now()
789+ # Runs until subscription confirmation.
790+ token = self._registrar.register(self._anne)
791+ self.assertIsNotNone(token)
792+ member = self._mlist.regular_members.get_member('anne@example.com')
793+ self.assertIsNone(member)
794+ # Now discard the subscription request.
795+ self._registrar.discard(token)
796+ # Trying to confirm the token now results in an exception.
797+ self.assertRaises(LookupError, self._registrar.confirm, token)
798
799=== modified file 'src/mailman/app/tests/test_subscriptions.py'
800--- src/mailman/app/tests/test_subscriptions.py 2015-01-05 01:22:39 +0000
801+++ src/mailman/app/tests/test_subscriptions.py 2015-04-15 04:15:28 +0000
802@@ -18,7 +18,8 @@
803 """Tests for the subscription service."""
804
805 __all__ = [
806- 'TestJoin'
807+ 'TestJoin',
808+ 'TestSubscriptionWorkflow',
809 ]
810
811
812@@ -26,11 +27,20 @@
813 import unittest
814
815 from mailman.app.lifecycle import create_list
816+from mailman.app.subscriptions import SubscriptionWorkflow
817 from mailman.interfaces.address import InvalidEmailAddressError
818-from mailman.interfaces.member import MemberRole, MissingPreferredAddressError
819+from mailman.interfaces.bans import IBanManager
820+from mailman.interfaces.member import (
821+ MemberRole, MembershipIsBannedError, MissingPreferredAddressError)
822+from mailman.interfaces.pending import IPendings
823 from mailman.interfaces.subscriptions import (
824 MissingUserError, ISubscriptionService)
825+from mailman.testing.helpers import LogFileMark, get_queue_messages
826 from mailman.testing.layers import ConfigLayer
827+from mailman.interfaces.mailinglist import SubscriptionPolicy
828+from mailman.interfaces.usermanager import IUserManager
829+from mailman.utilities.datetime import now
830+from unittest.mock import patch
831 from zope.component import getUtility
832
833
834@@ -65,3 +75,506 @@
835 self._service.join,
836 'test.example.com', anne.user.user_id,
837 role=MemberRole.owner)
838+
839+
840+
841
842+class TestSubscriptionWorkflow(unittest.TestCase):
843+ layer = ConfigLayer
844+ maxDiff = None
845+
846+ def setUp(self):
847+ self._mlist = create_list('test@example.com')
848+ self._mlist.admin_immed_notify = False
849+ self._anne = 'anne@example.com'
850+ self._user_manager = getUtility(IUserManager)
851+
852+ def test_user_or_address_required(self):
853+ # The `subscriber` attribute must be a user or address.
854+ workflow = SubscriptionWorkflow(self._mlist)
855+ self.assertRaises(AssertionError, list, workflow)
856+
857+ def test_sanity_checks_address(self):
858+ # Ensure that the sanity check phase, when given an IAddress, ends up
859+ # with a linked user.
860+ anne = self._user_manager.create_address(self._anne)
861+ workflow = SubscriptionWorkflow(self._mlist, anne)
862+ self.assertIsNotNone(workflow.address)
863+ self.assertIsNone(workflow.user)
864+ workflow.run_thru('sanity_checks')
865+ self.assertIsNotNone(workflow.address)
866+ self.assertIsNotNone(workflow.user)
867+ self.assertEqual(list(workflow.user.addresses)[0].email, self._anne)
868+
869+ def test_sanity_checks_user_with_preferred_address(self):
870+ # Ensure that the sanity check phase, when given an IUser with a
871+ # preferred address, ends up with an address.
872+ anne = self._user_manager.make_user(self._anne)
873+ address = list(anne.addresses)[0]
874+ address.verified_on = now()
875+ anne.preferred_address = address
876+ workflow = SubscriptionWorkflow(self._mlist, anne)
877+ # The constructor sets workflow.address because the user has a
878+ # preferred address.
879+ self.assertEqual(workflow.address, address)
880+ self.assertEqual(workflow.user, anne)
881+ workflow.run_thru('sanity_checks')
882+ self.assertEqual(workflow.address, address)
883+ self.assertEqual(workflow.user, anne)
884+
885+ def test_sanity_checks_user_without_preferred_address(self):
886+ # Ensure that the sanity check phase, when given a user without a
887+ # preferred address, but with at least one linked address, gets an
888+ # address.
889+ anne = self._user_manager.make_user(self._anne)
890+ workflow = SubscriptionWorkflow(self._mlist, anne)
891+ self.assertIsNone(workflow.address)
892+ self.assertEqual(workflow.user, anne)
893+ workflow.run_thru('sanity_checks')
894+ self.assertIsNotNone(workflow.address)
895+ self.assertEqual(workflow.user, anne)
896+
897+ def test_sanity_checks_user_with_multiple_linked_addresses(self):
898+ # Ensure that the santiy check phase, when given a user without a
899+ # preferred address, but with multiple linked addresses, gets of of
900+ # those addresses (exactly which one is undefined).
901+ anne = self._user_manager.make_user(self._anne)
902+ anne.link(self._user_manager.create_address('anne@example.net'))
903+ anne.link(self._user_manager.create_address('anne@example.org'))
904+ workflow = SubscriptionWorkflow(self._mlist, anne)
905+ self.assertIsNone(workflow.address)
906+ self.assertEqual(workflow.user, anne)
907+ workflow.run_thru('sanity_checks')
908+ self.assertIn(workflow.address.email, ['anne@example.com',
909+ 'anne@example.net',
910+ 'anne@example.org'])
911+ self.assertEqual(workflow.user, anne)
912+
913+ def test_sanity_checks_user_without_addresses(self):
914+ # It is an error to try to subscribe a user with no linked addresses.
915+ user = self._user_manager.create_user()
916+ workflow = SubscriptionWorkflow(self._mlist, user)
917+ self.assertRaises(AssertionError, workflow.run_thru, 'sanity_checks')
918+
919+ def test_sanity_checks_globally_banned_address(self):
920+ # An exception is raised if the address is globally banned.
921+ anne = self._user_manager.create_address(self._anne)
922+ IBanManager(None).ban(self._anne)
923+ workflow = SubscriptionWorkflow(self._mlist, anne)
924+ self.assertRaises(MembershipIsBannedError, list, workflow)
925+
926+ def test_sanity_checks_banned_address(self):
927+ # An exception is raised if the address is banned by the mailing list.
928+ anne = self._user_manager.create_address(self._anne)
929+ IBanManager(self._mlist).ban(self._anne)
930+ workflow = SubscriptionWorkflow(self._mlist, anne)
931+ self.assertRaises(MembershipIsBannedError, list, workflow)
932+
933+ def test_verification_checks_with_verified_address(self):
934+ # When the address is already verified, we skip straight to the
935+ # confirmation checks.
936+ anne = self._user_manager.create_address(self._anne)
937+ anne.verified_on = now()
938+ workflow = SubscriptionWorkflow(self._mlist, anne)
939+ workflow.run_thru('verification_checks')
940+ with patch.object(workflow, '_step_confirmation_checks') as step:
941+ next(workflow)
942+ step.assert_called_once_with()
943+
944+ def test_verification_checks_with_pre_verified_address(self):
945+ # When the address is not yet verified, but the pre-verified flag is
946+ # passed to the workflow, we skip to the confirmation checks.
947+ anne = self._user_manager.create_address(self._anne)
948+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
949+ workflow.run_thru('verification_checks')
950+ with patch.object(workflow, '_step_confirmation_checks') as step:
951+ next(workflow)
952+ step.assert_called_once_with()
953+ # And now the address is verified.
954+ self.assertIsNotNone(anne.verified_on)
955+
956+ def test_verification_checks_confirmation_needed(self):
957+ # The address is neither verified, nor is the pre-verified flag set.
958+ # A confirmation message must be sent to the user which will also
959+ # verify their address.
960+ anne = self._user_manager.create_address(self._anne)
961+ workflow = SubscriptionWorkflow(self._mlist, anne)
962+ workflow.run_thru('verification_checks')
963+ with patch.object(workflow, '_step_send_confirmation') as step:
964+ next(workflow)
965+ step.assert_called_once_with()
966+ # The address still hasn't been verified.
967+ self.assertIsNone(anne.verified_on)
968+
969+ def test_confirmation_checks_open_list(self):
970+ # A subscription to an open list does not need to be confirmed or
971+ # moderated.
972+ self._mlist.subscription_policy = SubscriptionPolicy.open
973+ anne = self._user_manager.create_address(self._anne)
974+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
975+ workflow.run_thru('confirmation_checks')
976+ with patch.object(workflow, '_step_do_subscription') as step:
977+ next(workflow)
978+ step.assert_called_once_with()
979+
980+ def test_confirmation_checks_no_user_confirmation_needed(self):
981+ # A subscription to a list which does not need user confirmation skips
982+ # to the moderation checks.
983+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
984+ anne = self._user_manager.create_address(self._anne)
985+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
986+ workflow.run_thru('confirmation_checks')
987+ with patch.object(workflow, '_step_moderation_checks') as step:
988+ next(workflow)
989+ step.assert_called_once_with()
990+
991+ def test_confirmation_checks_confirm_pre_confirmed(self):
992+ # The subscription policy requires user confirmation, but their
993+ # subscription is pre-confirmed.
994+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
995+ anne = self._user_manager.create_address(self._anne)
996+ workflow = SubscriptionWorkflow(self._mlist, anne,
997+ pre_verified=True,
998+ pre_confirmed=True)
999+ workflow.run_thru('confirmation_checks')
1000+ with patch.object(workflow, '_step_moderation_checks') as step:
1001+ next(workflow)
1002+ step.assert_called_once_with()
1003+
1004+ def test_confirmation_checks_confirm_and_moderate_pre_confirmed(self):
1005+ # The subscription policy requires user confirmation and moderation,
1006+ # but their subscription is pre-confirmed.
1007+ self._mlist.subscription_policy = \
1008+ SubscriptionPolicy.confirm_then_moderate
1009+ anne = self._user_manager.create_address(self._anne)
1010+ workflow = SubscriptionWorkflow(self._mlist, anne,
1011+ pre_verified=True,
1012+ pre_confirmed=True)
1013+ workflow.run_thru('confirmation_checks')
1014+ with patch.object(workflow, '_step_moderation_checks') as step:
1015+ next(workflow)
1016+ step.assert_called_once_with()
1017+
1018+ def test_confirmation_checks_confirmation_needed(self):
1019+ # The subscription policy requires confirmation and the subscription
1020+ # is not pre-confirmed.
1021+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
1022+ anne = self._user_manager.create_address(self._anne)
1023+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
1024+ workflow.run_thru('confirmation_checks')
1025+ with patch.object(workflow, '_step_send_confirmation') as step:
1026+ next(workflow)
1027+ step.assert_called_once_with()
1028+
1029+ def test_confirmation_checks_moderate_confirmation_needed(self):
1030+ # The subscription policy requires confirmation and moderation, and the
1031+ # subscription is not pre-confirmed.
1032+ self._mlist.subscription_policy = \
1033+ SubscriptionPolicy.confirm_then_moderate
1034+ anne = self._user_manager.create_address(self._anne)
1035+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
1036+ workflow.run_thru('confirmation_checks')
1037+ with patch.object(workflow, '_step_send_confirmation') as step:
1038+ next(workflow)
1039+ step.assert_called_once_with()
1040+
1041+ def test_moderation_checks_pre_approved(self):
1042+ # The subscription is pre-approved by the moderator.
1043+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
1044+ anne = self._user_manager.create_address(self._anne)
1045+ workflow = SubscriptionWorkflow(self._mlist, anne,
1046+ pre_verified=True,
1047+ pre_approved=True)
1048+ workflow.run_thru('moderation_checks')
1049+ with patch.object(workflow, '_step_do_subscription') as step:
1050+ next(workflow)
1051+ step.assert_called_once_with()
1052+
1053+ def test_moderation_checks_approval_required(self):
1054+ # The moderator must approve the subscription.
1055+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
1056+ anne = self._user_manager.create_address(self._anne)
1057+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
1058+ workflow.run_thru('moderation_checks')
1059+ with patch.object(workflow, '_step_get_moderator_approval') as step:
1060+ next(workflow)
1061+ step.assert_called_once_with()
1062+
1063+ def test_do_subscription(self):
1064+ # An open subscription policy plus a pre-verified address means the
1065+ # user gets subscribed to the mailing list without any further
1066+ # confirmations or approvals.
1067+ self._mlist.subscription_policy = SubscriptionPolicy.open
1068+ anne = self._user_manager.create_address(self._anne)
1069+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
1070+ # Consume the entire state machine.
1071+ list(workflow)
1072+ # Anne is now a member of the mailing list.
1073+ member = self._mlist.regular_members.get_member(self._anne)
1074+ self.assertEqual(member.address, anne)
1075+
1076+ def test_do_subscription_pre_approved(self):
1077+ # An moderation-requiring subscription policy plus a pre-verified and
1078+ # pre-approved address means the user gets subscribed to the mailing
1079+ # list without any further confirmations or approvals.
1080+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
1081+ anne = self._user_manager.create_address(self._anne)
1082+ workflow = SubscriptionWorkflow(self._mlist, anne,
1083+ pre_verified=True,
1084+ pre_approved=True)
1085+ # Consume the entire state machine.
1086+ list(workflow)
1087+ # Anne is now a member of the mailing list.
1088+ member = self._mlist.regular_members.get_member(self._anne)
1089+ self.assertEqual(member.address, anne)
1090+
1091+ def test_do_subscription_pre_approved_pre_confirmed(self):
1092+ # An moderation-requiring subscription policy plus a pre-verified and
1093+ # pre-approved address means the user gets subscribed to the mailing
1094+ # list without any further confirmations or approvals.
1095+ self._mlist.subscription_policy = \
1096+ SubscriptionPolicy.confirm_then_moderate
1097+ anne = self._user_manager.create_address(self._anne)
1098+ workflow = SubscriptionWorkflow(self._mlist, anne,
1099+ pre_verified=True,
1100+ pre_confirmed=True,
1101+ pre_approved=True)
1102+ # Consume the entire state machine.
1103+ list(workflow)
1104+ # Anne is now a member of the mailing list.
1105+ member = self._mlist.regular_members.get_member(self._anne)
1106+ self.assertEqual(member.address, anne)
1107+
1108+ def test_do_subscription_cleanups(self):
1109+ # Once the user is subscribed, the token, and its associated pending
1110+ # database record will be removed from the database.
1111+ self._mlist.subscription_policy = SubscriptionPolicy.open
1112+ anne = self._user_manager.create_address(self._anne)
1113+ workflow = SubscriptionWorkflow(self._mlist, anne,
1114+ pre_verified=True,
1115+ pre_confirmed=True,
1116+ pre_approved=True)
1117+ # Cache the token.
1118+ token = workflow.token
1119+ # Consume the entire state machine.
1120+ list(workflow)
1121+ # Anne is now a member of the mailing list.
1122+ member = self._mlist.regular_members.get_member(self._anne)
1123+ self.assertEqual(member.address, anne)
1124+ # The workflow is done, so it has no token.
1125+ self.assertIsNone(workflow.token)
1126+ # The pendable associated with the token has been evicted.
1127+ self.assertIsNone(getUtility(IPendings).confirm(token, expunge=False))
1128+ # There is no saved workflow associated with the token. This shows up
1129+ # as an exception when we try to restore the workflow.
1130+ new_workflow = SubscriptionWorkflow(self._mlist)
1131+ new_workflow.token = token
1132+ self.assertRaises(LookupError, new_workflow.restore)
1133+
1134+ def test_moderator_approves(self):
1135+ # The workflow runs until moderator approval is required, at which
1136+ # point the workflow is saved. Once the moderator approves, the
1137+ # workflow resumes and the user is subscribed.
1138+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
1139+ anne = self._user_manager.create_address(self._anne)
1140+ workflow = SubscriptionWorkflow(self._mlist, anne,
1141+ pre_verified=True,
1142+ pre_confirmed=True)
1143+ # Consume the entire state machine.
1144+ list(workflow)
1145+ # The user is not currently subscribed to the mailing list.
1146+ member = self._mlist.regular_members.get_member(self._anne)
1147+ self.assertIsNone(member)
1148+ # Create a new workflow with the previous workflow's save token, and
1149+ # restore its state. This models an approved subscription and should
1150+ # result in the user getting subscribed.
1151+ approved_workflow = SubscriptionWorkflow(self._mlist)
1152+ approved_workflow.token = workflow.token
1153+ approved_workflow.restore()
1154+ list(approved_workflow)
1155+ # Now the user is subscribed to the mailing list.
1156+ member = self._mlist.regular_members.get_member(self._anne)
1157+ self.assertEqual(member.address, anne)
1158+
1159+ def test_get_moderator_approval_log_on_hold(self):
1160+ # When the subscription is held for moderator approval, a message is
1161+ # logged.
1162+ mark = LogFileMark('mailman.subscribe')
1163+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
1164+ anne = self._user_manager.create_address(self._anne)
1165+ workflow = SubscriptionWorkflow(self._mlist, anne,
1166+ pre_verified=True,
1167+ pre_confirmed=True)
1168+ # Consume the entire state machine.
1169+ list(workflow)
1170+ line = mark.readline()
1171+ self.assertEqual(
1172+ line[29:-1],
1173+ 'test@example.com: held subscription request from anne@example.com'
1174+ )
1175+
1176+ def test_get_moderator_approval_notifies_moderators(self):
1177+ # When the subscription is held for moderator approval, and the list
1178+ # is so configured, a notification is sent to the list moderators.
1179+ self._mlist.admin_immed_notify = True
1180+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
1181+ anne = self._user_manager.create_address(self._anne)
1182+ workflow = SubscriptionWorkflow(self._mlist, anne,
1183+ pre_verified=True,
1184+ pre_confirmed=True)
1185+ # Consume the entire state machine.
1186+ list(workflow)
1187+ items = get_queue_messages('virgin')
1188+ self.assertEqual(len(items), 1)
1189+ message = items[0].msg
1190+ self.assertEqual(message['From'], 'test-owner@example.com')
1191+ self.assertEqual(message['To'], 'test-owner@example.com')
1192+ self.assertEqual(
1193+ message['Subject'],
1194+ 'New subscription request to Test from anne@example.com')
1195+ self.assertEqual(message.get_payload(), """\
1196+Your authorization is required for a mailing list subscription request
1197+approval:
1198+
1199+ For: anne@example.com
1200+ List: test@example.com""")
1201+
1202+ def test_get_moderator_approval_no_notifications(self):
1203+ # When the subscription is held for moderator approval, and the list
1204+ # is so configured, a notification is sent to the list moderators.
1205+ self._mlist.admin_immed_notify = False
1206+ self._mlist.subscription_policy = SubscriptionPolicy.moderate
1207+ anne = self._user_manager.create_address(self._anne)
1208+ workflow = SubscriptionWorkflow(self._mlist, anne,
1209+ pre_verified=True,
1210+ pre_confirmed=True)
1211+ # Consume the entire state machine.
1212+ list(workflow)
1213+ items = get_queue_messages('virgin')
1214+ self.assertEqual(len(items), 0)
1215+
1216+ def test_send_confirmation(self):
1217+ # A confirmation message gets sent when the address is not verified.
1218+ anne = self._user_manager.create_address(self._anne)
1219+ self.assertIsNone(anne.verified_on)
1220+ # Run the workflow to model the confirmation step.
1221+ workflow = SubscriptionWorkflow(self._mlist, anne)
1222+ list(workflow)
1223+ items = get_queue_messages('virgin')
1224+ self.assertEqual(len(items), 1)
1225+ message = items[0].msg
1226+ token = workflow.token
1227+ self.assertEqual(message['Subject'], 'confirm {}'.format(token))
1228+ self.assertEqual(
1229+ message['From'], 'test-confirm+{}@example.com'.format(token))
1230+
1231+ def test_send_confirmation_pre_confirmed(self):
1232+ # A confirmation message gets sent when the address is not verified
1233+ # but the subscription is pre-confirmed.
1234+ anne = self._user_manager.create_address(self._anne)
1235+ self.assertIsNone(anne.verified_on)
1236+ # Run the workflow to model the confirmation step.
1237+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_confirmed=True)
1238+ list(workflow)
1239+ items = get_queue_messages('virgin')
1240+ self.assertEqual(len(items), 1)
1241+ message = items[0].msg
1242+ token = workflow.token
1243+ self.assertEqual(
1244+ message['Subject'], 'confirm {}'.format(workflow.token))
1245+ self.assertEqual(
1246+ message['From'], 'test-confirm+{}@example.com'.format(token))
1247+
1248+ def test_send_confirmation_pre_verified(self):
1249+ # A confirmation message gets sent even when the address is verified
1250+ # when the subscription must be confirmed.
1251+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
1252+ anne = self._user_manager.create_address(self._anne)
1253+ self.assertIsNone(anne.verified_on)
1254+ # Run the workflow to model the confirmation step.
1255+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
1256+ list(workflow)
1257+ items = get_queue_messages('virgin')
1258+ self.assertEqual(len(items), 1)
1259+ message = items[0].msg
1260+ token = workflow.token
1261+ self.assertEqual(
1262+ message['Subject'], 'confirm {}'.format(workflow.token))
1263+ self.assertEqual(
1264+ message['From'], 'test-confirm+{}@example.com'.format(token))
1265+
1266+ def test_do_confirm_verify_address(self):
1267+ # The address is not yet verified, nor are we pre-verifying. A
1268+ # confirmation message will be sent. When the user confirms their
1269+ # subscription request, the address will end up being verified.
1270+ anne = self._user_manager.create_address(self._anne)
1271+ self.assertIsNone(anne.verified_on)
1272+ # Run the workflow to model the confirmation step.
1273+ workflow = SubscriptionWorkflow(self._mlist, anne)
1274+ list(workflow)
1275+ # The address is still not verified.
1276+ self.assertIsNone(anne.verified_on)
1277+ confirm_workflow = SubscriptionWorkflow(self._mlist)
1278+ confirm_workflow.token = workflow.token
1279+ confirm_workflow.restore()
1280+ confirm_workflow.run_thru('do_confirm_verify')
1281+ # The address is now verified.
1282+ self.assertIsNotNone(anne.verified_on)
1283+
1284+ def test_do_confirmation_subscribes_user(self):
1285+ # Subscriptions to the mailing list must be confirmed. Once that's
1286+ # done, the user's address (which is not initially verified) gets
1287+ # subscribed to the mailing list.
1288+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
1289+ anne = self._user_manager.create_address(self._anne)
1290+ self.assertIsNone(anne.verified_on)
1291+ workflow = SubscriptionWorkflow(self._mlist, anne)
1292+ list(workflow)
1293+ self.assertIsNone(self._mlist.regular_members.get_member(self._anne))
1294+ confirm_workflow = SubscriptionWorkflow(self._mlist)
1295+ confirm_workflow.token = workflow.token
1296+ confirm_workflow.restore()
1297+ list(confirm_workflow)
1298+ self.assertIsNotNone(anne.verified_on)
1299+ self.assertEqual(
1300+ self._mlist.regular_members.get_member(self._anne).address, anne)
1301+
1302+ def test_prevent_confirmation_replay_attacks(self):
1303+ # Ensure that if the workflow requires two confirmations, e.g. first
1304+ # the user confirming their subscription, and then the moderator
1305+ # approving it, that different tokens are used in these two cases.
1306+ self._mlist.subscription_policy = \
1307+ SubscriptionPolicy.confirm_then_moderate
1308+ anne = self._user_manager.create_address(self._anne)
1309+ workflow = SubscriptionWorkflow(self._mlist, anne, pre_verified=True)
1310+ # Run the state machine up to the first confirmation, and cache the
1311+ # confirmation token.
1312+ list(workflow)
1313+ token = workflow.token
1314+ # Anne is not yet a member of the mailing list.
1315+ member = self._mlist.regular_members.get_member(self._anne)
1316+ self.assertIsNone(member)
1317+ # The old token will not work for moderator approval.
1318+ moderator_workflow = SubscriptionWorkflow(self._mlist)
1319+ moderator_workflow.token = token
1320+ moderator_workflow.restore()
1321+ list(moderator_workflow)
1322+ # While we wait for the moderator to approve the subscription, note
1323+ # that there's a new token for the next steps.
1324+ self.assertNotEqual(token, moderator_workflow.token)
1325+ # The old token won't work.
1326+ final_workflow = SubscriptionWorkflow(self._mlist)
1327+ final_workflow.token = token
1328+ self.assertRaises(LookupError, final_workflow.restore)
1329+ # Running this workflow will fail.
1330+ self.assertRaises(AssertionError, list, final_workflow)
1331+ # Anne is still not subscribed.
1332+ member = self._mlist.regular_members.get_member(self._anne)
1333+ self.assertIsNone(member)
1334+ # However, if we use the new token, her subscription request will be
1335+ # approved by the moderator.
1336+ final_workflow.token = moderator_workflow.token
1337+ final_workflow.restore()
1338+ list(final_workflow)
1339+ # And now Anne is a member.
1340+ member = self._mlist.regular_members.get_member(self._anne)
1341+ self.assertEqual(member.address.email, self._anne)
1342
1343=== added file 'src/mailman/app/tests/test_workflow.py'
1344--- src/mailman/app/tests/test_workflow.py 1970-01-01 00:00:00 +0000
1345+++ src/mailman/app/tests/test_workflow.py 2015-04-15 04:15:28 +0000
1346@@ -0,0 +1,128 @@
1347+# Copyright (C) 2015 by the Free Software Foundation, Inc.
1348+#
1349+# This file is part of GNU Mailman.
1350+#
1351+# GNU Mailman is free software: you can redistribute it and/or modify it under
1352+# the terms of the GNU General Public License as published by the Free
1353+# Software Foundation, either version 3 of the License, or (at your option)
1354+# any later version.
1355+#
1356+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
1357+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
1358+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
1359+# more details.
1360+#
1361+# You should have received a copy of the GNU General Public License along with
1362+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
1363+
1364+"""App-level workflow tests."""
1365+
1366+__all__ = [
1367+ 'TestWorkflow',
1368+ ]
1369+
1370+
1371+import unittest
1372+
1373+from mailman.app.workflow import Workflow
1374+from mailman.testing.layers import ConfigLayer
1375+
1376+
1377+class MyWorkflow(Workflow):
1378+ INITIAL_STATE = 'first'
1379+ SAVE_ATTRIBUTES = ('ant', 'bee', 'cat')
1380+
1381+ def __init__(self):
1382+ super().__init__()
1383+ self.token = 'test-workflow'
1384+ self.ant = 1
1385+ self.bee = 2
1386+ self.cat = 3
1387+ self.dog = 4
1388+
1389+ def _step_first(self):
1390+ self.push('second')
1391+ return 'one'
1392+
1393+ def _step_second(self):
1394+ self.push('third')
1395+ return 'two'
1396+
1397+ def _step_third(self):
1398+ return 'three'
1399+
1400+
1401+
1402
1403+class TestWorkflow(unittest.TestCase):
1404+ layer = ConfigLayer
1405+
1406+ def setUp(self):
1407+ self._workflow = iter(MyWorkflow())
1408+
1409+ def test_basic_workflow(self):
1410+ # The work flows from one state to the next.
1411+ results = list(self._workflow)
1412+ self.assertEqual(results, ['one', 'two', 'three'])
1413+
1414+ def test_partial_workflow(self):
1415+ # You don't have to flow through every step.
1416+ results = next(self._workflow)
1417+ self.assertEqual(results, 'one')
1418+
1419+ def test_exhaust_workflow(self):
1420+ # Manually flow through a few steps, then consume the whole thing.
1421+ results = [next(self._workflow)]
1422+ results.extend(self._workflow)
1423+ self.assertEqual(results, ['one', 'two', 'three'])
1424+
1425+ def test_save_and_restore_workflow(self):
1426+ # Without running any steps, save and restore the workflow. Then
1427+ # consume the restored workflow.
1428+ self._workflow.save()
1429+ new_workflow = MyWorkflow()
1430+ new_workflow.restore()
1431+ results = list(new_workflow)
1432+ self.assertEqual(results, ['one', 'two', 'three'])
1433+
1434+ def test_save_and_restore_partial_workflow(self):
1435+ # After running a few steps, save and restore the workflow. Then
1436+ # consume the restored workflow.
1437+ next(self._workflow)
1438+ self._workflow.save()
1439+ new_workflow = MyWorkflow()
1440+ new_workflow.restore()
1441+ results = list(new_workflow)
1442+ self.assertEqual(results, ['two', 'three'])
1443+
1444+ def test_save_and_restore_exhausted_workflow(self):
1445+ # After consuming the entire workflow, save and restore it.
1446+ list(self._workflow)
1447+ self._workflow.save()
1448+ new_workflow = MyWorkflow()
1449+ new_workflow.restore()
1450+ results = list(new_workflow)
1451+ self.assertEqual(len(results), 0)
1452+
1453+ def test_save_and_restore_attributes(self):
1454+ # Saved attributes are restored.
1455+ self._workflow.ant = 9
1456+ self._workflow.bee = 8
1457+ self._workflow.cat = 7
1458+ # Don't save .dog.
1459+ self._workflow.save()
1460+ new_workflow = MyWorkflow()
1461+ new_workflow.restore()
1462+ self.assertEqual(new_workflow.ant, 9)
1463+ self.assertEqual(new_workflow.bee, 8)
1464+ self.assertEqual(new_workflow.cat, 7)
1465+ self.assertEqual(new_workflow.dog, 4)
1466+
1467+ def test_run_thru(self):
1468+ # Run all steps through the given one.
1469+ results = self._workflow.run_thru('second')
1470+ self.assertEqual(results, ['one', 'two'])
1471+
1472+ def test_run_until(self):
1473+ # Run until (but not including) the given step.
1474+ results = self._workflow.run_until('second')
1475+ self.assertEqual(results, ['one'])
1476
1477=== added file 'src/mailman/app/workflow.py'
1478--- src/mailman/app/workflow.py 1970-01-01 00:00:00 +0000
1479+++ src/mailman/app/workflow.py 2015-04-15 04:15:28 +0000
1480@@ -0,0 +1,156 @@
1481+# Copyright (C) 2015 by the Free Software Foundation, Inc.
1482+#
1483+# This file is part of GNU Mailman.
1484+#
1485+# GNU Mailman is free software: you can redistribute it and/or modify it under
1486+# the terms of the GNU General Public License as published by the Free
1487+# Software Foundation, either version 3 of the License, or (at your option)
1488+# any later version.
1489+#
1490+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
1491+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
1492+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
1493+# more details.
1494+#
1495+# You should have received a copy of the GNU General Public License along with
1496+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
1497+
1498+"""Generic workflow."""
1499+
1500+__all__ = [
1501+ 'Workflow',
1502+ ]
1503+
1504+
1505+import sys
1506+import json
1507+import logging
1508+
1509+from collections import deque
1510+from mailman.interfaces.workflow import IWorkflowStateManager
1511+from zope.component import getUtility
1512+
1513+
1514+COMMASPACE = ', '
1515+log = logging.getLogger('mailman.error')
1516+
1517+
1518+
1519
1520+class Workflow:
1521+ """Generic workflow."""
1522+
1523+ SAVE_ATTRIBUTES = ()
1524+ INITIAL_STATE = None
1525+
1526+ def __init__(self):
1527+ self.token = None
1528+ self._next = deque()
1529+ self.push(self.INITIAL_STATE)
1530+ self.debug = False
1531+ self._count = 0
1532+
1533+ @property
1534+ def name(self):
1535+ return self.__class__.__name__
1536+
1537+ def __iter__(self):
1538+ return self
1539+
1540+ def push(self, step):
1541+ self._next.append(step)
1542+
1543+ def _pop(self):
1544+ name = self._next.popleft()
1545+ step = getattr(self, '_step_{}'.format(name))
1546+ self._count += 1
1547+ if self.debug:
1548+ print('[{:02d}] -> {}'.format(self._count, name), file=sys.stderr)
1549+ return name, step
1550+
1551+ def __next__(self):
1552+ try:
1553+ name, step = self._pop()
1554+ return step()
1555+ except IndexError:
1556+ raise StopIteration
1557+ except:
1558+ log.exception('deque: {}'.format(COMMASPACE.join(self._next)))
1559+ raise
1560+
1561+ def run_thru(self, stop_after):
1562+ """Run the state machine through and including the given step.
1563+
1564+ :param stop_after: Name of method, sans prefix to run the
1565+ state machine through. In other words, the state machine runs
1566+ until the named method completes.
1567+ """
1568+ results = []
1569+ while True:
1570+ try:
1571+ name, step = self._pop()
1572+ except (StopIteration, IndexError):
1573+ # We're done.
1574+ break
1575+ results.append(step())
1576+ if name == stop_after:
1577+ break
1578+ return results
1579+
1580+ def run_until(self, stop_before):
1581+ """Trun the state machine until (not including) the given step.
1582+
1583+ :param stop_before: Name of method, sans prefix that the
1584+ state machine is run until the method is reached. Unlike
1585+ `run_thru()` the named method is not run.
1586+ """
1587+ results = []
1588+ while True:
1589+ try:
1590+ name, step = self._pop()
1591+ except (StopIteration, IndexError):
1592+ # We're done.
1593+ break
1594+ if name == stop_before:
1595+ # Stop executing, but not before we push the last state back
1596+ # onto the deque. Otherwise, resuming the state machine would
1597+ # skip this step.
1598+ self._next.appendleft(step)
1599+ break
1600+ results.append(step())
1601+ return results
1602+
1603+ def save(self):
1604+ assert self.token, 'Workflow token must be set'
1605+ state_manager = getUtility(IWorkflowStateManager)
1606+ data = {attr: getattr(self, attr) for attr in self.SAVE_ATTRIBUTES}
1607+ # Note: only the next step is saved, not the whole stack. This is not
1608+ # an issue in practice, since there's never more than a single step in
1609+ # the queue anyway. If we want to support more than a single step in
1610+ # the queue *and* want to support state saving/restoring, change this
1611+ # method and the restore() method.
1612+ if len(self._next) == 0:
1613+ step = None
1614+ elif len(self._next) == 1:
1615+ step = self._next[0]
1616+ else:
1617+ raise AssertionError(
1618+ "Can't save a workflow state with more than one step "
1619+ "in the queue")
1620+ state_manager.save(
1621+ self.__class__.__name__,
1622+ self.token,
1623+ step,
1624+ json.dumps(data))
1625+
1626+ def restore(self):
1627+ state_manager = getUtility(IWorkflowStateManager)
1628+ state = state_manager.restore(self.__class__.__name__, self.token)
1629+ if state is None:
1630+ # The token doesn't exist in the database.
1631+ raise LookupError(self.token)
1632+ self._next.clear()
1633+ if state.step:
1634+ self._next.append(state.step)
1635+ if state.data is not None:
1636+ for attr, value in json.loads(state.data).items():
1637+ setattr(self, attr, value)
1638
1639=== modified file 'src/mailman/commands/docs/membership.rst'
1640--- src/mailman/commands/docs/membership.rst 2015-04-06 20:07:04 +0000
1641+++ src/mailman/commands/docs/membership.rst 2015-04-15 04:15:28 +0000
1642@@ -70,7 +70,7 @@
1643 Joining the sender
1644 ------------------
1645
1646-When the message has a From field, that address will be subscribed.
1647+When the message has a ``From`` field, that address will be subscribed.
1648
1649 >>> msg = message_from_string("""\
1650 ... From: Anne Person <anne@example.com>
1651@@ -85,13 +85,10 @@
1652 Confirmation email sent to Anne Person <anne@example.com>
1653 <BLANKLINE>
1654
1655-Anne is not yet a member because she must confirm her subscription request
1656-first.
1657+Anne is not yet a member of the mailing list because she must confirm her
1658+subscription request first.
1659
1660- >>> from mailman.interfaces.usermanager import IUserManager
1661- >>> from zope.component import getUtility
1662- >>> user_manager = getUtility(IUserManager)
1663- >>> print(user_manager.get_user('anne@example.com'))
1664+ >>> print(mlist.members.get_member('anne@example.com'))
1665 None
1666
1667 Mailman has sent her the confirmation message.
1668@@ -118,10 +115,7 @@
1669 <BLANKLINE>
1670 Before you can start using GNU Mailman at this site, you must first
1671 confirm that this is your email address. You can do this by replying to
1672- this message, keeping the Subject header intact. Or you can visit this
1673- web page
1674- <BLANKLINE>
1675- http://lists.example.com/confirm/...
1676+ this message, keeping the Subject header intact.
1677 <BLANKLINE>
1678 If you do not wish to register this email address simply disregard this
1679 message. If you think you are being maliciously subscribed to the list, or
1680@@ -130,8 +124,7 @@
1681 alpha-owner@example.com
1682 <BLANKLINE>
1683
1684-Once Anne confirms her registration, she will be made a member of the mailing
1685-list.
1686+Anne confirms her registration.
1687 ::
1688
1689 >>> def extract_token(message):
1690@@ -156,13 +149,7 @@
1691 Confirmed
1692 <BLANKLINE>
1693
1694- >>> user = user_manager.get_user('anne@example.com')
1695- >>> print(user.display_name)
1696- Anne Person
1697- >>> list(user.addresses)
1698- [<Address: Anne Person <anne@example.com> [verified] at ...>]
1699-
1700-Anne is also now a member of the mailing list.
1701+Anne is now a member of the mailing list.
1702
1703 >>> mlist.members.get_member('anne@example.com')
1704 <Member: Anne Person <anne@example.com>
1705@@ -180,12 +167,7 @@
1706 >>> print(join.process(mlist_2, msg, {}, (), Results()))
1707 ContinueProcessing.yes
1708
1709-Anne of course, is still registered.
1710-
1711- >>> print(user_manager.get_user('anne@example.com'))
1712- <User "Anne Person" (...) at ...>
1713-
1714-But she is not a member of the mailing list.
1715+Anne is not a member of the mailing list.
1716
1717 >>> print(mlist_2.members.get_member('anne@example.com'))
1718 None
1719@@ -257,7 +239,9 @@
1720 will do.
1721 ::
1722
1723- >>> anne = user_manager.get_user('anne@example.com')
1724+ >>> from mailman.interfaces.usermanager import IUserManager
1725+ >>> from zope.component import getUtility
1726+ >>> anne = getUtility(IUserManager).get_user('anne@example.com')
1727 >>> address = anne.register('anne.person@example.org')
1728
1729 >>> results = Results()
1730@@ -333,11 +317,6 @@
1731 ... raise AssertionError('No confirmation message')
1732 >>> token = extract_token(item.msg)
1733
1734-Bart is still not a user.
1735-
1736- >>> print(user_manager.get_user('bart@example.com'))
1737- None
1738-
1739 Bart replies to the original message, specifically keeping the Subject header
1740 intact except for any prefix. Mailman matches the token and confirms Bart as
1741 a user of the system.
1742@@ -360,12 +339,7 @@
1743 Confirmed
1744 <BLANKLINE>
1745
1746-Now Bart is a user...
1747-
1748- >>> print(user_manager.get_user('bart@example.com'))
1749- <User "Bart Person" (...) at ...>
1750-
1751-...and a member of the mailing list.
1752+Now Bart is now a member of the mailing list.
1753
1754 >>> print(mlist.members.get_member('bart@example.com'))
1755 <Member: Bart Person <bart@example.com>
1756
1757=== modified file 'src/mailman/commands/eml_confirm.py'
1758--- src/mailman/commands/eml_confirm.py 2015-01-05 01:22:39 +0000
1759+++ src/mailman/commands/eml_confirm.py 2015-04-15 04:15:28 +0000
1760@@ -25,7 +25,6 @@
1761 from mailman.core.i18n import _
1762 from mailman.interfaces.command import ContinueProcessing, IEmailCommand
1763 from mailman.interfaces.registrar import IRegistrar
1764-from zope.component import getUtility
1765 from zope.interface import implementer
1766
1767
1768@@ -53,7 +52,11 @@
1769 return ContinueProcessing.yes
1770 tokens.add(token)
1771 results.confirms = tokens
1772- succeeded = getUtility(IRegistrar).confirm(token)
1773+ try:
1774+ succeeded = (IRegistrar(mlist).confirm(token) is None)
1775+ except LookupError:
1776+ # The token must not exist in the database.
1777+ succeeded = False
1778 if succeeded:
1779 print(_('Confirmed'), file=results)
1780 return ContinueProcessing.yes
1781
1782=== modified file 'src/mailman/commands/eml_membership.py'
1783--- src/mailman/commands/eml_membership.py 2015-01-05 01:22:39 +0000
1784+++ src/mailman/commands/eml_membership.py 2015-04-15 04:15:28 +0000
1785@@ -37,6 +37,28 @@
1786
1787
1788
1789
1790+def match_subscriber(email, display_name):
1791+ # Return something matching the email which should be used as the
1792+ # subscriber by the IRegistrar interface.
1793+ manager = getUtility(IUserManager)
1794+ # Is there a user with a preferred address matching the email?
1795+ user = manager.get_user(email)
1796+ if user is not None:
1797+ preferred = user.preferred_address
1798+ if preferred is not None and preferred.email == email.lower():
1799+ return user
1800+ # Is there an address matching the email?
1801+ address = manager.get_address(email)
1802+ if address is not None:
1803+ return address
1804+ # Make a new user and subscribe their first (and only) address. We can't
1805+ # make the first address their preferred address because it hasn't been
1806+ # verified yet.
1807+ user = manager.make_user(email, display_name)
1808+ return list(user.addresses)[0]
1809+
1810+
1811+
1812
1813 @implementer(IEmailCommand)
1814 class Join:
1815 """The email 'join' command."""
1816@@ -60,35 +82,35 @@
1817 delivery_mode = self._parse_arguments(arguments, results)
1818 if delivery_mode is ContinueProcessing.no:
1819 return ContinueProcessing.no
1820- display_name, address = parseaddr(msg['from'])
1821+ display_name, email = parseaddr(msg['from'])
1822 # Address could be None or the empty string.
1823- if not address:
1824- address = msg.sender
1825- if not address:
1826+ if not email:
1827+ email = msg.sender
1828+ if not email:
1829 print(_('$self.name: No valid address found to subscribe'),
1830 file=results)
1831 return ContinueProcessing.no
1832- if isinstance(address, bytes):
1833- address = address.decode('ascii')
1834+ if isinstance(email, bytes):
1835+ email = email.decode('ascii')
1836 # Have we already seen one join request from this user during the
1837 # processing of this email?
1838 joins = getattr(results, 'joins', set())
1839- if address in joins:
1840+ if email in joins:
1841 # Do not register this join.
1842 return ContinueProcessing.yes
1843- joins.add(address)
1844+ joins.add(email)
1845 results.joins = joins
1846- person = formataddr((display_name, address))
1847+ person = formataddr((display_name, email))
1848 # Is this person already a member of the list? Search for all
1849 # matching memberships.
1850 members = getUtility(ISubscriptionService).find_members(
1851- address, mlist.list_id, MemberRole.member)
1852+ email, mlist.list_id, MemberRole.member)
1853 if len(members) > 0:
1854 print(_('$person is already a member'), file=results)
1855- else:
1856- getUtility(IRegistrar).register(mlist, address,
1857- display_name, delivery_mode)
1858- print(_('Confirmation email sent to $person'), file=results)
1859+ return ContinueProcessing.yes
1860+ subscriber = match_subscriber(email, display_name)
1861+ IRegistrar(mlist).register(subscriber)
1862+ print(_('Confirmation email sent to $person'), file=results)
1863 return ContinueProcessing.yes
1864
1865 def _parse_arguments(self, arguments, results):
1866
1867=== modified file 'src/mailman/commands/tests/test_confirm.py'
1868--- src/mailman/commands/tests/test_confirm.py 2015-01-05 01:22:39 +0000
1869+++ src/mailman/commands/tests/test_confirm.py 2015-04-15 04:15:28 +0000
1870@@ -29,8 +29,9 @@
1871 from mailman.email.message import Message
1872 from mailman.interfaces.command import ContinueProcessing
1873 from mailman.interfaces.registrar import IRegistrar
1874+from mailman.interfaces.usermanager import IUserManager
1875 from mailman.runners.command import Results
1876-from mailman.testing.helpers import get_queue_messages, reset_the_world
1877+from mailman.testing.helpers import get_queue_messages
1878 from mailman.testing.layers import ConfigLayer
1879 from zope.component import getUtility
1880
1881@@ -43,15 +44,13 @@
1882
1883 def setUp(self):
1884 self._mlist = create_list('test@example.com')
1885- self._token = getUtility(IRegistrar).register(
1886- self._mlist, 'anne@example.com', 'Anne Person')
1887+ anne = getUtility(IUserManager).create_address(
1888+ 'anne@example.com', 'Anne Person')
1889+ self._token = IRegistrar(self._mlist).register(anne)
1890 self._command = Confirm()
1891 # Clear the virgin queue.
1892 get_queue_messages('virgin')
1893
1894- def tearDown(self):
1895- reset_the_world()
1896-
1897 def test_welcome_message(self):
1898 # A confirmation causes a welcome message to be sent to the member, if
1899 # enabled by the mailing list.
1900
1901=== modified file 'src/mailman/config/configure.zcml'
1902--- src/mailman/config/configure.zcml 2014-09-23 12:58:38 +0000
1903+++ src/mailman/config/configure.zcml 2015-04-15 04:15:28 +0000
1904@@ -40,6 +40,12 @@
1905 factory="mailman.model.requests.ListRequests"
1906 />
1907
1908+ <adapter
1909+ for="mailman.interfaces.mailinglist.IMailingList"
1910+ provides="mailman.interfaces.registrar.IRegistrar"
1911+ factory="mailman.app.registrar.Registrar"
1912+ />
1913+
1914 <utility
1915 provides="mailman.interfaces.bounce.IBounceProcessor"
1916 factory="mailman.model.bounce.BounceProcessor"
1917@@ -88,11 +94,6 @@
1918 />
1919
1920 <utility
1921- provides="mailman.interfaces.registrar.IRegistrar"
1922- factory="mailman.app.registrar.Registrar"
1923- />
1924-
1925- <utility
1926 provides="mailman.interfaces.styles.IStyleManager"
1927 factory="mailman.styles.manager.StyleManager"
1928 />
1929@@ -117,4 +118,9 @@
1930 factory="mailman.app.templates.TemplateLoader"
1931 />
1932
1933+ <utility
1934+ provides="mailman.interfaces.workflow.IWorkflowStateManager"
1935+ factory="mailman.model.workflow.WorkflowStateManager"
1936+ />
1937+
1938 </configure>
1939
1940=== added file 'src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py'
1941--- src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py 1970-01-01 00:00:00 +0000
1942+++ src/mailman/database/alembic/versions/16c2b25c7b_list_subscription_policy.py 2015-04-15 04:15:28 +0000
1943@@ -0,0 +1,41 @@
1944+"""List subscription policy
1945+
1946+Revision ID: 16c2b25c7b
1947+Revises: 46e92facee7
1948+Create Date: 2015-03-21 11:00:44.634883
1949+
1950+"""
1951+
1952+# revision identifiers, used by Alembic.
1953+revision = '16c2b25c7b'
1954+down_revision = '46e92facee7'
1955+
1956+from alembic import op
1957+import sqlalchemy as sa
1958+
1959+from mailman.database.types import Enum
1960+from mailman.interfaces.mailinglist import SubscriptionPolicy
1961+
1962+
1963+def upgrade():
1964+
1965+ ### Update the schema
1966+ op.add_column('mailinglist', sa.Column(
1967+ 'subscription_policy', Enum(SubscriptionPolicy), nullable=True))
1968+
1969+ ### Now migrate the data
1970+ # don't import the table definition from the models, it may break this
1971+ # migration when the model is updated in the future (see the Alembic doc)
1972+ mlist = sa.sql.table('mailinglist',
1973+ sa.sql.column('subscription_policy', Enum(SubscriptionPolicy))
1974+ )
1975+ # there were no enforced subscription policy before, so all lists are
1976+ # considered open
1977+ op.execute(mlist.update().values(
1978+ {'subscription_policy': op.inline_literal(SubscriptionPolicy.open)}))
1979+
1980+
1981+def downgrade():
1982+ if op.get_bind().dialect.name != 'sqlite':
1983+ # SQLite does not support dropping columns.
1984+ op.drop_column('mailinglist', 'subscription_policy')
1985
1986=== added file 'src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py'
1987--- src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py 1970-01-01 00:00:00 +0000
1988+++ src/mailman/database/alembic/versions/2bb9b382198_workflow_state_table.py 2015-04-15 04:15:28 +0000
1989@@ -0,0 +1,28 @@
1990+"""Workflow state table
1991+
1992+Revision ID: 2bb9b382198
1993+Revises: 16c2b25c7b
1994+Create Date: 2015-03-25 18:09:18.338790
1995+
1996+"""
1997+
1998+# revision identifiers, used by Alembic.
1999+revision = '2bb9b382198'
2000+down_revision = '16c2b25c7b'
2001+
2002+from alembic import op
2003+import sqlalchemy as sa
2004+
2005+
2006+def upgrade():
2007+ op.create_table('workflowstate',
2008+ sa.Column('name', sa.Unicode(), nullable=False),
2009+ sa.Column('key', sa.Unicode(), nullable=False),
2010+ sa.Column('step', sa.Unicode(), nullable=True),
2011+ sa.Column('data', sa.Unicode(), nullable=True),
2012+ sa.PrimaryKeyConstraint('name', 'key')
2013+ )
2014+
2015+
2016+def downgrade():
2017+ op.drop_table('workflowstate')
2018
2019=== modified file 'src/mailman/database/transaction.py'
2020--- src/mailman/database/transaction.py 2015-02-13 08:13:06 +0000
2021+++ src/mailman/database/transaction.py 2015-04-15 04:15:28 +0000
2022@@ -19,6 +19,7 @@
2023
2024 __all__ = [
2025 'dbconnection',
2026+ 'flush',
2027 'transaction',
2028 'transactional',
2029 ]
2030@@ -63,6 +64,22 @@
2031
2032
2033
2034
2035+@contextmanager
2036+def flush():
2037+ """Context manager for flushing SQLAlchemy.
2038+
2039+ We need this for SA whereas we didn't need it for Storm because the latter
2040+ did auto-reloads. However, in SA this is needed when we add or delete
2041+ objects from the database. Use it when you need the id after adding, or
2042+ when you want to be sure the object won't be found after a delete.
2043+
2044+ This is lighter weight than committing the transaction.
2045+ """
2046+ yield
2047+ config.db.store.flush()
2048+
2049+
2050+
2051
2052 def dbconnection(function):
2053 """Decorator for getting at the database connection.
2054
2055
2056=== modified file 'src/mailman/interfaces/mailinglist.py'
2057--- src/mailman/interfaces/mailinglist.py 2015-01-05 01:22:39 +0000
2058+++ src/mailman/interfaces/mailinglist.py 2015-04-15 04:15:28 +0000
2059@@ -25,6 +25,7 @@
2060 'IMailingList',
2061 'Personalization',
2062 'ReplyToMunging',
2063+ 'SubscriptionPolicy',
2064 ]
2065
2066
2067@@ -53,6 +54,18 @@
2068 explicit_header = 2
2069
2070
2071+class SubscriptionPolicy(Enum):
2072+ # Neither confirmation, nor moderator approval is required.
2073+ open = 0
2074+ # The user must confirm the subscription.
2075+ confirm = 1
2076+ # The moderator must approve the subscription.
2077+ moderate = 2
2078+ # The user must first confirm their subscription, and then if that is
2079+ # successful, the moderator must also approve it.
2080+ confirm_then_moderate = 3
2081+
2082+
2083
2084
2085 class IMailingList(Interface):
2086 """A mailing list."""
2087@@ -234,6 +247,9 @@
2088 deliver disabled or not, or of the type of digest they are to
2089 receive.""")
2090
2091+ subscription_policy = Attribute(
2092+ """The policy for subscribing new members to the list.""")
2093+
2094 subscribers = Attribute(
2095 """An iterator over all IMembers subscribed to this list, with any
2096 role.
2097
2098=== modified file 'src/mailman/interfaces/member.py'
2099--- src/mailman/interfaces/member.py 2015-01-05 01:22:39 +0000
2100+++ src/mailman/interfaces/member.py 2015-04-15 04:15:28 +0000
2101@@ -123,7 +123,7 @@
2102 """The address is not allowed to subscribe to the mailing list."""
2103
2104 def __init__(self, mlist, address):
2105- super(MembershipIsBannedError, self).__init__()
2106+ super().__init__()
2107 self._mlist = mlist
2108 self._address = address
2109
2110@@ -175,6 +175,14 @@
2111 user = Attribute(
2112 """The user associated with this member.""")
2113
2114+ subscriber = Attribute(
2115+ """The object representing how this member is subscribed.
2116+
2117+ This will be an ``IAddress`` if the user is subscribed via an explicit
2118+ address, otherwise if the the user is subscribed via their preferred
2119+ address, it will be an ``IUser``.
2120+ """)
2121+
2122 preferences = Attribute(
2123 """This member's preferences.""")
2124
2125
2126=== modified file 'src/mailman/interfaces/pending.py'
2127--- src/mailman/interfaces/pending.py 2015-01-05 01:22:39 +0000
2128+++ src/mailman/interfaces/pending.py 2015-04-15 04:15:28 +0000
2129@@ -82,11 +82,11 @@
2130 :return: A token string for inclusion in urls and email confirmations.
2131 """
2132
2133- def confirm(token, expunge=True):
2134+ def confirm(token, *, expunge=True):
2135 """Return the IPendable matching the token.
2136
2137 :param token: The token string for the IPendable given by the `.add()`
2138- method.
2139+ method, or None if there is no record associated with the token.
2140 :param expunge: A flag indicating whether the pendable record should
2141 also be removed from the database or not.
2142 :return: The matching IPendable or None if no match was found.
2143@@ -94,3 +94,6 @@
2144
2145 def evict():
2146 """Remove all pended items whose lifetime has expired."""
2147+
2148+ def count():
2149+ """The number of pendables in the pendings database."""
2150
2151=== modified file 'src/mailman/interfaces/registrar.py'
2152--- src/mailman/interfaces/registrar.py 2015-01-05 01:22:39 +0000
2153+++ src/mailman/interfaces/registrar.py 2015-04-15 04:15:28 +0000
2154@@ -35,79 +35,75 @@
2155 class ConfirmationNeededEvent:
2156 """Triggered when an address needs confirmation.
2157
2158- Addresses must be verified before they can receive messages or post to
2159- mailing list. When an address is registered with Mailman, via the
2160- `IRegistrar` interface, an `IPendable` is created which represents the
2161- pending registration. This pending registration is stored in the
2162- database, keyed by a token. Then this event is triggered.
2163-
2164- There may be several ways to confirm an email address. On some sites,
2165- registration may immediately produce a verification, e.g. because it is on
2166- a known intranet. Or verification may occur via external database lookup
2167- (e.g. LDAP). On most public mailing lists, a mail-back confirmation is
2168- sent to the address, and only if they reply to the mail-back, or click on
2169- an embedded link, is the registered address confirmed.
2170+ Addresses must be verified before they can receive messages or post
2171+ to mailing list. The confirmation message is sent to the user when
2172+ this event is triggered.
2173 """
2174- def __init__(self, mlist, pendable, token):
2175+ def __init__(self, mlist, token, email):
2176 self.mlist = mlist
2177- self.pendable = pendable
2178 self.token = token
2179+ self.email = email
2180
2181
2182
2183
2184 class IRegistrar(Interface):
2185- """Interface for registering and verifying email addresses and users.
2186+ """Interface for subscribing addresses and users.
2187
2188 This is a higher level interface to user registration, email address
2189 confirmation, etc. than the IUserManager. The latter does no validation,
2190 syntax checking, or confirmation, while this interface does.
2191 """
2192
2193- def register(mlist, email, display_name=None, delivery_mode=None):
2194- """Register the email address, requesting verification.
2195-
2196- No `IAddress` or `IUser` is created during this step, but after
2197- successful confirmation, it is guaranteed that an `IAddress` with a
2198- linked `IUser` will exist. When a verified `IAddress` matching
2199- `email` already exists, this method will do nothing, except link a new
2200- `IUser` to the `IAddress` if one is not yet associated with the
2201- email address.
2202-
2203- In all cases, the email address is sanity checked for validity first.
2204-
2205- :param mlist: The mailing list that is the focus of this registration.
2206+ def register(mlist, subscriber=None, *,
2207+ pre_verified=False, pre_confirmed=False, pre_approved=False):
2208+ """Subscribe an address or user according to subscription policies.
2209+
2210+ The mailing list's subscription policy is used to subscribe
2211+ `subscriber` to the given mailing list. The subscriber can be
2212+ an ``IUser``, in which case the user must have a preferred
2213+ address, and that preferred address will be subscribed. The
2214+ subscriber can also be an ``IAddress``, in which case the
2215+ address will be subscribed.
2216+
2217+ The workflow may pause (i.e. be serialized, saved, and
2218+ suspended) when some out-of-band confirmation step is required.
2219+ For example, if the user must confirm, or the moderator must
2220+ approve the subscription. Use the ``confirm(token)`` method to
2221+ resume the workflow.
2222+
2223+ :param mlist: The mailing list to subscribe to.
2224 :type mlist: `IMailingList`
2225- :param email: The email address to register.
2226- :type email: str
2227- :param display_name: The optional display name of the user.
2228- :type display_name: str
2229- :param delivery_mode: The optional delivery mode for this
2230- registration. If not given, regular delivery is used.
2231- :type delivery_mode: `DeliveryMode`
2232- :return: The confirmation token string.
2233- :rtype: str
2234- :raises InvalidEmailAddressError: if the address is not allowed.
2235+ :param subscriber: The user or address to subscribe.
2236+ :type email: ``IUser`` or ``IAddress``
2237+ :return: The confirmation token string, or None if the workflow
2238+ completes (i.e. the member has been subscribed).
2239+ :rtype: str or None
2240+ :raises MembershipIsBannedError: when the address being subscribed
2241+ appears in the global or list-centric bans.
2242 """
2243
2244 def confirm(token):
2245- """Confirm the pending registration matched to the given `token`.
2246-
2247- Confirmation ensures that the IAddress exists and is linked to an
2248- IUser, with the latter being created and linked if necessary.
2249-
2250- :param token: A token matching a pending event with a type of
2251- 'registration'.
2252- :return: Boolean indicating whether the confirmation succeeded or
2253- not. It may fail if the token is no longer in the database, or if
2254- the token did not match a registration event.
2255+ """Continue any paused workflow.
2256+
2257+ Confirmation may occur after the user confirms their
2258+ subscription request, or their email address must be verified,
2259+ or the moderator must approve the subscription request.
2260+
2261+ :param token: A token matching a workflow.
2262+ :type token: string
2263+ :return: The new token for any follow up confirmation, or None if the
2264+ user was subscribed.
2265+ :rtype: str or None
2266+ :raises LookupError: when no workflow is associated with the token.
2267 """
2268
2269 def discard(token):
2270- """Discard the pending registration matched to the given `token`.
2271-
2272- The event record is discarded and the IAddress is not verified. No
2273- IUser is created.
2274+ """Discard the workflow matched to the given `token`.
2275
2276 :param token: A token matching a pending event with a type of
2277 'registration'.
2278+ :raises LookupError: when no workflow is associated with the token.
2279 """
2280+
2281+ def evict():
2282+ """Evict all saved workflows which have expired."""
2283
2284=== modified file 'src/mailman/interfaces/roster.py'
2285--- src/mailman/interfaces/roster.py 2015-01-05 01:22:39 +0000
2286+++ src/mailman/interfaces/roster.py 2015-04-15 04:15:28 +0000
2287@@ -53,11 +53,26 @@
2288 managed by this roster.
2289 """)
2290
2291- def get_member(address):
2292+ def get_member(email):
2293 """Get the member for the given address.
2294
2295- :param address: The email address to search for.
2296- :type address: text
2297+ *Note* that it is possible for an email to be subscribed to a
2298+ mailing list twice, once through its explicit address and once
2299+ indirectly through a user's preferred address. In this case,
2300+ this API always returns the explicit address. Use
2301+ ``get_memberships()`` to return them all.
2302+
2303+ :param email: The email address to search for.
2304+ :type email: string
2305 :return: The member if found, otherwise None
2306 :rtype: `IMember` or None
2307 """
2308+
2309+ def get_memberships(email):
2310+ """Get the memberships for the given address.
2311+
2312+ :param email: The email address to search for.
2313+ :type email: string
2314+ :return: All the memberships associated with this email address.
2315+ :rtype: sequence of length 0, 1, or 2 of ``IMember``
2316+ """
2317
2318=== added file 'src/mailman/interfaces/workflow.py'
2319--- src/mailman/interfaces/workflow.py 1970-01-01 00:00:00 +0000
2320+++ src/mailman/interfaces/workflow.py 2015-04-15 04:15:28 +0000
2321@@ -0,0 +1,80 @@
2322+# Copyright (C) 2015 by the Free Software Foundation, Inc.
2323+#
2324+# This file is part of GNU Mailman.
2325+#
2326+# GNU Mailman is free software: you can redistribute it and/or modify it under
2327+# the terms of the GNU General Public License as published by the Free
2328+# Software Foundation, either version 3 of the License, or (at your option)
2329+# any later version.
2330+#
2331+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
2332+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
2333+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
2334+# more details.
2335+#
2336+# You should have received a copy of the GNU General Public License along with
2337+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
2338+
2339+"""Interfaces describing the state of a workflow."""
2340+
2341+__all__ = [
2342+ 'IWorkflowState',
2343+ 'IWorkflowStateManager',
2344+ ]
2345+
2346+
2347+from zope.interface import Attribute, Interface
2348+
2349+
2350+
2351
2352+class IWorkflowState(Interface):
2353+ """The state of a workflow."""
2354+
2355+ name = Attribute('The name of the workflow.')
2356+
2357+ token = Attribute('A unique key identifying the workflow instance.')
2358+
2359+ step = Attribute("This workflow's next step.")
2360+
2361+ data = Attribute('Additional data (may be JSON-encoded).')
2362+
2363+
2364+
2365
2366+class IWorkflowStateManager(Interface):
2367+ """The workflow states manager."""
2368+
2369+ def save(name, token, step, data=None):
2370+ """Save the state of a workflow.
2371+
2372+ :param name: The name of the workflow.
2373+ :type name: str
2374+ :param token: A unique token identifying this workflow instance.
2375+ :type token: str
2376+ :param step: The next step for this workflow.
2377+ :type step: str
2378+ :param data: Additional data (workflow-specific).
2379+ :type data: str
2380+ """
2381+
2382+ def restore(name, token):
2383+ """Get the saved state for a workflow or None if nothing was saved.
2384+
2385+ :param name: The name of the workflow.
2386+ :type name: str
2387+ :param token: A unique token identifying this workflow instance.
2388+ :type token: str
2389+ :return: The saved state associated with this name/token pair, or None
2390+ if the pair isn't in the database.
2391+ :rtype: ``IWorkflowState``
2392+ """
2393+
2394+ def discard(name, token):
2395+ """Throw away the saved state for a workflow.
2396+
2397+ :param name: The name of the workflow.
2398+ :type name: str
2399+ :param token: A unique token identifying this workflow instance.
2400+ :type token: str
2401+ """
2402+
2403+ count = Attribute('The number of saved workflows in the database.')
2404
2405=== modified file 'src/mailman/model/docs/membership.rst'
2406--- src/mailman/model/docs/membership.rst 2014-12-13 20:12:54 +0000
2407+++ src/mailman/model/docs/membership.rst 2015-04-15 04:15:28 +0000
2408@@ -228,6 +228,38 @@
2409 fperson@example.com MemberRole.nonmember
2410
2411
2412+Subscriber type
2413+===============
2414+
2415+Members can be subscribed to a mailing list either via an explicit address, or
2416+indirectly through a user's preferred address. Sometimes you want to know
2417+which one it is.
2418+
2419+Herb subscribes to the mailing list via an explicit address.
2420+
2421+ >>> herb = user_manager.create_address(
2422+ ... 'hperson@example.com', 'Herb Person')
2423+ >>> herb_member = mlist.subscribe(herb)
2424+
2425+Iris subscribes to the mailing list via her preferred address.
2426+
2427+ >>> iris = user_manager.make_user(
2428+ ... 'iperson@example.com', 'Iris Person')
2429+ >>> preferred = list(iris.addresses)[0]
2430+ >>> from mailman.utilities.datetime import now
2431+ >>> preferred.verified_on = now()
2432+ >>> iris.preferred_address = preferred
2433+ >>> iris_member = mlist.subscribe(iris)
2434+
2435+When we need to know which way a member is subscribed, we can look at the this
2436+attribute.
2437+
2438+ >>> herb_member.subscriber
2439+ <Address: Herb Person <hperson@example.com> [not verified] at ...>
2440+ >>> iris_member.subscriber
2441+ <User "Iris Person" (5) at ...>
2442+
2443+
2444 Moderation actions
2445 ==================
2446
2447@@ -250,6 +282,8 @@
2448 aperson@example.com MemberRole.member Action.defer
2449 bperson@example.com MemberRole.member Action.defer
2450 cperson@example.com MemberRole.member Action.defer
2451+ hperson@example.com MemberRole.member Action.defer
2452+ iperson@example.com MemberRole.member Action.defer
2453
2454 Postings by nonmembers are held for moderator approval by default.
2455
2456@@ -272,7 +306,7 @@
2457 >>> gwen_member = bee.subscribe(gwen_address)
2458 >>> for m in bee.members.members:
2459 ... print(m.member_id.int, m.mailing_list.list_id, m.address.email)
2460- 7 bee.example.com gwen@example.com
2461+ 9 bee.example.com gwen@example.com
2462
2463 Gwen gets a email address.
2464
2465@@ -288,7 +322,7 @@
2466
2467 >>> for m in bee.members.members:
2468 ... print(m.member_id.int, m.mailing_list.list_id, m.address.email)
2469- 7 bee.example.com gperson@example.com
2470+ 9 bee.example.com gperson@example.com
2471
2472
2473 Events
2474
2475=== modified file 'src/mailman/model/docs/pending.rst'
2476--- src/mailman/model/docs/pending.rst 2014-12-14 19:11:28 +0000
2477+++ src/mailman/model/docs/pending.rst 2015-04-15 04:15:28 +0000
2478@@ -13,6 +13,11 @@
2479 >>> from zope.component import getUtility
2480 >>> pendingdb = getUtility(IPendings)
2481
2482+There are nothing in the pendings database.
2483+
2484+ >>> pendingdb.count()
2485+ 0
2486+
2487 The pending database can add any ``IPendable`` to the database, returning a
2488 token that can be used in urls and such.
2489 ::
2490@@ -33,6 +38,11 @@
2491 >>> len(token)
2492 40
2493
2494+There's exactly one entry in the pendings database now.
2495+
2496+ >>> pendingdb.count()
2497+ 1
2498+
2499 There's not much you can do with tokens except to *confirm* them, which
2500 basically means returning the `IPendable` structure (as a dictionary) from the
2501 database that matches the token. If the token isn't in the database, None is
2502
2503=== modified file 'src/mailman/model/docs/registration.rst'
2504--- src/mailman/model/docs/registration.rst 2015-04-06 20:07:04 +0000
2505+++ src/mailman/model/docs/registration.rst 2015-04-15 04:15:28 +0000
2506@@ -1,333 +1,90 @@
2507-====================
2508-Address registration
2509-====================
2510-
2511-Before users can join a mailing list, they must first register with Mailman.
2512-The only thing they must supply is an email address, although there is
2513-additional information they may supply. All registered email addresses must
2514-be verified before Mailman will send them any list traffic.
2515-
2516-The ``IUserManager`` manages users, but it does so at a fairly low level.
2517-Specifically, it does not handle verification, email address syntax validity
2518-checks, etc. The ``IRegistrar`` is the interface to the object handling all
2519-this stuff.
2520+============
2521+Registration
2522+============
2523+
2524+When a user wants to join a mailing list, they must register and verify their
2525+email address. Then depending on how the mailing list is configured, they may
2526+need to confirm their subscription and have it approved by the list
2527+moderator. The ``IRegistrar`` interface manages this work flow.
2528
2529 >>> from mailman.interfaces.registrar import IRegistrar
2530- >>> from zope.component import getUtility
2531- >>> registrar = getUtility(IRegistrar)
2532-
2533-Here is a helper function to check the token strings.
2534-
2535- >>> def check_token(token):
2536- ... assert isinstance(token, str), 'Not a string'
2537- ... assert len(token) == 40, 'Unexpected length: %d' % len(token)
2538- ... assert token.isalnum(), 'Not alphanumeric'
2539- ... print('ok')
2540-
2541-Here is a helper function to extract tokens from confirmation messages.
2542-
2543- >>> import re
2544- >>> cre = re.compile('http://lists.example.com/confirm/(.*)')
2545- >>> def extract_token(msg):
2546- ... mo = cre.search(msg.get_payload())
2547- ... return mo.group(1)
2548-
2549-
2550-Invalid email addresses
2551-=======================
2552-
2553-Addresses are registered within the context of a mailing list, mostly so that
2554-confirmation emails can come from some place. You also need the email
2555-address of the user who is registering.
2556-
2557- >>> mlist = create_list('alpha@example.com')
2558+
2559+Registrars adapt mailing lists.
2560+
2561+ >>> from mailman.interfaces.mailinglist import SubscriptionPolicy
2562+ >>> mlist = create_list('ant@example.com')
2563 >>> mlist.send_welcome_message = False
2564-
2565-Some amount of sanity checks are performed on the email address, although
2566-honestly, not as much as probably should be done. Still, some patently bad
2567-addresses are rejected outright.
2568+ >>> mlist.subscription_policy = SubscriptionPolicy.open
2569+ >>> registrar = IRegistrar(mlist)
2570+
2571+Usually, addresses are registered, but users with preferred addresses can be
2572+registered too.
2573+
2574+ >>> from mailman.interfaces.usermanager import IUserManager
2575+ >>> from zope.component import getUtility
2576+ >>> anne = getUtility(IUserManager).create_address(
2577+ ... 'anne@example.com', 'Anne Person')
2578
2579
2580 Register an email address
2581 =========================
2582
2583-Registration of an unknown address creates nothing until the confirmation step
2584-is complete. No ``IUser`` or ``IAddress`` is created at registration time,
2585-but a record is added to the pending database, and the token for that record
2586-is returned.
2587-
2588- >>> token = registrar.register(mlist, 'aperson@example.com', 'Anne Person')
2589- >>> check_token(token)
2590- ok
2591-
2592-There should be no records in the user manager for this address yet.
2593-
2594- >>> from mailman.interfaces.usermanager import IUserManager
2595- >>> from zope.component import getUtility
2596- >>> user_manager = getUtility(IUserManager)
2597- >>> print(user_manager.get_user('aperson@example.com'))
2598- None
2599- >>> print(user_manager.get_address('aperson@example.com'))
2600- None
2601-
2602-But this address is waiting for confirmation.
2603-
2604- >>> from mailman.interfaces.pending import IPendings
2605- >>> pendingdb = getUtility(IPendings)
2606-
2607- >>> dump_msgdata(pendingdb.confirm(token, expunge=False))
2608- delivery_mode: regular
2609- display_name : Anne Person
2610- email : aperson@example.com
2611- list_id : alpha.example.com
2612- type : registration
2613-
2614-
2615-Verification by email
2616-=====================
2617-
2618-There is also a verification email sitting in the virgin queue now. This
2619-message is sent to the user in order to verify the registered address.
2620-
2621- >>> from mailman.testing.helpers import get_queue_messages
2622- >>> items = get_queue_messages('virgin')
2623- >>> len(items)
2624- 1
2625- >>> print(items[0].msg.as_string())
2626- MIME-Version: 1.0
2627- ...
2628- Subject: confirm ...
2629- From: alpha-confirm+...@example.com
2630- To: aperson@example.com
2631- ...
2632- <BLANKLINE>
2633- Email Address Registration Confirmation
2634- <BLANKLINE>
2635- Hello, this is the GNU Mailman server at example.com.
2636- <BLANKLINE>
2637- We have received a registration request for the email address
2638- <BLANKLINE>
2639- aperson@example.com
2640- <BLANKLINE>
2641- Before you can start using GNU Mailman at this site, you must first
2642- confirm that this is your email address. You can do this by replying to
2643- this message, keeping the Subject header intact. Or you can visit this
2644- web page
2645- <BLANKLINE>
2646- http://lists.example.com/confirm/...
2647- <BLANKLINE>
2648- If you do not wish to register this email address simply disregard this
2649- message. If you think you are being maliciously subscribed to the list,
2650- or have any other questions, you may contact
2651- <BLANKLINE>
2652- alpha-owner@example.com
2653- <BLANKLINE>
2654- >>> dump_msgdata(items[0].msgdata)
2655- _parsemsg : False
2656- listid : alpha.example.com
2657- nodecorate : True
2658- recipients : {'aperson@example.com'}
2659- reduced_list_headers: True
2660- version : 3
2661-
2662-The confirmation token shows up in several places, each of which provides an
2663-easy way for the user to complete the confirmation. The token will always
2664-appear in a URL in the body of the message.
2665-
2666- >>> sent_token = extract_token(items[0].msg)
2667- >>> sent_token == token
2668- True
2669-
2670-The same token will appear in the ``From`` header.
2671-
2672- >>> items[0].msg['from'] == 'alpha-confirm+' + token + '@example.com'
2673- True
2674-
2675-It will also appear in the ``Subject`` header.
2676-
2677- >>> items[0].msg['subject'] == 'confirm ' + token
2678- True
2679-
2680-The user would then validate their registered address by clicking on a url or
2681-responding to the message. Either way, the confirmation process extracts the
2682-token and uses that to confirm the pending registration.
2683-
2684- >>> registrar.confirm(token)
2685- True
2686-
2687-Now, there is an `IAddress` in the database matching the address, as well as
2688-an `IUser` linked to this address. The `IAddress` is verified.
2689-
2690- >>> found_address = user_manager.get_address('aperson@example.com')
2691- >>> found_address
2692- <Address: Anne Person <aperson@example.com> [verified] at ...>
2693- >>> found_user = user_manager.get_user('aperson@example.com')
2694- >>> found_user
2695- <User "Anne Person" (...) at ...>
2696- >>> found_user.controls(found_address.email)
2697- True
2698- >>> from datetime import datetime
2699- >>> isinstance(found_address.verified_on, datetime)
2700- True
2701-
2702-
2703-Non-standard registrations
2704-==========================
2705-
2706-If you try to confirm a registration token twice, of course only the first one
2707-will work. The second one is ignored.
2708-
2709- >>> token = registrar.register(mlist, 'bperson@example.com')
2710- >>> check_token(token)
2711- ok
2712- >>> items = get_queue_messages('virgin')
2713- >>> len(items)
2714- 1
2715- >>> sent_token = extract_token(items[0].msg)
2716- >>> token == sent_token
2717- True
2718- >>> registrar.confirm(token)
2719- True
2720- >>> registrar.confirm(token)
2721- False
2722-
2723-If an address is in the system, but that address is not linked to a user yet
2724-and the address is not yet validated, then no user is created until the
2725-confirmation step is completed.
2726-
2727- >>> user_manager.create_address('cperson@example.com')
2728- <Address: cperson@example.com [not verified] at ...>
2729- >>> token = registrar.register(
2730- ... mlist, 'cperson@example.com', 'Claire Person')
2731- >>> print(user_manager.get_user('cperson@example.com'))
2732- None
2733- >>> items = get_queue_messages('virgin')
2734- >>> len(items)
2735- 1
2736- >>> sent_token = extract_token(items[0].msg)
2737- >>> registrar.confirm(sent_token)
2738- True
2739- >>> user_manager.get_user('cperson@example.com')
2740- <User "Claire Person" (...) at ...>
2741- >>> user_manager.get_address('cperson@example.com')
2742- <Address: cperson@example.com [verified] at ...>
2743-
2744-Even if the address being registered has already been verified, the
2745-registration sends a confirmation.
2746-
2747- >>> token = registrar.register(mlist, 'cperson@example.com')
2748- >>> token is not None
2749- True
2750-
2751-
2752-Discarding
2753-==========
2754-
2755-A confirmation token can also be discarded, say if the user changes his or her
2756-mind about registering. When discarded, no `IAddress` or `IUser` is created.
2757-::
2758-
2759- >>> token = registrar.register(mlist, 'eperson@example.com', 'Elly Person')
2760- >>> check_token(token)
2761- ok
2762- >>> registrar.discard(token)
2763- >>> print(pendingdb.confirm(token))
2764- None
2765- >>> print(user_manager.get_address('eperson@example.com'))
2766- None
2767- >>> print(user_manager.get_user('eperson@example.com'))
2768- None
2769-
2770- # Clear the virgin queue of all the preceding confirmation messages.
2771- >>> ignore = get_queue_messages('virgin')
2772-
2773-
2774-Registering a new address for an existing user
2775-==============================================
2776-
2777-When a new address for an existing user is registered, there isn't too much
2778-different except that the new address will still need to be verified before it
2779-can be used.
2780-::
2781+When the registration steps involve confirmation or moderator approval, the
2782+process will pause until these steps are completed. A unique token is created
2783+which represents this work flow.
2784+
2785+Anne attempts to join the mailing list.
2786+
2787+ >>> token = registrar.register(anne)
2788+
2789+Because her email address has not yet been verified, she has not yet become a
2790+member of the mailing list.
2791+
2792+ >>> print(mlist.members.get_member('anne@example.com'))
2793+ None
2794+
2795+Once she verifies her email address, she will become a member of the mailing
2796+list. In this case, verifying implies that she also confirms her wish to join
2797+the mailing list.
2798+
2799+ >>> registrar.confirm(token)
2800+ >>> mlist.members.get_member('anne@example.com')
2801+ <Member: Anne Person <anne@example.com> on ant@example.com
2802+ as MemberRole.member>
2803+
2804+
2805+Register a user
2806+===============
2807+
2808+Users can also register, but they must have a preferred address. The mailing
2809+list will deliver messages to this preferred address.
2810+
2811+ >>> bart = getUtility(IUserManager).make_user(
2812+ ... 'bart@example.com', 'Bart Person')
2813+
2814+Bart verifies his address and makes it his preferred address.
2815
2816 >>> from mailman.utilities.datetime import now
2817- >>> dperson = user_manager.create_user(
2818- ... 'dperson@example.com', 'Dave Person')
2819- >>> dperson
2820- <User "Dave Person" (...) at ...>
2821- >>> address = user_manager.get_address('dperson@example.com')
2822- >>> address.verified_on = now()
2823-
2824- >>> from operator import attrgetter
2825- >>> dump_list(repr(address) for address in dperson.addresses)
2826- <Address: Dave Person <dperson@example.com> [verified] at ...>
2827- >>> dperson.register('david.person@example.com', 'David Person')
2828- <Address: David Person <david.person@example.com> [not verified] at ...>
2829- >>> token = registrar.register(mlist, 'david.person@example.com')
2830-
2831- >>> items = get_queue_messages('virgin')
2832- >>> len(items)
2833- 1
2834- >>> sent_token = extract_token(items[0].msg)
2835- >>> registrar.confirm(sent_token)
2836- True
2837- >>> user = user_manager.get_user('david.person@example.com')
2838- >>> user is dperson
2839- True
2840- >>> user
2841- <User "Dave Person" (...) at ...>
2842- >>> dump_list(repr(address) for address in user.addresses)
2843- <Address: Dave Person <dperson@example.com> [verified] at ...>
2844- <Address: David Person <david.person@example.com> [verified] at ...>
2845-
2846-
2847-Corner cases
2848-============
2849-
2850-If you try to confirm a token that doesn't exist in the pending database, the
2851-confirm method will just return False.
2852-
2853- >>> registrar.confirm(bytes(b'no token'))
2854- False
2855-
2856-Likewise, if you try to confirm, through the `IRegistrar` interface, a token
2857-that doesn't match a registration event, you will get ``None``. However, the
2858-pending event matched with that token will still be removed.
2859-::
2860-
2861- >>> from mailman.interfaces.pending import IPendable
2862- >>> from zope.interface import implementer
2863-
2864- >>> @implementer(IPendable)
2865- ... class SimplePendable(dict):
2866- ... pass
2867-
2868- >>> pendable = SimplePendable(type='foo', bar='baz')
2869- >>> token = pendingdb.add(pendable)
2870- >>> registrar.confirm(token)
2871- False
2872- >>> print(pendingdb.confirm(token))
2873- None
2874-
2875-
2876-Registration and subscription
2877-=============================
2878-
2879-Fred registers with Mailman at the same time that he subscribes to a mailing
2880-list.
2881-
2882- >>> token = registrar.register(
2883- ... mlist, 'fred.person@example.com', 'Fred Person')
2884-
2885-Before confirmation, Fred is not a member of the mailing list.
2886-
2887- >>> print(mlist.members.get_member('fred.person@example.com'))
2888- None
2889-
2890-But after confirmation, he is.
2891-
2892- >>> registrar.confirm(token)
2893- True
2894- >>> print(mlist.members.get_member('fred.person@example.com'))
2895- <Member: Fred Person <fred.person@example.com>
2896- on alpha@example.com as MemberRole.member>
2897+ >>> preferred = list(bart.addresses)[0]
2898+ >>> preferred.verified_on = now()
2899+ >>> bart.preferred_address = preferred
2900+
2901+The mailing list's subscription policy does not require Bart to confirm his
2902+subscription, but the moderate does want to approve all subscriptions.
2903+
2904+ >>> mlist.subscription_policy = SubscriptionPolicy.moderate
2905+
2906+Now when Bart registers as a user for the mailing list, a token will still be
2907+generated, but this is only used by the moderator. At first, Bart is not
2908+subscribed to the mailing list.
2909+
2910+ >>> token = registrar.register(bart)
2911+ >>> print(mlist.members.get_member('bart@example.com'))
2912+ None
2913+
2914+When the moderator confirms Bart's subscription, he joins the mailing list.
2915+
2916+ >>> registrar.confirm(token)
2917+ >>> mlist.members.get_member('bart@example.com')
2918+ <Member: Bart Person <bart@example.com> on ant@example.com
2919+ as MemberRole.member>
2920
2921=== modified file 'src/mailman/model/mailinglist.py'
2922--- src/mailman/model/mailinglist.py 2015-03-26 20:47:09 +0000
2923+++ src/mailman/model/mailinglist.py 2015-04-15 04:15:28 +0000
2924@@ -38,7 +38,7 @@
2925 from mailman.interfaces.languages import ILanguageManager
2926 from mailman.interfaces.mailinglist import (
2927 IAcceptableAlias, IAcceptableAliasSet, IListArchiver, IListArchiverSet,
2928- IMailingList, Personalization, ReplyToMunging)
2929+ IMailingList, Personalization, ReplyToMunging, SubscriptionPolicy)
2930 from mailman.interfaces.member import (
2931 AlreadySubscribedError, MemberRole, MissingPreferredAddressError,
2932 SubscriptionEvent)
2933@@ -183,6 +183,7 @@
2934 send_goodbye_message = Column(Boolean)
2935 send_welcome_message = Column(Boolean)
2936 subject_prefix = Column(Unicode)
2937+ subscription_policy = Column(Enum(SubscriptionPolicy))
2938 topics = Column(PickleType)
2939 topics_bodylines_limit = Column(Integer)
2940 topics_enabled = Column(Boolean)
2941
2942=== modified file 'src/mailman/model/member.py'
2943--- src/mailman/model/member.py 2015-01-05 01:22:39 +0000
2944+++ src/mailman/model/member.py 2015-04-15 04:15:28 +0000
2945@@ -135,6 +135,10 @@
2946 if self._address is None
2947 else getUtility(IUserManager).get_user(self._address.email))
2948
2949+ @property
2950+ def subscriber(self):
2951+ return (self._user if self._address is None else self._address)
2952+
2953 def _lookup(self, preference, default=None):
2954 pref = getattr(self.preferences, preference)
2955 if pref is not None:
2956
2957=== modified file 'src/mailman/model/pending.py'
2958--- src/mailman/model/pending.py 2015-01-05 01:22:39 +0000
2959+++ src/mailman/model/pending.py 2015-04-15 04:15:28 +0000
2960@@ -128,7 +128,7 @@
2961 return token
2962
2963 @dbconnection
2964- def confirm(self, store, token, expunge=True):
2965+ def confirm(self, store, token, *, expunge=True):
2966 # Token can come in as a unicode, but it's stored in the database as
2967 # bytes. They must be ascii.
2968 pendings = store.query(Pended).filter_by(token=str(token))
2969@@ -165,3 +165,7 @@
2970 for keyvalue in q:
2971 store.delete(keyvalue)
2972 store.delete(pending)
2973+
2974+ @dbconnection
2975+ def count(self, store):
2976+ return store.query(Pended).count()
2977
2978=== modified file 'src/mailman/model/roster.py'
2979--- src/mailman/model/roster.py 2015-01-05 01:22:39 +0000
2980+++ src/mailman/model/roster.py 2015-04-15 04:15:28 +0000
2981@@ -97,21 +97,48 @@
2982 yield member.address
2983
2984 @dbconnection
2985- def get_member(self, store, address):
2986- """See `IRoster`."""
2987- results = store.query(Member).filter(
2988+ def _get_all_memberships(self, store, email):
2989+ # Avoid circular imports.
2990+ from mailman.model.user import User
2991+ # Here's a query that finds all members subscribed with an explicit
2992+ # email address.
2993+ members_a = store.query(Member).filter(
2994 Member.list_id == self._mlist.list_id,
2995 Member.role == self.role,
2996- Address.email == address,
2997+ Address.email == email,
2998 Member.address_id == Address.id)
2999- if results.count() == 0:
3000+ # Here's a query that finds all members subscribed with their
3001+ # preferred address.
3002+ members_u = store.query(Member).filter(
3003+ Member.list_id == self._mlist.list_id,
3004+ Member.role == self.role,
3005+ Address.email==email,
3006+ Member.user_id == User.id)
3007+ return members_a.union(members_u).all()
3008+
3009+ def get_member(self, email):
3010+ """See ``IRoster``."""
3011+ memberships = self._get_all_memberships(email)
3012+ count = len(memberships)
3013+ if count == 0:
3014 return None
3015- elif results.count() == 1:
3016- return results[0]
3017- else:
3018- raise AssertionError(
3019- 'Too many matching member results: {0}'.format(
3020- results.count()))
3021+ elif count == 1:
3022+ return memberships[0]
3023+ assert count == 2, 'Unexpected membership count: {}'.format(count)
3024+ # This is the case where the email address is subscribed both
3025+ # explicitly and indirectly through the preferred address. By
3026+ # definition, we return the explicit address membership only.
3027+ return (memberships[0]
3028+ if memberships[0]._address is not None
3029+ else memberships[1])
3030+
3031+ def get_memberships(self, email):
3032+ """See ``IRoster``."""
3033+ memberships = self._get_all_memberships(email)
3034+ count = len(memberships)
3035+ assert 0 <= count <= 2, 'Unexpected membership count: {}'.format(
3036+ count)
3037+ return memberships
3038
3039
3040
3041
3042@@ -160,13 +187,13 @@
3043 Member.role == MemberRole.moderator))
3044
3045 @dbconnection
3046- def get_member(self, store, address):
3047+ def get_member(self, store, email):
3048 """See `IRoster`."""
3049 results = store.query(Member).filter(
3050 Member.list_id == self._mlist.list_id,
3051 or_(Member.role == MemberRole.moderator,
3052 Member.role == MemberRole.owner),
3053- Address.email == address,
3054+ Address.email == email,
3055 Member.address_id == Address.id)
3056 if results.count() == 0:
3057 return None
3058@@ -181,6 +208,8 @@
3059 class DeliveryMemberRoster(AbstractRoster):
3060 """Return all the members having a particular kind of delivery."""
3061
3062+ role = MemberRole.member
3063+
3064 @property
3065 def member_count(self):
3066 """See `IRoster`."""
3067@@ -285,7 +314,7 @@
3068 yield address
3069
3070 @dbconnection
3071- def get_member(self, store, address):
3072+ def get_member(self, store, email):
3073 """See `IRoster`."""
3074 results = store.query(Member).filter(
3075 Member.address_id == Address.id,
3076@@ -298,3 +327,10 @@
3077 raise AssertionError(
3078 'Too many matching member results: {0}'.format(
3079 results.count()))
3080+
3081+ @dbconnection
3082+ def get_memberships(self, store, address):
3083+ """See `IRoster`."""
3084+ # 2015-04-14 BAW: See LP: #1444055 -- this currently exists just to
3085+ # pass a test.
3086+ raise NotImplementedError
3087
3088=== removed file 'src/mailman/model/tests/test_registrar.py'
3089--- src/mailman/model/tests/test_registrar.py 2015-01-05 01:22:39 +0000
3090+++ src/mailman/model/tests/test_registrar.py 1970-01-01 00:00:00 +0000
3091@@ -1,64 +0,0 @@
3092-# Copyright (C) 2014-2015 by the Free Software Foundation, Inc.
3093-#
3094-# This file is part of GNU Mailman.
3095-#
3096-# GNU Mailman is free software: you can redistribute it and/or modify it under
3097-# the terms of the GNU General Public License as published by the Free
3098-# Software Foundation, either version 3 of the License, or (at your option)
3099-# any later version.
3100-#
3101-# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
3102-# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
3103-# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
3104-# more details.
3105-#
3106-# You should have received a copy of the GNU General Public License along with
3107-# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
3108-
3109-"""Test `IRegistrar`."""
3110-
3111-__all__ = [
3112- 'TestRegistrar',
3113- ]
3114-
3115-
3116-import unittest
3117-
3118-from functools import partial
3119-from mailman.app.lifecycle import create_list
3120-from mailman.interfaces.address import InvalidEmailAddressError
3121-from mailman.interfaces.registrar import IRegistrar
3122-from mailman.testing.layers import ConfigLayer
3123-from zope.component import getUtility
3124-
3125-
3126-
3127
3128-class TestRegistrar(unittest.TestCase):
3129- layer = ConfigLayer
3130-
3131- def setUp(self):
3132- mlist = create_list('test@example.com')
3133- self._register = partial(getUtility(IRegistrar).register, mlist)
3134-
3135- def test_invalid_empty_string(self):
3136- self.assertRaises(InvalidEmailAddressError, self._register, '')
3137-
3138- def test_invalid_space_in_name(self):
3139- self.assertRaises(InvalidEmailAddressError, self._register,
3140- 'some name@example.com')
3141-
3142- def test_invalid_funky_characters(self):
3143- self.assertRaises(InvalidEmailAddressError, self._register,
3144- '<script>@example.com')
3145-
3146- def test_invalid_nonascii(self):
3147- self.assertRaises(InvalidEmailAddressError, self._register,
3148- '\xa0@example.com')
3149-
3150- def test_invalid_no_at_sign(self):
3151- self.assertRaises(InvalidEmailAddressError, self._register,
3152- 'noatsign')
3153-
3154- def test_invalid_no_domain(self):
3155- self.assertRaises(InvalidEmailAddressError, self._register,
3156- 'nodom@ain')
3157
3158=== modified file 'src/mailman/model/tests/test_roster.py'
3159--- src/mailman/model/tests/test_roster.py 2015-01-05 01:22:39 +0000
3160+++ src/mailman/model/tests/test_roster.py 2015-04-15 04:15:28 +0000
3161@@ -26,7 +26,9 @@
3162 import unittest
3163
3164 from mailman.app.lifecycle import create_list
3165+from mailman.interfaces.address import IAddress
3166 from mailman.interfaces.member import DeliveryMode, MemberRole
3167+from mailman.interfaces.user import IUser
3168 from mailman.interfaces.usermanager import IUserManager
3169 from mailman.testing.layers import ConfigLayer
3170 from mailman.utilities.datetime import now
3171@@ -136,7 +138,8 @@
3172 self._ant = create_list('ant@example.com')
3173 self._bee = create_list('bee@example.com')
3174 user_manager = getUtility(IUserManager)
3175- self._anne = user_manager.create_user('anne@example.com')
3176+ self._anne = user_manager.make_user(
3177+ 'anne@example.com', 'Anne Person')
3178 preferred = list(self._anne.addresses)[0]
3179 preferred.verified_on = now()
3180 self._anne.preferred_address = preferred
3181@@ -144,9 +147,56 @@
3182 def test_no_memberships(self):
3183 # An unsubscribed user has no memberships.
3184 self.assertEqual(self._anne.memberships.member_count, 0)
3185+ self.assertIsNone(self._ant.members.get_member('anne@example.com'))
3186+ self.assertEqual(
3187+ self._ant.members.get_memberships('anne@example.com'),
3188+ [])
3189
3190 def test_subscriptions(self):
3191 # Anne subscribes to a couple of mailing lists.
3192 self._ant.subscribe(self._anne)
3193 self._bee.subscribe(self._anne)
3194 self.assertEqual(self._anne.memberships.member_count, 2)
3195+
3196+ def test_subscribed_as_user(self):
3197+ # Anne subscribes to a mailing list as a user and the member roster
3198+ # contains her membership.
3199+ self._ant.subscribe(self._anne)
3200+ self.assertEqual(
3201+ self._ant.members.get_member('anne@example.com').user,
3202+ self._anne)
3203+ memberships = self._ant.members.get_memberships('anne@example.com')
3204+ self.assertEqual(
3205+ [member.address.email for member in memberships],
3206+ ['anne@example.com'])
3207+
3208+ def test_subscribed_as_user_and_address(self):
3209+ # Anne subscribes to a mailing list twice, once as a user and once
3210+ # with an explicit address. She has two memberships.
3211+ self._ant.subscribe(self._anne)
3212+ self._ant.subscribe(self._anne.preferred_address)
3213+ self.assertEqual(self._anne.memberships.member_count, 2)
3214+ self.assertEqual(self._ant.members.member_count, 2)
3215+ self.assertEqual(
3216+ [member.address.email for member in self._ant.members.members],
3217+ ['anne@example.com', 'anne@example.com'])
3218+ # get_member() is defined to return the explicit address.
3219+ member = self._ant.members.get_member('anne@example.com')
3220+ subscriber = member.subscriber
3221+ self.assertTrue(IAddress.providedBy(subscriber))
3222+ self.assertFalse(IUser.providedBy(subscriber))
3223+ # get_memberships() returns them all.
3224+ memberships = self._ant.members.get_memberships('anne@example.com')
3225+ self.assertEqual(len(memberships), 2)
3226+ as_address = (memberships[0]
3227+ if IAddress.providedBy(memberships[0].subscriber)
3228+ else memberships[1])
3229+ as_user = (memberships[1]
3230+ if IUser.providedBy(memberships[1].subscriber)
3231+ else memberships[0])
3232+ self.assertEqual(as_address.subscriber, self._anne.preferred_address)
3233+ self.assertEqual(as_user.subscriber, self._anne)
3234+ # All the email addresses match.
3235+ self.assertEqual(
3236+ [record.address.email for record in memberships],
3237+ ['anne@example.com', 'anne@example.com'])
3238
3239=== modified file 'src/mailman/model/tests/test_usermanager.py'
3240--- src/mailman/model/tests/test_usermanager.py 2015-03-22 01:43:50 +0000
3241+++ src/mailman/model/tests/test_usermanager.py 2015-04-15 04:15:28 +0000
3242@@ -80,3 +80,8 @@
3243 user = self._usermanager.create_user('anne@example.com', 'Anne Person')
3244 other_user = self._usermanager.make_user('anne@example.com')
3245 self.assertIs(user, other_user)
3246+
3247+ def test_get_user_by_id(self):
3248+ original = self._usermanager.make_user('anne@example.com')
3249+ copy = self._usermanager.get_user_by_id(original.user_id)
3250+ self.assertEqual(original, copy)
3251
3252=== added file 'src/mailman/model/tests/test_workflow.py'
3253--- src/mailman/model/tests/test_workflow.py 1970-01-01 00:00:00 +0000
3254+++ src/mailman/model/tests/test_workflow.py 2015-04-15 04:15:28 +0000
3255@@ -0,0 +1,148 @@
3256+# Copyright (C) 2015 by the Free Software Foundation, Inc.
3257+#
3258+# This file is part of GNU Mailman.
3259+#
3260+# GNU Mailman is free software: you can redistribute it and/or modify it under
3261+# the terms of the GNU General Public License as published by the Free
3262+# Software Foundation, either version 3 of the License, or (at your option)
3263+# any later version.
3264+#
3265+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
3266+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
3267+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
3268+# more details.
3269+#
3270+# You should have received a copy of the GNU General Public License along with
3271+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
3272+
3273+"""Test the workflow model."""
3274+
3275+__all__ = [
3276+ 'TestWorkflow',
3277+ ]
3278+
3279+
3280+import unittest
3281+
3282+from mailman.interfaces.workflow import IWorkflowStateManager
3283+from mailman.testing.layers import ConfigLayer
3284+from zope.component import getUtility
3285+
3286+
3287+
3288
3289+class TestWorkflow(unittest.TestCase):
3290+ layer = ConfigLayer
3291+
3292+ def setUp(self):
3293+ self._manager = getUtility(IWorkflowStateManager)
3294+
3295+ def test_save_restore_workflow(self):
3296+ # Save and restore a workflow.
3297+ name = 'ant'
3298+ token = 'bee'
3299+ step = 'cat'
3300+ data = 'dog'
3301+ self._manager.save(name, token, step, data)
3302+ state = self._manager.restore(name, token)
3303+ self.assertEqual(state.name, name)
3304+ self.assertEqual(state.token, token)
3305+ self.assertEqual(state.step, step)
3306+ self.assertEqual(state.data, data)
3307+
3308+ def test_save_restore_workflow_without_step(self):
3309+ # Save and restore a workflow that contains no step.
3310+ name = 'ant'
3311+ token = 'bee'
3312+ data = 'dog'
3313+ self._manager.save(name, token, data=data)
3314+ state = self._manager.restore(name, token)
3315+ self.assertEqual(state.name, name)
3316+ self.assertEqual(state.token, token)
3317+ self.assertIsNone(state.step)
3318+ self.assertEqual(state.data, data)
3319+
3320+ def test_save_restore_workflow_without_data(self):
3321+ # Save and restore a workflow that contains no data.
3322+ name = 'ant'
3323+ token = 'bee'
3324+ step = 'cat'
3325+ self._manager.save(name, token, step)
3326+ state = self._manager.restore(name, token)
3327+ self.assertEqual(state.name, name)
3328+ self.assertEqual(state.token, token)
3329+ self.assertEqual(state.step, step)
3330+ self.assertIsNone(state.data)
3331+
3332+ def test_save_restore_workflow_without_step_or_data(self):
3333+ # Save and restore a workflow that contains no step or data.
3334+ name = 'ant'
3335+ token = 'bee'
3336+ self._manager.save(name, token)
3337+ state = self._manager.restore(name, token)
3338+ self.assertEqual(state.name, name)
3339+ self.assertEqual(state.token, token)
3340+ self.assertIsNone(state.step)
3341+ self.assertIsNone(state.data)
3342+
3343+ def test_restore_workflow_with_no_matching_name(self):
3344+ # Try to restore a workflow that has no matching name in the database.
3345+ name = 'ant'
3346+ token = 'bee'
3347+ self._manager.save(name, token)
3348+ state = self._manager.restore('ewe', token)
3349+ self.assertIsNone(state)
3350+
3351+ def test_restore_workflow_with_no_matching_token(self):
3352+ # Try to restore a workflow that has no matching token in the database.
3353+ name = 'ant'
3354+ token = 'bee'
3355+ self._manager.save(name, token)
3356+ state = self._manager.restore(name, 'fly')
3357+ self.assertIsNone(state)
3358+
3359+ def test_restore_workflow_with_no_matching_token_or_name(self):
3360+ # Try to restore a workflow that has no matching token or name in the
3361+ # database.
3362+ name = 'ant'
3363+ token = 'bee'
3364+ self._manager.save(name, token)
3365+ state = self._manager.restore('ewe', 'fly')
3366+ self.assertIsNone(state)
3367+
3368+ def test_restore_removes_record(self):
3369+ name = 'ant'
3370+ token = 'bee'
3371+ self.assertEqual(self._manager.count, 0)
3372+ self._manager.save(name, token)
3373+ self.assertEqual(self._manager.count, 1)
3374+ self._manager.restore(name, token)
3375+ self.assertEqual(self._manager.count, 0)
3376+
3377+ def test_save_after_restore(self):
3378+ name = 'ant'
3379+ token = 'bee'
3380+ self.assertEqual(self._manager.count, 0)
3381+ self._manager.save(name, token)
3382+ self.assertEqual(self._manager.count, 1)
3383+ self._manager.restore(name, token)
3384+ self.assertEqual(self._manager.count, 0)
3385+ self._manager.save(name, token)
3386+ self.assertEqual(self._manager.count, 1)
3387+
3388+ def test_discard(self):
3389+ # Discard some workflow state. This is use by IRegistrar.discard().
3390+ self._manager.save('ant', 'token', 'one')
3391+ self._manager.save('bee', 'token', 'two')
3392+ self._manager.save('ant', 'nekot', 'three')
3393+ self._manager.save('bee', 'nekot', 'four')
3394+ self.assertEqual(self._manager.count, 4)
3395+ self._manager.discard('bee', 'token')
3396+ self.assertEqual(self._manager.count, 3)
3397+ state = self._manager.restore('ant', 'token')
3398+ self.assertEqual(state.step, 'one')
3399+ state = self._manager.restore('bee', 'token')
3400+ self.assertIsNone(state)
3401+ state = self._manager.restore('ant', 'nekot')
3402+ self.assertEqual(state.step, 'three')
3403+ state = self._manager.restore('bee', 'nekot')
3404+ self.assertEqual(state.step, 'four')
3405
3406=== modified file 'src/mailman/model/usermanager.py'
3407--- src/mailman/model/usermanager.py 2015-03-22 01:32:12 +0000
3408+++ src/mailman/model/usermanager.py 2015-04-15 04:15:28 +0000
3409@@ -64,7 +64,6 @@
3410 user.display_name = (
3411 display_name if display_name else address.display_name)
3412 user.link(address)
3413- return user
3414 return user
3415
3416 @dbconnection
3417
3418=== added file 'src/mailman/model/workflow.py'
3419--- src/mailman/model/workflow.py 1970-01-01 00:00:00 +0000
3420+++ src/mailman/model/workflow.py 2015-04-15 04:15:28 +0000
3421@@ -0,0 +1,76 @@
3422+# Copyright (C) 2015 by the Free Software Foundation, Inc.
3423+#
3424+# This file is part of GNU Mailman.
3425+#
3426+# GNU Mailman is free software: you can redistribute it and/or modify it under
3427+# the terms of the GNU General Public License as published by the Free
3428+# Software Foundation, either version 3 of the License, or (at your option)
3429+# any later version.
3430+#
3431+# GNU Mailman is distributed in the hope that it will be useful, but WITHOUT
3432+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
3433+# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
3434+# more details.
3435+#
3436+# You should have received a copy of the GNU General Public License along with
3437+# GNU Mailman. If not, see <http://www.gnu.org/licenses/>.
3438+
3439+"""Model for workflow states."""
3440+
3441+__all__ = [
3442+ 'WorkflowState',
3443+ 'WorkflowStateManager',
3444+ ]
3445+
3446+
3447+from mailman.database.model import Model
3448+from mailman.database.transaction import dbconnection
3449+from mailman.interfaces.workflow import IWorkflowState, IWorkflowStateManager
3450+from sqlalchemy import Column, Unicode
3451+from zope.interface import implementer
3452+
3453+
3454+
3455
3456+@implementer(IWorkflowState)
3457+class WorkflowState(Model):
3458+ """Workflow states."""
3459+
3460+ __tablename__ = 'workflowstate'
3461+
3462+ name = Column(Unicode, primary_key=True)
3463+ token = Column(Unicode, primary_key=True)
3464+ step = Column(Unicode)
3465+ data = Column(Unicode)
3466+
3467+
3468+
3469
3470+@implementer(IWorkflowStateManager)
3471+class WorkflowStateManager:
3472+ """See `IWorkflowStateManager`."""
3473+
3474+ @dbconnection
3475+ def save(self, store, name, token, step=None, data=None):
3476+ """See `IWorkflowStateManager`."""
3477+ state = WorkflowState(name=name, token=token, step=step, data=data)
3478+ store.add(state)
3479+
3480+ @dbconnection
3481+ def restore(self, store, name, token):
3482+ """See `IWorkflowStateManager`."""
3483+ state = store.query(WorkflowState).get((name, token))
3484+ if state is not None:
3485+ store.delete(state)
3486+ return state
3487+
3488+ @dbconnection
3489+ def discard(self, store, name, token):
3490+ """See `IWorkflowStateManager`."""
3491+ state = store.query(WorkflowState).get((name, token))
3492+ if state is not None:
3493+ store.delete(state)
3494+
3495+ @property
3496+ @dbconnection
3497+ def count(self, store):
3498+ """See `IWorkflowStateManager`."""
3499+ return store.query(WorkflowState).count()
3500
3501=== modified file 'src/mailman/rest/docs/listconf.rst'
3502--- src/mailman/rest/docs/listconf.rst 2015-01-05 01:20:33 +0000
3503+++ src/mailman/rest/docs/listconf.rst 2015-04-15 04:15:28 +0000
3504@@ -61,6 +61,7 @@
3505 scheme: http
3506 send_welcome_message: True
3507 subject_prefix: [Ant]
3508+ subscription_policy: confirm
3509 volume: 1
3510 web_host: lists.example.com
3511 welcome_message_uri: mailman:///welcome.txt
3512@@ -106,6 +107,7 @@
3513 ... reply_to_address='bee@example.com',
3514 ... send_welcome_message=False,
3515 ... subject_prefix='[ant]',
3516+ ... subscription_policy='moderate',
3517 ... welcome_message_uri='mailman:///welcome.txt',
3518 ... default_member_action='hold',
3519 ... default_nonmember_action='discard',
3520@@ -156,6 +158,7 @@
3521 ...
3522 send_welcome_message: False
3523 subject_prefix: [ant]
3524+ subscription_policy: moderate
3525 ...
3526 welcome_message_uri: mailman:///welcome.txt
3527
3528
3529=== modified file 'src/mailman/rest/listconf.py'
3530--- src/mailman/rest/listconf.py 2015-04-07 01:39:34 +0000
3531+++ src/mailman/rest/listconf.py 2015-04-15 04:15:28 +0000
3532@@ -29,7 +29,8 @@
3533 from mailman.interfaces.action import Action
3534 from mailman.interfaces.archiver import ArchivePolicy
3535 from mailman.interfaces.autorespond import ResponseAction
3536-from mailman.interfaces.mailinglist import IAcceptableAliasSet, ReplyToMunging
3537+from mailman.interfaces.mailinglist import (
3538+ IAcceptableAliasSet, ReplyToMunging, SubscriptionPolicy)
3539 from mailman.rest.helpers import (
3540 GetterSetter, bad_request, etag, no_content, okay)
3541 from mailman.rest.validator import (
3542@@ -135,6 +136,7 @@
3543 scheme=GetterSetter(None),
3544 send_welcome_message=GetterSetter(as_boolean),
3545 subject_prefix=GetterSetter(str),
3546+ subscription_policy=GetterSetter(enum_validator(SubscriptionPolicy)),
3547 volume=GetterSetter(None),
3548 web_host=GetterSetter(None),
3549 welcome_message_uri=GetterSetter(str),
3550
3551=== modified file 'src/mailman/rest/tests/test_listconf.py'
3552--- src/mailman/rest/tests/test_listconf.py 2015-01-05 01:22:39 +0000
3553+++ src/mailman/rest/tests/test_listconf.py 2015-04-15 04:15:28 +0000
3554@@ -79,6 +79,7 @@
3555 reply_to_address='bee@example.com',
3556 send_welcome_message=False,
3557 subject_prefix='[ant]',
3558+ subscription_policy='confirm_then_moderate',
3559 welcome_message_uri='mailman:///welcome.txt',
3560 default_member_action='hold',
3561 default_nonmember_action='discard',
3562
3563=== modified file 'src/mailman/runners/docs/command.rst'
3564--- src/mailman/runners/docs/command.rst 2015-01-03 05:06:17 +0000
3565+++ src/mailman/runners/docs/command.rst 2015-04-15 04:15:28 +0000
3566@@ -141,15 +141,14 @@
3567 2
3568
3569 >>> from mailman.interfaces.registrar import IRegistrar
3570- >>> from zope.component import getUtility
3571- >>> registrar = getUtility(IRegistrar)
3572+ >>> registrar = IRegistrar(mlist)
3573 >>> for item in messages:
3574 ... subject = item.msg['subject']
3575 ... print('Subject:', subject)
3576 ... if 'confirm' in str(subject):
3577 ... token = str(subject).split()[1].strip()
3578- ... status = registrar.confirm(token)
3579- ... assert status, 'Confirmation failed'
3580+ ... new_token = registrar.confirm(token)
3581+ ... assert new_token is None, 'Confirmation failed'
3582 Subject: The results of your email commands
3583 Subject: confirm ...
3584
3585
3586=== modified file 'src/mailman/runners/tests/test_confirm.py'
3587--- src/mailman/runners/tests/test_confirm.py 2015-01-05 01:22:39 +0000
3588+++ src/mailman/runners/tests/test_confirm.py 2015-04-15 04:15:28 +0000
3589@@ -46,14 +46,14 @@
3590 layer = ConfigLayer
3591
3592 def setUp(self):
3593- registrar = getUtility(IRegistrar)
3594 self._commandq = config.switchboards['command']
3595 self._runner = make_testable_runner(CommandRunner, 'command')
3596 with transaction():
3597 # Register a subscription requiring confirmation.
3598 self._mlist = create_list('test@example.com')
3599 self._mlist.send_welcome_message = False
3600- self._token = registrar.register(self._mlist, 'anne@example.org')
3601+ anne = getUtility(IUserManager).create_address('anne@example.org')
3602+ self._token = IRegistrar(self._mlist).register(anne)
3603
3604 def test_confirm_with_re_prefix(self):
3605 subject = 'Re: confirm {0}'.format(self._token)
3606
3607=== modified file 'src/mailman/runners/tests/test_join.py'
3608--- src/mailman/runners/tests/test_join.py 2015-01-05 01:22:39 +0000
3609+++ src/mailman/runners/tests/test_join.py 2015-04-15 04:15:28 +0000
3610@@ -160,8 +160,8 @@
3611 subject_words = str(messages[1].msg['subject']).split()
3612 self.assertEqual(subject_words[0], 'confirm')
3613 token = subject_words[1]
3614- status = getUtility(IRegistrar).confirm(token)
3615- self.assertTrue(status, 'Confirmation failed')
3616+ status = IRegistrar(self._mlist).confirm(token)
3617+ self.assertIsNone(status, 'Confirmation failed')
3618 # Now, make sure that Anne is a member of the list and is receiving
3619 # digest deliveries.
3620 members = getUtility(ISubscriptionService).find_members(
3621@@ -197,6 +197,8 @@
3622 self.assertEqual(anne.address.email, 'anne@example.org')
3623 self.assertEqual(anne.delivery_mode, DeliveryMode.regular)
3624
3625+ # LP: #1444184 - digest=mime is not currently supported.
3626+ @unittest.expectedFailure
3627 def test_join_with_mime_digests(self):
3628 # Test the digest=mime argument to the join command.
3629 msg = mfs("""\
3630@@ -211,6 +213,8 @@
3631 self.assertEqual(anne.address.email, 'anne@example.org')
3632 self.assertEqual(anne.delivery_mode, DeliveryMode.mime_digests)
3633
3634+ # LP: #1444184 - digest=mime is not currently supported.
3635+ @unittest.expectedFailure
3636 def test_join_with_plain_digests(self):
3637 # Test the digest=mime argument to the join command.
3638 msg = mfs("""\
3639
3640=== modified file 'src/mailman/styles/base.py'
3641--- src/mailman/styles/base.py 2015-01-05 01:22:39 +0000
3642+++ src/mailman/styles/base.py 2015-04-15 04:15:28 +0000
3643@@ -41,7 +41,8 @@
3644 from mailman.interfaces.autorespond import ResponseAction
3645 from mailman.interfaces.bounce import UnrecognizedBounceDisposition
3646 from mailman.interfaces.digests import DigestFrequency
3647-from mailman.interfaces.mailinglist import Personalization, ReplyToMunging
3648+from mailman.interfaces.mailinglist import (
3649+ Personalization, ReplyToMunging, SubscriptionPolicy)
3650 from mailman.interfaces.nntp import NewsgroupModeration
3651
3652
3653@@ -75,6 +76,7 @@
3654 mlist.personalize = Personalization.none
3655 mlist.default_member_action = Action.defer
3656 mlist.default_nonmember_action = Action.hold
3657+ mlist.subscription_policy = SubscriptionPolicy.confirm
3658 # Notify the administrator of pending requests and membership changes.
3659 mlist.admin_immed_notify = True
3660 mlist.admin_notify_mchanges = False
3661
3662=== modified file 'src/mailman/templates/en/confirm.txt'
3663--- src/mailman/templates/en/confirm.txt 2012-03-04 08:08:52 +0000
3664+++ src/mailman/templates/en/confirm.txt 2015-04-15 04:15:28 +0000
3665@@ -8,9 +8,7 @@
3666
3667 Before you can start using GNU Mailman at this site, you must first confirm
3668 that this is your email address. You can do this by replying to this message,
3669-keeping the Subject header intact. Or you can visit this web page
3670-
3671- $confirm_url
3672+keeping the Subject header intact.
3673
3674 If you do not wish to register this email address simply disregard this
3675 message. If you think you are being maliciously subscribed to the list, or
3676
3677=== modified file 'src/mailman/templates/en/subauth.txt'
3678--- src/mailman/templates/en/subauth.txt 2008-09-29 06:44:19 +0000
3679+++ src/mailman/templates/en/subauth.txt 2015-04-15 04:15:28 +0000
3680@@ -3,9 +3,3 @@
3681
3682 For: $username
3683 List: $listname
3684-
3685-At your convenience, visit:
3686-
3687- $admindb_url
3688-
3689-to process the request.
3690
3691=== modified file 'src/mailman/utilities/importer.py'
3692--- src/mailman/utilities/importer.py 2015-03-26 02:32:56 +0000
3693+++ src/mailman/utilities/importer.py 2015-04-15 04:15:28 +0000
3694@@ -41,6 +41,7 @@
3695 from mailman.interfaces.languages import ILanguageManager
3696 from mailman.interfaces.mailinglist import IAcceptableAliasSet
3697 from mailman.interfaces.mailinglist import Personalization, ReplyToMunging
3698+from mailman.interfaces.mailinglist import SubscriptionPolicy
3699 from mailman.interfaces.member import DeliveryMode, DeliveryStatus, MemberRole
3700 from mailman.interfaces.nntp import NewsgroupModeration
3701 from mailman.interfaces.usermanager import IUserManager
3702@@ -178,6 +179,7 @@
3703 personalize=Personalization,
3704 preferred_language=check_language_code,
3705 reply_goes_to_list=ReplyToMunging,
3706+ subscription_policy=SubscriptionPolicy,
3707 topics_enabled=bool,
3708 )
3709
3710@@ -202,6 +204,7 @@
3711 real_name='display_name',
3712 send_goodbye_msg='send_goodbye_message',
3713 send_welcome_msg='send_welcome_message',
3714+ subscribe_policy='subscription_policy',
3715 )
3716
3717 # These DateTime fields of the mailinglist table need a type conversion to
3718
3719=== modified file 'src/mailman/utilities/tests/test_import.py'
3720--- src/mailman/utilities/tests/test_import.py 2015-03-26 02:32:56 +0000
3721+++ src/mailman/utilities/tests/test_import.py 2015-04-15 04:15:28 +0000
3722@@ -44,7 +44,8 @@
3723 from mailman.interfaces.bans import IBanManager
3724 from mailman.interfaces.bounce import UnrecognizedBounceDisposition
3725 from mailman.interfaces.languages import ILanguageManager
3726-from mailman.interfaces.mailinglist import IAcceptableAliasSet
3727+from mailman.interfaces.mailinglist import (
3728+ IAcceptableAliasSet, SubscriptionPolicy)
3729 from mailman.interfaces.member import DeliveryMode, DeliveryStatus
3730 from mailman.interfaces.nntp import NewsgroupModeration
3731 from mailman.interfaces.templates import ITemplateLoader
3732@@ -301,6 +302,30 @@
3733 self._import()
3734 self.assertEqual(self._mlist.encode_ascii_prefixes, True)
3735
3736+ def test_subscription_policy_open(self):
3737+ self._mlist.subscription_policy = SubscriptionPolicy.confirm
3738+ self._pckdict['subscribe_policy'] = 0
3739+ self._import()
3740+ self.assertEqual(self._mlist.subscription_policy, SubscriptionPolicy.open)
3741+
3742+ def test_subscription_policy_confirm(self):
3743+ self._mlist.subscription_policy = SubscriptionPolicy.open
3744+ self._pckdict['subscribe_policy'] = 1
3745+ self._import()
3746+ self.assertEqual(self._mlist.subscription_policy, SubscriptionPolicy.confirm)
3747+
3748+ def test_subscription_policy_moderate(self):
3749+ self._mlist.subscription_policy = SubscriptionPolicy.open
3750+ self._pckdict['subscribe_policy'] = 2
3751+ self._import()
3752+ self.assertEqual(self._mlist.subscription_policy, SubscriptionPolicy.moderate)
3753+
3754+ def test_subscription_policy_confirm_then_moderate(self):
3755+ self._mlist.subscription_policy = SubscriptionPolicy.open
3756+ self._pckdict['subscribe_policy'] = 3
3757+ self._import()
3758+ self.assertEqual(self._mlist.subscription_policy, SubscriptionPolicy.confirm_then_moderate)
3759+
3760
3761
3762
3763 class TestArchiveImport(unittest.TestCase):