⚡️(backend) send mention notification email asynchronously

Move the mention email off the request/response cycle into a Celery task
so the SMTP round-trip no longer delays the API response.

Signed-off-by: BOUKERFA Mohamed El Amine <boukerfa.ma@gmail.com>
This commit is contained in:
BOUKERFA Mohamed El Amine
2026-09-29 17:40:37 +02:00
committed by Manuel Raynaud
parent f18b6c0e5d
commit 28cb6b7251
3 changed files with 43 additions and 26 deletions
+6 -17
View File
@@ -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
+23
View File
@@ -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,
)
@@ -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