feat(unified-mailbox): enable search in the unified views

Enable text AND advanced search in all Unified Mailbox views (the per-role
mailboxes and the folder-selected All mail / Unread / Starred cross views). The
search input was hard-disabled for every unified view; the store fan-out already
supported text search.

- page.tsx: the search text input and the advanced-filter toggle are enabled for
  all unified views (only the scheduled view stays disabled). Clear-search also
  restores a cross view (not just per-role).
- Advanced filters now apply in cross views too: new advancedSearchCrossViewEmails
  ANDs the advanced filter (text + field conditions from buildJMAPFilter, built
  without an inMailbox clause) onto the cross-view membership. Per-role unified
  views keep using advancedSearchUnifiedEmails. Both honor the filter on the first
  page, on load-more, and on the folder-switch re-run. Fixes: an active Starred
  filter not applying after switching into a cross view, and the Unread filter in
  the Unread view returning nothing.
- Search persistence on folder switch: an active search is kept and re-run in the
  target view, preserving advanced filters. handleMailboxSelect picks
  advancedSearch when filters are set (normal, per-role unified, and cross views,
  after setting the unified state), text searchEmails when only a query is set,
  and browses otherwise. The scheduled view is the only view that resets the
  search on enter (unavailable there; setScheduledView clears searchQuery +
  searchFilters).

Account scope is intentionally left unrestricted in search (it already fanned out
across all accounts); the per-view folder selection still applies via
crossIncludedMailboxIds.
This commit is contained in:
Stefan Hildebrandt
2026-07-11 21:14:39 +02:00
parent 7c221c4a4a
commit dc72122ed8
5 changed files with 102 additions and 15 deletions
+17 -5
View File
@@ -7,7 +7,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import type { ExternalSearchResult } from "@/lib/plugin-types";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
@@ -959,9 +959,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const position = emails.length;
const built = await buildUnifiedAccountClients({ includeGroup });
const result = searchQuery
? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, position)
: await fetchCrossViewEmails(built, crossView, emailsPerPage, position);
const hasFilters = !isFilterEmpty(get().searchFilters);
const result = hasFilters
? await advancedSearchCrossViewEmails(built, crossView, buildJMAPFilter(searchQuery, get().searchFilters, undefined), emailsPerPage, position)
: searchQuery
? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, position)
: await fetchCrossViewEmails(built, crossView, emailsPerPage, position);
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
@@ -1796,7 +1799,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (isUnifiedView && crossView) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
const result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0);
// Cross views apply the advanced filter (text + fields) on top of the
// view membership; an empty filter degrades to a plain membership query.
const result = await advancedSearchCrossViewEmails(
built, crossView, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
);
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
@@ -3225,6 +3232,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
selectedEmail: leavingScheduled ? null : state.selectedEmail,
selectedEmailIds: leavingScheduled ? new Set<string>() : state.selectedEmailIds,
// Search is unavailable in the scheduled view (the input is disabled there).
// Reset any active search when entering it so a stale query can't linger or
// re-run when the user leaves again.
searchQuery: isScheduledView ? "" : state.searchQuery,
searchFilters: isScheduledView ? { ...DEFAULT_SEARCH_FILTERS } : state.searchFilters,
};
}),
clearPendingUndoSend: () => set({ pendingUndoSend: null }),