mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-05 08:02:14 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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, " & ")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user