mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +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.
139 lines
6.1 KiB
TypeScript
139 lines
6.1 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { splitSearchText } from '@/lib/jmap/search-utils';
|
|
import { accountMatches, matchesTerms, parseSearchQuery, tokenizeQuery, withScopeAndAccount } from '../query-parser';
|
|
|
|
describe('parseSearchQuery', () => {
|
|
it('treats plain words as free text terms', () => {
|
|
const parsed = parseSearchQuery(' quarterly Report ');
|
|
expect(parsed.text).toBe('quarterly Report');
|
|
expect(parsed.terms).toEqual(['quarterly', 'report']);
|
|
expect(parsed.scope).toBe('all');
|
|
expect(parsed.hasOperators).toBe(false);
|
|
});
|
|
|
|
it('keeps quoted phrases intact for the server and lower-cases them for local matching', () => {
|
|
const parsed = parseSearchQuery('"Q3 planning" budget');
|
|
expect(parsed.text).toBe('"Q3 planning" budget');
|
|
expect(parsed.terms).toEqual(['q3 planning', 'budget']);
|
|
// The search paths split the text again, one condition per phrase.
|
|
expect(splitSearchText(parsed.text)).toEqual(['"Q3 planning"', 'budget']);
|
|
});
|
|
|
|
it('produces text that splits into the same parts as the typed query', () => {
|
|
const typed = [
|
|
'"annual report" 2024 "draft"',
|
|
'project budg*',
|
|
'"half typed',
|
|
'"a b" c d*',
|
|
"'annual' 2024 'draft'",
|
|
'"" * budget',
|
|
're: meeting 10:30',
|
|
];
|
|
for (const raw of typed) {
|
|
expect(splitSearchText(parseSearchQuery(raw).text)).toEqual(splitSearchText(raw));
|
|
}
|
|
const withOperators = parseSearchQuery('from:anna "annual report" in:mail 2024 after:2026-01-01 "draft"');
|
|
expect(splitSearchText(withOperators.text)).toEqual(['"annual report"', '2024', '"draft"']);
|
|
});
|
|
|
|
it('tolerates an unterminated quote while the user is still typing', () => {
|
|
expect(parseSearchQuery('"half typed').terms).toEqual(['half typed']);
|
|
});
|
|
|
|
it('parses in: with aliases, including trash/junk as mail-only roles', () => {
|
|
expect(parseSearchQuery('in:contacts bob').scope).toBe('contacts');
|
|
expect(parseSearchQuery('in:events bob').scope).toBe('calendar');
|
|
expect(parseSearchQuery('in:drive bob').scope).toBe('files');
|
|
const trash = parseSearchQuery('in:trash invoice');
|
|
expect(trash.scope).toBe('mail');
|
|
expect(trash.mailboxRole).toBe('trash');
|
|
expect(parseSearchQuery('in:spam x').mailboxRole).toBe('junk');
|
|
});
|
|
|
|
it('maps mail operators onto SearchFilters', () => {
|
|
const parsed = parseSearchQuery('from:alice to:bob subject:"Q3 report" has:attachment is:unread is:starred invoice');
|
|
expect(parsed.mail.from).toBe('alice');
|
|
expect(parsed.mail.to).toBe('bob');
|
|
expect(parsed.mail.subject).toBe('Q3 report');
|
|
expect(parsed.mail.hasAttachment).toBe(true);
|
|
expect(parsed.mail.isUnread).toBe(true);
|
|
expect(parsed.mail.isStarred).toBe(true);
|
|
expect(parsed.text).toBe('invoice');
|
|
expect(parsed.hasOperators).toBe(true);
|
|
});
|
|
|
|
it('accepts valid dates and feeds them to both mail and calendar', () => {
|
|
const parsed = parseSearchQuery('after:2026-01-01 before:2026-02-28 sync');
|
|
expect(parsed.after).toBe('2026-01-01');
|
|
expect(parsed.before).toBe('2026-02-28');
|
|
expect(parsed.mail.dateAfter).toBe('2026-01-01');
|
|
expect(parsed.mail.dateBefore).toBe('2026-02-28');
|
|
});
|
|
|
|
it('keeps malformed dates and unknown operators as free text', () => {
|
|
const parsed = parseSearchQuery('after:2026-02-31 size:>5MB before:yesterday tag:x hello');
|
|
expect(parsed.after).toBeNull();
|
|
expect(parsed.before).toBeNull();
|
|
expect(parsed.terms).toEqual(['after:2026-02-31', 'size:>5mb', 'before:yesterday', 'tag:x', 'hello']);
|
|
expect(parsed.hasOperators).toBe(false);
|
|
});
|
|
|
|
it('is:anything lifts the trash/junk exclusion', () => {
|
|
expect(parseSearchQuery('is:anything x').includeTrashAndJunk).toBe(true);
|
|
expect(parseSearchQuery('x').includeTrashAndJunk).toBe(false);
|
|
});
|
|
|
|
it('lower-cases the account operator and supports quoted labels', () => {
|
|
expect(parseSearchQuery('account:Work x').account).toBe('work');
|
|
expect(parseSearchQuery('account:"Linus Rath" x').account).toBe('linus rath');
|
|
});
|
|
|
|
it('handles a colon inside an ordinary word', () => {
|
|
const parsed = parseSearchQuery('re: meeting 10:30');
|
|
expect(parsed.terms).toEqual(['re:', 'meeting', '10:30']);
|
|
});
|
|
});
|
|
|
|
describe('tokenizeQuery', () => {
|
|
it('splits operators, quoted values and words', () => {
|
|
expect(tokenizeQuery('a from:"x y" "p q" k:v')).toEqual([
|
|
{ key: null, value: 'a', quoted: false, original: 'a' },
|
|
{ key: 'from', value: 'x y', quoted: true, original: 'from:"x y"' },
|
|
{ key: null, value: 'p q', quoted: true, original: '"p q"' },
|
|
{ key: 'k', value: 'v', quoted: false, original: 'k:v' },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('withScopeAndAccount', () => {
|
|
it('rewrites in: and account: without touching the rest', () => {
|
|
expect(withScopeAndAccount('in:mail from:bob hello account:old', 'contacts', 'Work')).toBe('from:bob hello in:contacts account:Work');
|
|
expect(withScopeAndAccount('in:contacts hello', 'all', null)).toBe('hello');
|
|
expect(withScopeAndAccount('hello', 'files', 'Linus Rath')).toBe('hello in:files account:"Linus Rath"');
|
|
});
|
|
|
|
it('keeps in:trash because it is a folder, not a scope', () => {
|
|
expect(withScopeAndAccount('in:trash x', 'all', null)).toBe('in:trash x');
|
|
});
|
|
});
|
|
|
|
describe('accountMatches', () => {
|
|
const account = { label: 'Work mail', email: 'linus@example.org', localAccountId: 'linus@example.org@mail.example.org' };
|
|
it('matches label substrings, email prefixes and the login id', () => {
|
|
expect(accountMatches(null, account)).toBe(true);
|
|
expect(accountMatches('work', account)).toBe(true);
|
|
expect(accountMatches('linus@', account)).toBe(true);
|
|
expect(accountMatches('linus@example.org', account)).toBe(true);
|
|
expect(accountMatches('other', account)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('matchesTerms', () => {
|
|
it('requires every term as a substring of some field', () => {
|
|
expect(matchesTerms(['bob', 'partner'], ['Bob Miller', 'bob@partner.example'])).toBe(true);
|
|
expect(matchesTerms(['bob', 'zzz'], ['Bob Miller'])).toBe(false);
|
|
expect(matchesTerms([], ['x'])).toBe(false);
|
|
expect(matchesTerms(['x'], [null, undefined, ''])).toBe(false);
|
|
});
|
|
});
|