Skip to content

✨(backend) add user mentions with email notifications#2447

Open
maboukerfa wants to merge 5 commits into
suitenumerique:mainfrom
maboukerfa:feature/backend-mention
Open

✨(backend) add user mentions with email notifications#2447
maboukerfa wants to merge 5 commits into
suitenumerique:mainfrom
maboukerfa:feature/backend-mention

Conversation

@maboukerfa

@maboukerfa maboukerfa commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Purpose

Add a mention feature: when a user is mentioned in a document body or a comment thread, they receive an email notification linking straight to the anchor of the mention.

Proposal

  • Make the document access list visible to all collaborators
  • Add a POST /api/v1.0/documents/{id}/mention/ endpoint that records the mention and sends an email notification to the mentioned user.
  • Send the mention notification email asynchronously via a Celery task to keep the request fast
  • Throttle the mention endpoint to protect against spam / abuse

Notes

  • Email notifications are suppressed when the same user was already notified in
    the same context (document body or thread) within the cooldown window
    (MENTION_NOTIFICATION_COOLDOWN_MINUTES, default 15).

Email content

Subject :You were mentioned in the document mentions test
Screenshot 2026-06-20 at 21 06 56
Subject :You were mentioned in a comment on the document mentions test
Screenshot 2026-06-20 at 21 08 35

Return all accesses on the document and its ancestors to any
user with access, keeping the limited user details serializer
and adding the user id to it, so that collaborators allowed
to comment can identify and mention each other.

Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
We need this so mention notification emails can replace the default
share link with a link to the anchor of the mention.
Allow users with at least the commenter role on a document to mention
other collaborators in the document body or in a comment thread via
POST /documents/{id}/mention/. The mentioned user must already have
access to the document.

The mention record is always created, and the mentioned user is
notified by email with a link to the anchor, unless a notification
was already sent in the same context (document body or specific
thread) within a configurable cooldown period (15 minutes by default).

Signed-off-by: Mohamed El Amine BOUKERFA <boukerfa.ma@gmail.com>
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>
Add a dedicated "mention" throttle scope limiting calls
to the document mention endpoint to 30 requests per minute.

Signed-off-by: BOUKERFA Mohamed El Amine <boukerfa.ma@gmail.com>
@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

This PR adds a user mention feature to the document collaboration backend. A new Mention Django model tracks who mentioned whom in a document body or comment thread, with cooldown logic to suppress repeated notification emails within a configurable window. A new POST /documents/{id}/mention/ endpoint is added to DocumentViewSet, secured with a dedicated throttle scope. Notification emails are dispatched asynchronously via a new Celery task that also emits a Posthog analytics event. UserLightSerializer is extended to expose the user id field. DocumentAccessViewSet.list is changed to return all collaborator accesses rather than filtering to privileged roles for non-privileged requesters. Supporting changes include a new migration, factory, serializer, settings entries, and a comprehensive test suite.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • jmaupetit
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main feature being added: user mentions with email notifications in the backend.
Docstring Coverage ✅ Passed Docstring coverage is 98.55% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description clearly explains the mention feature implementation, including its purpose, proposal, email notification behavior, and cooldown logic.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/backend/core/models.py (1)

1439-1449: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not mark notified_at when email delivery fails.

Document.send_email() swallows SMTPException (Line 1439-1449), but Mention.notify() still sets notified_at (Line 2159-2160). That records false delivery and suppresses future retries.

Proposed fix
 def send_email(self, subject, emails, context=None, language=None):
@@
             try:
                 send_mail(
@@
                     fail_silently=False,
                 )
             except smtplib.SMTPException as exception:
                 logger.error("invitation to %s was not sent: %s", emails, exception)
+                return False
+        return True
@@
-        self.document.send_email(subject, [user.email], context, language)
+        sent = self.document.send_email(subject, [user.email], context, language)
+        if not sent:
+            return False
 
         self.notified_at = timezone.now()
         self.save(update_fields=["notified_at", "updated_at"])

Also applies to: 2157-2160

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/models.py` around lines 1439 - 1449, The
`Document.send_email()` method catches `smtplib.SMTPException` and logs it but
does not re-raise the exception or communicate failure to the caller. This
allows `Mention.notify()` to unconditionally set the `notified_at` field even
when email delivery fails, falsely marking notifications as sent and preventing
retries. Fix this by either re-raising the exception after logging it in the
`send_email()` method, or returning a success/failure status that
`Mention.notify()` can check before setting `notified_at`. Ensure `notified_at`
is only set when email delivery actually succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/backend/core/models.py`:
- Around line 2125-2160: The notification sending logic has a race condition
where multiple concurrent workers can pass the is_notification_in_cooldown()
check simultaneously before either saves the notified_at field, resulting in
duplicate emails being sent. To fix this, use database-level locking to make the
cooldown check and the notified_at update atomic. Fetch the current object
instance using select_for_update() before performing the
is_notification_in_cooldown() check to ensure only one worker can proceed at a
time, preventing race conditions between the cooldown validation and the
notified_at timestamp update in the save() call.
- Line 2112: The cooldown check at line 2112 uses `created_at__gt` to filter
notifications, but cooldown semantics should be based on when notifications were
actually sent, not when they were created. With async workers, there can be a
delay between creation and sending, causing cooldown suppression to be bypassed
prematurely. Replace the `created_at__gt` field with the appropriate sent or
delivered timestamp field (such as `sent_at` or similar) to ensure cooldown
decisions are based on actual notification delivery times rather than creation
times.

In `@src/backend/core/tasks/mail.py`:
- Line 35: The mention.notify() call has a race condition where concurrent tasks
can simultaneously check cooldown, both see no prior notification, and send
duplicate emails. Before calling mention.notify(), implement a locking mechanism
on the mention context or use an atomic idempotency key to serialize the
cooldown decision. Additionally, add a check to skip mentions that already have
a notified_at timestamp set before executing notify() to prevent redundant
processing of already-notified mentions.
- Around line 31-33: The query in the mail task uses `.get()` on the Mention
model which will raise an exception if the mention has been deleted before the
worker processes the queued task (since Mention objects are cascade-deleted).
Replace the `.get(id=mention_id)` call with `.filter(id=mention_id).first()` to
gracefully return None when the mention no longer exists, then add a check to
return early or skip processing if the mention is None instead of crashing on a
missing database row.
- Around line 40-43: In the dictionary construction where `mentioned_user_id` is
being set, apply a conditional check similar to the one already used for
`thread_id` to preserve null values in analytics. Instead of always calling
`str(mention.mentioned_user_id)`, conditionally convert it to string only if the
value is not None, otherwise pass None directly. This prevents null
`mentioned_user_id` values from being converted to the literal string "None" in
the PostHog analytics data.

In `@src/backend/core/tests/documents/test_api_documents_mention.py`:
- Around line 516-518: Replace the inline mutation of
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["mention"] with Django's
override_settings context manager to ensure proper cleanup even if the test
exits early. Create a copy of the existing DEFAULT_THROTTLE_RATES dictionary,
modify the mention key in the copy to "3/minute", and wrap the test logic in a
with override_settings() block that merges this modified throttle rates dict
back into REST_FRAMEWORK. Apply this pattern to all occurrences where throttle
rates are being mutated (at the locations mentioned in the review) to maintain
proper test isolation and avoid state leakage.

---

Outside diff comments:
In `@src/backend/core/models.py`:
- Around line 1439-1449: The `Document.send_email()` method catches
`smtplib.SMTPException` and logs it but does not re-raise the exception or
communicate failure to the caller. This allows `Mention.notify()` to
unconditionally set the `notified_at` field even when email delivery fails,
falsely marking notifications as sent and preventing retries. Fix this by either
re-raising the exception after logging it in the `send_email()` method, or
returning a success/failure status that `Mention.notify()` can check before
setting `notified_at`. Ensure `notified_at` is only set when email delivery
actually succeeds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 348f36be-a93d-4b8b-9cf6-6edfbddef963

📥 Commits

Reviewing files that changed from the base of the PR and between 87fbcba and 708363e.

📒 Files selected for processing (18)
  • CHANGELOG.md
  • src/backend/core/api/serializers.py
  • src/backend/core/api/viewsets.py
  • src/backend/core/factories.py
  • src/backend/core/migrations/0033_mention.py
  • src/backend/core/models.py
  • src/backend/core/tasks/mail.py
  • src/backend/core/tests/documents/test_api_document_accesses.py
  • src/backend/core/tests/documents/test_api_documents_comments.py
  • src/backend/core/tests/documents/test_api_documents_mention.py
  • src/backend/core/tests/documents/test_api_documents_retrieve.py
  • src/backend/core/tests/documents/test_api_documents_threads.py
  • src/backend/core/tests/documents/test_api_documents_trashbin.py
  • src/backend/core/tests/serializers/test_user_light_serializer.py
  • src/backend/core/tests/test_models_documents.py
  • src/backend/core/tests/test_models_mentions.py
  • src/backend/core/utils/analytics.py
  • src/backend/impress/settings.py

mentioned_user_id=self.mentioned_user_id,
thread_id=self.thread_id,
notified_at__isnull=False,
created_at__gt=cooldown_start,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use notification timestamp for cooldown decisions.

Line 2112 uses created_at__gt, but cooldown semantics are about sent notifications. With async workers, delayed sends can bypass suppression too early.

Proposed fix
-                created_at__gt=cooldown_start,
+                notified_at__gt=cooldown_start,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
created_at__gt=cooldown_start,
notified_at__gt=cooldown_start,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/models.py` at line 2112, The cooldown check at line 2112
uses `created_at__gt` to filter notifications, but cooldown semantics should be
based on when notifications were actually sent, not when they were created. With
async workers, there can be a delay between creation and sending, causing
cooldown suppression to be bypassed prematurely. Replace the `created_at__gt`
field with the appropriate sent or delivered timestamp field (such as `sent_at`
or similar) to ensure cooldown decisions are based on actual notification
delivery times rather than creation times.

Comment on lines +2125 to +2160
if user is None or not user.email or self.is_notification_in_cooldown():
return False

sender = self.mentioned_by_user
language = (
language or user.language or sender.language or settings.LANGUAGE_CODE
)
sender_name = sender.full_name or sender.email
domain = settings.EMAIL_URL_APP or Site.objects.get_current().domain

with override(language):
title = self.document.title or str(_("Untitled Document"))
if self.thread_id:
subject = _(
"You were mentioned in a comment on the document {title}"
).format(title=title)
message = _(
"{name} mentioned you in a comment on the following document:"
).format(name=sender_name)
else:
subject = _("You were mentioned in the document {title}").format(
title=title
)
message = _("{name} mentioned you in the following document:").format(
name=sender_name
)
context = {
"title": subject,
"message": message,
"link": f"{domain}/docs/{self.document_id}/#{self.anchor_id}",
}

self.document.send_email(subject, [user.email], context, language)

self.notified_at = timezone.now()
self.save(update_fields=["notified_at", "updated_at"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Cooldown check and update are race-prone under concurrent workers.

Two workers can pass is_notification_in_cooldown() before either writes notified_at, causing duplicate emails for the same context.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/models.py` around lines 2125 - 2160, The notification
sending logic has a race condition where multiple concurrent workers can pass
the is_notification_in_cooldown() check simultaneously before either saves the
notified_at field, resulting in duplicate emails being sent. To fix this, use
database-level locking to make the cooldown check and the notified_at update
atomic. Fetch the current object instance using select_for_update() before
performing the is_notification_in_cooldown() check to ensure only one worker can
proceed at a time, preventing race conditions between the cooldown validation
and the notified_at timestamp update in the save() call.

Comment on lines +31 to +33
mention = models.Mention.objects.select_related(
"document", "mentioned_user", "mentioned_by_user"
).get(id=mention_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Handle mentions deleted before the worker runs.

Because Mention is cascade-deleted with its document/thread/sender, a queued task can legitimately find no row and currently fails on .get().

Proposed fix
-    mention = models.Mention.objects.select_related(
-        "document", "mentioned_user", "mentioned_by_user"
-    ).get(id=mention_id)
+    try:
+        mention = models.Mention.objects.select_related(
+            "document", "mentioned_user", "mentioned_by_user"
+        ).get(id=mention_id)
+    except models.Mention.DoesNotExist:
+        return
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/tasks/mail.py` around lines 31 - 33, The query in the mail
task uses `.get()` on the Mention model which will raise an exception if the
mention has been deleted before the worker processes the queued task (since
Mention objects are cascade-deleted). Replace the `.get(id=mention_id)` call
with `.filter(id=mention_id).first()` to gracefully return None when the mention
no longer exists, then add a check to return early or skip processing if the
mention is None instead of crashing on a missing database row.

"document", "mentioned_user", "mentioned_by_user"
).get(id=mention_id)

notified = mention.notify()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Serialize the cooldown decision before sending.

Mention.notify() checks cooldown and then sends/sets notified_at without locking; concurrent tasks in the same document/thread/user context can both see no prior notification and send duplicate emails, bypassing the cooldown. Lock the mention context or use an atomic idempotency key before calling notify(), and also skip already-notified mentions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/tasks/mail.py` at line 35, The mention.notify() call has a
race condition where concurrent tasks can simultaneously check cooldown, both
see no prior notification, and send duplicate emails. Before calling
mention.notify(), implement a locking mechanism on the mention context or use an
atomic idempotency key to serialize the cooldown decision. Additionally, add a
check to skip mentions that already have a notified_at timestamp set before
executing notify() to prevent redundant processing of already-notified mentions.

Comment on lines +40 to +43
{
"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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve null for deleted mentioned users in analytics.

When mentioned_user_id is null, str(mention.mentioned_user_id) emits the literal string "None", mixing UUID strings with a sentinel string in PostHog.

Proposed fix
-            "mentioned_user_id": str(mention.mentioned_user_id),
+            "mentioned_user_id": (
+                str(mention.mentioned_user_id)
+                if mention.mentioned_user_id
+                else None
+            ),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
"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,
{
"mention_id": str(mention.id),
"mentioned_user_id": (
str(mention.mentioned_user_id)
if mention.mentioned_user_id
else None
),
"thread_id": str(mention.thread_id) if mention.thread_id else None,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/tasks/mail.py` around lines 40 - 43, In the dictionary
construction where `mentioned_user_id` is being set, apply a conditional check
similar to the one already used for `thread_id` to preserve null values in
analytics. Instead of always calling `str(mention.mentioned_user_id)`,
conditionally convert it to string only if the value is not None, otherwise pass
None directly. This prevents null `mentioned_user_id` values from being
converted to the literal string "None" in the PostHog analytics data.

Comment on lines +516 to +518
current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["mention"]
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["mention"] = "3/minute"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Harden throttle-rate test setup/teardown to avoid state leakage.

Mutating settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["mention"] inline is fragile if the test exits early. Use override_settings (or try/finally) around a copied throttle map to keep isolation robust.

Proposed refactor
-    current_rate = settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["mention"]
-    settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["mention"] = "3/minute"
+    throttle_rates = dict(settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"])
+    throttle_rates["mention"] = "3/minute"
+    with pytest.raises(Exception):
+        pass
# Example shape
throttle_rates = dict(settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"])
throttle_rates["mention"] = "3/minute"
with override_settings(
    REST_FRAMEWORK={**settings.REST_FRAMEWORK, "DEFAULT_THROTTLE_RATES": throttle_rates}
):
    ...

Also applies to: 540-541, 549-551, 572-573

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/tests/documents/test_api_documents_mention.py` around lines
516 - 518, Replace the inline mutation of
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["mention"] with Django's
override_settings context manager to ensure proper cleanup even if the test
exits early. Create a copy of the existing DEFAULT_THROTTLE_RATES dictionary,
modify the mention key in the copy to "3/minute", and wrap the test logic in a
with override_settings() block that merges this modified throttle rates dict
back into REST_FRAMEWORK. Apply this pattern to all occurrences where throttle
rates are being mutated (at the locations mentioned in the review) to maintain
proper test isolation and avoid state leakage.

@maboukerfa

Copy link
Copy Markdown
Contributor Author

@lunika i made a tradeoff in 9b74a2f by sending the email notification async in a celery job, but it introduces a race condition, if a user spams the /mention endpoint we can send multiple emails to the target. What do you think ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant