feat(unibox): search people and partial words, and offer to widen a search that found nothing in the current scope

This commit is contained in:
SUMAN JANA
2026-09-17 10:20:17 +00:00
parent 2104770185
commit ebbe432d2c
9 changed files with 363 additions and 29 deletions
+5 -1
View File
@@ -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
+83 -7
View File
@@ -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, " & ")
}
+47
View File
@@ -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)
}
}
+26
View File
@@ -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)}
/>
</div>
+12 -1
View File
@@ -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 };
@@ -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<string, unknown>;
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("");
});
});
@@ -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<React.SetStateAction<UniboxSearchParams>>;
/**
* 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<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(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({
<p className="text-[12.5px] text-slate-700 font-medium mb-1">
{filtering ? "No matches" : "Nothing here"}
</p>
<p className="text-[11.5px] text-slate-400 max-w-[28ch] mx-auto leading-relaxed">
<p className="text-[11.5px] text-slate-400 max-w-[32ch] mx-auto leading-relaxed">
{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."}
</p>
{/* 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 && (
<button
type="button"
onClick={onSearchAllMail}
className="mt-3 h-7 px-2.5 rounded-md bg-slate-900 hover:bg-slate-800 text-white text-[11.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
>
<SearchIcon className="w-3 h-3" />
Search all mail
</button>
)}
</div>
) : (
<React.Fragment key={shownKey.current}>
@@ -43,9 +43,9 @@ export default async function searchIncoming(
p: UniboxSearchParams = {},
): Promise<UniboxListResponse> {
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);
@@ -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;