From 550b8cf503f12535fa2e6e5a299a1ed9774e69d1 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Fri, 18 Sep 2026 18:27:42 +0200 Subject: [PATCH] Mail list: show who wrote, not everyone involved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first column listed a thread's `participants`, which the threader builds from from + to + cc — so every ordinary email read "Sender, You", and the +N badge beside the avatar counted the user themselves. `participants` cannot simply be narrowed: the subject-fallback join matches an incoming message's recipients against it (threader._by_subject), so dropping recipients would change how mail is threaded. Hence a separate `threads.senders`, holding only from addresses, newest writer first — which is also the right thing for a conversation where several people replied. Migration 0019 backfills it, so existing threads do not wait to be touched again. The list falls back to `participants` if a row somehow has no senders, rather than rendering an empty column. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo --- backend/app/api/schemas.py | 1 + backend/app/api/threads.py | 1 + backend/app/models.py | 7 +++ backend/app/threading_/threader.py | 10 ++++ .../versions/0019_thread_senders.py | 58 +++++++++++++++++++ frontend/src/components/list/MessageList.tsx | 10 ++-- frontend/src/lib/types.ts | 3 + 7 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 backend/migrations/versions/0019_thread_senders.py diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index f365dd4..72c47a9 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -17,6 +17,7 @@ class ThreadSummary(BaseModel): id: uuid.UUID subject: str snippet: str + senders: list[Address] participants: list[Address] account_ids: list[uuid.UUID] message_count: int diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py index 7413647..9bc503b 100644 --- a/backend/app/api/threads.py +++ b/backend/app/api/threads.py @@ -38,6 +38,7 @@ def _summary(thread: Thread, message_count: int | None = None) -> ThreadSummary: id=thread.id, subject=thread.subject_norm or "(no subject)", snippet=thread.snippet, + senders=_addresses(thread.senders), participants=_addresses(thread.participants), account_ids=list(thread.account_ids), # Count only the messages this view actually shows (deleted/spam excluded diff --git a/backend/app/models.py b/backend/app/models.py index 96fe85d..b007e34 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -152,6 +152,13 @@ class Thread(Base): ) subject_norm: Mapped[str] = mapped_column(Text, nullable=False) snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''")) + # Who WROTE in this thread, newest first. Distinct from `participants`, which + # also holds recipients because the subject-fallback threading matches on them + # (threader._by_subject). The message list shows senders; conflating the two + # put "you" next to every sender in the list. + senders: Mapped[list[dict[str, str]]] = mapped_column( + JSONB, nullable=False, server_default=text("'[]'") + ) participants: Mapped[list[dict[str, str]]] = mapped_column( JSONB, nullable=False, server_default=text("'[]'") ) diff --git a/backend/app/threading_/threader.py b/backend/app/threading_/threader.py index 0509090..c2244c2 100644 --- a/backend/app/threading_/threader.py +++ b/backend/app/threading_/threader.py @@ -197,6 +197,16 @@ async def denormalize_thread(session: AsyncSession, thread_id: uuid.UUID) -> Non seen_emails.add(email) participants.append({"name": addr.get("name", ""), "email": addr.get("email", "")}) + senders: list[dict[str, str]] = [] + seen_senders: set[str] = set() + for msg in messages: # newest first => most recent writer leads + for addr in msg.from_addr: + email = (addr.get("email") or "").lower() + if email and email not in seen_senders: + seen_senders.add(email) + senders.append({"name": addr.get("name", ""), "email": addr.get("email", "")}) + + thread.senders = senders thread.message_count = len(messages) thread.unread_count = sum(1 for m in messages if "\\Seen" not in m.flags) thread.has_attachments = any(m.has_attachments for m in messages) diff --git a/backend/migrations/versions/0019_thread_senders.py b/backend/migrations/versions/0019_thread_senders.py new file mode 100644 index 0000000..e58039f --- /dev/null +++ b/backend/migrations/versions/0019_thread_senders.py @@ -0,0 +1,58 @@ +"""threads.senders — who wrote, as distinct from who was involved + +The message list showed a thread's `participants`, which the threader builds from +from + to + cc because the subject-fallback join matches on recipients. In a list +that meant every ordinary email read "Sender, You" — and the "+N" badge counted +the user themselves. + +`senders` holds only the from addresses, newest writer first. Backfilled here so +existing threads do not have to wait to be touched again. + +Revision ID: 0019 +Revises: 0018 +Create Date: 2026-09-18 +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0019" +down_revision: str | None = "0018" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("ALTER TABLE threads ADD COLUMN senders jsonb NOT NULL DEFAULT '[]'") + # Newest writer first, de-duplicated by lower-cased address, matching what + # denormalize_thread computes from now on. + op.execute( + """ + WITH ranked AS ( + SELECT m.thread_id, + lower(a->>'email') AS email_key, + jsonb_build_object('name', coalesce(a->>'name', ''), + 'email', coalesce(a->>'email', '')) AS addr, + row_number() OVER (PARTITION BY m.thread_id, lower(a->>'email') + ORDER BY m.internal_date DESC) AS dup_rank, + max(m.internal_date) OVER (PARTITION BY m.thread_id, lower(a->>'email')) + AS recency + FROM messages m + CROSS JOIN LATERAL jsonb_array_elements(m.from_addr) AS a + WHERE m.thread_id IS NOT NULL AND coalesce(a->>'email', '') <> '' + ), + collected AS ( + SELECT thread_id, jsonb_agg(addr ORDER BY recency DESC) AS senders + FROM ranked WHERE dup_rank = 1 GROUP BY thread_id + ) + UPDATE threads t SET senders = c.senders + FROM collected c WHERE c.thread_id = t.id + """ + ) + + +def downgrade() -> None: + op.execute("ALTER TABLE threads DROP COLUMN IF EXISTS senders") diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx index 0bd6233..8437e16 100644 --- a/frontend/src/components/list/MessageList.tsx +++ b/frontend/src/components/list/MessageList.tsx @@ -119,10 +119,12 @@ export function MessageList({ const selected = selectedId === thread.id; const checked = checkedIds.has(thread.id); const dimmed = dimmedIds?.has(thread.id) ?? false; - const primary = thread.participants[0]; - const names = - thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)"; - const extra = Math.max(0, thread.participants.length - 1); + // Senders, not participants: the latter includes recipients, so every + // ordinary email read "Sender, You" and the +N badge counted the user. + const people = thread.senders.length ? thread.senders : thread.participants; + const primary = people[0]; + const names = people.map((p) => p.name || p.email).join(", ") || "(no one)"; + const extra = Math.max(0, people.length - 1); const avatarBg = `hsl(${hue(primary?.email || primary?.name || "?")} 45% 45%)`; const stop = (fn: () => void) => (e: React.MouseEvent) => { e.stopPropagation(); diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 258ef34..392150a 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -31,6 +31,9 @@ export interface ThreadSummary { id: string; subject: string; snippet: string; + /** Who wrote in the thread, newest first. The list shows these. */ + senders: Address[]; + /** Everyone involved, recipients included — used for threading, not display. */ participants: Address[]; account_ids: string[]; message_count: number;