mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
Stalwart 1.0 reads a text filter value that starts and ends with a quote as one exact phrase, and a value ending in `*` as one prefix over the whole string, not split into words and capped at 16 bytes. Every search path sent the typed input as one `text` value, so on 1.0 `"annual report" 2024 "draft"` became a single four-word phrase and `project budg*` a prefix containing a space: both found nothing. `"Q3 planning" budget` lost its phrase on every version. `splitSearchText` (`lib/jmap/search-utils.ts`) now sends each quoted phrase and each `word*` as a condition of its own, ANDed with the rest. The remaining words stay one condition, so a query without quotes or stars goes out as before. Outer single quotes are stripped from that part, since 1.0 reads them as a phrase too. The split applies to: - the search bar, tag views, cross-account and all-folders search; - advanced search, including the from/to/subject/body fields; - contact search; - global search for mail, contacts and calendar. `inMailbox` and the calendar bounds sit next to the text conditions in one AND, with one key per condition: Stalwart rejects an operator that also carries property keys, and 1.0 allows only one AND group when it expands recurrences. SearchSnippet/get switches the whole request to exact matching once a term is quoted, which marks nothing for a phrase ANDed with other words, so the snippet filter unquotes phrases. On 1.0 and on external search backends each phrase is now a real phrase. The 0.16 built-in index reads it as an AND of its words, without stemming. The dev mock and the demo client now evaluate AND filters and quoted phrases, so they keep working with the new shapes.
229 lines
7.1 KiB
TypeScript
229 lines
7.1 KiB
TypeScript
export interface SearchFilters {
|
|
from: string;
|
|
to: string;
|
|
subject: string;
|
|
body: string;
|
|
hasAttachment: boolean | null;
|
|
dateAfter: string;
|
|
dateBefore: string;
|
|
isUnread: boolean | null;
|
|
isStarred: boolean | null;
|
|
/** Message size bounds in KB (RFC 8621 §4.4.1 minSize / maxSize); "" = unset. */
|
|
minSizeKb: string;
|
|
maxSizeKb: string;
|
|
}
|
|
|
|
export const DEFAULT_SEARCH_FILTERS: SearchFilters = {
|
|
from: "",
|
|
to: "",
|
|
subject: "",
|
|
body: "",
|
|
hasAttachment: null,
|
|
dateAfter: "",
|
|
dateBefore: "",
|
|
isUnread: null,
|
|
isStarred: null,
|
|
minSizeKb: "",
|
|
maxSizeKb: "",
|
|
};
|
|
|
|
/** The KB size field as a positive byte count, or null when unset/invalid. */
|
|
export function sizeFilterBytes(value: string | undefined): number | null {
|
|
const kb = Number(value);
|
|
if (!value || !Number.isFinite(kb) || kb <= 0) return null;
|
|
return Math.round(kb * 1024);
|
|
}
|
|
|
|
/** 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 };
|
|
}
|
|
|
|
export function buildJMAPFilter(
|
|
textQuery: string,
|
|
filters: SearchFilters,
|
|
mailboxId?: string
|
|
): Record<string, unknown> {
|
|
const conditions: Record<string, unknown>[] = [];
|
|
|
|
// Quoted phrases and `word*` prefixes become conditions of their own (see
|
|
// splitSearchText); the other words go out as one condition, as typed.
|
|
// The field filters follow the same rules on the server.
|
|
if (textQuery.trim()) {
|
|
conditions.push(...textConditions(textQuery));
|
|
}
|
|
|
|
if (filters.from) {
|
|
conditions.push(...textConditions(filters.from, "from"));
|
|
}
|
|
|
|
if (filters.to) {
|
|
conditions.push(...textConditions(filters.to, "to"));
|
|
}
|
|
|
|
if (filters.subject) {
|
|
conditions.push(...textConditions(filters.subject, "subject"));
|
|
}
|
|
|
|
if (filters.body) {
|
|
conditions.push(...textConditions(filters.body, "body"));
|
|
}
|
|
|
|
if (filters.hasAttachment === true) {
|
|
conditions.push({ hasAttachment: true });
|
|
} else if (filters.hasAttachment === false) {
|
|
conditions.push({ hasAttachment: false });
|
|
}
|
|
|
|
if (filters.dateAfter) {
|
|
const date = new Date(filters.dateAfter);
|
|
if (!isNaN(date.getTime())) {
|
|
conditions.push({ after: date.toISOString() });
|
|
}
|
|
}
|
|
|
|
if (filters.dateBefore) {
|
|
const endOfDay = new Date(filters.dateBefore);
|
|
if (!isNaN(endOfDay.getTime())) {
|
|
endOfDay.setHours(23, 59, 59, 999);
|
|
conditions.push({ before: endOfDay.toISOString() });
|
|
}
|
|
}
|
|
|
|
if (filters.isUnread === true) {
|
|
conditions.push({ notKeyword: "$seen" });
|
|
} else if (filters.isUnread === false) {
|
|
conditions.push({ hasKeyword: "$seen" });
|
|
}
|
|
|
|
if (filters.isStarred === true) {
|
|
conditions.push({ hasKeyword: "$flagged" });
|
|
} else if (filters.isStarred === false) {
|
|
conditions.push({ notKeyword: "$flagged" });
|
|
}
|
|
|
|
// minSize is inclusive, maxSize exclusive (RFC 8621 §4.4.1).
|
|
const minSize = sizeFilterBytes(filters.minSizeKb);
|
|
if (minSize !== null) {
|
|
conditions.push({ minSize });
|
|
}
|
|
const maxSize = sizeFilterBytes(filters.maxSizeKb);
|
|
if (maxSize !== null) {
|
|
conditions.push({ maxSize });
|
|
}
|
|
|
|
if (mailboxId) {
|
|
conditions.push({ inMailbox: mailboxId });
|
|
}
|
|
|
|
if (conditions.length === 0) {
|
|
return mailboxId ? { inMailbox: mailboxId } : {};
|
|
}
|
|
|
|
if (conditions.length === 1) {
|
|
return conditions[0];
|
|
}
|
|
|
|
return {
|
|
operator: "AND",
|
|
conditions,
|
|
};
|
|
}
|
|
|
|
export function isFilterEmpty(filters: SearchFilters): boolean {
|
|
return (
|
|
!filters.from &&
|
|
!filters.to &&
|
|
!filters.subject &&
|
|
!filters.body &&
|
|
filters.hasAttachment === null &&
|
|
!filters.dateAfter &&
|
|
!filters.dateBefore &&
|
|
filters.isUnread === null &&
|
|
filters.isStarred === null &&
|
|
sizeFilterBytes(filters.minSizeKb) === null &&
|
|
sizeFilterBytes(filters.maxSizeKb) === null
|
|
);
|
|
}
|
|
|
|
export function activeFilterCount(filters: SearchFilters): number {
|
|
let count = 0;
|
|
if (filters.from) count++;
|
|
if (filters.to) count++;
|
|
if (filters.subject) count++;
|
|
if (filters.body) count++;
|
|
if (filters.hasAttachment !== null) count++;
|
|
if (filters.dateAfter) count++;
|
|
if (filters.dateBefore) count++;
|
|
if (filters.isUnread !== null) count++;
|
|
if (filters.isStarred !== null) count++;
|
|
if (sizeFilterBytes(filters.minSizeKb) !== null) count++;
|
|
if (sizeFilterBytes(filters.maxSizeKb) !== null) count++;
|
|
return count;
|
|
}
|