import { create } from "zustand"; import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types"; import type { UnifiedMailboxRole, CrossView } 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, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, getCrossUnreadTotal, 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; // Full thread email counts (from Thread/get across all folders) threadEmailCounts: Map; // 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; // Cross-account view ('unread' | 'starred' | 'all') when active; null for the // per-role unified views. Mutually exclusive with unifiedRole; both run under // isUnifiedView. crossView: CrossView | null; unifiedErrors: Map; // accountId -> error message // Unified-section sidebar badges. These are NOT an independent source of // truth: they are a pure projection of the live per-account mailbox lists // (`mailboxes` + `accountMailboxes`) over the last-known unified scope // (`unifiedScope`). Recomputed automatically whenever those lists change (see // the store subscription below), so optimistic delete/move/markRead patches and // push-driven mailbox refreshes flow into the badges without a server round // trip. (#281 follow-up: single source of truth for unified counters.) unifiedCounts: UnifiedMailboxCounts[]; // Unread total across the cross-view included folders (badge for unread/all). crossUnreadCount: number; // The account/folder structure (which accounts, role mailboxes, cross-include // selection) that the unified badges are projected over. Set by // refreshUnifiedCounts/refreshCrossCounts from the freshly-built // UnifiedAccountClient[]. The COUNTER values it carries are ignored at // projection time - live counters are read from `mailboxes`/`accountMailboxes` // instead - so a stale snapshot here only affects structure, never numbers. unifiedScope: UnifiedAccountClient[]; // 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). * `sourceJmapAccountId` is the mirror image for the source side: when the * emails live in a delegated/shared mailbox accessed through the source * client, the copy/delete must target the owner's JMAP account rather than * the source client's primary one. */ crossAccountMoveEmails: ( emailIdsBySource: Map, destAccountId: string, destMailboxId: string, destJmapAccountId?: string, sourceJmapAccountId?: 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 // For unified-view emails the undo must hit the same account: `accountId` is the // owning JMAP account (passed to JMAP), `sourceClientAccountId` is the login the // email is reachable through (used to pick the right client). (#281) 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; fetchThreadEmailCounts: (client: IJMAPClient) => Promise; markThreadAsRead: (client: IJMAPClient, threadId: string) => Promise; // 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; reorderMailboxes: (client: IJMAPClient, orderedIds: string[]) => 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; // Cross-account view operations (unread / starred / all) fetchCrossView: (accounts: UnifiedAccountClient[], view: CrossView) => Promise; refreshCrossCounts: (accounts: UnifiedAccountClient[]) => 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; } /** * The owner JMAP accountId of the shared/group folder currently being viewed * directly (the "Shared" sidebar section, non-unified), or `undefined` for a * normal own-account view. Emails in this view are undecorated (no * `sourceAccountId`) and are reached through the ACTIVE client, but their * mutations must carry this accountId — otherwise JMAP `Email/set` is sent to * the user's own account, where it silently no-ops (Stalwart returns the ids as * `updated: null` with an unchanged state, not `notUpdated`) and the change is * lost on the next reload. Mirrors `fetchEmails` and the single-email path. */ function resolveViewAccountId(): string | undefined { const state = useEmailStore.getState(); const mb = resolveActionMailboxes().find(m => m.id === state.selectedMailbox); return mb?.isShared ? mb.accountId : undefined; } /** * Resolves the store-side mailbox ids that make up a personal account's * contribution to the unified cross views (All mail / Unread / Starred), honoring * that account's `allMailFolderIds` folder selection. Returns `undefined` when the * account has no explicit selection, so the cross views fall back to their * role-exclusion default (inbox + custom folders). An explicit `[]` selection * yields an empty list (no own folders). `ownMailboxes` must already exclude * shared folders - the picker only ever scopes the user's own folders. */ function resolveCrossIncludedMailboxIds(accountId: string, ownMailboxes: Mailbox[]): string[] | undefined { const configured = useSettingsStore.getState().allMailFolderIds[accountId]; if (configured === undefined) return undefined; const selected = new Set(configured); return ownMailboxes.filter((mb) => selected.has(mb.id)).map((mb) => mb.id); } /** * Resolves the JMAP client, mailbox list, and JMAP accountId to use for a * single-email action. * * In aggregate views each email is decorated with its source reference: * `sourceClientAccountId` (the logged-in client it is reachable through) and * `sourceAccountId` (the owning JMAP account). The mutation must be routed to * that client/account, or it is sent to the active account whose server doesn't * know the id, so JMAP `Email/set` silently returns `notUpdated` and the change * is lost on the next reload (issue #281). We always pass `sourceAccountId` as * the JMAP accountId: for personal sources it equals the client's primary (a * no-op, no namespacing), for shared/group sources it targets the owner. The * owner's mailbox list (cached by `buildUnifiedAccountClients` under that JMAP * id) resolves role-based destinations like trash/archive. * * For the normal single-account / viewing-account flow this preserves the * existing behavior exactly: the active/viewing client, its mailbox list, and * the shared-mailbox accountId derived from the currently selected mailbox. */ function resolveEmailActionContext( email: { sourceClientAccountId?: string; sourceAccountId?: string }, passedClient: IJMAPClient, ): { client: IJMAPClient; mailboxes: Mailbox[]; accountId: string | undefined } { const state = useEmailStore.getState(); // In aggregate views every email is decorated with its source reference: // `sourceClientAccountId` (the login client it is reachable through) and // `sourceAccountId` (the owning JMAP account). These are unambiguous across // personal and shared/group sources, so resolution is the same three lines for // both - no id-space guessing, no capability scan. For personal sources // `sourceAccountId` equals the client's primary, so passing it to JMAP is a // no-op (matches the previous `accountId: undefined` behavior exactly). if (state.isUnifiedView && email.sourceClientAccountId && email.sourceAccountId) { return { client: useAuthStore.getState().getClientForAccount(email.sourceClientAccountId) ?? resolveActionClient(passedClient), mailboxes: state.accountMailboxes[email.sourceAccountId] ?? state.mailboxes, accountId: email.sourceAccountId, }; } const mailboxes = resolveActionMailboxes(); const currentMailbox = mailboxes.find((mb) => mb.id === state.selectedMailbox); return { client: resolveActionClient(passedClient), mailboxes, accountId: currentMailbox?.isShared ? currentMailbox.accountId : undefined, }; } /** * 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. * * When `scopeToClientAccountId` is set, only the matching logged-in account * (and the shared owners reachable through its client) is built - this keeps * the unified mailbox within a single account boundary. Omitting it spans every * logged-in account (the cross-account sub-option). * * Personal entries carry `crossIncludedMailboxIds` derived from the account's * `allMailFolderIds` folder selection, restricting the All mail / Unread / * Starred cross views to the chosen own folders (shared entries are left * unrestricted so all their folders are included). */ export async function buildUnifiedAccountClients( opts: { includeGroup?: boolean; scopeToClientAccountId?: string } = {}, ): Promise { const { includeGroup = false, scopeToClientAccountId } = opts; const authAccounts = useAccountStore.getState().accounts.filter( (a) => a.isConnected && (!scopeToClientAccountId || a.id === scopeToClientAccountId), ); const allClients = useAuthStore.getState().getAllConnectedClients(); const built: UnifiedAccountClient[] = []; // Per-account mailbox lists gathered here are cached into `accountMailboxes` // after the fan-out so single-email actions can resolve role-based // destinations (trash/archive) in the email's own account (issue #281). const fetchedMailboxes: Record = {}; 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; // Primary JMAP account id of this login. Stamped onto personal emails as // `sourceAccountId`; equals the client's primary so passing it to JMAP is a // no-op (no namespacing) — keeps personal behavior identical while making // resolution branch-free against shared sources. const primaryJmapId = c.getAccountId(); built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, clientAccountId: a.id, jmapAccountId: primaryJmapId, isShared: false, crossIncludedMailboxIds: resolveCrossIncludedMailboxIds(a.id, ownMailboxes) }); fetchedMailboxes[a.id] = ownMailboxes; // Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]` // resolves uniformly for personal and shared sources alike. fetchedMailboxes[primaryJmapId] = ownMailboxes; 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, clientAccountId: a.id, jmapAccountId: ownerId, isShared: true, }); // Cache the owner's mailbox list keyed by its JMAP id so single-email // and batch actions can resolve role-based destinations (trash/archive) // in the owner account instead of falling back to the active account. fetchedMailboxes[ownerId] = ownerMailboxes; } } } catch { /* skip account on mailbox fetch failure */ } } if (Object.keys(fetchedMailboxes).length > 0) { useEmailStore.setState((state) => ({ accountMailboxes: { ...state.accountMailboxes, ...fetchedMailboxes }, })); } 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); } } // Whether an email belongs to a given mailbox, for local counter math. // Own-account emails carry bare ids (equal to both `id` and `originalId`). // As of #281 V3, EVERY client fetch path (getEmails/getEmail/getThreadEmails/ // searchEmails/advancedSearchEmails) namespaces shared/delegated emails' // `mailboxIds` to the store id (`${ownerId}:${origId}`), so the `ids[mailbox.id]` // fast path below matches own and shared mailboxes alike - one id space. // The `originalId` branches remain as a defensive fallback for any email that // still carries a bare owner id (scoped to the owning account via // `sourceAccountId === mailbox.accountId` so a bare owner id can't collide with // another account's folder). (#281) function emailInMailbox( email: { mailboxIds?: Record; sourceAccountId?: string }, mailbox: Mailbox, ): boolean { const ids = email.mailboxIds; if (!ids) return false; if (ids[mailbox.id]) return true; if (!mailbox.isShared && mailbox.originalId) return !!ids[mailbox.originalId]; if ( mailbox.isShared && mailbox.originalId && email.sourceAccountId && email.sourceAccountId === mailbox.accountId ) { return !!ids[mailbox.originalId]; } return false; } // Apply a per-mailbox counter adjustment to the mailbox list that actually holds // the email's folders, and return the matching `set()` partial. // // In aggregate views an email may belong to another logged-in account. The // sidebar shows the active account's folders from `mailboxes` (its // getAllMailboxes, incl. its delegated shared folders) and every *other* // account's folders from `accountMailboxes[]`. Updating the // wrong list silently corrupts counters - and because JMAP mailbox ids can // collide across accounts, blindly matching `mailboxes` would even decrement the // *active* account's folder for a different account's email. So route by the // email's source login: a different account → its `accountMailboxes` entry, // otherwise (active account, its shared folders, or a non-aggregate view) → // `mailboxes`. (#281) function applyMailboxCounterUpdate( state: { mailboxes: Mailbox[]; accountMailboxes: Record }, email: { sourceClientAccountId?: string }, adjust: (mb: Mailbox) => Mailbox, ): { mailboxes?: Mailbox[]; accountMailboxes?: Record } { const srcClient = email.sourceClientAccountId; const activeId = useAuthStore.getState().activeAccountId; if (srcClient && srcClient !== activeId) { const list = state.accountMailboxes[srcClient]; if (!list) return {}; return { accountMailboxes: { ...state.accountMailboxes, [srcClient]: list.map(adjust) } }; } return { mailboxes: state.mailboxes.map(adjust) }; } // Batch variant of applyMailboxCounterUpdate: a set of emails may span accounts, // so group them by the list that holds them and run `mapMailbox` per list using // only that group's emails. Each account's counters are adjusted in its own list // (active account → `mailboxes`, others → `accountMailboxes[sourceClientAccountId]`), // avoiding cross-account id collisions. (#281) function applyBatchMailboxCounterUpdate( state: { mailboxes: Mailbox[]; accountMailboxes: Record }, emails: Array<{ sourceClientAccountId?: string }>, mapMailbox: (mb: Mailbox, emailsForList: Array<{ sourceClientAccountId?: string }>) => Mailbox, ): { mailboxes: Mailbox[]; accountMailboxes: Record } { const activeId = useAuthStore.getState().activeAccountId; const ACTIVE = '__active__'; const groups = new Map>(); for (const e of emails) { const k = e.sourceClientAccountId && e.sourceClientAccountId !== activeId ? e.sourceClientAccountId : ACTIVE; const g = groups.get(k); if (g) g.push(e); else groups.set(k, [e]); } let mailboxes = state.mailboxes; const accountMailboxes = { ...state.accountMailboxes }; for (const [k, es] of groups) { if (k === ACTIVE) { mailboxes = mailboxes.map((mb) => mapMailbox(mb, es)); } else if (accountMailboxes[k]) { accountMailboxes[k] = accountMailboxes[k].map((mb) => mapMailbox(mb, es)); } } return { mailboxes, accountMailboxes }; } // Sidebar tag badges render from `tagCounts`, which is *fetched from the server* // (`fetchTagCounts` -> `getTagCounts`) rather than derived from `state.emails`. // So a read/unread mutation has to keep it in step locally, exactly as it does // for `mailboxes[].unreadEmails` - otherwise the tag unread count (and the bold // tag name) stays stale until a full page reload. // // `changes` carries one entry per email whose read state *actually changed* // (callers already compute that), with delta -1 when it became read and +1 when // it became unread. Only `unread` moves: read state never changes tag // membership, so `total` is left alone. function applyTagCountReadDelta( tagCounts: Record, changes: Array<{ keywords?: Record; delta: number }>, ): Record { const keywordIds = useSettingsStore.getState().emailKeywords.map(k => k.id); if (keywordIds.length === 0) return tagCounts; let next: Record | null = null; for (const { keywords, delta } of changes) { if (!keywords || delta === 0) continue; for (const id of keywordIds) { if (!keywords[`$label:${id}`]) continue; const current = (next ?? tagCounts)[id]; if (!current) continue; // Tag not in the fetched counts yet; nothing to adjust. next = next ?? { ...tagCounts }; next[id] = { total: current.total, unread: Math.max(0, current.unread + delta) }; } } return next ?? tagCounts; } // Per-mailbox counter map (for applyBatchMailboxCounterUpdate) for removing a // group of emails from a folder: decrement total (and unread for unseen) for // each group email that lives in the mailbox. function applyDeleteCounters( mailbox: Mailbox, group: Array<{ sourceClientAccountId?: string }>, ): Mailbox { let dTotal = 0; let dUnread = 0; for (const email of group as Email[]) { if (emailInMailbox(email, mailbox)) { dTotal--; if (!email.keywords?.$seen) dUnread--; } } return dTotal === 0 && dUnread === 0 ? mailbox : { ...mailbox, totalEmails: Math.max(0, mailbox.totalEmails + dTotal), unreadEmails: Math.max(0, mailbox.unreadEmails + dUnread), totalThreads: Math.max(0, mailbox.totalThreads + dTotal), unreadThreads: Math.max(0, mailbox.unreadThreads + dUnread), }; } // ─── Unified-badge live projection ──────────────────────────────────────────── // // The unified-section badges (unifiedCounts / crossUnreadCount) are a pure // projection of the live per-account mailbox lists - the SAME lists the // optimistic delete/move/markRead paths patch and that push refreshes. Rather // than trusting the counter snapshot baked into the UnifiedAccountClient[] (which // came from a server fetch and goes stale the moment a local mutation runs), we // look each scope mailbox up by id in the live store list and use its current // counters. This keeps the badges in lockstep with the per-folder counters - one // source of truth, no server round trip, no eventual-consistency snap-back. // The live store list that holds a unified-scope account's folders. Mirrors // applyMailboxCounterUpdate's routing exactly: the active client's folders (incl. // its delegated shared folders) live in `mailboxes`; every other logged-in // account's folders live in `accountMailboxes[clientAccountId]`. Falls back to // the account's own (snapshot) list so an unknown account still contributes its // last-known counters instead of vanishing. function liveListForAccount( account: UnifiedAccountClient, state: { mailboxes: Mailbox[]; accountMailboxes: Record }, ): Mailbox[] { const activeId = useAuthStore.getState().activeAccountId; if (account.clientAccountId === activeId) return state.mailboxes; return state.accountMailboxes[account.clientAccountId] ?? account.mailboxes; } // Returns a shallow copy of the scope account whose mailboxes carry LIVE counter // values (matched by id against the live store list). Structure - which // mailboxes, their roles, originalId, crossIncludedMailboxIds - is preserved from // the scope snapshot; only the counter numbers are refreshed. This lets the // existing lib aggregators (fetchUnifiedMailboxCounts / getCrossUnreadTotal) run // unchanged over live data. function accountWithLiveCounters( account: UnifiedAccountClient, state: { mailboxes: Mailbox[]; accountMailboxes: Record }, ): UnifiedAccountClient { const live = liveListForAccount(account, state); const byId = new Map(live.map((m) => [m.id, m])); return { ...account, mailboxes: account.mailboxes.map((m) => byId.get(m.id) ?? m) }; } // Project the live mailbox state over the current unified scope into the two // badge values. Pure function of (unifiedScope, mailboxes, accountMailboxes). function projectUnifiedCounts( state: { unifiedScope: UnifiedAccountClient[]; mailboxes: Mailbox[]; accountMailboxes: Record }, ): { unifiedCounts: UnifiedMailboxCounts[]; crossUnreadCount: number } { if (state.unifiedScope.length === 0) { return { unifiedCounts: [], crossUnreadCount: 0 }; } const live = state.unifiedScope.map((a) => accountWithLiveCounters(a, state)); return { unifiedCounts: fetchUnifiedMailboxCounts(live), crossUnreadCount: getCrossUnreadTotal(live), }; } // 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, threadEmailCounts: new Map(), // 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, crossView: null, unifiedErrors: new Map(), unifiedCounts: [], crossUnreadCount: 0, unifiedScope: [], // 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(), threadEmailCounts: new Map(), isLoadingThread: null, }), fetchAccountMailboxes: async (client, accountId) => { try { // `accountId` is overloaded across callers: // - a real login (AccountEntry.id, e.g. per-account sidebar / cross-account // move) → `client` is that account's own login; getMailboxes() returns the // right list. // - a JMAP account id with no own login (a shared/group owner, used by the // unified archive refresh) → must fetch by that JMAP id through the // delegating client, else we'd cache the delegating account's own folders // under the owner key. // Distinguish by whether a directly-logged-in client exists for the id. const hasOwnLogin = !!useAuthStore.getState().getClientForAccount(accountId); const mailboxes = hasOwnLogin ? await client.getMailboxes() : await client.getMailboxes(accountId); // 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(), threadEmailCounts: 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(), threadEmailCounts: 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(); // Guard against a transient fetch returning an empty list (e.g. a server // concurrent-request limit hit during a burst of deletes). Replacing a // populated folder list with [] leaves the sidebar stuck on "Loading // mailboxes..." until the next successful refetch. Keep what we have. if (mailboxes.length === 0 && !isInitialLoad) { return; } // 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 // Unified per-role views (All Inbox/Drafts/Junk/…) and cross-account views // (All unread/starred/all) use a virtual id not present in the fetched // list. A background refresh after a delete must not clobber it and jump // the user back to the inbox. || isUnifiedMailboxId(currentSelectedMailbox) || isCrossViewId(currentSelectedMailbox) || (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, true); const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails); set({ emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId), hasMoreEmails: result.hasMore, totalEmails: result.total, // Clear thread caches since the email list was fully replaced threadEmailsCache: new Map(), expandedThreadIds: new Set(), isLoadingThread: null, isLoading: false }); // Fetch full thread counts in the background (non-blocking) void get().fetchThreadEmailCounts(client); } 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, crossView } = get(); // Don't load if already loading or no more emails if (isLoadingMore || !hasMoreEmails) return; // Cross-account views fan out across all accounts' included folders. Paginate // via the cross-view loader (search-aware), mirroring the unified branch. if (isUnifiedView && crossView) { 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 hasFilters = !isFilterEmpty(get().searchFilters); const result = hasFilters ? await advancedSearchCrossViewEmails(built, crossView, buildJMAPFilter(searchQuery, get().searchFilters, undefined), emailsPerPage, position) : searchQuery ? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, position) : await fetchCrossViewEmails(built, crossView, emailsPerPage, position); const currentEmails = get().emails; const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id)); const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails); set({ emails: [...currentEmails, ...enrichedNewEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false, unifiedErrors: result.errors, }); } catch (error) { console.error('Failed to load more cross-account emails:', error); set({ error: error instanceof Error ? error.message : "Failed to load more emails", isLoadingMore: false, }); } 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)); const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails); set({ emails: [...currentEmails, ...enrichedNewEmails], 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, true); } // 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)); const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails); set({ emails: [...currentEmails, ...enrichedNewEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false }); // Fetch full thread counts for newly loaded threads in the background if (enrichedNewEmails.length > 0) { void get().fetchThreadEmailCounts(client); } } 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 { // Route to the owning account. In aggregate views (All Mail, unified, // cross-account) the selected mailbox is virtual, so derive the client + // accountId from the email itself (handles shared/group accounts); fall // back to the selected-mailbox shared-folder logic for normal views. const listEmail = get().emails.find(e => e.id === emailId); let actionClient: IJMAPClient; let accountId: string | undefined; if (listEmail) { ({ client: actionClient, accountId } = resolveEmailActionContext(listEmail, client)); } else { const mailbox = resolveActionMailboxes().find(mb => mb.id === get().selectedMailbox); actionClient = resolveActionClient(client); accountId = mailbox?.isShared ? mailbox.accountId : undefined; } const email = await actionClient.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; // In unified view route to the email's own account (client + that // account's mailbox list); otherwise the active/viewing context. (#281) const { client: effectiveClient, mailboxes, accountId } = resolveEmailActionContext(email, client); // Get delete action preference from settings const deleteAction = useSettingsStore.getState().deleteAction; const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk; // The email's current mailbox drives the junk auto-permanent-delete rule. // In unified view it comes from the email's own folders (matching the // unified role), not the active account's selected mailbox. const currentMailbox = get().isUnifiedView ? (mailboxes.find(mb => emailInMailbox(email, mb) && mb.role === get().unifiedRole) ?? mailboxes.find(mb => emailInMailbox(email, mb))) : mailboxes.find(mb => mb.id === get().selectedMailbox); // 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). // Counter changes go to the email's own account list (#281). Source and // trash live in the same account. set((state) => { const mailboxPatch = email.mailboxIds ? applyMailboxCounterUpdate(state, email, (mailbox) => { if (emailInMailbox(email, mailbox)) { 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 === 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), ...mailboxPatch, }; }); 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 (in the email's own // account list). Unread emails also decrement the unread counters. (#281) set((state) => { const mailboxPatch = email.mailboxIds ? applyMailboxCounterUpdate(state, email, (mailbox) => emailInMailbox(email, mailbox) ? { ...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, } : mailbox) : {}; return { emails: state.emails.filter(e => e.id !== emailId), selectedEmail: getNextSelectedEmail(state, emailId), ...mailboxPatch, }; }); } 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; if (isCurrentlyRead === read) { return; // Already in desired state } // Add to processing set set((state) => ({ processingReadStatus: new Set([...state.processingReadStatus, processingKey]) })); // In unified view route to the email's own account client; otherwise use // the active/viewing client and the shared-folder accountId. (#281) const { client: actionClient, accountId } = resolveEmailActionContext(email, client); await actionClient.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; if (wasRead === read) { return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update } // Adjust the unread counter on the folder(s) holding this email, in the // email's *own* account's mailbox list (#281). -1 marking read, +1 unread. const delta = read ? -1 : 1; const mailboxPatch = applyMailboxCounterUpdate(state, emailInState, (mailbox) => emailInMailbox(emailInState, mailbox) ? { ...mailbox, unreadEmails: Math.max(0, mailbox.unreadEmails + delta), unreadThreads: Math.max(0, mailbox.unreadThreads + delta), } : 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, ...mailboxPatch, // Same delta, applied to every tag this email carries, so the sidebar // tag badges track the folder counters instead of going stale. tagCounts: applyTagCountReadDelta(state.tagCounts, [ { keywords: emailInState.keywords, delta }, ]), processingReadStatus: newProcessingSet, // Also update threadEmailsCache so expanded dropdowns reflect the change threadEmailsCache: (() => { let updated = false; const newCache = new Map(state.threadEmailsCache); for (const [tid, cachedEmails] of newCache) { const idx = cachedEmails.findIndex(e => e.id === emailId); if (idx !== -1) { newCache.set(tid, cachedEmails.map((e, i) => i === idx ? { ...e, keywords: { ...e.keywords, $seen: read } } : e )); updated = true; break; } } return updated ? newCache : state.threadEmailsCache; })(), }; }); } 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; // In unified view route to the email's own account (client + that // account's mailbox list, where the destination id lives); otherwise the // active/viewing context. (#281) const { client: actionClient, mailboxes, accountId } = resolveEmailActionContext(email, client); const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId); const jmapDestId = destMailbox?.originalId || destinationMailboxId; await actionClient.moveEmail(emailId, jmapDestId, accountId); set((state) => { // Counter changes go to the email's own account list; source and // destination live in the same account. (#281) const mailboxPatch = applyMailboxCounterUpdate(state, email, (mailbox) => { if (emailInMailbox(email, mailbox)) { 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), ...mailboxPatch, }; }); } 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 by owning JMAP account // and dispatch through the login client that can reach each one. The login // client is keyed by `sourceClientAccountId` (a real AccountEntry.id); the // owning account is `sourceAccountId` (passed for owner-scoped routing). const bySource = new Map(); for (const e of affected) { const key = e.sourceAccountId || '__default__'; if (!bySource.has(key)) bySource.set(key, { clientAccountId: e.sourceClientAccountId, ids: [] }); bySource.get(key)!.ids.push(e.id); } await Promise.all(Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => { const acctClient = sourceAccountId === '__default__' ? resolveActionClient(client) : (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined); if (!acctClient) return; const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId; await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId); })); } 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; for (const e of affected) { if (!e.keywords?.$seen) unreadDelta += 1; } const movedCount = affected.length; set((state) => { // Decrement source folders per the email's own account list (#281). const patch = applyBatchMailboxCounterUpdate(state, affected, (mb, group) => { let dTotal = 0; let dUnread = 0; for (const e of group as Email[]) { if (emailInMailbox(e, mb)) { dTotal--; if (!e.keywords?.$seen) dUnread--; } } return dTotal === 0 && dUnread === 0 ? mb : { ...mb, totalEmails: Math.max(0, mb.totalEmails + dTotal), unreadEmails: Math.max(0, mb.unreadEmails + dUnread), }; }); // The destination folder (picked from the active/viewing list) gains them. patch.mailboxes = patch.mailboxes.map(mb => mb.id === destinationMailboxId ? { ...mb, totalEmails: mb.totalEmails + movedCount, unreadEmails: mb.unreadEmails + unreadDelta } : mb); return { 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; })(), ...patch, }; }); } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to move emails' }); throw error; } }, crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId, sourceJmapAccountId) => { 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) => { // When the source is a delegated/shared mailbox, the email, // its blob, and the destroy all live in the owner's JMAP // account, not the source client's primary one. const full = await sourceClient.getEmail(emailId, sourceJmapAccountId); if (!full?.blobId) { throw new Error('Source email has no raw blob to copy'); } const blob = await sourceClient.fetchBlob(full.blobId, undefined, undefined, sourceJmapAccountId); const keywords: Record = { ...(full.keywords ?? {}) }; await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId); await sourceClient.deleteEmail(emailId, sourceJmapAccountId); 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; } // In unified view route to the email's own account (client + that // account's mailbox list); otherwise the active/viewing context. (#281) const { client: effectiveClient, mailboxes, accountId } = resolveEmailActionContext(email, client); 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, crossView, selectedMailbox, searchFilters } = get(); const emailsPerPage = useSettingsStore.getState().emailsPerPage; let result; let accountId; let unifiedErrors; if (isUnifiedView && crossView) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0); unifiedErrors = result.errors; } else if (isUnifiedView && unifiedRole) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0); unifiedErrors = result.errors; } else { // Get the current mailbox to scope the search. 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 accountId = mailbox?.isShared ? mailbox.accountId : undefined; result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); } const hookEdit = await emailHooks.onSearchResults.transform({ newEmailIds: [] as string[], result: result, query: query, filters: searchFilters }); result = hookEdit.result; if (hookEdit.newEmailIds.length > 0) { // in unified, accountId will be undefined and we will use the default. const newEmails = await resolveActionClient(client).getSomeEmails(hookEdit.newEmailIds, accountId); result.emails.push(...newEmails); result.total += newEmails.length; } const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: searchFilters }); result.emails = await emailHooks.onEmailsFetched.transform(result.emails); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), externalSearchResults: externals, hasMoreEmails: result.hasMore, totalEmails: result.total, isLoading: false, ...(unifiedErrors ? { unifiedErrors } : {}) }); } 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, crossView } = 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; let result; let accountId; let unifiedErrors; if (isUnifiedView && crossView) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); // Cross views apply the advanced filter (text + fields) on top of the // view membership; an empty filter degrades to a plain membership query. result = await advancedSearchCrossViewEmails( built, crossView, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0, ); unifiedErrors = result.errors; } else if (isUnifiedView && unifiedRole) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); result = await advancedSearchUnifiedEmails( built, unifiedRole, (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId), emailsPerPage, 0, ); unifiedErrors = result.errors; } else { const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); const jmapMailboxId = mailbox?.originalId || selectedMailbox; accountId = mailbox?.isShared ? mailbox.accountId : undefined; const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); } if (controller.signal.aborted) return; const hookEdit = await emailHooks.onSearchResults.transform({ newEmailIds: [] as string[], result: result, query: searchQuery, filters: searchFilters }); result = hookEdit.result; if (hookEdit.newEmailIds.length > 0) { const newEmails = await resolveActionClient(client).getSomeEmails(hookEdit.newEmailIds, accountId); result.emails.push(...newEmails); result.total += newEmails.length; } if (controller.signal.aborted) return; const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters }); if (controller.signal.aborted) return; result.emails = await emailHooks.onEmailsFetched.transform(result.emails); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), externalSearchResults: externals, hasMoreEmails: result.hasMore, totalEmails: result.total, isLoading: false, searchAbortController: null, ...(unifiedErrors ? { unifiedErrors } : {}) }); } 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; // In unified view route to the email's own account client + owner accountId // (the reaching client's primary is not the owner for shared sources). (#281) const { client: actionClient, accountId } = resolveEmailActionContext(email, client); await actionClient.toggleStar(emailId, !isFlagged, accountId); // 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) => { // This patch replaces the whole keyword map, so it can flip $seen as well // as labels. Only a genuine read-state change moves the tag unread counts. const previous = state.emails.find(e => e.id === emailId) ?? state.selectedEmail; const wasRead = previous?.keywords?.$seen ?? false; const isRead = keywords.$seen ?? false; const delta = wasRead === isRead ? 0 : (isRead ? -1 : 1); return { emails: state.emails.map(e => e.id === emailId ? { ...e, keywords: { ...keywords } } : e ), selectedEmail: state.selectedEmail?.id === emailId ? { ...state.selectedEmail, keywords: { ...keywords } } : state.selectedEmail, tagCounts: applyTagCountReadDelta(state.tagCounts, [{ keywords, delta }]), }; }); }, // Batch operations batchMarkAsRead: async (client, read) => { const { selectedEmailIds, emails } = get(); if (selectedEmailIds.size === 0) return; set({ isLoading: true, error: null }); try { const emailIdsArray = Array.from(selectedEmailIds); if (get().isUnifiedView) { // Group by owning JMAP account; dispatch through the reaching login client. const bySource = new Map(); for (const emailId of emailIdsArray) { const email = emails.find(e => e.id === emailId); const key = email?.sourceAccountId || '__default__'; if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] }); bySource.get(key)!.ids.push(emailId); } const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => { const acctClient = sourceAccountId === '__default__' ? resolveActionClient(client) : (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined); if (!acctClient) return; const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId; await acctClient.batchMarkAsRead(ids, read, jmapAccountId); }); await Promise.allSettled(promises); } else { // Non-unified: route to the viewed shared/group account (if any), reached // through the active client; undefined for a normal own-account view. await resolveActionClient(client).batchMarkAsRead(emailIdsArray, read, resolveViewAccountId()); } // Update local state const updatedEmails = emails.map(email => selectedEmailIds.has(email.id) ? { ...email, keywords: { ...email.keywords, $seen: read } } : email ); // Update mailbox counters per the email's own account list (#281). const affectedEmails = emails.filter(e => selectedEmailIds.has(e.id)); const mailboxPatch = applyBatchMailboxCounterUpdate(get(), affectedEmails, (mailbox, group) => { let deltaUnread = 0; for (const email of group as Email[]) { if (emailInMailbox(email, mailbox) && (email.keywords?.$seen ?? false) !== read) { deltaUnread += read ? -1 : 1; } } return deltaUnread === 0 ? mailbox : { ...mailbox, unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnread), unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread), }; }); // Tag badges follow the same delta as the folder counters, counting only // the emails whose read state actually changed. const tagCounts = applyTagCountReadDelta( get().tagCounts, affectedEmails .filter(email => (email.keywords?.$seen ?? false) !== read) .map(email => ({ keywords: email.keywords, delta: read ? -1 : 1 })), ); set({ emails: updatedEmails, ...mailboxPatch, tagCounts, 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 owning JMAP account (handles unified view and search results // spanning accounts). Each group resolves the reaching login client via // `sourceClientAccountId` and routes JMAP via `sourceAccountId`. Undecorated // emails (normal single-mailbox view) fall into '__default__' = active client. const accountMailboxes = get().accountMailboxes; const bySource = new Map(); for (const emailId of emailIdsArray) { const email = emails.find(e => e.id === emailId); const key = email?.sourceAccountId || '__default__'; if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] }); bySource.get(key)!.ids.push(emailId); } // Undecorated emails viewed in a shared/group folder directly (non-unified) // are reached through the active client but must carry the owner accountId, // or the move/destroy silently no-ops on the user's own account (see // resolveViewAccountId). undefined for a normal own-account view. const viewAccountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined; const getClient = (sourceAccountId: string, clientAccountId?: string) => sourceAccountId === '__default__' ? resolveActionClient(client) : (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined); const mailboxesFor = (sourceAccountId: string) => sourceAccountId === '__default__' ? (viewAccountId ? (accountMailboxes[viewAccountId] ?? mailboxes) : mailboxes) : (accountMailboxes[sourceAccountId] ?? mailboxes); const jmapIdFor = (sourceAccountId: string) => sourceAccountId === '__default__' ? viewAccountId : sourceAccountId; if (forceDestroy) { const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => { const acctClient = getClient(sourceAccountId, clientAccountId); if (!acctClient) return; await acctClient.batchDeleteEmails(ids, jmapIdFor(sourceAccountId)); }); await Promise.allSettled(promises); } else { // Move to trash per account. const failedAccounts: string[] = []; const movedEmailIds = new Set(); const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => { const acctClient = getClient(sourceAccountId, clientAccountId); if (!acctClient) { failedAccounts.push(sourceAccountId); return; } const trashMailbox = findTrashMailbox(mailboxesFor(sourceAccountId), { accountId: jmapIdFor(sourceAccountId), }); if (!trashMailbox) { // No trash for this account: skip rather than silently destroying. // The user asked to move to trash, not permanently delete. failedAccounts.push(sourceAccountId); return; } const trashId = trashMailbox.originalId || trashMailbox.id; await acctClient.batchMoveEmails(ids, trashId, jmapIdFor(sourceAccountId), 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 mailboxPatch = applyBatchMailboxCounterUpdate(get(), deletedEmails, applyDeleteCounters); set({ emails: remainingEmails, ...mailboxPatch, 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 per the email's own account list (#281). const deletedEmails = emails.filter(e => selectedEmailIds.has(e.id)); const mailboxPatch = applyBatchMailboxCounterUpdate(get(), deletedEmails, applyDeleteCounters); set({ emails: remainingEmails, ...mailboxPatch, 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 by owning JMAP account; dispatch through the reaching login client. const destMailbox = resolveActionMailboxes().find(mb => mb.id === toMailboxId); const jmapDestId = destMailbox?.originalId || toMailboxId; const bySource = new Map(); for (const emailId of emailIdsArray) { const email = emails.find(e => e.id === emailId); const key = email?.sourceAccountId || '__default__'; if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] }); bySource.get(key)!.ids.push(emailId); } const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => { const acctClient = sourceAccountId === '__default__' ? resolveActionClient(client) : (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined); if (!acctClient) return; const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId; await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId); }); await Promise.allSettled(promises); } else { // Non-unified: route to the viewed shared/group account (if any), reached // through the active client. Resolve the destination to its bare owner id // (`originalId`) since shared folders use namespaced store ids. const viewAccountId = resolveViewAccountId(); const destMailbox = resolveActionMailboxes().find(mb => mb.id === toMailboxId); const jmapDestId = destMailbox?.originalId || toMailboxId; await resolveActionClient(client).batchMoveEmails(emailIdsArray, jmapDestId, viewAccountId); } // 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; // Scope the archive folder to the viewed shared/group account (if any) so the // move lands on the owner account, not the user's own archive (which appears // first in the merged list); see resolveViewAccountId. Own view is unchanged. const viewAccountId = resolveViewAccountId(); const isArchive = (m: Mailbox) => m.role === 'archive' || m.name.toLowerCase() === 'archive'; const archiveMailbox = mailboxes.find(m => isArchive(m) && (viewAccountId ? m.accountId === viewAccountId : !m.isShared) ); 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 email = get().emails.find(e => e.id === emailId); if (!email) return; // In unified view route to the email's own account (client + that account's // mailbox list); otherwise the active/viewing context. (#281) const { client: actionClient, mailboxes, accountId } = resolveEmailActionContext(email, client); // The email's current mailbox is what undo restores it to. In unified view // derive it from the email's own folders (preferring the unified role), // otherwise the active account's selected mailbox. const currentMailbox = get().isUnifiedView ? (mailboxes.find(mb => emailInMailbox(email, mb) && mb.role === get().unifiedRole) ?? mailboxes.find(mb => emailInMailbox(email, mb))) : mailboxes.find(m => m.id === get().selectedMailbox); if (!currentMailbox) return; get().spamUndoCache.set(emailId, { emailId, originalMailboxId: currentMailbox.originalId || currentMailbox.id, accountId, sourceClientAccountId: get().isUnifiedView ? email.sourceClientAccountId : undefined, }); try { const isUnread = !email.keywords?.$seen; const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread; await actionClient.markAsSpam(emailId, accountId, alsoMarkRead); // Same junk resolution the client used for the move itself. const junkMailbox = mailboxes.find(m => accountId ? (m.role === 'junk' && m.accountId === accountId) : (m.role === 'junk' && !m.isShared) ); // After marking read in the same request, the email arrives in junk as read. const arrivesUnread = isUnread && !alsoMarkRead; set(state => { // Counter changes go to the email's own account list; source and junk // live in the same account. (#281) const mailboxPatch = applyMailboxCounterUpdate(state, email, (mailbox) => { if (emailInMailbox(email, mailbox)) { 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 (junkMailbox && mailbox.id === junkMailbox.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), ...mailboxPatch, }; }); } 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; // In unified view the cache records the email's owning account so the undo // is routed to that account's client, not the active one. (#281) let undoClient: IJMAPClient = resolveActionClient(client); if (cachedData) { // Use cached original mailbox (more accurate for immediate undo) targetMailboxId = cachedData.originalMailboxId; accountId = cachedData.accountId; if (cachedData.sourceClientAccountId) { undoClient = useAuthStore.getState().getClientForAccount(cachedData.sourceClientAccountId) ?? undoClient; } get().spamUndoCache.delete(emailId); } else { // Generic "not spam" (button/menu, no undo cache). In aggregate views // (e.g. "All Junk") route to the email's own account so shared/group // messages move back to *their* inbox via *their* client, not the active // account's. (#281) const listEmail = get().emails.find(e => e.id === emailId); let inboxMailboxes = mailboxes; if (get().isUnifiedView && listEmail?.sourceClientAccountId && listEmail?.sourceAccountId) { undoClient = useAuthStore.getState().getClientForAccount(listEmail.sourceClientAccountId) ?? undoClient; accountId = listEmail.sourceAccountId; inboxMailboxes = get().accountMailboxes[listEmail.sourceAccountId] ?? mailboxes; } else { const currentMailbox = mailboxes.find(m => m.id === selectedMailbox); accountId = currentMailbox?.accountId; } // Find inbox in same account const inboxMailbox = inboxMailboxes.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 undoClient.undoSpam(emailId, targetMailboxId, accountId); // Drop the restored mail from the current junk view and advance the open // message to the next one, mirroring markAsSpam. Without this the viewer // stays stuck on the mail that just left the folder. set(state => ({ emails: state.emails.filter(e => e.id !== emailId), selectedEmail: getNextSelectedEmail(state, emailId), })); // Refresh the view the user is actually looking at. if (get().isUnifiedView && get().crossView) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const accounts = await buildUnifiedAccountClients({ includeGroup }); await get().fetchCrossView(accounts, get().crossView!); } else if (get().isUnifiedView && get().unifiedRole) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const accounts = await buildUnifiedAccountClients({ includeGroup }); await get().fetchUnifiedEmails(accounts, get().unifiedRole!); } else { await get().fetchEmails(client, selectedMailbox); } // Folder counts don't move on their own without a reliable JMAP push // (often absent on subpath/cross-origin deploys), so refresh them here so // the junk folder's badge drops right away. void get().fetchMailboxes(client); } 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); } // Adjust counters and drop the emails from the current view. Junk is // resolved the same way the client did for the move itself. const affected = emails.filter(e => emailIds.includes(e.id)); const junkMailbox = mailboxes.find(m => currentMailbox.accountId ? (m.role === 'junk' && m.accountId === currentMailbox.accountId) : (m.role === 'junk' && !m.isShared) ); let junkTotal = 0; let junkUnread = 0; for (const e of affected) { junkTotal += 1; // After marking read in the same request, the email arrives in junk as read. if (!e.keywords?.$seen && !alsoMarkRead) junkUnread += 1; } set(state => { // Decrement source folders per the email's own account list (#281). const patch = applyBatchMailboxCounterUpdate(state, affected, (mb, group) => { let dTotal = 0; let dUnread = 0; for (const e of group as Email[]) { if (emailInMailbox(e, mb)) { dTotal--; if (!e.keywords?.$seen) dUnread--; } } return dTotal === 0 && dUnread === 0 ? mb : { ...mb, totalEmails: Math.max(0, mb.totalEmails + dTotal), unreadEmails: Math.max(0, mb.unreadEmails + dUnread), }; }); // The junk folder (picked from the active/viewing list) gains them. if (junkMailbox) { patch.mailboxes = patch.mailboxes.map(mb => mb.id === junkMailbox.id ? { ...mb, totalEmails: mb.totalEmails + junkTotal, unreadEmails: mb.unreadEmails + junkUnread } : mb); } return { emails: state.emails.filter(e => !emailIds.includes(e.id)), selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail, selectedEmailIds: new Set(), ...patch, }; }); } 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); } // Adjust counters and drop the restored emails from the junk view. const affected = get().emails.filter(e => emailIds.includes(e.id)); let unreadDelta = 0; for (const e of affected) { if (!e.keywords?.$seen) unreadDelta += 1; } set(state => { // Decrement source folders per the email's own account list (#281). const patch = applyBatchMailboxCounterUpdate(state, affected, (mb, group) => { let dTotal = 0; let dUnread = 0; for (const e of group as Email[]) { if (emailInMailbox(e, mb)) { dTotal--; if (!e.keywords?.$seen) dUnread--; } } return dTotal === 0 && dUnread === 0 ? mb : { ...mb, totalEmails: Math.max(0, mb.totalEmails + dTotal), unreadEmails: Math.max(0, mb.unreadEmails + dUnread), }; }); // The inbox (picked from the active/viewing list) gains them. patch.mailboxes = patch.mailboxes.map(mb => mb.id === inboxMailbox.id ? { ...mb, totalEmails: mb.totalEmails + affected.length, unreadEmails: mb.unreadEmails + unreadDelta } : mb); return { emails: state.emails.filter(e => !emailIds.includes(e.id)), selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail, selectedEmailIds: new Set(), ...patch, }; }); } 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(); // Changes may arrive for the client's primary account OR a delegated // shared/group owner it has access to. Active-account *view* concerns // (current email list, scheduled, calendar, filters) key off the primary // account only, but the mailbox-COUNT refresh must react to any changed // account: the active client's getAllMailboxes returns own + delegated // folders, and the unified-section counts project from that list. (#281) const accountChanges = change.changed[accountId]; const anyMailboxChanged = Object.values(change.changed).some((c) => c?.Mailbox); // 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 (own + delegated // shared folders), so both the active account's and its shared folders' // counters follow background activity. if (anyMailboxChanged) { 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, undefined, true); } 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. // Pinned mails sit above the date order, so the newest mail is the // first NON-pinned entry (a just-arrived mail cannot be pinned yet). const newFirst = result.emails.find(e => !e.keywords?.['$pinned']) ?? 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); // Invalidate thread email caches for threads whose composition changed // so expanded threads pick up new/removed emails. const prevThreadIds = new Set(currentEmails.map(e => e.threadId)); const nextThreadIds = new Set(merged.map(e => e.threadId)); const changedThreadIds = new Set(); for (const tid of prevThreadIds) { if (!nextThreadIds.has(tid)) changedThreadIds.add(tid); } for (const tid of nextThreadIds) { if (!prevThreadIds.has(tid)) changedThreadIds.add(tid); } // Also check threads where the set of email IDs changed const prevEmailsByThread = new Map>(); for (const e of currentEmails) { if (!prevEmailsByThread.has(e.threadId)) prevEmailsByThread.set(e.threadId, new Set()); prevEmailsByThread.get(e.threadId)!.add(e.id); } const nextEmailsByThread = new Map>(); for (const e of merged) { if (!nextEmailsByThread.has(e.threadId)) nextEmailsByThread.set(e.threadId, new Set()); nextEmailsByThread.get(e.threadId)!.add(e.id); } for (const [tid, nextIds] of nextEmailsByThread) { const prevIds = prevEmailsByThread.get(tid); if (!prevIds || prevIds.size !== nextIds.size) { changedThreadIds.add(tid); } else { for (const id of nextIds) { if (!prevIds.has(id)) { changedThreadIds.add(tid); break; } } } } set((state) => { const newCache = new Map(state.threadEmailsCache); for (const tid of changedThreadIds) { newCache.delete(tid); } return { emails: merged, hasMoreEmails: hasMore, totalEmails: result.total, threadEmailsCache: newCache, }; }); // Re-fetch cross-folder thread data for any currently expanded threads // so they show the complete conversation (not just current-folder emails). const expandedNow = get().expandedThreadIds; if (expandedNow.size > 0) { const effectiveClient2 = resolveActionClient(client); const accountId = effectiveClient2.getAccountId(); for (const tid of expandedNow) { void effectiveClient2.getThreadEmails(tid, accountId).then((fullEmails) => { if (fullEmails.length > 0) { set((state) => { const c = new Map(state.threadEmailsCache); c.set(tid, fullEmails); return { threadEmailsCache: c }; }); } }); } } // Fetch full thread counts in the background (non-blocking) void get().fetchThreadEmailCounts(client); } } 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 { // Route to the thread's own account. In aggregate views `selectedMailbox` // is virtual, so derive the client + accountId from a list email of this // thread (handles shared/group accounts); otherwise fall back to the // selected-mailbox shared-folder logic. (#281) const threadEmail = get().emails.find(e => e.threadId === threadId); let actionClient = resolveActionClient(client); let accountId: string | undefined; if (get().isUnifiedView && threadEmail?.sourceClientAccountId && threadEmail?.sourceAccountId) { actionClient = useAuthStore.getState().getClientForAccount(threadEmail.sourceClientAccountId) ?? actionClient; accountId = threadEmail.sourceAccountId; } else { const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); accountId = mailbox?.isShared ? mailbox.accountId : undefined; } // Fetch all emails in the thread const emails = await actionClient.getThreadEmails(threadId, accountId); // Re-stamp the source reference so actions on thread emails resolve to the // right account (the fetched objects don't carry it). if (get().isUnifiedView && threadEmail) { for (const e of emails) { e.accountId = threadEmail.accountId; e.accountLabel = threadEmail.accountLabel; e.sourceClientAccountId = threadEmail.sourceClientAccountId; e.sourceAccountId = threadEmail.sourceAccountId; } } // 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 []; } }, markThreadAsRead: async (client, threadId) => { const state = get(); const threadEmails = state.threadEmailsCache.get(threadId) ?? []; const mainEmails = state.emails.filter(e => e.threadId === threadId); // Combine unique emails from both sources const allEmailMap = new Map(); for (const e of mainEmails) allEmailMap.set(e.id, e); for (const e of threadEmails) allEmailMap.set(e.id, e); const unreadIds = Array.from(allEmailMap.values()) .filter(e => !e.keywords?.$seen) .map(e => e.id); if (unreadIds.length === 0) return; // Group by account for unified view support const emailsById = new Map(); for (const e of allEmailMap.values()) emailsById.set(e.id, e); // Group unread IDs by account client const groups = new Map(); for (const id of unreadIds) { const email = emailsById.get(id)!; const { client: actionClient } = resolveEmailActionContext(email, client); if (!groups.has(actionClient)) groups.set(actionClient, []); groups.get(actionClient)!.push(id); } // Mark all as read on the server try { await Promise.all( Array.from(groups.entries()).map(([actionClient, emailIds]) => actionClient.batchMarkAsRead(emailIds, true) ) ); } catch (error) { console.error('Failed to mark thread as read:', error); return; } // Update local state set((state) => { const unreadSet = new Set(unreadIds); const updatedEmails = state.emails.map(e => unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e ); // Update threadEmailsCache const newCache = new Map(state.threadEmailsCache); const cached = newCache.get(threadId); if (cached) { newCache.set(threadId, cached.map(e => unreadSet.has(e.id) ? { ...e, keywords: { ...e.keywords, $seen: true } } : e )); } // Update mailbox unread counters in the thread's own account list (#281). const affectedEmails = state.emails.filter(e => unreadSet.has(e.id)); const mailboxPatch = applyBatchMailboxCounterUpdate(state, affectedEmails, (mailbox, group) => { let delta = 0; for (const email of group as Email[]) { if (emailInMailbox(email, mailbox)) delta -= 1; } return delta === 0 ? mailbox : { ...mailbox, unreadEmails: Math.max(0, mailbox.unreadEmails + delta), unreadThreads: Math.max(0, mailbox.unreadThreads + delta), }; }); return { emails: updatedEmails, threadEmailsCache: newCache, ...mailboxPatch, selectedEmail: state.selectedEmail && unreadSet.has(state.selectedEmail.id) ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } } : state.selectedEmail, }; }); }, collapseAllThreads: () => { set({ expandedThreadIds: new Set(), isLoadingThread: null }); }, updateThreadCache: (threadId, emails) => { const newCache = new Map(get().threadEmailsCache); newCache.set(threadId, emails); set({ threadEmailsCache: newCache }); }, fetchThreadEmailCounts: async (client) => { const { emails } = get(); if (emails.length === 0) return; const uniqueThreadIds = [...new Set(emails.map(e => e.threadId).filter(Boolean))]; if (uniqueThreadIds.length === 0) return; try { const effectiveClient = resolveActionClient(client); const threads = await effectiveClient.getThreads(uniqueThreadIds); const newCounts = new Map(get().threadEmailCounts); for (const thread of threads) { newCounts.set(thread.id, thread.emailIds?.length ?? 0); } set({ threadEmailCounts: newCounts }); } catch { // Non-critical — fall back to inbox-only counts } }, // 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; } }, reorderMailboxes: async (client, orderedIds) => { // Assign a 1-based sortOrder to the given sibling group in its new order. // sortOrder is the primary sort key (see buildMailboxTree), so this pins // the folders' arrangement in the sidebar and settings. const updates = orderedIds.map((id, idx) => ({ id, sortOrder: idx + 1 })); const applyLocal = (list: Mailbox[]) => list.map(mb => { const u = updates.find(x => x.id === mb.id); return u ? { ...mb, sortOrder: u.sortOrder } : mb; }); // Optimistic local update so the reorder is reflected immediately. const viewingId = get().viewingAccountId; if (viewingId) { set((state) => ({ accountMailboxes: { ...state.accountMailboxes, [viewingId]: applyLocal(state.accountMailboxes[viewingId] ?? []), }, })); } else { set({ mailboxes: applyLocal(get().mailboxes) }); } try { const effectiveClient = resolveActionClient(client); for (const u of updates) { await effectiveClient.updateMailbox(u.id, { sortOrder: u.sortOrder }); } } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to reorder folders' }); // Re-sync from server so local state doesn't drift from persisted order. if (viewingId) { await refreshMailboxesForViewingAccount(client); } else { await get().fetchMailboxes(client); } throw error; } }, emptyMailbox: async (client, mailboxId) => { try { set({ isLoading: true, error: null }); const mailbox = resolveActionMailboxes().find(mb => mb.id === mailboxId); const accountId = mailbox?.isShared ? mailbox.accountId : undefined; const jmapMailboxId = mailbox?.originalId || mailboxId; await resolveActionClient(client).emptyMailbox(jmapMailboxId, accountId); // 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 ), })); // Tag counts are refetched here rather than adjusted with a local delta // (as markAsRead/batchMarkAsRead do). This is a server-side bulk operation // over the *whole* mailbox, so it also marks emails that were never loaded // into `state.emails` - a local delta would only see the loaded page and // would leave the tag counts drifting high. Fire-and-forget: the folder // counters above already update instantly. void get().fetchTagCounts(client); 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, crossView: null, 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 { // Store the scope and project live counters over it. The badges then track // the live mailbox lists via the store subscription (below), so subsequent // optimistic mutations update them without another build/fetch. set((state) => { const next = { ...state, unifiedScope: accounts }; return { unifiedScope: accounts, ...projectUnifiedCounts(next) }; }); } catch (error) { console.error('Failed to refresh unified counts:', error); } }, fetchCrossView: async (accounts, view) => { set({ isLoading: true, error: null, isUnifiedView: true, unifiedRole: null, crossView: view, selectedKeyword: null, }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const result = await fetchCrossViewEmails(accounts, view, emailsPerPage, 0); set({ emails: result.emails, hasMoreEmails: result.hasMore, totalEmails: result.total, isLoading: false, unifiedErrors: result.errors, }); } catch (error) { console.error('Failed to fetch cross-account view:', error); set({ error: error instanceof Error ? error.message : "Failed to fetch cross-account view", isLoading: false, emails: [], hasMoreEmails: false, totalEmails: 0, }); } }, refreshCrossCounts: (accounts) => { try { set((state) => { const next = { ...state, unifiedScope: accounts }; return { unifiedScope: accounts, ...projectUnifiedCounts(next) }; }); } catch (error) { console.error('Failed to refresh cross-account counts:', error); } }, exitUnifiedView: () => { set({ isUnifiedView: false, unifiedRole: null, crossView: 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, // Search is unavailable in the scheduled view (the input is disabled there). // Reset any active search when entering it so a stale query can't linger or // re-run when the user leaves again. searchQuery: isScheduledView ? "" : state.searchQuery, searchFilters: isScheduledView ? { ...DEFAULT_SEARCH_FILTERS } : state.searchFilters, }; }), clearPendingUndoSend: () => set({ pendingUndoSend: null }), fetchScheduledEmails: async (client) => { set({ isLoadingScheduled: true, error: null }); try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const result = await client.getScheduledEmails(emailsPerPage, 0); result.emails = await emailHooks.onEmailsFetched.transform(result.emails); 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); result.emails = await emailHooks.onEmailsFetched.transform(result.emails); 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, }); }, })); // Keep the unified-section badges in lockstep with the live per-account mailbox // lists. Whenever `mailboxes`, `accountMailboxes`, or the unified scope change - // i.e. after any optimistic delete/move/markRead patch or a push-driven mailbox // refresh - re-project the badges from that single source of truth. The guard // short-circuits on every unrelated state change (emails, loading flags, …) by // reference equality, and our own counter writes don't re-enter the projection // because they leave the three watched lists untouched (no loop). (#281) useEmailStore.subscribe((state, prev) => { if ( state.mailboxes === prev.mailboxes && state.accountMailboxes === prev.accountMailboxes && state.unifiedScope === prev.unifiedScope ) { return; } if (state.unifiedScope.length === 0) return; const projected = projectUnifiedCounts(state); const sameCross = projected.crossUnreadCount === state.crossUnreadCount; const sameUnified = projected.unifiedCounts.length === state.unifiedCounts.length && projected.unifiedCounts.every((c, i) => { const cur = state.unifiedCounts[i]; return cur && cur.role === c.role && cur.unreadEmails === c.unreadEmails && cur.totalEmails === c.totalEmails; }); if (sameCross && sameUnified) return; useEmailStore.setState(projected); });