From 06cbc5dce8d2b2f22c7ecb47bc7ea645e93d2b48 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Mon, 30 Mar 2026 13:45:35 +0200 Subject: [PATCH 1/6] Send a notification to the user on each succesful login. --- hypha/apply/users/apps.py | 8 ++++++ hypha/apply/users/signals.py | 27 +++++++++++++++++++ .../users/emails/login_notification.md | 19 +++++++++++++ hypha/settings/django.py | 2 +- 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 hypha/apply/users/apps.py create mode 100644 hypha/apply/users/signals.py create mode 100644 hypha/apply/users/templates/users/emails/login_notification.md diff --git a/hypha/apply/users/apps.py b/hypha/apply/users/apps.py new file mode 100644 index 0000000000..097707eaea --- /dev/null +++ b/hypha/apply/users/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + name = "hypha.apply.users" + + def ready(self): + from . import signals # NOQA diff --git a/hypha/apply/users/signals.py b/hypha/apply/users/signals.py new file mode 100644 index 0000000000..3cc59d9549 --- /dev/null +++ b/hypha/apply/users/signals.py @@ -0,0 +1,27 @@ +from django.conf import settings +from django.contrib.auth.signals import user_logged_in +from django.dispatch import receiver +from django.utils import formats, timezone +from django.utils.translation import gettext_lazy as _ +from wagtail.models import Site + +from hypha.core.mail import MarkdownMail + + +@receiver(user_logged_in) +def send_login_notification(sender, request, user, **kwargs): + if not settings.SEND_MESSAGES or not user.email: + return + + email = MarkdownMail("users/emails/login_notification.md") + email.send( + to=user.email, + subject=_("Successful login to %(org)s") % {"org": settings.ORG_LONG_NAME}, + from_email=settings.DEFAULT_FROM_EMAIL, + context={ + "user": user, + "login_time": formats.date_format(timezone.now(), "SHORT_DATETIME_FORMAT"), + "site": Site.find_for_request(request) if request else None, + "ORG_EMAIL": settings.ORG_EMAIL, + }, + ) diff --git a/hypha/apply/users/templates/users/emails/login_notification.md b/hypha/apply/users/templates/users/emails/login_notification.md new file mode 100644 index 0000000000..8cd8a29169 --- /dev/null +++ b/hypha/apply/users/templates/users/emails/login_notification.md @@ -0,0 +1,19 @@ +{% load i18n wagtailadmin_tags %}{% base_url_setting as base_url %} +{% blocktrans %}Dear {{ user }},{% endblocktrans %} + +{% blocktrans %}This is to notify you that your account was successfully logged in to {{ ORG_LONG_NAME }}.{% endblocktrans %} + +{% blocktrans with login_time=login_time %}Login time: {{ login_time }}{% endblocktrans %} + +{% blocktrans %}If you did not log in, please contact us immediately and consider changing your password.{% endblocktrans %} + +{% if ORG_EMAIL %} +{% blocktrans %}If you have any questions, please contact us at {{ ORG_EMAIL }}.{% endblocktrans %} +{% endif %} + +{% blocktrans %}Kind Regards, +The {{ ORG_SHORT_NAME }} Team{% endblocktrans %} + +-- +{{ ORG_LONG_NAME }} +{% if site %}{{ site.root_url }}{% else %}{{ base_url }}{% endif %} diff --git a/hypha/settings/django.py b/hypha/settings/django.py index 37a499fbf1..d32dff0f3b 100644 --- a/hypha/settings/django.py +++ b/hypha/settings/django.py @@ -15,7 +15,7 @@ "hypha.apply.dashboard", "hypha.apply.flags", "hypha.home", - "hypha.apply.users", + "hypha.apply.users.apps.UsersConfig", "hypha.apply.review", "hypha.apply.determinations", "hypha.apply.stream_forms", From 06a84c2fc1bfa4d178cf66ac5cf25da68f1f10d5 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Tue, 21 Apr 2026 16:05:16 +0200 Subject: [PATCH 2/6] Add tests. --- hypha/apply/users/tests/test_signals.py | 53 +++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 hypha/apply/users/tests/test_signals.py diff --git a/hypha/apply/users/tests/test_signals.py b/hypha/apply/users/tests/test_signals.py new file mode 100644 index 0000000000..e527bff55e --- /dev/null +++ b/hypha/apply/users/tests/test_signals.py @@ -0,0 +1,53 @@ +from django.contrib.auth.signals import user_logged_in +from django.core import mail +from django.test import RequestFactory, TestCase, override_settings + +from .factories import UserFactory + + +@override_settings(SEND_MESSAGES=True) +class TestSendLoginNotification(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.user = UserFactory() + + def _fire_signal(self, user=None, request=None): + if user is None: + user = self.user + if request is None: + request = self.factory.get("/") + user_logged_in.send(sender=user.__class__, request=request, user=user) + + def test_sends_email_on_login(self): + self._fire_signal() + self.assertEqual(len(mail.outbox), 1) + + def test_email_sent_to_user(self): + self._fire_signal() + self.assertIn(self.user.email, mail.outbox[0].to) + + def test_email_subject_contains_org_name(self): + from django.conf import settings + + self._fire_signal() + self.assertIn(settings.ORG_LONG_NAME, mail.outbox[0].subject) + + def test_no_email_when_send_messages_disabled(self): + with self.settings(SEND_MESSAGES=False): + self._fire_signal() + self.assertEqual(len(mail.outbox), 0) + + def test_no_email_when_user_has_no_email(self): + self.user.email = "" + self.user.save() + self._fire_signal() + self.assertEqual(len(mail.outbox), 0) + + def test_no_email_when_request_is_none(self): + # Signal can be fired without a request (e.g. management commands) + self._fire_signal(request=None) + self.assertEqual(len(mail.outbox), 1) + + def test_email_body_contains_login_time(self): + self._fire_signal() + self.assertTrue(any("Login time" in part for part in [mail.outbox[0].body])) From b06460ddde7b74037dccfd96c0de7339f3167443 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Thu, 14 May 2026 10:42:44 +0200 Subject: [PATCH 3/6] Use EMAIL_SUBJECT_PREFIX if set. --- docker/prod/.env.example | 2 +- hypha/apply/users/signals.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docker/prod/.env.example b/docker/prod/.env.example index 21f8791c20..657187a919 100644 --- a/docker/prod/.env.example +++ b/docker/prod/.env.example @@ -4,7 +4,7 @@ SECRET_KEY="changeme" PRIMARY_HOST="https://test.hypha.app" EMAIL_HOST="hypha.app" -EMAIL_SUBJECT_PREFIX="[Hypha]" +EMAIL_SUBJECT_PREFIX="[Hypha] " ORG_EMAIL="hello@hypha.app" SERVER_EMAIL="test@hypha.app" diff --git a/hypha/apply/users/signals.py b/hypha/apply/users/signals.py index 3cc59d9549..448be963f4 100644 --- a/hypha/apply/users/signals.py +++ b/hypha/apply/users/signals.py @@ -13,10 +13,14 @@ def send_login_notification(sender, request, user, **kwargs): if not settings.SEND_MESSAGES or not user.email: return + subject = _("Successful login to %(org)s") % {"org": settings.ORG_LONG_NAME} + if settings.EMAIL_SUBJECT_PREFIX: + subject = str(settings.EMAIL_SUBJECT_PREFIX) + str(subject) + email = MarkdownMail("users/emails/login_notification.md") email.send( to=user.email, - subject=_("Successful login to %(org)s") % {"org": settings.ORG_LONG_NAME}, + subject=subject, from_email=settings.DEFAULT_FROM_EMAIL, context={ "user": user, From ca671ef95d98d0e43ce7dc39b1db5badd8cc416f Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Sun, 17 May 2026 22:13:48 +0200 Subject: [PATCH 4/6] Do not send e-mails when using become/hijack. --- hypha/apply/users/signals.py | 11 ++++++++++ hypha/apply/users/tests/test_signals.py | 27 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/hypha/apply/users/signals.py b/hypha/apply/users/signals.py index 448be963f4..cea0d62ad0 100644 --- a/hypha/apply/users/signals.py +++ b/hypha/apply/users/signals.py @@ -7,12 +7,23 @@ from hypha.core.mail import MarkdownMail +HIJACK_VIEW_NAMES = { + "hijack-become", + "users:hijack", + "hijack:acquire", + "hijack:release", +} + @receiver(user_logged_in) def send_login_notification(sender, request, user, **kwargs): if not settings.SEND_MESSAGES or not user.email: return + if request and getattr(request, "resolver_match", None): + if request.resolver_match.view_name in HIJACK_VIEW_NAMES: + return + subject = _("Successful login to %(org)s") % {"org": settings.ORG_LONG_NAME} if settings.EMAIL_SUBJECT_PREFIX: subject = str(settings.EMAIL_SUBJECT_PREFIX) + str(subject) diff --git a/hypha/apply/users/tests/test_signals.py b/hypha/apply/users/tests/test_signals.py index e527bff55e..3075b4efc4 100644 --- a/hypha/apply/users/tests/test_signals.py +++ b/hypha/apply/users/tests/test_signals.py @@ -1,3 +1,5 @@ +from unittest.mock import MagicMock + from django.contrib.auth.signals import user_logged_in from django.core import mail from django.test import RequestFactory, TestCase, override_settings @@ -51,3 +53,28 @@ def test_no_email_when_request_is_none(self): def test_email_body_contains_login_time(self): self._fire_signal() self.assertTrue(any("Login time" in part for part in [mail.outbox[0].body])) + + def _fire_signal_with_view_name(self, view_name): + request = self.factory.get("/") + request.resolver_match = MagicMock(view_name=view_name) + self._fire_signal(request=request) + + def test_no_email_on_hijack_acquire(self): + self._fire_signal_with_view_name("hijack:acquire") + self.assertEqual(len(mail.outbox), 0) + + def test_no_email_on_hijack_release(self): + self._fire_signal_with_view_name("hijack:release") + self.assertEqual(len(mail.outbox), 0) + + def test_no_email_on_hijack_become(self): + self._fire_signal_with_view_name("hijack-become") + self.assertEqual(len(mail.outbox), 0) + + def test_no_email_on_users_hijack_view(self): + self._fire_signal_with_view_name("users:hijack") + self.assertEqual(len(mail.outbox), 0) + + def test_email_sent_for_non_hijack_view(self): + self._fire_signal_with_view_name("account_login") + self.assertEqual(len(mail.outbox), 1) From ebfea57681a673db607cbc233c4fa3d150dd645f Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Tue, 26 May 2026 09:49:17 +0200 Subject: [PATCH 5/6] Save user timezone to session and use that to get the local time in the login notification e-mail. --- hypha/apply/users/signals.py | 14 +++++++++++++- hypha/apply/users/urls.py | 2 ++ hypha/apply/users/utils.py | 11 +++++++++++ hypha/apply/users/views.py | 11 +++++++++++ hypha/templates/base.html | 9 +++++++++ 5 files changed, 46 insertions(+), 1 deletion(-) diff --git a/hypha/apply/users/signals.py b/hypha/apply/users/signals.py index cea0d62ad0..2057e63440 100644 --- a/hypha/apply/users/signals.py +++ b/hypha/apply/users/signals.py @@ -7,6 +7,8 @@ from hypha.core.mail import MarkdownMail +from .utils import get_zoneinfo + HIJACK_VIEW_NAMES = { "hijack-become", "users:hijack", @@ -20,10 +22,18 @@ def send_login_notification(sender, request, user, **kwargs): if not settings.SEND_MESSAGES or not user.email: return + if getattr(user, "backend", "").startswith("social_core."): + return + if request and getattr(request, "resolver_match", None): if request.resolver_match.view_name in HIJACK_VIEW_NAMES: return + tz_name = ( + getattr(request, "session", {}).get("user_timezone", "") if request else "" + ) + user_tz = get_zoneinfo(tz_name) + subject = _("Successful login to %(org)s") % {"org": settings.ORG_LONG_NAME} if settings.EMAIL_SUBJECT_PREFIX: subject = str(settings.EMAIL_SUBJECT_PREFIX) + str(subject) @@ -35,7 +45,9 @@ def send_login_notification(sender, request, user, **kwargs): from_email=settings.DEFAULT_FROM_EMAIL, context={ "user": user, - "login_time": formats.date_format(timezone.now(), "SHORT_DATETIME_FORMAT"), + "login_time": formats.date_format( + timezone.localtime(timezone=user_tz), "SHORT_DATETIME_FORMAT" + ), "site": Site.find_for_request(request) if request else None, "ORG_EMAIL": settings.ORG_EMAIL, }, diff --git a/hypha/apply/users/urls.py b/hypha/apply/users/urls.py index 47c0907a65..1cd520ec30 100644 --- a/hypha/apply/users/urls.py +++ b/hypha/apply/users/urls.py @@ -37,6 +37,7 @@ oauth, send_confirm_access_email_view, set_password_view, + set_timezone_view, ) app_name = "users" @@ -159,6 +160,7 @@ ), path("activate/", create_password, name="activate_password"), path("oauth", oauth, name="oauth"), + path("set-timezone/", set_timezone_view, name="set_timezone"), # 2FA path("two_factor/setup/", TWOFASetupView.as_view(), name="setup"), path( diff --git a/hypha/apply/users/utils.py b/hypha/apply/users/utils.py index 89f250aec4..c39fe34232 100644 --- a/hypha/apply/users/utils.py +++ b/hypha/apply/users/utils.py @@ -1,4 +1,5 @@ import string +import zoneinfo import nh3 from django.conf import settings @@ -196,6 +197,16 @@ def update_is_staff(request, user): user.save() +def get_zoneinfo(tz_name): + """Return a ZoneInfo for tz_name, or None if invalid/empty.""" + if not tz_name: + return None + try: + return zoneinfo.ZoneInfo(tz_name) + except (zoneinfo.ZoneInfoNotFoundError, KeyError): + return None + + def strip_html_and_nerf_urls(value: str): # Remove all HTML tags. This prohibits HTML without creating hurdles. cleaned_value = nh3.clean(value, tags=set()) diff --git a/hypha/apply/users/views.py b/hypha/apply/users/views.py index 84578c4a53..e10aac90ac 100644 --- a/hypha/apply/users/views.py +++ b/hypha/apply/users/views.py @@ -31,6 +31,7 @@ from django.views.decorators.cache import never_cache from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters +from django.views.decorators.http import require_POST from django.views.generic import UpdateView from django.views.generic.base import TemplateView from django.views.generic.edit import FormView @@ -70,6 +71,7 @@ from .utils import ( generate_numeric_token, get_redirect_url, + get_zoneinfo, send_activation_email, send_confirmation_email, ) @@ -870,6 +872,15 @@ def set_password_view(request): return HttpResponse(_("✓ Check your email for password set link.")) +@require_POST +def set_timezone_view(request: HttpRequest) -> HttpResponse: + """Store the browser timezone in the session for use in login notifications.""" + tz_name = request.POST.get("user_timezone", "") + if get_zoneinfo(tz_name): + request.session["user_timezone"] = tz_name + return HttpResponse(status=204) + + @never_cache @csrf_exempt @psa(f"{settings.SOCIAL_AUTH_URL_NAMESPACE}:complete") diff --git a/hypha/templates/base.html b/hypha/templates/base.html index 211f02e708..6b4392b606 100644 --- a/hypha/templates/base.html +++ b/hypha/templates/base.html @@ -202,6 +202,15 @@ }); {% endif %} + {% if not request.session.user_timezone %} + + {% endif %} {% include "includes/body_end.html" %} From bebee94f468adaa68fef4ca663bb5a7a43267683 Mon Sep 17 00:00:00 2001 From: Fredrik Jonsson Date: Tue, 26 May 2026 09:53:29 +0200 Subject: [PATCH 6/6] Add timezone to login time in e-mail. --- hypha/apply/users/signals.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/hypha/apply/users/signals.py b/hypha/apply/users/signals.py index 2057e63440..84d619f1ec 100644 --- a/hypha/apply/users/signals.py +++ b/hypha/apply/users/signals.py @@ -45,8 +45,11 @@ def send_login_notification(sender, request, user, **kwargs): from_email=settings.DEFAULT_FROM_EMAIL, context={ "user": user, - "login_time": formats.date_format( - timezone.localtime(timezone=user_tz), "SHORT_DATETIME_FORMAT" + "login_time": "{} ({})".format( + formats.date_format( + timezone.localtime(timezone=user_tz), "SHORT_DATETIME_FORMAT" + ), + tz_name or timezone.get_current_timezone_name(), ), "site": Site.find_for_request(request) if request else None, "ORG_EMAIL": settings.ORG_EMAIL,