import { create } from "zustand"; import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult } from "@/lib/jmap/types"; import type { UnifiedMailboxRole } from "@/lib/jmap/types"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import { useSettingsStore } from "@/stores/settings-store"; import { useCalendarStore } from "@/stores/calendar-store"; import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils"; import { emailHooks } from "@/lib/plugin-hooks"; import type { ExternalSearchResult } from "@/lib/plugin-types"; import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox"; import { useAuthStore } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; type ScheduledSubmissionMetadata = { submissionId: string; sendAt: string; identityId: string; undoStatus: 'pending' | 'final' | 'canceled'; }; const VIRTUAL_SCHEDULED_MAILBOX_ID = '__scheduled__'; type PendingUndoSend = { submissionId: string; emailId?: string; sendAt: string; isSmime: boolean }; interface EmailStore { emails: Email[]; mailboxes: Mailbox[]; /** * Mailbox caches keyed by accountId. Populated for every connected account * when the Pro shell is active so the sidebar can render per-account groups * Thunderbird-style. The active account's mailboxes still live in * `mailboxes` for back-compat with the single-account view. */ accountMailboxes: Record; /** * When set, the mail view is reading from this account instead of the * global active one. `null` means "use the global active account" - i.e. * the standard single-account behavior. Selecting a folder under a * non-active account in the Pro sidebar updates this without changing * `useAuthStore.activeAccountId`. */ viewingAccountId: string | null; selectedEmail: Email | null; selectedMailbox: string; isLoading: boolean; isLoadingEmail: boolean; // Track when a full email is being fetched isLoadingMore: boolean; // Track when loading more emails (pagination) error: string | null; searchQuery: string; quota: { used: number; total: number } | null; processingReadStatus: Set; // Track emails being marked as read/unread selectedEmailIds: Set; // Track selected emails for batch operations hasMoreEmails: boolean; // Track if more emails are available to load totalEmails: number; // Total number of emails in the current mailbox/query isPushConnected: boolean; // Track if push notifications are connected lastPushUpdate: number | null; // Timestamp of last push update newEmailNotification: Email | null; // New email notification for toast // Thread expansion state expandedThreadIds: Set; threadEmailsCache: Map; isLoadingThread: string | null; // Keyword/tag filter selectedKeyword: string | null; tagCounts: Record; // Advanced search state searchFilters: SearchFilters; isAdvancedSearchOpen: boolean; searchAbortController: AbortController | null; /** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */ externalSearchResults: ExternalSearchResult[]; // Unified mailbox state isUnifiedView: boolean; unifiedRole: UnifiedMailboxRole | null; unifiedErrors: Map; // accountId -> error message unifiedCounts: UnifiedMailboxCounts[]; // Scheduled send state scheduledEmails: ScheduledEmail[]; scheduledEmailIds: Set; scheduledSubmissionByEmailId: Map; scheduledTotal: number; scheduledHasMore: boolean; scheduledNextPosition: number; isLoadingScheduled: boolean; isScheduledView: boolean; pendingUndoSend: PendingUndoSend | null; setEmails: (emails: Email[]) => void; setMailboxes: (mailboxes: Mailbox[]) => void; /** Cache or update the mailbox list for a specific account. */ setAccountMailboxes: (accountId: string, mailboxes: Mailbox[]) => void; /** Wipe the per-account mailbox cache (e.g. on logout). */ clearAccountMailboxes: () => void; setViewingAccount: (accountId: string | null) => void; /** * Atomic version of (setViewingAccount + selectMailbox). Pass `null` for * the active account; pass an accountId to view a non-active account's * folder without changing the global active account. */ selectAccountMailbox: (accountId: string | null, mailboxId: string) => void; /** * Fetch mailboxes via the supplied client and store them under * `accountMailboxes[accountId]`. Used by the Pro shell to populate the * sidebar's per-account groups for every connected account. */ fetchAccountMailboxes: (client: IJMAPClient, accountId: string) => Promise; selectEmail: (email: Email | null) => void; selectMailbox: (mailboxId: string) => void; setLoading: (loading: boolean) => void; setLoadingEmail: (loading: boolean) => void; setError: (error: string | null) => void; setSearchQuery: (query: string) => void; setQuota: (quota: { used: number; total: number } | null) => void; selectKeyword: (keyword: string | null) => void; fetchTagCounts: (client: IJMAPClient) => Promise; toggleEmailSelection: (emailId: string) => void; selectRangeEmails: (targetEmailId: string) => void; lastSelectedEmailId: string | null; selectAllEmails: () => void; clearSelection: () => void; // JMAP operations fetchMailboxes: (client: IJMAPClient) => Promise; fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise; // Eager post-login bootstrap: fires mailboxes/quota/emails so the round-trips // overlap with Next's soft-nav + home-page hydration. Safe to call multiple // times; later calls are no-ops while a prior one is in flight. prefetchInitialData: (client: IJMAPClient) => Promise; loadMoreEmails: (client: IJMAPClient) => Promise; fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise; fetchQuota: (client: IJMAPClient) => Promise; sendEmail: (client: IJMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string, htmlBody?: string, attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>, inReplyTo?: string[], references?: string[], delayedUntil?: string, envelopeMailFrom?: string, options?: { requestReadReceipt?: boolean }) => Promise; sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]) => Promise; deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise; markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise; moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise; moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise; /** * Move emails across JMAP accounts. JMAP has no native cross-account move, * so for each email we fetch the source's raw RFC822 blob, import it into * the destination account's target mailbox, then delete the original. * `emailIdsBySource` maps each source accountId to the emails it owns; * pass the active account's id explicitly (no `__default__` sentinel). * `destMailboxId` is the raw JMAP id on the destination server (not the * `accountId:mailboxId` namespace used for shared folders). * `destJmapAccountId` overrides the destination client's primary account * for the import — used when dropping into a delegated/shared mailbox that * is owned by a different JMAP account but accessed through the same * client (i.e. there is no separate connected client for the owner). */ crossAccountMoveEmails: ( emailIdsBySource: Map, destAccountId: string, destMailboxId: string, destJmapAccountId?: string, ) => Promise; searchEmails: (client: IJMAPClient, query: string) => Promise; advancedSearch: (client: IJMAPClient) => Promise; setSearchFilters: (filters: Partial) => void; clearSearchFilters: () => void; toggleAdvancedSearch: () => void; toggleStar: (client: IJMAPClient, emailId: string) => Promise; setEmailKeywordsLocal: (emailId: string, keywords: Record) => void; // Batch operations batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise; batchDelete: (client: IJMAPClient, permanent?: boolean) => Promise; batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise; batchArchive: (client: IJMAPClient) => Promise; // Spam operations spamUndoCache: Map; markAsSpam: (client: IJMAPClient, emailId: string) => Promise; undoSpam: (client: IJMAPClient, emailId: string) => Promise; batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise; batchUndoSpam: (client: IJMAPClient, emailIds: string[]) => Promise; // Push notification handlers setPushConnected: (connected: boolean) => void; handleStateChange: (change: StateChange, client: IJMAPClient) => Promise; refreshCurrentMailbox: (client: IJMAPClient) => Promise; handleNewEmailNotification: (email: Email) => void; clearNewEmailNotification: () => void; // Thread expansion actions toggleThreadExpansion: (threadId: string) => void; fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise; collapseAllThreads: () => void; updateThreadCache: (threadId: string, emails: Email[]) => void; // Mailbox management createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise; renameMailbox: (client: IJMAPClient, mailboxId: string, name: string) => Promise; deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise; setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise; emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise; markMailboxAsRead: (client: IJMAPClient, mailboxId: string) => Promise; // Unified mailbox operations fetchUnifiedEmails: (accounts: UnifiedAccountClient[], role: UnifiedMailboxRole) => Promise; loadMoreUnifiedEmails: (accounts: UnifiedAccountClient[]) => Promise; refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise; exitUnifiedView: () => void; fetchScheduledEmails: (client: IJMAPClient) => Promise; loadMoreScheduledEmails: (client: IJMAPClient) => Promise; cancelScheduledEmail: (client: IJMAPClient, submissionId: string, emailId?: string) => Promise; cancelScheduledEmailForEdit: (client: IJMAPClient, email: ScheduledEmail | Email) => Promise; rescheduleScheduledEmail: (client: IJMAPClient, submissionId: string, emailId: string, identityId: string, delayedUntil: string) => Promise; cancelUndoSend: (client: IJMAPClient, pending: PendingUndoSend) => Promise; clearPendingUndoSend: () => void; refreshScheduledMetadata: (client: IJMAPClient) => Promise; setScheduledView: (isScheduledView: boolean) => void; // Mock data for demo loadMockData: () => void; } // Helper: compute the next email to select when removing one from the list function getNextSelectedEmailAfterRemoval(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailIds: Set): Email | null { if (!state.selectedEmail || !removedEmailIds.has(state.selectedEmail.id)) { return state.selectedEmail; } const idx = state.emails.findIndex(e => e.id === state.selectedEmail?.id); if (idx === -1) return null; for (let nextIndex = idx + 1; nextIndex < state.emails.length; nextIndex++) { const candidate = state.emails[nextIndex]; if (!removedEmailIds.has(candidate.id)) { return candidate; } } for (let prevIndex = idx - 1; prevIndex >= 0; prevIndex--) { const candidate = state.emails[prevIndex]; if (!removedEmailIds.has(candidate.id)) { return candidate; } } return null; } function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailId: string): Email | null { return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId])); } function annotateScheduledEmails( emails: Email[], scheduledSubmissionByEmailId: Map ): Email[] { if (scheduledSubmissionByEmailId.size === 0) return emails; return emails.map(email => annotateScheduledEmail(email, scheduledSubmissionByEmailId)); } function annotateScheduledEmail( email: Email, scheduledSubmissionByEmailId: Map ): Email { const scheduled = scheduledSubmissionByEmailId.get(email.id); if (!scheduled) return email; return { ...email, scheduledSendAt: scheduled.sendAt, emailSubmissionId: scheduled.submissionId, scheduledIdentityId: scheduled.identityId, scheduledUndoStatus: scheduled.undoStatus, isScheduled: true, }; } function shouldClearPendingUndoSend(pending: PendingUndoSend | null, scheduledEmails: ScheduledEmail[]): boolean { if (!pending) return false; const pendingSendTime = new Date(pending.sendAt).getTime(); if (Number.isFinite(pendingSendTime) && pendingSendTime <= Date.now()) return true; const scheduledEmail = scheduledEmails.find(email => email.emailSubmissionId === pending.submissionId); return scheduledEmail?.scheduledUndoStatus !== undefined && scheduledEmail.scheduledUndoStatus !== 'pending'; } /** * When the mail view is showing a non-active account (Pro shell's * Thunderbird-style sidebar), redirect read/write operations to that * account's JMAP client and mailbox cache. Returns the passed-in values * unchanged for the standard single-account flow. * * Compose/send still routes through the caller's client (the active * account), since identity binding for cross-account sending is a separate * concern. */ function resolveActionClient(passedClient: IJMAPClient): IJMAPClient { const viewingId = useEmailStore.getState().viewingAccountId; if (!viewingId) return passedClient; const c = useAuthStore.getState().getClientForAccount(viewingId); return c ?? passedClient; } function resolveActionMailboxes(): Mailbox[] { const state = useEmailStore.getState(); if (state.viewingAccountId) { return state.accountMailboxes[state.viewingAccountId] ?? state.mailboxes; } return state.mailboxes; } /** * 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. * * When `includeGroup` is true, also emits one synthetic entry per shared * owner account reachable through each logged-in client. The shared entries * are flagged with `isShared: true` so `lib/unified-mailbox.ts` routes JMAP * requests via `originalId` + owner accountId. */ export async function buildUnifiedAccountClients( opts: { includeGroup?: boolean } = {}, ): Promise { const { includeGroup = false } = opts; 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 = includeGroup ? await c.getAllMailboxes() : await c.getMailboxes(); const ownMailboxes = includeGroup ? mailboxes.filter((m) => !m.isShared) : mailboxes; built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, isShared: false }); if (includeGroup) { const sharedByOwner = new Map(); for (const m of mailboxes) { if (!m.isShared || !m.accountId || m.accountId === a.id) continue; const list = sharedByOwner.get(m.accountId) ?? []; list.push(m); sharedByOwner.set(m.accountId, list); } for (const [ownerId, ownerMailboxes] of sharedByOwner) { const label = ownerMailboxes.find((m) => m.accountName)?.accountName || ownerId; built.push({ accountId: ownerId, accountLabel: label, client: c, mailboxes: ownerMailboxes, isShared: true, }); } } } catch { /* skip account on mailbox fetch failure */ } } return built; } /** * After a mailbox-list mutation (create/rename/delete/etc.), refresh the * cache for whichever account we're operating on. Writes the result to the * standard `mailboxes` slot for the active account, or the per-account * cache for non-active accounts so the Pro sidebar stays in sync. */ async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise { const viewingId = useEmailStore.getState().viewingAccountId; const client = resolveActionClient(fallbackClient); try { const mailboxes = await client.getMailboxes(); if (viewingId) { useEmailStore.setState((state) => ({ accountMailboxes: { ...state.accountMailboxes, [viewingId]: mailboxes }, })); } else { useEmailStore.setState({ mailboxes }); } } catch (error) { console.error('Failed to refresh mailboxes after mutation:', error); } } // Find the trash mailbox for a given account scope. Prefers JMAP role, but // falls back to name matching ("trash" / "deleted") so users with custom or // pre-existing folders (e.g. "Deleted Items") aren't silently destroyed. function findTrashMailbox( mailboxes: Mailbox[], scope: { accountId?: string; isShared?: boolean } ): Mailbox | undefined { const matchesScope = (mb: Mailbox): boolean => { if (scope.accountId) return mb.accountId === scope.accountId; return !mb.isShared; }; const byRole = mailboxes.find(mb => mb.role === 'trash' && matchesScope(mb)); if (byRole) return byRole; return mailboxes.find(mb => { if (!matchesScope(mb)) return false; const lower = mb.name.toLowerCase(); return lower.includes('trash') || lower.includes('deleted'); }); } export const useEmailStore = create((set, get) => ({ emails: [], mailboxes: [], accountMailboxes: {}, viewingAccountId: null, selectedEmail: null, selectedMailbox: "", isLoading: false, isLoadingEmail: false, isLoadingMore: false, error: null, searchQuery: "", quota: null, processingReadStatus: new Set(), selectedEmailIds: new Set(), lastSelectedEmailId: null, hasMoreEmails: false, totalEmails: 0, isPushConnected: false, lastPushUpdate: null, newEmailNotification: null, // Thread expansion state expandedThreadIds: new Set(), threadEmailsCache: new Map(), isLoadingThread: null, // Keyword/tag filter selectedKeyword: null, tagCounts: {}, // Advanced search state searchFilters: { ...DEFAULT_SEARCH_FILTERS }, isAdvancedSearchOpen: false, searchAbortController: null, externalSearchResults: [], // Unified mailbox state isUnifiedView: false, unifiedRole: null, unifiedErrors: new Map(), unifiedCounts: [], // Scheduled send state scheduledEmails: [], scheduledEmailIds: new Set(), scheduledSubmissionByEmailId: new Map(), scheduledTotal: 0, scheduledHasMore: false, scheduledNextPosition: 0, isLoadingScheduled: false, isScheduledView: false, pendingUndoSend: null, // Spam undo cache spamUndoCache: new Map(), setEmails: (emails) => set({ emails }), setMailboxes: (mailboxes) => set({ mailboxes }), setAccountMailboxes: (accountId, mailboxes) => set((state) => ({ accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes }, })), clearAccountMailboxes: () => set({ accountMailboxes: {} }), setViewingAccount: (accountId) => set({ viewingAccountId: accountId }), selectAccountMailbox: (accountId, mailboxId) => set({ viewingAccountId: accountId, selectedMailbox: mailboxId, selectedEmail: null, selectedEmailIds: new Set(), selectedKeyword: null, expandedThreadIds: new Set(), threadEmailsCache: new Map(), isLoadingThread: null, }), fetchAccountMailboxes: async (client, accountId) => { try { const mailboxes = await client.getMailboxes(); // Re-check the cache after the await to avoid stomping a more recent // fetch that finished while this one was in flight. set((state) => ({ accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes }, })); } catch (error) { console.error(`Failed to fetch mailboxes for account ${accountId}:`, error); } }, selectEmail: (email) => { const prev = get().selectedEmail; set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId }); if (prev && (!email || email.id !== prev.id)) { emailHooks.onEmailClose.emitSync(prev); } if (email && (!prev || email.id !== prev.id)) { emailHooks.onEmailOpen.emitSync(email); } }, selectKeyword: (keyword) => set({ selectedKeyword: keyword, selectedEmail: null, selectedEmailIds: new Set(), expandedThreadIds: new Set(), threadEmailsCache: new Map(), }), fetchTagCounts: async (client) => { try { const keywords = useSettingsStore.getState().emailKeywords; if (keywords.length === 0) { set({ tagCounts: {} }); return; } const tagIds = keywords.map(k => k.id); const counts = await resolveActionClient(client).getTagCounts(tagIds); set({ tagCounts: counts }); } catch (error) { console.error('Failed to fetch tag counts:', error); } }, selectMailbox: (mailboxId) => set({ selectedMailbox: mailboxId, selectedEmail: null, selectedEmailIds: new Set(), selectedKeyword: null, expandedThreadIds: new Set(), threadEmailsCache: new Map(), isLoadingThread: null, }), setLoading: (loading) => set({ isLoading: loading }), setLoadingEmail: (loading) => set({ isLoadingEmail: loading }), setError: (error) => set({ error }), setSearchQuery: (query) => set({ searchQuery: query }), setQuota: (quota) => set({ quota }), toggleEmailSelection: (emailId) => { const { selectedEmailIds } = get(); const newSelection = new Set(selectedEmailIds); if (newSelection.has(emailId)) { newSelection.delete(emailId); } else { newSelection.add(emailId); } set({ selectedEmailIds: newSelection, lastSelectedEmailId: emailId }); }, selectRangeEmails: (targetEmailId) => { const { emails, lastSelectedEmailId, selectedEmailIds } = get(); const anchorId = lastSelectedEmailId || emails[0]?.id; if (!anchorId) return; const anchorIndex = emails.findIndex(e => e.id === anchorId); const targetIndex = emails.findIndex(e => e.id === targetEmailId); if (anchorIndex === -1 || targetIndex === -1) return; const start = Math.min(anchorIndex, targetIndex); const end = Math.max(anchorIndex, targetIndex); const newSelection = new Set(selectedEmailIds); for (let i = start; i <= end; i++) { newSelection.add(emails[i].id); } set({ selectedEmailIds: newSelection }); }, selectAllEmails: () => { const { emails } = get(); const allIds = new Set(emails.map(e => e.id)); set({ selectedEmailIds: allIds }); }, clearSelection: () => { set({ selectedEmailIds: new Set(), lastSelectedEmailId: null }); }, // JMAP operations fetchMailboxes: async (client) => { // Only toggle the email list's isLoading on the initial load. Background // refreshes (after a move/archive that may have created new folders) must // not flash the list's loading state, which hides the results-count bar // and dims the list while folders re-fetch. const isInitialLoad = get().mailboxes.length === 0; if (isInitialLoad) set({ isLoading: true, error: null }); try { const mailboxes = await client.getAllMailboxes(); // Auto-select inbox if no mailbox is selected or the current selection // doesn't exist in the fetched list (e.g. after an account switch) const currentSelectedMailbox = get().selectedMailbox; const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID || (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox)); const loadingPatch = isInitialLoad ? { isLoading: false } : {}; if (!selectionValid) { // Find inbox from PRIMARY account (not shared accounts) const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && !m.isShared); if (inboxMailbox) { set({ mailboxes, selectedMailbox: inboxMailbox.id, ...loadingPatch }); } else { set({ mailboxes, selectedMailbox: '', ...loadingPatch }); } } else { set({ mailboxes, ...loadingPatch }); } } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to fetch mailboxes", ...(isInitialLoad ? { isLoading: false } : {}) }); } }, prefetchInitialData: async (client) => { // Coalesce overlapping callers (e.g. login() and a slow home-page useEffect // racing for the same fetch). The promise is stashed on the client so we // don't need a separate keyed map and stale entries can't outlive the client. const target = client as IJMAPClient & { __prefetchPromise?: Promise }; if (target.__prefetchPromise) return target.__prefetchPromise; target.__prefetchPromise = (async () => { try { await Promise.all([ get().fetchMailboxes(client), get().fetchQuota(client), ]); const { selectedMailbox } = get(); if (selectedMailbox) { await get().fetchEmails(client, selectedMailbox); } else { await get().fetchEmails(client); } // Tag counts can finish whenever; don't block the prefetch on them. void get().fetchTagCounts(client); } finally { delete target.__prefetchPromise; } })(); return target.__prefetchPromise; }, fetchEmails: async (client, mailboxId) => { set({ isLoading: true, error: null }); // Keep previous emails visible during transition try { const targetMailboxId = mailboxId || get().selectedMailbox; if (targetMailboxId === VIRTUAL_SCHEDULED_MAILBOX_ID) { set({ isLoading: false, emails: [], hasMoreEmails: false, totalEmails: 0 }); await get().fetchScheduledEmails(client); return; } const effectiveClient = resolveActionClient(client); // Find the mailbox to get its accountId (for shared folder support) const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === targetMailboxId); // 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 || targetMailboxId; // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; // Build keyword filter if a tag is selected const { selectedKeyword } = get(); const keywordFilter = selectedKeyword ? `$label:${selectedKeyword}` : undefined; // When filtering by tag, omit the mailbox constraint so emails across // all folders that carry the tag are returned. const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), hasMoreEmails: result.hasMore, totalEmails: result.total, isLoading: false }); } catch (error) { console.error('Failed to fetch emails:', error); set({ error: error instanceof Error ? error.message : "Failed to fetch emails", isLoading: false, emails: [], hasMoreEmails: false, totalEmails: 0 }); } }, loadMoreEmails: async (client) => { const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword, isUnifiedView, unifiedRole } = get(); // Don't load if already loading or no more emails if (isLoadingMore || !hasMoreEmails) return; // Unified view uses a different fan-out loader. When a search query or // 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) { set({ isLoadingMore: true, error: null }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const includeGroup = useSettingsStore.getState().includeGroupInUnified; const position = emails.length; const built = await buildUnifiedAccountClients({ includeGroup }); const { searchFilters } = get(); const hasFilters = !isFilterEmpty(searchFilters); const result = hasFilters ? await advancedSearchUnifiedEmails( built, unifiedRole, (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId), emailsPerPage, position, ) : searchQuery ? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, position) : await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position); const currentEmails = get().emails; const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id)); set({ emails: [...currentEmails, ...newEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false, unifiedErrors: result.errors, }); } catch (error) { console.error('Failed to load more unified emails:', error); set({ error: error instanceof Error ? error.message : "Failed to load more emails", isLoadingMore: false, }); } return; } set({ isLoadingMore: true, error: null }); try { if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) { set({ isLoadingMore: false }); await get().loadMoreScheduledEmails(client); return; } const effectiveClient = resolveActionClient(client); // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; // Capture position from current email count before the async call const position = emails.length; let result; const { searchFilters } = get(); const hasFilters = !isFilterEmpty(searchFilters); if (searchQuery || hasFilters) { const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const jmapMailboxId = mailbox?.originalId || selectedMailbox; const accountId = mailbox?.isShared ? mailbox.accountId : undefined; if (hasFilters) { const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, position); } else { result = await effectiveClient.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position); } } else { // Load more from mailbox // Find the mailbox to get its accountId (for shared folder support) const mailboxes = resolveActionMailboxes(); 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; // When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails). result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined); } // Use fresh state when merging to avoid overwriting concurrent updates // (e.g. refreshCurrentMailbox running during the load) const currentEmails = get().emails; // Deduplicate: the server may return overlapping results if new emails // arrived between paginated requests and shifted positions. const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id)); set({ emails: [...currentEmails, ...newEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false }); } catch (error) { console.error('Failed to load more emails:', error); set({ error: error instanceof Error ? error.message : "Failed to load more emails", isLoadingMore: false }); } }, fetchEmailContent: async (client, emailId) => { try { // Find the selected mailbox to determine accountId (for shared folders) const selectedMailboxId = get().selectedMailbox; const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId); // Only pass accountId for shared mailboxes const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const email = await resolveActionClient(client).getEmail(emailId, accountId); if (email) { const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId); set({ selectedEmail: annotatedEmail }); return annotatedEmail; } return email; } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to fetch email content" }); return null; } }, fetchQuota: async (client) => { try { const quota = await resolveActionClient(client).getQuota(); set({ quota }); } catch { // Don't set error state as quota is optional } }, sendEmail: async (client, to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options) => { set({ isLoading: true, error: null }); try { const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options); // Refresh handled by UI layer for immediate feedback set({ isLoading: false, pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt ? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: false } : get().pendingUndoSend, }); return result; } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to send email", isLoading: false }); throw error; } }, sendRawEmail: async (client, rawMimeBlob, identityId, delayedUntil, envelopeRecipients) => { set({ isLoading: true, error: null }); try { const mailboxes = await client.getMailboxes(); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (!sentMailbox) throw new Error('No sent mailbox found'); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients); set({ isLoading: false, pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt ? { submissionId: result.emailSubmissionId, emailId: result.emailId, sendAt: result.sendAt, isSmime: true } : get().pendingUndoSend, }); return result; } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to send email", isLoading: false, }); throw error; } }, deleteEmail: async (client, emailId, forceDelete) => { try { // Get the email to check if it's unread and which mailboxes it belongs to const email = get().emails.find(e => e.id === emailId); if (!email) return; const isUnread = !email.keywords?.$seen; const effectiveClient = resolveActionClient(client); // Get delete action preference from settings const deleteAction = useSettingsStore.getState().deleteAction; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; // Determine accountId for shared folders const selectedMailboxId = get().selectedMailbox; const mailboxes = resolveActionMailboxes(); const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; // If in junk folder and setting is enabled, permanently delete const isInJunk = currentMailbox?.role === 'junk'; if (isInJunk && permanentlyDeleteJunk) { forceDelete = true; } // If deleteAction is 'trash' or 'trash-and-read' and not forced permanent delete, try to move to trash mailbox if ((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete) { const trashMailbox = findTrashMailbox(mailboxes, { accountId }); const alsoMarkRead = deleteAction === 'trash-and-read' && isUnread; if (trashMailbox) { // Use originalId for shared mailboxes if available const trashId = trashMailbox.originalId || trashMailbox.id; await effectiveClient.moveToTrash(emailId, trashId, accountId, alsoMarkRead); // After marking read in the same request, the email arrives in trash as read. const arrivesUnread = isUnread && !alsoMarkRead; // Remove from local state (email moved to trash, not in current view) set((state) => { let updatedMailboxes = state.mailboxes; // Update counters for source mailbox (email leaving) if (email.mailboxIds) { updatedMailboxes = state.mailboxes.map(mailbox => { if (email.mailboxIds[mailbox.id]) { return { ...mailbox, totalEmails: Math.max(0, mailbox.totalEmails - 1), unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails, totalThreads: Math.max(0, mailbox.totalThreads - 1), unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads }; } // Update trash mailbox counters (email arriving) if (mailbox.id === trashMailbox.id) { return { ...mailbox, totalEmails: mailbox.totalEmails + 1, unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, totalThreads: mailbox.totalThreads + 1, unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads }; } return mailbox; }); } return { emails: state.emails.filter(e => e.id !== emailId), selectedEmail: getNextSelectedEmail(state, emailId), mailboxes: updatedMailboxes }; }); return; } // No trash folder found in this account. Surface the failure rather // than silently destroying the email - the user asked to move it to // trash, not to permanently delete it. throw new Error('Trash mailbox not found - cannot move email to trash'); } // Permanent delete await effectiveClient.deleteEmail(emailId); // Remove from local state and update mailbox counters if needed set((state) => { let updatedMailboxes = state.mailboxes; // If the email was unread, decrement the unread counters if (isUnread && email.mailboxIds) { updatedMailboxes = state.mailboxes.map(mailbox => { if (email.mailboxIds[mailbox.id]) { return { ...mailbox, totalEmails: Math.max(0, mailbox.totalEmails - 1), unreadEmails: Math.max(0, mailbox.unreadEmails - 1), totalThreads: Math.max(0, mailbox.totalThreads - 1), unreadThreads: Math.max(0, mailbox.unreadThreads - 1) }; } return mailbox; }); } else if (email.mailboxIds) { // If email was read, only decrement total counters updatedMailboxes = state.mailboxes.map(mailbox => { if (email.mailboxIds[mailbox.id]) { return { ...mailbox, totalEmails: Math.max(0, mailbox.totalEmails - 1), totalThreads: Math.max(0, mailbox.totalThreads - 1) }; } return mailbox; }); } return { emails: state.emails.filter(e => e.id !== emailId), selectedEmail: getNextSelectedEmail(state, emailId), mailboxes: updatedMailboxes }; }); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to delete email" }); throw error; } }, markAsRead: async (client, emailId, read) => { try { // Check if this email is already being processed const processingKey = `${emailId}-${read}`; const currentProcessing = get().processingReadStatus; if (currentProcessing.has(processingKey)) { return; // Already being processed } // Get the email to check its current state and mailboxes const email = get().emails.find(e => e.id === emailId); if (!email) return; // Check if already in the desired state const isCurrentlyRead = email.keywords?.$seen === true; if (isCurrentlyRead === read) { return; // Already in desired state } // Add to processing set set((state) => ({ processingReadStatus: new Set([...state.processingReadStatus, processingKey]) })); // Determine accountId for shared folders const selectedMailboxId = get().selectedMailbox; const mailboxes = resolveActionMailboxes(); const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; await resolveActionClient(client).markAsRead(emailId, read, accountId); // Update local state including mailbox counters set((state) => { // Remove from processing set const newProcessingSet = new Set(state.processingReadStatus); newProcessingSet.delete(processingKey); // Only update counters if the state is actually changing const emailInState = state.emails.find(e => e.id === emailId); if (!emailInState) return { processingReadStatus: newProcessingSet }; const wasRead = emailInState.keywords?.$seen === true; if (wasRead === read) { return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update } const updatedMailboxes = state.mailboxes.map(mailbox => { // Check if this email belongs to this mailbox if (emailInState.mailboxIds && emailInState.mailboxIds[mailbox.id]) { // Adjust unread counter: -1 if marking as read, +1 if marking as unread const delta = read ? -1 : 1; return { ...mailbox, unreadEmails: Math.max(0, mailbox.unreadEmails + delta), unreadThreads: Math.max(0, mailbox.unreadThreads + delta) }; } return mailbox; }); return { emails: state.emails.map(e => e.id === emailId ? { ...e, keywords: { ...e.keywords, $seen: read } } : e ), selectedEmail: state.selectedEmail?.id === emailId ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } } : state.selectedEmail, mailboxes: updatedMailboxes, processingReadStatus: newProcessingSet }; }); } catch (error) { // Remove from processing set on error set((state) => { const newProcessingSet = new Set(state.processingReadStatus); newProcessingSet.delete(`${emailId}-${read}`); return { processingReadStatus: newProcessingSet, error: error instanceof Error ? error.message : "Failed to update email" }; }); throw error; } }, moveToMailbox: async (client, emailId, destinationMailboxId) => { try { const email = get().emails.find(e => e.id === emailId); if (!email) return; const isUnread = !email.keywords?.$seen; const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : []; const { selectedMailbox } = get(); const mailboxes = resolveActionMailboxes(); const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; await resolveActionClient(client).moveEmail(emailId, jmapDestId, accountId); set((state) => { const updatedMailboxes = state.mailboxes.map(mailbox => { if (currentMailboxIds.includes(mailbox.id)) { return { ...mailbox, totalEmails: Math.max(0, mailbox.totalEmails - 1), unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails, totalThreads: Math.max(0, mailbox.totalThreads - 1), unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads }; } if (mailbox.id === destinationMailboxId) { return { ...mailbox, totalEmails: mailbox.totalEmails + 1, unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails, totalThreads: mailbox.totalThreads + 1, unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads }; } return mailbox; }); return { emails: state.emails.filter(e => e.id !== emailId), selectedEmail: getNextSelectedEmail(state, emailId), mailboxes: updatedMailboxes }; }); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to move email" }); throw error; } }, moveEmailsToMailbox: async (client, emailIds, destinationMailboxId) => { if (emailIds.length === 0) return; if (emailIds.length === 1) { await get().moveToMailbox(client, emailIds[0], destinationMailboxId); return; } try { const { emails, selectedMailbox, isUnifiedView } = get(); const mailboxes = resolveActionMailboxes(); const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; const idSet = new Set(emailIds); const affected = emails.filter(e => idSet.has(e.id)); if (isUnifiedView) { // In unified view, emails may span accounts – group and dispatch per-account. const byAccount = new Map(); for (const e of affected) { const acct = e.accountId || '__default__'; if (!byAccount.has(acct)) byAccount.set(acct, []); byAccount.get(acct)!.push(e.id); } await Promise.all(Array.from(byAccount.entries()).map(async ([acct, ids]) => { const acctClient = acct === '__default__' ? client : useAuthStore.getState().getClientForAccount(acct); if (!acctClient) return; await acctClient.batchMoveEmails(ids, jmapDestId); })); } else { const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; await resolveActionClient(client).batchMoveEmails(emailIds, jmapDestId, accountId); } // Adjust counters and drop moved emails from the current view. let unreadDelta = 0; const sourceMailboxIds = new Set(); for (const e of affected) { if (!e.keywords?.$seen) unreadDelta += 1; if (e.mailboxIds) for (const mid of Object.keys(e.mailboxIds)) sourceMailboxIds.add(mid); } const movedCount = affected.length; set((state) => ({ emails: state.emails.filter(e => !idSet.has(e.id)), selectedEmail: state.selectedEmail && idSet.has(state.selectedEmail.id) ? null : state.selectedEmail, selectedEmailIds: (() => { const next = new Set(state.selectedEmailIds); for (const id of idSet) next.delete(id); return next; })(), mailboxes: state.mailboxes.map(mb => { if (sourceMailboxIds.has(mb.id)) { return { ...mb, totalEmails: Math.max(0, mb.totalEmails - movedCount), unreadEmails: Math.max(0, mb.unreadEmails - unreadDelta), }; } if (mb.id === destinationMailboxId) { return { ...mb, totalEmails: mb.totalEmails + movedCount, unreadEmails: mb.unreadEmails + unreadDelta, }; } return mb; }), })); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to move emails' }); throw error; } }, crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId) => { if (emailIdsBySource.size === 0) return; set({ isLoading: true, error: null }); try { const destClient = useAuthStore.getState().getClientForAccount(destAccountId); if (!destClient) { throw new Error('Destination account is not connected'); } const movedIds: string[] = []; const failures: Array<{ emailId: string; error: string }> = []; for (const [sourceAccountId, emailIds] of emailIdsBySource.entries()) { const sourceClient = useAuthStore.getState().getClientForAccount(sourceAccountId); if (!sourceClient) { for (const emailId of emailIds) { failures.push({ emailId, error: 'Source account not connected' }); } continue; } // Fan the per-email copy/import/delete pipeline out in parallel. // JMAP has no atomic cross-account move, so we accept that a crash // mid-flight could leave a duplicate; the delete on success keeps // the source clean in the happy path. const results = await Promise.allSettled( emailIds.map(async (emailId) => { const full = await sourceClient.getEmail(emailId); if (!full?.blobId) { throw new Error('Source email has no raw blob to copy'); } const blob = await sourceClient.fetchBlob(full.blobId); const keywords: Record = { ...(full.keywords ?? {}) }; await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId); await sourceClient.deleteEmail(emailId); return emailId; }), ); results.forEach((outcome, i) => { const emailId = emailIds[i]; if (outcome.status === 'fulfilled') { movedIds.push(emailId); } else { const err = outcome.reason; failures.push({ emailId, error: err instanceof Error ? err.message : String(err), }); } }); } // Drop the moved emails from the current view and clear stale selection // entries. Counter accuracy comes from the mailbox refresh below. const movedSet = new Set(movedIds); set((state) => ({ emails: state.emails.filter((e) => !movedSet.has(e.id)), selectedEmail: state.selectedEmail && movedSet.has(state.selectedEmail.id) ? null : state.selectedEmail, selectedEmailIds: (() => { const next = new Set(state.selectedEmailIds); for (const id of movedIds) next.delete(id); return next; })(), isLoading: false, })); // Refresh mailbox folder lists/counters for every account we touched. // Background-only so the move feels instant - counters will catch up. const activeAccountId = useAuthStore.getState().activeAccountId; const touched = new Set([destAccountId, ...emailIdsBySource.keys()]); for (const acctId of touched) { const c = useAuthStore.getState().getClientForAccount(acctId); if (!c) continue; if (acctId === activeAccountId) { void get().fetchMailboxes(c); } else { void get().fetchAccountMailboxes(c, acctId); } } if (failures.length > 0) { const first = failures[0]; throw new Error( failures.length === 1 ? `Failed to move email: ${first.error}` : `Failed to move ${failures.length} email(s); first error: ${first.error}`, ); } } catch (error) { set({ isLoading: false, error: error instanceof Error ? error.message : 'Failed to move emails between accounts', }); throw error; } }, moveThreadToMailbox: async (client, emailId, destinationMailboxId) => { try { const state = get(); const email = state.emails.find(e => e.id === emailId) ?? (state.selectedEmail?.id === emailId ? state.selectedEmail : null); if (!email?.threadId) { await get().moveToMailbox(client, emailId, destinationMailboxId); return; } const mailboxes = resolveActionMailboxes(); const effectiveClient = resolveActionClient(client); const currentMailbox = mailboxes.find(mb => mb.id === state.selectedMailbox); const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; const thread = await effectiveClient.getThread(email.threadId, accountId); const threadEmailIds = thread?.emailIds?.length ? thread.emailIds : [emailId]; if (threadEmailIds.length <= 1) { await get().moveToMailbox(client, emailId, destinationMailboxId); return; } await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId); const removedEmailIds = new Set(threadEmailIds); set((currentState) => { const nextSelectedEmail = getNextSelectedEmailAfterRemoval(currentState, removedEmailIds); const nextSelectedEmailIds = new Set( Array.from(currentState.selectedEmailIds).filter(id => !removedEmailIds.has(id)) ); const nextExpandedThreadIds = new Set(currentState.expandedThreadIds); nextExpandedThreadIds.delete(email.threadId); const nextThreadEmailsCache = new Map(currentState.threadEmailsCache); nextThreadEmailsCache.delete(email.threadId); return { emails: currentState.emails.filter(currentEmail => !removedEmailIds.has(currentEmail.id)), selectedEmail: nextSelectedEmail, selectedEmailIds: nextSelectedEmailIds, expandedThreadIds: nextExpandedThreadIds, threadEmailsCache: nextThreadEmailsCache, }; }); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to move email thread" }); throw error; } }, searchEmails: async (client, query) => { set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state try { const { isUnifiedView, unifiedRole } = get(); const emailsPerPage = useSettingsStore.getState().emailsPerPage; if (isUnifiedView && unifiedRole) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); 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 const selectedMailbox = get().selectedMailbox; const mailboxes = resolveActionMailboxes(); 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; const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), externalSearchResults: externals, hasMoreEmails: result.hasMore, totalEmails: result.total, isLoading: false }); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to search emails", isLoading: false, emails: [], externalSearchResults: [], hasMoreEmails: false, totalEmails: 0 }); } }, advancedSearch: async (client) => { const { searchQuery, searchFilters, selectedMailbox, searchAbortController, isUnifiedView, unifiedRole } = get(); const mailboxes = resolveActionMailboxes(); if (searchAbortController) { searchAbortController.abort(); } const controller = new AbortController(); set({ isLoading: true, error: null, emails: [], hasMoreEmails: false, totalEmails: 0, searchAbortController: controller, }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; if (isUnifiedView && unifiedRole) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); 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 jmapMailboxId = mailbox?.originalId || selectedMailbox; const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); if (controller.signal.aborted) return; const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters }); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), externalSearchResults: externals, hasMoreEmails: result.hasMore, totalEmails: result.total, isLoading: false, searchAbortController: null, }); } catch (error) { if (controller.signal.aborted) return; set({ error: error instanceof Error ? error.message : "Failed to search emails", isLoading: false, emails: [], externalSearchResults: [], hasMoreEmails: false, totalEmails: 0, searchAbortController: null, }); } }, setSearchFilters: (filters) => { set((state) => ({ searchFilters: { ...state.searchFilters, ...filters }, })); }, clearSearchFilters: () => { set({ searchFilters: { ...DEFAULT_SEARCH_FILTERS } }); }, toggleAdvancedSearch: () => { set((state) => ({ isAdvancedSearchOpen: !state.isAdvancedSearchOpen })); }, toggleStar: async (client, emailId) => { try { const email = get().emails.find(e => e.id === emailId); if (!email) return; const isFlagged = email.keywords.$flagged || false; await resolveActionClient(client).toggleStar(emailId, !isFlagged); // Update local state set((state) => ({ emails: state.emails.map(e => e.id === emailId ? { ...e, keywords: { ...e.keywords, $flagged: !isFlagged } } : e ), selectedEmail: state.selectedEmail?.id === emailId ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $flagged: !isFlagged } } : state.selectedEmail })); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to update star" }); throw error; } }, setEmailKeywordsLocal: (emailId, keywords) => { set((state) => ({ emails: state.emails.map(e => e.id === emailId ? { ...e, keywords: { ...keywords } } : e ), selectedEmail: state.selectedEmail?.id === emailId ? { ...state.selectedEmail, keywords: { ...keywords } } : state.selectedEmail, })); }, // Batch operations batchMarkAsRead: async (client, read) => { const { selectedEmailIds, emails } = get(); const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); try { const emailIdsArray = Array.from(selectedEmailIds); if (get().isUnifiedView) { // Group emails by accountId for cross-account operations const emailsByAccount = new Map(); for (const emailId of emailIdsArray) { const email = emails.find(e => e.id === emailId); const acctId = email?.accountId || '__default__'; if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []); emailsByAccount.get(acctId)!.push(emailId); } const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => { const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId); if (!acctClient) return; await acctClient.batchMarkAsRead(ids, read); }); await Promise.allSettled(promises); } else { await resolveActionClient(client).batchMarkAsRead(emailIdsArray, read); } // Update local state const updatedEmails = emails.map(email => selectedEmailIds.has(email.id) ? { ...email, keywords: { ...email.keywords, $seen: read } } : email ); // Update mailbox counters const affectedEmails = emails.filter(e => selectedEmailIds.has(e.id)); const updatedMailboxes = mailboxes.map(mailbox => { let deltaUnread = 0; affectedEmails.forEach(email => { if (email.mailboxIds?.[mailbox.id]) { const wasRead = email.keywords?.$seen === true; if (wasRead !== read) { deltaUnread += read ? -1 : 1; } } }); return { ...mailbox, unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnread), unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread) }; }); set({ emails: updatedEmails, mailboxes: updatedMailboxes, selectedEmailIds: new Set(), isLoading: false }); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to update emails", isLoading: false }); } }, batchDelete: async (client, permanent = false) => { const { selectedEmailIds, emails, selectedMailbox } = get(); const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); try { const emailIdsArray = Array.from(selectedEmailIds); // Determine if the current folder forces permanent deletion. const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const isInTrash = currentMailbox?.role === 'trash'; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; const isInJunk = currentMailbox?.role === 'junk'; const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk); const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read'; // Group emails by accountId (handles unified view and search results spanning accounts). const emailsByAccount = new Map(); for (const emailId of emailIdsArray) { const email = emails.find(e => e.id === emailId); const acctId = email?.accountId || '__default__'; if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []); emailsByAccount.get(acctId)!.push(emailId); } const getClient = (acctId: string) => acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId); if (forceDestroy) { const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => { const acctClient = getClient(acctId); if (!acctClient) return; await acctClient.batchDeleteEmails(ids); }); await Promise.allSettled(promises); } else { // Move to trash per account. const failedAccounts: string[] = []; const movedEmailIds = new Set(); const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => { const acctClient = getClient(acctId); if (!acctClient) { failedAccounts.push(acctId); return; } const trashMailbox = findTrashMailbox(mailboxes, { accountId: acctId === '__default__' ? undefined : acctId, }); if (!trashMailbox) { // No trash for this account: skip rather than silently destroying. // The user asked to move to trash, not permanently delete. failedAccounts.push(acctId); return; } const trashId = trashMailbox.originalId || trashMailbox.id; await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId, alsoMarkRead); ids.forEach(id => movedEmailIds.add(id)); }); await Promise.allSettled(promises); if (failedAccounts.length > 0 && movedEmailIds.size === 0) { // Nothing moved - bail out so the UI doesn't drop the emails from view. throw new Error('Trash mailbox not found - cannot move emails to trash'); } // Only remove successfully moved emails from local state. if (movedEmailIds.size < emailIdsArray.length) { const deletedEmails = emails.filter(e => movedEmailIds.has(e.id)); const remainingEmails = emails.filter(e => !movedEmailIds.has(e.id)); const updatedMailboxes = mailboxes.map(mailbox => { let deltaTotalEmails = 0; let deltaUnreadEmails = 0; deletedEmails.forEach(email => { if (email.mailboxIds?.[mailbox.id]) { deltaTotalEmails--; if (!email.keywords?.$seen) deltaUnreadEmails--; } }); return { ...mailbox, totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails), unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails), totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails), unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails), }; }); set({ emails: remainingEmails, mailboxes: updatedMailboxes, selectedEmailIds: new Set(), selectedEmail: null, isLoading: false, error: 'Some emails could not be moved: trash folder missing for one or more accounts', }); return; } } // Remove deleted emails from local state const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id)); // Update mailbox counters const deletedEmails = emails.filter(e => selectedEmailIds.has(e.id)); const updatedMailboxes = mailboxes.map(mailbox => { let deltaTotalEmails = 0; let deltaUnreadEmails = 0; deletedEmails.forEach(email => { if (email.mailboxIds?.[mailbox.id]) { deltaTotalEmails--; if (!email.keywords?.$seen) { deltaUnreadEmails--; } } }); return { ...mailbox, totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails), unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails), totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails), unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails) }; }); set({ emails: remainingEmails, mailboxes: updatedMailboxes, selectedEmailIds: new Set(), selectedEmail: null, isLoading: false }); } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to delete emails", isLoading: false }); } }, batchMoveToMailbox: async (client, toMailboxId) => { const { selectedEmailIds, emails } = get(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); try { const emailIdsArray = Array.from(selectedEmailIds); if (get().isUnifiedView) { // Group emails by accountId for cross-account operations const emailsByAccount = new Map(); for (const emailId of emailIdsArray) { const email = emails.find(e => e.id === emailId); const acctId = email?.accountId || '__default__'; if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []); emailsByAccount.get(acctId)!.push(emailId); } const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => { const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId); if (!acctClient) return; await acctClient.batchMoveEmails(ids, toMailboxId); }); await Promise.allSettled(promises); } else { await resolveActionClient(client).batchMoveEmails(emailIdsArray, toMailboxId); } // Update local state - remove from current view since they moved const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id)); set({ emails: remainingEmails, selectedEmailIds: new Set(), isLoading: false }); // Refresh emails to get updated list (honors active search/filters) if (!get().isUnifiedView) { await get().refreshCurrentMailbox(client); } } catch (error) { set({ error: error instanceof Error ? error.message : "Failed to move emails", isLoading: false }); } }, batchArchive: async (client) => { const { selectedEmailIds, emails } = get(); const mailboxes = resolveActionMailboxes(); if (selectedEmailIds.size === 0) return; const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive'); if (!archiveMailbox) return; const mode = useSettingsStore.getState().archiveMode; const archiveId = archiveMailbox.originalId || archiveMailbox.id; const selected = emails.filter(e => selectedEmailIds.has(e.id)); if (selected.length === 0) return; set({ isLoading: true, error: null }); try { await resolveActionClient(client).batchArchiveEmails( selected.map(e => ({ id: e.id, receivedAt: e.receivedAt })), archiveId, mode, mailboxes, archiveMailbox.accountId, ); const remaining = emails.filter(e => !selectedEmailIds.has(e.id)); set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false }); // Refresh the active or viewed account's mailbox cache after the // archive (a year/month archive can create new sub-folders). await refreshMailboxesForViewingAccount(client); await get().refreshCurrentMailbox(client); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to archive emails', isLoading: false, }); throw error; } }, // Spam operations markAsSpam: async (client, emailId) => { const { selectedMailbox, emails } = get(); const mailboxes = resolveActionMailboxes(); const email = emails.find(e => e.id === emailId); if (!email) return; const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); if (!currentMailbox) return; get().spamUndoCache.set(emailId, { emailId, originalMailboxId: currentMailbox.originalId || currentMailbox.id, accountId: currentMailbox.accountId, }); try { const isUnread = !email.keywords?.$seen; const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread; await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId, alsoMarkRead); set(state => ({ emails: state.emails.filter(e => e.id !== emailId), selectedEmail: getNextSelectedEmail(state, emailId), })); } catch (error) { console.error('Failed to mark as spam:', error); throw error; } }, undoSpam: async (client, emailId) => { const { selectedMailbox } = get(); const mailboxes = resolveActionMailboxes(); // Try cache first (preserves exact original mailbox for toast undo) const cachedData = get().spamUndoCache.get(emailId); let targetMailboxId: string; let accountId: string | undefined; if (cachedData) { // Use cached original mailbox (more accurate for immediate undo) targetMailboxId = cachedData.originalMailboxId; accountId = cachedData.accountId; get().spamUndoCache.delete(emailId); } else { // Fall back to finding Inbox (generic "not spam" button/menu) const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); accountId = currentMailbox?.accountId; // Find inbox in same account const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && (accountId ? m.accountId === accountId : !m.accountId) ); if (!inboxMailbox) { throw new Error('Inbox not found'); } targetMailboxId = inboxMailbox.originalId || inboxMailbox.id; } try { await resolveActionClient(client).undoSpam(emailId, targetMailboxId, accountId); await get().fetchEmails(client, selectedMailbox); } catch (error) { console.error('Failed to restore email:', error); throw error; } }, batchMarkAsSpam: async (client, emailIds) => { const { selectedMailbox, emails } = get(); const mailboxes = resolveActionMailboxes(); const effectiveClient = resolveActionClient(client); const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); if (!currentMailbox) return; const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read'; try { for (const emailId of emailIds) { const email = emails.find(e => e.id === emailId); const markRead = alsoMarkRead && !!email && !email.keywords?.$seen; await effectiveClient.markAsSpam(emailId, currentMailbox.accountId, markRead); } set(state => ({ emails: state.emails.filter(e => !emailIds.includes(e.id)), selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail, selectedEmailIds: new Set(), })); } catch (error) { console.error('Failed to batch mark as spam:', error); throw error; } }, batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => { const { selectedMailbox } = get(); const mailboxes = resolveActionMailboxes(); const effectiveClient = resolveActionClient(client); // Find inbox (batch operations don't preserve original mailboxes) const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); const accountId = currentMailbox?.accountId; const inboxMailbox = mailboxes.find(m => m.role === 'inbox' && (accountId ? m.accountId === accountId : !m.accountId) ); if (!inboxMailbox) { throw new Error('Inbox not found'); } try { for (const emailId of emailIds) { await effectiveClient.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId); } set(state => ({ emails: state.emails.filter(e => !emailIds.includes(e.id)), selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail, selectedEmailIds: new Set(), })); } catch (error) { console.error('Failed to batch restore emails:', error); throw error; } }, // Push notification handlers setPushConnected: (connected) => { set({ isPushConnected: connected }); }, handleStateChange: async (change, client) => { try { // Update last push update timestamp set({ lastPushUpdate: Date.now() }); // Get the current account ID from the client (assuming primary account) const accountId = client.getAccountId(); // Check if there are changes for this account const accountChanges = change.changed[accountId]; if (!accountChanges) return; // Handle Email state changes - refresh current mailbox if (accountChanges.Email) { await get().refreshCurrentMailbox(client); get().fetchTagCounts(client); } if (accountChanges.EmailSubmission) { await get().refreshScheduledMetadata(client); if (get().isScheduledView) { await get().fetchScheduledEmails(client); } } // Handle Mailbox state changes - refresh mailbox list if (accountChanges.Mailbox) { await get().fetchMailboxes(client); } // Handle Calendar/CalendarEvent state changes - refresh calendar data if (accountChanges.Calendar || accountChanges.CalendarEvent) { const calendarStore = useCalendarStore.getState(); if (calendarStore.supportsCalendar) { calendarStore.fetchCalendars(client); const { dateRange, selectedCalendarIds } = calendarStore; if (dateRange && selectedCalendarIds.length > 0) { calendarStore.fetchEvents(client, dateRange.start, dateRange.end); } // Refresh tasks when calendar events change (e.g. task created via CalDAV) const { useTaskStore } = await import('./task-store'); const taskStore = useTaskStore.getState(); if (taskStore.tasks.length > 0 || calendarStore.viewMode === 'tasks') { taskStore.fetchTasks(client); } } } // Handle SieveScript state changes - refresh filter rules if (accountChanges.SieveScript) { const { useFilterStore } = await import('./filter-store'); const filterStore = useFilterStore.getState(); if (filterStore.isSupported) { filterStore.fetchFilters(client).catch((err) => { console.error('Failed to refresh filters:', err); }); } } } catch (error) { console.error('Failed to handle state change:', error); set({ error: error instanceof Error ? error.message : "Failed to handle push notification" }); } }, refreshCurrentMailbox: async (client) => { const { selectedMailbox } = get(); // Only refresh if a mailbox is currently selected if (!selectedMailbox) return; if (selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID) { await get().fetchScheduledEmails(client); return; } try { // Fetch emails for the current mailbox without clearing the list first // This provides a smoother update experience const mailboxes = resolveActionMailboxes(); const effectiveClient = resolveActionClient(client); const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const jmapMailboxId = mailbox?.originalId || selectedMailbox; // Get emails per page from settings const emailsPerPage = useSettingsStore.getState().emailsPerPage; // Respect active search filters / query so that a push-triggered refresh // does not silently replace a filtered list with an unfiltered one. const { searchQuery, searchFilters } = get(); const hasFilters = !isFilterEmpty(searchFilters); let result; if (hasFilters || searchQuery) { const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0); } else { result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0); } const currentEmails = get().emails; const previousTotal = get().totalEmails; // Only notify for genuinely new incoming mail in the Inbox. // Without these guards the toast/sound also fires when sending, // saving drafts, or moving/deleting the top message in any mailbox, // because all of those change the first-email id of the current view. const newFirst = result.emails[0]; if ( newFirst && mailbox?.role === 'inbox' && !currentEmails.some(e => e.id === newFirst.id) ) { get().handleNewEmailNotification(newFirst); } // Merge the refreshed first page with the existing loaded emails. // This avoids discarding already-loaded pages which would cause the // virtual list to shrink and then rapidly re-load (scroll bounce). const refreshedEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId); // Build the merged list: start with the fresh first page, then append // existing emails beyond that page (if any), skipping duplicates. Do not // append the whole previous list: drafts are saved as destroy+create, so // the old draft can disappear from the refreshed first page and must not // be reintroduced from stale local state. const merged: Email[] = [...refreshedEmails]; const mergedIds = new Set(refreshedEmails.map((e: Email) => e.id)); const insertedCount = Math.max((result.total || 0) - previousTotal, 0); const appendFromIndex = Math.max(refreshedEmails.length - insertedCount, 0); for (const email of currentEmails.slice(appendFromIndex)) { if (!mergedIds.has(email.id)) { merged.push(email); mergedIds.add(email.id); } } // Check if anything actually changed to avoid unnecessary re-renders const hasChanged = currentEmails.length !== merged.length || merged.some((email, i) => { const curr = currentEmails[i]; if (!curr) return true; return ( curr.id !== email.id || curr.threadId !== email.threadId || JSON.stringify(curr.keywords) !== JSON.stringify(email.keywords) ); }); if (hasChanged) { // hasMore should reflect whether there are still more emails beyond // what we have loaded, using the fresh total from the server. const hasMore = merged.length < (result.total || 0); set({ emails: merged, hasMoreEmails: hasMore, totalEmails: result.total, }); } } catch (error) { console.error('Failed to refresh current mailbox:', error); // Don't set error state for background refreshes to avoid disrupting the UI } }, handleNewEmailNotification: (email) => { // Set the new email notification state // This can be consumed by a toast component set({ newEmailNotification: email }); }, clearNewEmailNotification: () => { set({ newEmailNotification: null }); }, // Thread expansion actions toggleThreadExpansion: (threadId) => { const { expandedThreadIds } = get(); const newExpandedThreadIds = new Set(expandedThreadIds); if (newExpandedThreadIds.has(threadId)) { newExpandedThreadIds.delete(threadId); } else { newExpandedThreadIds.add(threadId); } set({ expandedThreadIds: newExpandedThreadIds }); }, fetchThreadEmails: async (client, threadId) => { const { threadEmailsCache, selectedMailbox } = get(); const mailboxes = resolveActionMailboxes(); // Check if we already have this thread cached const cachedEmails = threadEmailsCache.get(threadId); if (cachedEmails && cachedEmails.length > 0) { return cachedEmails; } // Set loading state set({ isLoadingThread: threadId }); try { // Determine accountId for shared folders const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; // Fetch all emails in the thread const emails = await resolveActionClient(client).getThreadEmails(threadId, accountId); // Update cache const newCache = new Map(get().threadEmailsCache); newCache.set(threadId, emails); set({ threadEmailsCache: newCache, isLoadingThread: null }); return emails; } catch (error) { console.error('Failed to fetch thread emails:', error); set({ isLoadingThread: null }); return []; } }, collapseAllThreads: () => { set({ expandedThreadIds: new Set(), isLoadingThread: null }); }, updateThreadCache: (threadId, emails) => { const newCache = new Map(get().threadEmailsCache); newCache.set(threadId, emails); set({ threadEmailsCache: newCache }); }, // Mailbox management createMailbox: async (client, name, parentId) => { try { await resolveActionClient(client).createMailbox(name, parentId); if (get().viewingAccountId) { await refreshMailboxesForViewingAccount(client); } else { await get().fetchMailboxes(client); } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to create folder' }); throw error; } }, renameMailbox: async (client, mailboxId, name) => { try { await resolveActionClient(client).updateMailbox(mailboxId, { name }); const viewingId = get().viewingAccountId; if (viewingId) { set((state) => ({ accountMailboxes: { ...state.accountMailboxes, [viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb => mb.id === mailboxId ? { ...mb, name } : mb ), }, })); } else { set({ mailboxes: get().mailboxes.map(mb => mb.id === mailboxId ? { ...mb, name } : mb ), }); } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to rename folder' }); throw error; } }, deleteMailbox: async (client, mailboxId) => { try { await resolveActionClient(client).deleteMailbox(mailboxId); const { selectedMailbox, viewingAccountId: viewingId } = get(); if (viewingId) { const updatedList = (get().accountMailboxes[viewingId] ?? []).filter(mb => mb.id !== mailboxId); const patch: Partial = { accountMailboxes: { ...get().accountMailboxes, [viewingId]: updatedList }, }; if (selectedMailbox === mailboxId) { const inbox = updatedList.find(mb => mb.role === 'inbox' && !mb.isShared); if (inbox) patch.selectedMailbox = inbox.id; } set(patch); } else { const newMailboxes = get().mailboxes.filter(mb => mb.id !== mailboxId); const updates: Partial = { mailboxes: newMailboxes }; if (selectedMailbox === mailboxId) { const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared); if (inbox) updates.selectedMailbox = inbox.id; } set(updates); } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to delete folder' }); throw error; } }, setMailboxRole: async (client, mailboxId, role) => { try { const effectiveClient = resolveActionClient(client); // If assigning a role, first clear that role from ALL other mailboxes that have it if (role) { const existingMailboxes = resolveActionMailboxes().filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId); for (const existing of existingMailboxes) { await effectiveClient.updateMailbox(existing.id, { role: null }); } } await effectiveClient.updateMailbox(mailboxId, { role }); if (get().viewingAccountId) { await refreshMailboxesForViewingAccount(client); } else { await get().fetchMailboxes(client); } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to update folder role' }); throw error; } }, emptyMailbox: async (client, mailboxId) => { try { set({ isLoading: true, error: null }); await resolveActionClient(client).emptyMailbox(mailboxId); // Clear emails from local state if we're viewing this mailbox const currentMailbox = get().selectedMailbox; if (currentMailbox === mailboxId) { set({ emails: [], selectedEmail: null }); } const viewingId = get().viewingAccountId; if (viewingId) { set((state) => ({ accountMailboxes: { ...state.accountMailboxes, [viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb => mb.id === mailboxId ? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 } : mb ), }, })); } else { set({ mailboxes: get().mailboxes.map(mb => mb.id === mailboxId ? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 } : mb ), }); } set({ isLoading: false }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to empty folder', isLoading: false, }); throw error; } }, markMailboxAsRead: async (client, mailboxId) => { try { const mailbox = resolveActionMailboxes().find(mb => mb.id === mailboxId); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const jmapMailboxId = mailbox?.originalId || mailboxId; const count = await resolveActionClient(client).markMailboxAsRead(jmapMailboxId, accountId); // Update local state: mark all emails currently visible in this mailbox as read, // and zero-out the mailbox unread counter. set((state) => ({ emails: state.emails.map(e => e.mailboxIds && e.mailboxIds[mailboxId] ? { ...e, keywords: { ...e.keywords, $seen: true } } : e ), selectedEmail: state.selectedEmail && state.selectedEmail.mailboxIds?.[mailboxId] ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } } : state.selectedEmail, mailboxes: state.mailboxes.map(mb => mb.id === mailboxId ? { ...mb, unreadEmails: 0, unreadThreads: 0 } : mb ), })); return count; } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to mark folder as read' }); throw error; } }, // Unified mailbox operations fetchUnifiedEmails: async (accounts, role) => { set({ isLoading: true, error: null, isUnifiedView: true, unifiedRole: role, selectedKeyword: null, }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0); set({ emails: result.emails, hasMoreEmails: result.hasMore, totalEmails: result.total, isLoading: false, unifiedErrors: result.errors, }); } catch (error) { console.error('Failed to fetch unified emails:', error); set({ error: error instanceof Error ? error.message : "Failed to fetch unified emails", isLoading: false, emails: [], hasMoreEmails: false, totalEmails: 0, }); } }, loadMoreUnifiedEmails: async (accounts) => { const { isLoadingMore, hasMoreEmails, emails, unifiedRole } = get(); if (isLoadingMore || !hasMoreEmails || !unifiedRole) return; set({ isLoadingMore: true, error: null }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const position = emails.length; const result = await fetchUnifiedEmails(accounts, unifiedRole, emailsPerPage, position); const currentEmails = get().emails; const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id)); set({ emails: [...currentEmails, ...newEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false, unifiedErrors: result.errors, }); } catch (error) { console.error('Failed to load more unified emails:', error); set({ error: error instanceof Error ? error.message : "Failed to load more unified emails", isLoadingMore: false, }); } }, refreshUnifiedCounts: async (accounts) => { try { const counts = fetchUnifiedMailboxCounts(accounts); set({ unifiedCounts: counts }); } catch (error) { console.error('Failed to refresh unified counts:', error); } }, exitUnifiedView: () => { set({ isUnifiedView: false, unifiedRole: null, unifiedErrors: new Map(), }); }, setScheduledView: (isScheduledView) => set(state => { const leavingScheduled = !isScheduledView && state.selectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID; return { isScheduledView, selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox, selectedEmail: leavingScheduled ? null : state.selectedEmail, selectedEmailIds: leavingScheduled ? new Set() : state.selectedEmailIds, }; }), clearPendingUndoSend: () => set({ pendingUndoSend: null }), fetchScheduledEmails: async (client) => { set({ isLoadingScheduled: true, error: null }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const result = await client.getScheduledEmails(emailsPerPage, 0); const scheduledEmailIds = new Set(result.emails.map(email => email.id)); const scheduledSubmissionByEmailId = new Map(result.emails.map(email => [email.id, { submissionId: email.emailSubmissionId, sendAt: email.scheduledSendAt, identityId: email.scheduledIdentityId, undoStatus: email.scheduledUndoStatus, }])); const pendingUndoSend = get().pendingUndoSend; set({ scheduledEmails: result.emails, scheduledEmailIds, scheduledSubmissionByEmailId, scheduledTotal: result.total, scheduledHasMore: result.hasMore, scheduledNextPosition: result.nextPosition, isLoadingScheduled: false, pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, result.emails) ? null : pendingUndoSend, }); } catch (error) { console.error('Failed to fetch scheduled emails:', error); set({ error: error instanceof Error ? error.message : 'Failed to fetch scheduled emails', scheduledEmails: [], scheduledEmailIds: new Set(), scheduledSubmissionByEmailId: new Map(), scheduledTotal: 0, scheduledHasMore: false, scheduledNextPosition: 0, isLoadingScheduled: false, }); } }, loadMoreScheduledEmails: async (client) => { const { isLoadingScheduled, scheduledHasMore, scheduledEmails, scheduledNextPosition } = get(); if (isLoadingScheduled || !scheduledHasMore) return; set({ isLoadingScheduled: true, error: null }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const result = await client.getScheduledEmails(emailsPerPage, scheduledNextPosition); const merged = [...scheduledEmails, ...result.emails.filter(email => !scheduledEmails.some(existing => existing.id === email.id))]; const pendingUndoSend = get().pendingUndoSend; set({ scheduledEmails: merged, scheduledEmailIds: new Set(merged.map(email => email.id)), scheduledSubmissionByEmailId: new Map(merged.map(email => [email.id, { submissionId: email.emailSubmissionId, sendAt: email.scheduledSendAt, identityId: email.scheduledIdentityId, undoStatus: email.scheduledUndoStatus, }])), scheduledTotal: result.total, scheduledHasMore: result.hasMore, scheduledNextPosition: result.nextPosition, isLoadingScheduled: false, pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, merged) ? null : pendingUndoSend, }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to load scheduled emails', isLoadingScheduled: false }); } }, refreshScheduledMetadata: async (client) => { try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const allEmails: ScheduledEmail[] = []; let position = 0; let hasMore = true; let total = 0; while (hasMore) { const page = await client.getScheduledEmails(emailsPerPage, position); allEmails.push(...page.emails.filter(email => !allEmails.some(existing => existing.id === email.id))); total = page.total; hasMore = page.hasMore && page.nextPosition > position; position = page.nextPosition; } const pendingUndoSend = get().pendingUndoSend; set({ scheduledEmails: get().isScheduledView ? allEmails : get().scheduledEmails, scheduledEmailIds: new Set(allEmails.map(email => email.id)), scheduledSubmissionByEmailId: new Map(allEmails.map(email => [email.id, { submissionId: email.emailSubmissionId, sendAt: email.scheduledSendAt, identityId: email.scheduledIdentityId, undoStatus: email.scheduledUndoStatus, }])), scheduledTotal: total, scheduledHasMore: false, scheduledNextPosition: position, pendingUndoSend: shouldClearPendingUndoSend(pendingUndoSend, allEmails) ? null : pendingUndoSend, }); } catch (error) { console.error('Failed to refresh scheduled metadata:', error); } }, cancelScheduledEmail: async (client, submissionId, emailId) => { await client.cancelEmailSubmission(submissionId); if (emailId) { await client.deleteEmail(emailId); set(state => ({ selectedEmail: state.selectedEmail?.id === emailId ? null : state.selectedEmail, selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== emailId)), })); } if (get().pendingUndoSend?.submissionId === submissionId) { set({ pendingUndoSend: null }); } await get().fetchScheduledEmails(client); }, cancelScheduledEmailForEdit: async (client, email) => { const submissionId = email.emailSubmissionId; if (!submissionId) return null; await client.cancelEmailSubmission(submissionId); if (get().pendingUndoSend?.submissionId === submissionId) { set({ pendingUndoSend: null }); } if (email.isSmimeScheduled) { await client.deleteEmail(email.id); set(state => ({ selectedEmail: state.selectedEmail?.id === email.id ? null : state.selectedEmail, selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== email.id)), })); await get().fetchScheduledEmails(client); return null; } const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes(); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (draftsMailbox) { await client.restoreEmailToDraft(email.id, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id); } await get().fetchScheduledEmails(client); const restored = await client.getEmail(email.id); return restored; }, rescheduleScheduledEmail: async (client, submissionId, emailId, identityId, delayedUntil) => { let result: SendEmailResult | undefined; try { result = await client.rescheduleEmailSubmission(submissionId, emailId, identityId, delayedUntil); const pendingUndoSend = get().pendingUndoSend; if (pendingUndoSend?.submissionId === submissionId) { set({ pendingUndoSend: { ...pendingUndoSend, submissionId: result.emailSubmissionId || submissionId, sendAt: result.sendAt || delayedUntil } }); } return result; } finally { await get().fetchScheduledEmails(client); if (result && get().selectedEmail?.id === emailId) { const refreshed = get().scheduledEmails.find(email => email.id === emailId); set(state => ({ selectedEmail: refreshed || (state.selectedEmail ? { ...state.selectedEmail, emailSubmissionId: result?.emailSubmissionId || submissionId, scheduledSendAt: result?.sendAt || delayedUntil, scheduledIdentityId: identityId, scheduledUndoStatus: 'pending' as const, isScheduled: true, } : state.selectedEmail), })); } } }, cancelUndoSend: async (client, pending) => { await client.cancelEmailSubmission(pending.submissionId); if (pending.emailId && pending.isSmime) { await client.deleteEmail(pending.emailId); set(state => ({ selectedEmail: state.selectedEmail?.id === pending.emailId ? null : state.selectedEmail, selectedEmailIds: new Set(Array.from(state.selectedEmailIds).filter(id => id !== pending.emailId)), })); } else if (pending.emailId) { const mailboxes = get().mailboxes.length > 0 ? get().mailboxes : await client.getMailboxes(); const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts'); const sentMailbox = mailboxes.find(mb => mb.role === 'sent'); if (draftsMailbox) { await client.restoreEmailToDraft(pending.emailId, draftsMailbox.originalId || draftsMailbox.id, sentMailbox?.originalId || sentMailbox?.id); } } await get().refreshScheduledMetadata(client); set({ pendingUndoSend: null }); return pending.emailId && !pending.isSmime ? client.getEmail(pending.emailId) : null; }, loadMockData: () => { const mockEmails: Email[] = [ { id: "1", threadId: "thread-1", mailboxIds: { inbox: true }, keywords: { $seen: false }, size: 1024, receivedAt: new Date().toISOString(), from: [{ name: "GitHub", email: "notifications@github.com" }], to: [{ email: "you@example.com" }], subject: "[bulwark-webmail] New pull request #42: Add OAuth2 module", preview: "dependabot[bot] opened a pull request in bulwarkmail/webmail. This PR adds a comprehensive authentication module with OAuth2 PKCE support...", hasAttachment: false, }, { id: "2", threadId: "thread-2", mailboxIds: { inbox: true }, keywords: { $seen: true, $flagged: true }, size: 512, receivedAt: new Date(Date.now() - 3600000).toISOString(), from: [{ name: "Emily Chen", email: "emily.chen@gmail.com" }], to: [{ email: "you@example.com" }], subject: "Re: Dashboard Redesign v2 - feedback", preview: "Hey! I just pushed the updated mockups to Figma. I incorporated all the feedback from last week's meeting. Let me know what you think about the new nav...", hasAttachment: true, }, { id: "3", threadId: "thread-3", mailboxIds: { inbox: true }, keywords: { $seen: false }, size: 2048, receivedAt: new Date(Date.now() - 7200000).toISOString(), from: [{ name: "Slack", email: "notifications@slack.com" }], to: [{ email: "you@example.com" }], subject: "3 new messages in #engineering", preview: "Marcus: Hey team, the CI pipeline is green again. Sarah: Great, merging the feature branch now. Alex: Let's do a quick sync at 3 PM...", hasAttachment: false, }, { id: "4", threadId: "thread-4", mailboxIds: { inbox: true }, keywords: { $seen: true }, size: 768, receivedAt: new Date(Date.now() - 14400000).toISOString(), from: [{ name: "Marcus Rivera", email: "marcus.rivera@outlook.com" }], to: [{ email: "you@example.com" }], subject: "Quick question about the API rate limits", preview: "Hey, I was looking at the JMAP spec and I'm not sure how we should handle rate limiting on the server side. Do you have any thoughts on...", hasAttachment: false, }, { id: "5", threadId: "thread-5", mailboxIds: { inbox: true }, keywords: { $seen: true }, size: 1536, receivedAt: new Date(Date.now() - 86400000).toISOString(), from: [{ name: "Stripe", email: "receipts@stripe.com" }], to: [{ email: "you@example.com" }], subject: "Your invoice from Acme Corp is ready", preview: "Invoice #INV-2026-0312 for $49.00 has been paid. Thank you for your payment. View your receipt and download your invoice...", hasAttachment: true, }, { id: "6", threadId: "thread-6", mailboxIds: { inbox: true }, keywords: { $seen: false }, size: 3072, receivedAt: new Date(Date.now() - 108000000).toISOString(), from: [{ name: "Sarah Kim", email: "sarah.kim@proton.me" }], to: [{ email: "you@example.com" }], subject: "Conference talk proposal - need your review", preview: "I'm submitting a talk to ReactConf about our email client architecture. Could you take a look at my abstract before the deadline on Friday?...", hasAttachment: true, }, { id: "7", threadId: "thread-7", mailboxIds: { inbox: true }, keywords: { $seen: true, $flagged: true }, size: 4096, receivedAt: new Date(Date.now() - 172800000).toISOString(), from: [{ name: "Vercel", email: "notifications@vercel.com" }], to: [{ email: "you@example.com" }], subject: "Deployment successful: bulwark-webmail \u2192 Production", preview: "Your project bulwark-webmail has been deployed to production. Build completed in 47s. All checks passed. Preview: https://bulwark-webmail.vercel.app...", hasAttachment: false, }, { id: "8", threadId: "thread-8", mailboxIds: { inbox: true }, keywords: { $seen: true }, size: 2560, receivedAt: new Date(Date.now() - 259200000).toISOString(), from: [{ name: "Alex Petrov", email: "alex.petrov@fastmail.com" }], to: [{ email: "you@example.com" }], subject: "Meeting notes from yesterday's standup", preview: "Here are the action items from yesterday. 1) Finish the drag-and-drop implementation by Wednesday. 2) Review the accessibility audit results...", hasAttachment: false, }, { id: "9", threadId: "thread-9", mailboxIds: { inbox: true }, keywords: { $seen: false }, size: 1280, receivedAt: new Date(Date.now() - 345600000).toISOString(), from: [{ name: "Linear", email: "notifications@linear.app" }], to: [{ email: "you@example.com" }], subject: "ENG-384: Implement email threading view \u2014 moved to In Progress", preview: "Alice Johnson moved ENG-384 to In Progress. This issue covers implementing the conversation thread view for the email client...", hasAttachment: false, }, { id: "10", threadId: "thread-10", mailboxIds: { inbox: true }, keywords: { $seen: true }, size: 896, receivedAt: new Date(Date.now() - 432000000).toISOString(), from: [{ name: "Priya Sharma", email: "priya.sharma@icloud.com" }], to: [{ email: "you@example.com" }], subject: "Re: Onboarding docs for new contributors", preview: "Thanks for putting this together! I added a section on setting up the dev environment. Also linked the architecture diagram from our wiki...", hasAttachment: false, }, { id: "11", threadId: "thread-11", mailboxIds: { inbox: true }, keywords: { $seen: true }, size: 5120, receivedAt: new Date(Date.now() - 518400000).toISOString(), from: [{ name: "LaunchWeekly", email: "newsletter@launchweekly.com" }], to: [{ email: "you@example.com" }], subject: "\uD83D\uDE80 This week in tech: AI agents, new frameworks, and more", preview: "Happy Monday! Here's your weekly roundup of the most interesting launches, open-source projects, and developer tools you might have missed...", hasAttachment: false, }, ]; const mockMailboxes: Mailbox[] = [ { id: "inbox", name: "Inbox", role: "inbox", sortOrder: 1, totalEmails: 11, unreadEmails: 4, totalThreads: 11, unreadThreads: 4, myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true, maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true, maySubmit: true, }, isSubscribed: true, }, ]; set({ emails: mockEmails, mailboxes: mockMailboxes, }); }, }));