From b7d964db569f39d481fe8effe5a621670bcce2e0 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 14 Apr 2025 18:48:50 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20add=20email=20notification?= =?UTF-8?q?s=20for=20screen=20recordings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement backend method to send email notifications when screen recordings are ready for download. Enables users to be alerted when their recordings are available. Frontend implementation to follow in upcoming commits. This service is triggered by the storage hook from Minio. Add minimal unit test coverage for notification service, addressing previous lack of tests in this area. The notification service was responsible for calling the unstable summary service feature, which was developped way too quickly. The email template has been reviewed by a LLM, to make it user-friendly and crystal clear. --- env.d/development/common.dist | 7 + .../core/recording/event/notification.py | 63 ++++++- .../recording/event/test_notification.py | 167 ++++++++++++++++++ .../locale/en_US/LC_MESSAGES/django.po | 63 ++++++- .../locale/fr_FR/LC_MESSAGES/django.po | 63 ++++++- src/backend/meet/settings.py | 7 + .../dev-keycloak/values.meet.yaml.gotmpl | 5 + src/helm/env.d/dev/values.meet.yaml.gotmpl | 5 + src/mail/mjml/screen_recording.mjml | 67 +++++++ 9 files changed, 441 insertions(+), 6 deletions(-) create mode 100644 src/backend/core/tests/recording/event/test_notification.py create mode 100644 src/mail/mjml/screen_recording.mjml diff --git a/env.d/development/common.dist b/env.d/development/common.dist index 5018b1e4..3085e716 100644 --- a/env.d/development/common.dist +++ b/env.d/development/common.dist @@ -12,6 +12,10 @@ PYTHONPATH=/app # Mail DJANGO_EMAIL_HOST="mailcatcher" DJANGO_EMAIL_PORT=1025 +DJANGO_EMAIL_BRAND_NAME=La Suite Numérique +DJANGO_EMAIL_SUPPORT_EMAIL=test@yopmail.com +DJANGO_EMAIL_LOGO_IMG=http://localhost:3000/assets/logo-suite-numerique.png +DJANGO_EMAIL_DOMAIN=http://localhost:3000/ # Backend url MEET_BASE_URL="http://localhost:8072" @@ -42,3 +46,6 @@ LIVEKIT_API_SECRET=secret LIVEKIT_API_KEY=devkey LIVEKIT_API_URL=http://localhost:7880 ALLOW_UNREGISTERED_ROOMS=False + +# Recording +SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings diff --git a/src/backend/core/recording/event/notification.py b/src/backend/core/recording/event/notification.py index 49cf741c..32d2d030 100644 --- a/src/backend/core/recording/event/notification.py +++ b/src/backend/core/recording/event/notification.py @@ -1,8 +1,13 @@ """Service to notify external services when a new recording is ready.""" import logging +import smtplib from django.conf import settings +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils.translation import get_language, override +from django.utils.translation import gettext_lazy as _ import requests @@ -21,10 +26,7 @@ class NotificationService: return self._notify_summary_service(recording) if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING: - logger.warning( - "Screen recording mode not implemented for recording %s", recording.id - ) - return False + return self._notify_user_by_email(recording) logger.error( "Unknown recording mode %s for recording %s", @@ -33,6 +35,59 @@ class NotificationService: ) return False + @staticmethod + def _notify_user_by_email(recording) -> bool: + """ + Send an email notification to recording owners when their recording is ready. + + The email includes a direct link that redirects owners to a dedicated download + page in the frontend where they can access their specific recording. + """ + + owner_accesses = models.RecordingAccess.objects.select_related("user").filter( + role=models.RoleChoices.OWNER, + recording_id=recording.id, + ) + + if not owner_accesses: + logger.error("No owner found for recording %s", recording.id) + return False + + language = get_language() + + context = { + "brandname": settings.EMAIL_BRAND_NAME, + "support_email": settings.EMAIL_SUPPORT_EMAIL, + "logo_img": settings.EMAIL_LOGO_IMG, + "domain": settings.EMAIL_DOMAIN, + "room_name": recording.room.name, + "recording_date": recording.created_at.strftime("%A %d %B %Y"), + "recording_time": recording.created_at.strftime("%H:%M"), + "link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}", + } + + emails = [access.user.email for access in owner_accesses] + + with override(language): + msg_html = render_to_string("mail/html/screen_recording.html", context) + msg_plain = render_to_string("mail/text/screen_recording.txt", context) + subject = str(_("Your recording is ready")) # Force translation + + try: + send_mail( + subject.capitalize(), + msg_plain, + settings.EMAIL_FROM, + emails, + html_message=msg_html, + fail_silently=False, + ) + except smtplib.SMTPException as exception: + logger.error("notification to %s was not sent: %s", emails, exception) + return False + + return True + @staticmethod def _notify_summary_service(recording): """Notify summary service about a new recording.""" diff --git a/src/backend/core/tests/recording/event/test_notification.py b/src/backend/core/tests/recording/event/test_notification.py new file mode 100644 index 00000000..0fd8dcc7 --- /dev/null +++ b/src/backend/core/tests/recording/event/test_notification.py @@ -0,0 +1,167 @@ +""" +Test event notification. +""" + +# pylint: disable=E1128,W0621,W0613,W0212 + +import smtplib +from unittest import mock + +from django.contrib.sites.models import Site + +import pytest + +from core import factories, models +from core.recording.event.notification import NotificationService, notification_service + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def mocked_current_site(): + """Mocks the Site.objects.get_current()to return a controlled predefined domain.""" + + site_mock = mock.Mock() + site_mock.domain = "test-domain.com" + + with mock.patch.object( + Site.objects, "get_current", return_value=site_mock + ) as patched: + yield patched + + +@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True) +def test_notify_external_services_transcript_mode(mock_notify_summary): + """Test notification routing for transcript mode recordings.""" + + service = NotificationService() + + recording = factories.RecordingFactory(mode=models.RecordingModeChoices.TRANSCRIPT) + result = service.notify_external_services(recording) + + assert result is True + mock_notify_summary.assert_called_once_with(recording) + + +@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True) +def test_notify_external_services_screen_recording_mode(mock_notify_email): + """Test notification routing for screen recording mode.""" + + service = NotificationService() + + recording = factories.RecordingFactory( + mode=models.RecordingModeChoices.SCREEN_RECORDING + ) + + result = service.notify_external_services(recording) + + assert result is True + mock_notify_email.assert_called_once_with(recording) + + +def test_notify_external_services_unknown_mode(caplog): + """Test notification for unknown recording mode.""" + recording = factories.RecordingFactory() + + # Bypass validation + recording.mode = "unknown" + + service = NotificationService() + + result = service.notify_external_services(recording) + + assert result is False + assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text + + +def test_notify_user_by_email_success(mocked_current_site, settings): + """Test successful email notification to recording owners.""" + settings.EMAIL_BRAND_NAME = "ACME" + settings.EMAIL_SUPPORT_EMAIL = "support@acme.com" + settings.EMAIL_LOGO_IMG = "https://acme.com/logo" + settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings" + settings.EMAIL_FROM = "notifications@acme.com" + + recording = factories.RecordingFactory(room__name="Conference Room A") + + owners = [ + factories.UserRecordingAccessFactory( + recording=recording, role=models.RoleChoices.OWNER + ).user, + factories.UserRecordingAccessFactory( + recording=recording, role=models.RoleChoices.OWNER + ).user, + ] + owner_emails = [owner.email for owner in owners] + + # Create non-owner users to verify they don't receive emails + factories.UserRecordingAccessFactory( + recording=recording, role=models.RoleChoices.MEMBER + ) + factories.UserRecordingAccessFactory( + recording=recording, role=models.RoleChoices.ADMIN + ) + + notification_service = NotificationService() + + with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail: + result = notification_service._notify_user_by_email(recording) + + assert result is True + mock_send_mail.assert_called_once() + + subject, body, sender, recipients = mock_send_mail.call_args[0] + + assert subject == "Your recording is ready" + + # Verify email contains expected content + required_content = [ + "ACME", # Brand name + "support@acme.com", # Support email + "https://acme.com/logo", # Logo URL + f"https://acme.com/recordings/{recording.id}", # Recording link + "Conference Room A", # Room name + recording.created_at.strftime("%A %d %B %Y"), # Formatted date + recording.created_at.strftime("%H:%M"), # Formatted time + ] + + for content in required_content: + assert content in body + + assert sender == "notifications@acme.com" + + # Verify all owners received the email (order-independent comparison) + assert sorted(recipients) == sorted(owner_emails) + + +def test_notify_user_by_email_no_owners(mocked_current_site, caplog): + """Test email notification when no owners are found.""" + + # Recording with no access + recording = factories.RecordingFactory() + + result = notification_service._notify_user_by_email(recording) + + assert result is False + assert f"No owner found for recording {recording.id}" in caplog.text + + +def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog): + """Test email notification when an exception occurs.""" + + recording = factories.RecordingFactory(room__name="Conference Room A") + owner = factories.UserRecordingAccessFactory( + recording=recording, role=models.RoleChoices.OWNER + ).user + + notification_service = NotificationService() + + with mock.patch( + "core.recording.event.notification.send_mail", + side_effect=smtplib.SMTPException("SMTP Error"), + ) as mock_send_mail: + result = notification_service._notify_user_by_email(recording) + + assert result is False + mock_send_mail.assert_called_once() + assert f"notification to ['{owner.email}'] was not sent" in caplog.text diff --git a/src/backend/locale/en_US/LC_MESSAGES/django.po b/src/backend/locale/en_US/LC_MESSAGES/django.po index f6741bfa..04c9a96c 100644 --- a/src/backend/locale/en_US/LC_MESSAGES/django.po +++ b/src/backend/locale/en_US/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-14 19:00+0000\n" +"POT-Creation-Date: 2025-04-14 19:04+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -309,6 +309,10 @@ msgstr "" msgid "Invalid token" msgstr "" +#: core/recording/event/notification.py:81 +msgid "Your recording is ready" +msgstr "" + #: core/templates/mail/html/invitation.html:160 #: core/templates/mail/text/invitation.txt:3 msgid "La Suite Numérique" @@ -394,6 +398,63 @@ msgstr "" msgid "The La Suite Numérique Team" msgstr "" +#: core/templates/mail/html/screen_recording.html:159 +#: core/templates/mail/text/screen_recording.txt:3 +msgid "Logo email" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:188 +#: core/templates/mail/text/screen_recording.txt:6 +msgid "Your recording is ready!" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:195 +#: core/templates/mail/text/screen_recording.txt:8 +#, python-format +msgid "" +" Your recording of \"%(room_name)s\" on %(recording_date)s at " +"%(recording_time)s is now ready to download. " +msgstr "" + +#: core/templates/mail/html/screen_recording.html:201 +#: core/templates/mail/text/screen_recording.txt:10 +msgid "To keep this recording permanently:" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:203 +#: core/templates/mail/text/screen_recording.txt:12 +msgid "Click the \\" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:204 +#: core/templates/mail/text/screen_recording.txt:13 +msgid "Use the \\" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:205 +#: core/templates/mail/text/screen_recording.txt:14 +msgid "Save the file to your preferred location" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:216 +#: core/templates/mail/text/screen_recording.txt:16 +msgid "Open" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:225 +#: core/templates/mail/text/screen_recording.txt:18 +#, python-format +msgid "" +" If you have any questions or need assistance, please contact our support " +"team at %(support_email)s. " +msgstr "" + +#: core/templates/mail/html/screen_recording.html:240 +#: core/templates/mail/text/screen_recording.txt:22 +#, python-format +msgid " Thank you for using %(brandname)s. " +msgstr "" + #: core/templates/mail/text/invitation.txt:8 msgid "Welcome to Meet" msgstr "" diff --git a/src/backend/locale/fr_FR/LC_MESSAGES/django.po b/src/backend/locale/fr_FR/LC_MESSAGES/django.po index f6741bfa..04c9a96c 100644 --- a/src/backend/locale/fr_FR/LC_MESSAGES/django.po +++ b/src/backend/locale/fr_FR/LC_MESSAGES/django.po @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-04-14 19:00+0000\n" +"POT-Creation-Date: 2025-04-14 19:04+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -309,6 +309,10 @@ msgstr "" msgid "Invalid token" msgstr "" +#: core/recording/event/notification.py:81 +msgid "Your recording is ready" +msgstr "" + #: core/templates/mail/html/invitation.html:160 #: core/templates/mail/text/invitation.txt:3 msgid "La Suite Numérique" @@ -394,6 +398,63 @@ msgstr "" msgid "The La Suite Numérique Team" msgstr "" +#: core/templates/mail/html/screen_recording.html:159 +#: core/templates/mail/text/screen_recording.txt:3 +msgid "Logo email" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:188 +#: core/templates/mail/text/screen_recording.txt:6 +msgid "Your recording is ready!" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:195 +#: core/templates/mail/text/screen_recording.txt:8 +#, python-format +msgid "" +" Your recording of \"%(room_name)s\" on %(recording_date)s at " +"%(recording_time)s is now ready to download. " +msgstr "" + +#: core/templates/mail/html/screen_recording.html:201 +#: core/templates/mail/text/screen_recording.txt:10 +msgid "To keep this recording permanently:" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:203 +#: core/templates/mail/text/screen_recording.txt:12 +msgid "Click the \\" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:204 +#: core/templates/mail/text/screen_recording.txt:13 +msgid "Use the \\" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:205 +#: core/templates/mail/text/screen_recording.txt:14 +msgid "Save the file to your preferred location" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:216 +#: core/templates/mail/text/screen_recording.txt:16 +msgid "Open" +msgstr "" + +#: core/templates/mail/html/screen_recording.html:225 +#: core/templates/mail/text/screen_recording.txt:18 +#, python-format +msgid "" +" If you have any questions or need assistance, please contact our support " +"team at %(support_email)s. " +msgstr "" + +#: core/templates/mail/html/screen_recording.html:240 +#: core/templates/mail/text/screen_recording.txt:22 +#, python-format +msgid " Thank you for using %(brandname)s. " +msgstr "" + #: core/templates/mail/text/invitation.txt:8 msgid "Welcome to Meet" msgstr "" diff --git a/src/backend/meet/settings.py b/src/backend/meet/settings.py index 57d076c8..dd867a89 100755 --- a/src/backend/meet/settings.py +++ b/src/backend/meet/settings.py @@ -318,6 +318,10 @@ class Base(Configuration): EMAIL_USE_TLS = values.BooleanValue(False) EMAIL_USE_SSL = values.BooleanValue(False) EMAIL_FROM = values.Value("from@example.com") + EMAIL_BRAND_NAME = values.Value(None) + EMAIL_SUPPORT_EMAIL = values.Value(None) + EMAIL_LOGO_IMG = values.Value(None) + EMAIL_DOMAIN = values.Value(None) AUTH_USER_MODEL = "core.User" @@ -481,6 +485,9 @@ class Base(Configuration): SUMMARY_SERVICE_API_TOKEN = values.Value( None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None ) + SCREEN_RECORDING_BASE_URL = values.Value( + None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None + ) # Marketing and communication settings SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue( diff --git a/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl b/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl index 72bb8ad4..306e2e20 100644 --- a/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl +++ b/src/helm/env.d/dev-keycloak/values.meet.yaml.gotmpl @@ -16,6 +16,10 @@ backend: DJANGO_EMAIL_HOST: "mailcatcher" DJANGO_EMAIL_PORT: 1025 DJANGO_EMAIL_USE_SSL: False + DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique" + DJANGO_EMAIL_SUPPORT_EMAIL: "test@yopmail.com" + DJANGO_EMAIL_LOGO_IMG: https://meet.127.0.0.1.nip.io/assets/logo-suite-numerique.png + DJANGO_EMAIL_DOMAIN: https://meet.127.0.0.1.nip.io OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token @@ -61,6 +65,7 @@ backend: RECORDING_STORAGE_EVENT_TOKEN: password SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/ SUMMARY_SERVICE_API_TOKEN: password + SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings SSL_CERT_FILE: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem diff --git a/src/helm/env.d/dev/values.meet.yaml.gotmpl b/src/helm/env.d/dev/values.meet.yaml.gotmpl index b674a864..2e4d4ac5 100644 --- a/src/helm/env.d/dev/values.meet.yaml.gotmpl +++ b/src/helm/env.d/dev/values.meet.yaml.gotmpl @@ -32,6 +32,10 @@ backend: DJANGO_EMAIL_HOST: "mailcatcher" DJANGO_EMAIL_PORT: 1025 DJANGO_EMAIL_USE_SSL: False + DJANGO_EMAIL_BRAND_NAME: "La Suite Numérique" + DJANGO_EMAIL_SUPPORT_EMAIL: "test@yopmail.com" + DJANGO_EMAIL_LOGO_IMG: https://meet.127.0.0.1.nip.io/assets/logo-suite-numerique.png + DJANGO_EMAIL_DOMAIN: https://meet.127.0.0.1.nip.io OIDC_OP_JWKS_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/jwks OIDC_OP_AUTHORIZATION_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/authorize OIDC_OP_TOKEN_ENDPOINT: https://fca.integ01.dev-agentconnect.fr/api/v2/token @@ -83,6 +87,7 @@ backend: RECORDING_STORAGE_EVENT_TOKEN: password SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/ SUMMARY_SERVICE_API_TOKEN: password + SCREEN_RECORDING_BASE_URL: https://meet.127.0.0.1.nip.io/recordings SIGNUP_NEW_USER_TO_MARKETING_EMAIL: True BREVO_API_KEY: secretKeyRef: diff --git a/src/mail/mjml/screen_recording.mjml b/src/mail/mjml/screen_recording.mjml new file mode 100644 index 00000000..d0a04b2b --- /dev/null +++ b/src/mail/mjml/screen_recording.mjml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + +

{% trans "Your recording is ready!"%}

+
+ + + {% blocktrans %} + Your recording of "{{room_name}}" on {{recording_date}} at {{recording_time}} is now ready to download. + {% endblocktrans %} + + +

{% trans "To keep this recording permanently:" %}

+
    +
  1. {% trans "Click the \"Open\" button below" %}
  2. +
  3. {% trans "Use the \"Download\" button in the interface" %}
  4. +
  5. {% trans "Save the file to your preferred location" %}
  6. +
+
+ + {% trans "Open"%} + + + {% blocktrans %} + If you have any questions or need assistance, please contact our support team at {{support_email}}. + {% endblocktrans %} + + + + +

+ {% blocktrans %} + Thank you for using {{brandname}}. + {% endblocktrans %} +

+
+
+
+
+
+