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
+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