diff --git a/docs/source/reference_index/apps/preferences.rst b/docs/source/reference_index/apps/preferences.rst index b4c4cbee198..68a5039bdd5 100644 --- a/docs/source/reference_index/apps/preferences.rst +++ b/docs/source/reference_index/apps/preferences.rst @@ -9,6 +9,8 @@ preferences fields forms + models + tasks tests urls views diff --git a/intranet/apps/emailfwd/forms.py b/intranet/apps/emailfwd/forms.py index 48af5a2118f..ecf438a153b 100644 --- a/intranet/apps/emailfwd/forms.py +++ b/intranet/apps/emailfwd/forms.py @@ -1,9 +1,13 @@ from django import forms -from .models import SeniorEmailForward +from ..users.models import Email -class SeniorEmailForwardForm(forms.ModelForm): - class Meta: - model = SeniorEmailForward - fields = ["email"] +class SeniorEmailForwardForm(forms.Form): + def __init__(self, user, *args, **kwargs): + super().__init__(*args, **kwargs) + + label = "Email Forward" + self.fields["email"] = forms.ModelChoiceField( + queryset=Email.objects.filter(user=user, verified=True), required=False, label=label, disabled=(user.emails.all().count() == 0) + ) diff --git a/intranet/apps/emailfwd/tests.py b/intranet/apps/emailfwd/tests.py index fe02d8c4bb3..1675caf0520 100644 --- a/intranet/apps/emailfwd/tests.py +++ b/intranet/apps/emailfwd/tests.py @@ -6,6 +6,7 @@ from ...test.ion_test import IonTestCase from ...utils.date import get_senior_graduation_year +from ..users.models import Email from .models import SeniorEmailForward @@ -25,14 +26,23 @@ def test_email_fwd(self): self.assertEqual(response.status_code, 200) # Now, test setting an email - response = self.client.post(reverse("senior_emailfwd"), data={"email": "nonexistent@tjhsst.edu"}, follow=True) + email = Email.objects.get_or_create(user=user, address="verified_nonexistent@tjhsst.edu", verified=True)[0] + response = self.client.post(reverse("senior_emailfwd"), data={"email": email.id}, follow=True) self.assertEqual(response.status_code, 200) - self.assertEqual(1, SeniorEmailForward.objects.filter(user=user, email="nonexistent@tjhsst.edu").count()) + self.assertEqual(1, SeniorEmailForward.objects.filter(user=user).count()) + self.assertIn("Successfully added forwarding address.", list(map(str, list(response.context["messages"])))) - # Test invalid email - response = self.client.post(reverse("senior_emailfwd"), data={"email": "nonexistenttjhsst.edu"}, follow=True) + # Test removing an email + response = self.client.post(reverse("senior_emailfwd"), data={"email": ""}, follow=True) self.assertEqual(response.status_code, 200) - self.assertEqual(1, SeniorEmailForward.objects.filter(user=user, email="nonexistent@tjhsst.edu").count()) + self.assertEqual(0, SeniorEmailForward.objects.filter(user=user).count()) + self.assertIn("Successfully cleared email forward.", list(map(str, list(response.context["messages"])))) + + # Test setting an unverified email + email = Email.objects.get_or_create(user=user, address="unverified_nonexistent@tjhsst.edu", verified=False)[0] + response = self.client.post(reverse("senior_emailfwd"), data={"email": email.id}, follow=True) + self.assertEqual(response.status_code, 200) + self.assertEqual(0, SeniorEmailForward.objects.filter(user=user).count()) self.assertIn("Error adding forwarding address.", list(map(str, list(response.context["messages"])))) @@ -44,7 +54,8 @@ def test_getting_forwards(self): self.login() # Set the email - response = self.client.post(reverse("senior_emailfwd"), data={"email": "nonexistent@tjhsst.edu"}, follow=True) + email = Email.objects.get_or_create(user=user, address="nonexistent@tjhsst.edu")[0] + response = self.client.post(reverse("senior_emailfwd"), data={"email": email.id}, follow=True) self.assertEqual(response.status_code, 200) self.assertEqual(1, SeniorEmailForward.objects.filter(user=user, email="nonexistent@tjhsst.edu").count()) diff --git a/intranet/apps/emailfwd/views.py b/intranet/apps/emailfwd/views.py index aaaac66e405..ec992c9b7cd 100644 --- a/intranet/apps/emailfwd/views.py +++ b/intranet/apps/emailfwd/views.py @@ -11,6 +11,19 @@ logger = logging.getLogger(__name__) +def add_email_forward(user, email, forward): + """Create a new forward if no forward exists or update the existing one.""" + + if forward is None: + forward = SeniorEmailForward(user=user, email=email.address) + forward.save() + else: + forward.address = email.address + forward.save() + + return forward + + @login_required @deny_restricted def senior_email_forward_view(request): @@ -24,20 +37,27 @@ def senior_email_forward_view(request): forward = None if request.method == "POST": - if forward: - form = SeniorEmailForwardForm(request.POST, instance=forward) - else: - form = SeniorEmailForwardForm(request.POST) + form = SeniorEmailForwardForm(request.user, request.POST) + if form.is_valid(): - obj = form.save(commit=False) - obj.user = request.user - obj.save() - messages.success(request, "Successfully added forwarding address.") - return redirect("index") + email = form.cleaned_data["email"] + + if email is None: + if forward is not None: + forward.delete() + forward = None + messages.success(request, "Successfully cleared email forward.") + + elif email.verified: + forward = add_email_forward(request.user, email, forward) + + messages.success(request, "Successfully added forwarding address.") + else: + messages.error(request, "You can only forward verified emails.") else: messages.error(request, "Error adding forwarding address.") - elif forward: - form = SeniorEmailForwardForm(instance=forward) + else: - form = SeniorEmailForwardForm() + form = SeniorEmailForwardForm(request.user) + return render(request, "emailfwd/senior_forward.html", {"form": form, "forward": forward}) diff --git a/intranet/apps/preferences/forms.py b/intranet/apps/preferences/forms.py index 10e027f7822..53a4a757cd3 100644 --- a/intranet/apps/preferences/forms.py +++ b/intranet/apps/preferences/forms.py @@ -90,7 +90,7 @@ def flag(label, default): if user.emails.all().count() == 0: label = "You can set a primary email after adding emails below." self.fields["primary_email"] = forms.ModelChoiceField( - queryset=Email.objects.filter(user=user), required=False, label=label, disabled=(user.emails.all().count() == 0) + queryset=Email.objects.filter(user=user, verified=True), required=False, label=label, disabled=(user.emails.all().count() == 0) ) diff --git a/intranet/apps/preferences/migrations/0001_initial.py b/intranet/apps/preferences/migrations/0001_initial.py new file mode 100644 index 00000000000..d25dd41a20e --- /dev/null +++ b/intranet/apps/preferences/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 3.2.25 on 2025-05-07 01:56 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('users', '0043_email_verified'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='UnverifiedEmail', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('verification_token', models.UUIDField(default=uuid.uuid4, editable=False)), + ('email', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.email')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/intranet/apps/preferences/models.py b/intranet/apps/preferences/models.py new file mode 100644 index 00000000000..9ba969b58b9 --- /dev/null +++ b/intranet/apps/preferences/models.py @@ -0,0 +1,19 @@ +import uuid +from datetime import timedelta + +from django.conf import settings +from django.db import models +from django.utils.timezone import now + +from ..users.models import Email + + +class UnverifiedEmail(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + email = models.ForeignKey(Email, on_delete=models.CASCADE) + date_created = models.DateTimeField(auto_now_add=True) + verification_token = models.UUIDField(default=uuid.uuid4, editable=False) + + # Email link is expired if it's older than specified expire time and should be deleted. + def is_expired(self): + return now() - self.date_created >= timedelta(hours=settings.UNVERIFIED_EMAIL_EXPIRE_HOURS) diff --git a/intranet/apps/preferences/tasks.py b/intranet/apps/preferences/tasks.py new file mode 100644 index 00000000000..ed1c51e2620 --- /dev/null +++ b/intranet/apps/preferences/tasks.py @@ -0,0 +1,22 @@ +from datetime import timedelta + +from celery import shared_task +from celery.utils.log import get_task_logger +from django.conf import settings +from django.utils.timezone import now + +from .models import UnverifiedEmail + +logger = get_task_logger(__name__) + + +@shared_task +def delete_expired_emails(): + # Unverified email links should be deleted after specified timeout. + cutoff = now() - timedelta(hours=settings.UNVERIFIED_EMAIL_EXPIRE_HOURS) + expired_emails = UnverifiedEmail.objects.filter(date_created__lt=cutoff) + + emails_deleted = expired_emails.count() + expired_emails.delete() + + logger.info(f"Deleted {emails_deleted} expired email links.") diff --git a/intranet/apps/preferences/urls.py b/intranet/apps/preferences/urls.py index 15eb3ff7c80..1d13860be32 100644 --- a/intranet/apps/preferences/urls.py +++ b/intranet/apps/preferences/urls.py @@ -2,4 +2,8 @@ from . import views -urlpatterns = [path("", views.preferences_view, name="preferences"), path("/privacy", views.privacy_options_view, name="privacy_options")] +urlpatterns = [ + re_path(r"^$", views.preferences_view, name="preferences"), + re_path(r"^/privacy$", views.privacy_options_view, name="privacy_options"), + re_path(r"^/verify_email/(?P[0-9a-fA-F-]{36})$", views.verify_email_view, name="verify_email"), # The path only accepts valid UUIDs. +] diff --git a/intranet/apps/preferences/views.py b/intranet/apps/preferences/views.py index 6bcbf78337c..0ed78ce8575 100644 --- a/intranet/apps/preferences/views.py +++ b/intranet/apps/preferences/views.py @@ -6,11 +6,14 @@ from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.shortcuts import redirect, render +from django.urls import reverse from ..auth.decorators import eighth_admin_required from ..bus.models import Route +from ..notifications.tasks import email_send_task from ..users.models import Email from .forms import BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PreferredPictureForm, PrivacyOptionsForm +from .models import UnverifiedEmail # from .forms import (BusRouteForm, DarkModeForm, EmailFormset, NotificationOptionsForm, PhoneFormset, PreferredPictureForm, PrivacyOptionsForm, # WebsiteFormset) @@ -52,6 +55,20 @@ def get_personal_info(user): return personal_info, num_fields +def send_verification_email(request, user, email): + email_link = UnverifiedEmail(user=user, email=email) + email_link.save() + + verification_link = request.build_absolute_uri(reverse("verify_email", args=[email_link.verification_token])) + base_url = request.build_absolute_uri(reverse("index")) + data = {"verification_link": verification_link, "base_url": base_url} + headers = {"From": "Ion "} + + email_send_task.delay( + "preferences/email/verify_email.txt", "preferences/email/verify_email.html", data, "Email Verification", [email.address], headers + ) + + def save_personal_info(request, user): # phone_formset = PhoneFormset(request.POST, instance=user, prefix="pf") phone_formset = None @@ -68,7 +85,24 @@ def save_personal_info(request, user): # else: # errors.append("Could not set phone numbers.") if email_formset.is_valid(): - email_formset.save() + new_emails = email_formset.save(commit=False) + + # Manually handle saving the formset so we can flag new emails as unverified. + for email in new_emails: + if email._state.adding: + email.verified = False + email.save() + send_verification_email(request, user, email) + messages.success( + request, + f"Successfully sent verification email to '{email.address}'. The link will expire in {settings.UNVERIFIED_EMAIL_EXPIRE_HOURS} hours.", + ) + + for deleted_email in email_formset.deleted_objects: + try: + deleted_email.delete() + except deleted_email.DoesNotExist: + pass else: for error in email_formset.errors: if isinstance(error.get("address"), list): @@ -207,6 +241,13 @@ def save_notification_options(request, user): if field in notification_options and notification_options[field] == fields[field]: pass else: + # Users should only be able to set verified emails as their primary email. + if field == "primary_email" and fields[field] is not None: + email = Email.objects.filter(user=user, address=fields[field]).first() + if not email.verified: + messages.error(request, "You may only set verified emails as your primary email.") + continue + setattr(user, field, fields[field]) user.save() try: @@ -290,6 +331,28 @@ def save_dark_mode_settings(request, user): return dark_mode_form +@login_required +def verify_email_view(request, email_uuid): + """ "Verify the UUID associated with the unverified email.""" + user = request.user + + unverified_email = UnverifiedEmail.objects.filter(verification_token=email_uuid, user=user).first() + + # If the uuid isn't found or link is expired, return a error message. + if unverified_email is None or unverified_email.is_expired(): + messages.error(request, "Could not verify email, the link was either expired or invalid.") + return redirect("preferences") + + verified_mail = unverified_email.email + verified_mail.verified = True + + verified_mail.save() + unverified_email.delete() + + messages.success(request, f"Successfully verified '{verified_mail.address}'. You can add it as a primary email now.") + return redirect("preferences") + + @login_required def preferences_view(request): """View and process updates to the preferences page.""" @@ -331,6 +394,13 @@ def preferences_view(request): email_formset = EmailFormset(instance=user, prefix="ef") # website_formset = WebsiteFormset(instance=user, prefix="wf") + # Flag emails as verified or unverified for templating. + for form in email_formset: + if form.instance.pk: + form.verified = form.instance.verified + else: + form.verified = None + if user.is_student: preferred_pic = get_preferred_pic(user) bus_route = get_bus_route(user) diff --git a/intranet/apps/users/migrations/0043_email_verified.py b/intranet/apps/users/migrations/0043_email_verified.py new file mode 100644 index 00000000000..389cfb85bec --- /dev/null +++ b/intranet/apps/users/migrations/0043_email_verified.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.25 on 2025-05-07 01:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0042_user_seen_april_fools'), + ] + + operations = [ + migrations.AddField( + model_name='email', + name='verified', + field=models.BooleanField(default=True), + ), + ] diff --git a/intranet/apps/users/models.py b/intranet/apps/users/models.py index a4a5212abf7..9d6c7144757 100644 --- a/intranet/apps/users/models.py +++ b/intranet/apps/users/models.py @@ -1294,6 +1294,7 @@ class Email(models.Model): address = models.EmailField() user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="emails", on_delete=models.CASCADE) + verified = models.BooleanField(default=True) def __str__(self): return self.address diff --git a/intranet/settings/__init__.py b/intranet/settings/__init__.py index 43841e51d63..8577b2d2128 100644 --- a/intranet/settings/__init__.py +++ b/intranet/settings/__init__.py @@ -947,6 +947,9 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th # Age (in days) of a lost and found entry until it is hidden LOSTFOUND_EXPIRATION = 180 +# How many hours till a verification email expires. +UNVERIFIED_EMAIL_EXPIRE_HOURS = 12 + # Substrings of paths to not log in the Ion access logs NONLOGGABLE_PATH_BEGINNINGS = ["/static"] NONLOGGABLE_PATH_ENDINGS = [".png", ".jpg", ".jpeg", ".gif", ".css", ".js", ".ico", "jsi18n/"] @@ -988,6 +991,11 @@ def get_log(name): # pylint: disable=redefined-outer-name; 'name' is used as th "schedule": celery.schedules.crontab(day_of_month=1, hour=1), "args": (), }, + "delete-expired-email-links": { + "task": "intranet.apps.preferences.tasks.delete_expired_emails", + "schedule": celery.schedules.crontab(hour=0, minute=0), + "args": (), + } } MAINTENANCE_MODE = False diff --git a/intranet/templates/emailfwd/senior_forward.html b/intranet/templates/emailfwd/senior_forward.html index 5771afe65e8..972bc67d2b9 100644 --- a/intranet/templates/emailfwd/senior_forward.html +++ b/intranet/templates/emailfwd/senior_forward.html @@ -32,7 +32,8 @@

At the conclusion of the school year, your TJ Network Account will be deleted. However, you can configure messages sent to your TJ Email address, {{ request.user.tj_email }}, to forward to another address (such as your university email or a personal email). You will not be able to log into Webmail. Once set at the end of the school year, this address can't be changed.

Do not operate under the assumption that your mail will continue to forward for extended periods of time. You should attempt to transition away from using your TJ email over the next few years.

-

Enter the email address to forward messages sent to {{ request.user.tj_email }} after your TJ Network Account is deleted:

+

You may only add verified emails as a forward. You can add and verify emails in your user preferences.

+

Choose the email address to forward messages sent to {{ request.user.tj_email }} after your TJ Network Account is deleted:

{% csrf_token %} @@ -44,5 +45,10 @@

+ {% if forward is not None %} +

Your current forwarded email is: {{ forward.email }}

+ {% else %} +

You currently do not have an email forward.

+ {% endif %} {% endblock %} diff --git a/intranet/templates/preferences/email/verify_email.html b/intranet/templates/preferences/email/verify_email.html new file mode 100644 index 00000000000..a439fae295e --- /dev/null +++ b/intranet/templates/preferences/email/verify_email.html @@ -0,0 +1,13 @@ +

+ Hello, +

+

+ Someone, hopefully you, has requested to add this email to their Ion account. +

+

+ If this was you, you may navigate to the link below and verify this email. + Otherwise, you can safely ignore this email. +

+Verification Link + +{% include "email_footer.html" %} diff --git a/intranet/templates/preferences/email/verify_email.txt b/intranet/templates/preferences/email/verify_email.txt new file mode 100644 index 00000000000..5c3f191cc41 --- /dev/null +++ b/intranet/templates/preferences/email/verify_email.txt @@ -0,0 +1,8 @@ +Hello, + +Someone, hopefully you, has requested to add this email to their Ion account. + +If this was you, you may navigate to the link below and verify this email. Otherwise, you can safely ignore this email. +{{ verification_link }} + +{% include "email_footer.txt" %} \ No newline at end of file diff --git a/intranet/templates/preferences/preferences.html b/intranet/templates/preferences/preferences.html index 24ff9f7e21a..ee4bc37db78 100644 --- a/intranet/templates/preferences/preferences.html +++ b/intranet/templates/preferences/preferences.html @@ -161,7 +161,11 @@

Personal Information

{% for hidden in form.hidden_fields %} {{ hidden }} {% endfor %} - {% if form.instance.pk %}{{ form.DELETE }}{% endif %}{{ form.address }} + + {% if form.instance.pk %}{{ form.DELETE }}{% endif %} + {{ form.address }} + {% if form.verified == False %}(unverified){% endif %} + {% endfor %}