Files
native/src/lib/search-utils.ts
T
Linus Rath 6be51a2bab fix: find multi-word, address and long-word searches in the app on Stalwart 1.0
Stalwart 1.0 reads a text filter value that ends in `*` as one prefix
over the whole string, not split into words and capped at 16 bytes. The
app added `*` to every word it searched for, so on 1.0 `project budget`
became the prefix "project* budget", an address was no single word and
`Geschäftsführung` (18 bytes) was over the cap: all three found
nothing. Quoted phrases broke too, since `"annual report" 2024` went out
as one value ending in `*`.

The search text now goes out as typed, split the way the webmail does
it (5967a1c4): each quoted phrase and each `word*` becomes a condition
of its own, ANDed with one condition that holds the other words, so a
query without quotes or stars is sent unchanged. The folder, all-folders
and tag-view searches, the unified inbox and the from, to, subject and
body filters all use it, and `searchEmails` puts `inMailbox` next to
the text conditions in one AND instead of on an operator.

0.16's built-in index dropped the `*` anyway, so its results stay the
same; there a quoted phrase is an AND of its words. On 1.0 a typed
`word*` is now a real prefix.

The fake server of the store tests now matches text the way Stalwart's
built-in index does, which the old `*` after every word fails.
2026-09-25 20:41:11 +02:00

73 lines
3.1 KiB
TypeScript

// Free-text search conditions for Email/query, ported from the webmail's
// lib/jmap/search-utils.ts (5967a1c4) so both clients send the same filters.
/** Filter properties whose value the server reads as free text. */
export type SearchTextProperty = 'text' | 'from' | 'to' | 'subject' | 'body';
// A "quoted phrase" (the closing quote may still be missing while the user
// types) or a run of non-space characters.
const SEARCH_PART_RE = /"([^"]*)"?|(\S+)/g;
/**
* Splits typed search text into the values to send as separate text
* conditions, ANDed together.
*
* Stalwart 1.0 reads a whole filter value that starts and ends with `"` (or
* `'`) as one exact phrase, and a value ending in `*` as one prefix over
* everything before it (not split into words, at most 16 bytes). Sent as one
* value, `"annual report" 2024 "draft"` is a single phrase and
* `project budg*` a prefix containing a space: both find nothing. So:
* - every `"phrase"` is a part of its own, kept quoted; empty quotes are
* dropped;
* - every word ending in `*` is a part of its own; a lone `*` is dropped;
* - all other words stay together in one part, joined by single spaces, so
* they still have to match within one field and the server detects the
* language on the whole string. Without quotes or stars, that part is the
* query as typed.
* Outer `'` quotes are stripped from that last part, since they would make
* it a phrase too. The built-in index of 0.16 has neither phrases nor
* prefixes: a quoted part is an AND of its words, matched without stemming,
* and `*` only separates words.
*/
export function splitSearchText(raw: string): string[] {
const parts: string[] = [];
const words: string[] = [];
let wordsAt = -1;
for (const [, phrase, word] of raw.matchAll(SEARCH_PART_RE)) {
if (phrase !== undefined) {
const trimmed = phrase.trim();
if (trimmed) parts.push(`"${trimmed}"`);
} else if (word.endsWith('*')) {
if (!/^\*+$/.test(word)) parts.push(word);
} else {
if (wordsAt < 0) wordsAt = parts.length;
words.push(word);
}
}
let bare = words.join(' ');
while (bare.length > 1 && bare.startsWith("'") && bare.endsWith("'")) {
bare = bare.slice(1, -1).trim();
}
// Keep the parts in the order they were typed.
if (bare) parts.splice(wordsAt, 0, bare);
return parts;
}
/**
* One `{ [property]: part }` condition per part of `raw` (see
* splitSearchText). When no part is left (`""`, a lone `*`), the input is
* sent as one condition as before: dropping it would widen the search to
* every message.
*/
export function textConditions(raw: string, property: SearchTextProperty = 'text'): Record<string, string>[] {
const parts = splitSearchText(raw);
if (parts.length === 0) return [{ [property]: raw.trim() }];
return parts.map((part) => ({ [property]: part }));
}
/** `textConditions` as one filter: the single condition, or an AND of them. */
export function textFilter(raw: string, property: SearchTextProperty = 'text'): Record<string, unknown> {
const conditions = textConditions(raw, property);
return conditions.length === 1 ? conditions[0] : { operator: 'AND', conditions };
}