M2: Meili search + saved searches, sync windows + backfill progress, threading & image fixes
Search: - Meilisearch backend (app/search/meili.py) + factory wiring; pinned SDK 2.7 - Search-as-you-type: debounced query + Postgres prefix matching, built from the DB tokenizer so full email addresses match (email stays one lexeme) - Saved searches API + sidebar virtual mailboxes; save affordance in the box Account sync windows (SPEC §12 M2): - Choose at setup: All (recent 30d first, then remainder), Last 3 months, New only - Per-account sync_window + "Sync remaining mail" from settings (idempotent re-run) - Provider windowed backfill (SINCE/BEFORE), message_count, newest_uid - migration 0012: accounts.sync_window + backfill_total/backfill_done Backfill progress UX: - Floating progress card (auto-dissolves after 10s) + spinning sidebar ring that fills with the account colour; driven by sync.progress over WS Thread list pagination: - Server-side offset + accurate grand total, with filter/order in SQL; working prev/next across all synced mail Reading panel: - "Load images" one-off now actually renders images (?images=1) without trusting the sender; envelope carries images_allowed so a trusted sender shows no prompt Threading: - Subject fallback no longer merges messages that declare an explicit (different) parent; true siblings still group via a shared-parent reference match. Fixes unrelated auto-replies with the same subject landing in one thread Backend 257 tests green, ruff + mypy --strict clean; frontend tsc/eslint/vitest clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
+34
-21
@@ -3,13 +3,14 @@
|
||||
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, literal, or_, select, tuple_
|
||||
from sqlalchemy import Text, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.sql.elements import ColumnElement
|
||||
|
||||
from app.api.cursors import decode_cursor, encode_cursor
|
||||
from app.api.errors import raise_api_error
|
||||
from app.api.schemas import (
|
||||
Address,
|
||||
@@ -18,6 +19,7 @@ from app.api.schemas import (
|
||||
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
|
||||
@@ -48,7 +50,8 @@ def _summary(thread: Thread, message_count: int | None = None) -> ThreadSummary:
|
||||
)
|
||||
|
||||
|
||||
def _envelope(msg: Message) -> MessageEnvelope:
|
||||
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,
|
||||
@@ -65,6 +68,7 @@ def _envelope(msg: Message) -> MessageEnvelope:
|
||||
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,
|
||||
)
|
||||
@@ -75,10 +79,14 @@ async def list_threads(
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
view: str = Query("unified"),
|
||||
cursor: str | None = None,
|
||||
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)
|
||||
) -> ThreadListOut:
|
||||
stmt = select(Thread).where(Thread.user_id == user.id)
|
||||
# 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 view.startswith("account:"):
|
||||
# A single account's inbox: threads with a message in THIS account's
|
||||
@@ -97,7 +105,7 @@ async def list_threads(
|
||||
Mailbox.special_use == "inbox",
|
||||
)
|
||||
)
|
||||
stmt = stmt.where(Thread.id.in_(in_account_inbox))
|
||||
conditions.append(Thread.id.in_(in_account_inbox))
|
||||
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).
|
||||
@@ -107,24 +115,28 @@ async def list_threads(
|
||||
.join(Mailbox, Mailbox.id == Message.mailbox_id)
|
||||
.where(Message.user_id == user.id, Mailbox.special_use == special)
|
||||
)
|
||||
stmt = stmt.where(Thread.id.in_(in_folder))
|
||||
conditions.append(Thread.id.in_(in_folder))
|
||||
elif view != "unified":
|
||||
raise_api_error(400, "bad_view", "unsupported view")
|
||||
|
||||
decoded = decode_cursor(cursor)
|
||||
if decoded is not None:
|
||||
cur_date, cur_id = decoded
|
||||
stmt = stmt.where(
|
||||
tuple_(Thread.last_message_at, Thread.id) < tuple_(literal(cur_date), literal(cur_id))
|
||||
)
|
||||
stmt = stmt.order_by(Thread.last_message_at.desc(), Thread.id.desc()).limit(limit + 1)
|
||||
if filter == "unread":
|
||||
conditions.append(Thread.unread_count > 0)
|
||||
elif filter == "pinned":
|
||||
conditions.append(Thread.pinned.is_(True))
|
||||
|
||||
threads = list(await db.scalars(stmt))
|
||||
has_more = len(threads) > limit
|
||||
threads = threads[:limit]
|
||||
next_cursor = (
|
||||
encode_cursor(threads[-1].last_message_at, threads[-1].id) if has_more and threads else None
|
||||
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
|
||||
@@ -152,7 +164,7 @@ async def list_threads(
|
||||
}
|
||||
|
||||
return ThreadListOut(
|
||||
threads=[_summary(t, counts.get(t.id)) for t in threads], cursor=next_cursor
|
||||
threads=[_summary(t, counts.get(t.id)) for t in threads], total=total
|
||||
)
|
||||
|
||||
|
||||
@@ -216,9 +228,10 @@ async def get_thread(
|
||||
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)
|
||||
env = _envelope(m, allowed_senders)
|
||||
env.also_in_account_ids = also[m.delivery_id or f"id:{m.id}"]
|
||||
envelopes.append(env)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user