diff --git a/README.md b/README.md index 50f50df2..1a0666be 100644 --- a/README.md +++ b/README.md @@ -183,14 +183,6 @@ npm start **Settings** Settings - - - - - -**Sidebar Navigation** -Sidebar - diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 17a8437d..f585000f 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -67,6 +67,8 @@ export default function Home() { toggleStar, moveToMailbox, searchEmails, + searchQuery, + setSearchQuery, isLoading, isLoadingEmail, setLoadingEmail, @@ -469,7 +471,12 @@ export default function Home() { } if (client) { - await fetchEmails(client, mailboxId); + // If there's an active search, re-run it in the new mailbox + if (searchQuery) { + await searchEmails(client, searchQuery); + } else { + await fetchEmails(client, mailboxId); + } } }; @@ -483,6 +490,13 @@ export default function Home() { await searchEmails(client, query); }; + const handleClearSearch = async () => { + setSearchQuery(""); + if (client && selectedMailbox) { + await fetchEmails(client, selectedMailbox); + } + }; + const handleDownloadAttachment = async (blobId: string, name: string, type?: string) => { if (!client) return; @@ -645,6 +659,8 @@ export default function Home() { }} onLogout={handleLogout} onSearch={handleSearch} + onClearSearch={handleClearSearch} + activeSearchQuery={searchQuery} quota={quota} isPushConnected={isPushConnected} /> diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index cfbc8dbf..5dacae7b 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -60,6 +60,7 @@ export function EmailList({ loadMoreEmails, hasMoreEmails, isLoadingMore, + totalEmails, mailboxes, selectedMailbox, expandedThreadIds, @@ -260,7 +261,13 @@ export function EmailList({ )}

- {isLoading ? 'Loading...' : threadGroups.length > 0 ? `${threadGroups.length} conversations` : 'No conversations'} + {isLoading ? 'Loading...' : threadGroups.length > 0 + ? (totalEmails > threadGroups.length + ? `${threadGroups.length} of ${totalEmails} conversations` + : hasMoreEmails + ? `${threadGroups.length}+ conversations` + : `${threadGroups.length} conversations`) + : 'No conversations'}

diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index fad0aa83..4933c87d 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -24,6 +24,7 @@ import { ChevronUp, Users, User, + X, } from "lucide-react"; import { cn, buildMailboxTree, MailboxNode, formatFileSize } from "@/lib/utils"; import { Mailbox } from "@/lib/jmap/types"; @@ -37,6 +38,8 @@ interface SidebarProps { onCompose?: () => void; onLogout?: () => void; onSearch?: (query: string) => void; + onClearSearch?: () => void; + activeSearchQuery?: string; quota?: { used: number; total: number } | null; isPushConnected?: boolean; className?: string; @@ -206,6 +209,8 @@ export function Sidebar({ onCompose, onLogout, onSearch, + onClearSearch, + activeSearchQuery = "", quota, isPushConnected = false, className, @@ -215,6 +220,11 @@ export function Sidebar({ const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [showMenu, setShowMenu] = useState(false); const t = useTranslations('sidebar'); + + // Sync local search query with store's active search query + useEffect(() => { + setSearchQuery(activeSearchQuery); + }, [activeSearchQuery]); const params = useParams(); const router = useRouter(); @@ -334,9 +344,22 @@ export function Sidebar({ placeholder={t("search_placeholder")} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - className="pl-9" + className={cn("pl-9", searchQuery && "pr-8")} data-search-input /> + {searchQuery && ( + + )} )} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index bb25dc34..313ae604 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -753,19 +753,27 @@ export class JMAPClient { ]); } - async searchEmails(query: string, limit: number = 50): Promise { + async searchEmails(query: string, mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[], hasMore: boolean, total: number }> { try { + // Use provided accountId or fallback to primary account + const targetAccountId = accountId || this.accountId; + + // Build filter with text search, optionally scoped to a mailbox + const filter: Record = { text: query }; + if (mailboxId) { + filter.inMailbox = mailboxId; + } + const response = await this.request([ ["Email/query", { - accountId: this.accountId, - filter: { - text: query, - }, + accountId: targetAccountId, + filter: filter, sort: [{ property: "receivedAt", isAscending: false }], limit: limit, + position: position, }, "0"], ["Email/get", { - accountId: this.accountId, + accountId: targetAccountId, "#ids": { resultOf: "0", name: "Email/query", @@ -788,14 +796,21 @@ export class JMAPClient { }, "1"], ]); - if (response.methodResponses?.[1]?.[0] === "Email/get") { - return response.methodResponses[1][1].list || []; - } + const queryResponse = response.methodResponses?.[0]?.[1]; + const emails = response.methodResponses?.[1]?.[1]?.list || []; - return []; + // Stalwart doesn't always return 'total', so we use a different strategy: + // If we got exactly 'limit' emails, there might be more + // If we got fewer, we've reached the end + const total = queryResponse?.total || 0; + const hasMore = total > 0 + ? (position + emails.length) < total // Use total if available + : emails.length === limit; // Otherwise, check if we got a full page + + return { emails, hasMore, total }; } catch (error) { console.error('Search failed:', error); - return []; + return { emails: [], hasMore: false, total: 0 }; } } diff --git a/screenshots/07-inbox-sidebar.png b/screenshots/07-inbox-sidebar.png deleted file mode 100644 index fae09583..00000000 Binary files a/screenshots/07-inbox-sidebar.png and /dev/null differ diff --git a/stores/email-store.ts b/stores/email-store.ts index 88277632..6a5ca4ae 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -200,25 +200,39 @@ export const useEmailStore = create((set, get) => ({ }, loadMoreEmails: async (client) => { - const { isLoadingMore, hasMoreEmails, emails, selectedMailbox } = get(); + const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery } = get(); // Don't load if already loading or no more emails if (isLoadingMore || !hasMoreEmails) return; set({ isLoadingMore: true, error: null }); try { - // Find the mailbox to get its accountId (for shared folder support) - const mailboxes = get().mailboxes; - const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); - // Only pass accountId for shared mailboxes, not for primary account - const accountId = mailbox?.isShared ? mailbox.accountId : undefined; - // Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store) - const jmapMailboxId = mailbox?.originalId || selectedMailbox; - // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; - const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length); + let result; + + // Check if we're in search mode + if (searchQuery) { + // Load more search results (scoped to current mailbox) + const mailboxes = get().mailboxes; + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + const jmapMailboxId = mailbox?.originalId || selectedMailbox; + // Only pass accountId for shared mailboxes + const accountId = mailbox?.isShared ? mailbox.accountId : undefined; + result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length); + } else { + // Load more from mailbox + // Find the mailbox to get its accountId (for shared folder support) + const mailboxes = get().mailboxes; + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + // Only pass accountId for shared mailboxes, not for primary account + const accountId = mailbox?.isShared ? mailbox.accountId : undefined; + // Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store) + const jmapMailboxId = mailbox?.originalId || selectedMailbox; + + result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length); + } set({ emails: [...emails, ...result.emails], @@ -565,8 +579,24 @@ export const useEmailStore = create((set, get) => ({ searchEmails: async (client, query) => { set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state try { - const emails = await client.searchEmails(query); - set({ emails, isLoading: false, hasMoreEmails: false, totalEmails: emails.length }); + // Get the current mailbox to scope the search + const selectedMailbox = get().selectedMailbox; + const mailboxes = get().mailboxes; + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + // Use originalId for shared mailboxes + const jmapMailboxId = mailbox?.originalId || selectedMailbox; + // Only pass accountId for shared mailboxes, not for primary account + const accountId = mailbox?.isShared ? mailbox.accountId : undefined; + + // Get emails per page from settings + const emailsPerPage = useSettingsStore.getState().emailsPerPage; + const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); + set({ + emails: result.emails, + hasMoreEmails: result.hasMore, + totalEmails: result.total, + isLoading: false + }); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to search emails",