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:
@@ -6,6 +6,7 @@
|
|||||||
- Gmail-style threading with inline expansion and an optional conversation toggle
|
- Gmail-style threading with inline expansion and an optional conversation toggle
|
||||||
- Unified Mailbox – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account
|
- Unified Mailbox – combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account
|
||||||
- Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message
|
- Aggregated All mail / Unread / Starred entries in the Unified Mailbox – scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message
|
||||||
|
- Search inside the Unified Mailbox – text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes
|
||||||
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
|
||||||
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
|
||||||
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
- Attachment upload, download, drag-out to local file system, and inline preview – images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
|
||||||
|
|||||||
@@ -1838,7 +1838,18 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const populated = await buildPopulatedUnifiedAccounts();
|
const populated = await buildPopulatedUnifiedAccounts();
|
||||||
await fetchUnifiedEmailsAction(populated, role);
|
// Keep an active search across the switch and re-run it in this view
|
||||||
|
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
|
||||||
|
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
|
||||||
|
useEmailStore.setState({ isUnifiedView: true, unifiedRole: role, crossView: null });
|
||||||
|
if (!isFilterEmpty(searchFilters)) {
|
||||||
|
await advancedSearch(client);
|
||||||
|
} else {
|
||||||
|
await searchEmails(client, searchQuery);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await fetchUnifiedEmailsAction(populated, role);
|
||||||
|
}
|
||||||
refreshUnifiedCounts(populated);
|
refreshUnifiedCounts(populated);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1860,7 +1871,18 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const populated = await buildPopulatedUnifiedAccounts();
|
const populated = await buildPopulatedUnifiedAccounts();
|
||||||
await fetchCrossViewAction(populated, view);
|
// Keep an active search across the switch and re-run it in this view
|
||||||
|
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
|
||||||
|
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
|
||||||
|
useEmailStore.setState({ isUnifiedView: true, crossView: view, unifiedRole: null });
|
||||||
|
if (!isFilterEmpty(searchFilters)) {
|
||||||
|
await advancedSearch(client);
|
||||||
|
} else {
|
||||||
|
await searchEmails(client, searchQuery);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await fetchCrossViewAction(populated, view);
|
||||||
|
}
|
||||||
refreshCrossCounts(populated);
|
refreshCrossCounts(populated);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2206,13 +2228,16 @@ export default function Home() {
|
|||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
clearSearchFilters();
|
clearSearchFilters();
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
// In unified view the active "mailbox" is a virtual role, so refresh via
|
// In unified view the active "mailbox" is a virtual role or cross view, so
|
||||||
// the unified fan-out instead of fetchEmails.
|
// refresh via the unified fan-out instead of fetchEmails.
|
||||||
if (isUnifiedView) {
|
if (isUnifiedView) {
|
||||||
|
const populated = await buildPopulatedUnifiedAccounts();
|
||||||
const role = useEmailStore.getState().unifiedRole;
|
const role = useEmailStore.getState().unifiedRole;
|
||||||
|
const cross = useEmailStore.getState().crossView;
|
||||||
if (role) {
|
if (role) {
|
||||||
const populated = await buildPopulatedUnifiedAccounts();
|
|
||||||
await fetchUnifiedEmailsAction(populated, role);
|
await fetchUnifiedEmailsAction(populated, role);
|
||||||
|
} else if (cross) {
|
||||||
|
await fetchCrossViewAction(populated, cross);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2848,8 +2873,8 @@ export default function Home() {
|
|||||||
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
data-tour="search-input"
|
data-tour="search-input"
|
||||||
disabled={isUnifiedView || isScheduledView}
|
disabled={isScheduledView}
|
||||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -2865,15 +2890,15 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAdvancedSearch}
|
onClick={toggleAdvancedSearch}
|
||||||
disabled={isUnifiedView || isScheduledView}
|
disabled={isScheduledView}
|
||||||
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 || isScheduledView) && "opacity-50 cursor-not-allowed",
|
isScheduledView && "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") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
|
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : 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 && (
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
buildCrossFilter,
|
buildCrossFilter,
|
||||||
getCrossUnreadTotal,
|
getCrossUnreadTotal,
|
||||||
fetchCrossViewEmails,
|
fetchCrossViewEmails,
|
||||||
|
advancedSearchCrossViewEmails,
|
||||||
resolveSourceFolderName,
|
resolveSourceFolderName,
|
||||||
type UnifiedAccountClient,
|
type UnifiedAccountClient,
|
||||||
} from '@/lib/unified-mailbox';
|
} from '@/lib/unified-mailbox';
|
||||||
@@ -203,3 +204,28 @@ describe('fetchCrossViewEmails', () => {
|
|||||||
expect(result.errors.get('bad')).toBe('boom');
|
expect(result.errors.get('bad')).toBe('boom');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('advancedSearchCrossViewEmails', () => {
|
||||||
|
it('ANDs the advanced filter onto the cross-view membership', async () => {
|
||||||
|
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||||
|
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
|
||||||
|
|
||||||
|
await advancedSearchCrossViewEmails([a], 'all', { hasKeyword: '$flagged' }, 50, 0);
|
||||||
|
|
||||||
|
const [filter] = advancedSearchEmails.mock.calls[0];
|
||||||
|
expect(filter).toEqual({
|
||||||
|
operator: 'AND',
|
||||||
|
conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses only the membership filter when the extra filter is empty', async () => {
|
||||||
|
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
|
||||||
|
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
|
||||||
|
|
||||||
|
await advancedSearchCrossViewEmails([a], 'all', {}, 50, 0);
|
||||||
|
|
||||||
|
const [filter] = advancedSearchEmails.mock.calls[0];
|
||||||
|
expect(filter).toEqual({ inMailbox: 'inbox' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -465,6 +465,29 @@ export async function searchCrossViewEmails(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `searchCrossViewEmails`, but applies an advanced filter (text + field
|
||||||
|
* conditions from `buildJMAPFilter`, built WITHOUT an `inMailbox` clause) on top
|
||||||
|
* of the cross-view membership. `extraFilter` may be empty ({}), in which case
|
||||||
|
* only the membership filter is used (equivalent to a plain browse).
|
||||||
|
*/
|
||||||
|
export async function advancedSearchCrossViewEmails(
|
||||||
|
accounts: UnifiedAccountClient[],
|
||||||
|
view: CrossView,
|
||||||
|
extraFilter: Record<string, unknown>,
|
||||||
|
limit: number,
|
||||||
|
position: number,
|
||||||
|
): Promise<UnifiedFetchResult> {
|
||||||
|
const hasExtra = Object.keys(extraFilter).length > 0;
|
||||||
|
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) => {
|
||||||
|
const membership = buildCrossFilter(view, ids);
|
||||||
|
const filter = hasExtra
|
||||||
|
? { operator: 'AND', conditions: [membership, extraFilter] }
|
||||||
|
: membership;
|
||||||
|
return account.client.advancedSearchEmails(filter, jmapAccountId, limit, position);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the list of unified roles that exist in at least one account's
|
* Returns the list of unified roles that exist in at least one account's
|
||||||
* mailboxes.
|
* mailboxes.
|
||||||
|
|||||||
+17
-5
@@ -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, 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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
|
||||||
@@ -959,9 +959,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||||
const position = emails.length;
|
const position = emails.length;
|
||||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||||
const result = searchQuery
|
const hasFilters = !isFilterEmpty(get().searchFilters);
|
||||||
? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, position)
|
const result = hasFilters
|
||||||
: await fetchCrossViewEmails(built, crossView, emailsPerPage, position);
|
? 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 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));
|
||||||
@@ -1796,7 +1799,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
if (isUnifiedView && crossView) {
|
if (isUnifiedView && crossView) {
|
||||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
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;
|
if (controller.signal.aborted) return;
|
||||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
|
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
|
||||||
set({
|
set({
|
||||||
@@ -3225,6 +3232,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
|
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
|
||||||
selectedEmail: leavingScheduled ? null : state.selectedEmail,
|
selectedEmail: leavingScheduled ? null : state.selectedEmail,
|
||||||
selectedEmailIds: leavingScheduled ? new Set<string>() : state.selectedEmailIds,
|
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 }),
|
clearPendingUndoSend: () => set({ pendingUndoSend: null }),
|
||||||
|
|||||||
Reference in New Issue
Block a user