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/