Search: all results with a real count; bulk actions on results stay done

Search showed at most the first 50 matching messages ("1–50 of 50"). The
first page now carries the number of matching conversations, further pages
load as the list is paged through (a conversation spanning two appears once;
oldest-first loads them all, up to 40 pages), and the Unread/Pinned tabs filter
on the server, so their count covers every result.

Bulk Delete / Archive / Restore over selected results acts at once: the rows
leave the list (greyed instead when archived from a search, which still finds
them), the search results are refreshed, and the reading pane returns to
"Select a conversation". Each move landing on the server refreshes them again,
and a hidden row only comes back when no move has landed for 30 seconds — a
long batch no longer brings deleted results back.

Settings → Accounts → Notifications: shorter description.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 21:15:06 +02:00
co-authored by Claude Opus 5.5
parent 9c08cbb140
commit 5bf8fc81f6
9 changed files with 230 additions and 37 deletions
+2
View File
@@ -112,3 +112,5 @@ class SearchThreadHit(BaseModel):
class SearchOut(BaseModel):
threads: list[SearchThreadHit]
cursor: str | None = None
# Conversations matching in all (first page only; None where not counted).
total: int | None = None
+15 -2
View File
@@ -29,13 +29,22 @@ async def search(
q: str = Query(""),
cursor: str | None = None,
limit: int = Query(50, ge=1, le=200),
filter: str | None = Query(None, pattern="^(unread|pinned)$"),
) -> SearchOut:
"""One page of matching messages, grouped into conversations. ``filter``
keeps conversations with unread mail / pinned ones (the list's Unread and
Pinned tabs). The first page also says how many conversations match in
all, where the search engine can count them."""
if not q.strip():
return SearchOut(threads=[], cursor=None)
backend = get_search_backend()
query = to_search_query(q, user.id)
hits, next_cursor = await backend.query(query, limit=limit, cursor=cursor)
total: int | None = None
count = getattr(backend, "count_threads", None)
if cursor is None and count is not None:
total = await count(query, thread_filter=filter)
# Group hits by thread, preserving order and keeping the first highlight.
order: list[uuid.UUID] = []
@@ -48,7 +57,7 @@ async def search(
highlights[hit.thread_id] = hit.highlights
if not order:
return SearchOut(threads=[], cursor=next_cursor)
return SearchOut(threads=[], cursor=next_cursor, total=total)
threads = {
t.id: t
@@ -64,5 +73,9 @@ async def search(
)
for tid in order
if tid in threads
and (
filter is None
or (threads[tid].unread_count > 0 if filter == "unread" else threads[tid].pinned)
)
]
return SearchOut(threads=results, cursor=next_cursor)
return SearchOut(threads=results, cursor=next_cursor, total=total)
+41 -13
View File
@@ -13,6 +13,7 @@ import base64
import datetime as dt
import html
import re
from typing import Any
from uuid import UUID
from sqlalchemy import (
@@ -20,6 +21,7 @@ from sqlalchemy import (
any_,
case,
cast,
distinct,
exists,
func,
literal,
@@ -32,7 +34,7 @@ from sqlalchemy import (
from sqlalchemy.sql.elements import ColumnElement
from app.db import get_sessionmaker
from app.models import Attachment, Mailbox, Message
from app.models import Attachment, Mailbox, Message, Thread
from app.search.base import SearchBackend, SearchDoc, SearchHit, SearchQuery
_TS_CONFIG = "simple" # language-neutral; avoids wrong-language stemming (M0)
@@ -52,6 +54,8 @@ def _prefix_tsquery(value: str) -> ColumnElement[object]:
lexemes = cast(func.plainto_tsquery(_TS_CONFIG, value), Text)
prefixed = func.regexp_replace(lexemes, r"'([^']+)'", r"'\1':*", "g")
return func.to_tsquery(_TS_CONFIG, prefixed)
_RELATIVE_MULT = {"d": 1, "w": 7, "m": 30, "y": 365}
@@ -254,20 +258,9 @@ class PgSearchBackend(SearchBackend):
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == q.user_id, Message.thread_id.is_not(None))
)
for cond in conditions:
for cond in self._scope(groups, conditions):
stmt = stmt.where(cond)
# Exclude Trash/Junk from search by default (Gmail-style), so deleting a
# message from search results removes it. An explicit `in:trash`/`in:junk`
# overrides this to search there on purpose.
if not _targets_removal_folder(groups):
stmt = stmt.where(
or_(
Mailbox.special_use.is_(None),
Mailbox.special_use.notin_(_REMOVAL_FOLDERS),
)
)
decoded = _decode_cursor(cursor)
if decoded is not None:
cur_hit, cur_date, cur_id = decoded
@@ -301,6 +294,41 @@ class PgSearchBackend(SearchBackend):
)
return hits, next_cursor
@staticmethod
def _scope(groups: list[list[dict[str, object]]], conditions: list[Any]) -> list[Any]:
"""The query's conditions, plus leaving out Trash/Junk by default
(Gmail-style), so deleting a message from search results removes it. An
explicit `in:trash`/`in:junk` overrides this to search there on purpose."""
out = list(conditions)
if not _targets_removal_folder(groups):
out.append(
or_(
Mailbox.special_use.is_(None),
Mailbox.special_use.notin_(_REMOVAL_FOLDERS),
)
)
return out
async def count_threads(self, q: SearchQuery, *, thread_filter: str | None = None) -> int:
"""How many conversations match (``thread_filter``: "unread" / "pinned"
keeps only conversations with unread mail / pinned ones)."""
groups = q.filters.get("groups", []) if isinstance(q.filters, dict) else []
assert isinstance(groups, list)
stmt = (
select(func.count(distinct(Message.thread_id)))
.select_from(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(Message.user_id == q.user_id, Message.thread_id.is_not(None))
)
for cond in self._scope(groups, self._build_conditions(groups)):
stmt = stmt.where(cond)
if thread_filter in ("unread", "pinned"):
stmt = stmt.join(Thread, Thread.id == Message.thread_id).where(
Thread.unread_count > 0 if thread_filter == "unread" else Thread.pinned.is_(True)
)
async with get_sessionmaker()() as session:
return int(await session.scalar(stmt) or 0)
_REMOVAL_FOLDERS = ("trash", "junk")
+41
View File
@@ -245,3 +245,44 @@ async def test_negation(seeded: Ctx) -> None:
async def test_combined_operators(seeded: Ctx) -> None:
assert await _search(seeded, "from:dave has:attachment") == 1
assert await _search(seeded, "from:bob has:attachment") == 0
async def test_more_than_one_page_counts_and_pages(ctx: Ctx) -> None:
"""Beyond the first page: the first page says how many conversations match
in all, the cursor brings the rest, and Unread/Pinned filter conversations."""
for i in range(7):
await _ingest(
ctx,
frm=f"Sender {i} <s{i}@example.org>",
subject=f"Quarterly report {i}",
body="numbers inside",
flags=frozenset({"\\Seen"}) if i % 2 else frozenset(),
)
await _ingest(ctx, frm="Other <o@example.org>", subject="Lunch", body="nothing")
first = (await ctx.client.get("/api/search", params={"q": "quarterly", "limit": 3})).json()
assert first["total"] == 7 and len(first["threads"]) == 3 and first["cursor"]
seen = {t["thread"]["id"] for t in first["threads"]}
cursor = first["cursor"]
while cursor:
page = (
await ctx.client.get(
"/api/search", params={"q": "quarterly", "limit": 3, "cursor": cursor}
)
).json()
assert page["total"] is None # counted once, on the first page
seen |= {t["thread"]["id"] for t in page["threads"]}
cursor = page["cursor"]
assert len(seen) == 7
unread = (
await ctx.client.get("/api/search", params={"q": "quarterly", "filter": "unread"})
).json()
assert unread["total"] == 4 and len(unread["threads"]) == 4
assert all(t["thread"]["unread_count"] > 0 for t in unread["threads"])
pinned = (
await ctx.client.get("/api/search", params={"q": "quarterly", "filter": "pinned"})
).json()
assert pinned["total"] == 0 and pinned["threads"] == []
bad = await ctx.client.get("/api/search", params={"q": "quarterly", "filter": "nope"})
assert bad.status_code == 422