collapse cross-account dupes, Lato UI font, mail-list selection & density tuning

Backend:
- Collapse identical cross-account deliveries in a thread: new messages.delivery_id
  (migration 0011) preserves the real Message-ID even when message_id_hdr is
  synthesized (§7.1); get_thread groups by it and reports also_in_account_ids; the
  list count counts distinct deliveries. Backfill script for existing rows.

Frontend:
- Bundle Lato (SIL OFL) as the UI font; unread mail-list rows use Black weight.
- Mail-list selection: hover-avatar checkbox, tri-state "select all", bulk
  Archive/Pin/Delete (Restore/Delete in Archive/Trash/Spam) with a count in the
  reading pane; unread blue dot; "also delivered to" note in the thread.
- Spacing/size tuning: sender-column width, icon gaps, avatar size, bar heights,
  and a round of -1px font sizes across the sidebar, mail list, and reading pane.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-07-22 21:49:48 +02:00
co-authored by Claude Opus 4.8
parent fdb6ee002e
commit b4e07f11aa
28 changed files with 757 additions and 1291 deletions
+29 -3
View File
@@ -6,7 +6,7 @@ import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, literal, or_, select, tuple_
from sqlalchemy import Text, func, literal, or_, select, tuple_
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.cursors import decode_cursor, encode_cursor
@@ -131,8 +131,11 @@ async def list_threads(
# keeps the list count in step with a single deleted message.
counts: dict[uuid.UUID, int] = {}
if threads:
# count DISTINCT deliveries so collapsed cross-account copies count once,
# matching what get_thread renders.
delivery = func.coalesce(Message.delivery_id, func.cast(Message.id, Text))
count_stmt = (
select(Message.thread_id, func.count())
select(Message.thread_id, func.count(func.distinct(delivery)))
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == user.id, Message.thread_id.in_([t.id for t in threads]))
.group_by(Message.thread_id)
@@ -196,11 +199,34 @@ async def get_thread(
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS))
)
messages = list(await db.scalars(stmt))
# Collapse duplicate deliveries: the same email (same delivery_id) that landed
# in more than one of the user's accounts shows once, noting the other accounts.
# Messages without a real Message-ID (delivery_id is NULL) never collapse.
reps: dict[str, Message] = {}
also: dict[str, list[uuid.UUID]] = {}
ordered: list[Message] = []
for m in messages:
key = m.delivery_id or f"id:{m.id}"
rep = reps.get(key)
if rep is None:
reps[key] = m
also[key] = []
ordered.append(m)
elif m.account_id != rep.account_id and m.account_id not in also[key]:
also[key].append(m.account_id)
envelopes = []
for m in ordered:
env = _envelope(m)
env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"]
envelopes.append(env)
order = "desc" if sort == "desc" else "asc"
return ThreadDetail(
id=thread.id,
subject=thread.subject_norm or "(no subject)",
order=order,
pinned=thread.pinned,
messages=[_envelope(m) for m in messages],
messages=envelopes,
)