Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions src/backend/core/api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -10191,6 +10191,10 @@
"type": "integer",
"readOnly": true
},
"message_count": {
"type": "integer",
"readOnly": true
},
"abilities": {
"type": "object",
"additionalProperties": {
Expand Down Expand Up @@ -10230,6 +10234,7 @@
"is_spam",
"is_trashed",
"labels",
"message_count",
"messaged_at",
"messages",
"sender_messaged_at",
Expand Down
32 changes: 32 additions & 0 deletions src/backend/core/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -1019,6 +1020,7 @@ class Meta:
"labels",
"summary",
"events_count",
"message_count",
"abilities",
"assigned_users",
]
Expand Down Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/backend/core/api/viewsets/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/backend/core/api/viewsets/thread.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/backend/core/mda/autoreply.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions src/backend/core/mda/inbound_create.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
),
Comment on lines +476 to +479

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Avoid storing English UI text in the database.

Using "(No snippet available)" bakes localized UI text directly into the database, making it impossible for the frontend to translate this fallback state for non-English users. It's also inconsistent with outbound.py, which correctly falls back to "".

Consider falling back to an empty string "" and letting the frontend display the appropriate localized text when a snippet is missing.

♻️ Proposed fix
                     snippet=thread_snippet(
                         parsed_email,
-                        fallback=subject or "(No snippet available)",
+                        fallback=subject or "",
                     ),
📝 Committable suggestion

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

Suggested change
snippet=thread_snippet(
parsed_email,
fallback=subject or "(No snippet available)",
),
snippet=thread_snippet(
parsed_email,
fallback=subject or "",
),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/backend/core/mda/inbound_create.py` around lines 476 - 479, Update the
thread_snippet call in the inbound create flow to use an empty-string fallback
instead of the English "(No snippet available)" text, matching the outbound.py
behavior and leaving localized empty-snippet presentation to the frontend.

blob=blob,
mime_id=first_msgid(parsed_email.get("messageId")) or None,
parent=parent_message,
Expand Down
15 changes: 13 additions & 2 deletions src/backend/core/mda/outbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()}]

Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -491,6 +501,7 @@ def _finalize_sent_message(
"sender_user",
"draft_blob",
"created_at",
"snippet",
*extra_update_fields,
]
message.save(update_fields=update_fields)
Expand Down
18 changes: 18 additions & 0 deletions src/backend/core/migrations/0033_message_snippet.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
1 change: 1 addition & 0 deletions src/backend/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions src/backend/core/tests/api/test_message_summary_serializer.py
Original file line number Diff line number Diff line change
@@ -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"
59 changes: 59 additions & 0 deletions src/backend/core/tests/api/test_messages_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading