From a9ab4e642a187b42c101c19f031d81386b21c30c Mon Sep 17 00:00:00 2001 From: Nicolas Aunai Date: Sat, 18 Jul 2026 21:34:01 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend+backend)=20show=20per-thread?= =?UTF-8?q?=20message=20count=20badge=20in=20mailbox=20list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a message_count field to the ThreadSerializer (counting non-draft messages, distinct to avoid JOIN multiplication) and renders a neutral badge in the thread list when the count is greater than one. Signed-off-by: Nicolas Aunai --- CHANGELOG.md | 4 + src/backend/core/api/openapi.json | 5 + src/backend/core/api/serializers.py | 2 + src/backend/core/api/viewsets/thread.py | 3 + .../core/tests/api/test_threads_list.py | 104 ++++++++++++++++++ .../src/features/api/gen/models/thread.ts | 1 + .../components/thread-item/index.tsx | 11 ++ 7 files changed, 130 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5838b28d1..c8bcba811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to - Bump keycloak to 26.6.3 - Bump keycloak to 26.6.4 +### Added + +- ✨(frontend+backend) show per-thread message count badge in mailbox list + ### Fixed - 🐛(mta-out) fix relay block indentation breaking SASL auth #733 diff --git a/src/backend/core/api/openapi.json b/src/backend/core/api/openapi.json index 32f2b9355..bc48e7740 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -10132,6 +10132,10 @@ "type": "integer", "readOnly": true }, + "message_count": { + "type": "integer", + "readOnly": true + }, "abilities": { "type": "object", "additionalProperties": { @@ -10171,6 +10175,7 @@ "is_spam", "is_trashed", "labels", + "message_count", "messaged_at", "messages", "sender_messaged_at", diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 7ea1873d7..70bca3b2d 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -837,6 +837,7 @@ class ThreadSerializer(serializers.ModelSerializer): labels = serializers.SerializerMethodField() summary = serializers.CharField(read_only=True) events_count = serializers.IntegerField(read_only=True) + message_count = serializers.IntegerField(read_only=True) abilities = serializers.SerializerMethodField(read_only=True) assigned_users = serializers.SerializerMethodField(read_only=True) @@ -1019,6 +1020,7 @@ class Meta: "labels", "summary", "events_count", + "message_count", "abilities", "assigned_users", ] diff --git a/src/backend/core/api/viewsets/thread.py b/src/backend/core/api/viewsets/thread.py index 902318231..42d65d0c6 100644 --- a/src/backend/core/api/viewsets/thread.py +++ b/src/backend/core/api/viewsets/thread.py @@ -210,6 +210,9 @@ def _annotate_thread_permissions(queryset, user, mailbox_id): ) ), events_count=Count("events", distinct=True), + message_count=Count( + "messages", filter=Q(messages__is_draft=False), distinct=True + ), _can_edit=Exists(can_edit_qs), ).prefetch_related( # Feeds ThreadSerializer.get_assigned_users without N+1. UserEvent diff --git a/src/backend/core/tests/api/test_threads_list.py b/src/backend/core/tests/api/test_threads_list.py index b1148c2b2..97adf6764 100644 --- a/src/backend/core/tests/api/test_threads_list.py +++ b/src/backend/core/tests/api/test_threads_list.py @@ -1941,6 +1941,110 @@ def test_list_threads_events_count_distinct_per_thread(self, api_client, url): assert payload["events_count"] == 2 +class TestThreadListMessageCount: + """Test that ThreadSerializer exposes message_count on the list endpoint. + + message_count drives the per-thread email-count badge in the mailbox list. + It counts non-draft messages only. + """ + + @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_message_count_zero_when_no_messages(self, api_client, url): + """A thread without any message should expose message_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["message_count"] == 0 + + def test_list_threads_message_count_matches_messages(self, api_client, url): + """message_count should equal the number of non-draft messages.""" + user, mailbox, thread = self._setup_user_with_thread() + api_client.force_authenticate(user=user) + + MessageFactory(thread=thread) + MessageFactory(thread=thread) + MessageFactory(thread=thread) + + 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["message_count"] == 3 + + def test_list_threads_message_count_excludes_drafts(self, api_client, url): + """Drafts must not be counted in message_count.""" + user, mailbox, thread = self._setup_user_with_thread() + api_client.force_authenticate(user=user) + + MessageFactory(thread=thread) + MessageFactory(thread=thread, is_draft=True) + MessageFactory(thread=thread, is_draft=True) + + 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["message_count"] == 1 + + def test_list_threads_message_count_distinct_per_thread(self, api_client, url): + """message_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 message count. + """ + user, mailbox, thread = self._setup_user_with_thread() + api_client.force_authenticate(user=user) + + 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, + ) + + MessageFactory(thread=thread) + MessageFactory(thread=thread) + + 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["message_count"] == 2 + + class TestThreadListQueryCount: """Regression guard for N+1 queries on the thread list endpoint. diff --git a/src/frontend/src/features/api/gen/models/thread.ts b/src/frontend/src/features/api/gen/models/thread.ts index 41dde4b62..4a4661e67 100644 --- a/src/frontend/src/features/api/gen/models/thread.ts +++ b/src/frontend/src/features/api/gen/models/thread.ts @@ -74,6 +74,7 @@ export interface Thread { readonly labels: readonly ThreadLabel[]; readonly summary: string; readonly events_count: number; + readonly message_count: number; readonly abilities: ThreadAbilities; readonly assigned_users: readonly ThreadEventUser[]; } 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 e633d6e97..4234f7f34 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 @@ -205,6 +205,17 @@ export const ThreadItem = ({ thread, isSelected, onToggle, onSelectRange, select

{thread.subject || t('No subject')}

+ {thread.message_count > 1 && ( + + {thread.message_count} + + )} {thread.has_draft && (