Implement support for secondary email addresses
authorMagnus Hagander <magnus@hagander.net>
Thu, 22 Jan 2015 14:04:09 +0000 (15:04 +0100)
committerMagnus Hagander <magnus@hagander.net>
Thu, 22 Jan 2015 14:04:09 +0000 (15:04 +0100)
Each user can add a secondary email (well, more than one) and then pick
one of those when sending email.

Addresses are validated by sending a token to the newly added address,
with a link to click to confirm it. Only a fully confirmed address can
actually be used.

12 files changed:
pgcommitfest/commitfest/templates/base.html
pgcommitfest/commitfest/views.py
pgcommitfest/mailqueue/util.py
pgcommitfest/settings.py
pgcommitfest/urls.py
pgcommitfest/userprofile/__init__.py [new file with mode: 0644]
pgcommitfest/userprofile/forms.py [new file with mode: 0644]
pgcommitfest/userprofile/models.py [new file with mode: 0644]
pgcommitfest/userprofile/templates/extra_email_mail.txt [new file with mode: 0644]
pgcommitfest/userprofile/templates/userprofileform.html [new file with mode: 0644]
pgcommitfest/userprofile/util.py [new file with mode: 0644]
pgcommitfest/userprofile/views.py [new file with mode: 0644]

index e60f3a9074e1455588805a364e0debbff2442690..a22b1eeec14bb5a24450d51fe0776530e0e4cf03 100644 (file)
@@ -20,7 +20,7 @@
  <li class="active">{{title}}</li>
  <li class="pull-right active">
 {%if user.is_authenticated%}
- Logged in as {{user}} (<a href="/account/logout/">log out</a>{%if user.is_staff%} or access <a href="/admin/">administration</a>{%endif%})
+ Logged in as {{user}} (<a href="/account/profile/">edit profile</a> | <a href="/account/logout/">log out</a>{%if user.is_staff%} | <a href="/admin/">administration</a>{%endif%})
 {%else%}
  <a href="/account/login/?next={{request.path}}">Log in</a>
 {%endif%}
index f9ec991bc7c1319fe24f544ee7f5caf1d4a3c187..a57000ae34b6d1d822e9aae985b50667e6034835 100644 (file)
@@ -14,6 +14,7 @@ from email.mime.text import MIMEText
 from email.utils import formatdate, make_msgid
 
 from mailqueue.util import send_mail, send_simple_mail
+from userprofile.util import UserWrapper
 
 from models import CommitFest, Patch, PatchOnCommitFest, PatchHistory, Committer
 from forms import PatchForm, NewPatchForm, CommentForm, CommitFestFilterForm
@@ -333,7 +334,7 @@ def comment(request, cfid, patchid, what):
                                msg['Subject'] = 'Re: %s' % form.thread.subject
 
                        msg['To'] = settings.HACKERS_EMAIL
-                       msg['From'] = "%s %s <%s>" % (request.user.first_name, request.user.last_name, request.user.email)
+                       msg['From'] = "%s %s <%s>" % (request.user.first_name, request.user.last_name, UserWrapper(request.user).email)
                        msg['Date'] = formatdate(localtime=True)
                        msg['User-Agent'] = 'pgcommitfest'
                        msg['X-cfsender'] = request.user.username
@@ -344,7 +345,7 @@ def comment(request, cfid, patchid, what):
                        msg['References'] = '<%s> <%s>' % (form.thread.messageid, form.respid)
                        msg['Message-ID'] = make_msgid('pgcf')
 
-                       send_mail(request.user.email, settings.HACKERS_EMAIL, msg)
+                       send_mail(UserWrapper(request.user).email, settings.HACKERS_EMAIL, msg)
 
                        PatchHistory(patch=patch, by=request.user, what='Posted %s with messageid %s' % (what, msg['Message-ID'])).save()
 
@@ -366,7 +367,7 @@ def comment(request, cfid, patchid, what):
                'breadcrumbs': [{'title': cf.title, 'href': '/%s/' % cf.pk},
                                                {'title': 'View patch', 'href': '/%s/%s/' % (cf.pk, patch.pk)}],
                'title': "Add %s" % what,
-               'note': '<b>Note!</b> This form will generate an email to the public mailinglist <i>pgsql-hackers</i>, with sender set to %s!' % (request.user.email),
+               'note': '<b>Note!</b> This form will generate an email to the public mailinglist <i>pgsql-hackers</i>, with sender set to %s!' % (UserWrapper(request.user).email),
                'savebutton': 'Send %s' % what,
        }, context_instance=RequestContext(request))
 
@@ -522,7 +523,7 @@ def send_email(request, cfid):
                        recipients = User.objects.filter(q).distinct()
 
                        for r in recipients:
-                               send_simple_mail(request.user.email, r.email, form.cleaned_data['subject'], form.cleaned_data['body'], request.user.username)
+                               send_simple_mail(UserWrapper(request.user).email, r.email, form.cleaned_data['subject'], form.cleaned_data['body'], request.user.username)
                                messages.add_message(request, messages.INFO, "Sent email to %s" % r.email)
                        return HttpResponseRedirect('..')
        else:
@@ -543,7 +544,7 @@ def send_email(request, cfid):
                messages.add_message(request, messages.WARNING, "No recipients specified, cannot send email")
                return HttpResponseRedirect('..')
 
-       messages.add_message(request, messages.INFO, "Email will be sent from: %s" % request.user.email)
+       messages.add_message(request, messages.INFO, "Email will be sent from: %s" % UserWrapper(request.user).email)
        def _user_and_mail(u):
                return "%s %s (%s)" % (u.first_name, u.last_name, u.email)
 
index 60afd5dc4f694eb363cfdf9486740d29dbb2c1c0..38e114538ad115bfdafb77bfb59c0c6e7828a4a9 100644 (file)
@@ -1,3 +1,6 @@
+from django.template import Context
+from django.template.loader import get_template
+
 from email.mime.text import MIMEText
 from email.mime.multipart import MIMEMultipart
 from email.mime.nonmultipart import MIMENonMultipart
@@ -35,3 +38,8 @@ def send_simple_mail(sender, receiver, subject, msgtxt, sending_username, attach
 def send_mail(sender, receiver, fullmsg):
        # Send an email, prepared as the full MIME encoded mail already
        QueuedMail(sender=sender, receiver=receiver, fullmsg=fullmsg).save()
+
+def send_template_mail(sender, receiver, subject, templatename, templateattr={}, usergenerated=False):
+       send_simple_mail(sender, receiver, subject,
+                                        get_template(templatename).render(Context(templateattr)),
+                                        '__internal')
index 5ef0bd4cf1d919e3dbd16a851d44272fe44617e3..f33c71bcaab51fbe5e7729ef20ff90d6135e3052 100644 (file)
@@ -128,6 +128,7 @@ INSTALLED_APPS = (
        'selectable',
        'commitfest',
        'mailqueue',
+       'userprofile',
 )
 
 # A sample logging configuration. The only tangible logging
@@ -166,6 +167,9 @@ ARCHIVES_HOST="archives.postgresql.org"    # Host: header to send
 # Email address to pgsql-hackers. Set to something local to test maybe?
 HACKERS_EMAIL="pgsql-hackers-testing@localhost"
 
+# Email address for outgoing system messages
+NOTIFICATION_FROM="webmaster@postgresql.org"
+
 # Load local settings overrides
 try:
        from local_settings import *
index a715c7166d83f52641ae0ea3a28394d0ec29f6b4..46e03327ea168603f0e48ab7d1ec3891a96a382a 100644 (file)
@@ -31,6 +31,11 @@ urlpatterns = patterns('',
     (r'^(?:account/)?logout/?$', 'auth.logout'),
     (r'^auth_receive/$', 'auth.auth_receive'),
 
+    # Account management
+    (r'^account/profile/$', 'userprofile.views.userprofile'),
+    (r'^account/profile/delmail/$', 'userprofile.views.deletemail'),
+    (r'^account/profile/confirm/([0-9a-f]+)/$', 'userprofile.views.confirmemail'),
+
     # Examples:
     # url(r'^$', 'pgcommitfest.views.home', name='home'),
     # url(r'^pgcommitfest/', include('pgcommitfest.foo.urls')),
diff --git a/pgcommitfest/userprofile/__init__.py b/pgcommitfest/userprofile/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/pgcommitfest/userprofile/forms.py b/pgcommitfest/userprofile/forms.py
new file mode 100644 (file)
index 0000000..b7db7ad
--- /dev/null
@@ -0,0 +1,41 @@
+from django import forms
+from django.contrib.auth.models import User
+
+from models import UserProfile, UserExtraEmail
+
+class UserProfileForm(forms.ModelForm):
+       class Meta:
+               model = UserProfile
+               exclude = ('user', )
+
+       def __init__(self, user, *args, **kwargs):
+               super(UserProfileForm, self).__init__(*args, **kwargs)
+               self.user = user
+
+               self.fields['selectedemail'].empty_label=self.user.email
+               self.fields['selectedemail'].queryset=UserExtraEmail.objects.filter(user=self.user, confirmed=True)
+
+class MailForm(forms.Form):
+       email = forms.EmailField()
+       email2 = forms.EmailField(label="Repeat email")
+
+       def clean_email(self):
+               email = self.cleaned_data['email']
+
+               if User.objects.filter(email=email).exists():
+                       raise forms.ValidationError("This email is already in use by another account")
+
+               return email
+
+       def clean_email2(self):
+               # If the primary email checker had an exception, the data will be gone
+               # from the cleaned_data structure
+               if not self.cleaned_data.has_key('email'):
+                       return self.cleaned_data['email2']
+               email1 = self.cleaned_data['email']
+               email2 = self.cleaned_data['email2']
+
+               if email1 != email2:
+                       raise forms.ValidationError("Email addresses don't match")
+
+               return email2
diff --git a/pgcommitfest/userprofile/models.py b/pgcommitfest/userprofile/models.py
new file mode 100644 (file)
index 0000000..c5a982c
--- /dev/null
@@ -0,0 +1,25 @@
+from django.db import models
+from django.contrib.auth.models import User
+
+class UserExtraEmail(models.Model):
+       user = models.ForeignKey(User, null=False, blank=False, db_index=True)
+       email = models.EmailField(max_length=100, null=False, blank=False, unique=True)
+       confirmed = models.BooleanField(null=False, blank=False, default=False)
+       token = models.CharField(max_length=100, null=False, blank=True)
+       tokensent = models.DateTimeField(null=False, blank=False)
+
+       def __unicode__(self):
+               return self.email
+
+       class Meta:
+               ordering = ('user', 'email')
+               unique_together = (('user', 'email'),)
+
+
+class UserProfile(models.Model):
+       user = models.ForeignKey(User, null=False, blank=False)
+       selectedemail = models.ForeignKey(UserExtraEmail, null=True, blank=True,
+                                                                         verbose_name='Sender email')
+
+       def __unicode__(self):
+               return unicode(self.user)
diff --git a/pgcommitfest/userprofile/templates/extra_email_mail.txt b/pgcommitfest/userprofile/templates/extra_email_mail.txt
new file mode 100644 (file)
index 0000000..34c93cc
--- /dev/null
@@ -0,0 +1,7 @@
+Somebody, probably you, has registered this email address as a secondary
+address for the account {{user.username}} on commitfest.postgresql.org.
+
+To confirm this addition, please click on the following link:
+
+https://commitfest.postgresql.org/account/profile/confirm/{{token}}/
+
diff --git a/pgcommitfest/userprofile/templates/userprofileform.html b/pgcommitfest/userprofile/templates/userprofileform.html
new file mode 100644 (file)
index 0000000..4764fad
--- /dev/null
@@ -0,0 +1,113 @@
+{%extends "base.html"%}
+{%load commitfest%}
+
+{%block contents%}
+<style>
+.form-horizontal div.form-group {
+   margin-bottom: 10px;
+}
+div.form-group div.controls ul {
+   list-style-type: none;
+   margin: 0px;
+   padding: 0px;
+}
+div.form-group div.controls ul li {
+   display: inline;
+}
+div.form-group div.controls ul li label {
+   display: inline;
+   font-weight: normal;
+   vertical-align:middle;
+}
+div.form-group div.controls ul li label input {
+   display: inline;
+   vertical-align:middle;
+}
+div.form-group div.controls input[type='checkbox'] {
+   width: 10px;
+}
+
+div.controls ul.selectable-deck li.selectable-deck-item {
+   display: block;
+}
+
+div.controls ul.selectable-deck li.selectable-deck-item a.selectable-deck-remove {
+   float: none;
+   margin-left: 10px;
+}
+
+div.form-group div.controls input.threadpick-input {
+   width: 80%;
+   display: inline;
+}
+</style>
+<form class="form-horizontal {{extraformclass}}" method="POST" action=".">{%csrf_token%}
+{%if form.errors%}
+ <div class="alert">Please correct the errors below, and re-submit the form.</div>
+{%endif%}
+{%if form.non_field_errors%}
+ <div class="alert alert-danger">{{form.non_field_errors}}</div>
+{%endif%}
+ {%for field in form%}
+ {%if not field.is_hidden%}
+ <div class="form-group">
+   {{field|label_class:"control-label col-lg-1"}}
+   <div class="col-lg-11 controls">
+  {%if field.errors %}
+   {%for e in field.errors%}
+ <div class="alert alert-danger">{{e}}</div>
+   {%endfor%}
+  {%endif%}
+{{field|field_class:"form-control"}}
+{%if field.help_text%}<br/>{{field.help_text|safe}}{%endif%}</div>
+ </div>
+ {%else%}
+{{field}}
+ {%endif%}
+{%endfor%}
+ <div class="form-group">
+  <div class="col-lg-12">
+   <div class="control"><input type="submit" class="btn btn-default" name="submit" value="Save"></div>
+  </div>
+ </div>
+</form>
+
+<h2>Extra email addresses</h2>
+<p>
+The following extra email addresses are registered for your account:
+</p>
+<ul>
+{%for e in extramails%}
+ <li>{{e.email}}{%if not e.confirmed%} (<i>Pending confirmation</i>){%endif%} <a href="delmail/?{{e.id}}">delete</a></li>
+{%endfor%}
+</ul>
+
+<h3>Add email</h3>
+<form class="form-horizontal" method="post" action=".">{%csrf_token%}
+{%if mailform.errors%}
+ <div class="alert">Please correct the errors below, and re-submit the form.</div>
+{%endif%}
+{%if mailform.non_field_errors%}
+ <div class="alert alert-danger">{{mailform.non_field_errors}}</div>
+{%endif%}
+ {%for field in mailform%}
+ <div class="form-group">
+   {{field|label_class:"control-label col-lg-1"}}
+   <div class="col-lg-11 controls">
+  {%if field.errors %}
+   {%for e in field.errors%}
+ <div class="alert alert-danger">{{e}}</div>
+   {%endfor%}
+  {%endif%}
+{{field|field_class:"form-control"}}
+{%if field.help_text%}<br/>{{field.help_text|safe}}{%endif%}</div>
+ </div>
+{%endfor%}
+
+ <div class="form-group">
+  <div class="col-lg-12">
+   <div class="control"><input type="submit" class="btn btn-default" name="submit" value="Add email"></div>
+  </div>
+ </div>
+</form>
+{%endblock%}
diff --git a/pgcommitfest/userprofile/util.py b/pgcommitfest/userprofile/util.py
new file mode 100644 (file)
index 0000000..af50caf
--- /dev/null
@@ -0,0 +1,31 @@
+from Crypto.Hash import SHA256
+from Crypto import Random
+
+from models import UserProfile
+
+def generate_random_token():
+       """
+       Generate a random token of 64 characters. This token will be
+       generated using a strong random number, and then hex encoded to make
+       sure all characters are safe to put in emails and URLs.
+       """
+       s = SHA256.new()
+       r = Random.new()
+       s.update(r.read(250))
+       return s.hexdigest()
+
+
+class UserWrapper(object):
+       def __init__(self, user):
+               self.user = user
+
+       @property
+       def email(self):
+               try:
+                       up = UserProfile.objects.get(user=self.user)
+                       if up.selectedemail and up.selectedemail.confirmed:
+                               return up.selectedemail.email
+                       else:
+                               return self.user.email
+               except UserProfile.DoesNotExist:
+                       return self.user.email
diff --git a/pgcommitfest/userprofile/views.py b/pgcommitfest/userprofile/views.py
new file mode 100644 (file)
index 0000000..0d19a4f
--- /dev/null
@@ -0,0 +1,98 @@
+from django.shortcuts import render_to_response
+from django.http import HttpResponseRedirect
+from django.template import RequestContext
+from django.db import transaction
+from django.contrib import messages
+from django.contrib.auth.decorators import login_required
+from django.conf import settings
+
+from datetime import datetime
+
+from mailqueue.util import send_template_mail
+
+from models import UserProfile, UserExtraEmail
+from forms import UserProfileForm, MailForm
+from util import generate_random_token
+
+@login_required
+@transaction.commit_on_success
+def userprofile(request):
+       (profile, created) = UserProfile.objects.get_or_create(user=request.user)
+       form = mailform = None
+
+       if request.method == 'POST':
+               if request.POST['submit'] == 'Save':
+                       form = UserProfileForm(request.user, request.POST, instance=profile)
+                       if form.is_valid():
+                               form.save()
+                               messages.add_message(request, messages.INFO, "User profile saved.")
+                               return HttpResponseRedirect('.')
+               elif request.POST['submit'] == 'Add email':
+                       mailform = MailForm(request.POST)
+                       if mailform.is_valid():
+                               m = UserExtraEmail(user=request.user,
+                                                                  email=mailform.cleaned_data['email'],
+                                                                  confirmed=False,
+                                                                  token=generate_random_token(),
+                                                                  tokensent=datetime.now())
+                               m.save()
+                               send_template_mail(settings.NOTIFICATION_FROM,
+                                                                  m.email,
+                                                                  'Your email address for commitfest.postgresql.org',
+                                                                  'extra_email_mail.txt',
+                                                                  {'token': m.token, 'user': m.user})
+                               messages.info(request, "A confirmation token has been sent to %s" % m.email)
+                               return HttpResponseRedirect('.')
+               else:
+                       messages.error(request, "Invalid submit button pressed! Nothing saved.")
+                       return HttpResponseRedirect('.')
+
+       if not form:
+               form = UserProfileForm(request.user, instance=profile)
+       if not mailform:
+               mailform = MailForm()
+
+       extramails = UserExtraEmail.objects.filter(user=request.user)
+
+       return render_to_response('userprofileform.html', {
+               'form': form,
+               'extramails': extramails,
+               'mailform': mailform,
+               }, context_instance=RequestContext(request))
+
+@login_required
+@transaction.commit_on_success
+def deletemail(request):
+       try:
+               id = int(request.META['QUERY_STRING'])
+       except ValueError:
+               messages.error(request, "Invalid format of id in query string")
+               return HttpResponseRedirect('../')
+
+       try:
+               e = UserExtraEmail.objects.get(user=request.user, id=id)
+       except UserExtraEmail.DoesNotExist:
+               messages.error(request, "Specified email address does not exist on this user")
+               return HttpResponseRedirect('../')
+
+       messages.info(request, "Email address %s deleted." % e.email)
+       e.delete()
+       return HttpResponseRedirect('../')
+
+@login_required
+@transaction.commit_on_success
+def confirmemail(request, tokenhash):
+       try:
+               e = UserExtraEmail.objects.get(user=request.user, token=tokenhash)
+               if e.confirmed:
+                       messages.warning(request, "This email address has already been confirmed.")
+               else:
+                       # Ok, it's not confirmed. So let's do that now
+                       e.confirmed = True
+                       e.token = ''
+                       e.save()
+                       messages.info(request, "Email address %s added to profile." % e.email)
+       except UserExtraEmail.DoesNotExist:
+               messages.error(request, "Token %s was not found for your user. It may be because it has already been used?" % tokenhash)
+
+       return HttpResponseRedirect("../../")