From 0c58317302c0eeea57dcbafa28b5150b82eba3de Mon Sep 17 00:00:00 2001 From: Magnus Hagander Date: Thu, 22 Jan 2015 15:04:09 +0100 Subject: [PATCH] Implement support for secondary email addresses 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. --- pgcommitfest/commitfest/templates/base.html | 2 +- pgcommitfest/commitfest/views.py | 11 +- pgcommitfest/mailqueue/util.py | 8 ++ pgcommitfest/settings.py | 4 + pgcommitfest/urls.py | 5 + pgcommitfest/userprofile/__init__.py | 0 pgcommitfest/userprofile/forms.py | 41 +++++++ pgcommitfest/userprofile/models.py | 25 ++++ .../templates/extra_email_mail.txt | 7 ++ .../templates/userprofileform.html | 113 ++++++++++++++++++ pgcommitfest/userprofile/util.py | 31 +++++ pgcommitfest/userprofile/views.py | 98 +++++++++++++++ 12 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 pgcommitfest/userprofile/__init__.py create mode 100644 pgcommitfest/userprofile/forms.py create mode 100644 pgcommitfest/userprofile/models.py create mode 100644 pgcommitfest/userprofile/templates/extra_email_mail.txt create mode 100644 pgcommitfest/userprofile/templates/userprofileform.html create mode 100644 pgcommitfest/userprofile/util.py create mode 100644 pgcommitfest/userprofile/views.py diff --git a/pgcommitfest/commitfest/templates/base.html b/pgcommitfest/commitfest/templates/base.html index e60f3a9..a22b1ee 100644 --- a/pgcommitfest/commitfest/templates/base.html +++ b/pgcommitfest/commitfest/templates/base.html @@ -20,7 +20,7 @@
  • {{title}}
  • {%if user.is_authenticated%} - Logged in as {{user}} (log out{%if user.is_staff%} or access administration{%endif%}) + Logged in as {{user}} (edit profile | log out{%if user.is_staff%} | administration{%endif%}) {%else%} Log in {%endif%} diff --git a/pgcommitfest/commitfest/views.py b/pgcommitfest/commitfest/views.py index f9ec991..a57000a 100644 --- a/pgcommitfest/commitfest/views.py +++ b/pgcommitfest/commitfest/views.py @@ -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': 'Note! This form will generate an email to the public mailinglist pgsql-hackers, with sender set to %s!' % (request.user.email), + 'note': 'Note! This form will generate an email to the public mailinglist pgsql-hackers, 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) diff --git a/pgcommitfest/mailqueue/util.py b/pgcommitfest/mailqueue/util.py index 60afd5d..38e1145 100644 --- a/pgcommitfest/mailqueue/util.py +++ b/pgcommitfest/mailqueue/util.py @@ -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') diff --git a/pgcommitfest/settings.py b/pgcommitfest/settings.py index 5ef0bd4..f33c71b 100644 --- a/pgcommitfest/settings.py +++ b/pgcommitfest/settings.py @@ -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 * diff --git a/pgcommitfest/urls.py b/pgcommitfest/urls.py index a715c71..46e0332 100644 --- a/pgcommitfest/urls.py +++ b/pgcommitfest/urls.py @@ -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 index 0000000..e69de29 diff --git a/pgcommitfest/userprofile/forms.py b/pgcommitfest/userprofile/forms.py new file mode 100644 index 0000000..b7db7ad --- /dev/null +++ b/pgcommitfest/userprofile/forms.py @@ -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 index 0000000..c5a982c --- /dev/null +++ b/pgcommitfest/userprofile/models.py @@ -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 index 0000000..34c93cc --- /dev/null +++ b/pgcommitfest/userprofile/templates/extra_email_mail.txt @@ -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 index 0000000..4764fad --- /dev/null +++ b/pgcommitfest/userprofile/templates/userprofileform.html @@ -0,0 +1,113 @@ +{%extends "base.html"%} +{%load commitfest%} + +{%block contents%} + +
    {%csrf_token%} +{%if form.errors%} +
    Please correct the errors below, and re-submit the form.
    +{%endif%} +{%if form.non_field_errors%} +
    {{form.non_field_errors}}
    +{%endif%} + {%for field in form%} + {%if not field.is_hidden%} +
    + {{field|label_class:"control-label col-lg-1"}} +
    + {%if field.errors %} + {%for e in field.errors%} +
    {{e}}
    + {%endfor%} + {%endif%} +{{field|field_class:"form-control"}} +{%if field.help_text%}
    {{field.help_text|safe}}{%endif%}
    +
    + {%else%} +{{field}} + {%endif%} +{%endfor%} +
    +
    +
    +
    +
    +
    + +

    Extra email addresses

    +

    +The following extra email addresses are registered for your account: +

    + + +

    Add email

    +
    {%csrf_token%} +{%if mailform.errors%} +
    Please correct the errors below, and re-submit the form.
    +{%endif%} +{%if mailform.non_field_errors%} +
    {{mailform.non_field_errors}}
    +{%endif%} + {%for field in mailform%} +
    + {{field|label_class:"control-label col-lg-1"}} +
    + {%if field.errors %} + {%for e in field.errors%} +
    {{e}}
    + {%endfor%} + {%endif%} +{{field|field_class:"form-control"}} +{%if field.help_text%}
    {{field.help_text|safe}}{%endif%}
    +
    +{%endfor%} + +
    +
    +
    +
    +
    +
    +{%endblock%} diff --git a/pgcommitfest/userprofile/util.py b/pgcommitfest/userprofile/util.py new file mode 100644 index 0000000..af50caf --- /dev/null +++ b/pgcommitfest/userprofile/util.py @@ -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 index 0000000..0d19a4f --- /dev/null +++ b/pgcommitfest/userprofile/views.py @@ -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("../../") -- 2.30.2