feat: enable search in unified mailbox in pro mode
This commit is contained in:
@@ -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 && (
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user