Files
webmail/lib/__tests__/search-utils.test.ts
T
Linus Rath 5967a1c489 fix: find results when a search mixes quoted phrases with other words
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.
2026-09-25 18:06:49 +02:00

556 lines
22 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
buildJMAPFilter,
isFilterEmpty,
activeFilterCount,
splitSearchText,
textConditions,
textFilter,
DEFAULT_SEARCH_FILTERS,
} from '../jmap/search-utils';
import type { SearchFilters } from '../jmap/search-utils';
type Filter = Record<string, unknown>;
/** Stalwart rejects a filter object with both `operator` and property keys. */
function expectNoMixedKeys(filter: Filter): void {
if ('operator' in filter) {
expect(Object.keys(filter).sort()).toEqual(['conditions', 'operator']);
for (const c of filter.conditions as Filter[]) expectNoMixedKeys(c);
} else {
expect(filter).not.toHaveProperty('conditions');
}
}
// ---------------------------------------------------------------------------
// A small model of Email/query text conditions on Stalwart's built-in index,
// so the tests can check what a filter finds on each version, not only its
// shape. It leaves out stemming and language detection.
// - 1.0 (store/src/search/document.rs `has_text`): a value wrapped in `"` or
// `'` is an exact phrase (word positions exist for subject and body only;
// address fields fall back to all words); a value ending in `*` is one
// prefix over the rest, lower-cased and not split into words, at most 16
// bytes, matching words of at most 16 bytes; anything else needs all its
// words in the same field.
// - 0.16: no phrases and no prefixes; every value needs all its words in the
// same field (a quoted one without stemming, which the model leaves out),
// and `*` only separates words.
// - `text` is an OR over from, to, subject and body.
// ---------------------------------------------------------------------------
type Version = '0.16' | '1.0';
interface Message { id: string; from: string; to: string; subject: string; body: string }
const MESSAGES: Message[] = [
{ id: 'report', from: 'Anna Muster <anna@firma.de>', to: 'team@firma.de', subject: 'Draft: annual report 2024', body: 'The numbers are final.' },
{ id: 'shuffled', from: 'Ben <ben@firma.de>', to: 'team@firma.de', subject: 'Report on annual goals, 2024 draft', body: '' },
{ id: 'budget', from: 'Carla <carla@firma.de>', to: 'team@firma.de', subject: 'Project budget', body: 'Numbers for Q3.' },
{ id: 'board', from: 'Dieter <dieter@firma.de>', to: 'team@firma.de', subject: 'Protokoll', body: 'Die Geschäftsführung hat entschieden.' },
];
const wordsOf = (value: string): string[] => value.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? [];
const byteLength = (value: string) => new TextEncoder().encode(value).length;
function fieldMatches(version: Version, value: string, field: string, hasPositions: boolean): boolean {
const tokens = wordsOf(field);
if (version === '1.0') {
const exact = /^"([\s\S]*)"$/.exec(value) ?? /^'([\s\S]*)'$/.exec(value);
if (exact) {
const phrase = wordsOf(exact[1]);
if (phrase.length === 0) return false;
if (!hasPositions) return phrase.every((w) => tokens.includes(w));
return tokens.some((_, i) => phrase.every((w, j) => tokens[i + j] === w));
}
if (value.endsWith('*')) {
const prefix = value.slice(0, -1).trim().toLowerCase();
if (!prefix || byteLength(prefix) > 16) return false;
return tokens.some((t) => byteLength(t) <= 16 && t.startsWith(prefix));
}
}
const words = wordsOf(value);
return words.length > 0 && words.every((w) => tokens.includes(w));
}
function messageMatches(version: Version, filter: Filter, m: Message): boolean {
if (filter.operator === 'AND') return (filter.conditions as Filter[]).every((c) => messageMatches(version, c, m));
const entries = Object.entries(filter) as [string, string][];
return entries.every(([key, value]) => {
switch (key) {
case 'text':
return fieldMatches(version, value, m.from, false) || fieldMatches(version, value, m.to, false)
|| fieldMatches(version, value, m.subject, true) || fieldMatches(version, value, m.body, true);
case 'from':
case 'to':
return fieldMatches(version, value, m[key], false);
case 'subject':
case 'body':
return fieldMatches(version, value, m[key], true);
default:
throw new Error(`the model has no ${key} condition`);
}
});
}
const hits = (version: Version, filter: Filter) =>
MESSAGES.filter((m) => messageMatches(version, filter, m)).map((m) => m.id);
// ---------------------------------------------------------------------------
// splitSearchText / textConditions / textFilter
// ---------------------------------------------------------------------------
describe('splitSearchText', () => {
it('gives every quoted phrase a part of its own', () => {
expect(splitSearchText('"annual report" 2024 "draft"')).toEqual(['"annual report"', '2024', '"draft"']);
});
it('keeps plain queries as one part, as typed', () => {
expect(splitSearchText('project budget')).toEqual(['project budget']);
expect(splitSearchText('anna@firma.de')).toEqual(['anna@firma.de']);
expect(splitSearchText('Geschäftsführung')).toEqual(['Geschäftsführung']);
expect(splitSearchText(' project budget ')).toEqual(['project budget']);
});
it('gives every word ending in * a part of its own', () => {
expect(splitSearchText('project budg*')).toEqual(['project', 'budg*']);
expect(splitSearchText('"a b" c d*')).toEqual(['"a b"', 'c', 'd*']);
});
it('keeps the other words together in one part where the first of them was typed', () => {
expect(splitSearchText('q3 "annual report" budget')).toEqual(['q3 budget', '"annual report"']);
expect(splitSearchText('budg* "annual report" project')).toEqual(['budg*', '"annual report"', 'project']);
});
it('closes an unterminated quote at the end of the input', () => {
expect(splitSearchText('"half typed')).toEqual(['"half typed"']);
expect(splitSearchText('invoice "half typed')).toEqual(['invoice', '"half typed"']);
});
it('drops empty quotes and lone stars', () => {
expect(splitSearchText('""')).toEqual([]);
expect(splitSearchText('" " budget')).toEqual(['budget']);
expect(splitSearchText('*')).toEqual([]);
expect(splitSearchText('budget * **')).toEqual(['budget']);
});
it('strips outer single quotes, which would make the words one phrase', () => {
expect(splitSearchText("'annual' 2024 'draft'")).toEqual(["annual' 2024 'draft"]);
expect(splitSearchText("'annual report'")).toEqual(['annual report']);
expect(splitSearchText("''")).toEqual([]);
});
});
describe('textConditions / textFilter', () => {
it('makes one condition per part on the given property', () => {
expect(textConditions('"annual report" 2024 "draft"')).toEqual([
{ text: '"annual report"' }, { text: '2024' }, { text: '"draft"' },
]);
expect(textConditions('anna mül*', 'from')).toEqual([{ from: 'anna' }, { from: 'mül*' }]);
});
it('sends the input as before when no part is left', () => {
expect(textConditions('""')).toEqual([{ text: '""' }]);
expect(textConditions(' * ')).toEqual([{ text: '*' }]);
});
it('returns a single condition as is and ANDs several', () => {
expect(textFilter('project budget')).toEqual({ text: 'project budget' });
expect(textFilter('"annual report" 2024')).toEqual({
operator: 'AND',
conditions: [{ text: '"annual report"' }, { text: '2024' }],
});
});
});
describe('text search results', () => {
it('finds "annual report" 2024 "draft" on Stalwart 1.0, where the joined value was one phrase', () => {
expect(hits('1.0', { text: '"annual report" 2024 "draft"' })).toEqual([]);
expect(hits('1.0', textFilter('"annual report" 2024 "draft"'))).toEqual(['report']);
});
it('keeps the 0.16 hits for "annual report" 2024 "draft": an AND of the words', () => {
expect(hits('0.16', { text: '"annual report" 2024 "draft"' })).toEqual(['report', 'shuffled']);
expect(hits('0.16', textFilter('"annual report" 2024 "draft"'))).toEqual(['report', 'shuffled']);
});
it('finds project budg* on Stalwart 1.0 and changes nothing on 0.16', () => {
expect(hits('1.0', { text: 'project budg*' })).toEqual([]);
expect(hits('1.0', textFilter('project budg*'))).toEqual(['budget']);
expect(hits('0.16', { text: 'project budg*' })).toEqual([]);
expect(hits('0.16', textFilter('project budg*'))).toEqual([]);
});
it('sends plain queries unchanged, with the same hits on 0.16 and 1.0', () => {
const plain: Array<[string, string[]]> = [
['project budget', ['budget']],
['anna@firma.de', ['report']],
['Geschäftsführung', ['board']],
];
for (const [query, expected] of plain) {
expect(textFilter(query)).toEqual({ text: query });
expect(hits('0.16', textFilter(query))).toEqual(expected);
expect(hits('1.0', textFilter(query))).toEqual(expected);
}
});
});
// ---------------------------------------------------------------------------
// buildJMAPFilter
// ---------------------------------------------------------------------------
describe('buildJMAPFilter', () => {
const emptyFilters: SearchFilters = { ...DEFAULT_SEARCH_FILTERS };
// -- text query ----------------------------------------------------------
describe('text query', () => {
it('builds a text filter from textQuery alone, as typed', () => {
const result = buildJMAPFilter('pri', emptyFilters);
expect(result).toEqual({ text: 'pri' });
});
it('keeps a multi-word query as one text condition', () => {
const result = buildJMAPFilter('hello world', emptyFilters);
expect(result).toEqual({ text: 'hello world' });
});
it('returns empty object when no query and no filters', () => {
const result = buildJMAPFilter('', emptyFilters);
expect(result).toEqual({});
});
it('keeps a lone phrase as one condition', () => {
expect(buildJMAPFilter('"annual report"', emptyFilters)).toEqual({ text: '"annual report"' });
});
it('ANDs quoted phrases and prefixes as separate conditions, next to inMailbox', () => {
const result = buildJMAPFilter('"annual report" 2024 budg*', emptyFilters, 'inbox-1');
expect(result).toEqual({
operator: 'AND',
conditions: [
{ text: '"annual report"' },
{ text: '2024' },
{ text: 'budg*' },
{ inMailbox: 'inbox-1' },
],
});
expectNoMixedKeys(result);
});
it('splits the from, to, subject and body fields the same way', () => {
const filters: SearchFilters = {
...emptyFilters,
from: 'anna mül*',
to: 'team@firma.de',
subject: '"Q3 report" draft',
body: "'annual report'",
};
const result = buildJMAPFilter('', filters);
expect(result).toEqual({
operator: 'AND',
conditions: [
{ from: 'anna' },
{ from: 'mül*' },
{ to: 'team@firma.de' },
{ subject: '"Q3 report"' },
{ subject: 'draft' },
{ body: 'annual report' },
],
});
expectNoMixedKeys(result);
});
});
// -- individual field filters -------------------------------------------
describe('individual field filters', () => {
it('builds a from filter (no wildcard)', () => {
const filters: SearchFilters = { ...emptyFilters, from: 'alice' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ from: 'alice' });
});
it('builds a to filter (no wildcard)', () => {
const filters: SearchFilters = { ...emptyFilters, to: 'bob@example.com' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ to: 'bob@example.com' });
});
it('builds a subject filter (no wildcard)', () => {
const filters: SearchFilters = { ...emptyFilters, subject: 'meeting' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ subject: 'meeting' });
});
it('builds a body filter (no wildcard)', () => {
const filters: SearchFilters = { ...emptyFilters, body: 'payment' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ body: 'payment' });
});
});
// -- boolean / keyword filters ------------------------------------------
describe('boolean and keyword filters', () => {
it('adds hasAttachment: true', () => {
const filters: SearchFilters = { ...emptyFilters, hasAttachment: true };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ hasAttachment: true });
});
it('adds hasAttachment: false', () => {
const filters: SearchFilters = { ...emptyFilters, hasAttachment: false };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ hasAttachment: false });
});
it('adds notKeyword $seen for isUnread=true', () => {
const filters: SearchFilters = { ...emptyFilters, isUnread: true };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ notKeyword: '$seen' });
});
it('adds hasKeyword $seen for isUnread=false', () => {
const filters: SearchFilters = { ...emptyFilters, isUnread: false };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ hasKeyword: '$seen' });
});
it('adds hasKeyword $flagged for isStarred=true', () => {
const filters: SearchFilters = { ...emptyFilters, isStarred: true };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ hasKeyword: '$flagged' });
});
it('adds notKeyword $flagged for isStarred=false', () => {
const filters: SearchFilters = { ...emptyFilters, isStarred: false };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({ notKeyword: '$flagged' });
});
});
// -- date filters -------------------------------------------------------
describe('date filters', () => {
it('adds after condition for dateAfter', () => {
const filters: SearchFilters = { ...emptyFilters, dateAfter: '2024-06-01' };
const result = buildJMAPFilter('', filters);
expect(result).toHaveProperty('after');
expect(new Date(result.after as string).toISOString()).toContain('2024-06-01');
});
it('adds before condition for dateBefore (end of day)', () => {
const filters: SearchFilters = { ...emptyFilters, dateBefore: '2024-12-31' };
const result = buildJMAPFilter('', filters);
expect(result).toHaveProperty('before');
const d = new Date(result.before as string);
expect(d.getHours()).toBe(23);
expect(d.getMinutes()).toBe(59);
});
it('ignores invalid dateAfter', () => {
const filters: SearchFilters = { ...emptyFilters, dateAfter: 'not-a-date' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({});
});
it('ignores invalid dateBefore', () => {
const filters: SearchFilters = { ...emptyFilters, dateBefore: 'nope' };
const result = buildJMAPFilter('', filters);
expect(result).toEqual({});
});
});
// -- mailbox filter ------------------------------------------------------
describe('mailbox filter', () => {
it('returns inMailbox filter when only mailboxId provided', () => {
const result = buildJMAPFilter('', emptyFilters, 'inbox-1');
expect(result).toEqual({ inMailbox: 'inbox-1' });
});
it('wraps in AND when mailboxId combined with text query', () => {
const result = buildJMAPFilter('pri', emptyFilters, 'inbox-1');
expect(result).toEqual({
operator: 'AND',
conditions: [
{ text: 'pri' },
{ inMailbox: 'inbox-1' },
],
});
});
});
// -- combinations --------------------------------------------------------
describe('combined filters', () => {
it('ANDs text query with from filter', () => {
const filters: SearchFilters = { ...emptyFilters, from: 'alice' };
const result = buildJMAPFilter('urgent', filters);
expect(result).toEqual({
operator: 'AND',
conditions: [
{ text: 'urgent' },
{ from: 'alice' },
],
});
});
it('ANDs text query, from, subject, hasAttachment, and mailbox', () => {
const filters: SearchFilters = {
...emptyFilters,
from: 'ceo@corp.com',
subject: 'quarterly',
hasAttachment: true,
};
const result = buildJMAPFilter('report', filters, 'mb-42');
expect(result).toEqual({
operator: 'AND',
conditions: [
{ text: 'report' },
{ from: 'ceo@corp.com' },
{ subject: 'quarterly' },
{ hasAttachment: true },
{ inMailbox: 'mb-42' },
],
});
});
it('ANDs all possible filters at once', () => {
const filters: SearchFilters = {
from: 'alice',
to: 'bob',
subject: 'invoice',
body: 'payment',
hasAttachment: true,
dateAfter: '2024-01-01',
dateBefore: '2024-12-31',
isUnread: true,
isStarred: true,
minSizeKb: '',
maxSizeKb: '',
};
const result = buildJMAPFilter('money', filters, 'mb-1') as {
operator: string;
conditions: Record<string, unknown>[];
};
expect(result.operator).toBe('AND');
expect(result.conditions).toContainEqual({ text: 'money' });
expect(result.conditions).toContainEqual({ from: 'alice' });
expect(result.conditions).toContainEqual({ to: 'bob' });
expect(result.conditions).toContainEqual({ subject: 'invoice' });
expect(result.conditions).toContainEqual({ body: 'payment' });
expect(result.conditions).toContainEqual({ hasAttachment: true });
expect(result.conditions).toContainEqual({ notKeyword: '$seen' });
expect(result.conditions).toContainEqual({ hasKeyword: '$flagged' });
expect(result.conditions).toContainEqual({ inMailbox: 'mb-1' });
// dateAfter and dateBefore produce after/before ISO strings
expect(result.conditions.some(c => 'after' in c)).toBe(true);
expect(result.conditions.some(c => 'before' in c)).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// isFilterEmpty
// ---------------------------------------------------------------------------
describe('isFilterEmpty', () => {
it('returns true for default filters', () => {
expect(isFilterEmpty(DEFAULT_SEARCH_FILTERS)).toBe(true);
});
it('returns false when from is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, from: 'alice' })).toBe(false);
});
it('returns false when to is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, to: 'bob' })).toBe(false);
});
it('returns false when subject is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, subject: 'hello' })).toBe(false);
});
it('returns false when body is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, body: 'world' })).toBe(false);
});
it('returns false when hasAttachment is true', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, hasAttachment: true })).toBe(false);
});
it('returns false when hasAttachment is false', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, hasAttachment: false })).toBe(false);
});
it('returns false when dateAfter is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, dateAfter: '2024-01-01' })).toBe(false);
});
it('returns false when dateBefore is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, dateBefore: '2024-12-31' })).toBe(false);
});
it('returns false when isUnread is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, isUnread: true })).toBe(false);
});
it('returns false when isStarred is set', () => {
expect(isFilterEmpty({ ...DEFAULT_SEARCH_FILTERS, isStarred: false })).toBe(false);
});
});
// ---------------------------------------------------------------------------
// activeFilterCount
// ---------------------------------------------------------------------------
describe('activeFilterCount', () => {
it('returns 0 for default filters', () => {
expect(activeFilterCount(DEFAULT_SEARCH_FILTERS)).toBe(0);
});
it('counts each active text field', () => {
expect(activeFilterCount({ ...DEFAULT_SEARCH_FILTERS, from: 'a', to: 'b' })).toBe(2);
});
it('counts boolean filters', () => {
expect(activeFilterCount({
...DEFAULT_SEARCH_FILTERS,
hasAttachment: true,
isUnread: false,
isStarred: true,
})).toBe(3);
});
it('counts date filters', () => {
expect(activeFilterCount({
...DEFAULT_SEARCH_FILTERS,
dateAfter: '2024-01-01',
dateBefore: '2024-12-31',
})).toBe(2);
});
it('counts all 9 filters when all active', () => {
expect(activeFilterCount({
from: 'a',
to: 'b',
subject: 'c',
body: 'd',
hasAttachment: true,
dateAfter: '2024-01-01',
dateBefore: '2024-12-31',
isUnread: true,
isStarred: true,
minSizeKb: '',
maxSizeKb: '',
})).toBe(9);
});
});
describe('size filters', () => {
const base = { ...DEFAULT_SEARCH_FILTERS };
it('converts KB bounds to minSize / maxSize byte conditions', () => {
expect(buildJMAPFilter('', { ...base, minSizeKb: '100', maxSizeKb: '2048' })).toEqual({
operator: 'AND',
conditions: [{ minSize: 102400 }, { maxSize: 2097152 }],
});
});
it('ignores empty, zero and non-numeric sizes', () => {
expect(buildJMAPFilter('', { ...base, minSizeKb: '0', maxSizeKb: 'abc' })).toEqual({});
expect(isFilterEmpty({ ...base, minSizeKb: '0', maxSizeKb: '' })).toBe(true);
expect(isFilterEmpty({ ...base, minSizeKb: '5' })).toBe(false);
expect(activeFilterCount({ ...base, minSizeKb: '5', maxSizeKb: '9' })).toBe(2);
});
});