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
|
subject: str
|
||||||
snippet: str
|
snippet: str
|
||||||
participants: list[Address]
|
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]
|
account_ids: list[uuid.UUID]
|
||||||
message_count: int
|
message_count: int
|
||||||
unread_count: int
|
unread_count: int
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.schemas import SearchOut, SearchThreadHit
|
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.db import get_session
|
||||||
from app.identity.deps import CurrentUser
|
from app.identity.deps import CurrentUser
|
||||||
from app.models import Thread
|
from app.models import Thread
|
||||||
@@ -56,8 +56,12 @@ async def search(
|
|||||||
select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id)
|
select(Thread).where(Thread.id.in_(order), Thread.user_id == user.id)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
senders = await thread_senders(db, user.id, list(threads))
|
||||||
results = [
|
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
|
for tid in order
|
||||||
if tid in threads
|
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]
|
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(
|
return ThreadSummary(
|
||||||
id=thread.id,
|
id=thread.id,
|
||||||
subject=thread.subject_norm or "(no subject)",
|
subject=thread.subject_norm or "(no subject)",
|
||||||
snippet=thread.snippet,
|
snippet=thread.snippet,
|
||||||
participants=_addresses(thread.participants),
|
participants=_addresses(thread.participants),
|
||||||
|
senders=senders or [],
|
||||||
account_ids=list(thread.account_ids),
|
account_ids=list(thread.account_ids),
|
||||||
# Count only the messages this view actually shows (deleted/spam excluded
|
# Count only the messages this view actually shows (deleted/spam excluded
|
||||||
# elsewhere); falls back to the denormalized total.
|
# 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:
|
# 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
|
# Trash/Spam count only their own messages; every other view excludes them. This
|
||||||
# keeps the list count in step with a single deleted message.
|
# 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] = {}
|
counts: dict[uuid.UUID, int] = {}
|
||||||
if threads:
|
if threads:
|
||||||
# count DISTINCT deliveries so collapsed cross-account copies count once,
|
# 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]))
|
.where(Message.user_id == user.id, Message.thread_id.in_([t.id for t in threads]))
|
||||||
.group_by(Message.thread_id)
|
.group_by(Message.thread_id)
|
||||||
)
|
)
|
||||||
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
|
count_stmt = count_stmt.where(_visible_in(current_special))
|
||||||
if current_special in _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(Mailbox.special_use == current_special)
|
senders = await thread_senders(db, user.id, [t.id for t in threads], 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
|
|
||||||
}
|
|
||||||
|
|
||||||
return ThreadListOut(
|
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:
|
async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) -> Thread:
|
||||||
thread = await db.scalar(
|
thread = await db.scalar(
|
||||||
select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id)
|
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)
|
.where(Message.thread_id == thread_id, Message.user_id == user.id)
|
||||||
.order_by(Message.internal_date.asc(), Message.id.asc())
|
.order_by(Message.internal_date.asc(), Message.id.asc())
|
||||||
)
|
)
|
||||||
if current_special in _REMOVAL_FOLDERS:
|
# Trash/Spam: only the messages actually filed there. Conversation view: hide
|
||||||
# Show only the messages actually in this Trash/Spam folder.
|
# messages that were individually trashed or spammed.
|
||||||
stmt = stmt.where(Mailbox.special_use == current_special)
|
stmt = stmt.where(_visible_in(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))
|
|
||||||
)
|
|
||||||
messages = list(await db.scalars(stmt))
|
messages = list(await db.scalars(stmt))
|
||||||
|
|
||||||
# Collapse duplicate deliveries: the same email (same delivery_id) that landed
|
# Collapse duplicate deliveries: the same email (same delivery_id) that landed
|
||||||
|
|||||||
@@ -37,11 +37,17 @@ class ApiCtx:
|
|||||||
|
|
||||||
|
|
||||||
def _raw(
|
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:
|
) -> bytes:
|
||||||
m = EmailMessage()
|
m = EmailMessage()
|
||||||
m["Subject"] = subject
|
m["Subject"] = subject
|
||||||
m["From"] = "Alice <alice@example.com>"
|
m["From"] = sender
|
||||||
m["To"] = "me@example.com"
|
m["To"] = "me@example.com"
|
||||||
m["Message-ID"] = mid
|
m["Message-ID"] = mid
|
||||||
m["Date"] = "Mon, 09 Mar 2026 08:36:37 +0100"
|
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
|
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:
|
async def test_folder_view(api: ApiCtx) -> None:
|
||||||
# all seeded mail is in an inbox mailbox
|
# all seeded mail is in an inbox mailbox
|
||||||
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
|
inbox = await api.client.get("/api/threads", params={"view": "folder:inbox"})
|
||||||
|
|||||||
@@ -102,6 +102,11 @@ export function MessageList({
|
|||||||
});
|
});
|
||||||
const labelMap = labelsQuery.data ?? {};
|
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({
|
const virtualizer = useVirtualizer({
|
||||||
count: threads.length,
|
count: threads.length,
|
||||||
getScrollElement: () => parentRef.current,
|
getScrollElement: () => parentRef.current,
|
||||||
@@ -128,11 +133,15 @@ export function MessageList({
|
|||||||
const selected = selectedId === thread.id;
|
const selected = selectedId === thread.id;
|
||||||
const checked = checkedIds.has(thread.id);
|
const checked = checkedIds.has(thread.id);
|
||||||
const dimmed = dimmedIds?.has(thread.id) ?? false;
|
const dimmed = dimmedIds?.has(thread.id) ?? false;
|
||||||
// `participants` is from + to + cc, newest message first — so the first
|
// The sender column lists everyone who wrote in the thread, oldest first,
|
||||||
// entry is the sender and the rest are recipients (usually just you).
|
// with the user's own accounts shown as "me" — so a thread you started
|
||||||
// The list shows the sender only.
|
// and someone answered reads "me, Alice". Recipients are not listed.
|
||||||
const primary = thread.participants[0];
|
const senders = thread.senders.length ? thread.senders : thread.participants.slice(0, 1);
|
||||||
const names = primary ? primary.name || primary.email : "(no one)";
|
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
|
// The badge still counts everyone involved — the name is narrowed, the
|
||||||
// "how many people are in this" signal is not.
|
// "how many people are in this" signal is not.
|
||||||
const extra = Math.max(0, thread.participants.length - 1);
|
const extra = Math.max(0, thread.participants.length - 1);
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const en = {
|
|||||||
"nav.refresh": "Refresh all folders",
|
"nav.refresh": "Refresh all folders",
|
||||||
|
|
||||||
"mail.noConversations": "No conversations.",
|
"mail.noConversations": "No conversations.",
|
||||||
|
"mail.me": "me",
|
||||||
"mail.selectConversation": "Select a conversation.",
|
"mail.selectConversation": "Select a conversation.",
|
||||||
"mail.loadingThread": "Loading thread…",
|
"mail.loadingThread": "Loading thread…",
|
||||||
"mail.expandAll": "Expand all",
|
"mail.expandAll": "Expand all",
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface ThreadSummary {
|
|||||||
subject: string;
|
subject: string;
|
||||||
snippet: string;
|
snippet: string;
|
||||||
participants: Address[];
|
participants: Address[];
|
||||||
|
/** Distinct From addresses of the thread's visible messages, oldest first. */
|
||||||
|
senders: Address[];
|
||||||
account_ids: string[];
|
account_ids: string[];
|
||||||
message_count: number;
|
message_count: number;
|
||||||
unread_count: number;
|
unread_count: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user