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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user