diff --git a/esp/esp/dbmail/admin.py b/esp/esp/dbmail/admin.py index b8954b825a..c02d4e58d4 100644 --- a/esp/esp/dbmail/admin.py +++ b/esp/esp/dbmail/admin.py @@ -61,8 +61,8 @@ class MessageRequestAdmin(admin.ModelAdmin): admin_site.register(MessageRequest, MessageRequestAdmin) class TextOfEmailAdmin(admin.ModelAdmin): - list_display = ('id', 'send_from', 'send_to', 'subject', 'sent') - search_fields = ('=id', 'send_from', 'send_to', 'subject') + list_display = ('id', 'send_from', 'send_to', 'subject', 'sent', 'user') + search_fields = ('=id', 'send_from', 'send_to', 'subject', 'user') date_hierarchy = 'sent' list_filter = ('send_from',) admin_site.register(TextOfEmail, TextOfEmailAdmin) diff --git a/esp/esp/dbmail/migrations/0006_textofemail_user.py b/esp/esp/dbmail/migrations/0006_textofemail_user.py new file mode 100644 index 0000000000..f2740b3c46 --- /dev/null +++ b/esp/esp/dbmail/migrations/0006_textofemail_user.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2024-02-08 20:52 +from __future__ import unicode_literals + +from django.db import migrations +import django.db.models.deletion +import esp.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0034_auto_20230525_2024'), + ('dbmail', '0005_auto_20220719_2056'), + ] + + operations = [ + migrations.AddField( + model_name='textofemail', + name='user', + field=esp.db.fields.AjaxForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='users.ESPUser'), + ), + ] diff --git a/esp/esp/dbmail/models.py b/esp/esp/dbmail/models.py index 3279fcae88..42d13461ac 100644 --- a/esp/esp/dbmail/models.py +++ b/esp/esp/dbmail/models.py @@ -52,22 +52,37 @@ from django.conf import settings from django.contrib.sites.models import Site +from django.utils.html import strip_tags from django.core.mail import get_connection from django.core.mail.backends.smtp import EmailBackend as SMTPEmailBackend from django.core.mail.message import sanitize_address from django.core.exceptions import ImproperlyConfigured +# `user` is required for marketing and subscribed messages to add unsubscribe headers +# this includes all comm panel emails +# https://support.google.com/a/answer/81126?visit_id=638428689824104778-3542874255&rd=1#subscriptions def send_mail(subject, message, from_email, recipient_list, fail_silently=False, bcc=None, - return_path=settings.DEFAULT_EMAIL_ADDRESSES['bounces'], extra_headers={}, + return_path=settings.DEFAULT_EMAIL_ADDRESSES['bounces'], extra_headers={}, user=None, *args, **kwargs): from_email = from_email.strip() + # the from_email must match one of our DMARC domains/subdomains + # or the email may be rejected by email clients + if not re.match(r"(^.+@%s>?$)|(^.+@(\w+\.)?learningu\.org>?$)" % settings.SITE_INFO[1].replace(".", "\."), from_email): + raise ESPError("Invalid 'From' email address (" + from_email + "). The 'From' email address must " + + "end in @" + settings.SITE_INFO[1] + " (your website), " + + "@learningu.org, or a valid subdomain of learningu.org " + + "(i.e., @subdomain.learningu.org).") + if 'Reply-To' in extra_headers: extra_headers['Reply-To'] = extra_headers['Reply-To'].strip() if isinstance(recipient_list, basestring): new_list = [ recipient_list ] else: new_list = [ x for x in recipient_list ] + if user is not None: + extra_headers['List-Unsubscribe-Post'] = "List-Unsubscribe=One-Click" + extra_headers['List-Unsubscribe'] = '<%s>' % (user.unsubscribe_oneclick()) # remove duplicate email addresses (sendgrid doesn't like them) recipients = [] @@ -83,17 +98,24 @@ def send_mail(subject, message, from_email, recipient_list, fail_silently=False, recipients.append(x) emails.remove(tmp) - from django.core.mail import EmailMessage #send_mail as django_send_mail + from django.core.mail import EmailMessage, EmailMultiAlternatives #send_mail as django_send_mail logger.info("Sent mail to %s", recipients) # Get whatever type of email connection Django provides. # Normally this will be SMTP, but it also has an in-memory backend for testing. connection = get_connection(fail_silently=fail_silently, return_path=return_path) - msg = EmailMessage(subject, message, from_email, recipients, bcc=bcc, connection=connection, headers=extra_headers) # Detect HTML tags in message and change content-type if they are found if '' in message: - msg.content_subtype = 'html' + # Generate a plaintext version of the email + # Remove html tags and continuous whitespaces + text_only = re.sub('[ \t]+', ' ', strip_tags(message)) + # Strip single spaces in the beginning of each line + message_text = text_only.replace('\n ', '\n').strip() + msg = EmailMultiAlternatives(subject, message_text, from_email, recipients, bcc=bcc, connection=connection, headers=extra_headers) + msg.attach_alternative(message, "text/html") + else: + msg = EmailMessage(subject, message, from_email, recipients, bcc=bcc, connection=connection, headers=extra_headers) msg.send() @@ -350,6 +372,7 @@ def process(self): newemailrequest = {'target': user, 'msgreq': self} send_to = ESPUser.email_sendto_address(*address_pair) newtxt = { + 'user': user, 'send_to': send_to, 'send_from': send_from, 'subject': subject, @@ -383,6 +406,7 @@ def process(self): class TextOfEmail(models.Model): """ Contains the processed form of an EmailRequest, ready to be sent. SmartText becomes plain text. """ + user = AjaxForeignKey(ESPUser, blank=True, null=True) # blank=True because there isn't an easy way to backfill this send_to = models.CharField(max_length=1024) # Valid email address, "Name" send_from = models.CharField(max_length=1024) # Valid email address subject = models.TextField() # Email subject; plain text @@ -430,7 +454,8 @@ def send(self): self.send_from, self.send_to, False, - extra_headers=extra_headers) + extra_headers=extra_headers, + user = self.user) except Exception as e: self.tries += 1 self.save() diff --git a/esp/esp/local_settings.py.travis b/esp/esp/local_settings.py.travis index 8c3aae3b82..bd5c376b6a 100644 --- a/esp/esp/local_settings.py.travis +++ b/esp/esp/local_settings.py.travis @@ -48,6 +48,10 @@ SITE_INFO = (1, 'testserver.learningu.org', 'ESP Test Server') CACHE_PREFIX = "Test" SECRET_KEY = 'b36cfdfb78aba27ddbde330b23352bc6d40973b4' +SERVER_EMAIL = 'server@testserver.learningu.org' +EMAIL_HOST = 'testserver.learningu.org' +EMAIL_HOST_SENDER = 'testserver.learningu.org' + ################### # Plugin settings # ################### diff --git a/esp/esp/program/forms.py b/esp/esp/program/forms.py index b8f10d08d1..f4881275a1 100644 --- a/esp/esp/program/forms.py +++ b/esp/esp/program/forms.py @@ -36,6 +36,7 @@ import re import unicodedata +from django.conf import settings from esp.users.models import StudentInfo, K12School, RecordType from esp.program.models import Program, ProgramModule, ClassFlag, ClassFlagType, ClassCategories from esp.dbmail.models import PlainRedirect @@ -150,9 +151,10 @@ class Meta: 'program_modules': forms.SelectMultiple(attrs={'class': 'hidden-field'}), } model = Program -ProgramCreationForm.base_fields['director_email'].widget = forms.TextInput(attrs={'size': 40}) -ProgramCreationForm.base_fields['director_cc_email'].widget = forms.TextInput(attrs={'size': 40}) -ProgramCreationForm.base_fields['director_confidential_email'].widget = forms.TextInput(attrs={'size': 40}) +ProgramCreationForm.base_fields['director_email'].widget = forms.EmailInput(attrs={'size': 40, + 'pattern': r'(^.+@%s$)|(^.+@(\w+\.)+learningu\.org$)' % settings.SITE_INFO[1].replace('.', '\.')}) +ProgramCreationForm.base_fields['director_cc_email'].widget = forms.EmailInput(attrs={'size': 40}) +ProgramCreationForm.base_fields['director_confidential_email'].widget = forms.EmailInput(attrs={'size': 40}) ''' ProgramCreationForm.base_fields['term'].line_group = -4 ProgramCreationForm.base_fields['term_friendly'].line_group = -4 diff --git a/esp/esp/program/migrations/0027_auto_20240220_2124.py b/esp/esp/program/migrations/0027_auto_20240220_2124.py new file mode 100644 index 0000000000..ad627b6874 --- /dev/null +++ b/esp/esp/program/migrations/0027_auto_20240220_2124.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2024-02-20 21:24 +from __future__ import unicode_literals + +from django.conf import settings +from django.core import validators +from django.db import migrations, models +import django.db.models.fields +import re + +def replace_director_emails(apps, schema_editor): + Program = apps.get_model('program', 'Program') + for prog in Program.objects.all(): + if not re.match(r"(^.+@%s$)|(^.+@(\w+\.)*learningu\.org$)" % settings.SITE_INFO[1].replace(".", "\."), prog.director_email): + prog.director_email = 'info@' + settings.SITE_INFO[1] + prog.save() + +def create_info_redirect(apps, schema_editor): + PlainRedirect = apps.get_model('dbmail', 'PlainRedirect') + prs = PlainRedirect.objects.filter(original = "info") + if not prs.exists(): + redirect = PlainRedirect.objects.create(original = "info", destination = settings.DEFAULT_EMAIL_ADDRESSES['default']) + +class Migration(migrations.Migration): + + dependencies = [ + ('program', '0026_auto_20221122_2100'), + ] + + operations = [ + migrations.AlterField( + model_name='program', + name='director_email', + field=models.EmailField(default=b'info@' + settings.SITE_INFO[1], max_length=75, + help_text=b'The director email address must end in @' + settings.SITE_INFO[1] + + ' (your website), @learningu.org, or a valid subdomain of learningu.org (i.e., @subdomain.learningu.org). The default is info@' + settings.SITE_INFO[1] + + ', which redirects to the "default" email address from your site\'s settings by default. You can create and manage your email redirects here.', + validators=[validators.RegexValidator(r'(^.+@%s$)|(^.+@(\w+\.)?learningu\.org$)' % settings.SITE_INFO[1].replace('.', '\.'))]), + ), + # This will run backwards, but won't do anything + migrations.RunPython(replace_director_emails, lambda a, s: None), + migrations.RunPython(create_info_redirect, lambda a, s: None), + ] diff --git a/esp/esp/program/models/__init__.py b/esp/esp/program/models/__init__.py index c30cc1d353..f8935dc995 100644 --- a/esp/esp/program/models/__init__.py +++ b/esp/esp/program/models/__init__.py @@ -249,7 +249,14 @@ class Program(models.Model, CustomFormsLinkModel): name = models.CharField(max_length=80) grade_min = models.IntegerField() grade_max = models.IntegerField() - director_email = models.EmailField(max_length=75) # director contact email address used for from field and display + # director contact email address used for from field and display + director_email = models.EmailField(default='info@' + settings.SITE_INFO[1], max_length=75, + validators=[validators.RegexValidator(r'(^.+@%s$)|(^.+@(\w+\.)?learningu\.org$)' % settings.SITE_INFO[1].replace('.', '\.'))], + help_text=mark_safe('The director email address must end in @' + settings.SITE_INFO[1] + ' (your website), ' + + '@learningu.org, or a valid subdomain of learningu.org (i.e., @subdomain.learningu.org). ' + + 'The default is info@' + settings.SITE_INFO[1] + ', which redirects to the "default" ' + + 'email address from your site\'s settings by default. ' + + 'You can create and manage your email redirects here.')) director_cc_email = models.EmailField(blank=True, default='', max_length=75, help_text=mark_safe('If set, automated outgoing mail (except class cancellations) will be sent to this address instead of the director email. Use this if you do not want to spam the director email with teacher class registration emails. Otherwise, leave this field blank.')) # "carbon-copy" address for most automated outgoing mail to or CC'd to directors (except class cancellations) director_confidential_email = models.EmailField(blank=True, default='', max_length=75, help_text='If set, confidential emails such as financial aid applications will be sent to this address instead of the director email.') program_size_max = models.IntegerField(null=True, help_text='Set to 0 for no cap. Student registration performance is best when no cap is set.') diff --git a/esp/esp/program/modules/forms/admincore.py b/esp/esp/program/modules/forms/admincore.py index 1042cae5c9..300f6aba54 100644 --- a/esp/esp/program/modules/forms/admincore.py +++ b/esp/esp/program/modules/forms/admincore.py @@ -1,5 +1,6 @@ from decimal import Decimal from django import forms +from django.conf import settings from django.contrib import admin from django.template import Template, Context from django.template.loader import select_template @@ -93,6 +94,7 @@ class Meta: 'program_modules': forms.SelectMultiple(attrs={'class': 'hidden-field'}), } model = Program +ProgramSettingsForm.base_fields['director_email'].widget = forms.EmailInput(attrs={'pattern': r'(^.+@%s$)|(^.+@(\w+\.)?learningu\.org$)' % settings.SITE_INFO[1].replace('.', '\.')}) class TeacherRegSettingsForm(BetterModelForm): """ Form for changing teacher class registration settings. """ diff --git a/esp/esp/program/modules/handlers/commmodule.py b/esp/esp/program/modules/handlers/commmodule.py index b7528b4cee..25cb8bd548 100644 --- a/esp/esp/program/modules/handlers/commmodule.py +++ b/esp/esp/program/modules/handlers/commmodule.py @@ -35,7 +35,7 @@ from esp.program.modules.base import ProgramModuleObj, needs_admin, main_call, aux_call from esp.program.modules.handlers.listgenmodule import ListGenModule from esp.utils.web import render_to_response -from esp.dbmail.models import MessageRequest +from esp.dbmail.models import MessageRequest, PlainRedirect from esp.users.models import ESPUser, PersistentQueryFilter from esp.users.controllers.usersearch import UserSearchController from esp.users.views.usersearch import get_user_checklist @@ -45,6 +45,8 @@ from esp.middleware import ESPError from django.template import loader +import re + class CommModule(ProgramModuleObj): doc = """Email users that match specific search criteria.""" """ Want to email all ESP students within a 60 mile radius of NYC? @@ -79,9 +81,17 @@ def commprev(self, request, tl, one, two, module, extra, prog): # Set From address if request.POST.get('from', '').strip(): fromemail = request.POST['from'] + if not re.match(r"(^.+@%s$)|(^.+@(\w+\.)?learningu\.org$)" % settings.SITE_INFO[1].replace(".", "\."), fromemail): + raise ESPError("Invalid 'From' email address. The 'From' email address must " + + "end in @" + settings.SITE_INFO[1] + " (your website), " + + "@learningu.org, or a valid subdomain of learningu.org " + + "(i.e., @subdomain.learningu.org).") else: - # String together an address like username@esp.mit.edu - fromemail = '%s@%s' % (request.user.username, settings.SITE_INFO[1]) + # Use the info redirect (make one for the default email address if it doesn't exist) + prs = PlainRedirect.objects.filter(original = "info") + if not prs.exists(): + redirect = PlainRedirect.objects.create(original = "info", destination = settings.DEFAULT_EMAIL_ADDRESSES['default']) + fromemail = '%s@%s' % ("info", settings.SITE_INFO[1]) # Set Reply-To address if request.POST.get('replyto', '').strip(): @@ -241,6 +251,7 @@ def commpanel_old(self, request, tl, one, two, module, extra, prog): @main_call @needs_admin def commpanel(self, request, tl, one, two, module, extra, prog): + from django.conf import settings usc = UserSearchController() @@ -267,6 +278,11 @@ def commpanel(self, request, tl, one, two, module, extra, prog): context['sendto_fn_name'] = sendto_fn_name context['listcount'] = self.approx_num_of_recipients(filterObj, sendto_fn) context['selected'] = selected + # Use the info redirect (make one for the default email address if it doesn't exist) + prs = PlainRedirect.objects.filter(original = "info") + if not prs.exists(): + redirect = PlainRedirect.objects.create(original = "info", destination = settings.DEFAULT_EMAIL_ADDRESSES['default']) + context['from'] = '%s@%s' % ("info", settings.SITE_INFO[1]) return render_to_response(self.baseDir()+'step2.html', request, context) ## Prepare a message starting from an earlier request diff --git a/esp/esp/program/modules/handlers/teachereventsmodule.py b/esp/esp/program/modules/handlers/teachereventsmodule.py index 42a39b8e39..524c87cb4c 100644 --- a/esp/esp/program/modules/handlers/teachereventsmodule.py +++ b/esp/esp/program/modules/handlers/teachereventsmodule.py @@ -41,6 +41,7 @@ from esp.users.models import ESPUser, UserAvailability from esp.middleware.threadlocalrequest import get_current_request from django.contrib.auth.models import Group +from django.conf import settings class TeacherEventsModule(ProgramModuleObj): doc = """Allows teachers to sign up for one or more teacher events (e.g. interviews, training).""" @@ -132,8 +133,8 @@ def event_signup(self, request, tl, one, two, module, extra, prog): send_mail('['+self.program.niceName()+'] Teacher Interview for ' + request.user.first_name + ' ' + request.user.last_name + ': ' + event_name, \ """Teacher Interview Registration Notification\n--------------------------------- \n\nTeacher: %s %s\n\nTime: %s\n\n""" % \ (request.user.first_name, request.user.last_name, event_name) , \ - (request.user.get_email_sendto_address()), \ - [self.program.getDirectorCCEmail()], True) + '%s Registration System ' % (self.program.program_type, settings.EMAIL_HOST_SENDER), \ + [self.program.getDirectorCCEmail()], True, extra_headers = {'Reply-To': request.user.get_email_sendto_address()}) # Register for training if data['training']: diff --git a/esp/esp/program/modules/tests/commpanel.py b/esp/esp/program/modules/tests/commpanel.py index 920d8fca3d..bfd3e44dc1 100644 --- a/esp/esp/program/modules/tests/commpanel.py +++ b/esp/esp/program/modules/tests/commpanel.py @@ -90,8 +90,8 @@ def runTest(self): post_data = { 'subject': 'Test Subject 123', 'body': 'Test Body 123', - 'from': 'from@email-server', - 'replyto': 'replyto@email-server', + 'from': 'info@testserver.learningu.org', + 'replyto': 'replyto@testserver.learningu.org', 'filterid': filterid, } response = self.client.post('/manage/%s/%s' % (self.program.getUrlBase(), 'commfinal'), post_data) @@ -113,8 +113,8 @@ def runTest(self): msg = mail.outbox[0] self.assertEqual(msg.subject, 'Test Subject 123') self.assertEqual(msg.body, 'Test Body 123') - self.assertEqual(msg.from_email, 'from@email-server') - self.assertEqual(msg.extra_headers.get('Reply-To', ''), 'replyto@email-server') + self.assertEqual(msg.from_email, 'info@testserver.learningu.org') + self.assertEqual(msg.extra_headers.get('Reply-To', ''), 'replyto@testserver.learningu.org') # Check that the MessageRequest was marked as processed m = MessageRequest.objects.filter(recipients__id=filterid, subject='Test Subject 123') diff --git a/esp/esp/program/tests.py b/esp/esp/program/tests.py index 8b1df8b1f5..1b05f0814a 100644 --- a/esp/esp/program/tests.py +++ b/esp/esp/program/tests.py @@ -328,7 +328,7 @@ def makeprogram(self): 'term_friendly': 'Winter 3001', 'grade_min': '7', 'grade_max': '12', - 'director_email': '123456789-223456789-323456789-423456789-523456789-623456789-7234567@mit.edu', + 'director_email': 'info@test.learningu.org', 'program_size_max': '3000', 'program_type': 'Prubbogrubbam!', 'program_modules': [x.id for x in ProgramModule.objects.all()], @@ -604,7 +604,7 @@ def setUp(self, *args, **kwargs): 'term_friendly': settings['program_instance_label'], 'grade_min': '7', 'grade_max': '12', - 'director_email': '123456789-223456789-323456789-423456789-523456789-623456789-7234567@mit.edu', + 'director_email': 'info@test.learningu.org', 'program_size_max': '3000', 'program_type': settings['program_type'], 'program_modules': settings['modules'], @@ -767,7 +767,7 @@ def create_past_program(self): 'term_friendly': 'Spring 1901', 'grade_min': '7', 'grade_max': '12', - 'director_email': '123456789-223456789-323456789-423456789-523456789-623456789-7234568@mit.edu', + 'director_email': 'info@test.learningu.org', 'program_size_max': '3000', 'program_type': 'TestProgramPast', 'program_modules': [x.id for x in ProgramModule.objects.all()], diff --git a/esp/esp/users/models/__init__.py b/esp/esp/users/models/__init__.py index a37f08169d..b2b18355f8 100644 --- a/esp/esp/users/models/__init__.py +++ b/esp/esp/users/models/__init__.py @@ -64,6 +64,8 @@ from django.core import urlresolvers from django.utils.functional import SimpleLazyObject from django.utils.safestring import mark_safe +from django.core.signing import TimestampSigner, BadSignature, SignatureExpired +from django.urls import reverse from esp.cal.models import Event, EventType @@ -386,6 +388,8 @@ def get_msg_vars(self, otheruser, key): (settings.DEFAULT_HOST, otheruser.password) elif key == 'recover_query': return "?code=%s" % otheruser.password + elif key == 'unsubscribe_link': + return otheruser.unsubscribe_link_full() return u'' def getTaughtPrograms(self): @@ -1142,6 +1146,32 @@ def userHash(self, program): user_hash = hash(str(self.id) + str(program.id)) return '{0:06d}'.format(abs(user_hash))[:6] + # modified from here: https://www.grokcode.com/819/one-click-unsubscribes-for-django-apps/ + def unsubscribe_link(self): + username, token = self.make_token().split(":", 1) + return reverse('unsubscribe', kwargs={'username': username, 'token': token,}) + + def unsubscribe_link_full(self): + unsub_link = self.unsubscribe_link() + return 'https://%s%s' % (Site.objects.get_current().domain, unsub_link) + + # this is an insecure version that accepts a POST from external sources + def unsubscribe_oneclick(self): + unsub_link = self.unsubscribe_link() + unsub_link = unsub_link.replace("unsubscribe", "unsubscribe_oneclick") + return 'http://%s%s' % (Site.objects.get_current().domain, unsub_link) + + def make_token(self): + return TimestampSigner().sign(self.username) + + def check_token(self, token): + try: + key = '%s:%s' % (self.username, token) + TimestampSigner().unsign(key, max_age=60 * 60 * 24 * 7) # Valid for 7 days + except (BadSignature, SignatureExpired): + return False + return True + class ESPUser(User, BaseESPUser): """ Create a user of the ESP Website This user extends the auth.User of django""" @@ -2816,7 +2846,7 @@ def send_confirmation_email(self): subject, message = self._confirmation_email_content() send_mail(subject, message, - settings.DEFAULT_FROM_EMAIL, + 'info@' + settings.SITE_INFO[1], [self.requesting_student.email, ]) def get_admin_url(self): diff --git a/esp/esp/users/urls.py b/esp/esp/users/urls.py index fff6586c9b..3d0cf7eb22 100644 --- a/esp/esp/users/urls.py +++ b/esp/esp/users/urls.py @@ -22,12 +22,14 @@ name='esp.users.views.resend_activation_view'), url(r'^signout/?$', views.signout), url(r'^signedout/?$', views.signed_out_message), - url(r'^login/?$', views.login_checked), + url(r'^login/?$', views.login_checked, name="login"), url(r'^disableaccount/?$', views.disable_account), url(r'^grade_change_request/?$', GradeChangeRequestView.as_view(), name = 'grade_change_request'), url(r'^makeadmin/?$', views.make_admin), url(r'^loginhelp', TemplateView.as_view(template_name='users/loginhelp.html'), name='Login Help'), url(r'^morph/?$', views.morph_into_user), + url(r'^unsubscribe/(?P[\w.@+-]+)/(?P[\w.:\-_=]+)/$', views.unsubscribe, name="unsubscribe"), + url(r'^unsubscribe_oneclick/(?P[\w.@+-]+)/(?P[\w.:\-_=]+)/$', views.unsubscribe_oneclick, name="unsubscribe_oneclick"), ] urlpatterns += [ diff --git a/esp/esp/users/views/__init__.py b/esp/esp/users/views/__init__.py index 68d2e09f5a..1a7494b09c 100644 --- a/esp/esp/users/views/__init__.py +++ b/esp/esp/users/views/__init__.py @@ -4,6 +4,8 @@ from django.contrib.auth.views import login from django.http import HttpResponseRedirect, HttpResponse from django.template import RequestContext +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt from esp.program.models import Program, RegistrationProfile from esp.tagdict.models import Tag @@ -162,6 +164,54 @@ def disable_account(request): return render_to_response('users/disable_account.html', request, context) +# modified from here: https://www.grokcode.com/819/one-click-unsubscribes-for-django-apps/ +def unsubscribe(request, username, token, oneclick = False): + """ + User is immediately unsubscribed if they are logged in as username, or + if they came from an unexpired unsubscribe link. Otherwise, they are + redirected to the login page and unsubscribed as soon as they log in. + """ + + # render our own error message if the username doesn't match + users = ESPUser.objects.filter(username=username) + if users.exists(): + users_active = users.filter(is_active=True) + if users_active.exists(): + user = users_active[0] + else: + raise ESPError("User " + users[0].username + " is already unsubscribed.") + else: + raise ESPError("No user matching that unsubscribe request.") + + # if POSTing, they clicked the confirm button + # if oneclick=True, then they came here from an email client + if request.POST.get("List-Unsubscribe") == "One-Click" or oneclick == True: + # "unsubscribe" them (deactivate their account) + user.is_active = False + user.save() + return render_to_response('users/unsubscribe.html', request, context = {'user': user, 'deactivated': True}) + + # otherwise show them a confirmation button + # if they are logged into the correct account or the token is valid + if ( (request.user.is_authenticated() and request.user == user) or user.check_token(token)): + return render_to_response('users/unsubscribe.html', request, context = {'user': user}) + # if they are logged into a different account + # tell them to log out and try again + elif request.user.is_authenticated() and request.user != user: + raise ESPError("You are logged into a different account than the one you are trying to unsubscribe. Please log out and try your request again.") + # otherwise they will need to log in (or find a more recent link) + # so show the login page (with a custom alert message) + else: + next_url = reverse('unsubscribe', kwargs={'username': username, 'token': token,}) + return HttpResponseRedirect('%s?next=%s' % (reverse('login'), next_url)) + +# have an email client (etc) POST to this view to process a +# "oneclick" unsubscribe +@csrf_exempt +def unsubscribe_oneclick(request, username, token): + if request.POST.get("List-Unsubscribe") == "One-Click": + return unsubscribe(request, username, token, oneclick = True) + raise ESPError("Invalid oneclick data.") @admin_required def morph_into_user(request): diff --git a/esp/esp/web/templatetags/main.py b/esp/esp/web/templatetags/main.py index 0fd161f3df..2fd85a6ba0 100644 --- a/esp/esp/web/templatetags/main.py +++ b/esp/esp/web/templatetags/main.py @@ -62,6 +62,10 @@ def bool_and(obj1,obj2): def get_field(object, field): return getattr(object, field) +@register.filter +def regexsite(str): + return str.replace(".", "\.") + @register.filter def extract_theme(url): # Get the appropriate color scheme out of the Tag that controls nav structure diff --git a/esp/templates/program/modules/commmodule/step2.html b/esp/templates/program/modules/commmodule/step2.html index 1fd1d38a83..40c197092b 100644 --- a/esp/templates/program/modules/commmodule/step2.html +++ b/esp/templates/program/modules/commmodule/step2.html @@ -22,7 +22,7 @@ {% endblock %} {% block content %} - +{% load main %}

Communications Portal


@@ -38,6 +38,14 @@

Step 2:


Tip: The default {{ settings.ORGANIZATION_SHORT_NAME }} template (which you can modify here) is automatically wrapped around your message below.

+
+ +Note, the "From" email address must end in @{{ settings.SITE_INFO.1 }} (your website), @learningu.org, or a valid subdomain of learningu.org (i.e., @subdomain.learningu.org). +By default the "From" email address is info@{{ current_site.domain }}, which redirects to the "default" email address from your site's settings. +You can create and manage your email redirects here. +The "Reply-To" field can be any email address (by default it is the same as the "From" email address). +
+
@@ -45,19 +53,19 @@

Step 2:


- -
+ (If blank: info@{{ current_site.domain }}) + + +


+ -
+


+

@@ -94,6 +102,7 @@

Step 2:

'{% templatetag openvariable %}user.username{% templatetag closevariable %}': 'User\'s ESP Web Site Username', '{% templatetag openvariable %}user.first_name{% templatetag closevariable %}': 'User\'s First Name', '{% templatetag openvariable %}user.last_name{% templatetag closevariable %}': 'User\'s Last Name', + '{% templatetag openvariable %}user.unsubscribe_link{% templatetag closevariable %}': 'User-Specific Unsubscribe Link', '{% templatetag openvariable %}program.student_schedule{% templatetag closevariable %}': 'Student\'s schedule for Program', '{% templatetag openvariable %}program.student_schedule_norooms{% templatetag closevariable %}': 'Student\'s schedule without room numbers', '{% templatetag openvariable %}program.teacher_schedule{% templatetag closevariable %}': 'Teacher\'s schedule for Program', diff --git a/esp/templates/registration/login.html b/esp/templates/registration/login.html index 8521ba4a1d..a664a66805 100644 --- a/esp/templates/registration/login.html +++ b/esp/templates/registration/login.html @@ -5,15 +5,18 @@ {% block content %}

Please log in

+
+
{% if next and not initiated_login %} - -

+

+{% if "unsubscribe" in next %} +Unsubscribe token is invalid or has expired (>7 days old). Please login or use an unexpired unsubscribe token. +{% else %} You need to log in before you can access {{ next }}. -

- +{% endif %} +
{% endif %} -
Both the username and password are case-sensitive: capitalization matters!
diff --git a/esp/templates/users/unsubscribe.html b/esp/templates/users/unsubscribe.html new file mode 100644 index 0000000000..933913b110 --- /dev/null +++ b/esp/templates/users/unsubscribe.html @@ -0,0 +1,32 @@ +{% extends "main.html" %} + +{% block title %}Deactivate and Unsubscribe{% endblock %} + +{% block stylesheets %} +{{ block.super }} + +{% endblock %} + +{% block content %} +

{{ settings.INSTITUTION_NAME }} {{ settings.ORGANIZATION_SHORT_NAME }} Account Deactivation and Unsubscription

+ +
+{% if deactivated %} +

+Your account, {{ user.username }}, has been properly deactivated and you have been unsubscribed from emails from {{ settings.INSTITUTION_NAME }} {{ settings.ORGANIZATION_SHORT_NAME }}. +

+ +

+Thank you for your interest in {{ settings.ORGANIZATION_SHORT_NAME }}! If you have any questions, please contact us. +

+{% else %} +

We're sorry to see you go. If you are sure you would like to deactivate your account ({{ user.username }}) and unsubscribe from emails from {{ settings.INSTITUTION_NAME }} {{ settings.ORGANIZATION_SHORT_NAME }}, please click the confirmation button below:

+ +{% csrf_token %} + + + +{% endif %} +
+ +{% endblock %} \ No newline at end of file