Sender avatars (privacy-safe, server-fetched, cached): - New GET /api/avatar resolves Gravatar (d=404) then the sender domain's favicon under the SSRF guard, Redis-cached (hit+miss); gated by SENDER_AVATARS_ENABLED. GuardedResponse gains raw bytes. Shown in the mail list, reading pane, People panel, and Contacts (overlay that falls back to initials on a miss). PDF preview: - Password-protected PDFs now prompt in-place and retry (pdf.js PasswordException), with a clear error for other load failures instead of a blank overlay. The password is remembered per attachment until the thread is left; clicking the field no longer dismisses the overlay. Contacts: - Autosave reply recipients: message.sent now carries to/cc (name+email); the contacts plugin subscribes and adds any non-contact recipients to the Autosaved group. Contact avatars show the gravatar/favicon via /api/avatar. Mail views / retention: - Clicking an account shows all its mail except Trash/Spam (was inbox-only). - Daily cron autopurge_trash_spam permanently deletes Trash/Spam older than TRASH_RETENTION_DAYS (default 30; 0 disables) — server EXPUNGE + local rows. UI polish: - Ultimo signet logo in the Mail/Calendar/Contacts sidebar header and collapsed rail; consistent 11px top margin and vertically-aligned header row across states (fixed-size flex icon buttons); SVG favicon on the browser tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
257 lines
10 KiB
Python
257 lines
10 KiB
Python
"""Thread listing and detail endpoints (SPEC §9.1 Mail)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from collections.abc import Collection
|
|
from typing import Annotated
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import Text, func, or_, select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.sql.elements import ColumnElement
|
|
|
|
from app.api.errors import raise_api_error
|
|
from app.api.schemas import (
|
|
Address,
|
|
MessageEnvelope,
|
|
ThreadDetail,
|
|
ThreadListOut,
|
|
ThreadSummary,
|
|
)
|
|
from app.api.settings import allowed_image_senders
|
|
from app.db import get_session
|
|
from app.identity.deps import CurrentUser
|
|
from app.models import Mailbox, Message, Thread, User
|
|
|
|
router = APIRouter(prefix="/threads", tags=["mail"])
|
|
|
|
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
|
|
|
|
|
def _addresses(raw: list[dict[str, str]]) -> list[Address]:
|
|
return [Address(name=a.get("name", ""), email=a.get("email", "")) for a in raw]
|
|
|
|
|
|
def _summary(thread: Thread, message_count: int | None = None) -> ThreadSummary:
|
|
return ThreadSummary(
|
|
id=thread.id,
|
|
subject=thread.subject_norm or "(no subject)",
|
|
snippet=thread.snippet,
|
|
participants=_addresses(thread.participants),
|
|
account_ids=list(thread.account_ids),
|
|
# Count only the messages this view actually shows (deleted/spam excluded
|
|
# elsewhere); falls back to the denormalized total.
|
|
message_count=message_count if message_count is not None else thread.message_count,
|
|
unread_count=thread.unread_count,
|
|
has_attachments=thread.has_attachments,
|
|
pinned=thread.pinned,
|
|
last_message_at=thread.last_message_at,
|
|
)
|
|
|
|
|
|
def _envelope(msg: Message, allowed_senders: Collection[str] = ()) -> MessageEnvelope:
|
|
sender = msg.from_addr[0].get("email", "").lower() if msg.from_addr else ""
|
|
return MessageEnvelope(
|
|
id=msg.id,
|
|
account_id=msg.account_id,
|
|
mailbox_id=msg.mailbox_id,
|
|
thread_id=msg.thread_id,
|
|
subject=msg.subject,
|
|
from_addr=_addresses(msg.from_addr),
|
|
to_addr=_addresses(msg.to_addr),
|
|
cc_addr=_addresses(msg.cc_addr),
|
|
bcc_addr=_addresses(msg.bcc_addr),
|
|
date=msg.date_hdr or msg.internal_date,
|
|
flags=list(msg.flags),
|
|
is_unread="\\Seen" not in msg.flags,
|
|
is_starred="\\Flagged" in msg.flags,
|
|
has_attachments=msg.has_attachments,
|
|
has_remote_images=msg.has_remote_images,
|
|
images_allowed=bool(sender) and sender in allowed_senders,
|
|
size_bytes=msg.size_bytes,
|
|
snippet=msg.snippet,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=ThreadListOut)
|
|
async def list_threads(
|
|
user: CurrentUser,
|
|
db: DbSession,
|
|
view: str = Query("unified"),
|
|
limit: int = Query(50, ge=1, le=200),
|
|
offset: int = Query(0, ge=0),
|
|
filter: str = Query("all"), # all | unread | pinned
|
|
order: str = Query("desc"), # desc (newest first) | asc (oldest first)
|
|
ids: str = Query(""), # comma-separated thread ids (e.g. a plugin's label view)
|
|
) -> ThreadListOut:
|
|
# Collect WHERE conditions once so the page query and the total-count query
|
|
# stay in lockstep (offset pagination needs an accurate grand total).
|
|
conditions: list[ColumnElement[bool]] = [Thread.user_id == user.id]
|
|
|
|
if ids:
|
|
# Explicit id set (ignores view/folder) — used by plugin-driven views like
|
|
# "all mail with label X", which supply the matching thread ids.
|
|
try:
|
|
wanted = [uuid.UUID(x) for x in ids.split(",") if x.strip()]
|
|
except ValueError:
|
|
raise_api_error(400, "bad_ids", "invalid thread id in ids")
|
|
conditions.append(Thread.id.in_(wanted or [uuid.uuid4()]))
|
|
elif view.startswith("account:"):
|
|
# A single account's mail: threads with a message in ANY of THIS account's
|
|
# mailboxes except Trash/Spam (those are only seen via their own folders).
|
|
try:
|
|
account_id = uuid.UUID(view.split(":", 1)[1])
|
|
except ValueError:
|
|
raise_api_error(400, "bad_view", "invalid account id in view")
|
|
in_account = (
|
|
select(Message.thread_id)
|
|
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
|
.where(
|
|
Message.user_id == user.id,
|
|
Message.account_id == account_id,
|
|
or_(
|
|
Mailbox.special_use.is_(None),
|
|
Mailbox.special_use.notin_(_REMOVAL_FOLDERS),
|
|
),
|
|
)
|
|
)
|
|
conditions.append(Thread.id.in_(in_account))
|
|
elif view.startswith("folder:"):
|
|
# Threads with at least one message in a mailbox of this special_use,
|
|
# across all of the user's accounts (unified per-folder view).
|
|
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")
|
|
|
|
if filter == "unread":
|
|
conditions.append(Thread.unread_count > 0)
|
|
elif filter == "pinned":
|
|
conditions.append(Thread.pinned.is_(True))
|
|
|
|
total = (
|
|
await db.scalar(
|
|
select(func.count()).select_from(select(Thread.id).where(*conditions).subquery())
|
|
)
|
|
) or 0
|
|
|
|
order_by = (
|
|
(Thread.last_message_at.asc(), Thread.id.asc())
|
|
if order == "asc"
|
|
else (Thread.last_message_at.desc(), Thread.id.desc())
|
|
)
|
|
stmt = select(Thread).where(*conditions).order_by(*order_by).offset(offset).limit(limit)
|
|
threads = list(await db.scalars(stmt))
|
|
|
|
# Per-thread visible message count matching what get_thread shows for this view:
|
|
# Trash/Spam count only their own messages; every other view excludes them. This
|
|
# 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(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)
|
|
)
|
|
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
|
|
if current_special in _REMOVAL_FOLDERS:
|
|
count_stmt = count_stmt.where(Mailbox.special_use == current_special)
|
|
else:
|
|
count_stmt = count_stmt.where(
|
|
or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS))
|
|
)
|
|
counts = {
|
|
r[0]: r[1] for r in (await db.execute(count_stmt)).all() if r[0] is not None
|
|
}
|
|
|
|
return ThreadListOut(
|
|
threads=[_summary(t, counts.get(t.id)) for t in threads], total=total
|
|
)
|
|
|
|
|
|
async def _resolve_thread(db: AsyncSession, user: User, thread_id: uuid.UUID) -> Thread:
|
|
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")
|
|
return thread
|
|
|
|
|
|
# Trash/Spam are message-scoped folders: an individually deleted message lives
|
|
# there on its own. Viewing a thread from Trash/Spam shows ONLY the messages filed
|
|
# there; every other view shows the conversation with trashed/spam messages hidden.
|
|
_REMOVAL_FOLDERS = frozenset({"trash", "junk"})
|
|
|
|
|
|
@router.get("/{thread_id}", response_model=ThreadDetail)
|
|
async def get_thread(
|
|
thread_id: uuid.UUID,
|
|
user: CurrentUser,
|
|
db: DbSession,
|
|
sort: str = Query("asc"),
|
|
view: str = Query("unified"),
|
|
) -> ThreadDetail:
|
|
thread = await _resolve_thread(db, user, thread_id)
|
|
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
|
|
# Messages are always returned ASC (oldest first); the client reverses for
|
|
# display per the requested order (invariant I-5 / §9.4).
|
|
stmt = (
|
|
select(Message)
|
|
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
|
.where(Message.thread_id == thread_id, Message.user_id == user.id)
|
|
.order_by(Message.internal_date.asc(), Message.id.asc())
|
|
)
|
|
if current_special in _REMOVAL_FOLDERS:
|
|
# Show only the messages actually in this Trash/Spam folder.
|
|
stmt = stmt.where(Mailbox.special_use == current_special)
|
|
else:
|
|
# Conversation view: hide messages that were individually trashed or spammed
|
|
# (keep custom folders, special_use IS NULL, and every non-removal folder).
|
|
stmt = stmt.where(
|
|
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)
|
|
|
|
allowed_senders = allowed_image_senders(user)
|
|
envelopes = []
|
|
for m in ordered:
|
|
env = _envelope(m, allowed_senders)
|
|
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=envelopes,
|
|
)
|