From 521e72d1cc72a7219759836ad7acfb6a22ae71f2 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Sat, 10 Oct 2026 00:55:47 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9A=A1=EF=B8=8F(backend)=20send=20recording?= =?UTF-8?q?=20notification=20emails=20from=20a=20Celery=20task?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". --- CHANGELOG.md | 1 + docs/installation/compose.md | 1 + docs/installation/kubernetes.md | 1 + .../core/recording/event/notification.py | 61 +++++------- src/backend/core/tasks/__init__.py | 2 + src/backend/core/tasks/mail.py | 74 ++++++++++++++ .../recording/event/test_notification.py | 59 ++++++++++- src/backend/core/tests/tasks/test_mail.py | 98 +++++++++++++++++++ src/backend/meet/settings.py | 1 + 9 files changed, 258 insertions(+), 40 deletions(-) create mode 100644 src/backend/core/tasks/mail.py create mode 100644 src/backend/core/tests/tasks/test_mail.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce552242..16bf673e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to - ✨(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 diff --git a/docs/installation/compose.md b/docs/installation/compose.md index 75f11c81f..411ed1f94 100644 --- a/docs/installation/compose.md +++ b/docs/installation/compose.md @@ -113,6 +113,7 @@ DJANGO_EMAIL_FROM= #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= # e.g. "La Suite Numérique" diff --git a/docs/installation/kubernetes.md b/docs/installation/kubernetes.md index 414f3c322..008cac6db 100644 --- a/docs/installation/kubernetes.md +++ b/docs/installation/kubernetes.md @@ -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 | | diff --git a/src/backend/core/recording/event/notification.py b/src/backend/core/recording/event/notification.py index 81624b1a3..9d0c76028 100644 --- a/src/backend/core/recording/event/notification.py +++ b/src/backend/core/recording/event/notification.py @@ -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 diff --git a/src/backend/core/tasks/__init__.py b/src/backend/core/tasks/__init__.py index b433db5fc..9c6e29133 100644 --- a/src/backend/core/tasks/__init__.py +++ b/src/backend/core/tasks/__init__.py @@ -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", ) diff --git a/src/backend/core/tasks/mail.py b/src/backend/core/tasks/mail.py new file mode 100644 index 000000000..ecec4c272 --- /dev/null +++ b/src/backend/core/tasks/mail.py @@ -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/