Compare commits

..

4 Commits

Author SHA1 Message Date
lebaudantoine 6f1414b34e 🔖(minor) bump release to 1.35.0 2026-10-13 09:34:46 +02:00
lebaudantoine 0194f7f3f3 ⚡️(backend) send recording notification emails from a Celery task
Emails were rendered and sent inline in the LiveKit webhook, so a
slow SMTP server blocked the request and a transient failure lost
the email. Delivery now runs in a worker, with retries and backoff.

The task is generic (template, subject, recipients, context,
language) so other emails can reuse it. The subject is translated
in the worker, and dates are formatted per owner before queuing,
so the arguments stay JSON-serializable.

Without Celery, the task still runs synchronously. Success now
means "queued", not "delivered".
2026-10-11 23:37:00 +02:00
Roee Sivan 0bde103911 🔧(project) silence gitlint regex-style deprecation warning
gitlint prints a warning for each ignore-by-title rule because it will switch
its regexes from match (start of the string) to search (anywhere) semantics.

Both ignore-by-title regexes are already anchored with ^, so they match the
same titles either way. Setting regex-style-search opts in to the new behavior
now and removes the warning.

Fixes #96
2026-10-11 23:34:02 +02:00
lebaudantoine c7c41abcb5 ✨(frontend) let users pick the recording encoding
Add a Recording tab to the settings dialog with two selectors, a
content profile and a resolution, building on the per-recording
encoding API.

The goal is to let an instance default to a cheaper profile and
resolution to save egress CPU, while users who really need a smooth
HD recording can still opt in. Most users only need a recording of
acceptable quality, so the default stays cheap and configuring it is
optional.

Profiles are labelled by content ("Talking heads", "Presentations and
text", ...) rather than by fps or bitrate: users know what they are
about to record, not which encoder settings fit it. Each profile shows
a one-line description of its trade-off, so picking one requires no
technical knowledge.

The panel gets a third row, matching the meeting language row of the
transcript panel. It is a shortcut to the settings, without adding
mental load about how to configure a recording. It reads as a short
statement with a trailing "Change" link, like the informational rows
above it, and stays on a single line in every locale: the statement
truncates if a translation gets too long, the link never wraps.

The selected options are intentionally not shown in the panel.
Several users can access it, and each would see their own local
configuration while the running recording may use another user's
encoding, which would be misleading. The issue is milder in the
settings tab, which is frozen while a recording is starting or in
progress.

The selectors are disabled from the moment a start request is sent
until the recording is saved, for both screen recordings and
transcripts. Settings are sent once, when the recording starts, so
changing them afterwards would have no effect and suggest otherwise.

Choices are persisted in localStorage to keep things simple,
consistent with the meeting language, which is already stored
client-side.

The backend now exposes the available resolutions and profiles, and
their defaults, in the frontend configuration, so the UI follows
whatever the operator configures instead of hardcoding values. Stored
choices are checked against it before being sent: a profile removed by
the operator falls back to the instance default instead of being
rejected by the API. No encoding is sent when per-recording encoding
is disabled, as the backend would reject it. Operator-defined values
without a translation are shown by their raw key.

Alternatives considered:

- Storing the preference in room metadata: it would turn a pre-start,
  personal choice into shared editable state, requiring a write
  endpoint, permission checks and handling of concurrent edits.
- Notifying participants of the chosen encoding: notifications are
  one-off, so users who join or reload after the recording started
  would miss them, and the information is noise for most participants.

Next steps:

This is a deliberately small first iteration, meant to deliver most
of the value and evolve with feedback and usage. Possible next steps:
- persist the preferences in the backend instead of localStorage
- store the encoding chosen for a recording on the room (or on a
  session, if such a model is introduced) and read it from there
  rather than from user settings, so every user sees the same value
2026-10-11 23:02:57 +02:00
22 changed files with 278 additions and 55 deletions
+3
View File
@@ -22,6 +22,9 @@
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
extra-path=gitlint/
# Use "search" semantics for regexes (gitlint's future default)
regex-style-search=True
# [title-max-length]
# line-length=80
+3
View File
@@ -8,10 +8,13 @@ and this project adheres to
## [Unreleased]
## [1.35.0] - 2026-10-13
### Added
- ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task
### Changed
+1
View File
@@ -113,6 +113,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
+1
View File
@@ -357,6 +357,7 @@ These are the environmental options available on meet backend.
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.34.0"
version = "1.35.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.7.0",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.34.0"
version = "1.35.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
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, gettext, override
from django.utils.translation import get_language, gettext, gettext_noop, override
from django.utils.translation import gettext_lazy as _
import aiohttp
import requests
from asgiref.sync import async_to_sync
from celery.exceptions import OperationalError as CeleryOperationalError
from livekit import api as livekit_api
from core import models, utils
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
from core.tasks.mail import send_email
from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__)
@@ -68,10 +68,14 @@ class NotificationService:
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Send an email notification to recording owners when their recording is ready.
Queue 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.
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
this only does one DB query and enqueues one message per owner.
Returns True when every email was queued (or sent, if Celery is disabled).
"""
owner_accesses = (
@@ -88,10 +92,6 @@ class NotificationService:
return False
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_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{get_recording_download_base_url()}/{recording.id}",
@@ -104,37 +104,22 @@ class NotificationService:
# 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses:
user = access.user
language = user.language or get_language()
with override(language):
personalized_context = {
"recording_date": recording.created_at.astimezone(
user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
user.timezone
).strftime("%H:%M"),
**context,
}
msg_html = render_to_string(
"mail/html/screen_recording.html", personalized_context
local_created_at = recording.created_at.astimezone(user.timezone)
try:
send_email.delay(
template="screen_recording",
subject=gettext_noop("Your recording is ready"),
recipients=[user.email],
language=user.language or get_language(),
context={
**context,
"recording_date": local_created_at.strftime("%Y-%m-%d"),
"recording_time": local_created_at.strftime("%H:%M"),
},
)
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = gettext("Your recording is ready") # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
except (smtplib.SMTPException, CeleryOperationalError) as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
+2
View File
@@ -2,8 +2,10 @@
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.tasks.mail import send_email
__all__ = (
"delete_connection_test_room",
"process_file_deletion",
"send_email",
)
+74
View File
@@ -0,0 +1,74 @@
"""Send mail using celery task."""
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, gettext, override
from core.tasks._task import task
logger = logging.getLogger(__name__)
def get_base_email_context() -> dict:
"""Context shared by every email template (branding, support, domain)."""
return {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
}
@task(
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
retry_backoff=True,
retry_backoff_max=600,
retry_jitter=True,
max_retries=5,
)
def send_email(
*,
template: str,
subject: str,
recipients: list[str],
context: dict | None = None,
language: str | None = None,
):
"""Render a mail template and send it.
Generic on purpose: any feature can reuse it with its own template and context.
Args:
template: Template base name, e.g. "screen_recording". Both
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
call site; it is translated here, in ``language``.
recipients: Email addresses, all receiving the same rendered message.
context: Template context. Must be JSON-serializable (str, int, list, dict…):
pre-format dates, cast UUIDs to str. Merged over the base branding context.
language: Language to render the email in. Defaults to the active language.
Raises:
OSError: SMTP or network failure, after retries are exhausted (or
immediately when Celery is disabled and the call runs synchronously).
"""
full_context = {**get_base_email_context(), **(context or {})}
with override(language or get_language()):
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
translated_subject = gettext(subject)
send_mail(
translated_subject,
msg_plain,
settings.EMAIL_FROM,
recipients,
html_message=msg_html,
fail_silently=False,
)
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
@@ -12,6 +12,7 @@ from unittest import mock
from django.contrib.sites.models import Site
import pytest
from celery.exceptions import OperationalError as CeleryOperationalError
from core import factories, models
from core.analytics import UserFeatureFlag
@@ -142,7 +143,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is True
@@ -237,7 +238,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
notification_service = NotificationService()
with mock.patch(
"core.recording.event.notification.send_mail",
"core.tasks.mail.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
@@ -247,6 +248,60 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
assert "notification could not be sent:" in caplog.text
def test_notify_user_by_email_queues_generic_task(settings):
"""One generic send_email task is queued per owner, with serializable args."""
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
settings.RECORDING_EXPIRATION_DAYS = 7
recording = factories.RecordingFactory(room__name="Conference Room A")
recording.created_at = datetime.datetime(
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
)
user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=user
)
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
result = notification_service._notify_user_by_email(recording)
assert result is True
mock_delay.assert_called_once_with(
template="screen_recording",
subject="Your recording is ready",
recipients=["franc@test.com"],
language="fr-fr",
context={
"room_name": "Conference Room A",
"recording_expiration_days": 7,
"link": f"https://acme.com/recordings/{recording.id}",
"recording_date": "2023-05-15",
"recording_time": "16:30",
},
)
# Celery's JSON serializer must accept every argument
json.dumps(mock_delay.call_args.kwargs)
def test_notify_user_by_email_broker_unavailable(caplog):
"""A broker outage is reported as a failure without crashing the caller."""
recording = factories.RecordingFactory()
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
with mock.patch(
"core.recording.event.notification.send_email.delay",
side_effect=CeleryOperationalError("broker down"),
):
result = notification_service._notify_user_by_email(recording)
assert result is False
assert "notification could not be sent: broker down" in caplog.text
@mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object(
+98
View File
@@ -0,0 +1,98 @@
"""Tests for the generic send_email task."""
# pylint: disable=unused-argument,redefined-outer-name
import smtplib
from unittest import mock
from django.core.mail import get_connection
from django.utils.translation import gettext_noop
import pytest
from core.tasks.mail import send_email
@pytest.fixture
def email_settings(settings):
"""Branding settings shared by every email."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_DOMAIN = "acme.com"
settings.EMAIL_FROM = "notifications@acme.com"
return settings
@mock.patch("core.tasks.mail.send_mail")
@mock.patch("core.tasks.mail.render_to_string", side_effect=["<p>html</p>", "text"])
def test_send_email_renders_template_and_sends(
mock_render, mock_send_mail, email_settings
):
"""Both template variants are rendered with base + caller context, then sent."""
send_email(
template="some_template",
subject="Any subject",
recipients=["a@test.com", "b@test.com"],
context={"foo": "bar", "brandname": "Overridden"},
)
expected_context = {
"brandname": "Overridden",
"support_email": "support@acme.com",
"logo_img": "https://acme.com/logo",
"domain": "acme.com",
"foo": "bar",
}
assert mock_render.call_args_list == [
mock.call("mail/html/some_template.html", expected_context),
mock.call("mail/text/some_template.txt", expected_context),
]
mock_send_mail.assert_called_once_with(
"Any subject",
"text",
"notifications@acme.com",
["a@test.com", "b@test.com"],
html_message="<p>html</p>",
fail_silently=False,
)
@mock.patch("core.tasks.mail.send_mail")
def test_send_email_translates_subject_in_requested_language(
mock_send_mail, email_settings
):
"""The subject msgid is translated in the worker, in the recipient language."""
send_email(
template="screen_recording",
subject=gettext_noop("Your recording is ready"),
recipients=["franc@test.com"],
context={"room_name": "Room", "link": "https://acme.com/r/1"},
language="fr-fr",
)
subject, body, *_ = mock_send_mail.call_args[0]
assert subject == "Votre enregistrement est prêt"
assert "Votre enregistrement est prêt !" in body
@mock.patch(
"core.tasks.mail.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
)
@mock.patch("core.tasks.mail.render_to_string", return_value="content")
def test_send_email_raises_on_smtp_error(mock_render, mock_send_mail, email_settings):
"""SMTP errors propagate so Celery can retry the task."""
with pytest.raises(smtplib.SMTPException):
send_email(
template="some_template",
subject="Any subject",
recipients=["a@test.com"],
)
def test_smtp_backend_uses_email_timeout(settings):
"""A stalled SMTP server cannot hold the worker forever."""
settings.EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
settings.EMAIL_TIMEOUT = 12
assert get_connection().timeout == 12
+1
View File
@@ -508,6 +508,7 @@ class Base(Configuration):
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_TIMEOUT = values.PositiveIntegerValue(30)
EMAIL_FROM = values.Value("from@example.com")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None)
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.34.0"
version = "1.35.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1297,7 +1297,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.34.0"
version = "1.35.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.34.0",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.34.0",
"version": "1.35.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.34.0",
"version": "1.35.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.34.0",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.34.0",
"version": "1.35.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.34.0",
"version": "1.35.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.34.0",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.34.0",
"version": "1.35.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.34.0",
"version": "1.35.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.34.0"
version = "1.35.0"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]>=0.105.0",
+1 -1
View File
@@ -1484,7 +1484,7 @@ wheels = [
[[package]]
name = "summary"
version = "1.34.0"
version = "1.35.0"
source = { editable = "." }
dependencies = [
{ name = "boto3" },