diff --git a/src/postorius/forms.py b/src/postorius/forms.py
index 20c897d..bd17c87 100644
--- a/src/postorius/forms.py
+++ b/src/postorius/forms.py
@@ -902,3 +902,16 @@
class ListDeleteForm(forms.Form):
list_name = forms.EmailField(widget=forms.HiddenInput())
+
+
+class AddressActivationForm(forms.Form):
+ email = forms.EmailField()
+ user_email = forms.EmailField(widget=forms.HiddenInput)
+
+ def clean(self):
+ cleaned_data = super(AddressActivationForm, self).clean()
+ email = cleaned_data.get('email')
+ user_email = cleaned_data.get('user_email')
+ if email == user_email:
+ raise forms.ValidationError(_('Please provide a different email address than your own.'))
+ return cleaned_data
diff --git a/src/postorius/models.py b/src/postorius/models.py
index 06a055a..2280ec6 100644
--- a/src/postorius/models.py
+++ b/src/postorius/models.py
@@ -15,15 +15,24 @@
#
# You should have received a copy of the GNU General Public License along with
# Postorius. If not, see .
+from __future__ import (
+ absolute_import, division, print_function, unicode_literals)
+
+
+import random
+import hashlib
import logging
+from datetime import datetime, timedelta
from django.conf import settings
from django.contrib.auth.models import User
-from django.db.models.signals import pre_delete, pre_save
+from django.core.urlresolvers import reverse
+from django.core.mail import send_mail
from django.db import models
-from django.dispatch import receiver
from django.http import Http404
-from mailmanclient import Client, MailmanConnectionError
+from django.template import Context
+from django.template.loader import get_template
+from mailmanclient import MailmanConnectionError
from postorius.utils import get_client
from urllib2 import HTTPError
@@ -86,7 +95,6 @@
def create(self, **kwargs):
try:
method = getattr(get_client(), 'create_' + self.resource_name)
- print kwargs
return method(**kwargs)
except AttributeError, e:
raise MailmanApiError(e)
@@ -173,3 +181,115 @@
"""Member model class.
"""
objects = MailmanRestManager('member', 'members')
+
+
+class AddressConfirmationProfileManager(models.Manager):
+ """
+ Manager class for AddressConfirmationProfile.
+ """
+
+ def create_profile(self, email, user):
+ # Create or update a profile
+ # Guarantee an email bytestr type that can be fed to hashlib.
+ email_str = email
+ if isinstance(email_str, unicode):
+ email_str = email_str.encode('utf-8')
+ activation_key = hashlib.sha1(
+ str(random.random())+email_str).hexdigest()
+ # Make now tz naive (we don't care about the timezone)
+ now = datetime.now().replace(tzinfo=None)
+ # Either update an existing profile record for the given email address
+ try:
+ profile = self.get(email=email)
+ profile.activation_key = activation_key
+ profile.created = now
+ profile.save()
+ # ... or create a new one.
+ except AddressConfirmationProfile.DoesNotExist:
+ profile = self.create(email=email,
+ activation_key=activation_key,
+ user=user,
+ created=now)
+ return profile
+
+
+class AddressConfirmationProfile(models.Model):
+ """
+ Profile model for temporarily storing an activation key to register
+ an email address.
+ """
+ email = models.EmailField()
+ activation_key = models.CharField(max_length=40)
+ created = models.DateTimeField()
+ user = models.ForeignKey(User)
+
+ objects = AddressConfirmationProfileManager()
+
+ def __unicode__(self):
+ return u'Address Confirmation Profile for {0}'.format(self.email)
+
+ @property
+ def is_expired(self):
+ """
+ a profile expires after 1 day by default.
+ This can be configured in the settings.
+
+ >>> EMAIL_CONFIRMATION_EXPIRATION_DELTA = timedelta(days=2)
+
+ """
+ expiration_delta = getattr(
+ settings, 'EMAIL_CONFIRMATION_EXPIRATION_DELTA', timedelta(days=1))
+ age = datetime.now().replace(tzinfo=None) - \
+ self.created.replace(tzinfo=None)
+ return age > expiration_delta
+
+ def _create_host_url(self, request):
+ # Create the host url
+ protocol = 'https'
+ if not request.is_secure():
+ protocol = 'http'
+ server_name = request.META['SERVER_NAME']
+ if server_name[-1] == '/':
+ server_name = server_name[:len(server_name) - 1]
+ return '{0}://{1}'.format(protocol, server_name)
+
+ def send_confirmation_link(self, request, template_context=None,
+ template_path=None):
+ """
+ Send out a message containing a link to activate the given address.
+
+ The following settings are recognized:
+
+ >>> EMAIL_CONFIRMATION_TEMPLATE = 'postorius/address_confirmation_message.txt'
+ >>> EMAIL_CONFIRMATION_FROM = 'Confirmation needed'
+ >>> EMAIL_CONFIRMATION_SUBJECT = 'postmaster@list.org'
+
+ :param request: The HTTP request object.
+ :type request: HTTPRequest
+ :param template_context: The context used when rendering the template.
+ Falls back to host url and activation link.
+ :type template_context: django.template.Context
+ """
+ # create the host url and the activation link need for the template
+ host_url = self._create_host_url(request)
+ # Get the url string from url conf.
+ url = reverse('address_activation_link',
+ kwargs={'activation_key': self.activation_key})
+ activation_link = '{0}{1}'.format(host_url, url)
+ # Detect the right template path, either from the param,
+ # the setting or the default
+ if not template_path:
+ template_path = getattr(settings,
+ 'EMAIL_CONFIRMATION_TEMPLATE',
+ 'postorius/address_confirmation_message.txt')
+ # Create a template context (if there is none) containing
+ # the activation_link and the host_url.
+ if not template_context:
+ template_context = Context(
+ {'activation_link': activation_link, 'host_url': host_url})
+ email_subject = getattr(
+ settings, 'EMAIL_CONFIRMATION_SUBJECT', u'Confirmation needed')
+ send_mail(email_subject,
+ get_template(template_path).render(template_context),
+ getattr(settings, 'EMAIL_CONFIRMATION_FROM'),
+ [self.email])
diff --git a/src/postorius/templates/postorius/address_confirmation_message.txt b/src/postorius/templates/postorius/address_confirmation_message.txt
new file mode 100644
index 0000000..8166a13
--- /dev/null
+++ b/src/postorius/templates/postorius/address_confirmation_message.txt
@@ -0,0 +1,6 @@
+Please click the link below to add your email address to your mailing list
+profile at {{ host_url }}:
+
+{{ activation_link }}
+
+Thanks!
diff --git a/src/postorius/templates/postorius/address_notification_message.txt b/src/postorius/templates/postorius/address_notification_message.txt
new file mode 100644
index 0000000..73bae33
--- /dev/null
+++ b/src/postorius/templates/postorius/address_notification_message.txt
@@ -0,0 +1,7 @@
+Someone has requested to add {{email}} to their mailing list profile at {{ host_url }}.
+
+If you are that person, please click the link below to confirm.
+
+{{ activation_link }}
+
+Thanks!
diff --git a/src/postorius/templates/postorius/menu/user_nav.html b/src/postorius/templates/postorius/menu/user_nav.html
index cccc25a..3c552ca 100644
--- a/src/postorius/templates/postorius/menu/user_nav.html
+++ b/src/postorius/templates/postorius/menu/user_nav.html
@@ -8,5 +8,6 @@
{% trans "Profile" %}
{% trans "Subscription Settings" %}
{% trans "Subscriptions" %}
+ {% trans "Add Email Address" %}
diff --git a/src/postorius/templates/postorius/user_address_activation.html b/src/postorius/templates/postorius/user_address_activation.html
new file mode 100644
index 0000000..aee976c
--- /dev/null
+++ b/src/postorius/templates/postorius/user_address_activation.html
@@ -0,0 +1,15 @@
+{% extends postorius_base_template %}
+{% load url from future %}
+{% load i18n %}
+
+{% block main %}
+ {% include 'postorius/menu/user_nav.html' %}
+
+ {% trans 'Add another email address to your account' %}
+
+
+{% endblock main %}
diff --git a/src/postorius/templates/postorius/user_address_activation_link.html b/src/postorius/templates/postorius/user_address_activation_link.html
new file mode 100644
index 0000000..b493058
--- /dev/null
+++ b/src/postorius/templates/postorius/user_address_activation_link.html
@@ -0,0 +1,6 @@
+{% extends postorius_base_template %}
+{% load url from future %}
+{% load i18n %}
+
+{% block main %}
+{% endblock main %}
diff --git a/src/postorius/templates/postorius/user_address_activation_sent.html b/src/postorius/templates/postorius/user_address_activation_sent.html
new file mode 100644
index 0000000..58c6d09
--- /dev/null
+++ b/src/postorius/templates/postorius/user_address_activation_sent.html
@@ -0,0 +1,10 @@
+{% extends postorius_base_template %}
+{% load url from future %}
+{% load i18n %}
+{% block main %}
+{% include 'postorius/menu/user_nav.html' %}
+
+ {% trans 'Email address activation sent' %}
+ A confirmation link has been sent to the email address you submitted. Please check your email account an click on the confirmation link to add this address for your account.
+{% endblock main %}
+
diff --git a/src/postorius/tests/test_address_activation.py b/src/postorius/tests/test_address_activation.py
new file mode 100644
index 0000000..02fef05
--- /dev/null
+++ b/src/postorius/tests/test_address_activation.py
@@ -0,0 +1,216 @@
+from __future__ import (absolute_import, division, print_function,
+ unicode_literals)
+
+
+from datetime import datetime, timedelta
+from django.contrib.auth.models import User
+from django.core.urlresolvers import reverse
+from django.core import mail
+from django.test.client import Client, RequestFactory
+from django.test.utils import override_settings
+from django.utils import unittest
+from mailmanclient._client import _Connection
+from mock import patch, call
+
+from postorius.forms import AddressActivationForm
+from postorius.models import AddressConfirmationProfile
+from postorius import views
+from postorius.views.user import AddressActivationView, address_activation_link
+
+
+class TestAddressActivationForm(unittest.TestCase):
+ """
+ Test the activation form.
+ """
+
+ def test_valid_email_is_valid(self):
+ data = {
+ 'email': 'les@example.org',
+ 'user_email': 'me@example.org',
+ }
+ form = AddressActivationForm(data)
+ self.assertTrue(form.is_valid())
+
+ def test_identical_emails_are_invalid(self):
+ data = {
+ 'email': 'les@example.org',
+ 'user_email': 'les@example.org',
+ }
+ form = AddressActivationForm(data)
+ self.assertFalse(form.is_valid())
+
+ def test_invalid_email_is_not_valid(self):
+ data = {
+ 'email': 'les@example',
+ 'user_email': 'me@example.org',
+ }
+ form = AddressActivationForm(data)
+ self.assertFalse(form.is_valid())
+
+
+class TestAddressActivationView(unittest.TestCase):
+ """
+ Tests to make sure the view is properly connected, renders the form
+ correctly and starts the actual address activation process if a valid
+ form is submitted.
+ """
+
+ def setUp(self):
+ # We create a new user and log that user in.
+ # We don't use Client().login because it triggers the browserid dance.
+ self.user = User.objects.create_user(
+ username='les', email='les@example.org', password='secret')
+ self.client = Client()
+ self.client.post(reverse('user_login'),
+ {'username': 'les', 'password': 'secret'})
+
+ def tearDown(self):
+ # Log out and delete user.
+ self.client.logout()
+ self.user.delete()
+
+ def test_view_is_connected(self):
+ # The view should be connected in the url configuration.
+ response = self.client.get(reverse('address_activation'))
+ self.assertEqual(response.status_code, 200)
+
+ def test_view_contains_form(self):
+ # The view context should contain a form.
+ response = self.client.get(reverse('address_activation'))
+ self.assertTrue('form' in response.context)
+
+ def test_view_renders_correct_template(self):
+ # The view should render the user_address_activation template.
+ response = self.client.get(reverse('address_activation'))
+ self.assertTrue('postorius/user_address_activation.html'
+ in [t.name for t in response.templates])
+
+ def test_post_invalid_form_shows_error_msg(self):
+ # Entering an invalid email address should render an error message.
+ response = self.client.post(reverse('address_activation'), {
+ 'email': 'invalid_email',
+ 'user_email': self.user.email})
+ self.assertTrue('Enter a valid email address.' in response.content)
+
+ @patch.object(AddressConfirmationProfile, 'send_confirmation_link')
+ def test_post_valid_form_renders_success_template(
+ self, mock_send_confirmation_link):
+ # Entering a valid email should render the activation_sent template.
+ response = self.client.post(reverse('address_activation'), {
+ 'email': 'new_address@example.org',
+ 'user_email': self.user.email})
+ self.assertEqual(mock_send_confirmation_link.call_count, 1)
+ self.assertTrue('postorius/user_address_activation_sent.html'
+ in [t.name for t in response.templates])
+
+
+class TestAddressConfirmationProfile(unittest.TestCase):
+ """
+ Test the confirmation of an email address activation (validating token,
+ expiration, Mailman API calls etc.).
+ """
+
+ def setUp(self):
+ # Create a user and profile.
+ self.user = User.objects.create_user(
+ username=u'ler_mm', email=u'ler@mailman.mostdesirable.org',
+ password=u'pwd')
+ self.profile = AddressConfirmationProfile.objects.create_profile(
+ u'les@example.org', self.user)
+ # Create a test request object
+ self.request = RequestFactory().get('/')
+
+ def tearDown(self):
+ self.profile.delete()
+ self.user.delete()
+
+ def test_profile_creation(self):
+ # Profile is created and has all necessary properties.
+ self.assertEqual(self.profile.email, u'les@example.org')
+ self.assertEqual(len(self.profile.activation_key), 40)
+ self.assertTrue(type(self.profile.created), datetime)
+
+ def test_no_duplicate_profiles(self):
+ # Creating a new profile returns an existing record
+ # (if one exists), instead of creating a new one.
+ new_profile = AddressConfirmationProfile.objects.create_profile(
+ u'les@example.org',
+ User.objects.create(email=u'ler@mailman.mostdesirable.org'))
+ self.assertEqual(self.profile, new_profile)
+
+ def test_unicode_representation(self):
+ # Correct unicode representation?
+ self.assertEqual(unicode(self.profile),
+ 'Address Confirmation Profile for les@example.org')
+
+ def test_profile_not_expired_default_setting(self):
+ # A profile created less then a day ago is not expired by default.
+ delta = timedelta(hours=23)
+ now = datetime.now()
+ self.profile.created = now - delta
+ self.assertFalse(self.profile.is_expired)
+
+ def test_profile_is_expired_default_setting(self):
+ # A profile older than 1 day is expired by default.
+ delta = timedelta(days=1, hours=1)
+ now = datetime.now()
+ self.profile.created = now - delta
+ self.assertTrue(self.profile.is_expired)
+
+ @override_settings(
+ EMAIL_CONFIRMATION_EXPIRATION_DELTA=timedelta(hours=5))
+ def test_profile_not_expired(self):
+ # A profile older than the timedelta set in the settings is
+ # expired.
+ delta = timedelta(hours=6)
+ now = datetime.now()
+ self.profile.created = now - delta
+ self.assertTrue(self.profile.is_expired)
+
+ @override_settings(
+ EMAIL_BACKEND='django.core.mail.backends.locmem.EmailBackend',
+ EMAIL_CONFIRMATION_FROM='mailman@mostdesirable.org')
+ def test_confirmation_link(self):
+ # The profile obj can send out a confirmation email.
+ # set the activation key to a fixed string for testing
+ self.profile.activation_key = \
+ '6323fba0097781fdb887cfc37a1122ee7c8bb0b0'
+ self.profile.send_confirmation_link(self.request)
+ self.assertEqual(mail.outbox[0].to[0], u'les@example.org')
+ self.assertEqual(mail.outbox[0].subject, u'Confirmation needed')
+ self.assertTrue(self.profile.activation_key in mail.outbox[0].body)
+
+
+class TestAddressActivationLinkSuccess(unittest.TestCase):
+ """
+ This tests the activation link view if the key is valid and the profile is
+ not expired.
+ """
+
+ def setUp(self):
+ # Set up a profile with a predictable key
+ self.user = User.objects.create_user(
+ username='ler', email=u'ler@mailman.mostdesirable.org',
+ password='pwd')
+ self.profile = AddressConfirmationProfile.objects.create_profile(
+ u'les@mailman.mostdesirable.org', self.user)
+ self.profile.activation_key = \
+ u'6323fba0097781fdb887cfc37a1122ee7c8bb0b0'
+ self.profile.save()
+
+ def tearDown(self):
+ self.profile.delete()
+ self.user.delete()
+
+ @patch.object(views.user, '_add_address')
+ def test_mailman(self, _add_address_mock):
+ # An activation key pointing to a valid profile adds the address
+ # to the user.
+ request = RequestFactory().get(reverse(
+ 'address_activation_link', kwargs={
+ 'activation_key': '6323fba0097781fdb887cfc37a1122ee7c8bb0b0'}))
+ address_activation_link(
+ request, '6323fba0097781fdb887cfc37a1122ee7c8bb0b0')
+ expected_calls = [call(request, u'ler@mailman.mostdesirable.org',
+ u'les@mailman.mostdesirable.org')]
+ self.assertEqual(_add_address_mock.mock_calls, expected_calls)
diff --git a/src/postorius/urls.py b/src/postorius/urls.py
index df63a87..df2be43 100644
--- a/src/postorius/urls.py
+++ b/src/postorius/urls.py
@@ -108,4 +108,9 @@
url(r'^more_info/(?P[^/]+)/(?P[^/]+)$',
'more_info_tab', name='more_info_tab'),
url(r'^lists/(?P[^/]+)/', include(per_list_urlpatterns)),
+ url(r'^users/address_activation/$',
+ AddressActivationView.as_view(),
+ name='address_activation'),
+ url(r'^users/address_activation/(?P[A-Za-z0-9]{40})/$',
+ 'address_activation_link', name='address_activation_link'),
)
diff --git a/src/postorius/views/user.py b/src/postorius/views/user.py
index f9430f4..fac5df2 100644
--- a/src/postorius/views/user.py
+++ b/src/postorius/views/user.py
@@ -17,36 +17,31 @@
# Postorius. If not, see .
-import re
-import sys
-import json
import logging
-from django.conf import settings
from django.forms.formsets import formset_factory
from django.contrib import messages
from django.contrib.auth import logout, authenticate, login
from django.contrib.auth.decorators import (login_required,
- permission_required,
user_passes_test)
-from django.contrib.auth.forms import (AuthenticationForm, PasswordResetForm,
- SetPasswordForm, PasswordChangeForm)
+from django.contrib.auth.forms import AuthenticationForm
from django.contrib.auth.models import User
-from django.core.urlresolvers import reverse
-from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render_to_response, redirect
-from django.template import Context, loader, RequestContext
+from django.template import RequestContext
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
+from django.views.generic import TemplateView
from urllib2 import HTTPError
from postorius import utils
-from postorius.models import (Domain, List, Member, MailmanUser,
- MailmanApiError, Mailman404Error)
+from postorius.models import (
+ MailmanUser, MailmanConnectionError, MailmanApiError, Mailman404Error,
+ AddressConfirmationProfile)
from postorius.forms import *
from postorius.auth.decorators import *
-from postorius.views.generic import MailingListView, MailmanUserView
+from postorius.views.generic import MailmanUserView
+from smtplib import SMTPException
class UserMailmanSettingsView(MailmanUserView):
@@ -231,6 +226,37 @@
context_instance=RequestContext(request))
+class AddressActivationView(TemplateView):
+ """
+ Starts the process of adding additional email addresses to a mailman user
+ record. Forms are processes and email notifications are sent accordingly.
+ """
+
+ @method_decorator(login_required)
+ def get(self, request):
+ form = AddressActivationForm(initial={'user_email': request.user.email})
+ return render_to_response('postorius/user_address_activation.html',
+ {'form': form},
+ context_instance=RequestContext(request))
+
+ @method_decorator(login_required)
+ def post(self, request):
+ form = AddressActivationForm(request.POST)
+ if form.is_valid():
+ profile = AddressConfirmationProfile.objects.create_profile(
+ email=form.cleaned_data['email'], user=request.user)
+ try:
+ profile.send_confirmation_link(request)
+ except SMTPException:
+ messages.error(request, 'The email confirmation message could '
+ 'not be sent. %s' % profile.activation_key)
+ return render_to_response('postorius/user_address_activation_sent.html',
+ context_instance=RequestContext(request))
+ return render_to_response('postorius/user_address_activation.html',
+ {'form': form},
+ context_instance=RequestContext(request))
+
+
@user_passes_test(lambda u: u.is_superuser)
def user_index(request, page=1, template='postorius/users/index.html'):
"""Show a table of all users.
@@ -365,3 +391,29 @@
return render_to_response(template,
{'user_id': user_id, 'email_id': email_id},
context_instance=RequestContext(request))
+
+
+def _add_address(request, user_email, address):
+ # Add an address to a user record in mailman.
+ try:
+ mailman_user = utils.get_client().get_user(user_email)
+ mailman_user.add_address(address)
+ except (MailmanApiError, MailmanConnectionError) as e:
+ messages.error(request, 'The address could not be added.')
+
+
+def address_activation_link(request, activation_key):
+ """
+ Checks the given activation_key. If it is valid, the saved address will be
+ added to mailman. Also, the corresponding profile record will be removed.
+ If the key is not valid, it will be ignored.
+ """
+ try:
+ profile = AddressConfirmationProfile.objects.get(
+ activation_key=activation_key)
+ if not profile.is_expired:
+ _add_address(request, profile.user.email, profile.email)
+ except profile.DoesNotExist:
+ pass
+ return render_to_response('postorius/user_address_activation_link.html',
+ {}, context_instance=RequestContext(request))