diff --git a/CHANGELOG.md b/CHANGELOG.md index 5838b28d1..97976bd32 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 + +- Expand thread-list rows with a chevron to show per-message summaries (sender, date, snippet, read state) and jump directly to a message from its summary + ### 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 869ee882d..1b1b0c532 100644 --- a/src/backend/core/api/openapi.json +++ b/src/backend/core/api/openapi.json @@ -10191,6 +10191,10 @@ "type": "integer", "readOnly": true }, + "message_count": { + "type": "integer", + "readOnly": true + }, "abilities": { "type": "object", "additionalProperties": { @@ -10230,6 +10234,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..207c96532 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", ] @@ -1075,6 +1077,36 @@ class Meta: read_only_fields = fields +class MessageSummarySerializer(serializers.ModelSerializer): + """Lightweight per-message summary for the thread-list expand dropdown. + + Reads only stored fields β never Message.get_parsed_data(), which hits + object storage and re-parses MIME on every call. Used behind the + ``?summary=true`` query param on MessageViewSet's list action. + """ + + sender = ContactSerializer(read_only=True) + is_unread = serializers.SerializerMethodField(read_only=True) + + @extend_schema_field(serializers.BooleanField()) + def get_is_unread(self, instance): + """Return the ``_is_unread`` annotation set by ``MessageQuerySet.with_read_state()``.""" + return getattr(instance, "_is_unread", False) + + class Meta: + model = models.Message + fields = [ + "id", + "sender", + "sent_at", + "is_unread", + "is_draft", + "has_attachments", + "snippet", + ] + read_only_fields = fields + + class MessageSerializer(serializers.ModelSerializer): """ Serialize messages, getting parsed details from the Message model. diff --git a/src/backend/core/api/viewsets/message.py b/src/backend/core/api/viewsets/message.py index a92d29a79..0689e6ed1 100644 --- a/src/backend/core/api/viewsets/message.py +++ b/src/backend/core/api/viewsets/message.py @@ -72,6 +72,16 @@ class MessageViewSet( lookup_field = "id" lookup_url_kwarg = "id" + def get_serializer_class(self): + """Use the lightweight summary serializer for ?summary=true list requests. + + Powers the thread-list expand dropdown, which only needs + sender/date/snippet/unread per message β never the full body. + """ + if self.action == "list" and self.request.GET.get("summary") == "true": + return serializers.MessageSummarySerializer + return super().get_serializer_class() + def get_queryset(self): """Restrict results to messages in threads accessible by the current user.""" user = self.request.user 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/mda/autoreply.py b/src/backend/core/mda/autoreply.py index d3a9d40ba..c03f37307 100644 --- a/src/backend/core/mda/autoreply.py +++ b/src/backend/core/mda/autoreply.py @@ -294,7 +294,7 @@ def send_autoreply_for_message( message.blob = models.Blob.objects.create_blob( content=signed_mime, content_type="message/rfc822" ) - message.save(update_fields=["mime_id", "blob", "has_attachments"]) + message.save(update_fields=["mime_id", "blob", "has_attachments", "snippet"]) # Trigger async send (outside transaction to avoid sending before commit) send_message_task.delay(str(message.id)) diff --git a/src/backend/core/mda/inbound_create.py b/src/backend/core/mda/inbound_create.py index f157f50df..93e97ce75 100644 --- a/src/backend/core/mda/inbound_create.py +++ b/src/backend/core/mda/inbound_create.py @@ -473,6 +473,10 @@ def _create_message_from_inbound( # pylint: disable=too-many-arguments thread=thread, sender=sender_contact, subject=subject, + snippet=thread_snippet( + parsed_email, + fallback=subject or "(No snippet available)", + ), blob=blob, mime_id=first_msgid(parsed_email.get("messageId")) or None, parent=parent_message, diff --git a/src/backend/core/mda/outbound.py b/src/backend/core/mda/outbound.py index 68209fb5a..580503d71 100644 --- a/src/backend/core/mda/outbound.py +++ b/src/backend/core/mda/outbound.py @@ -29,7 +29,7 @@ from core.mda.replies import make_forward, make_reply from core.mda.signing import sign_message_dkim, verify_message_dkim from core.mda.smtp import send_smtp_mail -from core.mda.utils import current_sent_at +from core.mda.utils import current_sent_at, thread_snippet from core.services.blob_gc import schedule_for_gc from core.services.dns.check import check_spf_status from core.services.throttle import check_and_increment_throttle @@ -211,6 +211,13 @@ def compose_and_sign_mime( "messageId": [message.mime_id] if message.mime_id else None, } + # Mutated in memory like mime_id/has_attachments above; the caller + # persists it (see _finalize_sent_message's update_fields). + message.snippet = thread_snippet( + {"textBody": mime_data["textBody"]}, + fallback=message.subject or "", + ) + # Advertise the sending application via X-Mailer (see build_xmailer_value). mime_data["headers"] = [{"name": "X-Mailer", "value": build_xmailer_value()}] @@ -346,6 +353,7 @@ def prepare_outbound_message( # unparseable input (already rejected upstream by the submit view). parsed = parse_email(raw_mime) if parsed is not None: + message.snippet = thread_snippet(parsed, fallback=message.subject or "") if not find_header(parsed, "to"): raw_mime = UNDISCLOSED_RECIPIENTS_TO_HEADER + b"\r\n" + raw_mime # Mirror the composed-body path: advertise the sending application @@ -371,7 +379,9 @@ def prepare_outbound_message( # TODO: Fetch MIME IDs of "references" from the thread # references = message.thread.messages.exclude(id=message.id).order_by("-created_at").all() - # TODO: set the thread snippet? + # Message.snippet is set inside compose_and_sign_mime, from the + # already-in-memory composed body. + # TODO: set the thread-level snippet? # Insert the validated signature validated_signature = mailbox_sender.get_validated_signature( @@ -491,6 +501,7 @@ def _finalize_sent_message( "sender_user", "draft_blob", "created_at", + "snippet", *extra_update_fields, ] message.save(update_fields=update_fields) diff --git a/src/backend/core/migrations/0033_message_snippet.py b/src/backend/core/migrations/0033_message_snippet.py new file mode 100644 index 000000000..0235e2739 --- /dev/null +++ b/src/backend/core/migrations/0033_message_snippet.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.11 on 2026-07-20 21:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0032_inboundmessage_blob_envelope'), + ] + + operations = [ + migrations.AddField( + model_name='message', + name='snippet', + field=models.TextField(blank=True, verbose_name='snippet'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 0ac99c279..e36fb7326 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -2013,6 +2013,7 @@ class Message(BaseModel): Thread, on_delete=models.CASCADE, related_name="messages" ) subject = models.CharField("subject", max_length=255, null=True, blank=True) + snippet = models.TextField("snippet", blank=True) sender = models.ForeignKey("Contact", on_delete=models.CASCADE) sender_user = models.ForeignKey( "User", diff --git a/src/backend/core/tests/api/test_message_summary_serializer.py b/src/backend/core/tests/api/test_message_summary_serializer.py new file mode 100644 index 000000000..2aa4bc030 --- /dev/null +++ b/src/backend/core/tests/api/test_message_summary_serializer.py @@ -0,0 +1,54 @@ +"""Test MessageSummarySerializer.""" + +import pytest + +from core import factories +from core.api.serializers import MessageSummarySerializer + + +@pytest.mark.django_db +class TestMessageSummarySerializer: + """MessageSummarySerializer must expose only the lightweight summary fields.""" + + def test_serializes_expected_fields(self): + """Only id/sender/sent_at/is_unread/has_attachments/snippet are exposed.""" + message = factories.MessageFactory( + snippet="Hello preview", + has_attachments=True, + ) + + data = MessageSummarySerializer(message).data + + assert set(data.keys()) == { + "id", + "sender", + "sent_at", + "is_unread", + "is_draft", + "has_attachments", + "snippet", + } + assert data["id"] == str(message.id) + assert data["snippet"] == "Hello preview" + assert data["has_attachments"] is True + assert data["sender"]["email"] == message.sender.email + + def test_is_unread_defaults_false_without_annotation(self): + """Falls back to False when the queryset wasn't annotated with _is_unread.""" + message = factories.MessageFactory() + + data = MessageSummarySerializer(message).data + + assert data["is_unread"] is False + + def test_does_not_touch_blob_storage(self, monkeypatch): + """Serializing must never call get_parsed_data() (blob fetch + MIME parse).""" + message = factories.MessageFactory(snippet="Already computed") + + def fail_if_called(*args, **kwargs): + raise AssertionError("MessageSummarySerializer must not parse the blob") + + monkeypatch.setattr(type(message), "get_parsed_data", fail_if_called) + + serialized = MessageSummarySerializer(message).data # must not raise + assert serialized["snippet"] == "Already computed" diff --git a/src/backend/core/tests/api/test_messages_list.py b/src/backend/core/tests/api/test_messages_list.py index fe0e2424d..a7b0eddf7 100644 --- a/src/backend/core/tests/api/test_messages_list.py +++ b/src/backend/core/tests/api/test_messages_list.py @@ -664,3 +664,62 @@ def test_is_unread_defaults_to_false_without_mailbox(self): assert response.status_code == status.HTTP_200_OK messages = {str(m["id"]): m for m in response.data} assert messages[str(msg.id)]["is_unread"] is False + + +@pytest.mark.django_db +class TestMessageListSummaryParam: + """?summary=true on the messages list endpoint returns the lightweight shape.""" + + def test_summary_true_returns_summary_fields_only(self, api_client): + """With summary=true, the response is the lightweight MessageSummarySerializer shape.""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=enums.MailboxRoleChoices.EDITOR + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR + ) + message = factories.MessageFactory(thread=thread, snippet="Preview text") + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("messages-list"), + {"thread_id": str(thread.id), "summary": "true"}, + ) + + assert response.status_code == status.HTTP_200_OK + payload = next(m for m in response.data if m["id"] == str(message.id)) + assert set(payload.keys()) == { + "id", + "sender", + "sent_at", + "is_unread", + "is_draft", + "has_attachments", + "snippet", + } + assert payload["snippet"] == "Preview text" + + def test_without_summary_param_returns_full_message(self, api_client): + """Without summary=true, behavior is unchanged (full MessageSerializer).""" + user = factories.UserFactory() + mailbox = factories.MailboxFactory() + factories.MailboxAccessFactory( + mailbox=mailbox, user=user, role=enums.MailboxRoleChoices.EDITOR + ) + thread = factories.ThreadFactory() + factories.ThreadAccessFactory( + mailbox=mailbox, thread=thread, role=enums.ThreadAccessRoleChoices.EDITOR + ) + message = factories.MessageFactory(thread=thread) + api_client.force_authenticate(user=user) + + response = api_client.get( + reverse("messages-list"), {"thread_id": str(thread.id)} + ) + + assert response.status_code == status.HTTP_200_OK + payload = next(m for m in response.data if m["id"] == str(message.id)) + assert "textBody" in payload diff --git a/src/backend/core/tests/api/test_threads_list.py b/src/backend/core/tests/api/test_threads_list.py index b1148c2b2..93d67cfc3 100644 --- a/src/backend/core/tests/api/test_threads_list.py +++ b/src/backend/core/tests/api/test_threads_list.py @@ -2020,3 +2020,107 @@ def test_labels_and_assignees_prefetched(self, api_client, url): f"(N+1 regression on prefetch chain?), " f"got 1β{queries_1} vs 5β{queries_5}" ) + + +class TestThreadListMessageCount: + """Test that ThreadSerializer exposes message_count on the list endpoint. + + message_count drives the chevron that expands a thread's per-message + summary 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 diff --git a/src/backend/core/tests/mda/test_autoreply.py b/src/backend/core/tests/mda/test_autoreply.py index 0e975f349..e938f1ca7 100644 --- a/src/backend/core/tests/mda/test_autoreply.py +++ b/src/backend/core/tests/mda/test_autoreply.py @@ -1061,3 +1061,18 @@ def test_does_not_update_sender_read_at( access.refresh_from_db() assert access.read_at is None + + @patch("core.mda.outbound_tasks.send_message_task", new_callable=MagicMock) + @patch("core.mda.outbound.sign_message_dkim", return_value=None) + def test_snippet_persisted( + self, mock_dkim, mock_send_task, mailbox, autoreply_template, inbound_message + ): + """Snippet computed by compose_and_sign_mime is persisted to the database.""" + send_autoreply_for_message(autoreply_template, mailbox, inbound_message) + + autoreply_msg = models.Message.objects.filter( + parent=inbound_message, is_sender=True + ).last() + # Reload from DB to verify persisted value + autoreply_msg.refresh_from_db() + assert autoreply_msg.snippet == "I am out of office." diff --git a/src/backend/core/tests/mda/test_inbound.py b/src/backend/core/tests/mda/test_inbound.py index 370268497..0a0e2211a 100644 --- a/src/backend/core/tests/mda/test_inbound.py +++ b/src/backend/core/tests/mda/test_inbound.py @@ -330,6 +330,7 @@ def test_basic_delivery_new_thread( assert message.sender.mailbox == target_mailbox assert message.blob.get_content() == raw_email_data assert message.mime_id == sample_parsed_email["messageId"][0] + assert message.snippet == "Test body content." # Inbound message from another sender: thread should be unread assert access.read_at is None diff --git a/src/backend/core/tests/mda/test_outbound.py b/src/backend/core/tests/mda/test_outbound.py index 6305db4c1..896fcf73b 100644 --- a/src/backend/core/tests/mda/test_outbound.py +++ b/src/backend/core/tests/mda/test_outbound.py @@ -915,6 +915,73 @@ def test_prepare_outbound_message_updates_sender_read_at(self, mailbox_sender): assert access.read_at >= message.created_at +@pytest.mark.django_db +class TestMessageSnippetOnSend: + """``prepare_outbound_message`` must persist a ``Message.snippet`` + derived from the content actually sent, for both the composed-body + and raw-MIME paths.""" + + def test_composed_body_send_sets_snippet( + self, user, mailbox_sender, mailbox_access + ): + """The composed text body becomes the persisted snippet.""" + message = factories.MessageFactory( + thread=factories.ThreadFactory(), + sender=factories.ContactFactory(mailbox=mailbox_sender), + is_draft=True, + subject="Test Message", + signature=None, + ) + factories.MessageRecipientFactory( + message=message, + contact=factories.ContactFactory( + mailbox=mailbox_sender, email="to@example.com" + ), + type=models.MessageRecipientTypeChoices.TO, + ) + text_body = "This is the composed body content used for the snippet test." + + outbound.prepare_outbound_message( + mailbox_sender, message, text_body, "
irrelevant html
", user + ) + + message.refresh_from_db() + assert message.snippet != "" + assert message.snippet.startswith(text_body[:20]) + + def test_raw_mime_send_sets_snippet(self, user, mailbox_sender, mailbox_access): + """A caller-supplied raw MIME body also produces a persisted snippet.""" + message = factories.MessageFactory( + thread=factories.ThreadFactory(), + sender=factories.ContactFactory(mailbox=mailbox_sender), + is_draft=True, + subject="Test Message", + signature=None, + ) + factories.MessageRecipientFactory( + message=message, + contact=factories.ContactFactory( + mailbox=mailbox_sender, email="to@example.com" + ), + type=models.MessageRecipientTypeChoices.TO, + ) + raw_mime = ( + b"From: sender@example.com\r\n" + b"To: to@example.com\r\n" + b"Subject: Raw MIME snippet test\r\n" + b"\r\n" + b"This is the raw MIME body used for the snippet test.\r\n" + ) + + outbound.prepare_outbound_message( + mailbox_sender, message, "", "", user, raw_mime=raw_mime + ) + + message.refresh_from_db() + assert message.snippet != "" + assert "This is the raw MIME body" in message.snippet + + @pytest.mark.django_db class TestUndisclosedRecipientsHeader: """A message with no To recipient (e.g. Bcc-only) must get an empty-group 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.scss b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss index 8a91e5ead..345ea5735 100644 --- a/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss +++ b/src/frontend/src/features/layouts/components/thread-panel/components/thread-item/_index.scss @@ -1,3 +1,50 @@ +// Wraps the disclosure chevron and the thread .thread-item as +// siblings (interactive content cannot be nested inside an β see the +// same constraint documented on mailbox-list's .mailbox__item-row). +.thread-item-row { + display: flex; + // flex-wrap so the expanded summaries container (below) can be + // pushed onto its own line instead of squeezing in beside the + // chevron/Link as a third flex item. + flex-wrap: wrap; + align-items: flex-start; + + .thread-item { + flex: 1; + min-width: 0; + } +} + +// Container for ThreadItemMessageSummaries β forces it onto its own row +// below the chevron/Link pair (see flex-wrap above), spanning the full +// width of the thread-list row. +.thread-item-row > [id^="thread-item-summaries-"] { + flex-basis: 100%; + width: 100%; +} + +// Native