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:
+57
-21
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user