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
+42 -2
View File
@@ -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"})