diff --git a/docs/env.md b/docs/env.md index 6bd5e1b5..cc97db7a 100644 --- a/docs/env.md +++ b/docs/env.md @@ -286,6 +286,7 @@ without redeploying the frontend (the flag is pulled from | `MAX_OUTGOING_BODY_SIZE` | `5242880` | Maximum size in bytes for outgoing email body (text + HTML) (5MB) | Optional | | `MAX_TEMPLATE_IMAGE_SIZE` | `2097152` | Maximum size in bytes for images embedded in templates and signatures (2MB) | Optional | | `MAX_RECIPIENTS_PER_MESSAGE` | `500` | Maximum number of recipients per message (to + cc + bcc) | Optional | +| `MAX_THREAD_EVENT_EDIT_DELAY` | `3600` | Time window in seconds during which a ThreadEvent (internal comment) can still be edited or deleted after creation. Set to `0` to disable the restriction. | Optional | ### Model custom attributes schema diff --git a/src/backend/core/admin.py b/src/backend/core/admin.py index d9d6cd7b..276ad95a 100644 --- a/src/backend/core/admin.py +++ b/src/backend/core/admin.py @@ -544,11 +544,36 @@ class ThreadEventInline(admin.TabularInline): extra = 0 +class UserEventInline(admin.TabularInline): + """Inline class for the UserEvent model. + + UserEvent entries are created exclusively by business logic (mentions, + assignments) and must never be edited via the admin: editing ``thread_event`` + would desynchronize ``user_event.thread`` from + ``user_event.thread_event.thread``, breaking mention filters and unread flags. + """ + + model = models.UserEvent + readonly_fields = ( + "user", + "thread", + "thread_event", + "type", + "read_at", + "created_at", + ) + can_delete = False + extra = 0 + + def has_add_permission(self, request, obj=None): + return False + + @admin.register(models.Thread) class ThreadAdmin(admin.ModelAdmin): """Admin class for the Thread model""" - inlines = [ThreadAccessInline, ThreadEventInline] + inlines = [ThreadAccessInline, ThreadEventInline, UserEventInline] list_display = ( "id", "subject", diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index a78fe832..1776f2ad 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -4852,6 +4852,14 @@ }, "description": "Filter threads with draft messages (1=true, 0=false)." }, + { + "in": "query", + "name": "has_mention", + "schema": { + "type": "integer" + }, + "description": "Filter threads with any mention (read or unread) for the current user (1=true, 0=false)." + }, { "in": "query", "name": "has_messages", @@ -4892,6 +4900,14 @@ }, "description": "Filter threads with unread messages (1=true, 0=false). Requires mailbox_id." }, + { + "in": "query", + "name": "has_unread_mention", + "schema": { + "type": "integer" + }, + "description": "Filter threads with unread mentions for the current user (1=true, 0=false)." + }, { "in": "query", "name": "is_spam", @@ -5800,6 +5816,49 @@ } } }, + "/api/v1.0/threads/{thread_id}/events/{id}/read-mention/": { + "patch": { + "operationId": "threads_events_read_mention_partial_update", + "description": "Mark the current user's unread MENTION on this ThreadEvent as read.\n\nReturns 204 even when no UserEvent matches (idempotent); the thread\nevent itself is resolved via the standard ``get_object`` lookup so a\nmissing event yields 404.", + "parameters": [ + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "description": "primary key for the record as UUID" + }, + "required": true + }, + { + "in": "path", + "name": "thread_id", + "schema": { + "type": "string", + "format": "uuid" + }, + "required": true + } + ], + "tags": [ + "thread-events" + ], + "security": [ + { + "cookieAuth": [] + } + ], + "responses": { + "204": { + "description": "No response body" + }, + "404": { + "description": "Thread event not found" + } + } + } + }, "/api/v1.0/threads/{thread_id}/users/": { "get": { "operationId": "threads_users_list", @@ -5877,6 +5936,14 @@ }, "description": "Filter threads with draft messages (1=true, 0=false)." }, + { + "in": "query", + "name": "has_mention", + "schema": { + "type": "integer" + }, + "description": "Filter threads with any mention (read or unread) for the current user (1=true, 0=false)." + }, { "in": "query", "name": "has_sender", @@ -5901,6 +5968,14 @@ }, "description": "Filter threads that are trashed (1=true, 0=false)." }, + { + "in": "query", + "name": "has_unread_mention", + "schema": { + "type": "integer" + }, + "description": "Filter threads with unread mentions for the current user (1=true, 0=false)." + }, { "in": "query", "name": "label_slug", @@ -5935,10 +6010,12 @@ "all", "all_unread", "has_delivery_failed", - "has_delivery_pending" + "has_delivery_pending", + "has_mention", + "has_unread_mention" ] }, - "description": "Comma-separated list of fields to aggregate.\n Special values: 'all' (count all threads), 'all_unread' (count all unread threads).\n Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,\n has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages.\n Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.\n Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'", + "description": "Comma-separated list of fields to aggregate.\n Special values: 'all' (count all threads), 'all_unread' (count all unread threads).\n Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived,\n has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention.\n Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread.\n Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'", "required": true, "explode": false, "style": "form" @@ -7132,15 +7209,23 @@ "readOnly": true }, "count_unread_threads": { - "type": "string", + "type": "integer", + "description": "Return the number of threads with unread messages in the mailbox.", "readOnly": true }, "count_threads": { - "type": "string", + "type": "integer", + "description": "Return the number of threads in the mailbox.", "readOnly": true }, "count_delivering": { - "type": "string", + "type": "integer", + "description": "Return the number of threads with messages being delivered.", + "readOnly": true + }, + "count_unread_mentions": { + "type": "integer", + "description": "Return the number of threads with unread mentions for the current user.", "readOnly": true }, "abilities": { @@ -7212,6 +7297,7 @@ "abilities", "count_delivering", "count_threads", + "count_unread_mentions", "count_unread_threads", "email", "id", @@ -8830,6 +8916,10 @@ "type": "boolean", "readOnly": true }, + "has_unread_mention": { + "type": "boolean", + "readOnly": true + }, "has_trashed": { "type": "boolean", "readOnly": true @@ -8961,6 +9051,10 @@ "summary": { "type": "string", "readOnly": true + }, + "events_count": { + "type": "integer", + "readOnly": true } }, "required": [ @@ -8968,6 +9062,7 @@ "active_messaged_at", "archived_messaged_at", "draft_messaged_at", + "events_count", "has_active", "has_archived", "has_attachments", @@ -8979,6 +9074,7 @@ "has_starred", "has_trashed", "has_unread", + "has_unread_mention", "id", "is_spam", "is_trashed", @@ -9191,6 +9287,14 @@ } ] }, + "has_unread_mention": { + "type": "boolean", + "readOnly": true + }, + "is_editable": { + "type": "boolean", + "readOnly": true + }, "created_at": { "type": "string", "format": "date-time", @@ -9211,7 +9315,9 @@ "channel", "created_at", "data", + "has_unread_mention", "id", + "is_editable", "thread", "type", "updated_at" diff --git a/src/backend/core/api/permissions.py b/src/backend/core/api/permissions.py index c9d3b146..b5e3f9cb 100644 --- a/src/backend/core/api/permissions.py +++ b/src/backend/core/api/permissions.py @@ -168,16 +168,11 @@ class IsAllowedToAccess(IsAuthenticated): return models.MailboxAccess.objects.filter(mailbox=obj, user=user).exists() if isinstance(obj, models.ThreadEvent): - thread = obj.thread - has_access = models.ThreadAccess.objects.filter( - thread=thread, mailbox__accesses__user=user + # Write actions are handled by HasThreadEditAccess, so we only + # need to gate read access on any ThreadAccess for the thread. + return models.ThreadAccess.objects.filter( + thread=obj.thread, mailbox__accesses__user=user ).exists() - if not has_access: - return False - # Only the author can update or delete their own events - if view.action in ["update", "partial_update", "destroy"]: - return obj.author_id == user.id - return True if isinstance(obj, (models.Message, models.Thread)): thread = obj.thread if isinstance(obj, models.Message) else obj @@ -543,10 +538,47 @@ class HasThreadEditAccess(IsAuthenticated): message = "You do not have permission to perform this action on this thread." - def has_object_permission(self, request, view, obj): - """Check if user has editor access to the thread.""" + def has_permission(self, request, view): + """Check editor access up-front on nested thread routes. + + On nested routes (e.g. ``/threads/{thread_id}/events/``), the thread + id comes from the URL and we can enforce the editor role before the + view resolves any object — required for ``create`` where no object + exists yet. On top-level routes (e.g. ``/threads/{pk}/``), defer to + ``has_object_permission``. + """ + if not super().has_permission(request, view): + return False + + thread_id_from_url = view.kwargs.get("thread_id") + if thread_id_from_url is None: + return True + return models.ThreadAccess.objects.filter( - thread=obj, + thread_id=thread_id_from_url, + role__in=enums.THREAD_ROLES_CAN_EDIT, + mailbox__accesses__user=request.user, + ).exists() + + def has_object_permission(self, request, view, obj): + """Check editor access on the thread the object belongs to. + + Supports both ``Thread`` and ``ThreadEvent`` objects. For + ``ThreadEvent``, also enforces that only the event author can perform + update or destroy actions. + """ + if isinstance(obj, models.ThreadEvent): + if ( + view.action in ("update", "partial_update", "destroy") + and obj.author_id != request.user.id + ): + return False + thread = obj.thread + else: + thread = obj + + return models.ThreadAccess.objects.filter( + thread=thread, mailbox__accesses__user=request.user, role__in=enums.THREAD_ROLES_CAN_EDIT, ).exists() diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 97db88bb..6906f684 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -256,6 +256,7 @@ class MailboxSerializer(AbilitiesModelSerializer): count_unread_threads = serializers.SerializerMethodField(read_only=True) count_threads = serializers.SerializerMethodField(read_only=True) count_delivering = serializers.SerializerMethodField(read_only=True) + count_unread_mentions = serializers.SerializerMethodField(read_only=True) class Meta: model = models.Mailbox @@ -267,6 +268,7 @@ class MailboxSerializer(AbilitiesModelSerializer): "count_unread_threads", "count_threads", "count_delivering", + "count_unread_mentions", ] read_only_fields = fields @@ -298,7 +300,7 @@ class MailboxSerializer(AbilitiesModelSerializer): return None def _get_cached_counts(self, instance): - """Get or compute cached counts for the instance in a single query.""" + """Get or compute cached counts for the instance.""" cache_key = f"_counts_{instance.pk}" if not hasattr(self, cache_key): counts = instance.thread_accesses.aggregate( @@ -313,21 +315,43 @@ class MailboxSerializer(AbilitiesModelSerializer): distinct=True, ), ) + # Count distinct threads in this mailbox with an active unread + # mention UserEvent for the current user. Done in a separate query + # to avoid join-multiplication breaking the other counts above. + request = self.context.get("request") + if request and request.user.is_authenticated: + counts["count_unread_mentions"] = ( + models.UserEvent.objects.filter( + user=request.user, + type=enums.UserEventTypeChoices.MENTION, + read_at__isnull=True, + thread__accesses__mailbox=instance, + ) + .values("thread_id") + .distinct() + .count() + ) + else: + counts["count_unread_mentions"] = 0 setattr(self, cache_key, counts) return getattr(self, cache_key) - def get_count_unread_threads(self, instance): + def get_count_unread_threads(self, instance) -> int: """Return the number of threads with unread messages in the mailbox.""" return self._get_cached_counts(instance)["count_unread_threads"] - def get_count_threads(self, instance): + def get_count_threads(self, instance) -> int: """Return the number of threads in the mailbox.""" return self._get_cached_counts(instance)["count_threads"] - def get_count_delivering(self, instance): + def get_count_delivering(self, instance) -> int: """Return the number of threads with messages being delivered.""" return self._get_cached_counts(instance)["count_delivering"] + def get_count_unread_mentions(self, instance) -> int: + """Return the number of threads with unread mentions for the current user.""" + return self._get_cached_counts(instance)["count_unread_mentions"] + @extend_schema_field( { "type": "object", @@ -621,10 +645,12 @@ class ThreadSerializer(serializers.ModelSerializer): sender_names = serializers.ListField(child=serializers.CharField(), read_only=True) user_role = serializers.SerializerMethodField(read_only=True) has_unread = serializers.SerializerMethodField(read_only=True) + has_unread_mention = serializers.SerializerMethodField(read_only=True) has_starred = serializers.SerializerMethodField(read_only=True) accesses = serializers.SerializerMethodField() labels = serializers.SerializerMethodField() summary = serializers.CharField(read_only=True) + events_count = serializers.IntegerField(read_only=True) @extend_schema_field(serializers.BooleanField()) def get_has_unread(self, instance): @@ -635,6 +661,15 @@ class ThreadSerializer(serializers.ModelSerializer): """ return getattr(instance, "_has_unread", False) + @extend_schema_field(serializers.BooleanField()) + def get_has_unread_mention(self, instance): + """Return whether the thread has unread mentions for the current user. + + Requires the _has_unread_mention annotation (set by ThreadViewSet). + Returns False when the annotation is absent (no mailbox context). + """ + return getattr(instance, "_has_unread_mention", False) + @extend_schema_field(serializers.BooleanField()) def get_has_starred(self, instance): """Return whether the thread is starred for the current mailbox. @@ -689,6 +724,7 @@ class ThreadSerializer(serializers.ModelSerializer): "snippet", "messages", "has_unread", + "has_unread_mention", "has_trashed", "is_trashed", "has_archived", @@ -713,6 +749,7 @@ class ThreadSerializer(serializers.ModelSerializer): "accesses", "labels", "summary", + "events_count", ] read_only_fields = fields # Mark all as read-only for safety @@ -968,6 +1005,8 @@ class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer): author = UserWithoutAbilitiesSerializer(read_only=True) data = ThreadEventDataField() + has_unread_mention = serializers.SerializerMethodField() + is_editable = serializers.SerializerMethodField() class Meta: model = models.ThreadEvent @@ -979,6 +1018,8 @@ class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer): "message", "author", "data", + "has_unread_mention", + "is_editable", "created_at", "updated_at", ] @@ -987,11 +1028,23 @@ class ThreadEventSerializer(CreateOnlyFieldsMixin, serializers.ModelSerializer): "thread", "channel", "author", + "has_unread_mention", + "is_editable", "created_at", "updated_at", ] create_only_fields = ["type", "message"] + @extend_schema_field(serializers.BooleanField()) + def get_has_unread_mention(self, obj): + """Return whether the event has an unread mention for the current user.""" + return getattr(obj, "_has_unread_mention", False) + + @extend_schema_field(serializers.BooleanField()) + def get_is_editable(self, obj): + """Return whether the event is still within the edit delay window.""" + return obj.is_editable() + class MailboxAccessReadSerializer(serializers.ModelSerializer): """Serialize mailbox access information for read operations with nested user details. diff --git a/src/backend/core/api/viewsets/thread.py b/src/backend/core/api/viewsets/thread.py index 00c6f80b..3dee1820 100644 --- a/src/backend/core/api/viewsets/thread.py +++ b/src/backend/core/api/viewsets/thread.py @@ -75,10 +75,7 @@ class ThreadViewSet( "You do not have access to this mailbox." ) from e - queryset = queryset.annotate( - _has_unread=models.ThreadAccess.thread_unread_filter(user, mailbox_id), - _has_starred=models.ThreadAccess.thread_starred_filter(user, mailbox_id), - ) + queryset = self._annotate_thread_permissions(queryset, user, mailbox_id) if label_slug: # Filter threads by label slug, ensuring user has access to the label's mailbox @@ -107,6 +104,8 @@ class ThreadViewSet( "has_messages": "has_messages", "has_attachments": "has_attachments", "has_delivery_pending": "has_delivery_pending", + "has_unread_mention": "_has_unread_mention", + "has_mention": "_has_mention", "is_trashed": "is_trashed", "is_spam": "is_spam", } @@ -136,6 +135,35 @@ class ThreadViewSet( queryset = queryset.order_by(order_expression, "-created_at") return queryset + @staticmethod + def _annotate_thread_permissions(queryset, user, mailbox_id): + """Attach permission/state annotations expected by ThreadSerializer. + + Shared between the regular DB queryset and the OpenSearch fallback so + both code paths always expose the same fields (e.g. ``events_count``), + avoiding silent divergence in the serialized payload. + """ + return queryset.annotate( + _has_unread=models.ThreadAccess.thread_unread_filter(user, mailbox_id), + _has_starred=models.ThreadAccess.thread_starred_filter(user, mailbox_id), + _has_unread_mention=Exists( + models.UserEvent.objects.filter( + thread=OuterRef("pk"), + user=user, + type=enums.UserEventTypeChoices.MENTION, + read_at__isnull=True, + ) + ), + _has_mention=Exists( + models.UserEvent.objects.filter( + thread=OuterRef("pk"), + user=user, + type=enums.UserEventTypeChoices.MENTION, + ) + ), + events_count=Count("events", distinct=True), + ) + @staticmethod def _get_order_expression(query_params): """Return the ordering expression based on the active view filter.""" @@ -225,6 +253,18 @@ class ThreadViewSet( "Filter threads with delivery pending messages: sending, retry or failed (1=true, 0=false)." ), ), + OpenApiParameter( + name="has_unread_mention", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Filter threads with unread mentions for the current user (1=true, 0=false).", + ), + OpenApiParameter( + name="has_mention", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Filter threads with any mention (read or unread) for the current user (1=true, 0=false).", + ), OpenApiParameter( name="stats_fields", type=OpenApiTypes.STR, @@ -233,7 +273,7 @@ class ThreadViewSet( description="""Comma-separated list of fields to aggregate. Special values: 'all' (count all threads), 'all_unread' (count all unread threads). Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived, - has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages. + has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention. Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread. Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread'""", enum=list(enums.THREAD_STATS_FIELDS_MAP.keys()), @@ -296,6 +336,8 @@ class ThreadViewSet( "has_active", "has_delivery_failed", "has_delivery_pending", + "has_unread_mention", + "has_mention", "is_spam", "has_messages", } @@ -303,6 +345,11 @@ class ThreadViewSet( # Special fields special_fields = {"all", "all_unread"} + # Base fields that cannot be combined with the "_unread" suffix because + # they are annotations (not real model columns) and their unread variant + # is either already exposed (has_unread_mention) or meaningless. + annotation_fields = {"has_mention", "has_unread_mention"} + # Validate requested fields for field in requested_fields: if field in special_fields: @@ -315,6 +362,11 @@ class ThreadViewSet( {"detail": f"Invalid base field in '{field}': {base_field}"}, status=drf.status.HTTP_400_BAD_REQUEST, ) + if base_field in annotation_fields: + return drf.response.Response( + {"detail": f"Invalid field requested in stats_fields: {field}"}, + status=drf.status.HTTP_400_BAD_REQUEST, + ) elif field in valid_base_fields: continue else: @@ -323,9 +375,11 @@ class ThreadViewSet( status=drf.status.HTTP_400_BAD_REQUEST, ) - # Build unread/starred conditions from annotations (always available) + # Build conditions from annotations (always available) unread_condition = Q(_has_unread=True) starred_condition = Q(_has_starred=True) + unread_mention_condition = Q(_has_unread_mention=True) + mention_condition = Q(_has_mention=True) aggregations = {} for field in requested_fields: @@ -341,6 +395,10 @@ class ThreadViewSet( aggregations[agg_key] = Count( "pk", filter=starred_condition & unread_condition ) + elif field == "has_unread_mention": + aggregations[agg_key] = Count("pk", filter=unread_mention_condition) + elif field == "has_mention": + aggregations[agg_key] = Count("pk", filter=mention_condition) elif field.endswith("_unread"): base_field = field[:-7] base_condition = Q(**{base_field: True}) @@ -462,6 +520,18 @@ class ThreadViewSet( location=OpenApiParameter.QUERY, description="Filter threads with unread messages (1=true, 0=false). Requires mailbox_id.", ), + OpenApiParameter( + name="has_unread_mention", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Filter threads with unread mentions for the current user (1=true, 0=false).", + ), + OpenApiParameter( + name="has_mention", + type=OpenApiTypes.INT, + location=OpenApiParameter.QUERY, + description="Filter threads with any mention (read or unread) for the current user (1=true, 0=false).", + ), ], ) def list(self, request, *args, **kwargs): @@ -527,13 +597,8 @@ class ThreadViewSet( ) ), ) - threads = threads.annotate( - _has_unread=models.ThreadAccess.thread_unread_filter( - request.user, mailbox_id - ), - _has_starred=models.ThreadAccess.thread_starred_filter( - request.user, mailbox_id - ), + threads = self._annotate_thread_permissions( + threads, request.user, mailbox_id ) # Order the threads in the same order as the search results @@ -753,6 +818,36 @@ class ThreadViewSet( thread=new_thread, parent__thread=old_thread ).update(parent=None) + # Move ThreadEvents that belong chronologically to the new thread. + # Two cases: + # - ThreadEvent attached to a moved message (FK `message`) — it + # must follow its message so that `event.thread == message.thread` + # stays coherent. + # - Free ThreadEvent (no message FK) created at or after the split + # point — symmetric with how messages are split, so the timeline + # follows the cut. + event_ids = list( + models.ThreadEvent.objects.filter(thread=old_thread) + .filter( + Q(message__thread=new_thread) + | Q( + message__isnull=True, + created_at__gte=split_message.created_at, + ) + ) + .values_list("id", flat=True) + ) + if event_ids: + models.ThreadEvent.objects.filter(id__in=event_ids).update( + thread=new_thread + ) + # Keep the `UserEvent.thread` denormalization in sync with + # `thread_event.thread` — the ThreadViewSet annotations rely + # on filtering UserEvent by `thread` directly to avoid a JOIN. + models.UserEvent.objects.filter(thread_event_id__in=event_ids).update( + thread=new_thread + ) + # Recalculate old thread snippet from its most recent remaining message last_remaining = old_thread.messages.order_by("-created_at").first() if last_remaining: diff --git a/src/backend/core/api/viewsets/thread_event.py b/src/backend/core/api/viewsets/thread_event.py index 75eae9db..ebd5ba3a 100644 --- a/src/backend/core/api/viewsets/thread_event.py +++ b/src/backend/core/api/viewsets/thread_event.py @@ -1,11 +1,16 @@ """API ViewSet for ThreadEvent model.""" +from django.db.models import Exists, OuterRef from django.shortcuts import get_object_or_404 +from django.utils import timezone -from drf_spectacular.utils import extend_schema -from rest_framework import mixins, viewsets +from drf_spectacular.utils import OpenApiResponse, extend_schema +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response -from core import models +from core import enums, models from .. import permissions, serializers @@ -30,19 +35,92 @@ class ThreadEventViewSet( lookup_field = "id" lookup_url_kwarg = "id" + def get_permissions(self): + """Use HasThreadEditAccess for write actions. + + ThreadEvent write operations require editor access, except + ``read_mention`` which is a personal acknowledgement and only + requires read access on the thread. + """ + if self.action in ["list", "retrieve", "read_mention"]: + return [permissions.IsAuthenticated(), permissions.IsAllowedToAccess()] + + return [permissions.HasThreadEditAccess()] + def get_queryset(self): """Restrict results to events for the specified thread.""" thread_id = self.kwargs.get("thread_id") if not thread_id: return models.ThreadEvent.objects.none() - return ( - models.ThreadEvent.objects.filter(thread_id=thread_id) - .select_related("author", "channel", "message") - .order_by("created_at") - ) + queryset = models.ThreadEvent.objects.filter( + thread_id=thread_id + ).select_related("author", "channel", "message") + + # Annotate with unread mention status for the current user + if self.request.user.is_authenticated: + queryset = queryset.annotate( + _has_unread_mention=Exists( + models.UserEvent.objects.filter( + thread_event=OuterRef("pk"), + user=self.request.user, + type=enums.UserEventTypeChoices.MENTION, + read_at__isnull=True, + ) + ) + ) + + return queryset.order_by("created_at") def perform_create(self, serializer): """Set thread from URL and author from request user.""" thread = get_object_or_404(models.Thread, id=self.kwargs["thread_id"]) serializer.save(thread=thread, author=self.request.user) + + def perform_update(self, serializer): + """Reject updates made after the configured edit delay elapsed. + + Past the window, the event (and any UserEvent MENTION records derived + from it) is considered historical and must remain immutable. + """ + if not serializer.instance.is_editable(): + raise PermissionDenied( + "This event can no longer be edited (edit delay expired)." + ) + serializer.save() + + def perform_destroy(self, instance): + """Reject deletions made after the configured edit delay elapsed. + + Deletion is gated alongside edits so users cannot circumvent the + window by deleting and recreating an event. + """ + if not instance.is_editable(): + raise PermissionDenied( + "This event can no longer be deleted (edit delay expired)." + ) + instance.delete() + + @extend_schema( + request=None, + responses={ + 204: OpenApiResponse(description="No response body"), + 404: OpenApiResponse(description="Thread event not found"), + }, + ) + @action(detail=True, methods=["patch"], url_path="read-mention") + def read_mention(self, request, **kwargs): + """Mark the current user's unread MENTION on this ThreadEvent as read. + + Returns 204 even when no UserEvent matches (idempotent); the thread + event itself is resolved via the standard ``get_object`` lookup so a + missing event yields 404. + """ + thread_event = self.get_object() + models.UserEvent.objects.filter( + user=request.user, + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + read_at__isnull=True, + ).update(read_at=timezone.now()) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/backend/core/enums.py b/src/backend/core/enums.py index 46147394..7caffe5d 100644 --- a/src/backend/core/enums.py +++ b/src/backend/core/enums.py @@ -91,6 +91,8 @@ THREAD_STATS_FIELDS_MAP = { "all_unread": "all_unread", "has_delivery_pending": "has_delivery_pending", "has_delivery_failed": "has_delivery_failed", + "has_unread_mention": "has_unread_mention", + "has_mention": "has_mention", } @@ -256,6 +258,32 @@ CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY = frozenset( ) +def thread_event_type_choices(): + """Return ThreadEventTypeChoices.choices as a callable. + + Used as ``choices=`` argument on ``ThreadEvent.type`` so Django stores the + import path of this function in migrations rather than the resolved list. + Adding new enum values then no longer triggers a schema migration. + """ + return ThreadEventTypeChoices.choices + + +class UserEventTypeChoices(models.TextChoices): + """Defines the possible types of user events.""" + + MENTION = "mention", "Mention" + + +def user_event_type_choices(): + """Return UserEventTypeChoices.choices as a callable. + + Used as ``choices=`` argument on ``UserEvent.type`` so Django stores the + import path of this function in migrations rather than the resolved list. + Adding new enum values then no longer triggers a schema migration. + """ + return UserEventTypeChoices.choices + + class MessageTemplateTypeChoices(models.IntegerChoices): """Defines the possible types of message templates.""" diff --git a/src/backend/core/factories.py b/src/backend/core/factories.py index 305107c9..440209e4 100644 --- a/src/backend/core/factories.py +++ b/src/backend/core/factories.py @@ -13,6 +13,7 @@ import factory.fuzzy from faker import Faker from core import enums, models +from core.enums import UserEventTypeChoices fake = Faker() @@ -157,6 +158,21 @@ class ThreadEventFactory(factory.django.DjangoModelFactory): author = factory.SubFactory(UserFactory) +class UserEventFactory(factory.django.DjangoModelFactory): + """A factory to create user events for testing purposes.""" + + class Meta: + model = models.UserEvent + + user = factory.SubFactory(UserFactory) + thread = factory.SubFactory(ThreadFactory) + thread_event = factory.SubFactory( + ThreadEventFactory, + thread=factory.SelfAttribute("..thread"), + ) + type = UserEventTypeChoices.MENTION + + class ContactFactory(factory.django.DjangoModelFactory): """A factory to random contacts for testing purposes.""" diff --git a/src/backend/core/migrations/0025_alter_threadevent_type_userevent.py b/src/backend/core/migrations/0025_alter_threadevent_type_userevent.py new file mode 100644 index 00000000..e63fd5e2 --- /dev/null +++ b/src/backend/core/migrations/0025_alter_threadevent_type_userevent.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.11 on 2026-04-09 14:41 + +import core.enums +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0024_channel_encrypted_settings_scope_level'), + ] + + operations = [ + migrations.AlterField( + model_name='threadevent', + name='type', + field=models.CharField(choices=core.enums.thread_event_type_choices, max_length=36, verbose_name='type'), + ), + migrations.CreateModel( + name='UserEvent', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), + ('type', models.CharField(choices=core.enums.user_event_type_choices, max_length=36, verbose_name='type')), + ('read_at', models.DateTimeField(blank=True, null=True, verbose_name='read at')), + ('thread', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_events', to='core.thread')), + ('thread_event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_events', to='core.threadevent')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_events', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'user event', + 'verbose_name_plural': 'user events', + 'db_table': 'messages_userevent', + 'ordering': ['-created_at'], + 'indexes': [models.Index(fields=['user', 'type', 'read_at'], name='usrevt_user_type_read'), models.Index(fields=['thread', 'type'], name='usrevt_thread_type')], + 'constraints': [models.UniqueConstraint(fields=('user', 'thread_event', 'type'), name='usrevt_user_event_type_uniq')], + }, + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 7577cfab..fbbcf0f8 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -48,6 +48,8 @@ from core.enums import ( ThreadAccessRoleChoices, ThreadEventTypeChoices, UserAbilities, + thread_event_type_choices, + user_event_type_choices, ) from core.mda.rfc5322 import EmailParseError, parse_email_message from core.mda.signing import generate_dkim_key as _generate_dkim_key @@ -1567,7 +1569,7 @@ class ThreadEvent(BaseModel): type = models.CharField( "type", max_length=36, - choices=ThreadEventTypeChoices.choices, + choices=thread_event_type_choices, ) channel = models.ForeignKey( "Channel", @@ -1614,6 +1616,88 @@ class ThreadEvent(BaseModel): raise ValidationError({"data": exception.message}) from exception super().clean() + def is_editable(self): + """Return whether the event can still be edited or deleted. + + The time window is controlled by ``settings.MAX_THREAD_EVENT_EDIT_DELAY`` + (in seconds). A value of 0 disables the restriction. + """ + delay = settings.MAX_THREAD_EVENT_EDIT_DELAY + if not delay: + return True + return timezone.now() - self.created_at <= timedelta(seconds=delay) + + +class UserEvent(BaseModel): + """User event model to track user-specific events like mentions and assignments. + + Semantics: a UserEvent is a *global* notification for the (user, thread) + pair. It is intentionally **not** scoped to a specific mailbox: a user who + accesses the same thread through multiple mailboxes sees the notification + in each of them, because the notification targets the user, not a given + access path. This is why there is no FK to ``Mailbox`` / ``ThreadAccess``. + + Any future ``UserEventTypeChoices`` that would carry a mailbox-specific + context (e.g. quota or delivery failure on a given BAL) does **not** fit + this model as-is and should either introduce nullable discriminators keyed + on ``type``, or live in a separate model. + + Note: ``thread`` is denormalized from ``thread_event.thread`` so that the + ``Exists(...)`` annotations used by ThreadViewSet.get_queryset() can filter + on the thread FK directly, avoiding an extra JOIN on every thread list. + """ + + user = models.ForeignKey( + "User", + on_delete=models.CASCADE, + related_name="user_events", + ) + thread = models.ForeignKey( + "Thread", + on_delete=models.CASCADE, + related_name="user_events", + ) + thread_event = models.ForeignKey( + "ThreadEvent", + on_delete=models.CASCADE, + related_name="user_events", + ) + type = models.CharField( + "type", + max_length=36, + choices=user_event_type_choices, + ) + read_at = models.DateTimeField( + "read at", + null=True, + blank=True, + ) + + class Meta: + db_table = "messages_userevent" + verbose_name = "user event" + verbose_name_plural = "user events" + ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["user", "thread_event", "type"], + name="usrevt_user_event_type_uniq", + ), + ] + indexes = [ + models.Index( + fields=["user", "type", "read_at"], + name="usrevt_user_type_read", + ), + models.Index( + fields=["thread", "type"], + name="usrevt_thread_type", + ), + ] + + def __str__(self): + return f"{self.user} - {self.type} - {self.thread} - {self.created_at}" + class Contact(BaseModel): """Contact model to store contact information.""" diff --git a/src/backend/core/signals.py b/src/backend/core/signals.py index e0a6dd43..52e5d64d 100644 --- a/src/backend/core/signals.py +++ b/src/backend/core/signals.py @@ -2,14 +2,14 @@ # pylint: disable=unused-argument import logging +import uuid from django.conf import settings from django.db import transaction from django.db.models.signals import post_delete, post_save, pre_delete from django.dispatch import receiver -from core import models -from core.enums import ChannelScopeLevel +from core import enums, models from core.services.identity.keycloak import ( sync_mailbox_to_keycloak_user, sync_maildomain_to_keycloak_group, @@ -309,5 +309,157 @@ def delete_user_scope_channels_on_user_delete(sender, instance, **kwargs): """ models.Channel.objects.filter( user=instance, - scope_level=ChannelScopeLevel.USER, + scope_level=enums.ChannelScopeLevel.USER, ).delete() + + +def _validate_user_ids_with_access(thread_event, thread, users_data): + """Validate and deduplicate user IDs, checking ThreadAccess. + + Shared validation logic for mentions. Parses UUIDs from + the 'id' field of each entry, deduplicates, and batch-validates that each + user has access to the thread via the MailboxAccess -> ThreadAccess chain. + + Args: + thread_event: The ThreadEvent instance (for logging context). + thread: The Thread instance. + users_data: List of dicts with 'id' and 'name' keys. + + Returns: + Set of valid user UUIDs that have access to the thread. + """ + if not users_data: + return set() + + seen_user_ids = set() + unique_user_ids = [] + for entry in users_data: + raw_id = entry.get("id") + if not raw_id: + continue + try: + user_id = uuid.UUID(raw_id) + except (ValueError, AttributeError): + logger.warning( + "Skipping user with invalid UUID '%s' in ThreadEvent %s", + raw_id, + thread_event.id, + ) + continue + if user_id not in seen_user_ids: + seen_user_ids.add(user_id) + unique_user_ids.append(user_id) + + if not unique_user_ids: + return set() + + # Batch validate: users who have access to this thread + # Chain: User -> MailboxAccess.user -> MailboxAccess.mailbox -> ThreadAccess.mailbox + valid_user_ids = set( + models.ThreadAccess.objects.filter( + thread=thread, + mailbox__accesses__user_id__in=unique_user_ids, + ).values_list("mailbox__accesses__user_id", flat=True) + ) + + for user_id in unique_user_ids: + if user_id not in valid_user_ids: + logger.warning( + "Skipping user %s in ThreadEvent %s: " + "user not found or no thread access", + user_id, + thread_event.id, + ) + + return valid_user_ids + + +def sync_mention_user_events(thread_event, thread, mentions_data): + """Sync UserEvent MENTION records to match the current mentions payload. + + Diffs the currently mentioned users against the existing UserEvent MENTION + records for this ThreadEvent and reconciles the two: + - Creates UserEvent records for newly mentioned users. + - Deletes UserEvent records for users who are no longer mentioned so that + stale entries do not linger in the "Mentioned" folder after an edit. + - Leaves existing records untouched when the user is still mentioned, which + preserves their ``read_at`` state across edits. + + Invalid or unauthorized mentions are silently skipped with a warning log. + + Args: + thread_event: The ThreadEvent instance containing mentions. + thread: The Thread instance. + mentions_data: List of mention dicts with 'id' and 'name' keys. + """ + new_valid_user_ids = _validate_user_ids_with_access( + thread_event, thread, mentions_data + ) + + existing_user_ids = set( + models.UserEvent.objects.filter( + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ).values_list("user_id", flat=True) + ) + + to_add = new_valid_user_ids - existing_user_ids + to_remove = existing_user_ids - new_valid_user_ids + + if to_remove: + deleted_count, _ = models.UserEvent.objects.filter( + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + user_id__in=to_remove, + ).delete() + if deleted_count: + logger.info( + "Deleted %d UserEvent MENTION(s) for ThreadEvent %s", + deleted_count, + thread_event.id, + ) + + if to_add: + user_events = [ + models.UserEvent( + user_id=user_id, + thread=thread, + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ) + for user_id in to_add + ] + # ignore_conflicts=True lets the UniqueConstraint on + # (user, thread_event, type) absorb races between concurrent + # post_save signals on the same ThreadEvent (e.g. two PATCH in flight). + models.UserEvent.objects.bulk_create(user_events, ignore_conflicts=True) + logger.info( + "Created %d UserEvent MENTION(s) for ThreadEvent %s", + len(user_events), + thread_event.id, + ) + + +@receiver(post_save, sender=models.ThreadEvent) +def handle_thread_event_post_save(sender, instance, created, **kwargs): + """Handle post-save signal for ThreadEvent to sync UserEvent records. + + Dispatches by ThreadEvent type: + - IM: Syncs UserEvent MENTION records on both create and update so that + edits to the mentions list add/remove notifications accordingly. + """ + try: + if instance.type == enums.ThreadEventTypeChoices.IM: + sync_mention_user_events( + thread_event=instance, + thread=instance.thread, + mentions_data=(instance.data or {}).get("mentions", []), + ) + + # pylint: disable=broad-exception-caught + except Exception as e: + logger.exception( + "Error in ThreadEvent post_save handler for event %s: %s", + instance.id, + e, + ) diff --git a/src/backend/core/tests/api/test_mailboxes.py b/src/backend/core/tests/api/test_mailboxes.py index 07f1cfc9..fe793fae 100644 --- a/src/backend/core/tests/api/test_mailboxes.py +++ b/src/backend/core/tests/api/test_mailboxes.py @@ -119,6 +119,7 @@ class TestMailboxViewSet: assert response.data[0]["count_unread_threads"] == 1 assert response.data[0]["count_threads"] == 2 assert response.data[0]["count_delivering"] == 1 + assert response.data[0]["count_unread_mentions"] == 0 assert response.data[1]["id"] == str(user_mailbox1.id) assert response.data[1]["email"] == str(user_mailbox1) @@ -127,6 +128,7 @@ class TestMailboxViewSet: assert response.data[1]["count_unread_threads"] == 1 assert response.data[1]["count_threads"] == 1 assert response.data[1]["count_delivering"] == 1 + assert response.data[1]["count_unread_mentions"] == 0 def test_list_is_identity_false(self): """A mailbox that is not an identity should return is_identity=False.""" @@ -342,6 +344,114 @@ class TestMailboxViewSet: assert response.data["count_unread_threads"] == 1 assert response.data["count_threads"] == 1 assert response.data["count_delivering"] == 1 + assert response.data["count_unread_mentions"] == 0 + + def test_list_count_unread_mentions(self): + """count_unread_mentions should count distinct threads with an active + unread MENTION UserEvent for the current user, scoped per mailbox.""" + user = factories.UserFactory() + other_user = factories.UserFactory() + + mailbox_a = factories.MailboxFactory() + mailbox_b = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox_a, user=user, role=models.MailboxRoleChoices.EDITOR + ) + factories.MailboxAccessFactory( + mailbox=mailbox_b, user=user, role=models.MailboxRoleChoices.EDITOR + ) + + # Thread in mailbox_a with one unread mention for `user` + thread_a1 = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox_a, + thread=thread_a1, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event_a1 = factories.ThreadEventFactory(thread=thread_a1, author=other_user) + factories.UserEventFactory( + user=user, + thread=thread_a1, + thread_event=event_a1, + type=enums.UserEventTypeChoices.MENTION, + ) + + # Thread in mailbox_a with two unread mentions for `user` — still counted once + thread_a2 = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox_a, + thread=thread_a2, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event_a2a = factories.ThreadEventFactory(thread=thread_a2, author=other_user) + event_a2b = factories.ThreadEventFactory(thread=thread_a2, author=other_user) + factories.UserEventFactory( + user=user, + thread=thread_a2, + thread_event=event_a2a, + type=enums.UserEventTypeChoices.MENTION, + ) + factories.UserEventFactory( + user=user, + thread=thread_a2, + thread_event=event_a2b, + type=enums.UserEventTypeChoices.MENTION, + ) + + # Thread in mailbox_a with a mention already read — must be excluded + thread_a3 = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox_a, + thread=thread_a3, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event_a3 = factories.ThreadEventFactory(thread=thread_a3, author=other_user) + factories.UserEventFactory( + user=user, + thread=thread_a3, + thread_event=event_a3, + type=enums.UserEventTypeChoices.MENTION, + read_at=timezone.now(), + ) + + # Thread in mailbox_a mentioning someone else — must be excluded + thread_a5 = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox_a, + thread=thread_a5, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event_a5 = factories.ThreadEventFactory(thread=thread_a5, author=user) + factories.UserEventFactory( + user=other_user, + thread=thread_a5, + thread_event=event_a5, + type=enums.UserEventTypeChoices.MENTION, + ) + + # Thread in mailbox_b with one unread mention — scoping check + thread_b1 = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox_b, + thread=thread_b1, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event_b1 = factories.ThreadEventFactory(thread=thread_b1, author=other_user) + factories.UserEventFactory( + user=user, + thread=thread_b1, + thread_event=event_b1, + type=enums.UserEventTypeChoices.MENTION, + ) + + client = APIClient() + client.force_authenticate(user=user) + response = client.get(reverse("mailboxes-list")) + assert response.status_code == status.HTTP_200_OK + + by_id = {m["id"]: m for m in response.data} + assert by_id[str(mailbox_a.id)]["count_unread_mentions"] == 2 + assert by_id[str(mailbox_b.id)]["count_unread_mentions"] == 1 def test_retrieve_mailbox_unauthorized(self): """Test that users cannot retrieve mailboxes they don't have access to.""" diff --git a/src/backend/core/tests/api/test_thread_event.py b/src/backend/core/tests/api/test_thread_event.py index 970eb01d..15e7fbec 100644 --- a/src/backend/core/tests/api/test_thread_event.py +++ b/src/backend/core/tests/api/test_thread_event.py @@ -1,8 +1,11 @@ """Tests for the ThreadEvent API endpoints.""" import uuid +from datetime import timedelta +from django.test import override_settings from django.urls import reverse +from django.utils import timezone import pytest from rest_framework import status @@ -12,6 +15,12 @@ from core import enums, factories, models pytestmark = pytest.mark.django_db +def _force_created_at(event, created_at): + """Bypass ``auto_now_add`` to set a past ``created_at`` on an event.""" + models.ThreadEvent.objects.filter(pk=event.pk).update(created_at=created_at) + event.refresh_from_db() + + def get_thread_event_url(thread_id, event_id=None): """Helper function to get the thread event URL.""" if event_id: @@ -228,6 +237,162 @@ class TestThreadEventUpdate: assert event.type == "im" +def _grant_thread_access(thread): + """Create a user with edit access to ``thread`` so they can be mentioned.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=enums.MailboxRoleChoices.ADMIN, + ) + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + return user + + +class TestThreadEventMentionSyncOnUpdate: + """Test that editing an IM ThreadEvent syncs UserEvent MENTION records.""" + + def test_edit_adds_new_mention(self, api_client): + """Adding a mention on edit should create a UserEvent MENTION.""" + author, _mailbox, thread = setup_user_with_thread_access() + alice = _grant_thread_access(thread) + api_client.force_authenticate(user=author) + + event = factories.ThreadEventFactory( + thread=thread, + author=author, + type=enums.ThreadEventTypeChoices.IM, + data={"content": "Hello"}, + ) + assert not models.UserEvent.objects.filter(thread_event=event).exists() + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + { + "data": { + "content": "Hello @[Alice]", + "mentions": [{"id": str(alice.id), "name": "Alice"}], + } + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + user_events = models.UserEvent.objects.filter( + thread_event=event, type=enums.UserEventTypeChoices.MENTION + ) + assert user_events.count() == 1 + assert user_events.first().user_id == alice.id + + def test_edit_removes_dropped_mention(self, api_client): + """Removing a mention on edit should delete the stale UserEvent MENTION.""" + author, _mailbox, thread = setup_user_with_thread_access() + alice = _grant_thread_access(thread) + api_client.force_authenticate(user=author) + + event = factories.ThreadEventFactory( + thread=thread, + author=author, + type=enums.ThreadEventTypeChoices.IM, + data={ + "content": "Hey @[Alice]", + "mentions": [{"id": str(alice.id), "name": "Alice"}], + }, + ) + # Signal created the UserEvent on save. + assert models.UserEvent.objects.filter(thread_event=event, user=alice).exists() + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"data": {"content": "Never mind"}}, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + assert not models.UserEvent.objects.filter( + thread_event=event, type=enums.UserEventTypeChoices.MENTION + ).exists() + + def test_edit_preserves_read_at_for_unchanged_mention(self, api_client): + """Editing content without touching a mention must preserve read_at.""" + author, _mailbox, thread = setup_user_with_thread_access() + alice = _grant_thread_access(thread) + api_client.force_authenticate(user=author) + + event = factories.ThreadEventFactory( + thread=thread, + author=author, + type=enums.ThreadEventTypeChoices.IM, + data={ + "content": "Hey @[Alice]", + "mentions": [{"id": str(alice.id), "name": "Alice"}], + }, + ) + user_event = models.UserEvent.objects.get( + thread_event=event, user=alice, type=enums.UserEventTypeChoices.MENTION + ) + # Simulate Alice having already read the mention. + read_at = timezone.now() + user_event.read_at = read_at + user_event.save() + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + { + "data": { + "content": "Hey @[Alice], updated", + "mentions": [{"id": str(alice.id), "name": "Alice"}], + } + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + user_event.refresh_from_db() + assert user_event.read_at == read_at + + def test_edit_swaps_mentioned_user(self, api_client): + """Replacing a mention should delete the old UserEvent and create the new one.""" + author, _mailbox, thread = setup_user_with_thread_access() + alice = _grant_thread_access(thread) + bob = _grant_thread_access(thread) + api_client.force_authenticate(user=author) + + event = factories.ThreadEventFactory( + thread=thread, + author=author, + type=enums.ThreadEventTypeChoices.IM, + data={ + "content": "Hey @[Alice]", + "mentions": [{"id": str(alice.id), "name": "Alice"}], + }, + ) + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + { + "data": { + "content": "Hey @[Bob]", + "mentions": [{"id": str(bob.id), "name": "Bob"}], + } + }, + format="json", + ) + + assert response.status_code == status.HTTP_200_OK + mention_user_ids = set( + models.UserEvent.objects.filter( + thread_event=event, type=enums.UserEventTypeChoices.MENTION + ).values_list("user_id", flat=True) + ) + assert mention_user_ids == {bob.id} + + class TestThreadEventDelete: """Test the DELETE /threads/{thread_id}/events/{id}/ endpoint.""" @@ -258,6 +423,102 @@ class TestThreadEventDelete: assert response.status_code == status.HTTP_401_UNAUTHORIZED +class TestThreadEventEditDelay: + """Test the MAX_THREAD_EVENT_EDIT_DELAY window on update and delete.""" + + @override_settings(MAX_THREAD_EVENT_EDIT_DELAY=24 * 60 * 60) + def test_update_within_delay_allowed(self, api_client): + """An event within the edit delay can still be updated.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"data": {"content": "Edited"}}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + + @override_settings(MAX_THREAD_EVENT_EDIT_DELAY=24 * 60 * 60) + def test_update_after_delay_forbidden(self, api_client): + """An event older than the edit delay cannot be updated.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + _force_created_at(event, timezone.now() - timedelta(hours=25)) + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"data": {"content": "Too late"}}, + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + event.refresh_from_db() + # Content must be unchanged + assert event.data.get("content") != "Too late" + + @override_settings(MAX_THREAD_EVENT_EDIT_DELAY=24 * 60 * 60) + def test_delete_after_delay_forbidden(self, api_client): + """An event older than the edit delay cannot be deleted.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + _force_created_at(event, timezone.now() - timedelta(hours=25)) + + response = api_client.delete(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert models.ThreadEvent.objects.filter(pk=event.pk).exists() + + @override_settings(MAX_THREAD_EVENT_EDIT_DELAY=0) + def test_update_when_delay_disabled(self, api_client): + """A zero delay disables the restriction entirely.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + _force_created_at(event, timezone.now() - timedelta(days=365)) + + response = api_client.patch( + get_thread_event_url(thread.id, event.id), + {"data": {"content": "Still editable"}}, + format="json", + ) + assert response.status_code == status.HTTP_200_OK + + @override_settings(MAX_THREAD_EVENT_EDIT_DELAY=0) + def test_delete_when_delay_disabled(self, api_client): + """A zero delay allows deletion regardless of age.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + _force_created_at(event, timezone.now() - timedelta(days=365)) + + response = api_client.delete(get_thread_event_url(thread.id, event.id)) + assert response.status_code == status.HTTP_204_NO_CONTENT + + @override_settings(MAX_THREAD_EVENT_EDIT_DELAY=24 * 60 * 60) + def test_is_editable_field_in_response(self, api_client): + """``is_editable`` reflects the current delay state in the payload.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + fresh = factories.ThreadEventFactory(thread=thread, author=user) + stale = factories.ThreadEventFactory(thread=thread, author=user) + _force_created_at(stale, timezone.now() - timedelta(hours=25)) + + response = api_client.get(get_thread_event_url(thread.id)) + assert response.status_code == status.HTTP_200_OK + + by_id = {item["id"]: item for item in response.data} + assert by_id[str(fresh.id)]["is_editable"] is True + assert by_id[str(stale.id)]["is_editable"] is False + + class TestThreadEventDataValidation: """Test that the data field is validated against the JSON schema for each event type.""" @@ -326,3 +587,129 @@ class TestThreadEventDataValidation: response = api_client.post(get_thread_event_url(thread.id), data, format="json") assert response.status_code == status.HTTP_400_BAD_REQUEST assert "data" in response.data + + +def get_read_mention_url(thread_id, event_id): + """Helper to build the read-mention URL for a thread event.""" + return reverse( + "thread-event-read-mention", + kwargs={"thread_id": thread_id, "id": event_id}, + ) + + +class TestThreadEventReadMention: + """Test the PATCH /threads/{thread_id}/events/{id}/read-mention/ endpoint.""" + + def test_read_mention_success(self, api_client): + """PATCH marks the current user's unread MENTION on the event as read.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + user_event = factories.UserEventFactory( + user=user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + response = api_client.patch(get_read_mention_url(thread.id, event.id)) + + assert response.status_code == status.HTTP_204_NO_CONTENT + user_event.refresh_from_db() + assert user_event.read_at is not None + + def test_read_mention_viewer_access(self, api_client): + """A viewer can acknowledge their own mention (no edit access required).""" + user, _mailbox, thread = setup_user_with_thread_access( + role=enums.ThreadAccessRoleChoices.VIEWER + ) + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + user_event = factories.UserEventFactory( + user=user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + response = api_client.patch(get_read_mention_url(thread.id, event.id)) + + assert response.status_code == status.HTTP_204_NO_CONTENT + user_event.refresh_from_db() + assert user_event.read_at is not None + + def test_read_mention_without_thread_access_forbidden(self, api_client): + """PATCH without access to the thread is forbidden.""" + user = factories.UserFactory() + api_client.force_authenticate(user=user) + + _other_user, _mailbox, thread = setup_user_with_thread_access() + event = factories.ThreadEventFactory(thread=thread, author=_other_user) + + response = api_client.patch(get_read_mention_url(thread.id, event.id)) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_read_mention_does_not_affect_other_users(self, api_client): + """PATCH must never touch other users' UserEvents.""" + user, _mailbox, thread = setup_user_with_thread_access() + other_user = factories.UserFactory() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + other_ue = factories.UserEventFactory( + user=other_user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + response = api_client.patch(get_read_mention_url(thread.id, event.id)) + + assert response.status_code == status.HTTP_204_NO_CONTENT + other_ue.refresh_from_db() + assert other_ue.read_at is None + + def test_read_mention_idempotent(self, api_client): + """PATCH on an already-read mention is a no-op but still returns 204.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + original_read_at = event.created_at + user_event = factories.UserEventFactory( + user=user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + read_at=original_read_at, + ) + + response = api_client.patch(get_read_mention_url(thread.id, event.id)) + + assert response.status_code == status.HTTP_204_NO_CONTENT + user_event.refresh_from_db() + assert user_event.read_at == original_read_at + + def test_read_mention_non_existent_event(self, api_client): + """PATCH on a non-existent event returns 404.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.patch(get_read_mention_url(thread.id, uuid.uuid4())) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_read_mention_unauthenticated(self, api_client): + """Unauthenticated request should be rejected.""" + _user, _mailbox, thread = setup_user_with_thread_access() + event = factories.ThreadEventFactory(thread=thread) + + response = api_client.patch(get_read_mention_url(thread.id, event.id)) + + assert response.status_code in ( + status.HTTP_401_UNAUTHORIZED, + status.HTTP_403_FORBIDDEN, + ) diff --git a/src/backend/core/tests/api/test_thread_filter_mention.py b/src/backend/core/tests/api/test_thread_filter_mention.py new file mode 100644 index 00000000..7d57de71 --- /dev/null +++ b/src/backend/core/tests/api/test_thread_filter_mention.py @@ -0,0 +1,533 @@ +"""Tests for has_mention and has_unread_mention filters and stats on Thread API.""" + +from django.urls import reverse +from django.utils import timezone + +import pytest +from rest_framework import status + +from core import enums, factories + +pytestmark = pytest.mark.django_db + + +def setup_user_with_thread_access(role=enums.ThreadAccessRoleChoices.EDITOR): + """Create a user with mailbox access and thread access.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=enums.MailboxRoleChoices.ADMIN, + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=role, + ) + return user, mailbox, thread + + +class TestThreadFilterUnreadMention: + """Test GET /api/v1.0/threads/?has_unread_mention=1 filter.""" + + def test_thread_mention_unread_filter_returns_matching(self, api_client): + """Filter should return only threads with active unread MENTION UserEvents.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Create an unread mention on this thread + event = factories.ThreadEventFactory(thread=thread, author=user) + factories.UserEventFactory( + user=user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + # Create another thread without mentions + thread_no_mention = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread_no_mention, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_unread_mention": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + thread_ids = [t["id"] for t in response.data["results"]] + assert str(thread.id) in thread_ids + assert str(thread_no_mention.id) not in thread_ids + + def test_thread_mention_unread_filter_empty_when_none(self, api_client): + """Filter should return empty list when no unread mentions exist.""" + user, mailbox, _thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_unread_mention": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 0 + + def test_thread_mention_unread_filter_excludes_read(self, api_client): + """A thread whose only MENTION UserEvent has ``read_at`` set must be excluded. + + Guards the ``read_at__isnull=True`` clause in ``_has_unread_mention``: + without it, the filter would collapse to ``has_mention`` and leak + already-acknowledged mentions. + """ + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Seed a mention that has already been read. + event = factories.ThreadEventFactory(thread=thread, author=user) + factories.UserEventFactory( + user=user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + read_at=timezone.now(), + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_unread_mention": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + thread_ids = [t["id"] for t in response.data["results"]] + assert str(thread.id) not in thread_ids + assert len(response.data["results"]) == 0 + + def test_thread_mention_unread_filter_ignores_other_user(self, api_client): + """Mentions belonging to another user must not leak into the filter.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Another user has an unread mention on the same thread + other_user = factories.UserFactory() + event = factories.ThreadEventFactory(thread=thread, author=other_user) + factories.UserEventFactory( + user=other_user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_unread_mention": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 0 + + +class TestThreadStatsUnreadMention: + """Test GET /api/v1.0/threads/stats/?stats_fields=has_unread_mention.""" + + def test_thread_mention_unread_stats_returns_count(self, api_client): + """Stats should return correct has_unread_mention count.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Create unread mentions on two threads + event = factories.ThreadEventFactory(thread=thread, author=user) + factories.UserEventFactory( + user=user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + thread2 = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread2, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event2 = factories.ThreadEventFactory(thread=thread2, author=user) + factories.UserEventFactory( + user=user, + thread=thread2, + thread_event=event2, + type=enums.UserEventTypeChoices.MENTION, + ) + + response = api_client.get( + reverse("threads-stats"), + { + "mailbox_id": str(mailbox.id), + "stats_fields": "has_unread_mention", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["has_unread_mention"] == 2 + + def test_thread_mention_unread_stats_ignores_other_user(self, api_client): + """Stats must not count MENTION UserEvents belonging to another user.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + other_user = factories.UserFactory() + event = factories.ThreadEventFactory(thread=thread, author=other_user) + factories.UserEventFactory( + user=other_user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + response = api_client.get( + reverse("threads-stats"), + { + "mailbox_id": str(mailbox.id), + "stats_fields": "has_unread_mention", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["has_unread_mention"] == 0 + + +class TestThreadEventHasUnreadMention: + """Test GET /api/v1.0/threads/{id}/events/ returns has_unread_mention.""" + + def test_thread_mention_unread_event_flag_true(self, api_client): + """ThreadEvent with unread mention for current user should have has_unread_mention=True.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + factories.UserEventFactory( + user=user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + response = api_client.get( + reverse("thread-event-list", kwargs={"thread_id": thread.id}), + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["has_unread_mention"] is True + + def test_thread_mention_unread_event_flag_false_no_mention(self, api_client): + """ThreadEvent without mention should have has_unread_mention=False.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + factories.ThreadEventFactory(thread=thread, author=user) + + response = api_client.get( + reverse("thread-event-list", kwargs={"thread_id": thread.id}), + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["has_unread_mention"] is False + + def test_thread_mention_unread_event_flag_false_when_read(self, api_client): + """ThreadEvent with already-read mention should have has_unread_mention=False.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + factories.UserEventFactory( + user=user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + read_at=timezone.now(), + ) + + response = api_client.get( + reverse("thread-event-list", kwargs={"thread_id": thread.id}), + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["has_unread_mention"] is False + + def test_thread_mention_unread_event_flag_ignores_other_user(self, api_client): + """has_unread_mention must ignore mentions whose UserEvent targets another user.""" + user, _mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + other_user = factories.UserFactory() + event = factories.ThreadEventFactory(thread=thread, author=other_user) + factories.UserEventFactory( + user=other_user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + response = api_client.get( + reverse("thread-event-list", kwargs={"thread_id": thread.id}), + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data) == 1 + assert response.data[0]["has_unread_mention"] is False + + +class TestThreadFilterMention: + """Test GET /api/v1.0/threads/?has_mention=1 filter. + + Unlike has_unread_mention which only returns threads with unread mentions, + has_mention returns threads with ANY active mention (read or unread). + """ + + def test_thread_mention_any_filter_returns_matching(self, api_client): + """Filter should return threads with both read and unread mentions.""" + user, mailbox, thread_unread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Thread with unread mention + event1 = factories.ThreadEventFactory(thread=thread_unread, author=user) + factories.UserEventFactory( + user=user, + thread=thread_unread, + thread_event=event1, + type=enums.UserEventTypeChoices.MENTION, + ) + + # Thread with read mention + thread_read = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread_read, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event2 = factories.ThreadEventFactory(thread=thread_read, author=user) + factories.UserEventFactory( + user=user, + thread=thread_read, + thread_event=event2, + type=enums.UserEventTypeChoices.MENTION, + read_at=timezone.now(), + ) + + # Thread without any mention + thread_none = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread_none, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_mention": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + thread_ids = [t["id"] for t in response.data["results"]] + assert str(thread_unread.id) in thread_ids + assert str(thread_read.id) in thread_ids + assert str(thread_none.id) not in thread_ids + + def test_thread_mention_any_filter_empty_when_none(self, api_client): + """Filter should not return threads without any MENTION UserEvent.""" + user, mailbox, _thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_mention": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 0 + + def test_thread_mention_any_filter_includes_read(self, api_client): + """Filter should include threads where the mention has been read.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + event = factories.ThreadEventFactory(thread=thread, author=user) + factories.UserEventFactory( + user=user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + read_at=timezone.now(), + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_mention": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + thread_ids = [t["id"] for t in response.data["results"]] + assert str(thread.id) in thread_ids + + def test_thread_mention_any_filter_ignores_other_user(self, api_client): + """Mentions belonging to another user must not leak into the filter.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + other_user = factories.UserFactory() + event = factories.ThreadEventFactory(thread=thread, author=other_user) + factories.UserEventFactory( + user=other_user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + response = api_client.get( + reverse("threads-list"), + {"mailbox_id": str(mailbox.id), "has_mention": "1"}, + ) + + assert response.status_code == status.HTTP_200_OK + assert len(response.data["results"]) == 0 + + +class TestThreadStatsMention: + """Test GET /api/v1.0/threads/stats/?stats_fields=has_mention.""" + + def test_thread_mention_any_stats_returns_count(self, api_client): + """Stats should count threads with any active mention (read or unread).""" + user, mailbox, thread1 = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + # Thread 1: unread mention + event1 = factories.ThreadEventFactory(thread=thread1, author=user) + factories.UserEventFactory( + user=user, + thread=thread1, + thread_event=event1, + type=enums.UserEventTypeChoices.MENTION, + ) + + # Thread 2: read mention + thread2 = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, + thread=thread2, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + event2 = factories.ThreadEventFactory(thread=thread2, author=user) + factories.UserEventFactory( + user=user, + thread=thread2, + thread_event=event2, + type=enums.UserEventTypeChoices.MENTION, + read_at=timezone.now(), + ) + + response = api_client.get( + reverse("threads-stats"), + { + "mailbox_id": str(mailbox.id), + "stats_fields": "has_mention", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["has_mention"] == 2 + + def test_thread_mention_any_stats_ignores_other_user(self, api_client): + """Stats must not count MENTION UserEvents belonging to another user.""" + user, mailbox, thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + other_user = factories.UserFactory() + event = factories.ThreadEventFactory(thread=thread, author=other_user) + factories.UserEventFactory( + user=other_user, + thread=thread, + thread_event=event, + type=enums.UserEventTypeChoices.MENTION, + ) + + response = api_client.get( + reverse("threads-stats"), + { + "mailbox_id": str(mailbox.id), + "stats_fields": "has_mention", + }, + ) + + assert response.status_code == status.HTTP_200_OK + assert response.data["has_mention"] == 0 + + +class TestThreadStatsMentionUnreadSuffixValidation: + """Test that mention-related fields reject the generic '_unread' suffix. + + has_mention / has_unread_mention are annotation-only fields (no real model + column), and "unread" in has_unread_mention refers to UserEvent.read_at, not + ThreadAccess.read_at. Allowing the generic '_unread' suffix would both crash + at runtime (Q(has_mention=True) has no column) and be semantically misleading. + """ + + def test_thread_mention_stats_rejects_has_mention_unread_suffix(self, api_client): + """`has_mention_unread` must be rejected at validation with a 400.""" + user, mailbox, _thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("threads-stats"), + { + "mailbox_id": str(mailbox.id), + "stats_fields": "has_mention_unread", + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "has_mention_unread" in response.data["detail"] + + def test_thread_mention_stats_rejects_has_unread_mention_unread_suffix( + self, api_client + ): + """`has_unread_mention_unread` must be rejected at validation with a 400.""" + user, mailbox, _thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("threads-stats"), + { + "mailbox_id": str(mailbox.id), + "stats_fields": "has_unread_mention_unread", + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "has_unread_mention_unread" in response.data["detail"] + + def test_thread_mention_stats_rejects_mixed_suffix_validation(self, api_client): + """A valid field next to an invalid mention unread combo must still 400. + + Guards against a regression where one-off validation would let the + invalid field reach the aggregation loop because another valid field + was present in the same request. + """ + user, mailbox, _thread = setup_user_with_thread_access() + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("threads-stats"), + { + "mailbox_id": str(mailbox.id), + "stats_fields": "has_unread_mention,has_mention_unread", + }, + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert "has_mention_unread" in response.data["detail"] diff --git a/src/backend/core/tests/api/test_thread_split.py b/src/backend/core/tests/api/test_thread_split.py index db8cf922..49cd943c 100644 --- a/src/backend/core/tests/api/test_thread_split.py +++ b/src/backend/core/tests/api/test_thread_split.py @@ -19,10 +19,12 @@ from core.factories import ( MailboxFactory, MessageFactory, ThreadAccessFactory, + ThreadEventFactory, ThreadFactory, + UserEventFactory, UserFactory, ) -from core.models import Thread, ThreadAccess +from core.models import Thread, ThreadAccess, ThreadEvent, UserEvent pytestmark = pytest.mark.django_db @@ -645,3 +647,167 @@ def test_split_thread_returns_new_thread_data( assert "subject" in response.data assert "messages" in response.data assert "accesses" in response.data + + +# --- ThreadEvent / UserEvent split tests --- + + +def _force_event_created_at(event, created_at): + """Bypass ``auto_now_add`` to pin a ThreadEvent at a specific timestamp.""" + ThreadEvent.objects.filter(pk=event.pk).update(created_at=created_at) + event.refresh_from_db() + + +@patch("core.signals.reindex_thread_task") +@patch("core.signals.index_message_task") +def test_split_thread_moves_free_event_after_split_point( + _mock_index_msg, _mock_reindex_thread, api_client +): + """A ThreadEvent without a message FK created at or after the split point + must follow the new thread, symmetric with how messages are split.""" + user = UserFactory() + api_client.force_authenticate(user=user) + + mailbox = MailboxFactory() + thread, messages = _create_thread_with_messages(mailbox, count=3) + _setup_editor_access(user, mailbox, thread) + + # Free event posted after messages[1] (the split point) + event_after = ThreadEventFactory(thread=thread, author=user, message=None) + _force_event_created_at(event_after, messages[1].created_at + timedelta(seconds=30)) + # Free event posted before the split point — must stay on the old thread. + # NOTE: ``_create_thread_with_messages`` doesn't bypass ``auto_now_add`` so + # all messages[*].created_at are within microseconds of each other. We + # anchor offsets on messages[1].created_at (the split point) to guarantee + # strict ordering. + event_before = ThreadEventFactory(thread=thread, author=user, message=None) + _force_event_created_at( + event_before, messages[1].created_at - timedelta(seconds=30) + ) + + url = _get_split_url(thread.id) + response = api_client.post(url, {"message_id": str(messages[1].id)}) + assert response.status_code == status.HTTP_201_CREATED + new_thread_id = response.data["id"] + + event_after.refresh_from_db() + event_before.refresh_from_db() + assert str(event_after.thread_id) == new_thread_id + assert event_before.thread_id == thread.id + + +@patch("core.signals.reindex_thread_task") +@patch("core.signals.index_message_task") +def test_split_thread_moves_event_attached_to_moved_message( + _mock_index_msg, _mock_reindex_thread, api_client +): + """A ThreadEvent attached to a moved message must follow its message, even + if its own created_at is earlier than the split point.""" + user = UserFactory() + api_client.force_authenticate(user=user) + + mailbox = MailboxFactory() + thread, messages = _create_thread_with_messages(mailbox, count=3) + _setup_editor_access(user, mailbox, thread) + + # Event attached to the split message itself, with a created_at BEFORE + # the split point — the FK must win over the timestamp. + event_on_moved = ThreadEventFactory(thread=thread, author=user, message=messages[1]) + _force_event_created_at(event_on_moved, messages[0].created_at - timedelta(hours=1)) + # Event attached to a message that stays — must not move. + event_on_staying = ThreadEventFactory( + thread=thread, author=user, message=messages[0] + ) + _force_event_created_at( + event_on_staying, messages[2].created_at + timedelta(hours=1) + ) + + url = _get_split_url(thread.id) + response = api_client.post(url, {"message_id": str(messages[1].id)}) + assert response.status_code == status.HTTP_201_CREATED + new_thread_id = response.data["id"] + + event_on_moved.refresh_from_db() + event_on_staying.refresh_from_db() + assert str(event_on_moved.thread_id) == new_thread_id + assert event_on_staying.thread_id == thread.id + + +@patch("core.signals.reindex_thread_task") +@patch("core.signals.index_message_task") +def test_split_thread_moves_user_events_with_their_thread_event( + _mock_index_msg, _mock_reindex_thread, api_client +): + """UserEvent.thread is denormalized from thread_event.thread. When an + event is moved, its UserEvents must be updated to keep the invariant.""" + user = UserFactory() + mentioned = UserFactory() + api_client.force_authenticate(user=user) + + mailbox = MailboxFactory() + thread, messages = _create_thread_with_messages(mailbox, count=3) + _setup_editor_access(user, mailbox, thread) + + event_after = ThreadEventFactory(thread=thread, author=user, message=None) + _force_event_created_at(event_after, messages[1].created_at + timedelta(seconds=30)) + mention_after = UserEventFactory( + user=mentioned, thread=thread, thread_event=event_after + ) + + event_before = ThreadEventFactory(thread=thread, author=user, message=None) + _force_event_created_at( + event_before, messages[1].created_at - timedelta(seconds=30) + ) + mention_before = UserEventFactory( + user=mentioned, thread=thread, thread_event=event_before + ) + + url = _get_split_url(thread.id) + response = api_client.post(url, {"message_id": str(messages[1].id)}) + assert response.status_code == status.HTTP_201_CREATED + new_thread_id = response.data["id"] + + mention_after.refresh_from_db() + mention_before.refresh_from_db() + assert str(mention_after.thread_id) == new_thread_id + assert mention_before.thread_id == thread.id + + # Invariant: every UserEvent.thread matches its thread_event.thread. + for ue in UserEvent.objects.all(): + assert ue.thread_id == ue.thread_event.thread_id + + +@patch("core.signals.reindex_thread_task") +@patch("core.signals.index_message_task") +def test_split_thread_events_are_counted_on_new_thread( + _mock_index_msg, _mock_reindex_thread, api_client +): + """After split, events must be distributed so that counts add up on both + threads — guards against a regression where events would stay on the old + thread or be duplicated.""" + user = UserFactory() + api_client.force_authenticate(user=user) + + mailbox = MailboxFactory() + thread, messages = _create_thread_with_messages(mailbox, count=3) + _setup_editor_access(user, mailbox, thread) + + # Two events before split, three after. + for i in range(2): + event = ThreadEventFactory(thread=thread, author=user, message=None) + _force_event_created_at( + event, messages[1].created_at - timedelta(seconds=i + 1) + ) + for i in range(3): + event = ThreadEventFactory(thread=thread, author=user, message=None) + _force_event_created_at( + event, messages[1].created_at + timedelta(seconds=i + 1) + ) + + url = _get_split_url(thread.id) + response = api_client.post(url, {"message_id": str(messages[1].id)}) + assert response.status_code == status.HTTP_201_CREATED + new_thread_id = response.data["id"] + + assert ThreadEvent.objects.filter(thread=thread).count() == 2 + assert ThreadEvent.objects.filter(thread_id=new_thread_id).count() == 3 diff --git a/src/backend/core/tests/api/test_threads_list.py b/src/backend/core/tests/api/test_threads_list.py index e14fb3ae..e80c68e4 100644 --- a/src/backend/core/tests/api/test_threads_list.py +++ b/src/backend/core/tests/api/test_threads_list.py @@ -20,6 +20,7 @@ from core.factories import ( MessageFactory, MessageRecipientFactory, ThreadAccessFactory, + ThreadEventFactory, ThreadFactory, UserFactory, ) @@ -1685,3 +1686,93 @@ class TestThreadListAPI: ) assert response.status_code == status.HTTP_403_FORBIDDEN + + +class TestThreadListEventsCount: + """Test that ThreadSerializer exposes events_count on the list endpoint. + + This count drives the frontend refetch-on-new-event effect in MailboxProvider. + """ + + @pytest.fixture + def url(self): + """Return the URL for the list endpoint.""" + return reverse("threads-list") + + @staticmethod + def _setup_user_with_thread(user=None): + """Create a user with an admin mailbox and an editor thread access.""" + user = user or UserFactory() + mailbox = MailboxFactory() + MailboxAccessFactory( + mailbox=mailbox, + user=user, + role=enums.MailboxRoleChoices.ADMIN, + ) + thread = ThreadFactory() + ThreadAccessFactory( + mailbox=mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + return user, mailbox, thread + + def test_list_threads_events_count_zero_when_no_events(self, api_client, url): + """A thread without any ThreadEvent should expose events_count == 0.""" + user, mailbox, thread = self._setup_user_with_thread() + api_client.force_authenticate(user=user) + + response = api_client.get(url, {"mailbox_id": str(mailbox.id)}) + + assert response.status_code == status.HTTP_200_OK + payload = next(t for t in response.data["results"] if t["id"] == str(thread.id)) + assert payload["events_count"] == 0 + + def test_list_threads_events_count_matches_thread_events(self, api_client, url): + """events_count should equal the number of ThreadEvents attached to the thread.""" + user, mailbox, thread = self._setup_user_with_thread() + api_client.force_authenticate(user=user) + + ThreadEventFactory(thread=thread, author=user) + ThreadEventFactory(thread=thread, author=user) + ThreadEventFactory(thread=thread, author=user) + + response = api_client.get(url, {"mailbox_id": str(mailbox.id)}) + + assert response.status_code == status.HTTP_200_OK + payload = next(t for t in response.data["results"] if t["id"] == str(thread.id)) + assert payload["events_count"] == 3 + + def test_list_threads_events_count_distinct_per_thread(self, api_client, url): + """events_count must use distinct counting to avoid JOIN multiplication. + + The queryset joins on accesses__mailbox, so without ``distinct=True`` the + count would be multiplied by the number of ThreadAccess rows. Guard against + regressions by creating several accesses and expecting the raw event count. + """ + user, mailbox, thread = self._setup_user_with_thread() + api_client.force_authenticate(user=user) + + # Add two extra accesses on the same thread from other mailboxes the user + # also has access to, to force multiple JOIN rows. + for _ in range(2): + extra_mailbox = MailboxFactory() + MailboxAccessFactory( + mailbox=extra_mailbox, + user=user, + role=enums.MailboxRoleChoices.ADMIN, + ) + ThreadAccessFactory( + mailbox=extra_mailbox, + thread=thread, + role=enums.ThreadAccessRoleChoices.EDITOR, + ) + + ThreadEventFactory(thread=thread, author=user) + ThreadEventFactory(thread=thread, author=user) + + response = api_client.get(url, {"mailbox_id": str(mailbox.id)}) + + assert response.status_code == status.HTTP_200_OK + payload = next(t for t in response.data["results"] if t["id"] == str(thread.id)) + assert payload["events_count"] == 2 diff --git a/src/backend/core/tests/conftest.py b/src/backend/core/tests/conftest.py index 6cb6f9f6..0de269f3 100644 --- a/src/backend/core/tests/conftest.py +++ b/src/backend/core/tests/conftest.py @@ -2,6 +2,8 @@ from unittest import mock +from django.db.models import F + import pytest USER = "user" @@ -18,6 +20,43 @@ def mock_user_teams(): yield mock_teams +@pytest.fixture(autouse=True) +def _assert_user_event_thread_invariant(request): + """Turn every DB-enabled test into a sentinel for the UserEvent invariant. + + ``UserEvent.thread`` is a denormalization of ``UserEvent.thread_event.thread`` + (see the model docstring at ``core/models.py``). The denormalization exists + for query-plan reasons — the ``Exists(...)`` annotations that power the + mention filters in ``ThreadViewSet.get_queryset`` rely on filtering + ``UserEvent`` by ``thread`` directly without a JOIN on ``ThreadEvent``. + That makes the ``thread_id == thread_event.thread_id`` equality a hard + invariant: any divergence silently corrupts the mention UX. + + Python writes through ``save()`` don't help here because the hot path uses + ``bulk_create`` (mention signal) and plain ``update()`` (thread split). + Rather than duplicate a check on every call site, we let the test suite + catch regressions by scanning for violators after each DB-enabled test. + The query is a single index-friendly ``EXCLUDE`` with no JOIN amplification + so the overhead is negligible. + """ + yield + # Skip for tests that don't hit the database — avoids + # ``Database access not allowed`` errors on pure unit tests. + if not any(request.node.iter_markers("django_db")): + return + + # Imported late so conftest import does not pull Django models before + # settings are configured. + from core.models import UserEvent # pylint: disable=import-outside-toplevel + + violators = UserEvent.objects.exclude(thread_id=F("thread_event__thread_id")) + count = violators.count() + assert count == 0, ( + f"UserEvent invariant broken: {count} row(s) where " + "thread_id != thread_event.thread_id" + ) + + # @pytest.fixture # @pytest.mark.django_db # def create_testdomain(): diff --git a/src/backend/core/tests/models/test_user_event.py b/src/backend/core/tests/models/test_user_event.py new file mode 100644 index 00000000..982dbcba --- /dev/null +++ b/src/backend/core/tests/models/test_user_event.py @@ -0,0 +1,179 @@ +"""Tests for UserEvent model.""" + +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction + +import pytest + +from core import enums, factories +from core import models as core_models + + +@pytest.mark.django_db +class TestUserEvent: + """Test the UserEvent model.""" + + def test_user_event_factory_creates_valid_instance(self): + """UserEventFactory should create a valid UserEvent with all fields.""" + user_event = factories.UserEventFactory() + assert user_event.id is not None + assert user_event.user is not None + assert user_event.thread is not None + assert user_event.thread_event is not None + assert user_event.type is not None + assert user_event.created_at is not None + assert user_event.updated_at is not None + + def test_user_event_invalid_type_raises_validation_error(self): + """UserEvent with an invalid type should raise ValidationError on save.""" + with pytest.raises(ValidationError): + factories.UserEventFactory(type="invalid") + + def test_user_event_multiple_same_type_for_same_user_thread_allowed(self): + """Multiple UserEvent of the same type for the same (user, thread) are allowed.""" + user = factories.UserFactory() + thread = factories.ThreadFactory() + thread_event_1 = factories.ThreadEventFactory(thread=thread) + thread_event_2 = factories.ThreadEventFactory(thread=thread) + + event_1 = factories.UserEventFactory( + user=user, thread=thread, thread_event=thread_event_1 + ) + event_2 = factories.UserEventFactory( + user=user, thread=thread, thread_event=thread_event_2 + ) + + assert event_1.id != event_2.id + assert event_1.user == event_2.user + assert event_1.thread == event_2.thread + assert event_1.type == event_2.type + + def test_user_event_read_at_null_by_default(self): + """UserEvent.read_at should be null by default.""" + user_event = factories.UserEventFactory() + assert user_event.read_at is None + + def test_user_event_str_representation(self): + """UserEvent.__str__ should return the expected format.""" + user_event = factories.UserEventFactory() + expected = ( + f"{user_event.user} - {user_event.type} - " + f"{user_event.thread} - {user_event.created_at}" + ) + assert str(user_event) == expected + + def test_user_event_ordering_is_descending_created_at(self): + """Default ordering should be ['-created_at'] (descending).""" + assert core_models.UserEvent._meta.ordering == ["-created_at"] + + def test_user_event_cascade_delete_user(self): + """Deleting a User should cascade-delete related UserEvents.""" + user_event = factories.UserEventFactory() + user_id = user_event.user.id + user_event.user.delete() + assert not core_models.UserEvent.objects.filter(user_id=user_id).exists() + + def test_user_event_cascade_delete_thread(self): + """Deleting a Thread should cascade-delete related UserEvents.""" + user_event = factories.UserEventFactory() + thread_id = user_event.thread.id + user_event.thread.delete() + assert not core_models.UserEvent.objects.filter(thread_id=thread_id).exists() + + def test_user_event_cascade_delete_thread_event(self): + """Deleting a ThreadEvent should cascade-delete related UserEvents.""" + user_event = factories.UserEventFactory() + thread_event_id = user_event.thread_event.id + user_event.thread_event.delete() + assert not core_models.UserEvent.objects.filter( + thread_event_id=thread_event_id + ).exists() + + def test_user_event_duplicate_via_save_raises_validation_error(self): + """The unique constraint must reject a duplicate via the normal save path. + + ``BaseModel.save()`` runs ``full_clean()``, so the duplicate is caught + by Django model validation before hitting the DB. + """ + user = factories.UserFactory() + thread_event = factories.ThreadEventFactory() + factories.UserEventFactory( + user=user, + thread=thread_event.thread, + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ) + + with pytest.raises(ValidationError): + core_models.UserEvent.objects.create( + user=user, + thread=thread_event.thread, + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ) + + def test_user_event_duplicate_via_bulk_create_raises_integrity_error(self): + """The DB UniqueConstraint must reject duplicates on the bulk_create path. + + ``bulk_create`` bypasses ``full_clean()``, so this exercises the actual + DB-level constraint. This is the path used by ``sync_mention_user_events`` + and it is what protects against races between two concurrent post_save + signals on the same ThreadEvent. + """ + user = factories.UserFactory() + thread_event = factories.ThreadEventFactory() + factories.UserEventFactory( + user=user, + thread=thread_event.thread, + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ) + + with transaction.atomic(), pytest.raises(IntegrityError): + core_models.UserEvent.objects.bulk_create( + [ + core_models.UserEvent( + user=user, + thread=thread_event.thread, + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ) + ] + ) + + def test_user_event_bulk_create_ignore_conflicts_absorbs_duplicates(self): + """``bulk_create(..., ignore_conflicts=True)`` must be idempotent. + + This mirrors the signal behavior in ``sync_mention_user_events``: when + two concurrent flows try to insert the same (user, thread_event, type), + the second one must be silently absorbed by the UniqueConstraint. + """ + user = factories.UserFactory() + thread_event = factories.ThreadEventFactory() + factories.UserEventFactory( + user=user, + thread=thread_event.thread, + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ) + + core_models.UserEvent.objects.bulk_create( + [ + core_models.UserEvent( + user=user, + thread=thread_event.thread, + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ) + ], + ignore_conflicts=True, + ) + + assert ( + core_models.UserEvent.objects.filter( + user=user, + thread_event=thread_event, + type=enums.UserEventTypeChoices.MENTION, + ).count() + == 1 + ) diff --git a/src/backend/e2e/management/commands/e2e_demo.py b/src/backend/e2e/management/commands/e2e_demo.py index 062cf374..04dc4d47 100644 --- a/src/backend/e2e/management/commands/e2e_demo.py +++ b/src/backend/e2e/management/commands/e2e_demo.py @@ -5,6 +5,7 @@ This command creates demo users, mailboxes, shared mailboxes, and outbox test da for E2E testing across different browsers (chromium, firefox, webkit). """ +from django.conf import settings from django.core.management.base import BaseCommand from django.db import transaction from django.utils import timezone @@ -15,6 +16,7 @@ from core.enums import ( MailDomainAccessRoleChoices, MessageDeliveryStatusChoices, ThreadAccessRoleChoices, + ThreadEventTypeChoices, ) from core.services.identity.keycloak import get_keycloak_admin_client @@ -142,7 +144,7 @@ class Command(BaseCommand): # Step 8: Create shared mailbox thread data for IM testing self.stdout.write("\n-- 7/7 💬 Creating shared mailbox thread for IM testing") - self._create_shared_mailbox_thread_data(shared_mailbox) + self._create_shared_mailbox_thread_data(shared_mailbox, regular_users) def _create_user_with_mailbox( self, email, domain, is_domain_admin=False, is_superuser=False @@ -469,8 +471,14 @@ class Command(BaseCommand): return thread - def _create_shared_mailbox_thread_data(self, shared_mailbox): - """Create a thread in the shared mailbox for testing internal messages (IM).""" + def _create_shared_mailbox_thread_data(self, shared_mailbox, regular_users): + """Create a thread in the shared mailbox for testing internal messages (IM). + + Also seeds one pre-aged ThreadEvent per browser user so the + "edit delay elapsed" e2e test can assert on a message whose + `is_editable` has already flipped to false — no runtime ageing + required, keeps the test deterministic and fast. + """ subject = "Shared inbox thread for IM" # Clean up existing thread @@ -509,6 +517,38 @@ class Command(BaseCommand): is_draft=False, ) + # Seed one pre-aged IM ThreadEvent per browser user. + # + # The "edit delay elapsed" test needs a ThreadEvent whose + # `created_at` is older than `MAX_THREAD_EVENT_EDIT_DELAY`, so that + # `is_editable` returns false and the UI hides Edit/Delete actions. + # + # Each browser gets its own aged event authored by its own user so + # the `canModify = isAuthor && is_editable` check in the frontend + # exercises the `is_editable: false` branch (not the `isAuthor: false` + # one). + past = timezone.now() - timezone.timedelta( + seconds=settings.MAX_THREAD_EVENT_EDIT_DELAY + 1 + ) + for user, _mailbox in regular_users: + # Extract "{browser}" from "user.e2e.{browser}@{domain}". + browser = user.email.split("@")[0].removeprefix("user.e2e.") + content = f"[e2e-aged-{browser}] Message past edit delay" + event = models.ThreadEvent.objects.create( + thread=thread, + type=ThreadEventTypeChoices.IM, + author=user, + data={"content": content}, + ) + # Bypass `auto_now` / `auto_now_add` with a raw UPDATE so the + # timestamps actually land in the past. + models.ThreadEvent.objects.filter(pk=event.pk).update( + created_at=past, updated_at=past + ) + self.stdout.write( + self.style.SUCCESS(f" ✓ Seeded aged ThreadEvent for {user.email}") + ) + thread.update_stats() self.stdout.write( diff --git a/src/backend/messages/settings.py b/src/backend/messages/settings.py index 72835e0d..58098e9d 100755 --- a/src/backend/messages/settings.py +++ b/src/backend/messages/settings.py @@ -120,6 +120,16 @@ class Base(Configuration): 500, environ_name="MAX_RECIPIENTS_PER_MESSAGE", environ_prefix=None ) + # Thread events + # Time window (in seconds) during which a ThreadEvent can be edited or + # deleted after creation. Set to 0 to disable the restriction and allow + # edits indefinitely. + MAX_THREAD_EVENT_EDIT_DELAY = values.PositiveIntegerValue( + 60 * 60, # 1 hour in seconds + environ_name="MAX_THREAD_EVENT_EDIT_DELAY", + environ_prefix=None, + ) + # Throttling - limits external recipients per mailbox/maildomain per time period # Format: "count/period" where period is minute, hour, or day. None to disable. THROTTLE_MAILBOX_OUTBOUND_EXTERNAL_RECIPIENTS = ThrottleRateValue( diff --git a/src/e2e/src/__tests__/thread-event.spec.ts b/src/e2e/src/__tests__/thread-event.spec.ts index 3b648483..a3f2cd9e 100644 --- a/src/e2e/src/__tests__/thread-event.spec.ts +++ b/src/e2e/src/__tests__/thread-event.spec.ts @@ -1,7 +1,8 @@ import test, { expect, Page } from "@playwright/test"; import { resetDatabase, getMailboxEmail } from "../utils"; -import { signInKeycloakIfNeeded } from "../utils-test"; +import { signInKeycloakIfNeeded, inboxFolderLink } from "../utils-test"; import { BrowserName } from "../types"; +import { API_URL } from "../constants"; /** * Navigate to the shared mailbox and open the IM test thread. @@ -20,7 +21,7 @@ async function navigateToSharedThread(page: Page, browserName: BrowserName) { await page.waitForLoadState("networkidle"); // Navigate to inbox and open the IM thread - await page.getByRole("link", { name: /^inbox/i }).click(); + await inboxFolderLink(page).click(); await page.waitForLoadState("networkidle"); await page .getByRole("link", { name: "Shared inbox thread for IM" }) @@ -70,7 +71,7 @@ test.describe("Thread Events (Internal Messages)", () => { }) => { await page.waitForLoadState("networkidle"); - await page.getByRole("link", { name: /^inbox/i }).click(); + await inboxFolderLink(page).click(); await page.waitForLoadState("networkidle"); await page @@ -353,7 +354,7 @@ test.describe("Thread Events (Internal Messages)", () => { // Confirmation modal appears const confirmModal = page.getByRole("dialog"); await expect(confirmModal).toBeVisible(); - await expect(confirmModal.getByText("Delete message")).toBeVisible(); + await expect(confirmModal.getByText("Delete internal comment")).toBeVisible(); // Confirm deletion await confirmModal.getByRole("button", { name: "Delete" }).click(); @@ -389,4 +390,165 @@ test.describe("Thread Events (Internal Messages)", () => { await expect(link).toHaveAttribute("rel", "noopener noreferrer"); await expect(link).toHaveText("https://example.com"); }); + + test("should surface a new IM mention in the mailbox list and thread entry", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + // Extract the thread ID from the URL we just landed on so we can POST + // directly against the thread-events endpoint. + const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i); + const threadId = threadMatch?.[1]; + expect(threadId, "thread id should be present in URL").toBeTruthy(); + + // Reuse the existing browser session cookies — they carry both the + // session id and the CSRF token expected by DRF on unsafe verbs. + const cookies = await page.context().cookies(); + const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? ""; + + // Fetch the current user so we can mention ourselves. The UI filters + // out self-mentions, but the backend allows them — POSTing directly is + // the simplest way to create an unread mention visible to the test user. + const meResponse = await page.request.get(`${API_URL}/api/v1.0/users/me/`); + expect(meResponse.ok()).toBeTruthy(); + const me = (await meResponse.json()) as { id: string; full_name: string }; + + // Create an IM mentioning the current user. sync_mention_user_events + // runs in the post_save signal and materialises the UserEvent MENTION + // record that feeds the "Mentioned" folder counter and the thread item + // badge. + const createResponse = await page.request.post( + `${API_URL}/api/v1.0/threads/${threadId}/events/`, + { + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + data: { + type: "im", + data: { + content: `@[${me.full_name}] please take a look`, + mentions: [{ id: me.id, name: me.full_name }], + }, + }, + }, + ); + expect(createResponse.ok()).toBeTruthy(); + const createdEvent = (await createResponse.json()) as { id: string }; + + // Sanity-check the backend state directly so that a later UI assertion + // failure unambiguously points at the frontend (stats query cache, + // wrong mailbox context, etc.) rather than a missing UserEvent. + const mailboxIdMatch = page.url().match(/\/mailbox\/([0-9a-f-]+)/i); + const mailboxId = mailboxIdMatch?.[1]; + expect(mailboxId, "mailbox id should be present in URL").toBeTruthy(); + const statsResponse = await page.request.get( + `${API_URL}/api/v1.0/threads/stats/`, + { + params: { + mailbox_id: mailboxId!, + stats_fields: "has_unread_mention", + has_active: "1", + has_mention: "1", + }, + }, + ); + expect(statsResponse.ok()).toBeTruthy(); + const stats = (await statsResponse.json()) as { has_unread_mention: number }; + expect(stats.has_unread_mention).toBe(1); + + // Click the "Refresh" button to force `refetchMailboxes()`. The returned + // mailbox has `count_unread_mentions=1` which trips the effect in + // MailboxProvider that invalidates both the threads list and the stats + // queries — the exact chain of cache updates we need for the UI + // assertions below to pick up the new mention. + // + // We deliberately avoid `page.goto`/`page.reload` here: full page loads + // hit a Next.js static-export hydration race in MailboxProvider that + // bounces shared-mailbox sessions back to the personal mailbox. + await page.getByRole("button", { name: "Refresh" }).click(); + await page.waitForLoadState("networkidle"); + + // Primary UI assertion: the thread entry in the thread list shows the + // "Unread mention" badge. The thread list is re-fetched on navigation, + // which makes it the most reliable indicator that the mention landed. + const threadLink = page + .getByRole("link", { name: "Shared inbox thread for IM" }) + .first(); + await expect( + threadLink.getByLabel("Unread mention").first(), + ).toBeVisible(); + + // Secondary assertion: the "Mentioned" folder counter in the sidebar. + // Scoped to `nav.mailbox-list` to disambiguate from any thread subject + // that might contain "Mentioned". Uses toContainText so trailing + // whitespace in the span does not fail the match. + const mentionedCounter = page + .locator("nav.mailbox-list .mailbox__item") + .filter({ hasText: "Mentioned" }) + .locator(".mailbox__item-counter"); + await expect(mentionedCounter).toContainText("1"); + + // Cleanup: delete the event we created so subsequent tests (and re-runs + // without db:reset) do not see a lingering unread mention on the thread. + const deleteResponse = await page.request.delete( + `${API_URL}/api/v1.0/threads/${threadId}/events/${createdEvent.id}/`, + { headers: { "X-CSRFToken": csrfToken } }, + ); + expect(deleteResponse.ok()).toBeTruthy(); + }); + + test("should hide edit and delete actions once the edit delay has elapsed", async ({ + page, + browserName, + }) => { + await navigateToSharedThread(page, browserName); + + // The e2e_demo management command seeds one pre-aged ThreadEvent per + // browser user with a browser-scoped content string. That event's + // `created_at` is pushed 2h into the past, so the backend's + // `is_editable` returns false and the frontend's `canModify` guard + // hides Edit/Delete. We locate the bubble by its unique content + // instead of sending a fresh message and ageing it at runtime. + const agedContent = `[e2e-aged-${browserName}] Message past edit delay`; + const agedBubble = page + .locator(".thread-event--im") + .filter({ hasText: agedContent }); + await expect(agedBubble).toBeVisible(); + + // Hover to try and reveal actions — they must remain hidden because + // `canModify` in the component is false. + await agedBubble.locator(".thread-event__bubble").hover(); + await expect( + agedBubble.getByRole("button", { name: "Edit" }), + ).toHaveCount(0); + await expect( + agedBubble.getByRole("button", { name: "Delete" }), + ).toHaveCount(0); + + // And the server must reject the write even if the button were forced: + // this guards against the UI hiding actions while the backend still + // allows edits (or vice versa). + const eventDomId = await agedBubble.getAttribute("id"); + const eventId = eventDomId?.replace(/^thread-event-/, ""); + expect(eventId, "event id should be present on aged bubble").toBeTruthy(); + + const cookies = await page.context().cookies(); + const csrfToken = cookies.find((c) => c.name === "csrftoken")?.value ?? ""; + const threadMatch = page.url().match(/\/thread\/([0-9a-f-]+)/i); + const threadId = threadMatch?.[1]; + const updateResponse = await page.request.patch( + `${API_URL}/api/v1.0/threads/${threadId}/events/${eventId}/`, + { + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + data: { data: { content: "tampered after delay" } }, + }, + ); + expect(updateResponse.status()).toBe(403); + }); }); diff --git a/src/e2e/src/__tests__/thread-starred-read.spec.ts b/src/e2e/src/__tests__/thread-starred-read.spec.ts index eedd5bfd..36a9de80 100644 --- a/src/e2e/src/__tests__/thread-starred-read.spec.ts +++ b/src/e2e/src/__tests__/thread-starred-read.spec.ts @@ -1,6 +1,6 @@ import test, { expect } from "@playwright/test"; import { resetDatabase } from "../utils"; -import { signInKeycloakIfNeeded } from "../utils-test"; +import { signInKeycloakIfNeeded, inboxFolderLink } from "../utils-test"; test.describe("Thread starred", () => { test.beforeAll(async () => { @@ -107,7 +107,7 @@ test.describe("Thread read / unread", () => { await page.waitForLoadState("networkidle"); // Navigate to inbox where received threads exist - await page.getByRole("link", { name: /^inbox/i }).click(); + await inboxFolderLink(page).click(); await page.waitForLoadState("networkidle"); // Open the thread (the IntersectionObserver auto-marks messages as read) @@ -146,7 +146,7 @@ test.describe("Thread read / unread", () => { await page.waitForLoadState("networkidle"); // Navigate to inbox - await page.getByRole("link", { name: /^inbox/i }).click(); + await inboxFolderLink(page).click(); await page.waitForLoadState("networkidle"); // Apply the unread filter first @@ -196,7 +196,7 @@ test.describe("Thread read / unread", () => { await page.waitForLoadState("networkidle"); // Navigate to inbox - await page.getByRole("link", { name: /^inbox/i }).click(); + await inboxFolderLink(page).click(); await page.waitForLoadState("networkidle"); // Verify both threads are visible initially diff --git a/src/e2e/src/utils-test.ts b/src/e2e/src/utils-test.ts index 5cbb07b5..f9900ab9 100644 --- a/src/e2e/src/utils-test.ts +++ b/src/e2e/src/utils-test.ts @@ -1,8 +1,23 @@ -import { expect, Page } from "@playwright/test"; +import { expect, Locator, Page } from "@playwright/test"; import { AUTHENTICATION_URL } from "./constants"; import { getStorageStatePath } from "./utils"; +/** + * Locate the "Inbox" folder link inside the sidebar mailbox list. + * + * Scoped to `nav.mailbox-list` to disambiguate from thread entries whose + * subject contains "inbox" (e.g. "Shared inbox thread for IM"). Matches the + * "Inbox" label case-sensitively — the lowercase "inbox" string that + * appears in textContent from the Material Icons font ligature is skipped + * by the capital-I match. + */ +export const inboxFolderLink = (page: Page): Locator => + page + .locator("nav.mailbox-list .mailbox__item") + .filter({ hasText: /Inbox/ }) + .first(); + export const signInKeycloakIfNeeded = async ({ page, username, navigateTo = "/" }: { page: Page, username: string, navigateTo?: string }) => { // Set up response listener BEFORE navigation to avoid race condition const meResponsePromise = page.waitForResponse((response) => response.url().includes('/api/v1.0/users/me/') && [200, 401].includes(response.status())); diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 072bfdc4..2dfb3332 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -59,8 +59,7 @@ "vitest": "4.0.18" }, "engines": { - "node": ">=22.0.0 <23.0.0", - "npm": ">=10.0.0" + "node": ">=22.0.0 <23.0.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.57.1", diff --git a/src/frontend/public/locales/common/en-US.json b/src/frontend/public/locales/common/en-US.json index 1894cd84..3c36d648 100755 --- a/src/frontend/public/locales/common/en-US.json +++ b/src/frontend/public/locales/common/en-US.json @@ -21,6 +21,8 @@ "{{count}} messages have been updated._other": "{{count}} messages have been updated.", "{{count}} messages imported_one": "{{count}} message imported", "{{count}} messages imported_other": "{{count}} messages imported", + "{{count}} messages mentioning you_one": "{{count}} message mentioning you", + "{{count}} messages mentioning you_other": "{{count}} messages mentioning you", "{{count}} messages of this thread have been deleted._one": "{{count}} message of this thread has been deleted.", "{{count}} messages of this thread have been deleted._other": "{{count}} messages of this thread have been deleted.", "{{count}} minutes ago_one": "{{count}} minute ago", @@ -31,12 +33,18 @@ "{{count}} occurrences_other": "{{count}} occurrences", "{{count}} results_one": "{{count}} result", "{{count}} results_other": "{{count}} results", + "{{count}} results mentioning you_one": "{{count}} result mentioning you", + "{{count}} results mentioning you_other": "{{count}} results mentioning you", "{{count}} selected threads_one": "{{count}} selected thread", "{{count}} selected threads_other": "{{count}} selected threads", "{{count}} starred messages_one": "{{count}} starred message", "{{count}} starred messages_other": "{{count}} starred messages", + "{{count}} starred messages mentioning you_one": "{{count}} starred message mentioning you", + "{{count}} starred messages mentioning you_other": "{{count}} starred messages mentioning you", "{{count}} starred results_one": "{{count}} starred result", "{{count}} starred results_other": "{{count}} starred results", + "{{count}} starred results mentioning you_one": "{{count}} starred result mentioning you", + "{{count}} starred results mentioning you_other": "{{count}} starred results mentioning you", "{{count}} threads are now starred._one": "The thread is now starred.", "{{count}} threads are now starred._other": "{{count}} threads are now starred.", "{{count}} threads have been archived._one": "The thread has been archived.", @@ -55,12 +63,20 @@ "{{count}} threads selected_other": "{{count}} threads selected", "{{count}} unread messages_one": "{{count}} unread message", "{{count}} unread messages_other": "{{count}} unread messages", + "{{count}} unread messages mentioning you_one": "{{count}} unread message mentioning you", + "{{count}} unread messages mentioning you_other": "{{count}} unread messages mentioning you", "{{count}} unread results_one": "{{count}} unread result", "{{count}} unread results_other": "{{count}} unread results", + "{{count}} unread results mentioning you_one": "{{count}} unread result mentioning you", + "{{count}} unread results mentioning you_other": "{{count}} unread results mentioning you", "{{count}} unread starred messages_one": "{{count}} unread starred message", "{{count}} unread starred messages_other": "{{count}} unread starred messages", + "{{count}} unread starred messages mentioning you_one": "{{count}} unread starred message mentioning you", + "{{count}} unread starred messages mentioning you_other": "{{count}} unread starred messages mentioning you", "{{count}} unread starred results_one": "{{count}} unread starred result", "{{count}} unread starred results_other": "{{count}} unread starred results", + "{{count}} unread starred results mentioning you_one": "{{count}} unread starred result mentioning you", + "{{count}} unread starred results mentioning you_other": "{{count}} unread starred results mentioning you", "{{count}} weeks ago_one": "{{count}} week ago", "{{count}} weeks ago_other": "{{count}} weeks ago", "{{count}} years ago_one": "{{count}} year ago", @@ -109,9 +125,9 @@ "Are you sure you want to delete this auto-reply? This action is irreversible!": "Are you sure you want to delete this auto-reply? This action is irreversible!", "Are you sure you want to delete this draft? This action cannot be undone.": "Are you sure you want to delete this draft? This action cannot be undone.", "Are you sure you want to delete this integration? This action is irreversible!": "Are you sure you want to delete this integration? This action is irreversible!", + "Are you sure you want to delete this internal comment? It will be deleted for all users. This action cannot be undone.": "Are you sure you want to delete this internal comment? It will be deleted for all users. This action cannot be undone.", "Are you sure you want to delete this label? This action is irreversible!": "Are you sure you want to delete this label? This action is irreversible!", "Are you sure you want to delete this mailbox? This action is irreversible!": "Are you sure you want to delete this mailbox? This action is irreversible!", - "Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.": "Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.", "Are you sure you want to delete this signature? This action is irreversible!": "Are you sure you want to delete this signature? This action is irreversible!", "Are you sure you want to delete this template? This action is irreversible!": "Are you sure you want to delete this template? This action is irreversible!", "Are you sure you want to reset the password?": "Are you sure you want to reset the password?", @@ -153,6 +169,7 @@ "Close the menu": "Close the menu", "Close this thread": "Close this thread", "Collapse": "Collapse", + "Collapse {{name}}": "Collapse {{name}}", "Collapse all": "Collapse all", "Color: ": "Color: ", "Coming soon": "Coming soon", @@ -199,9 +216,9 @@ "Delete auto-reply \"{{autoreply}}\"": "Delete auto-reply \"{{autoreply}}\"", "Delete draft": "Delete draft", "Delete integration \"{{name}}\"": "Delete integration \"{{name}}\"", + "Delete internal comment": "Delete internal comment", "Delete label \"{{label}}\"": "Delete label \"{{label}}\"", "Delete mailbox {{mailbox}}": "Delete mailbox {{mailbox}}", - "Delete message": "Delete message", "Delete signature \"{{signature}}\"": "Delete signature \"{{signature}}\"", "Delete template \"{{template}}\"": "Delete template \"{{template}}\"", "Delivering": "Delivering", @@ -262,6 +279,7 @@ "Every {{count}} years_one": "Every {{count}} years", "Every {{count}} years_other": "Every {{count}} years", "Expand": "Expand", + "Expand {{name}}": "Expand {{name}}", "Expand all": "Expand all", "Failed to delete auto-reply.": "Failed to delete auto-reply.", "Failed to delete integration.": "Failed to delete integration.", @@ -368,6 +386,7 @@ "Mark as read from here": "Mark as read from here", "Mark as unread": "Mark as unread", "Mark as unread from here": "Mark as unread from here", + "Mentioned": "Mentioned", "Message content": "Message content", "Message from {referer_domain}": "Message from {referer_domain}", "Message sent successfully": "Message sent successfully", @@ -586,6 +605,7 @@ "Unknown": "Unknown", "Unknown user": "Unknown user", "Unread": "Unread", + "Unread mention": "Unread mention", "Unsaved changes": "Unsaved changes", "Unstar": "Unstar", "Unstar this thread": "Unstar this thread", diff --git a/src/frontend/public/locales/common/fr-FR.json b/src/frontend/public/locales/common/fr-FR.json index 001eed67..e28827ae 100755 --- a/src/frontend/public/locales/common/fr-FR.json +++ b/src/frontend/public/locales/common/fr-FR.json @@ -32,6 +32,9 @@ "{{count}} messages imported_one": "{{count}} message importé", "{{count}} messages imported_many": "{{count}} messages importés", "{{count}} messages imported_other": "{{count}} messages importés", + "{{count}} messages mentioning you_one": "{{count}} message vous mentionnant", + "{{count}} messages mentioning you_many": "{{count}} messages vous mentionnant", + "{{count}} messages mentioning you_other": "{{count}} messages vous mentionnant", "{{count}} messages of this thread have been deleted._one": "{{count}} message de cette conversation a été supprimé.", "{{count}} messages of this thread have been deleted._many": "{{count}} messages de cette conversation ont été supprimés.", "{{count}} messages of this thread have been deleted._other": "{{count}} messages de cette conversation ont été supprimés.", @@ -47,15 +50,24 @@ "{{count}} results_one": "{{count}} résultat", "{{count}} results_many": "{{count}} résultats", "{{count}} results_other": "{{count}} résultats", + "{{count}} results mentioning you_one": "{{count}} résultat vous mentionnant", + "{{count}} results mentioning you_many": "{{count}} résultats vous mentionnant", + "{{count}} results mentioning you_other": "{{count}} résultats vous mentionnant", "{{count}} selected threads_one": "{{count}} conversation sélectionnée", "{{count}} selected threads_many": "{{count}} conversations sélectionnées", "{{count}} selected threads_other": "{{count}} conversations sélectionnées", "{{count}} starred messages_one": "{{count}} message suivi", "{{count}} starred messages_many": "{{count}} messages suivis", "{{count}} starred messages_other": "{{count}} messages suivis", + "{{count}} starred messages mentioning you_one": "{{count}} message suivi vous mentionnant", + "{{count}} starred messages mentioning you_many": "{{count}} messages suivis vous mentionnant", + "{{count}} starred messages mentioning you_other": "{{count}} messages suivis vous mentionnant", "{{count}} starred results_one": "{{count}} résultat suivi", "{{count}} starred results_many": "{{count}} résultats suivis", "{{count}} starred results_other": "{{count}} résultats suivis", + "{{count}} starred results mentioning you_one": "{{count}} résultat suivi vous mentionnant", + "{{count}} starred results mentioning you_many": "{{count}} résultats suivis vous mentionnant", + "{{count}} starred results mentioning you_other": "{{count}} résultats suivis vous mentionnant", "{{count}} threads are now starred._one": "{{count}} conversation est maintenant marquée pour suivi.", "{{count}} threads are now starred._many": "{{count}} conversations sont maintenant marquées pour suivi.", "{{count}} threads are now starred._other": "{{count}} conversations sont maintenant marquées pour suivi.", @@ -83,15 +95,27 @@ "{{count}} unread messages_one": "{{count}} message non lu", "{{count}} unread messages_many": "{{count}} messages non lus", "{{count}} unread messages_other": "{{count}} messages non lus", + "{{count}} unread messages mentioning you_one": "{{count}} message non lu vous mentionnant", + "{{count}} unread messages mentioning you_many": "{{count}} messages non lus vous mentionnant", + "{{count}} unread messages mentioning you_other": "{{count}} messages non lus vous mentionnant", "{{count}} unread results_one": "{{count}} résultat non lu", "{{count}} unread results_many": "{{count}} résultats non lus", "{{count}} unread results_other": "{{count}} résultats non lus", + "{{count}} unread results mentioning you_one": "{{count}} résultat non lu vous mentionnant", + "{{count}} unread results mentioning you_many": "{{count}} résultats non lus vous mentionnant", + "{{count}} unread results mentioning you_other": "{{count}} résultats non lus vous mentionnant", "{{count}} unread starred messages_one": "{{count}} message suivi non lu", "{{count}} unread starred messages_many": "{{count}} messages suivis non lus", "{{count}} unread starred messages_other": "{{count}} messages suivis non lus", + "{{count}} unread starred messages mentioning you_one": "{{count}} message suivi non lu vous mentionnant", + "{{count}} unread starred messages mentioning you_many": "{{count}} messages suivis non lus vous mentionnant", + "{{count}} unread starred messages mentioning you_other": "{{count}} messages suivis non lus vous mentionnant", "{{count}} unread starred results_one": "{{count}} résultat suivi non lu", "{{count}} unread starred results_many": "{{count}} résultats suivis non lus", "{{count}} unread starred results_other": "{{count}} résultats suivis non lus", + "{{count}} unread starred results mentioning you_one": "{{count}} résultat suivi non lu vous mentionnant", + "{{count}} unread starred results mentioning you_many": "{{count}} résultats suivis non lus vous mentionnant", + "{{count}} unread starred results mentioning you_other": "{{count}} résultats suivis non lus vous mentionnant", "{{count}} weeks ago_one": "il y a {{count}} semaine", "{{count}} weeks ago_many": "il y a {{count}} semaines", "{{count}} weeks ago_other": "il y a {{count}} semaines", @@ -143,9 +167,9 @@ "Are you sure you want to delete this auto-reply? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette réponse automatique ? Cette action est irréversible !", "Are you sure you want to delete this draft? This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce brouillon ? Cette action est irréversible.", "Are you sure you want to delete this integration? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette intégration ? Cette action est irréversible !", + "Are you sure you want to delete this internal comment? It will be deleted for all users. This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce commentaire interne ? Il sera supprimé pour tous les utilisateurs. Cette action ne peut pas être annulée.", "Are you sure you want to delete this label? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer ce libellé ? Cette action est irréversible !", "Are you sure you want to delete this mailbox? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette boîte aux lettres ? Cette action est irréversible !", - "Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.": "Êtes-vous sûr de vouloir supprimer ce message ? Il sera supprimé pour tous les utilisateurs. Cette action ne peut pas être annulée.", "Are you sure you want to delete this signature? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer cette signature ? Cette action est irréversible !", "Are you sure you want to delete this template? This action is irreversible!": "Êtes-vous sûr de vouloir supprimer ce modèle ? Cette action est irréversible !", "Are you sure you want to reset the password?": "Êtes-vous sûr de vouloir réinitialiser le mot de passe ?", @@ -187,6 +211,7 @@ "Close the menu": "Fermer le menu", "Close this thread": "Fermer cette conversation", "Collapse": "Réduire", + "Collapse {{name}}": "Réduire {{name}}", "Collapse all": "Tout réduire", "Color: ": "Couleur : ", "Coming soon": "Bientôt disponible", @@ -233,9 +258,9 @@ "Delete auto-reply \"{{autoreply}}\"": "Supprimer la réponse automatique \"{{autoreply}}\"", "Delete draft": "Supprimer le brouillon", "Delete integration \"{{name}}\"": "Supprimer l'intégration \"{{name}}\"", + "Delete internal comment": "Supprimer le commentaire interne", "Delete label \"{{label}}\"": "Supprimer le libellé \"{{label}}\"", "Delete mailbox {{mailbox}}": "Supprimer la boîte aux lettres {{mailbox}}", - "Delete message": "Supprimer le message", "Delete signature \"{{signature}}\"": "Supprimer la signature \"{{signature}}\"", "Delete template \"{{template}}\"": "Supprimer le modèle \"{{template}}\"", "Delivering": "En cours d'envoi", @@ -300,6 +325,7 @@ "Every {{count}} years_many": "Tous les {{count}} ans", "Every {{count}} years_other": "Tous les {{count}} ans", "Expand": "Développer", + "Expand {{name}}": "", "Expand all": "Tout développer", "Failed to delete auto-reply.": "Erreur lors de la suppression de la réponse automatique.", "Failed to delete integration.": "Erreur lors de la suppression de l'intégration.", @@ -407,6 +433,7 @@ "Mark as read from here": "Marquer comme lu à partir d'ici", "Mark as unread": "Marquer comme non lu", "Mark as unread from here": "Marquer comme non lu à partir d'ici", + "Mentioned": "Mentionné", "Message content": "Contenu du message", "Message from {referer_domain}": "Message de {referer_domain}", "Message sent successfully": "Message envoyé avec succès", @@ -629,6 +656,7 @@ "Unknown": "Inconnu", "Unknown user": "Utilisateur inconnu", "Unread": "Non lu", + "Unread mention": "Mention non lue", "Unsaved changes": "Modifications non enregistrées", "Unstar": "Désactiver le suivi", "Unstar this thread": "Ne plus suivre cette conversation", diff --git a/src/frontend/src/features/api/gen/models/mailbox.ts b/src/frontend/src/features/api/gen/models/mailbox.ts index ab826255..ccfa4ffa 100644 --- a/src/frontend/src/features/api/gen/models/mailbox.ts +++ b/src/frontend/src/features/api/gen/models/mailbox.ts @@ -18,9 +18,14 @@ export interface Mailbox { /** Whether this mailbox identifies a person (i.e. is not an alias or a group) */ readonly is_identity: boolean; readonly role: MailboxRoleChoices; - readonly count_unread_threads: string; - readonly count_threads: string; - readonly count_delivering: string; + /** Return the number of threads with unread messages in the mailbox. */ + readonly count_unread_threads: number; + /** Return the number of threads in the mailbox. */ + readonly count_threads: number; + /** Return the number of threads with messages being delivered. */ + readonly count_delivering: number; + /** Return the number of threads with unread mentions for the current user. */ + readonly count_unread_mentions: number; /** Instance permissions and capabilities */ readonly abilities: MailboxAbilities; } diff --git a/src/frontend/src/features/api/gen/models/thread.ts b/src/frontend/src/features/api/gen/models/thread.ts index d5a72c61..fc1895d7 100644 --- a/src/frontend/src/features/api/gen/models/thread.ts +++ b/src/frontend/src/features/api/gen/models/thread.ts @@ -20,6 +20,7 @@ export interface Thread { readonly snippet: string; readonly messages: string; readonly has_unread: boolean; + readonly has_unread_mention: boolean; readonly has_trashed: boolean; /** Whether all messages in the thread are trashed */ readonly is_trashed: boolean; @@ -69,4 +70,5 @@ export interface Thread { readonly accesses: readonly ThreadAccessDetail[]; readonly labels: readonly ThreadLabel[]; readonly summary: string; + readonly events_count: number; } diff --git a/src/frontend/src/features/api/gen/models/thread_event.ts b/src/frontend/src/features/api/gen/models/thread_event.ts index fefffa53..96bd86b3 100644 --- a/src/frontend/src/features/api/gen/models/thread_event.ts +++ b/src/frontend/src/features/api/gen/models/thread_event.ts @@ -30,6 +30,8 @@ export interface ThreadEvent { message?: string | null; readonly author: UserWithoutAbilities; data: ThreadEventDataOneOf; + readonly has_unread_mention: boolean; + readonly is_editable: boolean; /** date and time at which a record was created */ readonly created_at: string; /** date and time at which a record was last updated */ diff --git a/src/frontend/src/features/api/gen/models/threads_list_params.ts b/src/frontend/src/features/api/gen/models/threads_list_params.ts index a03fa790..e8386479 100644 --- a/src/frontend/src/features/api/gen/models/threads_list_params.ts +++ b/src/frontend/src/features/api/gen/models/threads_list_params.ts @@ -27,6 +27,10 @@ export type ThreadsListParams = { * Filter threads with draft messages (1=true, 0=false). */ has_draft?: number; + /** + * Filter threads with any mention (read or unread) for the current user (1=true, 0=false). + */ + has_mention?: number; /** * Filter threads that have messages (1=true, 0=false). */ @@ -47,6 +51,10 @@ export type ThreadsListParams = { * Filter threads with unread messages (1=true, 0=false). Requires mailbox_id. */ has_unread?: number; + /** + * Filter threads with unread mentions for the current user (1=true, 0=false). + */ + has_unread_mention?: number; /** * Filter threads that are spam (1=true, 0=false). */ diff --git a/src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts b/src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts index 0d120f54..8b14cb74 100644 --- a/src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts +++ b/src/frontend/src/features/api/gen/models/threads_stats_retrieve_params.ts @@ -24,6 +24,10 @@ export type ThreadsStatsRetrieveParams = { * Filter threads with draft messages (1=true, 0=false). */ has_draft?: number; + /** + * Filter threads with any mention (read or unread) for the current user (1=true, 0=false). + */ + has_mention?: number; /** * Filter threads with messages sent by the user (1=true, 0=false). */ @@ -36,6 +40,10 @@ export type ThreadsStatsRetrieveParams = { * Filter threads that are trashed (1=true, 0=false). */ has_trashed?: number; + /** + * Filter threads with unread mentions for the current user (1=true, 0=false). + */ + has_unread_mention?: number; /** * Filter threads by label slug. */ @@ -52,7 +60,7 @@ export type ThreadsStatsRetrieveParams = { * Comma-separated list of fields to aggregate. Special values: 'all' (count all threads), 'all_unread' (count all unread threads). Boolean fields: has_trashed, has_draft, has_starred, has_attachments, has_archived, - has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages. + has_sender, has_active, has_delivery_pending, has_delivery_failed, is_spam, has_messages, has_unread_mention, has_mention. Unread variants ('_unread' suffix): count threads where the condition is true AND the thread is unread. Examples: 'all,all_unread', 'has_starred,has_starred_unread', 'is_spam,is_spam_unread' */ diff --git a/src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts b/src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts index a67e47e2..0502031d 100644 --- a/src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts +++ b/src/frontend/src/features/api/gen/models/threads_stats_retrieve_stats_fields.ts @@ -15,4 +15,6 @@ export const ThreadsStatsRetrieveStatsFields = { all_unread: "all_unread", has_delivery_failed: "has_delivery_failed", has_delivery_pending: "has_delivery_pending", + has_mention: "has_mention", + has_unread_mention: "has_unread_mention", } as const; diff --git a/src/frontend/src/features/api/gen/thread-events/thread-events.ts b/src/frontend/src/features/api/gen/thread-events/thread-events.ts index 2341d536..69bb77a6 100644 --- a/src/frontend/src/features/api/gen/thread-events/thread-events.ts +++ b/src/frontend/src/features/api/gen/thread-events/thread-events.ts @@ -823,3 +823,124 @@ export const useThreadsEventsDestroy = < return useMutation(mutationOptions, queryClient); }; +/** + * Mark the current user's unread MENTION on this ThreadEvent as read. + +Returns 204 even when no UserEvent matches (idempotent); the thread +event itself is resolved via the standard ``get_object`` lookup so a +missing event yields 404. + */ +export type threadsEventsReadMentionPartialUpdateResponse204 = { + data: void; + status: 204; +}; + +export type threadsEventsReadMentionPartialUpdateResponse404 = { + data: void; + status: 404; +}; + +export type threadsEventsReadMentionPartialUpdateResponseSuccess = + threadsEventsReadMentionPartialUpdateResponse204 & { + headers: Headers; + }; +export type threadsEventsReadMentionPartialUpdateResponseError = + threadsEventsReadMentionPartialUpdateResponse404 & { + headers: Headers; + }; + +export type threadsEventsReadMentionPartialUpdateResponse = + | threadsEventsReadMentionPartialUpdateResponseSuccess + | threadsEventsReadMentionPartialUpdateResponseError; + +export const getThreadsEventsReadMentionPartialUpdateUrl = ( + threadId: string, + id: string, +) => { + return `/api/v1.0/threads/${threadId}/events/${id}/read-mention/`; +}; + +export const threadsEventsReadMentionPartialUpdate = async ( + threadId: string, + id: string, + options?: RequestInit, +): Promise => { + return fetchAPI( + getThreadsEventsReadMentionPartialUpdateUrl(threadId, id), + { + ...options, + method: "PATCH", + }, + ); +}; + +export const getThreadsEventsReadMentionPartialUpdateMutationOptions = < + TError = ErrorType, + TContext = unknown, +>(options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string }, + TContext + >; + request?: SecondParameter; +}): UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string }, + TContext +> => { + const mutationKey = ["threadsEventsReadMentionPartialUpdate"]; + const { mutation: mutationOptions, request: requestOptions } = options + ? options.mutation && + "mutationKey" in options.mutation && + options.mutation.mutationKey + ? options + : { ...options, mutation: { ...options.mutation, mutationKey } } + : { mutation: { mutationKey }, request: undefined }; + + const mutationFn: MutationFunction< + Awaited>, + { threadId: string; id: string } + > = (props) => { + const { threadId, id } = props ?? {}; + + return threadsEventsReadMentionPartialUpdate(threadId, id, requestOptions); + }; + + return { mutationFn, ...mutationOptions }; +}; + +export type ThreadsEventsReadMentionPartialUpdateMutationResult = NonNullable< + Awaited> +>; + +export type ThreadsEventsReadMentionPartialUpdateMutationError = + ErrorType; + +export const useThreadsEventsReadMentionPartialUpdate = < + TError = ErrorType, + TContext = unknown, +>( + options?: { + mutation?: UseMutationOptions< + Awaited>, + TError, + { threadId: string; id: string }, + TContext + >; + request?: SecondParameter; + }, + queryClient?: QueryClient, +): UseMutationResult< + Awaited>, + TError, + { threadId: string; id: string }, + TContext +> => { + const mutationOptions = + getThreadsEventsReadMentionPartialUpdateMutationOptions(options); + + return useMutation(mutationOptions, queryClient); +}; diff --git a/src/frontend/src/features/config/constants.ts b/src/frontend/src/features/config/constants.ts index c0a04510..a4d82fbb 100644 --- a/src/frontend/src/features/config/constants.ts +++ b/src/frontend/src/features/config/constants.ts @@ -21,6 +21,7 @@ export const MESSAGE_IMPORT_TASK_KEY = APP_STORAGE_PREFIX + "message-import-task export const EXTERNAL_IMAGES_CONSENT_KEY = APP_STORAGE_PREFIX + "external-images-consent"; export const THREAD_SELECTED_FILTERS_KEY = APP_STORAGE_PREFIX + "thread-selected-filters"; export const SILENT_LOGIN_RETRY_KEY = APP_STORAGE_PREFIX + "silent-login-retry"; +export const EXPANDED_FOLDERS_KEY = APP_STORAGE_PREFIX + "expanded-folders"; // Enums diff --git a/src/frontend/src/features/forms/components/search-filters-form/index.tsx b/src/frontend/src/features/forms/components/search-filters-form/index.tsx index 9f7d353e..6717e66b 100644 --- a/src/frontend/src/features/forms/components/search-filters-form/index.tsx +++ b/src/frontend/src/features/forms/components/search-filters-form/index.tsx @@ -1,4 +1,4 @@ -import { MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/components/mailbox-list"; +import { ALL_MESSAGES_FOLDER, MAILBOX_FOLDERS } from "@/features/layouts/components/mailbox-panel/components/mailbox-list"; import { SearchHelper } from "@/features/utils/search-helper"; import { Label } from "@gouvfr-lasuite/ui-kit"; import { Button, Checkbox, Input, Select } from "@gouvfr-lasuite/cunningham-react"; @@ -74,7 +74,7 @@ export const SearchFiltersForm = ({ query, onChange }: SearchFiltersFormProps) = value={parsedQuery.in as string ?? 'all_messages'} showLabelWhenSelected={false} onChange={handleChange} - options={MAILBOX_FOLDERS().filter((folder) => folder.searchable).map((folder) => ({ + options={[ALL_MESSAGES_FOLDER(), ...MAILBOX_FOLDERS().filter((folder) => folder.searchable)].map((folder) => ({ label: t(folder.name), render: () => , value: folder.id diff --git a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss index 66b5fc6a..4853b0d4 100644 --- a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss +++ b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/_index.scss @@ -4,7 +4,7 @@ display: flex; align-items: center; gap: var(--c--globals--spacings--2xs); - padding-block: var(--c--globals--spacings--xs); + padding-block: var(--c--globals--spacings--3xs); padding-inline: var(--c--globals--spacings--3xs); color: inherit; font-weight: 500; @@ -39,9 +39,6 @@ transform: translateY(-50%); width: var(--label-toggle-size); height: var(--label-toggle-size); - & .c__button__icon > span { - font-size: var(--c--globals--font--sizes--h3); - } } .label-item__column:last-child { @@ -75,9 +72,9 @@ min-width: 0; .label-item__icon { - border: 2px solid var(--c--contextuals--border--semantic--contextual--primary); - width: 0.875rem; - height: 0.875rem; + border: 1px solid var(--c--contextuals--border--semantic--contextual--primary); + width: 0.75rem; + height: 0.75rem; border-radius: 4px; box-sizing: border-box; padding: 0; diff --git a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx index 669d018c..fe680db5 100644 --- a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx +++ b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-labels/components/label-item/index.tsx @@ -1,6 +1,6 @@ import { TreeLabel, ThreadsStatsRetrieveStatsFields, useLabelsDestroy, useLabelsList, useThreadsStatsRetrieve, ThreadsStatsRetrieve200, useLabelsAddThreadsCreate, useLabelsRemoveThreadsCreate, useLabelsPartialUpdate } from "@/features/api/gen"; -import { useMailboxContext } from "@/features/providers/mailbox"; -import { DropdownMenu, Icon, IconType } from "@gouvfr-lasuite/ui-kit"; +import { getThreadsStatsQueryKey, useMailboxContext } from "@/features/providers/mailbox"; +import { DropdownMenu, Icon, IconSize, IconType } from "@gouvfr-lasuite/ui-kit"; import { Button, useModals } from "@gouvfr-lasuite/cunningham-react"; import clsx from "clsx"; import Link from "next/link"; @@ -44,7 +44,7 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l label_slug: label.slug }, { query: { - queryKey: ['threads', 'stats', selectedMailbox!.id, queryParams], + queryKey: getThreadsStatsQueryKey(selectedMailbox!.id, queryParams), } }); const unreadCount = (stats?.data as ThreadsStatsRetrieve200)?.all_unread ?? 0; @@ -270,7 +270,7 @@ export const LabelItem = ({ level = 0, onEdit, canManage, defaultFoldState, ...l className='label-item__toggle' aria-expanded={isFolded} title={isFolded ? t('Collapse') : t('Expand')} - icon={} + icon={} aria-label={isFolded ? t('Expand') : t('Collapse')} /> )} diff --git a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss index 430a005c..dedf56f5 100644 --- a/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss +++ b/src/frontend/src/features/layouts/components/mailbox-panel/components/mailbox-list/_index.scss @@ -6,6 +6,34 @@ flex-shrink: 0; } +// Wraps a folder link together with its disclosure chevron button. The +// two stay visually attached (one hover background spanning both) but the +// DOM keeps them as siblings so interactive content is not nested inside +// the anchor. +.mailbox__item-row { + display: flex; + flex-direction: row; + align-items: stretch; + border-radius: 4px; + + &:has(.mailbox__item--drag-over), + &:has(.mailbox__item:not(.mailbox__item--active):hover), + &:has(.mailbox__item:not(.mailbox__item--active):focus), + &:has(.mailbox__item-chevron:hover), + &:has(.mailbox__item-chevron:focus-visible), + &:has(.mailbox__item--active) { + background-color: var(--c--contextuals--background--semantic--contextual--primary); + & > .mailbox__item { + background: none; + } + } + + .mailbox__item { + flex: 1; + min-width: 0; + } +} + .mailbox__item { color: inherit; display: flex; @@ -14,11 +42,19 @@ align-items: center; width: auto; padding-inline: var(--c--globals--spacings--xs); - padding-block: var(--c--globals--font--sizes--t); + padding-block: var(--c--globals--spacings--3xs); + min-height: 2rem; border-radius: 4px; text-decoration: none; } +// When the folder has a disclosure chevron, the button sits flush to the +// left of the link — remove the link's left padding so the label lines up +// where the chevron used to live and children keep their existing indent. +.mailbox__item--with-chevron { + padding-inline-start: 0; +} + .mailbox__item--drag-over, .mailbox__item:not(.mailbox__item--active):hover, .mailbox__item:not(.mailbox__item--active):focus { @@ -61,3 +97,38 @@ color: var(--c--contextuals--content--semantic--error--tertiary); font-size: var(--c--globals--font--sizes--xl); } + +// Native + {link} + ) } diff --git a/src/frontend/src/features/layouts/components/thread-panel/_index.scss b/src/frontend/src/features/layouts/components/thread-panel/_index.scss index 6f00b196..62dfde38 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-panel/_index.scss @@ -47,6 +47,10 @@ .thread-panel__header--count { white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1 1 auto; } } } @@ -56,6 +60,8 @@ flex-direction: row; align-items: center; gap: var(--c--globals--spacings--4xs); + flex-shrink: 0; + margin-left: auto; } .thread-panel__threads_list { diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx index bfbc3f12..ada05600 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/index.tsx @@ -170,27 +170,81 @@ export const ThreadItem = ({ thread, isSelected, onToggleSelection, selectedThre type={IconType.FILLED} name="mode_edit" className="icon--size-sm" + aria-hidden="true" /> )} {thread.has_attachments ? ( - - + + ) : null} + {thread.has_starred && ( + + + )} + {thread.has_unread_mention && ( + + + )} {thread.has_delivery_failed && ( - - + + )} {!thread.has_delivery_failed && thread.has_delivery_pending && ( - - - - )} - {thread.has_starred && ( - - + + )} diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx index a8031afb..d5026a89 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-filter.tsx @@ -1,70 +1,56 @@ -import { useSearchParams } from "next/navigation"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button, Tooltip } from "@gouvfr-lasuite/cunningham-react"; import { ContextMenu, Icon, IconType } from "@gouvfr-lasuite/ui-kit"; import { THREAD_SELECTED_FILTERS_KEY } from "@/features/config/constants"; import { useMailboxContext } from "@/features/providers/mailbox"; -import { useSafeRouterPush } from "@/hooks/use-safe-router-push"; -import { useThreadPanelFilters } from "../hooks/use-thread-panel-filters"; - -export const THREAD_PANEL_FILTER_PARAMS = [ - "has_unread", - "has_starred", -] as const; - -export type FilterType = (typeof THREAD_PANEL_FILTER_PARAMS)[number]; +import { + DEFAULT_SELECTED_FILTERS, + THREAD_PANEL_FILTER_PARAMS, + useThreadPanelFilters, + type FilterType, +} from "../hooks/use-thread-panel-filters"; const getStoredSelectedFilters = (): FilterType[] => { try { const stored = JSON.parse( localStorage.getItem(THREAD_SELECTED_FILTERS_KEY) ?? "[]", ); - if ( - Array.isArray(stored) && - stored.length > 0 && - stored.every((s: string) => THREAD_PANEL_FILTER_PARAMS.includes(s as FilterType)) - ) { - return stored; + if (Array.isArray(stored) && stored.length > 0) { + const validFilters = stored.filter( + (value): value is FilterType => + typeof value === "string" && + THREAD_PANEL_FILTER_PARAMS.includes(value as FilterType), + ); + if (validFilters.length > 0) { + return validFilters; + } } } catch { // ignore } - return ["has_unread"]; + return DEFAULT_SELECTED_FILTERS; }; export const ThreadPanelFilter = () => { const { t } = useTranslation(); - const searchParams = useSearchParams(); - const safePush = useSafeRouterPush(); const [selectedFilters, setSelectedFilters] = useState(getStoredSelectedFilters); const { threads } = useMailboxContext(); - const { hasActiveFilters, activeFilters } = useThreadPanelFilters(); + const { hasActiveFilters, activeFilters, applyFilters, clearFilters } = + useThreadPanelFilters(); const isDisabled = !threads?.results.length && !hasActiveFilters; const filterLabels: Record = useMemo( () => ({ has_unread: t("Unread"), has_starred: t("Starred"), + has_mention: t("Mentioned"), }), [t], ); - const applyFilters = (filters: FilterType[]) => { - const params = new URLSearchParams(searchParams.toString()); - THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param)); - filters.forEach((filter) => params.set(filter, "1")); - safePush(params); - }; - - const clearFilters = () => { - const params = new URLSearchParams(searchParams.toString()); - THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param)); - safePush(params); - }; - const handleToggleClick = () => { if (hasActiveFilters) { clearFilters(); @@ -77,7 +63,7 @@ export const ThreadPanelFilter = () => { const toggled = selectedFilters.includes(type) ? selectedFilters.filter((f) => f !== type) : [...selectedFilters, type]; - const next = toggled.length > 0 ? toggled : ["has_unread"] as FilterType[]; + const next = toggled.length > 0 ? toggled : DEFAULT_SELECTED_FILTERS; setSelectedFilters(next); localStorage.setItem(THREAD_SELECTED_FILTERS_KEY, JSON.stringify(next)); if (hasActiveFilters) { diff --git a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx index 478cbb4e..ffcbd380 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-panel-header.tsx @@ -1,5 +1,5 @@ import { useSearchParams } from "next/navigation"; -import { MAILBOX_FOLDERS } from "../../mailbox-panel/components/mailbox-list"; +import { findRootFolder } from "../../mailbox-panel/components/mailbox-list"; import { useLabelsList } from "@/features/api/gen"; import { useMailboxContext } from "@/features/providers/mailbox"; import { useTranslation } from "react-i18next"; @@ -12,8 +12,8 @@ import useArchive from "@/features/message/use-archive"; import useSpam from "@/features/message/use-spam"; import useTrash from "@/features/message/use-trash"; import useStarred from "@/features/message/use-starred"; -import { ThreadPanelFilter, THREAD_PANEL_FILTER_PARAMS } from "./thread-panel-filter"; -import { useThreadPanelFilters } from "../hooks/use-thread-panel-filters"; +import { ThreadPanelFilter } from "./thread-panel-filter"; +import { THREAD_PANEL_FILTER_PARAMS, useThreadPanelFilters } from "../hooks/use-thread-panel-filters"; import { SelectionReadStatus, SelectionStarredStatus } from "@/features/providers/thread-selection"; type ThreadPanelTitleProps = { @@ -47,19 +47,18 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is const isSentView = ViewHelper.isSentView(); const isDraftsView = ViewHelper.isDraftsView(); - const { hasActiveFilters, activeFilters } = useThreadPanelFilters(); - - const folderSearchParams = useMemo(() => { - const params = new URLSearchParams(searchParams.toString()); - THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param)); - return params; - }, [searchParams]); + const { activeFilters } = useThreadPanelFilters(); const title = useMemo(() => { if (searchParams.has('search')) return t('folder.search', { defaultValue: 'Search' }); if (searchParams.has('label_slug')) return (labelsQuery.data?.data || []).find((label) => label.slug === searchParams.get('label_slug'))?.name; - return MAILBOX_FOLDERS().find((folder) => new URLSearchParams(folder.filter).toString() === folderSearchParams.toString())?.name; - }, [searchParams, folderSearchParams, labelsQuery.data?.data, selectedMailbox, t]) + // Thread panel filters stack on top of the folder filter — strip them + // so the matching resolves to the underlying folder. + const folderParams = new URLSearchParams(searchParams.toString()); + THREAD_PANEL_FILTER_PARAMS.forEach((param) => folderParams.delete(param)); + const activeFolder = findRootFolder((folder) => new URLSearchParams(folder.filter).toString() === folderParams.toString()); + return activeFolder?.name; + }, [searchParams, labelsQuery.data?.data, selectedMailbox, t]) const handleSelectAllToggle = () => { if (isAllSelected) { @@ -96,6 +95,18 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is const unstarLabel = t('Unstar'); const countLabel = useMemo(() => { if (isSearch) { + if (activeFilters.has_mention && activeFilters.has_unread && activeFilters.has_starred) { + return t('{{count}} unread starred results mentioning you', { count: threads?.count, defaultValue_one: '{{count}} unread starred result mentioning you' }); + } + if (activeFilters.has_mention && activeFilters.has_unread) { + return t('{{count}} unread results mentioning you', { count: threads?.count, defaultValue_one: '{{count}} unread result mentioning you' }); + } + if (activeFilters.has_mention && activeFilters.has_starred) { + return t('{{count}} starred results mentioning you', { count: threads?.count, defaultValue_one: '{{count}} starred result mentioning you' }); + } + if (activeFilters.has_mention) { + return t('{{count}} results mentioning you', { count: threads?.count, defaultValue_one: '{{count}} result mentioning you' }); + } if (activeFilters.has_unread && activeFilters.has_starred) { return t('{{count}} unread starred results', { count: threads?.count, defaultValue_one: '{{count}} unread starred result' }); } @@ -108,6 +119,18 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is return t('{{count}} results', { count: threads?.count, defaultValue_one: '{{count}} result' }); } else { + if (activeFilters.has_mention && activeFilters.has_unread && activeFilters.has_starred) { + return t('{{count}} unread starred messages mentioning you', { count: threads?.count, defaultValue_one: '{{count}} unread starred message mentioning you' }); + } + if (activeFilters.has_mention && activeFilters.has_unread) { + return t('{{count}} unread messages mentioning you', { count: threads?.count, defaultValue_one: '{{count}} unread message mentioning you' }); + } + if (activeFilters.has_mention && activeFilters.has_starred) { + return t('{{count}} starred messages mentioning you', { count: threads?.count, defaultValue_one: '{{count}} starred message mentioning you' }); + } + if (activeFilters.has_mention) { + return t('{{count}} messages mentioning you', { count: threads?.count, defaultValue_one: '{{count}} message mentioning you' }); + } if (activeFilters.has_unread && activeFilters.has_starred) { return t('{{count}} unread starred messages', { count: threads?.count, defaultValue_one: '{{count}} unread starred message' }); } @@ -119,7 +142,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is } return t('{{count}} messages', { count: threads?.count, defaultValue_one: '{{count}} message' }); } - }, [hasActiveFilters, activeFilters, isSearch, threads?.count, t]); + }, [activeFilters, isSearch, threads?.count, t]); return (
@@ -137,7 +160,7 @@ const ThreadPanelTitle = ({ selectedThreadIds, isAllSelected, isSomeSelected, is className="thread-panel__header--checkbox" /> )} -

+

{countLabel}

diff --git a/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts b/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts index be765b91..68e6367a 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts +++ b/src/frontend/src/features/layouts/components/thread-panel/hooks/use-thread-panel-filters.ts @@ -1,11 +1,20 @@ import { useSearchParams } from "next/navigation"; -import { - THREAD_PANEL_FILTER_PARAMS, - type FilterType, -} from "../components/thread-panel-filter"; +import { useCallback } from "react"; +import { useSafeRouterPush } from "@/hooks/use-safe-router-push"; + +export const THREAD_PANEL_FILTER_PARAMS = [ + "has_unread", + "has_starred", + "has_mention", +] as const; + +export type FilterType = (typeof THREAD_PANEL_FILTER_PARAMS)[number]; + +export const DEFAULT_SELECTED_FILTERS: FilterType[] = ["has_unread"]; export const useThreadPanelFilters = () => { const searchParams = useSearchParams(); + const safePush = useSafeRouterPush(); const activeFilters = THREAD_PANEL_FILTER_PARAMS.reduce( (acc, param) => { @@ -17,5 +26,21 @@ export const useThreadPanelFilters = () => { const hasActiveFilters = Object.values(activeFilters).some(Boolean); - return { hasActiveFilters, activeFilters }; + const applyFilters = useCallback( + (filters: FilterType[]) => { + const params = new URLSearchParams(searchParams.toString()); + THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param)); + filters.forEach((filter) => params.set(filter, "1")); + safePush(params); + }, + [searchParams, safePush], + ); + + const clearFilters = useCallback(() => { + const params = new URLSearchParams(searchParams.toString()); + THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param)); + safePush(params); + }, [searchParams, safePush]); + + return { hasActiveFilters, activeFilters, applyFilters, clearFilters }; }; diff --git a/src/frontend/src/features/layouts/components/thread-panel/index.tsx b/src/frontend/src/features/layouts/components/thread-panel/index.tsx index b22315b2..b7a4e06a 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-panel/index.tsx @@ -9,16 +9,13 @@ import { useSearchParams } from "next/navigation"; import ThreadPanelHeader from "./components/thread-panel-header"; import { useThreadSelection } from "@/features/providers/thread-selection"; import { useScrollRestore } from "@/features/providers/scroll-restore"; -import { THREAD_PANEL_FILTER_PARAMS } from "./components/thread-panel-filter"; -import { useSafeRouterPush } from "@/hooks/use-safe-router-push"; import { useThreadPanelFilters } from "./hooks/use-thread-panel-filters"; export const ThreadPanel = () => { const { threads, queryStates, unselectThread, loadNextThreads, selectedThread, selectedMailbox } = useMailboxContext(); const searchParams = useSearchParams(); - const safePush = useSafeRouterPush(); const isSearch = searchParams.has('search'); - const { hasActiveFilters } = useThreadPanelFilters(); + const { hasActiveFilters, clearFilters } = useThreadPanelFilters(); const { t } = useTranslation(); const loaderRef = useRef(null); const scrollContextKey = `${selectedMailbox?.id}:${searchParams.toString()}`; @@ -74,12 +71,6 @@ export const ThreadPanel = () => { ); } - const clearFilters = () => { - const params = new URLSearchParams(searchParams.toString()); - THREAD_PANEL_FILTER_PARAMS.forEach((param) => params.delete(param)); - safePush(params); - }; - const isEmpty = !threads?.results.length; return ( diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss index 71d72cd1..0805c9f2 100755 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/_index.scss @@ -49,6 +49,15 @@ border-radius: var(--c--globals--spacings--3xs) var(--c--globals--spacings--base) var(--c--globals--spacings--base) var(--c--globals--spacings--base); } +// Unread mention: a compact accessible badge (icon + text) sits in the +// header, pushed to the right. Keeping the indicator on the header — not +// on the bubble — avoids distracting from the message content itself. +.thread-event__mention-badge { + margin-left: auto; + gap: var(--c--globals--spacings--4xs); + transition: opacity 0.2s ease; +} + // Condensed: consecutive IMs from same author within 2 min .thread-event--condensed { margin-top: calc(-1 * var(--c--globals--spacings--xs)); diff --git a/src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx index 6e692e49..de48c6ed 100755 --- a/src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx +++ b/src/frontend/src/features/layouts/components/thread-view/components/thread-event/index.tsx @@ -5,8 +5,10 @@ import { useThreadsEventsDestroy } from "@/features/api/gen/thread-events/thread import { useTranslation } from "react-i18next"; import { useAuth } from "@/features/auth"; import { useMailboxContext } from "@/features/providers/mailbox"; -import { AVATAR_COLORS, Icon, IconType, UserAvatar } from "@gouvfr-lasuite/ui-kit"; +import { Badge } from "@/features/ui/components/badge"; +import { AVATAR_COLORS, Icon, IconSize, IconType, UserAvatar } from "@gouvfr-lasuite/ui-kit"; import { Button, useModals } from "@gouvfr-lasuite/cunningham-react"; +import clsx from "clsx"; const TWO_MINUTES_MS = 2 * 60 * 1000; @@ -24,9 +26,23 @@ const getAvatarColor = (name: string): string => { type ThreadEventProps = { event: ThreadEventType; - previousEvent?: ThreadEventType | null; + isCondensed?: boolean; onEdit?: (event: ThreadEventType) => void; onDelete?: (eventId: string) => void; + /** + * Ref setter wired by the parent when the event carries an unread mention, + * used by the IntersectionObserver that marks mentions as read on scroll. + * Receives the bubble element — the observer needs a target with a real + * bounding box to reliably report intersections. + */ + mentionRef?: (el: HTMLDivElement | null) => void; + /** + * True when this event OR any subsequent event condensed with it carries + * an unread mention for the current user. Drives the badge shown in the + * header. Computed by the parent so the first event of a condensed group + * surfaces mentions that would otherwise be hidden on condensed siblings. + */ + hasUnreadMention?: boolean; }; const formatTime = (dateString: string) => { @@ -43,7 +59,7 @@ const formatTime = (dateString: string) => { * Returns true if this IM event should show a condensed view (no header), * because the previous event is also an IM from the same author within 2 minutes. */ -const isCondensed = (event: ThreadEventType, previousEvent?: ThreadEventType | null): boolean => { +export const isCondensed = (event: ThreadEventType, previousEvent?: ThreadEventType | null): boolean => { if (!previousEvent) return false; if (event.type !== ThreadEventTypeEnum.im || previousEvent.type !== ThreadEventTypeEnum.im) return false; if (event.author?.id !== previousEvent.author?.id) return false; @@ -57,15 +73,17 @@ const isCondensed = (event: ThreadEventType, previousEvent?: ThreadEventType | n * Consecutive IMs from the same author within 2 minutes are condensed (no header). * For other types: renders a minimal card with type badge and data. */ -export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEventProps) => { +export const ThreadEvent = ({ event, isCondensed = false, onEdit, onDelete, mentionRef, hasUnreadMention = false }: ThreadEventProps) => { const { t } = useTranslation(); const { user } = useAuth(); const modals = useModals(); const { invalidateThreadEvents } = useMailboxContext(); const content = event.data?.content ?? ""; - const condensed = isCondensed(event, previousEvent); - const isSender = event.author?.id === user?.id; + const isAuthor = event.author?.id === user?.id; const isEdited = Math.abs(new Date(event.updated_at).getTime() - new Date(event.created_at).getTime()) > 1000; + // Edit/delete actions are only available to the author while the + // server-side edit delay has not elapsed (MAX_THREAD_EVENT_EDIT_DELAY). + const canModify = isAuthor && event.is_editable; const deleteEvent = useThreadsEventsDestroy(); const [showActions, setShowActions] = useState(false); @@ -108,8 +126,8 @@ export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEv const handleDelete = async () => { const decision = await modals.deleteConfirmationModal({ - title: {t('Delete message')}, - children: t('Are you sure you want to delete this message? It will be deleted for all users. This action cannot be undone.'), + title: {t('Delete internal comment')}, + children: t('Are you sure you want to delete this internal comment? It will be deleted for all users. This action cannot be undone.'), }); if (decision !== 'delete') return; deleteEvent.mutate( @@ -128,25 +146,34 @@ export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEv ? event.data?.mentions?.map((m) => m.id)?.includes(user.id) : false; - const imClasses = [ + const imClasses = clsx( "thread-event", "thread-event--im", - condensed && "thread-event--condensed", - isSender && "thread-event--sender", - ].filter(Boolean).join(" "); + { + "thread-event--condensed": isCondensed, + }, + ); const bubbleStyle = { "--thread-event-color": `var(--c--contextuals--background--palette--${avatarColor}--primary)`, } as React.CSSProperties; return ( -
+
{ + // Combined ref: keeps the local bubbleRef (used for + // click-outside detection) and the parent-provided + // mentionRef (used by the IntersectionObserver that + // acknowledges unread mentions on scroll) in sync. + bubbleRef.current = el; + mentionRef?.(el); + }} className={`thread-event__bubble${showActions ? " thread-event__bubble--actions-visible" : ""}`} style={bubbleStyle} + data-event-id={event.id} > - {!condensed && ( + {!isCondensed && (
@@ -155,14 +182,31 @@ export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEv {formatTime(event.created_at)} + {hasUnreadMention && ( + + + )}
)}
{TextHelper.renderLinks( TextHelper.renderMentions( @@ -175,7 +219,7 @@ export const ThreadEvent = ({ event, previousEvent, onEdit, onDelete }: ThreadEv ({t("edited")}) )}
- {isSender && ( + {canModify && (