Mail list: name everyone who wrote in the thread, and call the user "me"

The sender column showed participants[0], and participants are rebuilt
newest message first — so replying to a thread put the user in that slot
and the list named them instead of the person who wrote in.

Thread summaries (list and search) now carry `senders`: the distinct From
addresses of the thread's visible messages, oldest first, with the same
Trash/Spam visibility as the message count. The list joins them and shows
the user's own account addresses as "me" ("me, Alice" for a thread the
user started), and the avatar prefers the other side of the conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 20:17:37 +02:00
co-authored by Claude Opus 5.5
parent 901156526c
commit 08d428de8a
7 changed files with 125 additions and 30 deletions
+3
View File
@@ -18,6 +18,9 @@ class ThreadSummary(BaseModel):
subject: str
snippet: str
participants: list[Address]
# Distinct From addresses of the thread's visible messages, oldest first —
# what the list's sender column shows (recipients excluded).
senders: list[Address] = []
account_ids: list[uuid.UUID]
message_count: int
unread_count: int
+6 -2
View File
@@ -10,7 +10,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.schemas import SearchOut, SearchThreadHit
from app.api.threads import _summary
from app.api.threads import _summary, thread_senders
from app.db import get_session
from app.identity.deps import CurrentUser
from app.models import Thread
@@ -56,8 +56,12 @@ async def search(
select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id)
)
}
senders = await thread_senders(db, user.id, list(threads))
results = [
SearchThreadHit(thread=_summary(threads[tid]), highlights=highlights.get(tid, {}))
SearchThreadHit(
thread=_summary(threads[tid], senders=senders.get(tid)),
highlights=highlights.get(tid, {}),
)
for tid in order
if tid in threads
]
+57 -21
View File
@@ -33,12 +33,15 @@ def _addresses(raw: list[dict[str, str]]) -> list[Address]:
return [Address(name=a.get("name", ""), email=a.get("email", "")) for a in raw]
def _summary(thread: Thread, message_count: int | None = None) -> ThreadSummary:
def _summary(
thread: Thread, message_count: int | None = None, senders: list[Address] | None = None
) -> ThreadSummary:
return ThreadSummary(
id=thread.id,
subject=thread.subject_norm or "(no subject)",
snippet=thread.snippet,
participants=_addresses(thread.participants),
senders=senders or [],
account_ids=list(thread.account_ids),
# Count only the messages this view actually shows (deleted/spam excluded
# elsewhere); falls back to the denormalized total.
@@ -152,6 +155,7 @@ async def list_threads(
# Per-thread visible message count matching what get_thread shows for this view:
# Trash/Spam count only their own messages; every other view excludes them. This
# keeps the list count in step with a single deleted message.
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
counts: dict[uuid.UUID, int] = {}
if threads:
# count DISTINCT deliveries so collapsed cross-account copies count once,
@@ -163,22 +167,60 @@ async def list_threads(
.where(Message.user_id == user.id, Message.thread_id.in_([t.id for t in threads]))
.group_by(Message.thread_id)
)
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
if current_special in _REMOVAL_FOLDERS:
count_stmt = count_stmt.where(Mailbox.special_use == current_special)
else:
count_stmt = count_stmt.where(
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS))
)
counts = {
r[0]: r[1] for r in (await db.execute(count_stmt)).all() if r[0] is not None
}
count_stmt = count_stmt.where(_visible_in(current_special))
counts = {r[0]: r[1] for r in (await db.execute(count_stmt)).all() if r[0] is not None}
senders = await thread_senders(db, user.id, [t.id for t in threads], current_special)
return ThreadListOut(
threads=[_summary(t, counts.get(t.id)) for t in threads], total=total
threads=[_summary(t, counts.get(t.id), senders.get(t.id)) for t in threads],
total=total,
)
def _visible_in(current_special: str | None) -> ColumnElement[bool]:
"""Which of a thread's messages a view shows (see ``_REMOVAL_FOLDERS``): Trash/Spam
show only their own messages; every other view hides them."""
if current_special in _REMOVAL_FOLDERS:
return Mailbox.special_use == current_special
return or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS))
async def thread_senders(
db: AsyncSession,
user_id: uuid.UUID,
thread_ids: list[uuid.UUID],
current_special: str | None = None,
) -> dict[uuid.UUID, list[Address]]:
"""Distinct From addresses per thread, oldest message first, over the messages
the view shows. Recipients are left out: the list's sender column names who
wrote, not everyone copied."""
if not thread_ids:
return {}
rows = await db.execute(
select(Message.thread_id, Message.from_addr)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user_id,
Message.thread_id.in_(thread_ids),
_visible_in(current_special),
)
.order_by(Message.internal_date.asc(), Message.id.asc())
)
senders: dict[uuid.UUID, list[Address]] = {}
seen: dict[uuid.UUID, set[str]] = {}
for thread_id, from_addr in rows.all():
if thread_id is None:
continue
for addr in from_addr:
email = (addr.get("email") or "").lower()
if email and email not in seen.setdefault(thread_id, set()):
seen[thread_id].add(email)
senders.setdefault(thread_id, []).append(
Address(name=addr.get("name", ""), email=addr.get("email", ""))
)
return senders
async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) -> Thread:
thread = await db.scalar(
select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id)
@@ -212,15 +254,9 @@ async def get_thread(
.where(Message.thread_id == thread_id, Message.user_id == user.id)
.order_by(Message.internal_date.asc(), Message.id.asc())
)
if current_special in _REMOVAL_FOLDERS:
# Show only the messages actually in this Trash/Spam folder.
stmt = stmt.where(Mailbox.special_use == current_special)
else:
# Conversation view: hide messages that were individually trashed or spammed
# (keep custom folders, special_use IS NULL, and every non-removal folder).
stmt = stmt.where(
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS))
)
# Trash/Spam: only the messages actually filed there. Conversation view: hide
# messages that were individually trashed or spammed.
stmt = stmt.where(_visible_in(current_special))
messages = list(await db.scalars(stmt))
# Collapse duplicate deliveries: the same email (same delivery_id) that landed
+42 -2
View File
@@ -37,11 +37,17 @@ class ApiCtx:
def _raw(
subject: str, body: str, *, mid: str, refs: str | None = None, html: str | None = None
subject: str,
body: str,
*,
mid: str,
refs: str | None = None,
html: str | None = None,
sender: str = "Alice <alice@example.com>",
) -> bytes:
m = EmailMessage()
m["Subject"] = subject
m["From"] = "Alice <alice@example.com>"
m["From"] = sender
m["To"] = "me@example.com"
m["Message-ID"] = mid
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
@@ -159,6 +165,40 @@ async def test_list_threads(api: ApiCtx) -> None:
assert kickoff["message_count"] == 2
async def test_list_senders_are_authors_oldest_first(api: ApiCtx) -> None:
# Our reply joins the kickoff thread: the sender column lists both authors in
# the order they wrote — Alice twice collapses to one entry, and the recipients
# (me, on Alice's messages) are not senders.
assert api.session is not None and api.account_id is not None
inbox = await api.session.scalar(
select(Mailbox).where(Mailbox.account_id == api.account_id, Mailbox.path == "INBOX")
)
assert inbox is not None
raw = _raw(
"Re: Project kickoff",
"thanks",
mid="<d@x.com>",
refs="<b@x.com>",
sender="Me <me@example.com>",
)
remote = RemoteMessage(uid=9, raw=raw, flags=frozenset(), internal_date=datetime.now(UTC))
await persist_message(
api.session,
user_id=api.user_id,
account_id=api.account_id,
mailbox_id=inbox.id,
mailbox_special_use="inbox",
remote=remote,
)
await api.session.commit()
resp = await api.client.get("/api/threads")
kickoff = next(t for t in resp.json()["threads"] if "kickoff" in t["subject"].lower())
assert [a["email"] for a in kickoff["senders"]] == ["alice@example.com", "me@example.com"]
lunch = next(t for t in resp.json()["threads"] if t["subject"] == "Lunch")
assert [a["email"] for a in lunch["senders"]] == ["alice@example.com"]
async def test_folder_view(api: ApiCtx) -> None:
# all seeded mail is in an inbox mailbox
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
+14 -5
View File
@@ -102,6 +102,11 @@ export function MessageList({
});
const labelMap = labelsQuery.data ?? {};
// The user's own addresses, shown as "me" in the sender column.
const accounts = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const myEmails = new Set((accounts.data ?? []).map((a) => a.email.toLowerCase()));
const isMe = (a: Address) => myEmails.has(a.email.toLowerCase());
const virtualizer = useVirtualizer({
count: threads.length,
getScrollElement: () => parentRef.current,
@@ -128,11 +133,15 @@ export function MessageList({
const selected = selectedId === thread.id;
const checked = checkedIds.has(thread.id);
const dimmed = dimmedIds?.has(thread.id) ?? false;
// `participants` is from + to + cc, newest message first — so the first
// entry is the sender and the rest are recipients (usually just you).
// The list shows the sender only.
const primary = thread.participants[0];
const names = primary ? primary.name || primary.email : "(no one)";
// The sender column lists everyone who wrote in the thread, oldest first,
// with the user's own accounts shown as "me" — so a thread you started
// and someone answered reads "me, Alice". Recipients are not listed.
const senders = thread.senders.length ? thread.senders : thread.participants.slice(0, 1);
const names = senders.length
? senders.map((a) => (isMe(a) ? t("mail.me") : a.name || a.email)).join(", ")
: "(no one)";
// The avatar is the other side of the conversation where there is one.
const primary = senders.find((a) => !isMe(a)) ?? senders[0];
// The badge still counts everyone involved — the name is narrowed, the
// "how many people are in this" signal is not.
const extra = Math.max(0, thread.participants.length - 1);
+1
View File
@@ -67,6 +67,7 @@ export const en = {
"nav.refresh": "Refresh all folders",
"mail.noConversations": "No conversations.",
"mail.me": "me",
"mail.selectConversation": "Select a conversation.",
"mail.loadingThread": "Loading thread…",
"mail.expandAll": "Expand all",
+2
View File
@@ -32,6 +32,8 @@ export interface ThreadSummary {
subject: string;
snippet: string;
participants: Address[];
/** Distinct From addresses of the thread's visible messages, oldest first. */
senders: Address[];
account_ids: string[];
message_count: number;
unread_count: number;