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
231 lines
8.7 KiB
Python
231 lines
8.7 KiB
Python
"""Flag / move / archive endpoints (SPEC §9.1). DB-first, then push via worker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.errors import raise_api_error
|
|
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
|
|
from app.models import Mailbox, Message, Thread
|
|
from app.queue import enqueue_job
|
|
from app.sync.actions import apply_flags_to_messages, sanitize_flags
|
|
|
|
router = APIRouter(tags=["mail"], dependencies=[Depends(require_csrf)])
|
|
|
|
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
|
|
|
|
|
async def _push_and_publish(
|
|
user_id: uuid.UUID, changed: list[Message], add: frozenset[str], remove: frozenset[str]
|
|
) -> None:
|
|
bus = get_event_bus()
|
|
for message in changed:
|
|
await enqueue_job("push_flags_job", str(message.id), sorted(add), sorted(remove))
|
|
await bus.publish(
|
|
"flags.changed",
|
|
{
|
|
"user_id": str(user_id),
|
|
"message_id": str(message.id),
|
|
"flags": list(message.flags),
|
|
},
|
|
)
|
|
|
|
|
|
def _would_change(message: Message, add: frozenset[str], remove: frozenset[str]) -> bool:
|
|
current = set(message.flags)
|
|
return bool((add - current) or (remove & current))
|
|
|
|
|
|
@router.post("/messages/{message_id}/flags")
|
|
async def message_flags(
|
|
message_id: uuid.UUID, body: FlagChangeRequest, user: CurrentUser, db: DbSession
|
|
) -> dict[str, object]:
|
|
message = await db.scalar(
|
|
select(Message).where(Message.id == message_id, Message.user_id == user.id)
|
|
)
|
|
if message is None:
|
|
raise_api_error(404, "not_found", "message not found")
|
|
add, remove = sanitize_flags(body.add), sanitize_flags(body.remove)
|
|
changed = [message] if _would_change(message, add, remove) else []
|
|
await apply_flags_to_messages(db, [message], add, remove)
|
|
await db.commit()
|
|
await _push_and_publish(user.id, changed, add, remove)
|
|
return {"id": str(message.id), "flags": list(message.flags)}
|
|
|
|
|
|
@router.post("/threads/{thread_id}/flags")
|
|
async def thread_flags(
|
|
thread_id: uuid.UUID, body: FlagChangeRequest, user: CurrentUser, db: DbSession
|
|
) -> dict[str, object]:
|
|
thread = await db.scalar(
|
|
select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id)
|
|
)
|
|
if thread is None:
|
|
raise_api_error(404, "not_found", "thread not found")
|
|
messages = list(
|
|
await db.scalars(
|
|
select(Message).where(Message.thread_id == thread_id, Message.user_id == user.id)
|
|
)
|
|
)
|
|
add, remove = sanitize_flags(body.add), sanitize_flags(body.remove)
|
|
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)
|
|
await get_event_bus().publish(
|
|
"thread.updated", {"user_id": str(user.id), "thread_id": str(thread_id)}
|
|
)
|
|
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:
|
|
thread = await db.scalar(
|
|
select(Thread).where(Thread.id == thread_id, Thread.user_id == user_id)
|
|
)
|
|
if thread is None:
|
|
raise_api_error(404, "not_found", "thread not found")
|
|
await enqueue_job("move_thread_to_folder_job", str(thread_id), special)
|
|
|
|
|
|
@router.post("/threads/{thread_id}/archive")
|
|
async def archive_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
|
await _move_thread(user.id, thread_id, db, "archive")
|
|
return {"status": "queued"}
|
|
|
|
|
|
@router.post("/threads/{thread_id}/trash")
|
|
async def trash_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
|
await _move_thread(user.id, thread_id, db, "trash")
|
|
return {"status": "queued"}
|
|
|
|
|
|
@router.post("/threads/{thread_id}/restore")
|
|
async def restore_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
|
"""Restore a thread (from Archive or Trash) to each message's original folder."""
|
|
thread = await db.scalar(
|
|
select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id)
|
|
)
|
|
if thread is None:
|
|
raise_api_error(404, "not_found", "thread not found")
|
|
await enqueue_job("restore_thread_job", str(thread_id))
|
|
return {"status": "queued"}
|
|
|
|
|
|
@router.post("/threads/{thread_id}/delete")
|
|
async def delete_thread(thread_id: uuid.UUID, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
|
"""Permanently delete a thread (server EXPUNGE + DB rows)."""
|
|
thread = await db.scalar(
|
|
select(Thread).where(Thread.id == thread_id, Thread.user_id == user.id)
|
|
)
|
|
if thread is None:
|
|
raise_api_error(404, "not_found", "thread not found")
|
|
await enqueue_job("delete_thread_job", str(thread_id))
|
|
return {"status": "queued"}
|
|
|
|
|
|
# Folders whose "Empty" action permanently deletes everything they hold.
|
|
_EMPTYABLE_FOLDERS = frozenset({"trash", "junk", "archive"})
|
|
|
|
|
|
@router.post("/folders/{special}/empty")
|
|
async def empty_folder(special: str, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
|
"""Permanently delete every message in a Trash/Spam/Archive folder."""
|
|
if special not in _EMPTYABLE_FOLDERS:
|
|
raise_api_error(400, "bad_folder", "this folder cannot be emptied")
|
|
await enqueue_job("empty_folder_job", str(user.id), special)
|
|
return {"status": "queued"}
|
|
|
|
|
|
@router.post("/messages/{message_id}/move")
|
|
async def move_message(
|
|
message_id: uuid.UUID, body: MoveRequest, user: CurrentUser, db: DbSession
|
|
) -> dict[str, object]:
|
|
message = await db.scalar(
|
|
select(Message).where(Message.id == message_id, Message.user_id == user.id)
|
|
)
|
|
if message is None:
|
|
raise_api_error(404, "not_found", "message not found")
|
|
dst = await db.scalar(
|
|
select(Mailbox).where(
|
|
Mailbox.id == body.mailbox_id,
|
|
Mailbox.user_id == user.id,
|
|
Mailbox.account_id == message.account_id, # moves stay within an account
|
|
)
|
|
)
|
|
if dst is None:
|
|
raise_api_error(400, "bad_mailbox", "target mailbox not found in this account")
|
|
|
|
src_mailbox_id = message.mailbox_id
|
|
message.mailbox_id = dst.id # optimistic
|
|
await db.commit()
|
|
|
|
await enqueue_job("move_job", str(message.id), str(dst.id), str(src_mailbox_id))
|
|
return {"id": str(message.id), "mailbox_id": str(dst.id)}
|