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:
@@ -17,6 +17,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
from app.api.messages import router as messages_router
|
||||
from app.api.recipients import router as recipients_router
|
||||
from app.api.saved_searches import router as saved_searches_router
|
||||
from app.api.search import router as search_router
|
||||
from app.api.settings import router as settings_router
|
||||
from app.api.threads import router as threads_router
|
||||
@@ -34,6 +35,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
recipients_router,
|
||||
settings_router,
|
||||
mailboxes_router,
|
||||
saved_searches_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
|
||||
@@ -82,6 +82,9 @@ async def list_accounts(user: CurrentUser, db: DbSession) -> list[AccountOut]:
|
||||
color=a.color,
|
||||
state=a.state,
|
||||
signature=a.signature,
|
||||
sync_window=a.sync_window,
|
||||
backfill_total=a.backfill_total,
|
||||
backfill_done=a.backfill_done,
|
||||
)
|
||||
for a in accounts
|
||||
]
|
||||
@@ -186,6 +189,18 @@ async def update_account(
|
||||
if body.signature is not None:
|
||||
account.signature = body.signature
|
||||
|
||||
# Broadening the sync window (e.g. "new" -> "all", "Download all mail") means
|
||||
# importing history not fetched before, so re-run the backfill (SPEC §12 M2).
|
||||
# persist_message is idempotent on (mailbox_id, uid), so already-synced mail
|
||||
# is skipped. Narrowing keeps existing mail — nothing to fetch.
|
||||
_COVERAGE = {"new": 0, "3months": 1, "all": 2}
|
||||
broaden = False
|
||||
if body.sync_window is not None and body.sync_window != account.sync_window:
|
||||
broaden = _COVERAGE[body.sync_window] > _COVERAGE.get(account.sync_window, 0)
|
||||
account.sync_window = body.sync_window
|
||||
if broaden:
|
||||
account.state = "backfilling"
|
||||
|
||||
# Connection settings and/or password: re-validate against the servers, then
|
||||
# re-encrypt the secret and re-sync. The password must be supplied (§10.2: the
|
||||
# stored secret is only decrypted in the worker, never here).
|
||||
@@ -224,7 +239,7 @@ async def update_account(
|
||||
|
||||
await db.commit()
|
||||
|
||||
if conn_change:
|
||||
if conn_change or broaden:
|
||||
await enqueue_job("sync_bootstrap_account", str(account.id))
|
||||
|
||||
return _detail(account)
|
||||
@@ -248,6 +263,9 @@ def _detail(account: Account) -> AccountDetailOut:
|
||||
state=account.state,
|
||||
state_detail=account.state_detail,
|
||||
signature=account.signature,
|
||||
sync_window=account.sync_window,
|
||||
backfill_total=account.backfill_total,
|
||||
backfill_done=account.backfill_done,
|
||||
imap_config=account.imap_config,
|
||||
smtp_config=account.smtp_config,
|
||||
)
|
||||
|
||||
@@ -65,14 +65,21 @@ def _sender_email(message: Message) -> str:
|
||||
|
||||
|
||||
@router.get("/{message_id}/html", response_class=HTMLResponse)
|
||||
async def message_html(message_id: uuid.UUID, user: CurrentUser, db: DbSession) -> HTMLResponse:
|
||||
async def message_html(
|
||||
message_id: uuid.UUID,
|
||||
user: CurrentUser,
|
||||
db: DbSession,
|
||||
images: bool = Query(False),
|
||||
) -> HTMLResponse:
|
||||
"""Return sanitized HTML wrapped in a document for the sandboxed iframe (SPEC §9.4, I-8).
|
||||
|
||||
Re-sanitizes from the raw blob when the sender is image-approved, or when the
|
||||
stored copy predates the current sanitizer version (SPEC §10.4).
|
||||
stored copy predates the current sanitizer version (SPEC §10.4). ``images=1``
|
||||
is a one-time "Load images" — render remote images for this request only,
|
||||
without persisting the sender as trusted.
|
||||
"""
|
||||
message = await _owned_message(db, user.id, message_id)
|
||||
allow_remote = _sender_email(message) in allowed_image_senders(user)
|
||||
allow_remote = images or _sender_email(message) in allowed_image_senders(user)
|
||||
|
||||
parts = list(await db.scalars(select(BodyPart).where(BodyPart.message_id == message_id)))
|
||||
html_part = next((p for p in parts if p.kind == "html_sanitized"), None)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Saved searches — stored queries shown as virtual mailboxes (SPEC §12 M2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentUser, require_csrf
|
||||
from app.models import SavedSearch
|
||||
|
||||
router = APIRouter(prefix="/saved-searches", tags=["search"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class SavedSearchOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
query: str
|
||||
pinned: bool
|
||||
|
||||
|
||||
class SavedSearchIn(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
query: str = Field(min_length=1, max_length=500)
|
||||
pinned: bool = True
|
||||
|
||||
|
||||
def _out(s: SavedSearch) -> SavedSearchOut:
|
||||
return SavedSearchOut(id=s.id, name=s.name, query=s.query, pinned=s.pinned)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SavedSearchOut])
|
||||
async def list_saved_searches(user: CurrentUser, db: DbSession) -> list[SavedSearchOut]:
|
||||
rows = await db.scalars(
|
||||
select(SavedSearch).where(SavedSearch.user_id == user.id).order_by(SavedSearch.name)
|
||||
)
|
||||
return [_out(s) for s in rows]
|
||||
|
||||
|
||||
@router.post("", response_model=SavedSearchOut)
|
||||
async def create_saved_search(
|
||||
body: SavedSearchIn, user: CurrentUser, db: DbSession
|
||||
) -> SavedSearchOut:
|
||||
name = body.name.strip()
|
||||
query = body.query.strip()
|
||||
if not name or not query:
|
||||
raise_api_error(400, "invalid", "name and query are required")
|
||||
# Upsert by (user, name): re-saving under an existing name updates its query.
|
||||
existing = await db.scalar(
|
||||
select(SavedSearch).where(SavedSearch.user_id == user.id, SavedSearch.name == name)
|
||||
)
|
||||
if existing is not None:
|
||||
existing.query = query
|
||||
existing.pinned = body.pinned
|
||||
saved = existing
|
||||
else:
|
||||
saved = SavedSearch(user_id=user.id, name=name, query=query, pinned=body.pinned)
|
||||
db.add(saved)
|
||||
await db.commit()
|
||||
return _out(saved)
|
||||
|
||||
|
||||
@router.delete("/{search_id}")
|
||||
async def delete_saved_search(
|
||||
search_id: uuid.UUID, user: CurrentUser, db: DbSession
|
||||
) -> dict[str, str]:
|
||||
saved = await db.scalar(
|
||||
select(SavedSearch).where(SavedSearch.id == search_id, SavedSearch.user_id == user.id)
|
||||
)
|
||||
if saved is None:
|
||||
raise_api_error(404, "not_found", "saved search not found")
|
||||
await db.delete(saved)
|
||||
await db.commit()
|
||||
return {"status": "deleted"}
|
||||
@@ -29,6 +29,7 @@ class ThreadSummary(BaseModel):
|
||||
class ThreadListOut(BaseModel):
|
||||
threads: list[ThreadSummary]
|
||||
cursor: str | None = None
|
||||
total: int = 0
|
||||
|
||||
|
||||
class AttachmentOut(BaseModel):
|
||||
@@ -54,6 +55,8 @@ class MessageEnvelope(BaseModel):
|
||||
is_starred: bool
|
||||
has_attachments: bool
|
||||
has_remote_images: bool
|
||||
# The sender is on the user's trusted list, so remote images load without a prompt.
|
||||
images_allowed: bool = False
|
||||
size_bytes: int
|
||||
snippet: str
|
||||
# Other accounts this same delivery also landed in (collapsed duplicates).
|
||||
@@ -84,6 +87,9 @@ class AccountOut(BaseModel):
|
||||
color: str
|
||||
state: str
|
||||
signature: str = ""
|
||||
sync_window: str = "all"
|
||||
backfill_total: int = 0
|
||||
backfill_done: int = 0
|
||||
|
||||
|
||||
class SearchThreadHit(BaseModel):
|
||||
|
||||
+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