diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 32bf33606..75efbe151 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -71,7 +71,7 @@ from core.services.search_indexers import ( from core.services.yhub_services import YHubError, YHubService from core.tasks.access import reset_service_connections_in_cascade from core.tasks.documents import sync_service_deletions_in_cascade -from core.tasks.mail import send_ask_for_access_mail +from core.tasks.mail import send_ask_for_access_mail, send_mention_notification_mail from core.tasks.search import trigger_batch_document_indexer from core.utils.analytics import PosthogEventName, posthog_capture from core.utils.dicts import lowercase_keys @@ -1881,9 +1881,10 @@ class DocumentViewSet( def mention(self, request, *args, **kwargs): """Mention a user on the document and notify them by email. - The mention record is always created; the email notification is - suppressed when the same user was already notified in the same context - (document body or thread) within the cooldown period. + The mention record is created synchronously; the email notification is + sent asynchronously by a Celery task, which suppresses it when the same + user was already notified in the same context (document body or thread) + within the cooldown period. """ # Check permissions first document = self.get_object() @@ -1895,19 +1896,7 @@ class DocumentViewSet( serializer.is_valid(raise_exception=True) mention = serializer.save(document=document, mentioned_by_user=request.user) - mention.notify() - - posthog_capture( - PosthogEventName.MENTION_CREATED, - request.user, - { - "mention_id": str(mention.id), - "mentioned_user_id": str(mention.mentioned_user_id), - "thread_id": str(mention.thread_id) if mention.thread_id else None, - "notified": mention.notified_at is not None, - }, - document=document, - ) + send_mention_notification_mail.delay(str(mention.id)) return drf.response.Response( serializer.data, status=drf.status.HTTP_201_CREATED diff --git a/src/backend/core/tasks/mail.py b/src/backend/core/tasks/mail.py index 483c96148..51602d969 100644 --- a/src/backend/core/tasks/mail.py +++ b/src/backend/core/tasks/mail.py @@ -3,6 +3,7 @@ from django.conf import settings from core import models +from core.utils.analytics import PosthogEventName, posthog_capture from impress.celery_app import app @@ -22,3 +23,25 @@ def send_ask_for_access_mail(ask_for_access_id): access.user.email, access.user.language or settings.LANGUAGE_CODE, ) + + +@app.task +def send_mention_notification_mail(mention_id): + """Notify a mentioned user by email, outside the request/response cycle.""" + mention = models.Mention.objects.select_related( + "document", "mentioned_user", "mentioned_by_user" + ).get(id=mention_id) + + notified = mention.notify() + + posthog_capture( + PosthogEventName.MENTION_CREATED, + mention.mentioned_by_user, + { + "mention_id": str(mention.id), + "mentioned_user_id": str(mention.mentioned_user_id), + "thread_id": str(mention.thread_id) if mention.thread_id else None, + "notified": notified, + }, + document=mention.document, + ) diff --git a/src/backend/core/tests/documents/test_api_documents_mention.py b/src/backend/core/tests/documents/test_api_documents_mention.py index b1fa23c5d..d066f7a83 100644 --- a/src/backend/core/tests/documents/test_api_documents_mention.py +++ b/src/backend/core/tests/documents/test_api_documents_mention.py @@ -128,7 +128,9 @@ def test_api_documents_mention_authenticated_success(role): "mentioned_user_id": str(mentioned_user.id), "mentioned_by_user_id": str(user.id), "created_at": mention.created_at.isoformat().replace("+00:00", "Z"), - "notified_at": mention.notified_at.isoformat().replace("+00:00", "Z"), + # The email is sent asynchronously, so the create response carries no + # notification timestamp even though the stored mention is notified. + "notified_at": None, } assert len(mail.outbox) == 1 @@ -352,7 +354,7 @@ def test_api_documents_mention_cooldown_same_context(): response = client.post(f"/api/v1.0/documents/{document.id!s}/mention/", payload) assert response.status_code == 201 - assert response.json()["notified_at"] is not None + assert models.Mention.objects.get(anchor_id="block-1").notified_at is not None assert len(mail.outbox) == 1 response = client.post( @@ -360,7 +362,7 @@ def test_api_documents_mention_cooldown_same_context(): {"anchor_id": "block-2", "mentioned_user_id": str(mentioned_user.id)}, ) assert response.status_code == 201 - assert response.json()["notified_at"] is None + assert models.Mention.objects.get(anchor_id="block-2").notified_at is None assert len(mail.outbox) == 1 assert models.Mention.objects.count() == 2 @@ -390,7 +392,9 @@ def test_api_documents_mention_cooldown_distinct_contexts(): payload["thread_id"] = thread_id response = client.post(f"/api/v1.0/documents/{document.id!s}/mention/", payload) assert response.status_code == 201 - assert response.json()["notified_at"] is not None + assert ( + models.Mention.objects.get(anchor_id=f"block-{i}").notified_at is not None + ) assert len(mail.outbox) == 3 @@ -404,7 +408,7 @@ def test_api_documents_mention_cooldown_distinct_contexts(): }, ) assert response.status_code == 201 - assert response.json()["notified_at"] is None + assert models.Mention.objects.get(anchor_id="block-4").notified_at is None assert len(mail.outbox) == 3 # The cooldown should apply per mentioned user @@ -419,7 +423,7 @@ def test_api_documents_mention_cooldown_distinct_contexts(): }, ) assert response.status_code == 201 - assert response.json()["notified_at"] is not None + assert models.Mention.objects.get(anchor_id="block-5").notified_at is not None assert len(mail.outbox) == 4 @@ -453,7 +457,7 @@ def test_api_documents_mention_cooldown_expired(settings): {"anchor_id": "block-2", "mentioned_user_id": str(mentioned_user.id)}, ) assert response.status_code == 201 - assert response.json()["notified_at"] is not None + assert models.Mention.objects.get(anchor_id="block-2").notified_at is not None assert len(mail.outbox) == 2 @@ -467,7 +471,7 @@ def test_api_documents_mention_cooldown_only_considers_notified_mentions(): factories.UserDocumentAccessFactory(document=document, user=user, role="commenter") mentioned_user = factories.UserFactory() factories.UserDocumentAccessFactory(document=document, user=mentioned_user) - factories.MentionFactory( + existing_mention = factories.MentionFactory( document=document, mentioned_user=mentioned_user, mentioned_by_user=user, @@ -482,7 +486,8 @@ def test_api_documents_mention_cooldown_only_considers_notified_mentions(): ) assert response.status_code == 201 - assert response.json()["notified_at"] is not None + new_mention = models.Mention.objects.exclude(pk=existing_mention.pk).get() + assert new_mention.notified_at is not None assert len(mail.outbox) == 1