Mail mark-all-read + iframe fixes; Contacts detail/selection/groups + resizable panels

Mail:
- New "More" (⋯) menu in the list sorting bar with "Mark all as read": POST
  /threads/mark-read marks every unread thread in the current view (unified /
  folder:<special> / account:<uuid>) as \Seen, pushes + publishes updates.
- Message iframe: no longer shows a spurious vertical scrollbar (display:block,
  getBoundingClientRect+ceil sizing, overflow-y:hidden on the doc) and has
  rounded corners.
- Deep links from Contacts: /mail?compose=1&to= opens a maximized prefilled
  composer; /mail?q= prefills the search box.

Contacts:
- Detail pane shows all populated fields (work subtitle, phones, address, urls,
  nickname, birthday). Company name is used as the display name when there's no
  personal name (existing rows too). Editable "Add notes".
- Selection: checkbox reveals on avatar hover only; Groups/More bulk menus in
  select mode; "N contacts selected" panel; filled yellow VIP star in list +
  detail; pagination bottom bar; Lato pinned on form controls.
- Groups: "+" on the GROUPS header and "Create new" in the group menus (bulk
  add/remove via POST /groups/{id}/add|remove, ownership-guarded).
- Rounded Mail-style list rows; removed the ⋯ button from the search bar;
  Compose→Mail, Find-all-conversations→Mail search; Message button removed;
  delete confirmation; resizable sidebar + list/detail split.

Calendar: the right panel (agenda / search) is now resizable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-02 21:34:22 +02:00
co-authored by Claude Opus 4.8
parent a393939628
commit 421b44a8ab
10 changed files with 455 additions and 34 deletions
+58 -1
View File
@@ -10,7 +10,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.api.schemas import FlagChangeRequest, MoveRequest
from app.api.schemas import FlagChangeRequest, MarkReadRequest, MoveRequest
from app.db import get_session
from app.events import get_event_bus
from app.identity.deps import CurrentUser, require_csrf
@@ -86,6 +86,63 @@ async def thread_flags(
return {"thread_id": str(thread_id), "changed": len(changed)}
@router.post("/threads/mark-read")
async def mark_all_read(
body: MarkReadRequest, user: CurrentUser, db: DbSession
) -> dict[str, object]:
"""Mark every unread thread in the given view as read (adds \\Seen). Scopes the
same way as the thread list: unified, folder:<special>, or account:<uuid>."""
view = body.view
conditions = [Thread.user_id == user.id, Thread.unread_count > 0]
if view.startswith("account:"):
try:
account_id = uuid.UUID(view.split(":", 1)[1])
except ValueError:
raise_api_error(400, "bad_view", "invalid account id in view")
in_account_inbox = (
select(Message.thread_id)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user.id,
Message.account_id == account_id,
Mailbox.special_use == "inbox",
)
)
conditions.append(Thread.id.in_(in_account_inbox))
elif view.startswith("folder:"):
special = view.split(":", 1)[1]
in_folder = (
select(Message.thread_id)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == user.id, Mailbox.special_use == special)
)
conditions.append(Thread.id.in_(in_folder))
elif view != "unified":
raise_api_error(400, "bad_view", "unsupported view")
thread_ids = list(await db.scalars(select(Thread.id).where(*conditions)))
if not thread_ids:
return {"changed": 0}
messages = list(
await db.scalars(
select(Message).where(
Message.thread_id.in_(thread_ids), Message.user_id == user.id
)
)
)
add, remove = sanitize_flags(["\\Seen"]), sanitize_flags([])
changed = [m for m in messages if _would_change(m, add, remove)]
await apply_flags_to_messages(db, messages, add, remove)
await db.commit()
await _push_and_publish(user.id, changed, add, remove)
bus = get_event_bus()
for tid in thread_ids:
await bus.publish(
"thread.updated", {"user_id": str(user.id), "thread_id": str(tid)}
)
return {"changed": len(changed)}
async def _move_thread(
user_id: uuid.UUID, thread_id: uuid.UUID, db: AsyncSession, special: str
) -> None:
+4
View File
@@ -76,6 +76,10 @@ class FlagChangeRequest(BaseModel):
remove: list[str] = []
class MarkReadRequest(BaseModel):
view: str = "unified" # unified | folder:<special> | account:<uuid>
class MoveRequest(BaseModel):
mailbox_id: uuid.UUID