feat: implement wildcard query functionality for enhanced search capabilities
This commit is contained in:
@@ -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<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,
|
||||
})).toBe(9);
|
||||
});
|
||||
});
|
||||
+8
-13
@@ -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<string, unknown> = {
|
||||
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<string, unknown> = { text: wildcardQuery };
|
||||
|
||||
let filter: Record<string, unknown>;
|
||||
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([
|
||||
|
||||
@@ -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<string, unknown>[] = [];
|
||||
|
||||
if (textQuery) {
|
||||
conditions.push({ text: textQuery });
|
||||
conditions.push({ text: toWildcardQuery(textQuery) });
|
||||
}
|
||||
|
||||
if (filters.from) {
|
||||
|
||||
Reference in New Issue
Block a user