diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index f365dd4..7296f27 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -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 diff --git a/backend/app/api/search.py b/backend/app/api/search.py index b9c1f74..6f5cef5 100644 --- a/backend/app/api/search.py +++ b/backend/app/api/search.py @@ -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 ] diff --git a/backend/app/api/threads.py b/backend/app/api/threads.py index 7413647..2de3e07 100644 --- a/backend/app/api/threads.py +++ b/backend/app/api/threads.py @@ -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 diff --git a/backend/tests/api/test_api.py b/backend/tests/api/test_api.py index 9cbcfcc..609d829 100644 --- a/backend/tests/api/test_api.py +++ b/backend/tests/api/test_api.py @@ -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 ", ) -> bytes: m = EmailMessage() m["Subject"] = subject - m["From"] = "Alice " + 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="", + refs="", + sender="Me ", + ) + 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"}) diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx index 82ede33..ff1e0fb 100644 --- a/frontend/src/components/list/MessageList.tsx +++ b/frontend/src/components/list/MessageList.tsx @@ -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); diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index 28c6e00..ee2b9ce 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -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", diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 258ef34..c3de467 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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;