(backend) add email notifications for screen recordings

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.
This commit is contained in:
lebaudantoine
2025-04-14 18:48:50 +02:00
committed by aleb_the_flash
parent 88b7a7dc58
commit b7d964db56
9 changed files with 441 additions and 6 deletions

View File

@@ -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

View File

@@ -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."""

View File

@@ -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

View File

@@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ""

View File

@@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ""

View File

@@ -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(

View File

@@ -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

View File

@@ -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:

View File

@@ -0,0 +1,67 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="5px 25px 0px 25px">
<mj-section css-class="wrapper-logo">
<mj-column>
<mj-image
align="center"
src="{{logo_img}}"
width="320px"
alt="{%trans 'Logo email' %}"
/>
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="0px 20px 60px 20px">
<mj-column>
<mj-text align="center">
<h1>{% trans "Your recording is ready!"%}</h1>
</mj-text>
<!-- Main Message -->
<mj-text>
{% blocktrans %}
Your recording of "{{room_name}}" on {{recording_date}} at {{recording_time}} is now ready to download.
{% endblocktrans %}
</mj-text>
<mj-text>
<p>{% trans "To keep this recording permanently:" %}</p>
<ol>
<li>{% trans "Click the \"Open\" button below" %}</li>
<li>{% trans "Use the \"Download\" button in the interface" %}</li>
<li>{% trans "Save the file to your preferred location" %}</li>
</ol>
</mj-text>
<mj-button
href="{{link}}"
background-color="#000091"
color="white"
padding-bottom="30px"
>
{% trans "Open"%}
</mj-button>
<mj-text>
{% blocktrans %}
If you have any questions or need assistance, please contact our support team at {{support_email}}.
{% endblocktrans %}
</mj-text>
<mj-divider
border-width="1px"
border-style="solid"
border-color="#DDDDDD"
width="30%"
align="center"
/>
<!-- Signature -->
<mj-text>
<p>
{% blocktrans %}
Thank you for using {{brandname}}.
{% endblocktrans %}
</p>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
</mjml>