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:
2026-07-23 23:00:08 +02:00
co-authored by Claude Opus 4.8
parent b4e07f11aa
commit 49738740c0
37 changed files with 1526 additions and 108 deletions
+175 -47
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { Link, useMatch, useNavigate } from "react-router-dom";
@@ -9,11 +9,12 @@ import { MessageList } from "../components/list/MessageList";
import { Resizer } from "../components/Resizer";
import { ThemeToggle } from "../components/ThemeToggle";
import { EmptyIllustration } from "../components/EmptyIllustration";
import { BackfillBanner } from "../components/BackfillBanner";
import { ThreadView } from "../components/thread/ThreadView";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api";
import type { QuotaOut, ThreadSummary } from "../lib/types";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
function quotaLabel(q: QuotaOut): string {
@@ -48,6 +49,13 @@ export function MailLayout() {
const threadId = match?.params.threadId;
const [view, setView] = useState<string>("folder:inbox");
const [term, setTerm] = useState("");
// Debounced copy of the search box for search-as-you-type (avoids a request per key).
const [debouncedTerm, setDebouncedTerm] = useState("");
useEffect(() => {
const h = window.setTimeout(() => setDebouncedTerm(term), 180);
return () => window.clearTimeout(h);
}, [term]);
const searching = debouncedTerm.trim().length > 0;
const [filter, setFilter] = useState<"all" | "unread" | "pinned">("all");
const [listOrder, setListOrder] = useState<"newest" | "oldest">(() =>
localStorage.getItem("mail.listOrder") === "oldest" ? "oldest" : "newest",
@@ -90,10 +98,13 @@ export function MailLayout() {
const currentFolder = view.startsWith("folder:") ? view.slice("folder:".length) : "inbox";
const accountsQuery = useQuery({ queryKey: ["accounts"], queryFn: api.accounts });
const mailboxesQuery = useQuery({ queryKey: ["mailboxes"], queryFn: api.mailboxes });
const orderParam: "asc" | "desc" = listOrder === "newest" ? "desc" : "asc";
const threadsQuery = useQuery({
queryKey: ["threads", view],
queryFn: () => api.threads(view),
enabled: !isDrafts,
queryKey: ["threads", view, filter, orderParam, page],
queryFn: () =>
api.threads(view, { offset: page * PAGE_SIZE, limit: PAGE_SIZE, filter, order: orderParam }),
enabled: !isDrafts && !searching,
placeholderData: keepPreviousData, // keep the current page visible while the next loads
});
const draftsQuery = useQuery({
queryKey: ["drafts"],
@@ -106,33 +117,36 @@ export function MailLayout() {
enabled: isDrafts,
});
const searchQuery = useQuery({
queryKey: ["search", term],
queryFn: () => api.search(term),
enabled: term.trim().length > 0,
queryKey: ["search", debouncedTerm],
queryFn: () => api.search(debouncedTerm),
enabled: searching,
});
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
const allThreads: ThreadSummary[] = term.trim()
? (searchQuery.data?.threads ?? []).map((h) => h.thread)
: (threadsQuery.data?.threads ?? []);
const filtered = allThreads.filter((th) =>
filter === "unread" ? th.unread_count > 0 : filter === "pinned" ? th.pinned : true,
);
// The backend returns threads newest-first; reverse for an oldest-first view.
const threads = listOrder === "newest" ? filtered : [...filtered].reverse();
// Client-side pagination of the (filtered, ordered) list.
const total = threads.length;
const maxPage = Math.max(0, Math.ceil(total / PAGE_SIZE) - 1);
const curPage = Math.min(page, maxPage);
const pageStart = curPage * PAGE_SIZE;
const pageEnd = Math.min(pageStart + PAGE_SIZE, total);
const pageThreads = threads.slice(pageStart, pageEnd);
// Reset to the first page whenever the underlying list identity changes.
useEffect(() => setPage(0), [view, filter, term, listOrder]);
// Folder/inbox views paginate server-side with an accurate grand total; search
// results (a single fetched page of hits) still paginate client-side.
let pageThreads: ThreadSummary[];
let total: number;
if (searching) {
const hits = (searchQuery.data?.threads ?? []).map((h) => h.thread);
const f = hits.filter((th) =>
filter === "unread" ? th.unread_count > 0 : filter === "pinned" ? th.pinned : true,
);
const ordered = listOrder === "newest" ? f : [...f].reverse();
total = ordered.length;
pageThreads = ordered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE);
} else {
pageThreads = threadsQuery.data?.threads ?? [];
total = threadsQuery.data?.total ?? 0;
}
const pageStart = page * PAGE_SIZE;
const pageEnd = pageStart + pageThreads.length;
// Reset to the first page whenever the list identity (not the page) changes.
useEffect(() => setPage(0), [view, filter, debouncedTerm, listOrder]);
// Master "select all" state over the currently listed threads.
const listedSelectedCount = pageThreads.filter((th) => checkedIds.has(th.id)).length;
@@ -141,8 +155,9 @@ export function MailLayout() {
const toggleSelectAll = () =>
setCheckedIds(allSelected ? new Set() : new Set(pageThreads.map((th) => th.id)));
const threadsRef = useRef<ThreadSummary[]>(threads);
threadsRef.current = threads;
// Keyboard next/prev navigates within the currently visible page.
const threadsRef = useRef<ThreadSummary[]>(pageThreads);
threadsRef.current = pageThreads;
const detailQuery = useQuery({
queryKey: ["thread", threadId, threadOrder, view],
@@ -157,6 +172,27 @@ export function MailLayout() {
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
},
});
// Saved searches (virtual mailboxes, §12 M2).
const savedSearches = savedSearchesQuery.data ?? [];
const saveSearch = useMutation({
mutationFn: (name: string) => api.createSavedSearch(name, term.trim()),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }),
});
const deleteSaved = useMutation({
mutationFn: (id: string) => api.deleteSavedSearch(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["saved-searches"] }),
});
const onSaveSearch = () => {
const q = term.trim();
if (!q) return;
const name = window.prompt(t("search.saveNamePrompt"), q);
if (name && name.trim()) saveSearch.mutate(name.trim());
};
const runSavedSearch = (s: SavedSearch) => {
setTerm(s.query);
navigate("/mail");
};
// Auto-mark an opened thread read after a short dwell (4s), not instantly — and
// cancel it if you leave before then, so a quick glance doesn't mark it read.
useEffect(() => {
@@ -359,6 +395,7 @@ export function MailLayout() {
return (
<div className="flex h-screen bg-app-bg text-app-fg">
<BackfillBanner />
{/* Sidebar */}
<aside
style={{ width: collapsed ? COLLAPSED_W : sidebarW }}
@@ -435,10 +472,50 @@ export function MailLayout() {
active={view === `account:${a.id}`}
onClick={() => { setView(`account:${a.id}`); navigate("/mail"); }}
>
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
<AccountDot
color={a.color}
syncing={a.state === "backfilling" && a.backfill_total > 0}
total={a.backfill_total}
done={a.backfill_done}
/>
<span className="truncate">{a.email}</span>
</SidebarItem>
))}
{savedSearches.length > 0 && (
<>
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("search.saved")}
</div>
{savedSearches.map((s) => (
<div key={s.id} className="group/saved relative">
<SidebarItem
active={searching && debouncedTerm === s.query}
onClick={() => runSavedSearch(s)}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" className="shrink-0">
<circle cx="7" cy="7" r="4.5" />
<path d="M10.5 10.5L14 14" />
</svg>
<span className="flex-1 truncate">{s.name}</span>
</SidebarItem>
<button
type="button"
title={t("common.delete")}
aria-label={t("common.delete")}
onClick={(e) => {
e.stopPropagation();
deleteSaved.mutate(s.id);
}}
className="absolute right-2 top-1/2 hidden -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg group-hover/saved:block"
>
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</div>
))}
</>
)}
</>
)}
</nav>
@@ -489,26 +566,41 @@ export function MailLayout() {
<div className="relative w-full">
<input
ref={searchRef}
className="w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 pr-8 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none"
className={`w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none ${
term ? "pr-14" : "pr-8"
}`}
placeholder={t("nav.search")}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
{term && (
<button
type="button"
title={t("common.clear")}
aria-label={t("common.clear")}
onClick={() => {
setTerm("");
searchRef.current?.focus();
}}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
<>
<button
type="button"
title={t("search.save")}
aria-label={t("search.save")}
onClick={onSaveSearch}
className="absolute right-8 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-accent"
>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 2.5h8v11l-4-2.6-4 2.6z" />
</svg>
</button>
<button
type="button"
title={t("common.clear")}
aria-label={t("common.clear")}
onClick={() => {
setTerm("");
searchRef.current?.focus();
}}
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded p-1 text-app-faint hover:bg-app-hover hover:text-app-fg"
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</>
)}
</div>
</div>
@@ -612,14 +704,14 @@ export function MailLayout() {
</span>
<div className="-mr-1 flex items-center gap-0.5">
<PagerButton
disabled={curPage === 0}
onClick={() => setPage(curPage - 1)}
disabled={page === 0}
onClick={() => setPage(page - 1)}
label={t("pager.prev")}
dir="left"
/>
<PagerButton
disabled={pageEnd >= total}
onClick={() => setPage(curPage + 1)}
onClick={() => setPage(page + 1)}
label={t("pager.next")}
dir="right"
/>
@@ -765,6 +857,42 @@ export function MailLayout() {
);
}
// Account bullet in the sidebar. While the account is backfilling it becomes a
// spinning ring that fills with the account colour to show import progress
// (SPEC §12 M2); once live it's the plain colour-filled dot.
function AccountDot({
color,
syncing,
total,
done,
}: {
color: string;
syncing: boolean;
total: number;
done: number;
}) {
if (!syncing) {
return (
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ backgroundColor: color }} />
);
}
const pct = total > 0 ? Math.min(100, Math.round((done / Math.max(done, total)) * 100)) : 0;
const ring = "rgba(128,128,128,0.35)";
return (
<span
className="h-3.5 w-3.5 shrink-0 animate-spin rounded-full"
style={{
animationDuration: "1.1s",
background: `conic-gradient(${color} ${pct}%, ${ring} ${pct}% 100%)`,
// Punch out the centre to leave a ~3px ring.
WebkitMask: "radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0)",
mask: "radial-gradient(farthest-side, transparent calc(100% - 3px), #000 0)",
}}
title={`${pct}%`}
/>
);
}
function FilterTab({
active,
onClick,