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/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..cea0d62ad0 --- /dev/null +++ b/hypha/apply/users/signals.py @@ -0,0 +1,42 @@ +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 + +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) + + email = MarkdownMail("users/emails/login_notification.md") + email.send( + to=user.email, + subject=subject, + 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/apply/users/tests/test_signals.py b/hypha/apply/users/tests/test_signals.py new file mode 100644 index 0000000000..3075b4efc4 --- /dev/null +++ b/hypha/apply/users/tests/test_signals.py @@ -0,0 +1,80 @@ +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 + +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])) + + 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) 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",