feat: enable search in unified mailbox in pro mode

This commit is contained in:
Linus Rath
2026-05-21 17:12:06 +02:00
parent c9435f7580
commit 1756f5ac1c
3 changed files with 195 additions and 31 deletions
+16 -10
View File
@@ -1796,7 +1796,6 @@ export default function Home() {
const handleSearch = async (query: string) => { const handleSearch = async (query: string) => {
if (!client) return; if (!client) return;
if (isUnifiedView) return;
setSearchQuery(query); setSearchQuery(query);
if (!isFilterEmpty(searchFilters)) { if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client); await advancedSearch(client);
@@ -1808,14 +1807,25 @@ export default function Home() {
const handleClearSearch = async () => { const handleClearSearch = async () => {
setSearchQuery(""); setSearchQuery("");
clearSearchFilters(); clearSearchFilters();
if (client && selectedMailbox) { if (!client) return;
// In unified view the active "mailbox" is a virtual role, so refresh via
// the unified fan-out instead of fetchEmails.
if (isUnifiedView) {
const role = useEmailStore.getState().unifiedRole;
if (role) {
const built = buildUnifiedAccounts();
const populated = await populateUnifiedAccountMailboxes(built);
await fetchUnifiedEmailsAction(populated, role);
}
return;
}
if (selectedMailbox) {
await fetchEmails(client, selectedMailbox); await fetchEmails(client, selectedMailbox);
} }
}; };
const handleAdvancedSearch = async () => { const handleAdvancedSearch = async () => {
if (!client) return; if (!client) return;
if (isUnifiedView) return;
await advancedSearch(client); await advancedSearch(client);
}; };
@@ -1825,9 +1835,9 @@ export default function Home() {
clearTimeout(advancedSearchDebounceRef.current); clearTimeout(advancedSearchDebounceRef.current);
} }
advancedSearchDebounceRef.current = setTimeout(() => { advancedSearchDebounceRef.current = setTimeout(() => {
if (client && !isUnifiedView) advancedSearch(client); if (client) advancedSearch(client);
}, 300); }, 300);
}, [client, advancedSearch, isUnifiedView]); }, [client, advancedSearch]);
useEffect(() => { useEffect(() => {
return () => { return () => {
@@ -2326,8 +2336,6 @@ export default function Home() {
className={cn("pl-9 h-9", searchQuery && "pr-8")} className={cn("pl-9 h-9", searchQuery && "pr-8")}
data-search-input data-search-input
data-tour="search-input" data-tour="search-input"
disabled={isUnifiedView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : undefined}
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -2343,15 +2351,13 @@ export default function Home() {
<button <button
type="button" type="button"
onClick={toggleAdvancedSearch} onClick={toggleAdvancedSearch}
disabled={isUnifiedView}
className={cn( className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors", "relative flex-shrink-0 p-2 rounded-md transition-colors",
isUnifiedView && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0 isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary" ? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)} )}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : t("advanced_search.toggle_filters")} title={t("advanced_search.toggle_filters")}
> >
<Filter className="w-4 h-4" /> <Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && ( {!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
+93
View File
@@ -117,6 +117,99 @@ export async function fetchUnifiedEmails(
}; };
} }
/**
* Runs a text search across every account that has a mailbox for the given
* unified role, merging and sorting the results by receivedAt descending. The
* fan-out / error-collection shape mirrors `fetchUnifiedEmails` so the caller
* sees consistent behavior between browse and search.
*/
export async function searchUnifiedEmails(
accounts: UnifiedAccountClient[],
role: UnifiedMailboxRole,
query: string,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
return account.client.searchEmails(query, mailbox.id, undefined, limit, position);
});
}
/**
* Like `searchUnifiedEmails`, but uses the JMAP advanced filter shape. The
* caller supplies a `filterFor(mailboxId)` factory because each account's role
* mailbox has a different id and the filter must include the right
* `inMailbox` clause per request.
*/
export async function advancedSearchUnifiedEmails(
accounts: UnifiedAccountClient[],
role: UnifiedMailboxRole,
filterFor: (mailboxId: string) => Record<string, unknown>,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
return account.client.advancedSearchEmails(filterFor(mailbox.id), undefined, limit, position);
});
}
async function fanOutUnifiedQuery(
accounts: UnifiedAccountClient[],
role: UnifiedMailboxRole,
run: (
account: UnifiedAccountClient,
mailbox: Mailbox,
) => Promise<{ emails: Email[]; total: number; hasMore: boolean }>,
): Promise<UnifiedFetchResult> {
const errors = new Map<string, string>();
type AccountResult = {
account: UnifiedAccountClient;
result: { emails: Email[]; total: number; hasMore: boolean };
} | null;
const promises = accounts.map(async (account): Promise<AccountResult> => {
const mailbox = findMailboxByRole(account.mailboxes, role);
if (!mailbox) return null;
try {
const result = await run(account, mailbox);
return { account, result };
} catch (err) {
errors.set(
account.accountId,
err instanceof Error ? err.message : String(err),
);
return null;
}
});
const results = await Promise.allSettled(promises);
let mergedEmails: Email[] = [];
let totalSum = 0;
let anyHasMore = false;
for (const outcome of results) {
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
const { account, result } = outcome.value;
for (const email of result.emails) {
email.accountId = account.accountId;
email.accountLabel = account.accountLabel;
}
mergedEmails = mergedEmails.concat(result.emails);
totalSum += result.total;
if (result.hasMore) anyHasMore = true;
}
mergedEmails.sort((a, b) => {
const dateA = new Date(a.receivedAt).getTime();
const dateB = new Date(b.receivedAt).getTime();
return dateB - dateA;
});
return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors };
}
/** /**
* Aggregates unread and total email counts across all accounts for each * Aggregates unread and total email counts across all accounts for each
* unified mailbox role. Only includes roles that exist in at least one account. * unified mailbox role. Only includes roles that exist in at least one account.
+86 -21
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 { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks"; import { emailHooks } from "@/lib/plugin-hooks";
import type { ExternalSearchResult } from "@/lib/plugin-types"; import type { ExternalSearchResult } from "@/lib/plugin-types";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store"; import { useAccountStore } from "@/stores/account-store";
@@ -231,6 +231,30 @@ function resolveActionMailboxes(): Mailbox[] {
* standard `mailboxes` slot for the active account, or the per-account * standard `mailboxes` slot for the active account, or the per-account
* cache for non-active accounts so the Pro sidebar stays in sync. * cache for non-active accounts so the Pro sidebar stays in sync.
*/ */
/**
* Builds the `UnifiedAccountClient[]` list used by every unified fan-out
* action (browse, load-more, search). Each entry has a JMAP client plus a
* fresh mailbox list so the helpers can resolve the role mailbox per account.
* Accounts whose mailbox fetch fails are skipped — the unified result will
* surface that in its per-account error map.
*/
async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected);
const allClients = useAuthStore.getState().getAllConnectedClients();
const built: UnifiedAccountClient[] = [];
for (const a of authAccounts) {
const c = allClients.get(a.id);
if (!c) continue;
try {
const mailboxes = await c.getMailboxes();
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes });
} catch {
/* skip account on mailbox fetch failure */
}
}
return built;
}
async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise<void> { async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise<void> {
const viewingId = useEmailStore.getState().viewingAccountId; const viewingId = useEmailStore.getState().viewingAccountId;
const client = resolveActionClient(fallbackClient); const client = resolveActionClient(fallbackClient);
@@ -538,27 +562,28 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Don't load if already loading or no more emails // Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return; if (isLoadingMore || !hasMoreEmails) return;
// Unified view uses a different fan-out loader. Rebuild the per-account // Unified view uses a different fan-out loader. When a search query or
// client list from auth/account stores and delegate. // advanced filter is active we paginate the unified search instead of the
// unified browse, so "load more" matches what's on screen.
if (isUnifiedView && unifiedRole) { if (isUnifiedView && unifiedRole) {
set({ isLoadingMore: true, error: null }); set({ isLoadingMore: true, error: null });
try { try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage; const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const position = emails.length; const position = emails.length;
const authAccounts = useAccountStore.getState().accounts.filter(a => a.isConnected); const built = await buildUnifiedAccountClients();
const allClients = useAuthStore.getState().getAllConnectedClients(); const { searchFilters } = get();
const built: UnifiedAccountClient[] = []; const hasFilters = !isFilterEmpty(searchFilters);
for (const a of authAccounts) { const result = hasFilters
const c = allClients.get(a.id); ? await advancedSearchUnifiedEmails(
if (!c) continue; built,
try { unifiedRole,
const mailboxes = await c.getMailboxes(); (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes }); emailsPerPage,
} catch { position,
/* skip account on mailbox fetch failure */ )
} : searchQuery
} ? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, position)
const result = await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position); : await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position);
const currentEmails = get().emails; const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id)); const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id));
@@ -1110,6 +1135,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchEmails: async (client, query) => { searchEmails: async (client, query) => {
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
try { try {
const { isUnifiedView, unifiedRole } = get();
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && unifiedRole) {
const built = await buildUnifiedAccountClients();
const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
return;
}
// Get the current mailbox to scope the search // Get the current mailbox to scope the search
const selectedMailbox = get().selectedMailbox; const selectedMailbox = get().selectedMailbox;
const mailboxes = resolveActionMailboxes(); const mailboxes = resolveActionMailboxes();
@@ -1119,8 +1162,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Only pass accountId for shared mailboxes, not for primary account // Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({ set({
@@ -1143,7 +1184,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}, },
advancedSearch: async (client) => { advancedSearch: async (client) => {
const { searchQuery, searchFilters, selectedMailbox, searchAbortController } = get(); const { searchQuery, searchFilters, selectedMailbox, searchAbortController, isUnifiedView, unifiedRole } = get();
const mailboxes = resolveActionMailboxes(); const mailboxes = resolveActionMailboxes();
if (searchAbortController) { if (searchAbortController) {
@@ -1161,12 +1202,36 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}); });
try { try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && unifiedRole) {
const built = await buildUnifiedAccountClients();
const result = await advancedSearchUnifiedEmails(
built,
unifiedRole,
(mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
emailsPerPage,
0,
);
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
searchAbortController: null,
unifiedErrors: result.errors,
});
return;
}
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox; const jmapMailboxId = mailbox?.originalId || selectedMailbox;
const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
if (controller.signal.aborted) return; if (controller.signal.aborted) return;