Files
lukasandClaude Opus 5.5 8a1b9979e9 Productivity features, UI translations, local email translation
- Snooze (with a Snoozed folder), keyboard shortcuts with a Settings
  section, mail templates (Compose > More > Templates), a Subscriptions
  folder with bulk unsubscribe/delete, and tracker blocking with a
  Privacy toggle (on by default)
- Own dialogs instead of browser popups; emoji picker in compose; compose
  windows survive a visit to Settings
- Sentence completion learned from the user's sent mail (no AI)
- Interface in English and Polish (one file per language, English as the
  fallback), picked from the browser on first run, then kept
- Email translation on the server via LibreTranslate: Translate button
  and "Translate to...", messages in several languages translated part
  by part, names/addresses/emoji/file names/phone numbers kept, an
  animated line while translating
- Antispam "Mark as spam" loads with the message, not when the menu opens

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:00:31 +02:00

359 lines
14 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, Any
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 import subscriptions as subs
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, blocks_trackers
from app.db import get_session
from app.identity.deps import CurrentUser
from app.models import BodyPart, Mailbox, Message, SubscriptionState, 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, senders: list[Address] | None = None
) -> ThreadSummary:
return ThreadSummary(
id=thread.id,
subject=thread.subject_norm or "(no subject)",
snippet=thread.snippet,
participants=_addresses(thread.participants),
senders=senders or [],
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,
snoozed_until=thread.snoozed_until,
)
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,
content_language=msg.content_language or "",
)
@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))
if special == "inbox":
conditions.append(Thread.snoozed_until.is_(None)) # back when it wakes
elif view == "snoozed":
conditions.append(Thread.snoozed_until.isnot(None))
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
# A thread woken from snooze sorts as if it had just arrived.
sort_at = func.greatest(
Thread.last_message_at, func.coalesce(Thread.unsnoozed_at, Thread.last_message_at)
)
if view == "snoozed":
# Soonest back first, whatever the list order.
order_by: tuple[ColumnElement[Any], ...] = (
Thread.snoozed_until.asc(),
Thread.id.asc(),
)
elif order == "asc":
order_by = (sort_at.asc(), Thread.id.asc())
else:
order_by = (sort_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.
current_special = view.split(":", 1)[1] if view.startswith("folder:") else None
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)
)
count_stmt = count_stmt.where(_visible_in(current_special))
counts = {r[0]: r[1] for r in (await db.execute(count_stmt)).all() if r[0] is not None}
senders = await thread_senders(db, user.id, [t.id for t in threads], current_special)
return ThreadListOut(
threads=[_summary(t, counts.get(t.id), senders.get(t.id)) for t in threads],
total=total,
)
async def _mark_subscriptions(
db: AsyncSession,
user_id: uuid.UUID,
messages: list[Message],
envelopes: list[MessageEnvelope],
) -> None:
"""Tell the reading pane which messages can be unsubscribed from, and how."""
keys: dict[uuid.UUID, str] = {}
for m, env in zip(messages, envelopes, strict=True):
if not m.list_unsubscribe:
continue
targets = subs.unsubscribe_targets(m.list_unsubscribe)
if m.list_unsubscribe_post and targets.https:
env.unsubscribe_via = "one_click"
elif targets.mailto:
env.unsubscribe_via = "mailto"
elif targets.https:
env.unsubscribe_via = "web"
else:
continue
sender = m.from_addr[0].get("email", "") if m.from_addr else ""
env.subscription_key = keys[m.id] = subs.group_key(m.list_id, sender)
if keys:
done = set(
await db.scalars(
select(SubscriptionState.key).where(
SubscriptionState.user_id == user_id,
SubscriptionState.key.in_(set(keys.values())),
)
)
)
for env in envelopes:
env.unsubscribed = env.subscription_key in done
def _visible_in(current_special: str | None) -> ColumnElement[bool]:
"""Which of a thread's messages a view shows (see ``_REMOVAL_FOLDERS``): Trash/Spam
show only their own messages; every other view hides them."""
if current_special in _REMOVAL_FOLDERS:
return Mailbox.special_use == current_special
return or_(Mailbox.special_use.is_(None), Mailbox.special_use.notin_(_REMOVAL_FOLDERS))
async def thread_senders(
db: AsyncSession,
user_id: uuid.UUID,
thread_ids: list[uuid.UUID],
current_special: str | None = None,
) -> dict[uuid.UUID, list[Address]]:
"""Distinct From addresses per thread, oldest message first, over the messages
the view shows. Recipients are left out: the list's sender column names who
wrote, not everyone copied."""
if not thread_ids:
return {}
rows = await db.execute(
select(Message.thread_id, Message.from_addr)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user_id,
Message.thread_id.in_(thread_ids),
_visible_in(current_special),
)
.order_by(Message.internal_date.asc(), Message.id.asc())
)
senders: dict[uuid.UUID, list[Address]] = {}
seen: dict[uuid.UUID, set[str]] = {}
for thread_id, from_addr in rows.all():
if thread_id is None:
continue
for addr in from_addr:
email = (addr.get("email") or "").lower()
if email and email not in seen.setdefault(thread_id, set()):
seen[thread_id].add(email)
senders.setdefault(thread_id, []).append(
Address(name=addr.get("name", ""), email=addr.get("email", ""))
)
return senders
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())
)
# Trash/Spam: only the messages actually filed there. Conversation view: hide
# messages that were individually trashed or spammed.
stmt = stmt.where(_visible_in(current_special))
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)
await _mark_subscriptions(db, user.id, ordered, envelopes)
if not blocks_trackers(user) and ordered:
# Trackers left in are remote images: offer "Load images" for them too.
with_trackers = set(
await db.scalars(
select(BodyPart.message_id).where(
BodyPart.message_id.in_([m.id for m in ordered]),
BodyPart.kind == "html_sanitized",
BodyPart.trackers_blocked > 0,
)
)
)
for env in envelopes:
env.has_remote_images = env.has_remote_images or env.id in with_trackers
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,
snoozed_until=thread.snoozed_until,
)