From ebbe432d2cd07397436d289de40bcecf3368c722 Mon Sep 17 00:00:00 2001 From: SUMAN JANA Date: Thu, 17 Sep 2026 10:20:17 +0000 Subject: [PATCH] feat(unibox): search people and partial words, and offer to widen a search that found nothing in the current scope --- docs/content/docs/guides/unibox.mdx | 6 +- internal/repository/pg_unibox.go | 90 ++++++++++- internal/repository/unibox_search_test.go | 47 ++++++ web/src/app/app/unibox/page.tsx | 26 ++++ web/src/app/app/unibox/uniboxHarness.tsx | 13 +- web/src/app/app/unibox/uniboxSearch.test.tsx | 147 ++++++++++++++++++ .../app/unibox/ConversationList.tsx | 50 ++++-- .../api/client/app/unibox/searchIncoming.ts | 6 +- .../lib/api/models/app/unibox/UniboxSearch.ts | 7 +- 9 files changed, 363 insertions(+), 29 deletions(-) create mode 100644 internal/repository/unibox_search_test.go create mode 100644 web/src/app/app/unibox/uniboxSearch.test.tsx diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index f505eec9d..6a091a473 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -76,7 +76,11 @@ Below those, the rail lists each mailbox with its unread count, plus your **Labe Counts come from the server in one request, so they always match the rows. The current view and open conversation live in the URL (`/app/unibox/unread`), so refreshing, bookmarking, and sharing all land in the same place. -Search (press `/`) searches within your current scope, across subjects and the text of the messages themselves, so a phrase from the middle of an email finds the conversation. The filter button next to the title opens a small panel that narrows the list on the spot: read state, a date range (today, the last 7 or 30 days, or two dates), sender, labels, mailboxes (or a whole tag of them), and sort order. Anything you add shows as a chip under the search box, and each chip removes itself with one click. A view that already fixes one of these, such as Unread or a single mailbox, does not offer that control again. +Search (press `/`) covers the sender and recipients, the subject, and the text of the messages themselves, so a name, an address, or a phrase from the middle of an email all find the conversation. A half-typed word matches as a prefix, so results narrow as you type rather than appearing only once a word is finished. Quoted phrases match exactly, `OR` widens, and a `-word` excludes. + +It searches the scope you are in. When that finds nothing, the empty state offers **Search all mail**, which widens to every folder and keeps what you typed. Changing view yourself clears the box, so a query typed for one scope never silently filters the next. + +The filter button next to the title opens a small panel that narrows the list on the spot: read state, a date range (today, the last 7 or 30 days, or two dates), sender, labels, mailboxes (or a whole tag of them), and sort order. Anything you add shows as a chip under the search box, and each chip removes itself with one click. A view that already fixes one of these, such as Unread or a single mailbox, does not offer that control again. ## Threading diff --git a/internal/repository/pg_unibox.go b/internal/repository/pg_unibox.go index 617d275aa..3e512d912 100644 --- a/internal/repository/pg_unibox.go +++ b/internal/repository/pg_unibox.go @@ -7,6 +7,7 @@ import ( "fmt" "strings" "time" + "unicode" "github.com/google/uuid" "github.com/jackc/pgx/v5" @@ -496,13 +497,51 @@ func (r *uniboxRepository) Search(ctx context.Context, orgID uuid.UUID, params * } if params.Subject != nil && *params.Subject != "" { - // Two indexes, one query: search_tsv covers subject + preview, and the - // body expression matches idx_unibox_emails_body_search exactly (it has - // to be written the same way, or the index is not used). - inner += fmt.Sprintf(` AND (ue.search_tsv @@ plainto_tsquery('english', $%d) - OR to_tsvector('english'::regconfig, ue.body_text) @@ plainto_tsquery('english', $%d))`, argPos, argPos) - args = append(args, *params.Subject) - argPos++ + // One box, everything a person searches an inbox for: the words in the + // subject or the body, and the people the message was with. + // + // Three matchers, because no single one covers it: + // + // full text — websearch_to_tsquery over search_tsv (subject + + // preview) and the body. It reads "quoted phrases", + // OR and -excluded the way every search box does, and + // unlike to_tsquery it cannot be made to raise on + // punctuation, so a user typing `re: (urgent)` gets + // results rather than a 500. + // prefix — whole words only is wrong while someone is still + // typing: "dyno" found 2 of the 100+ messages that say + // dynoweb. The sanitized prefix query fixes that. + // people — the addresses. Searching a name or an address was + // finding nothing at all unless it also appeared in the + // body, which is not what anyone expects from an inbox. + // + // The body expression is written exactly as idx_unibox_emails_body_search + // declares it, or the index is not used. + q := *params.Subject + webPos, addrPos := argPos, argPos+1 + args = append(args, q, q) + argPos += 2 + + match := fmt.Sprintf(`ue.search_tsv @@ websearch_to_tsquery('english', $%d) + OR to_tsvector('english'::regconfig, ue.body_text) @@ websearch_to_tsquery('english', $%d)`, webPos, webPos) + + if prefix := prefixTSQuery(q); prefix != "" { + match += fmt.Sprintf(` + OR ue.search_tsv @@ to_tsquery('english', $%d) + OR to_tsvector('english'::regconfig, ue.body_text) @@ to_tsquery('english', $%d)`, argPos, argPos) + args = append(args, prefix) + argPos++ + } + + // Participants. The stored header is "Display Name (addr)", so one + // substring match covers searching by either. + match += fmt.Sprintf(` + OR EXISTS (SELECT 1 FROM unnest(ue.from_addr) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%') + OR EXISTS (SELECT 1 FROM unnest(ue.to_addr) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%') + OR EXISTS (SELECT 1 FROM unnest(ue.cc) AS s(addr) WHERE s.addr ILIKE '%%' || $%d || '%%')`, + addrPos, addrPos, addrPos) + + inner += ` AND (` + match + `)` } if params.Sender != nil && *params.Sender != "" { @@ -1440,3 +1479,40 @@ func (r *uniboxRepository) scanGrounding(ctx context.Context, query string, orgI } return out, rows.Err() } + +// prefixTSQuery turns what the user has typed into a prefix tsquery, so a +// half-typed word still matches: "dynow" finds "dynoweb". +// +// Built here rather than in SQL because to_tsquery is a parser, not a matcher: +// an `&`, a `:` or an unbalanced bracket in the input makes it raise, and a +// search box is exactly where those arrive. Everything that is not a letter, a +// digit or a space is dropped, which leaves nothing that can change the shape +// of the query. Returns "" when there is nothing left to search on, and the +// caller then relies on the other matchers. +// +// Terms are ANDed, matching what websearch_to_tsquery does with a bare phrase, +// so adding a word narrows rather than widens. +func prefixTSQuery(input string) string { + const maxTerms = 8 + + cleaned := strings.Map(func(r rune) rune { + switch { + case unicode.IsLetter(r), unicode.IsDigit(r): + return unicode.ToLower(r) + default: + return ' ' + } + }, input) + + terms := strings.Fields(cleaned) + if len(terms) == 0 { + return "" + } + if len(terms) > maxTerms { + terms = terms[:maxTerms] + } + for i, t := range terms { + terms[i] = t + ":*" + } + return strings.Join(terms, " & ") +} diff --git a/internal/repository/unibox_search_test.go b/internal/repository/unibox_search_test.go new file mode 100644 index 000000000..76453a9bd --- /dev/null +++ b/internal/repository/unibox_search_test.go @@ -0,0 +1,47 @@ +package repository + +import "testing" + +// prefixTSQuery builds a tsquery from whatever lands in a search box, so the +// cases that matter are the hostile ones: to_tsquery is a parser and raises on +// syntax it cannot read, which would turn a stray bracket into a 500. +func TestPrefixTSQuery(t *testing.T) { + cases := []struct { + name string + input string + want string + }{ + {"single word", "dyno", "dyno:*"}, + {"two words are ANDed", "quick collab", "quick:* & collab:*"}, + {"lowercased", "DynoWeb", "dynoweb:*"}, + {"an address splits into its parts", "suman@mail.dynoweb.app", "suman:* & mail:* & dynoweb:* & app:*"}, + + // Every one of these makes to_tsquery raise when passed through raw. + {"operators are stripped", "a & b | c", "a:* & b:* & c:*"}, + {"unbalanced bracket", "re: (urgent", "re:* & urgent:*"}, + {"negation and colons", "!foo:bar", "foo:* & bar:*"}, + {"quotes", `"exact phrase"`, "exact:* & phrase:*"}, + {"a lone operator has nothing to search", "&&&", ""}, + + {"empty", "", ""}, + {"whitespace only", " \t ", ""}, + {"digits are kept", "invoice 2026", "invoice:* & 2026:*"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := prefixTSQuery(tc.input); got != tc.want { + t.Fatalf("prefixTSQuery(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +// A pasted paragraph must not become a 200-term query the planner has to walk. +func TestPrefixTSQueryCapsTerms(t *testing.T) { + got := prefixTSQuery("one two three four five six seven eight nine ten eleven") + want := "one:* & two:* & three:* & four:* & five:* & six:* & seven:* & eight:*" + if got != want { + t.Fatalf("prefixTSQuery capped = %q, want %q", got, want) + } +} diff --git a/web/src/app/app/unibox/page.tsx b/web/src/app/app/unibox/page.tsx index 6bd900c55..97996339a 100644 --- a/web/src/app/app/unibox/page.tsx +++ b/web/src/app/app/unibox/page.tsx @@ -317,6 +317,20 @@ export default function UniboxPage() { setParams((prev) => paramsForScope(prev.sortBy)); } + // ── Search text ──────────────────────────────────────────────── + // Owned here, not in the list, because "Search all mail" changes scope and + // has to keep what was typed. A scope change the reader made themselves + // still clears it: a query typed for one view silently filtering the next is + // what the list used to guard against. + const [search, setSearch] = React.useState(""); + const keepSearch = React.useRef(false); + const [searchScope, setSearchScope] = React.useState(scope); + if (searchScope !== scope) { + setSearchScope(scope); + if (keepSearch.current) keepSearch.current = false; + else setSearch(""); + } + // ── Scope label for header chip ──────────────────────────────── const overviewData = overview.data; const scopeLabel = React.useMemo(() => { @@ -412,6 +426,18 @@ export default function UniboxPage() { params={params} baseParams={baseParams} setParams={setParams} + search={search} + setSearch={setSearch} + onSearchAllMail={ + scope.kind === "all" + ? undefined + : () => { + // Widening keeps the query; the reset below reads + // this flag on the scope change it causes. + keepSearch.current = true; + setScope({ kind: "all" }); + } + } onOpenScopeSheet={() => setScopeSheetOpen(true)} /> diff --git a/web/src/app/app/unibox/uniboxHarness.tsx b/web/src/app/app/unibox/uniboxHarness.tsx index a6d189ffc..cbc771785 100644 --- a/web/src/app/app/unibox/uniboxHarness.tsx +++ b/web/src/app/app/unibox/uniboxHarness.tsx @@ -154,7 +154,18 @@ export function route(url: string): unknown { return { data: [{ ...row, seen: true }], pagination: { has_more: false, next_cursor: null } }; } if (url === "/unibox" || url.startsWith("/unibox?")) { - return { data: ROWS, pagination: { has_more: false, next_cursor: null } }; + // Honour the free-text param so a suite can assert on a search that + // finds nothing. Substring over subject and snippet is enough here; the + // real matcher (people, body, prefixes) is the server's job and is + // covered in Go. + const params = new URL(url, "https://test.local").searchParams; + const subject = params.get("subject"); + const rows = subject + ? ROWS.filter((r) => + `${r.subject} ${r.snippet}`.toLowerCase().includes(subject.toLowerCase()), + ) + : ROWS; + return { data: rows, pagination: { has_more: false, next_cursor: null } }; } if (url.startsWith("/analytics")) return { summary: {}, steps: [], data: [] }; if (url.startsWith("/advisor")) return { findings: [], data: [], total: 0 }; diff --git a/web/src/app/app/unibox/uniboxSearch.test.tsx b/web/src/app/app/unibox/uniboxSearch.test.tsx new file mode 100644 index 000000000..d2fd25411 --- /dev/null +++ b/web/src/app/app/unibox/uniboxSearch.test.tsx @@ -0,0 +1,147 @@ +// The search box used to be scoped state: typing in Inbox and switching view +// cleared it, which is right, but it also meant there was no way to widen a +// search that found nothing without retyping it. The query now lives on the +// page so "Search all mail" can change scope and keep it, and a scope change +// the reader makes themselves still clears it. + +import React from "react"; +import { describe, it, expect, vi, beforeAll, beforeEach } from "vitest"; +import { screen, act, fireEvent, waitFor } from "@testing-library/react"; +import { useAppStore } from "@/stores"; +import { + installLayoutShims, + mount, + resetScrollTops, + setViewportWidth, + settle, + SUITE, +} from "./uniboxHarness"; + +const searchRequests = vi.hoisted((): string[] => []); + +beforeAll(() => { + installLayoutShims(); + setViewportWidth(1512); +}); + +vi.mock("@/lib/api/client/Request", () => ({ + default: async (cfg: { url?: string }) => { + if (cfg.url === "/unibox" || cfg.url?.startsWith("/unibox?")) { + searchRequests.push(cfg.url); + } + const { route } = await import("./uniboxHarness"); + return route(String(cfg?.url ?? "")); + }, +})); +vi.mock("@/lib/helper/getToken", () => ({ + default: () => ({ + access_token: "a", + refresh_token: "r", + access_token_expires_at: new Date(Date.now() + 3600e3).toISOString(), + refresh_token_expires_at: new Date(Date.now() + 3600e3).toISOString(), + }), +})); +vi.mock("@/hooks/SocketProvider", () => ({ + default: ({ children }: { children: React.ReactNode }) => <>{children}, +})); +vi.mock("@/hooks/context/socket", async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + useSocket: () => ({ + isConnected: false, + subscribeToChannel: () => () => {}, + pushToChannel: () => {}, + socket: null, + status: "closed", + }), + useChannel: () => ({ state: "closed", push: () => {}, channel: null }), + useChannelEvent: () => {}, + useChannelSubscription: () => {}, + }; +}); + +function box(): HTMLInputElement { + return screen.getByPlaceholderText(/name, address, or any word/i) as HTMLInputElement; +} + +async function type(value: string) { + await act(async () => { + fireEvent.change(box(), { target: { value } }); + }); + // The box is debounced into the query, so the request needs the timer. + await settle(); + await settle(); +} + +function lastQuery(): string | null { + const url = searchRequests[searchRequests.length - 1]; + return url ? new URL(url, "https://test.local").searchParams.get("subject") : null; +} + +describe("unibox search", SUITE, () => { + beforeEach(() => { + searchRequests.length = 0; + resetScrollTops(); + useAppStore.setState({ selectedThreadId: null, navCollapsed: false }); + }); + + it("sends what was typed as the free-text param", async () => { + await mount("/app/unibox/all"); + await settle(); + + await type("Subject 3"); + await waitFor(() => expect(lastQuery()).toBe("Subject 3")); + }); + + it("offers to widen a search that found nothing in a folder, keeping the query", async () => { + const router = await mount("/app/unibox/inbox"); + await settle(); + + await type("nothing matches this"); + await waitFor(() => expect(screen.getByText("No matches")).toBeTruthy()); + + // The offer only exists because the reader is not already in All mail. + const widen = screen.getByRole("button", { name: /search all mail/i }); + searchRequests.length = 0; + await act(async () => { + fireEvent.click(widen); + }); + await settle(); + + // Scope widened... + expect(router.state.location.pathname).toBe("/app/unibox/all"); + // ...and the query survived it, which is the whole point. + expect(box().value).toBe("nothing matches this"); + await waitFor(() => expect(lastQuery()).toBe("nothing matches this")); + expect( + searchRequests.every( + (u) => !new URL(u, "https://test.local").searchParams.has("folder"), + ), + ).toBe(true); + }); + + it("has nothing to widen to when already searching all mail", async () => { + await mount("/app/unibox/all"); + await settle(); + + await type("nothing matches this"); + await waitFor(() => expect(screen.getByText("No matches")).toBeTruthy()); + expect(screen.queryByRole("button", { name: /search all mail/i })).toBeNull(); + }); + + it("clears the query on a scope change the reader made", async () => { + const router = await mount("/app/unibox/all"); + await settle(); + + await type("Subject 3"); + await waitFor(() => expect(box().value).toBe("Subject 3")); + + await act(async () => { + await router.navigate("/app/unibox/inbox"); + }); + await settle(); + + expect(box().value).toBe(""); + }); +}); diff --git a/web/src/components/app/unibox/ConversationList.tsx b/web/src/components/app/unibox/ConversationList.tsx index ef7f8bd76..b3087532c 100644 --- a/web/src/components/app/unibox/ConversationList.tsx +++ b/web/src/components/app/unibox/ConversationList.tsx @@ -53,13 +53,21 @@ function bucketFor(d: Date): Bucket { } interface ConversationListProps { - /** Identity of the current scope; a change clears the local search. */ + /** Identity of the current scope. */ scopeKey: string; scopeLabel: string; params: UniboxSearchParams; /** What the scope alone queries; anything beyond it is a user filter. */ baseParams: UniboxSearchParams; setParams: React.Dispatch>; + /** + * The search box. Owned by the page rather than here, so widening a search + * to every folder can switch scope without throwing away what was typed. + */ + search: string; + setSearch: (value: string) => void; + /** Widen to every folder, keeping the query. Absent when already there. */ + onSearchAllMail?: () => void; /** Opens the mobile view switcher; the rail is hidden below lg. */ onOpenScopeSheet?: () => void; } @@ -70,22 +78,13 @@ export function ConversationList({ params, baseParams, setParams, + search, + setSearch, + onSearchAllMail, onOpenScopeSheet, }: ConversationListProps) { - const [search, setSearch] = React.useState(""); const [filtersOpen, setFiltersOpen] = React.useState(false); - // The page keeps this component mounted across a scope switch (that is what - // holds the scroll offset when a thread opens), so the search box has to be - // cleared here or a query typed for one scope would silently filter the next. - // Set during render, like the page's own param reset, so the stale query - // never reaches the request. - const [searchScope, setSearchScope] = React.useState(scopeKey); - if (searchScope !== scopeKey) { - setSearchScope(scopeKey); - setSearch(""); - } - const searchRef = React.useRef(null); const listRef = React.useRef(null); // State, not a ref: the rows sit in a keyed fragment that remounts when a @@ -302,7 +301,11 @@ export function ConversationList({ onKeyDown={(e) => { if (e.key === "Escape") e.currentTarget.blur(); }} - placeholder={`Search ${scopeLabel.toLowerCase()}`} + // The box searches people, subject and message body, and naming + // that is the difference between it looking broken and looking + // useful: nobody tries an address in a box labelled "Search inbox". + placeholder={`Search ${scopeLabel.toLowerCase()} — name, address, or any word`} + title={'Searches the sender, recipients, subject and message body. "quoted phrases", OR and -exclude work.'} className="flex-1 min-w-0 h-full bg-transparent text-[12.5px] text-slate-900 placeholder:text-slate-400 outline-none" /> {search ? ( @@ -358,11 +361,26 @@ export function ConversationList({

{filtering ? "No matches" : "Nothing here"}

-

+

{filtering - ? "Try a different search or clear the filters." + ? search.trim() + ? `Nothing in ${scopeLabel.toLowerCase()} matches "${search.trim()}". The search covers names, addresses, subjects and message bodies.` + : "Try a different search or clear the filters." : "New mail shows up here as it arrives."}

+ {/* The commonest reason a search finds nothing is that the thing + is filed somewhere else. Offer the wider search rather than + quietly overriding the scope the reader chose. */} + {filtering && search.trim() && onSearchAllMail && ( + + )} ) : ( diff --git a/web/src/lib/api/client/app/unibox/searchIncoming.ts b/web/src/lib/api/client/app/unibox/searchIncoming.ts index 2ff13c351..d733e8f04 100644 --- a/web/src/lib/api/client/app/unibox/searchIncoming.ts +++ b/web/src/lib/api/client/app/unibox/searchIncoming.ts @@ -43,9 +43,9 @@ export default async function searchIncoming( p: UniboxSearchParams = {}, ): Promise { const usp = new URLSearchParams(); - // The server's free-text matcher is subject. If the frontend wants - // body matching too, that's a server change — surfacing the param - // here in case the user passed something. + // The free-text param is still named `subject` for compatibility, but the + // server matches it against the subject, the preview, the message body and + // the people on the message. if (p.query) usp.set("subject", p.query); if (p.from) usp.set("from", p.from); if (p.address) usp.set("address", p.address); diff --git a/web/src/lib/api/models/app/unibox/UniboxSearch.ts b/web/src/lib/api/models/app/unibox/UniboxSearch.ts index 82cd8c5e5..0caf16d0c 100644 --- a/web/src/lib/api/models/app/unibox/UniboxSearch.ts +++ b/web/src/lib/api/models/app/unibox/UniboxSearch.ts @@ -21,7 +21,12 @@ export const UNIBOX_FOLDERS: UniboxFolder[] = [ ]; export interface UniboxSearchParams { - query?: string; // Free text — currently matched as subject ILIKE + /** + * Free text. Matched against the subject, the preview, the message body, + * and the sender and recipients. Quoted phrases, OR and -exclusion work; + * a half-typed word matches as a prefix. + */ + query?: string; from?: string; // Sender substring /** Exact address match against sender or recipients (compose history). */ address?: string;