Mail list: show who wrote, not everyone involved
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo
This commit is contained in:
@@ -17,6 +17,7 @@ class ThreadSummary(BaseModel):
|
|||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
subject: str
|
subject: str
|
||||||
snippet: str
|
snippet: str
|
||||||
|
senders: list[Address]
|
||||||
participants: list[Address]
|
participants: list[Address]
|
||||||
account_ids: list[uuid.UUID]
|
account_ids: list[uuid.UUID]
|
||||||
message_count: int
|
message_count: int
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ def _summary(thread: Thread, message_count: int | None = None) -> ThreadSummary:
|
|||||||
id=thread.id,
|
id=thread.id,
|
||||||
subject=thread.subject_norm or "(no subject)",
|
subject=thread.subject_norm or "(no subject)",
|
||||||
snippet=thread.snippet,
|
snippet=thread.snippet,
|
||||||
|
senders=_addresses(thread.senders),
|
||||||
participants=_addresses(thread.participants),
|
participants=_addresses(thread.participants),
|
||||||
account_ids=list(thread.account_ids),
|
account_ids=list(thread.account_ids),
|
||||||
# Count only the messages this view actually shows (deleted/spam excluded
|
# Count only the messages this view actually shows (deleted/spam excluded
|
||||||
|
|||||||
@@ -152,6 +152,13 @@ class Thread(Base):
|
|||||||
)
|
)
|
||||||
subject_norm: Mapped[str] = mapped_column(Text, nullable=False)
|
subject_norm: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
snippet: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
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(
|
participants: Mapped[list[dict[str, str]]] = mapped_column(
|
||||||
JSONB, nullable=False, server_default=text("'[]'")
|
JSONB, nullable=False, server_default=text("'[]'")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -197,6 +197,16 @@ async def denormalize_thread(session: AsyncSession, thread_id: uuid.UUID) -> Non
|
|||||||
seen_emails.add(email)
|
seen_emails.add(email)
|
||||||
participants.append({"name": addr.get("name", ""), "email": addr.get("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.message_count = len(messages)
|
||||||
thread.unread_count = sum(1 for m in messages if "\\Seen" not in m.flags)
|
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)
|
thread.has_attachments = any(m.has_attachments for m in messages)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -119,10 +119,12 @@ export function MessageList({
|
|||||||
const selected = selectedId === thread.id;
|
const selected = selectedId === thread.id;
|
||||||
const checked = checkedIds.has(thread.id);
|
const checked = checkedIds.has(thread.id);
|
||||||
const dimmed = dimmedIds?.has(thread.id) ?? false;
|
const dimmed = dimmedIds?.has(thread.id) ?? false;
|
||||||
const primary = thread.participants[0];
|
// Senders, not participants: the latter includes recipients, so every
|
||||||
const names =
|
// ordinary email read "Sender, You" and the +N badge counted the user.
|
||||||
thread.participants.map((p) => p.name || p.email).join(", ") || "(no one)";
|
const people = thread.senders.length ? thread.senders : thread.participants;
|
||||||
const extra = Math.max(0, thread.participants.length - 1);
|
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 avatarBg = `hsl(${hue(primary?.email || primary?.name || "?")} 45% 45%)`;
|
||||||
const stop = (fn: () => void) => (e: React.MouseEvent) => {
|
const stop = (fn: () => void) => (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ export interface ThreadSummary {
|
|||||||
id: string;
|
id: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
snippet: 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[];
|
participants: Address[];
|
||||||
account_ids: string[];
|
account_ids: string[];
|
||||||
message_count: number;
|
message_count: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user