Skip to content

Commit 37d32d1

Browse files
committed
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.
1 parent 76fbc88 commit 37d32d1

5 files changed

Lines changed: 108 additions & 17 deletions

File tree

FEATURES.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
- Gmail-style threading with inline expansion and an optional conversation toggle
77
- 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
88
- 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
9+
- 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
910
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
1011
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
1112
- 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

app/(main)/[locale]/page.tsx

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1760,7 +1760,18 @@ export default function Home() {
17601760
}
17611761

17621762
const populated = await buildPopulatedUnifiedAccounts();
1763-
await fetchUnifiedEmailsAction(populated, role);
1763+
// Keep an active search across the switch and re-run it in this view
1764+
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
1765+
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
1766+
useEmailStore.setState({ isUnifiedView: true, unifiedRole: role, crossView: null });
1767+
if (!isFilterEmpty(searchFilters)) {
1768+
await advancedSearch(client);
1769+
} else {
1770+
await searchEmails(client, searchQuery);
1771+
}
1772+
} else {
1773+
await fetchUnifiedEmailsAction(populated, role);
1774+
}
17641775
refreshUnifiedCounts(populated);
17651776
return;
17661777
}
@@ -1782,7 +1793,18 @@ export default function Home() {
17821793
}
17831794

17841795
const populated = await buildPopulatedUnifiedAccounts();
1785-
await fetchCrossViewAction(populated, view);
1796+
// Keep an active search across the switch and re-run it in this view
1797+
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
1798+
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
1799+
useEmailStore.setState({ isUnifiedView: true, crossView: view, unifiedRole: null });
1800+
if (!isFilterEmpty(searchFilters)) {
1801+
await advancedSearch(client);
1802+
} else {
1803+
await searchEmails(client, searchQuery);
1804+
}
1805+
} else {
1806+
await fetchCrossViewAction(populated, view);
1807+
}
17861808
refreshCrossCounts(populated);
17871809
return;
17881810
}
@@ -1807,8 +1829,12 @@ export default function Home() {
18071829
}
18081830

18091831
if (client) {
1810-
// If there's an active search, re-run it in the new mailbox
1811-
if (searchQuery) {
1832+
// Keep an active search across the switch and re-run it in the new mailbox,
1833+
// preserving advanced filters when set (text-only when just a query; browse
1834+
// when neither).
1835+
if (!isFilterEmpty(searchFilters)) {
1836+
await advancedSearch(client);
1837+
} else if (searchQuery) {
18121838
await searchEmails(client, searchQuery);
18131839
} else {
18141840
await fetchEmails(client, mailboxId);
@@ -2123,13 +2149,16 @@ export default function Home() {
21232149
setSearchQuery("");
21242150
clearSearchFilters();
21252151
if (!client) return;
2126-
// In unified view the active "mailbox" is a virtual role, so refresh via
2127-
// the unified fan-out instead of fetchEmails.
2152+
// In unified view the active "mailbox" is a virtual role or cross view, so
2153+
// refresh via the unified fan-out instead of fetchEmails.
21282154
if (isUnifiedView) {
2155+
const populated = await buildPopulatedUnifiedAccounts();
21292156
const role = useEmailStore.getState().unifiedRole;
2157+
const cross = useEmailStore.getState().crossView;
21302158
if (role) {
2131-
const populated = await buildPopulatedUnifiedAccounts();
21322159
await fetchUnifiedEmailsAction(populated, role);
2160+
} else if (cross) {
2161+
await fetchCrossViewAction(populated, cross);
21332162
}
21342163
return;
21352164
}
@@ -2752,8 +2781,8 @@ export default function Home() {
27522781
className={cn("pl-9 h-9", searchQuery && "pr-8")}
27532782
data-search-input
27542783
data-tour="search-input"
2755-
disabled={isUnifiedView || isScheduledView}
2756-
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
2784+
disabled={isScheduledView}
2785+
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
27572786
/>
27582787
{searchQuery && (
27592788
<button
@@ -2769,15 +2798,15 @@ export default function Home() {
27692798
<button
27702799
type="button"
27712800
onClick={toggleAdvancedSearch}
2772-
disabled={isUnifiedView || isScheduledView}
2801+
disabled={isScheduledView}
27732802
className={cn(
27742803
"relative flex-shrink-0 p-2 rounded-md transition-colors",
2775-
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
2804+
isScheduledView && "opacity-50 cursor-not-allowed",
27762805
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
27772806
? "bg-primary/10 text-primary"
27782807
: "text-muted-foreground hover:text-foreground hover:bg-muted"
27792808
)}
2780-
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
2809+
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
27812810
>
27822811
<Filter className="w-4 h-4" />
27832812
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (

lib/__tests__/unified-mailbox-cross.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
buildCrossFilter,
77
getCrossUnreadTotal,
88
fetchCrossViewEmails,
9+
advancedSearchCrossViewEmails,
910
resolveSourceFolderName,
1011
type UnifiedAccountClient,
1112
} from '@/lib/unified-mailbox';
@@ -203,3 +204,28 @@ describe('fetchCrossViewEmails', () => {
203204
expect(result.errors.get('bad')).toBe('boom');
204205
});
205206
});
207+
208+
describe('advancedSearchCrossViewEmails', () => {
209+
it('ANDs the advanced filter onto the cross-view membership', async () => {
210+
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
211+
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
212+
213+
await advancedSearchCrossViewEmails([a], 'all', { hasKeyword: '$flagged' }, 50, 0);
214+
215+
const [filter] = advancedSearchEmails.mock.calls[0];
216+
expect(filter).toEqual({
217+
operator: 'AND',
218+
conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }],
219+
});
220+
});
221+
222+
it('uses only the membership filter when the extra filter is empty', async () => {
223+
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
224+
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
225+
226+
await advancedSearchCrossViewEmails([a], 'all', {}, 50, 0);
227+
228+
const [filter] = advancedSearchEmails.mock.calls[0];
229+
expect(filter).toEqual({ inMailbox: 'inbox' });
230+
});
231+
});

lib/unified-mailbox.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,29 @@ export async function searchCrossViewEmails(
465465
));
466466
}
467467

468+
/**
469+
* Like `searchCrossViewEmails`, but applies an advanced filter (text + field
470+
* conditions from `buildJMAPFilter`, built WITHOUT an `inMailbox` clause) on top
471+
* of the cross-view membership. `extraFilter` may be empty ({}), in which case
472+
* only the membership filter is used (equivalent to a plain browse).
473+
*/
474+
export async function advancedSearchCrossViewEmails(
475+
accounts: UnifiedAccountClient[],
476+
view: CrossView,
477+
extraFilter: Record<string, unknown>,
478+
limit: number,
479+
position: number,
480+
): Promise<UnifiedFetchResult> {
481+
const hasExtra = Object.keys(extraFilter).length > 0;
482+
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) => {
483+
const membership = buildCrossFilter(view, ids);
484+
const filter = hasExtra
485+
? { operator: 'AND', conditions: [membership, extraFilter] }
486+
: membership;
487+
return account.client.advancedSearchEmails(filter, jmapAccountId, limit, position);
488+
});
489+
}
490+
468491
/**
469492
* Returns the list of unified roles that exist in at least one account's
470493
* mailboxes.

stores/email-store.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { useCalendarStore } from "@/stores/calendar-store";
77
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
88
import { emailHooks } from "@/lib/plugin-hooks";
99
import type { ExternalSearchResult } from "@/lib/plugin-types";
10-
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
10+
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
1111
import { useAuthStore } from "@/stores/auth-store";
1212
import { useAccountStore } from "@/stores/account-store";
1313

@@ -959,9 +959,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
959959
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
960960
const position = emails.length;
961961
const built = await buildUnifiedAccountClients({ includeGroup });
962-
const result = searchQuery
963-
? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, position)
964-
: await fetchCrossViewEmails(built, crossView, emailsPerPage, position);
962+
const hasFilters = !isFilterEmpty(get().searchFilters);
963+
const result = hasFilters
964+
? await advancedSearchCrossViewEmails(built, crossView, buildJMAPFilter(searchQuery, get().searchFilters, undefined), emailsPerPage, position)
965+
: searchQuery
966+
? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, position)
967+
: await fetchCrossViewEmails(built, crossView, emailsPerPage, position);
965968
const currentEmails = get().emails;
966969
const existingIds = new Set(currentEmails.map(e => e.id));
967970
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
@@ -1796,7 +1799,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
17961799
if (isUnifiedView && crossView) {
17971800
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
17981801
const built = await buildUnifiedAccountClients({ includeGroup });
1799-
const result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0);
1802+
// Cross views apply the advanced filter (text + fields) on top of the
1803+
// view membership; an empty filter degrades to a plain membership query.
1804+
const result = await advancedSearchCrossViewEmails(
1805+
built, crossView, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
1806+
);
18001807
if (controller.signal.aborted) return;
18011808
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
18021809
set({
@@ -3104,6 +3111,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
31043111
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
31053112
selectedEmail: leavingScheduled ? null : state.selectedEmail,
31063113
selectedEmailIds: leavingScheduled ? new Set<string>() : state.selectedEmailIds,
3114+
// Search is unavailable in the scheduled view (the input is disabled there).
3115+
// Reset any active search when entering it so a stale query can't linger or
3116+
// re-run when the user leaves again.
3117+
searchQuery: isScheduledView ? "" : state.searchQuery,
3118+
searchFilters: isScheduledView ? { ...DEFAULT_SEARCH_FILTERS } : state.searchFilters,
31073119
};
31083120
}),
31093121
clearPendingUndoSend: () => set({ pendingUndoSend: null }),

0 commit comments

Comments
 (0)