feat: implement wildcard query functionality for enhanced search capabilities

This commit is contained in:
Linus Rath
2026-03-14 16:50:43 +01:00
parent c024d89477
commit f1f31df0af
3 changed files with 386 additions and 14 deletions
+8 -13
View File
@@ -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([
+15 -1
View File
@@ -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) {