Files
SRCmail/stores/email-store.ts
T

2515 lines
95 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import type { ExternalSearchResult } from "@/lib/plugin-types";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
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<string, Mailbox[]>;
/**
* 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<string>; // Track emails being marked as read/unread
selectedEmailIds: Set<string>; // 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<string>;
threadEmailsCache: Map<string, Email[]>;
isLoadingThread: string | null;
// Keyword/tag filter
selectedKeyword: string | null;
tagCounts: Record<string, { total: number; unread: number }>;
// Advanced search state
searchFilters: SearchFilters;
isAdvancedSearchOpen: boolean;
searchAbortController: AbortController | null;
/** Plugin-contributed search results (CRM hits, Slack messages, etc.) populated by emailHooks.onProvideSearchResults. */
externalSearchResults: ExternalSearchResult[];
// Unified mailbox state
isUnifiedView: boolean;
unifiedRole: UnifiedMailboxRole | null;
unifiedErrors: Map<string, string>; // accountId -> error message
unifiedCounts: UnifiedMailboxCounts[];
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<void>;
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<void>;
toggleEmailSelection: (emailId: string) => void;
selectRangeEmails: (targetEmailId: string) => void;
lastSelectedEmailId: string | null;
selectAllEmails: () => void;
clearSelection: () => void;
// JMAP operations
fetchMailboxes: (client: IJMAPClient) => Promise<void>;
fetchEmails: (client: IJMAPClient, mailboxId?: string) => Promise<void>;
// 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<void>;
loadMoreEmails: (client: IJMAPClient) => Promise<void>;
fetchEmailContent: (client: IJMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: IJMAPClient) => Promise<void>;
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[], envelopeMailFrom?: string) => Promise<void>;
sendRawEmail: (client: IJMAPClient, rawMimeBlob: Blob, identityId: string) => Promise<void>;
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
moveEmailsToMailbox: (client: IJMAPClient, emailIds: string[], mailboxId: string) => Promise<void>;
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
/**
* 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).
*/
crossAccountMoveEmails: (
emailIdsBySource: Map<string, string[]>,
destAccountId: string,
destMailboxId: string,
) => Promise<void>;
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
advancedSearch: (client: IJMAPClient) => Promise<void>;
setSearchFilters: (filters: Partial<SearchFilters>) => void;
clearSearchFilters: () => void;
toggleAdvancedSearch: () => void;
toggleStar: (client: IJMAPClient, emailId: string) => Promise<void>;
setEmailKeywordsLocal: (emailId: string, keywords: Record<string, boolean>) => void;
// Batch operations
batchMarkAsRead: (client: IJMAPClient, read: boolean) => Promise<void>;
batchDelete: (client: IJMAPClient, permanent?: boolean) => Promise<void>;
batchMoveToMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
batchArchive: (client: IJMAPClient) => Promise<void>;
// Spam operations
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
markAsSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
batchUndoSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
// Push notification handlers
setPushConnected: (connected: boolean) => void;
handleStateChange: (change: StateChange, client: IJMAPClient) => Promise<void>;
refreshCurrentMailbox: (client: IJMAPClient) => Promise<void>;
handleNewEmailNotification: (email: Email) => void;
clearNewEmailNotification: () => void;
// Thread expansion actions
toggleThreadExpansion: (threadId: string) => void;
fetchThreadEmails: (client: IJMAPClient, threadId: string) => Promise<Email[]>;
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
// Mailbox management
createMailbox: (client: IJMAPClient, name: string, parentId?: string) => Promise<void>;
renameMailbox: (client: IJMAPClient, mailboxId: string, name: string) => Promise<void>;
deleteMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
setMailboxRole: (client: IJMAPClient, mailboxId: string, role: string | null) => Promise<void>;
emptyMailbox: (client: IJMAPClient, mailboxId: string) => Promise<void>;
markMailboxAsRead: (client: IJMAPClient, mailboxId: string) => Promise<number>;
// Unified mailbox operations
fetchUnifiedEmails: (accounts: UnifiedAccountClient[], role: UnifiedMailboxRole) => Promise<void>;
loadMoreUnifiedEmails: (accounts: UnifiedAccountClient[]) => Promise<void>;
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
exitUnifiedView: () => 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<string>): 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]));
}
/**
* 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;
}
/**
* 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.
*/
/**
* 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.
*/
async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected);
const allClients = useAuthStore.getState().getAllConnectedClients();
const built: UnifiedAccountClient[] = [];
for (const a of authAccounts) {
const c = allClients.get(a.id);
if (!c) continue;
try {
const mailboxes = await c.getMailboxes();
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes });
} catch {
/* skip account on mailbox fetch failure */
}
}
return built;
}
async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): Promise<void> {
const viewingId = useEmailStore.getState().viewingAccountId;
const client = resolveActionClient(fallbackClient);
try {
const mailboxes = await client.getMailboxes();
if (viewingId) {
useEmailStore.setState((state) => ({
accountMailboxes: { ...state.accountMailboxes, [viewingId]: mailboxes },
}));
} else {
useEmailStore.setState({ mailboxes });
}
} catch (error) {
console.error('Failed to refresh mailboxes after mutation:', error);
}
}
// Find the trash mailbox for a given account scope. Prefers JMAP role, but
// falls back to name matching ("trash" / "deleted") so users with custom or
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
function findTrashMailbox(
mailboxes: Mailbox[],
scope: { accountId?: string; isShared?: boolean }
): Mailbox | undefined {
const matchesScope = (mb: Mailbox): boolean => {
if (scope.accountId) return mb.accountId === scope.accountId;
return !mb.isShared;
};
const byRole = mailboxes.find(mb => mb.role === 'trash' && matchesScope(mb));
if (byRole) return byRole;
return mailboxes.find(mb => {
if (!matchesScope(mb)) return false;
const lower = mb.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
});
}
export const useEmailStore = create<EmailStore>((set, get) => ({
emails: [],
mailboxes: [],
accountMailboxes: {},
viewingAccountId: null,
selectedEmail: null,
selectedMailbox: "",
isLoading: false,
isLoadingEmail: false,
isLoadingMore: false,
error: null,
searchQuery: "",
quota: null,
processingReadStatus: new Set(),
selectedEmailIds: new Set(),
lastSelectedEmailId: null,
hasMoreEmails: false,
totalEmails: 0,
isPushConnected: false,
lastPushUpdate: null,
newEmailNotification: null,
// Thread expansion state
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
// Keyword/tag filter
selectedKeyword: null,
tagCounts: {},
// Advanced search state
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
isAdvancedSearchOpen: false,
searchAbortController: null,
externalSearchResults: [],
// Unified mailbox state
isUnifiedView: false,
unifiedRole: null,
unifiedErrors: new Map(),
unifiedCounts: [],
// Spam undo cache
spamUndoCache: new Map(),
setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }),
setAccountMailboxes: (accountId, mailboxes) => set((state) => ({
accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes },
})),
clearAccountMailboxes: () => set({ accountMailboxes: {} }),
setViewingAccount: (accountId) => set({ viewingAccountId: accountId }),
selectAccountMailbox: (accountId, mailboxId) => set({
viewingAccountId: accountId,
selectedMailbox: mailboxId,
selectedEmail: null,
selectedEmailIds: new Set(),
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
}),
fetchAccountMailboxes: async (client, accountId) => {
try {
const mailboxes = await client.getMailboxes();
// Re-check the cache after the await to avoid stomping a more recent
// fetch that finished while this one was in flight.
set((state) => ({
accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes },
}));
} catch (error) {
console.error(`Failed to fetch mailboxes for account ${accountId}:`, error);
}
},
selectEmail: (email) => {
const prev = get().selectedEmail;
set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId });
if (prev && (!email || email.id !== prev.id)) {
emailHooks.onEmailClose.emitSync(prev);
}
if (email && (!prev || email.id !== prev.id)) {
emailHooks.onEmailOpen.emitSync(email);
}
},
selectKeyword: (keyword) => set({
selectedKeyword: keyword,
selectedEmail: null,
selectedEmailIds: new Set(),
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
}),
fetchTagCounts: async (client) => {
try {
const keywords = useSettingsStore.getState().emailKeywords;
if (keywords.length === 0) {
set({ tagCounts: {} });
return;
}
const tagIds = keywords.map(k => k.id);
const counts = await resolveActionClient(client).getTagCounts(tagIds);
set({ tagCounts: counts });
} catch (error) {
console.error('Failed to fetch tag counts:', error);
}
},
selectMailbox: (mailboxId) => set({
selectedMailbox: mailboxId,
selectedEmail: null,
selectedEmailIds: new Set(),
selectedKeyword: null,
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
isLoadingThread: null,
}),
setLoading: (loading) => set({ isLoading: loading }),
setLoadingEmail: (loading) => set({ isLoadingEmail: loading }),
setError: (error) => set({ error }),
setSearchQuery: (query) => set({ searchQuery: query }),
setQuota: (quota) => set({ quota }),
toggleEmailSelection: (emailId) => {
const { selectedEmailIds } = get();
const newSelection = new Set(selectedEmailIds);
if (newSelection.has(emailId)) {
newSelection.delete(emailId);
} else {
newSelection.add(emailId);
}
set({ selectedEmailIds: newSelection, lastSelectedEmailId: emailId });
},
selectRangeEmails: (targetEmailId) => {
const { emails, lastSelectedEmailId, selectedEmailIds } = get();
const anchorId = lastSelectedEmailId || emails[0]?.id;
if (!anchorId) return;
const anchorIndex = emails.findIndex(e => e.id === anchorId);
const targetIndex = emails.findIndex(e => e.id === targetEmailId);
if (anchorIndex === -1 || targetIndex === -1) return;
const start = Math.min(anchorIndex, targetIndex);
const end = Math.max(anchorIndex, targetIndex);
const newSelection = new Set(selectedEmailIds);
for (let i = start; i <= end; i++) {
newSelection.add(emails[i].id);
}
set({ selectedEmailIds: newSelection });
},
selectAllEmails: () => {
const { emails } = get();
const allIds = new Set(emails.map(e => e.id));
set({ selectedEmailIds: allIds });
},
clearSelection: () => {
set({ selectedEmailIds: new Set(), lastSelectedEmailId: null });
},
// JMAP operations
fetchMailboxes: async (client) => {
// Only toggle the email list's isLoading on the initial load. Background
// refreshes (after a move/archive that may have created new folders) must
// not flash the list's loading state, which hides the results-count bar
// and dims the list while folders re-fetch.
const isInitialLoad = get().mailboxes.length === 0;
if (isInitialLoad) set({ isLoading: true, error: null });
try {
const mailboxes = await client.getAllMailboxes();
// Auto-select inbox if no mailbox is selected or the current selection
// doesn't exist in the fetched list (e.g. after an account switch)
const currentSelectedMailbox = get().selectedMailbox;
const selectionValid = currentSelectedMailbox && 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<void> };
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;
const effectiveClient = resolveActionClient(client);
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === targetMailboxId);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || targetMailboxId;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
// Build keyword filter if a tag is selected
const { selectedKeyword } = get();
const keywordFilter = selectedKeyword ? `$label:${selectedKeyword}` : undefined;
// When filtering by tag, omit the mailbox constraint so emails across
// all folders that carry the tag are returned.
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
});
} catch (error) {
console.error('Failed to fetch emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to fetch emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0
});
}
},
loadMoreEmails: async (client) => {
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword, isUnifiedView, unifiedRole } = get();
// Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return;
// Unified view uses a different fan-out loader. When a search query or
// advanced filter is active we paginate the unified search instead of the
// unified browse, so "load more" matches what's on screen.
if (isUnifiedView && unifiedRole) {
set({ isLoadingMore: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const position = emails.length;
const built = await buildUnifiedAccountClients();
const { searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters);
const result = hasFilters
? await advancedSearchUnifiedEmails(
built,
unifiedRole,
(mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
emailsPerPage,
position,
)
: searchQuery
? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, position)
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position);
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
set({
emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to load more unified emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more emails",
isLoadingMore: false,
});
}
return;
}
set({ isLoadingMore: true, error: null });
try {
const effectiveClient = resolveActionClient(client);
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
// Capture position from current email count before the async call
const position = emails.length;
let result;
const { searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters);
if (searchQuery || hasFilters) {
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
if (hasFilters) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, position);
} else {
result = await effectiveClient.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
}
} else {
// Load more from mailbox
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails).
result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
}
// Use fresh state when merging to avoid overwriting concurrent updates
// (e.g. refreshCurrentMailbox running during the load)
const currentEmails = get().emails;
// Deduplicate: the server may return overlapping results if new emails
// arrived between paginated requests and shifted positions.
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter((e: Email) => !existingIds.has(e.id));
set({
emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false
});
} catch (error) {
console.error('Failed to load more emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more emails",
isLoadingMore: false
});
}
},
fetchEmailContent: async (client, emailId) => {
try {
// Find the selected mailbox to determine accountId (for shared folders)
const selectedMailboxId = get().selectedMailbox;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const email = await resolveActionClient(client).getEmail(emailId, accountId);
if (email) {
set({ selectedEmail: email });
}
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, envelopeMailFrom) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, envelopeMailFrom);
// Refresh handled by UI layer for immediate feedback
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
isLoading: false
});
throw error;
}
},
sendRawEmail: async (client, rawMimeBlob, identityId) => {
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');
await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id);
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
isLoading: false,
});
throw error;
}
},
deleteEmail: async (client, emailId, forceDelete) => {
try {
// Get the email to check if it's unread and which mailboxes it belongs to
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
const isUnread = !email.keywords?.$seen;
const effectiveClient = resolveActionClient(client);
// Get delete action preference from settings
const deleteAction = useSettingsStore.getState().deleteAction;
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = resolveActionMailboxes();
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
// If in junk folder and setting is enabled, permanently delete
const isInJunk = currentMailbox?.role === 'junk';
if (isInJunk && permanentlyDeleteJunk) {
forceDelete = true;
}
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
if (deleteAction === 'trash' && !forceDelete) {
const trashMailbox = findTrashMailbox(mailboxes, { accountId });
if (trashMailbox) {
// Use originalId for shared mailboxes if available
const trashId = trashMailbox.originalId || trashMailbox.id;
await effectiveClient.moveToTrash(emailId, trashId, accountId);
// Remove from local state (email moved to trash, not in current view)
set((state) => {
let updatedMailboxes = state.mailboxes;
// Update counters for source mailbox (email leaving)
if (email.mailboxIds) {
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
totalThreads: Math.max(0, mailbox.totalThreads - 1),
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads
};
}
// Update trash mailbox counters (email arriving)
if (mailbox.id === trashMailbox.id) {
return {
...mailbox,
totalEmails: mailbox.totalEmails + 1,
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
totalThreads: mailbox.totalThreads + 1,
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
};
}
return mailbox;
});
}
return {
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: getNextSelectedEmail(state, emailId),
mailboxes: updatedMailboxes
};
});
return;
}
// No trash folder found in this account. Surface the failure rather
// than silently destroying the email - the user asked to move it to
// trash, not to permanently delete it.
throw new Error('Trash mailbox not found - cannot move email to trash');
}
// Permanent delete
await effectiveClient.deleteEmail(emailId);
// Remove from local state and update mailbox counters if needed
set((state) => {
let updatedMailboxes = state.mailboxes;
// If the email was unread, decrement the unread counters
if (isUnread && email.mailboxIds) {
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
unreadEmails: Math.max(0, mailbox.unreadEmails - 1),
totalThreads: Math.max(0, mailbox.totalThreads - 1),
unreadThreads: Math.max(0, mailbox.unreadThreads - 1)
};
}
return mailbox;
});
} else if (email.mailboxIds) {
// If email was read, only decrement total counters
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
totalThreads: Math.max(0, mailbox.totalThreads - 1)
};
}
return mailbox;
});
}
return {
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: getNextSelectedEmail(state, emailId),
mailboxes: updatedMailboxes
};
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to delete email"
});
throw error;
}
},
markAsRead: async (client, emailId, read) => {
try {
// Check if this email is already being processed
const processingKey = `${emailId}-${read}`;
const currentProcessing = get().processingReadStatus;
if (currentProcessing.has(processingKey)) {
return; // Already being processed
}
// Get the email to check its current state and mailboxes
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
// Check if already in the desired state
const isCurrentlyRead = email.keywords?.$seen === true;
if (isCurrentlyRead === read) {
return; // Already in desired state
}
// Add to processing set
set((state) => ({
processingReadStatus: new Set([...state.processingReadStatus, processingKey])
}));
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
await resolveActionClient(client).markAsRead(emailId, read, accountId);
// Update local state including mailbox counters
set((state) => {
// Remove from processing set
const newProcessingSet = new Set(state.processingReadStatus);
newProcessingSet.delete(processingKey);
// Only update counters if the state is actually changing
const emailInState = state.emails.find(e => e.id === emailId);
if (!emailInState) return { processingReadStatus: newProcessingSet };
const wasRead = emailInState.keywords?.$seen === true;
if (wasRead === read) {
return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update
}
const updatedMailboxes = state.mailboxes.map(mailbox => {
// Check if this email belongs to this mailbox
if (emailInState.mailboxIds && emailInState.mailboxIds[mailbox.id]) {
// Adjust unread counter: -1 if marking as read, +1 if marking as unread
const delta = read ? -1 : 1;
return {
...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + delta),
unreadThreads: Math.max(0, mailbox.unreadThreads + delta)
};
}
return mailbox;
});
return {
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...e.keywords, $seen: read } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } }
: state.selectedEmail,
mailboxes: updatedMailboxes,
processingReadStatus: newProcessingSet
};
});
} catch (error) {
// Remove from processing set on error
set((state) => {
const newProcessingSet = new Set(state.processingReadStatus);
newProcessingSet.delete(`${emailId}-${read}`);
return {
processingReadStatus: newProcessingSet,
error: error instanceof Error ? error.message : "Failed to update email"
};
});
throw error;
}
},
moveToMailbox: async (client, emailId, destinationMailboxId) => {
try {
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
const isUnread = !email.keywords?.$seen;
const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : [];
const { selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
await resolveActionClient(client).moveEmail(emailId, jmapDestId, accountId);
set((state) => {
const updatedMailboxes = state.mailboxes.map(mailbox => {
if (currentMailboxIds.includes(mailbox.id)) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
unreadEmails: isUnread ? Math.max(0, mailbox.unreadEmails - 1) : mailbox.unreadEmails,
totalThreads: Math.max(0, mailbox.totalThreads - 1),
unreadThreads: isUnread ? Math.max(0, mailbox.unreadThreads - 1) : mailbox.unreadThreads
};
}
if (mailbox.id === destinationMailboxId) {
return {
...mailbox,
totalEmails: mailbox.totalEmails + 1,
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
totalThreads: mailbox.totalThreads + 1,
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
};
}
return mailbox;
});
return {
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: getNextSelectedEmail(state, emailId),
mailboxes: updatedMailboxes
};
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move email"
});
throw error;
}
},
moveEmailsToMailbox: async (client, emailIds, destinationMailboxId) => {
if (emailIds.length === 0) return;
if (emailIds.length === 1) {
await get().moveToMailbox(client, emailIds[0], destinationMailboxId);
return;
}
try {
const { emails, selectedMailbox, isUnifiedView } = get();
const mailboxes = resolveActionMailboxes();
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
const idSet = new Set(emailIds);
const affected = emails.filter(e => idSet.has(e.id));
if (isUnifiedView) {
// In unified view, emails may span accounts group and dispatch per-account.
const byAccount = new Map<string, string[]>();
for (const e of affected) {
const acct = e.accountId || '__default__';
if (!byAccount.has(acct)) byAccount.set(acct, []);
byAccount.get(acct)!.push(e.id);
}
await Promise.all(Array.from(byAccount.entries()).map(async ([acct, ids]) => {
const acctClient = acct === '__default__' ? client : useAuthStore.getState().getClientForAccount(acct);
if (!acctClient) return;
await acctClient.batchMoveEmails(ids, jmapDestId);
}));
} else {
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
await resolveActionClient(client).batchMoveEmails(emailIds, jmapDestId, accountId);
}
// Adjust counters and drop moved emails from the current view.
let unreadDelta = 0;
const sourceMailboxIds = new Set<string>();
for (const e of affected) {
if (!e.keywords?.$seen) unreadDelta += 1;
if (e.mailboxIds) for (const mid of Object.keys(e.mailboxIds)) sourceMailboxIds.add(mid);
}
const movedCount = affected.length;
set((state) => ({
emails: state.emails.filter(e => !idSet.has(e.id)),
selectedEmail: state.selectedEmail && idSet.has(state.selectedEmail.id) ? null : state.selectedEmail,
selectedEmailIds: (() => {
const next = new Set(state.selectedEmailIds);
for (const id of idSet) next.delete(id);
return next;
})(),
mailboxes: state.mailboxes.map(mb => {
if (sourceMailboxIds.has(mb.id)) {
return {
...mb,
totalEmails: Math.max(0, mb.totalEmails - movedCount),
unreadEmails: Math.max(0, mb.unreadEmails - unreadDelta),
};
}
if (mb.id === destinationMailboxId) {
return {
...mb,
totalEmails: mb.totalEmails + movedCount,
unreadEmails: mb.unreadEmails + unreadDelta,
};
}
return mb;
}),
}));
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to move emails' });
throw error;
}
},
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId) => {
if (emailIdsBySource.size === 0) return;
set({ isLoading: true, error: null });
try {
const destClient = useAuthStore.getState().getClientForAccount(destAccountId);
if (!destClient) {
throw new Error('Destination account is not connected');
}
const movedIds: string[] = [];
const failures: Array<{ emailId: string; error: string }> = [];
for (const [sourceAccountId, emailIds] of emailIdsBySource.entries()) {
const sourceClient = useAuthStore.getState().getClientForAccount(sourceAccountId);
if (!sourceClient) {
for (const emailId of emailIds) {
failures.push({ emailId, error: 'Source account not connected' });
}
continue;
}
// Fan the per-email copy/import/delete pipeline out in parallel.
// JMAP has no atomic cross-account move, so we accept that a crash
// mid-flight could leave a duplicate; the delete on success keeps
// the source clean in the happy path.
const results = await Promise.allSettled(
emailIds.map(async (emailId) => {
const full = await sourceClient.getEmail(emailId);
if (!full?.blobId) {
throw new Error('Source email has no raw blob to copy');
}
const blob = await sourceClient.fetchBlob(full.blobId);
const keywords: Record<string, boolean> = { ...(full.keywords ?? {}) };
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords);
await sourceClient.deleteEmail(emailId);
return emailId;
}),
);
results.forEach((outcome, i) => {
const emailId = emailIds[i];
if (outcome.status === 'fulfilled') {
movedIds.push(emailId);
} else {
const err = outcome.reason;
failures.push({
emailId,
error: err instanceof Error ? err.message : String(err),
});
}
});
}
// Drop the moved emails from the current view and clear stale selection
// entries. Counter accuracy comes from the mailbox refresh below.
const movedSet = new Set(movedIds);
set((state) => ({
emails: state.emails.filter((e) => !movedSet.has(e.id)),
selectedEmail:
state.selectedEmail && movedSet.has(state.selectedEmail.id)
? null
: state.selectedEmail,
selectedEmailIds: (() => {
const next = new Set(state.selectedEmailIds);
for (const id of movedIds) next.delete(id);
return next;
})(),
isLoading: false,
}));
// Refresh mailbox folder lists/counters for every account we touched.
// Background-only so the move feels instant - counters will catch up.
const activeAccountId = useAuthStore.getState().activeAccountId;
const touched = new Set<string>([destAccountId, ...emailIdsBySource.keys()]);
for (const acctId of touched) {
const c = useAuthStore.getState().getClientForAccount(acctId);
if (!c) continue;
if (acctId === activeAccountId) {
void get().fetchMailboxes(c);
} else {
void get().fetchAccountMailboxes(c, acctId);
}
}
if (failures.length > 0) {
const first = failures[0];
throw new Error(
failures.length === 1
? `Failed to move email: ${first.error}`
: `Failed to move ${failures.length} email(s); first error: ${first.error}`,
);
}
} catch (error) {
set({
isLoading: false,
error:
error instanceof Error
? error.message
: 'Failed to move emails between accounts',
});
throw error;
}
},
moveThreadToMailbox: async (client, emailId, destinationMailboxId) => {
try {
const state = get();
const email = state.emails.find(e => e.id === emailId)
?? (state.selectedEmail?.id === emailId ? state.selectedEmail : null);
if (!email?.threadId) {
await get().moveToMailbox(client, emailId, destinationMailboxId);
return;
}
const mailboxes = resolveActionMailboxes();
const effectiveClient = resolveActionClient(client);
const currentMailbox = mailboxes.find(mb => mb.id === state.selectedMailbox);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
const thread = await effectiveClient.getThread(email.threadId, accountId);
const threadEmailIds = thread?.emailIds?.length ? thread.emailIds : [emailId];
if (threadEmailIds.length <= 1) {
await get().moveToMailbox(client, emailId, destinationMailboxId);
return;
}
await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId);
const removedEmailIds = new Set(threadEmailIds);
set((currentState) => {
const nextSelectedEmail = getNextSelectedEmailAfterRemoval(currentState, removedEmailIds);
const nextSelectedEmailIds = new Set(
Array.from(currentState.selectedEmailIds).filter(id => !removedEmailIds.has(id))
);
const nextExpandedThreadIds = new Set(currentState.expandedThreadIds);
nextExpandedThreadIds.delete(email.threadId);
const nextThreadEmailsCache = new Map(currentState.threadEmailsCache);
nextThreadEmailsCache.delete(email.threadId);
return {
emails: currentState.emails.filter(currentEmail => !removedEmailIds.has(currentEmail.id)),
selectedEmail: nextSelectedEmail,
selectedEmailIds: nextSelectedEmailIds,
expandedThreadIds: nextExpandedThreadIds,
threadEmailsCache: nextThreadEmailsCache,
};
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move email thread"
});
throw error;
}
},
searchEmails: async (client, query) => {
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
try {
const { isUnifiedView, unifiedRole } = get();
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && unifiedRole) {
const built = await buildUnifiedAccountClients();
const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
return;
}
// Get the current mailbox to scope the search
const selectedMailbox = get().selectedMailbox;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Use originalId for shared mailboxes
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
externalSearchResults: [],
hasMoreEmails: false,
totalEmails: 0
});
}
},
advancedSearch: async (client) => {
const { searchQuery, searchFilters, selectedMailbox, searchAbortController, isUnifiedView, unifiedRole } = get();
const mailboxes = resolveActionMailboxes();
if (searchAbortController) {
searchAbortController.abort();
}
const controller = new AbortController();
set({
isLoading: true,
error: null,
emails: [],
hasMoreEmails: false,
totalEmails: 0,
searchAbortController: controller,
});
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && unifiedRole) {
const built = await buildUnifiedAccountClients();
const result = await advancedSearchUnifiedEmails(
built,
unifiedRole,
(mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId),
emailsPerPage,
0,
);
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
searchAbortController: null,
unifiedErrors: result.errors,
});
return;
}
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
searchAbortController: null,
});
} catch (error) {
if (controller.signal.aborted) return;
set({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
externalSearchResults: [],
hasMoreEmails: false,
totalEmails: 0,
searchAbortController: null,
});
}
},
setSearchFilters: (filters) => {
set((state) => ({
searchFilters: { ...state.searchFilters, ...filters },
}));
},
clearSearchFilters: () => {
set({ searchFilters: { ...DEFAULT_SEARCH_FILTERS } });
},
toggleAdvancedSearch: () => {
set((state) => ({ isAdvancedSearchOpen: !state.isAdvancedSearchOpen }));
},
toggleStar: async (client, emailId) => {
try {
const email = get().emails.find(e => e.id === emailId);
if (!email) return;
const isFlagged = email.keywords.$flagged || false;
await resolveActionClient(client).toggleStar(emailId, !isFlagged);
// Update local state
set((state) => ({
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...e.keywords, $flagged: !isFlagged } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $flagged: !isFlagged } }
: state.selectedEmail
}));
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to update star"
});
throw error;
}
},
setEmailKeywordsLocal: (emailId, keywords) => {
set((state) => ({
emails: state.emails.map(e =>
e.id === emailId ? { ...e, keywords: { ...keywords } } : e
),
selectedEmail: state.selectedEmail?.id === emailId
? { ...state.selectedEmail, keywords: { ...keywords } }
: state.selectedEmail,
}));
},
// Batch operations
batchMarkAsRead: async (client, read) => {
const { selectedEmailIds, emails } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
if (get().isUnifiedView) {
// Group emails by accountId for cross-account operations
const emailsByAccount = new Map<string, string[]>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
}
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
if (!acctClient) return;
await acctClient.batchMarkAsRead(ids, read);
});
await Promise.allSettled(promises);
} else {
await resolveActionClient(client).batchMarkAsRead(emailIdsArray, read);
}
// Update local state
const updatedEmails = emails.map(email =>
selectedEmailIds.has(email.id)
? { ...email, keywords: { ...email.keywords, $seen: read } }
: email
);
// Update mailbox counters
const affectedEmails = emails.filter(e => selectedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaUnread = 0;
affectedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
const wasRead = email.keywords?.$seen === true;
if (wasRead !== read) {
deltaUnread += read ? -1 : 1;
}
}
});
return {
...mailbox,
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnread),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnread)
};
});
set({
emails: updatedEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to update emails",
isLoading: false
});
}
},
batchDelete: async (client, permanent = false) => {
const { selectedEmailIds, emails, selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
// Determine if the current folder forces permanent deletion.
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const isInTrash = currentMailbox?.role === 'trash';
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
const isInJunk = currentMailbox?.role === 'junk';
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk);
// Group emails by accountId (handles unified view and search results spanning accounts).
const emailsByAccount = new Map<string, string[]>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
}
const getClient = (acctId: string) =>
acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
if (forceDestroy) {
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = getClient(acctId);
if (!acctClient) return;
await acctClient.batchDeleteEmails(ids);
});
await Promise.allSettled(promises);
} else {
// Move to trash per account.
const failedAccounts: string[] = [];
const movedEmailIds = new Set<string>();
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = getClient(acctId);
if (!acctClient) {
failedAccounts.push(acctId);
return;
}
const trashMailbox = findTrashMailbox(mailboxes, {
accountId: acctId === '__default__' ? undefined : acctId,
});
if (!trashMailbox) {
// No trash for this account: skip rather than silently destroying.
// The user asked to move to trash, not permanently delete.
failedAccounts.push(acctId);
return;
}
const trashId = trashMailbox.originalId || trashMailbox.id;
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId);
ids.forEach(id => movedEmailIds.add(id));
});
await Promise.allSettled(promises);
if (failedAccounts.length > 0 && movedEmailIds.size === 0) {
// Nothing moved - bail out so the UI doesn't drop the emails from view.
throw new Error('Trash mailbox not found - cannot move emails to trash');
}
// Only remove successfully moved emails from local state.
if (movedEmailIds.size < emailIdsArray.length) {
const deletedEmails = emails.filter(e => movedEmailIds.has(e.id));
const remainingEmails = emails.filter(e => !movedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaTotalEmails = 0;
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
deltaTotalEmails--;
if (!email.keywords?.$seen) deltaUnreadEmails--;
}
});
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails),
};
});
set({
emails: remainingEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
selectedEmail: null,
isLoading: false,
error: 'Some emails could not be moved: trash folder missing for one or more accounts',
});
return;
}
}
// Remove deleted emails from local state
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
// Update mailbox counters
const deletedEmails = emails.filter(e => selectedEmailIds.has(e.id));
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaTotalEmails = 0;
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
deltaTotalEmails--;
if (!email.keywords?.$seen) {
deltaUnreadEmails--;
}
}
});
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails + deltaTotalEmails),
unreadEmails: Math.max(0, mailbox.unreadEmails + deltaUnreadEmails),
totalThreads: Math.max(0, mailbox.totalThreads + deltaTotalEmails),
unreadThreads: Math.max(0, mailbox.unreadThreads + deltaUnreadEmails)
};
});
set({
emails: remainingEmails,
mailboxes: updatedMailboxes,
selectedEmailIds: new Set(),
selectedEmail: null,
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to delete emails",
isLoading: false
});
}
},
batchMoveToMailbox: async (client, toMailboxId) => {
const { selectedEmailIds, emails } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
if (get().isUnifiedView) {
// Group emails by accountId for cross-account operations
const emailsByAccount = new Map<string, string[]>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
}
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
if (!acctClient) return;
await acctClient.batchMoveEmails(ids, toMailboxId);
});
await Promise.allSettled(promises);
} else {
await resolveActionClient(client).batchMoveEmails(emailIdsArray, toMailboxId);
}
// Update local state - remove from current view since they moved
const remainingEmails = emails.filter(e => !selectedEmailIds.has(e.id));
set({
emails: remainingEmails,
selectedEmailIds: new Set(),
isLoading: false
});
// Refresh emails to get updated list (honors active search/filters)
if (!get().isUnifiedView) {
await get().refreshCurrentMailbox(client);
}
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move emails",
isLoading: false
});
}
},
batchArchive: async (client) => {
const { selectedEmailIds, emails } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive');
if (!archiveMailbox) return;
const mode = useSettingsStore.getState().archiveMode;
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
const selected = emails.filter(e => selectedEmailIds.has(e.id));
if (selected.length === 0) return;
set({ isLoading: true, error: null });
try {
await resolveActionClient(client).batchArchiveEmails(
selected.map(e => ({ id: e.id, receivedAt: e.receivedAt })),
archiveId,
mode,
mailboxes,
archiveMailbox.accountId,
);
const remaining = emails.filter(e => !selectedEmailIds.has(e.id));
set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false });
// Refresh the active or viewed account's mailbox cache after the
// archive (a year/month archive can create new sub-folders).
await refreshMailboxesForViewingAccount(client);
await get().refreshCurrentMailbox(client);
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to archive emails',
isLoading: false,
});
throw error;
}
},
// Spam operations
markAsSpam: async (client, emailId) => {
const { selectedMailbox, emails } = get();
const mailboxes = resolveActionMailboxes();
const email = emails.find(e => e.id === emailId);
if (!email) return;
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
get().spamUndoCache.set(emailId, {
emailId,
originalMailboxId: currentMailbox.originalId || currentMailbox.id,
accountId: currentMailbox.accountId,
});
try {
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId);
set(state => ({
emails: state.emails.filter(e => e.id !== emailId),
selectedEmail: getNextSelectedEmail(state, emailId),
}));
} catch (error) {
console.error('Failed to mark as spam:', error);
throw error;
}
},
undoSpam: async (client, emailId) => {
const { selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
// Try cache first (preserves exact original mailbox for toast undo)
const cachedData = get().spamUndoCache.get(emailId);
let targetMailboxId: string;
let accountId: string | undefined;
if (cachedData) {
// Use cached original mailbox (more accurate for immediate undo)
targetMailboxId = cachedData.originalMailboxId;
accountId = cachedData.accountId;
get().spamUndoCache.delete(emailId);
} else {
// Fall back to finding Inbox (generic "not spam" button/menu)
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
accountId = currentMailbox?.accountId;
// Find inbox in same account
const inboxMailbox = mailboxes.find(m =>
m.role === 'inbox' &&
(accountId ? m.accountId === accountId : !m.accountId)
);
if (!inboxMailbox) {
throw new Error('Inbox not found');
}
targetMailboxId = inboxMailbox.originalId || inboxMailbox.id;
}
try {
await resolveActionClient(client).undoSpam(emailId, targetMailboxId, accountId);
await get().fetchEmails(client, selectedMailbox);
} catch (error) {
console.error('Failed to restore email:', error);
throw error;
}
},
batchMarkAsSpam: async (client, emailIds) => {
const { selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
const effectiveClient = resolveActionClient(client);
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
try {
for (const emailId of emailIds) {
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
} catch (error) {
console.error('Failed to batch mark as spam:', error);
throw error;
}
},
batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => {
const { selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
const effectiveClient = resolveActionClient(client);
// Find inbox (batch operations don't preserve original mailboxes)
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
const accountId = currentMailbox?.accountId;
const inboxMailbox = mailboxes.find(m =>
m.role === 'inbox' &&
(accountId ? m.accountId === accountId : !m.accountId)
);
if (!inboxMailbox) {
throw new Error('Inbox not found');
}
try {
for (const emailId of emailIds) {
await effectiveClient.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId);
}
set(state => ({
emails: state.emails.filter(e => !emailIds.includes(e.id)),
selectedEmail: emailIds.includes(state.selectedEmail?.id || '') ? null : state.selectedEmail,
selectedEmailIds: new Set(),
}));
} catch (error) {
console.error('Failed to batch restore emails:', error);
throw error;
}
},
// Push notification handlers
setPushConnected: (connected) => {
set({ isPushConnected: connected });
},
handleStateChange: async (change, client) => {
try {
// Update last push update timestamp
set({ lastPushUpdate: Date.now() });
// Get the current account ID from the client (assuming primary account)
const accountId = client.getAccountId();
// Check if there are changes for this account
const accountChanges = change.changed[accountId];
if (!accountChanges) return;
// Handle Email state changes - refresh current mailbox
if (accountChanges.Email) {
await get().refreshCurrentMailbox(client);
get().fetchTagCounts(client);
}
// Handle Mailbox state changes - refresh mailbox list
if (accountChanges.Mailbox) {
await get().fetchMailboxes(client);
}
// Handle Calendar/CalendarEvent state changes - refresh calendar data
if (accountChanges.Calendar || accountChanges.CalendarEvent) {
const calendarStore = useCalendarStore.getState();
if (calendarStore.supportsCalendar) {
calendarStore.fetchCalendars(client);
const { dateRange, selectedCalendarIds } = calendarStore;
if (dateRange && selectedCalendarIds.length > 0) {
calendarStore.fetchEvents(client, dateRange.start, dateRange.end);
}
// Refresh tasks when calendar events change (e.g. task created via CalDAV)
const { useTaskStore } = await import('./task-store');
const taskStore = useTaskStore.getState();
if (taskStore.tasks.length > 0 || calendarStore.viewMode === 'tasks') {
taskStore.fetchTasks(client);
}
}
}
// Handle SieveScript state changes - refresh filter rules
if (accountChanges.SieveScript) {
const { useFilterStore } = await import('./filter-store');
const filterStore = useFilterStore.getState();
if (filterStore.isSupported) {
filterStore.fetchFilters(client).catch((err) => {
console.error('Failed to refresh filters:', err);
});
}
}
} catch (error) {
console.error('Failed to handle state change:', error);
set({
error: error instanceof Error ? error.message : "Failed to handle push notification"
});
}
},
refreshCurrentMailbox: async (client) => {
const { selectedMailbox } = get();
// Only refresh if a mailbox is currently selected
if (!selectedMailbox) return;
try {
// Fetch emails for the current mailbox without clearing the list first
// This provides a smoother update experience
const mailboxes = resolveActionMailboxes();
const effectiveClient = resolveActionClient(client);
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
// Respect active search filters / query so that a push-triggered refresh
// does not silently replace a filtered list with an unfiltered one.
const { searchQuery, searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters);
let result;
if (hasFilters || searchQuery) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
} else {
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
}
const currentEmails = get().emails;
// Only notify for genuinely new incoming mail in the Inbox.
// Without these guards the toast/sound also fires when sending,
// saving drafts, or moving/deleting the top message in any mailbox,
// because all of those change the first-email id of the current view.
const newFirst = result.emails[0];
if (
newFirst &&
mailbox?.role === 'inbox' &&
!currentEmails.some(e => e.id === newFirst.id)
) {
get().handleNewEmailNotification(newFirst);
}
// Merge the refreshed first page with the existing loaded emails.
// This avoids discarding already-loaded pages which would cause the
// virtual list to shrink and then rapidly re-load (scroll bounce).
const freshMap = new Map(result.emails.map((e: Email) => [e.id, e]));
// Build the merged list: start with the fresh first page, then append
// existing emails beyond that page (if any), skipping duplicates and
// emails removed from the first page (e.g. deleted or moved).
const merged: Email[] = [...result.emails];
const mergedIds = new Set(result.emails.map((e: Email) => e.id));
for (const email of currentEmails) {
if (!mergedIds.has(email.id)) {
merged.push(email);
mergedIds.add(email.id);
}
}
// Check if anything actually changed to avoid unnecessary re-renders
const hasChanged =
currentEmails.length !== merged.length ||
merged.some((email, i) => {
const curr = currentEmails[i];
if (!curr) return true;
return (
curr.id !== email.id ||
curr.threadId !== email.threadId ||
JSON.stringify(curr.keywords) !== JSON.stringify(email.keywords)
);
});
if (hasChanged) {
// hasMore should reflect whether there are still more emails beyond
// what we have loaded, using the fresh total from the server.
const hasMore = merged.length < (result.total || 0);
set({
emails: merged,
hasMoreEmails: hasMore,
totalEmails: result.total,
});
}
} catch (error) {
console.error('Failed to refresh current mailbox:', error);
// Don't set error state for background refreshes to avoid disrupting the UI
}
},
handleNewEmailNotification: (email) => {
// Set the new email notification state
// This can be consumed by a toast component
set({ newEmailNotification: email });
},
clearNewEmailNotification: () => {
set({ newEmailNotification: null });
},
// Thread expansion actions
toggleThreadExpansion: (threadId) => {
const { expandedThreadIds } = get();
const newExpandedThreadIds = new Set(expandedThreadIds);
if (newExpandedThreadIds.has(threadId)) {
newExpandedThreadIds.delete(threadId);
} else {
newExpandedThreadIds.add(threadId);
}
set({ expandedThreadIds: newExpandedThreadIds });
},
fetchThreadEmails: async (client, threadId) => {
const { threadEmailsCache, selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
// Check if we already have this thread cached
const cachedEmails = threadEmailsCache.get(threadId);
if (cachedEmails && cachedEmails.length > 0) {
return cachedEmails;
}
// Set loading state
set({ isLoadingThread: threadId });
try {
// Determine accountId for shared folders
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Fetch all emails in the thread
const emails = await resolveActionClient(client).getThreadEmails(threadId, accountId);
// Update cache
const newCache = new Map(get().threadEmailsCache);
newCache.set(threadId, emails);
set({
threadEmailsCache: newCache,
isLoadingThread: null
});
return emails;
} catch (error) {
console.error('Failed to fetch thread emails:', error);
set({ isLoadingThread: null });
return [];
}
},
collapseAllThreads: () => {
set({
expandedThreadIds: new Set(),
isLoadingThread: null
});
},
updateThreadCache: (threadId, emails) => {
const newCache = new Map(get().threadEmailsCache);
newCache.set(threadId, emails);
set({ threadEmailsCache: newCache });
},
// Mailbox management
createMailbox: async (client, name, parentId) => {
try {
await resolveActionClient(client).createMailbox(name, parentId);
if (get().viewingAccountId) {
await refreshMailboxesForViewingAccount(client);
} else {
await get().fetchMailboxes(client);
}
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to create folder' });
throw error;
}
},
renameMailbox: async (client, mailboxId, name) => {
try {
await resolveActionClient(client).updateMailbox(mailboxId, { name });
const viewingId = get().viewingAccountId;
if (viewingId) {
set((state) => ({
accountMailboxes: {
...state.accountMailboxes,
[viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb =>
mb.id === mailboxId ? { ...mb, name } : mb
),
},
}));
} else {
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId ? { ...mb, name } : mb
),
});
}
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to rename folder' });
throw error;
}
},
deleteMailbox: async (client, mailboxId) => {
try {
await resolveActionClient(client).deleteMailbox(mailboxId);
const { selectedMailbox, viewingAccountId: viewingId } = get();
if (viewingId) {
const updatedList = (get().accountMailboxes[viewingId] ?? []).filter(mb => mb.id !== mailboxId);
const patch: Partial<EmailStore> = {
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<EmailStore> = { mailboxes: newMailboxes };
if (selectedMailbox === mailboxId) {
const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared);
if (inbox) updates.selectedMailbox = inbox.id;
}
set(updates);
}
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to delete folder' });
throw error;
}
},
setMailboxRole: async (client, mailboxId, role) => {
try {
const effectiveClient = resolveActionClient(client);
// If assigning a role, first clear that role from ALL other mailboxes that have it
if (role) {
const existingMailboxes = resolveActionMailboxes().filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId);
for (const existing of existingMailboxes) {
await effectiveClient.updateMailbox(existing.id, { role: null });
}
}
await effectiveClient.updateMailbox(mailboxId, { role });
if (get().viewingAccountId) {
await refreshMailboxesForViewingAccount(client);
} else {
await get().fetchMailboxes(client);
}
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to update folder role' });
throw error;
}
},
emptyMailbox: async (client, mailboxId) => {
try {
set({ isLoading: true, error: null });
await resolveActionClient(client).emptyMailbox(mailboxId);
// Clear emails from local state if we're viewing this mailbox
const currentMailbox = get().selectedMailbox;
if (currentMailbox === mailboxId) {
set({ emails: [], selectedEmail: null });
}
const viewingId = get().viewingAccountId;
if (viewingId) {
set((state) => ({
accountMailboxes: {
...state.accountMailboxes,
[viewingId]: (state.accountMailboxes[viewingId] ?? []).map(mb =>
mb.id === mailboxId
? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 }
: mb
),
},
}));
} else {
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId
? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 }
: mb
),
});
}
set({ isLoading: false });
} catch (error) {
set({
error: error instanceof Error ? error.message : 'Failed to empty folder',
isLoading: false,
});
throw error;
}
},
markMailboxAsRead: async (client, mailboxId) => {
try {
const mailbox = resolveActionMailboxes().find(mb => mb.id === mailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const jmapMailboxId = mailbox?.originalId || mailboxId;
const count = await resolveActionClient(client).markMailboxAsRead(jmapMailboxId, accountId);
// Update local state: mark all emails currently visible in this mailbox as read,
// and zero-out the mailbox unread counter.
set((state) => ({
emails: state.emails.map(e =>
e.mailboxIds && e.mailboxIds[mailboxId]
? { ...e, keywords: { ...e.keywords, $seen: true } }
: e
),
selectedEmail: state.selectedEmail && state.selectedEmail.mailboxIds?.[mailboxId]
? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: true } }
: state.selectedEmail,
mailboxes: state.mailboxes.map(mb =>
mb.id === mailboxId
? { ...mb, unreadEmails: 0, unreadThreads: 0 }
: mb
),
}));
return count;
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to mark folder as read' });
throw error;
}
},
// Unified mailbox operations
fetchUnifiedEmails: async (accounts, role) => {
set({
isLoading: true,
error: null,
isUnifiedView: true,
unifiedRole: role,
selectedKeyword: null,
});
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to fetch unified emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to fetch unified emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0,
});
}
},
loadMoreUnifiedEmails: async (accounts) => {
const { isLoadingMore, hasMoreEmails, emails, unifiedRole } = get();
if (isLoadingMore || !hasMoreEmails || !unifiedRole) return;
set({ isLoadingMore: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const position = emails.length;
const result = await fetchUnifiedEmails(accounts, unifiedRole, emailsPerPage, position);
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
set({
emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to load more unified emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more unified emails",
isLoadingMore: false,
});
}
},
refreshUnifiedCounts: async (accounts) => {
try {
const counts = fetchUnifiedMailboxCounts(accounts);
set({ unifiedCounts: counts });
} catch (error) {
console.error('Failed to refresh unified counts:', error);
}
},
exitUnifiedView: () => {
set({
isUnifiedView: false,
unifiedRole: null,
unifiedErrors: new Map(),
});
},
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,
});
},
}));