diff --git a/lib/__tests__/search-utils.test.ts b/lib/__tests__/search-utils.test.ts new file mode 100644 index 00000000..11a14fe8 --- /dev/null +++ b/lib/__tests__/search-utils.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect } from 'vitest'; +import { + toWildcardQuery, + buildJMAPFilter, + isFilterEmpty, + activeFilterCount, + DEFAULT_SEARCH_FILTERS, +} from '../jmap/search-utils'; +import type { SearchFilters } from '../jmap/search-utils'; + +// --------------------------------------------------------------------------- +// toWildcardQuery +// --------------------------------------------------------------------------- +describe('toWildcardQuery', () => { + it('appends * to a single word', () => { + expect(toWildcardQuery('pri')).toBe('pri*'); + }); + + it('appends * to every word in a multi-word query', () => { + expect(toWildcardQuery('hello world')).toBe('hello* world*'); + }); + + it('handles single-character queries', () => { + expect(toWildcardQuery('a')).toBe('a*'); + }); + + it('handles two-character queries', () => { + expect(toWildcardQuery('pr')).toBe('pr*'); + }); + + it('handles long words', () => { + expect(toWildcardQuery('internationalization')).toBe('internationalization*'); + }); + + it('does not double-append * if already present', () => { + expect(toWildcardQuery('hello*')).toBe('hello*'); + expect(toWildcardQuery('hello* world')).toBe('hello* world*'); + }); + + it('preserves quoted phrases ending with "', () => { + expect(toWildcardQuery('"hello world"')).toBe('"hello* world"'); + }); + + it('trims leading/trailing whitespace', () => { + expect(toWildcardQuery(' hello ')).toBe('hello*'); + }); + + it('collapses multiple spaces between words', () => { + expect(toWildcardQuery('hello world')).toBe('hello* world*'); + }); + + it('returns empty string for empty input', () => { + expect(toWildcardQuery('')).toBe(''); + }); + + it('returns empty string for whitespace-only input', () => { + expect(toWildcardQuery(' ')).toBe(''); + }); + + it('handles mixed words with and without wildcards', () => { + expect(toWildcardQuery('foo* bar baz*')).toBe('foo* bar* baz*'); + }); +}); + +// --------------------------------------------------------------------------- +// buildJMAPFilter +// --------------------------------------------------------------------------- +describe('buildJMAPFilter', () => { + const emptyFilters: SearchFilters = { ...DEFAULT_SEARCH_FILTERS }; + + // -- text query ---------------------------------------------------------- + describe('text query', () => { + it('builds a text filter with wildcard from textQuery alone', () => { + const result = buildJMAPFilter('pri', emptyFilters); + expect(result).toEqual({ text: 'pri*' }); + }); + + it('wildcards each word in multi-word text query', () => { + 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({}); + }); + }); + + // -- 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, + }; + const result = buildJMAPFilter('money', filters, 'mb-1') as { + operator: string; + conditions: Record[]; + }; + 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, + })).toBe(9); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index ecf55261..ecc79d83 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,5 +1,6 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; +import { toWildcardQuery } from "./search-utils"; // JMAP protocol types - these are intentionally flexible due to server variations interface JMAPSession { @@ -871,17 +872,11 @@ export class JMAPClient { try { const targetAccountId = accountId || this.accountId; - // Standard search: OR across all fields (from, to, subject, body) - // so the search bar finds matches in name, email address, subject, and body - const orFilter: Record = { - operator: "OR", - conditions: [ - { from: query }, - { to: query }, - { subject: query }, - { body: query }, - ], - }; + // Use the JMAP "text" filter which searches across from, to, cc, bcc, + // subject, and body. Stalwart's FTS engine supports wildcard prefix + // matching (e.g. "pri*" matches "prime", "primary", "private", etc.) + const wildcardQuery = toWildcardQuery(query); + const textFilter: Record = { text: wildcardQuery }; let filter: Record; if (mailboxId) { @@ -889,11 +884,11 @@ export class JMAPClient { operator: "AND", conditions: [ { inMailbox: mailboxId }, - orFilter, + textFilter, ], }; } else { - filter = orFilter; + filter = textFilter; } const response = await this.request([ diff --git a/lib/jmap/search-utils.ts b/lib/jmap/search-utils.ts index f5af873b..3ed50aef 100644 --- a/lib/jmap/search-utils.ts +++ b/lib/jmap/search-utils.ts @@ -22,6 +22,20 @@ export const DEFAULT_SEARCH_FILTERS: SearchFilters = { isStarred: null, }; +/** + * Appends wildcard `*` to each word in a query to enable prefix matching + * in Stalwart's full-text search engine. For example, "prim" becomes "prim*" + * which matches "prime", "primary", etc. + */ +export function toWildcardQuery(query: string): string { + return query + .trim() + .split(/\s+/) + .filter(Boolean) + .map((word) => (word.endsWith('*') || word.endsWith('"') ? word : word + '*')) + .join(' '); +} + export function buildJMAPFilter( textQuery: string, filters: SearchFilters, @@ -30,7 +44,7 @@ export function buildJMAPFilter( const conditions: Record[] = []; if (textQuery) { - conditions.push({ text: textQuery }); + conditions.push({ text: toWildcardQuery(textQuery) }); } if (filters.from) {