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:
@@ -56,6 +56,8 @@ class MessageEnvelope(BaseModel):
|
||||
has_remote_images: bool
|
||||
size_bytes: int
|
||||
snippet: str
|
||||
# Other accounts this same delivery also landed in (collapsed duplicates).
|
||||
also_in_account_ids: list[uuid.UUID] = []
|
||||
|
||||
|
||||
class ThreadDetail(BaseModel):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -161,6 +161,9 @@ async def persist_message(
|
||||
mailbox_id=mailbox_id,
|
||||
uid=remote.uid,
|
||||
message_id_hdr=message_id_hdr,
|
||||
# Keep the real delivered Message-ID (may differ from the stored, possibly
|
||||
# synthesized, message_id_hdr) so cross-account copies collapse in threads.
|
||||
delivery_id=parsed.message_id,
|
||||
in_reply_to=parsed.in_reply_to,
|
||||
references_hdr=parsed.references,
|
||||
subject=parsed.subject,
|
||||
|
||||
@@ -157,6 +157,10 @@ class Message(Base):
|
||||
UUID(as_uuid=True), ForeignKey("threads.id")
|
||||
)
|
||||
message_id_hdr: Mapped[str | None] = mapped_column(Text)
|
||||
# The real header Message-ID as delivered (kept even when message_id_hdr is
|
||||
# synthesized for a cross-account duplicate, §7.1). Identifies one delivery, so
|
||||
# the same email fanned out to several accounts collapses to one row in a thread.
|
||||
delivery_id: Mapped[str | None] = mapped_column(Text)
|
||||
in_reply_to: Mapped[str | None] = mapped_column(Text)
|
||||
references_hdr: Mapped[list[str]] = mapped_column(
|
||||
ARRAY(Text), nullable=False, server_default=text("'{}'")
|
||||
|
||||
Reference in New Issue
Block a user