mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-06 00:07:28 +00:00
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.
73 lines
3.1 KiB
TypeScript
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 };
|
|
}
|