Files
SRCmail/stores/email-store.ts
T

1449 lines
52 KiB
TypeScript

import { create } from "zustand";
import { Email, Mailbox, StateChange } from "@/lib/jmap/types";
import { JMAPClient } from "@/lib/jmap/client";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
interface EmailStore {
emails: Email[];
mailboxes: Mailbox[];
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;
// Advanced search state
searchFilters: SearchFilters;
isAdvancedSearchOpen: boolean;
searchAbortController: AbortController | null;
setEmails: (emails: Email[]) => void;
setMailboxes: (mailboxes: Mailbox[]) => 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;
toggleEmailSelection: (emailId: string) => void;
selectRangeEmails: (targetEmailId: string) => void;
lastSelectedEmailId: string | null;
selectAllEmails: () => void;
clearSelection: () => void;
// JMAP operations
fetchMailboxes: (client: JMAPClient) => Promise<void>;
fetchEmails: (client: JMAPClient, mailboxId?: string) => Promise<void>;
loadMoreEmails: (client: JMAPClient) => Promise<void>;
fetchEmailContent: (client: JMAPClient, emailId: string) => Promise<Email | null>;
fetchQuota: (client: JMAPClient) => Promise<void>;
sendEmail: (client: JMAPClient, to: string[], subject: string, body: string, cc?: string[], bcc?: string[], identityId?: string, fromEmail?: string, draftId?: string, fromName?: string) => Promise<void>;
deleteEmail: (client: JMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
markAsRead: (client: JMAPClient, emailId: string, read: boolean) => Promise<void>;
moveToMailbox: (client: JMAPClient, emailId: string, mailboxId: string) => Promise<void>;
searchEmails: (client: JMAPClient, query: string) => Promise<void>;
advancedSearch: (client: JMAPClient) => Promise<void>;
setSearchFilters: (filters: Partial<SearchFilters>) => void;
clearSearchFilters: () => void;
toggleAdvancedSearch: () => void;
toggleStar: (client: JMAPClient, emailId: string) => Promise<void>;
// Batch operations
batchMarkAsRead: (client: JMAPClient, read: boolean) => Promise<void>;
batchDelete: (client: JMAPClient) => Promise<void>;
batchMoveToMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
// Spam operations
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string }>;
markAsSpam: (client: JMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: JMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
batchUndoSpam: (client: JMAPClient, emailIds: string[]) => Promise<void>;
// Push notification handlers
setPushConnected: (connected: boolean) => void;
handleStateChange: (change: StateChange, client: JMAPClient) => Promise<void>;
refreshCurrentMailbox: (client: JMAPClient) => Promise<void>;
handleNewEmailNotification: (email: Email) => void;
clearNewEmailNotification: () => void;
// Thread expansion actions
toggleThreadExpansion: (threadId: string) => void;
fetchThreadEmails: (client: JMAPClient, threadId: string) => Promise<Email[]>;
collapseAllThreads: () => void;
updateThreadCache: (threadId: string, emails: Email[]) => void;
// Mailbox management
createMailbox: (client: JMAPClient, name: string, parentId?: string) => Promise<void>;
renameMailbox: (client: JMAPClient, mailboxId: string, name: string) => Promise<void>;
deleteMailbox: (client: JMAPClient, mailboxId: string) => Promise<void>;
setMailboxRole: (client: JMAPClient, mailboxId: string, role: string | null) => Promise<void>;
// Mock data for demo
loadMockData: () => void;
}
// Helper: compute the next email to select when removing one from the list
function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailId: string): Email | null {
if (state.selectedEmail?.id !== removedEmailId) return state.selectedEmail;
const idx = state.emails.findIndex(e => e.id === removedEmailId);
if (idx === -1) return null;
// Prefer next email, fall back to previous
if (idx < state.emails.length - 1) return state.emails[idx + 1];
if (idx > 0) return state.emails[idx - 1];
return null;
}
export const useEmailStore = create<EmailStore>((set, get) => ({
emails: [],
mailboxes: [],
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,
// Advanced search state
searchFilters: { ...DEFAULT_SEARCH_FILTERS },
isAdvancedSearchOpen: false,
searchAbortController: null,
// Spam undo cache
spamUndoCache: new Map(),
setEmails: (emails) => set({ emails }),
setMailboxes: (mailboxes) => set({ mailboxes }),
selectEmail: (email) => set({ selectedEmail: email, lastSelectedEmailId: email?.id ?? get().lastSelectedEmailId }),
selectKeyword: (keyword) => set({
selectedKeyword: keyword,
selectedEmail: null,
selectedEmailIds: new Set(),
expandedThreadIds: new Set(),
threadEmailsCache: new Map(),
}),
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) => {
set({ isLoading: true, error: null });
try {
const mailboxes = await client.getAllMailboxes();
// Auto-select inbox if no mailbox is currently selected
const currentSelectedMailbox = get().selectedMailbox;
if (!currentSelectedMailbox) {
// 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, isLoading: false });
} else {
set({ mailboxes, isLoading: false });
}
} else {
set({ mailboxes, isLoading: false });
}
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to fetch mailboxes",
isLoading: false
});
}
},
fetchEmails: async (client, mailboxId) => {
set({ isLoading: true, error: null }); // Keep previous emails visible during transition
try {
const targetMailboxId = mailboxId || get().selectedMailbox;
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
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;
const result = await client.getEmails(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 } = get();
// Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return;
set({ isLoadingMore: true, error: null });
try {
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
let result;
const { searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters);
if (searchQuery || hasFilters) {
const mailboxes = get().mailboxes;
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 client.advancedSearchEmails(filter, accountId, emailsPerPage, emails.length);
} else {
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, emails.length);
}
} else {
// Load more from mailbox
// Find the mailbox to get its accountId (for shared folder support)
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Use originalId for JMAP queries (shared mailboxes use namespaced IDs in the store)
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, emails.length, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
}
set({
emails: [...emails, ...result.emails],
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 = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const email = await 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 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) => {
set({ isLoading: true, error: null });
try {
await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName);
// 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;
}
},
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;
// Get delete action preference from settings
const deleteAction = useSettingsStore.getState().deleteAction;
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
if (deleteAction === 'trash' && !forceDelete) {
// Find trash mailbox for the correct account
const trashMailbox = mailboxes.find(mb => {
if (accountId) {
// For shared folders, match by accountId
return mb.role === 'trash' && mb.accountId === accountId;
}
// For primary account, find trash that's not from a shared folder
return mb.role === 'trash' && !mb.isShared;
});
if (trashMailbox) {
// Use originalId for shared mailboxes if available
const trashId = trashMailbox.originalId || trashMailbox.id;
await client.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;
}
// If no trash mailbox found, fall through to permanent delete
}
// Permanent delete
await client.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 = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
await 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, mailboxes } = get();
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 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;
}
},
searchEmails: async (client, query) => {
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
try {
// Get the current mailbox to scope the search
const selectedMailbox = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Use originalId for shared mailboxes
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Only pass accountId for shared mailboxes, not for primary account
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false
});
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to search emails",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0
});
}
},
advancedSearch: async (client) => {
const { searchQuery, searchFilters, selectedMailbox, mailboxes, searchAbortController } = get();
if (searchAbortController) {
searchAbortController.abort();
}
const controller = new AbortController();
set({
isLoading: true,
error: null,
emails: [],
hasMoreEmails: false,
totalEmails: 0,
searchAbortController: controller,
});
try {
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 emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
if (controller.signal.aborted) return;
set({
emails: result.emails,
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: [],
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 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;
}
},
// Batch operations
batchMarkAsRead: async (client, read) => {
const { selectedEmailIds, emails, mailboxes } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await 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) => {
const { selectedEmailIds, emails, mailboxes } = get();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
try {
const emailIdsArray = Array.from(selectedEmailIds);
await client.batchDeleteEmails(emailIdsArray);
// 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);
await 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
await get().fetchEmails(client, get().selectedMailbox);
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to move emails",
isLoading: false
});
}
},
// Spam operations
markAsSpam: async (client, emailId) => {
const { selectedMailbox, mailboxes, emails } = get();
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 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 { mailboxes, selectedMailbox } = get();
// 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 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, mailboxes } = get();
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
if (!currentMailbox) return;
try {
for (const emailId of emailIds) {
await client.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: JMAPClient, emailIds: string[]) => {
const { mailboxes, selectedMailbox } = get();
// 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 client.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);
}
// 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);
}
}
}
// 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 = get().mailboxes;
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;
const result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
const currentEmails = get().emails;
// Check if there are new emails by comparing the first email ID
const currentFirstEmailId = currentEmails[0]?.id;
const newFirstEmailId = result.emails[0]?.id;
// If the first email changed, we have a new email - trigger notification
if (currentFirstEmailId !== newFirstEmailId && result.emails[0]) {
get().handleNewEmailNotification(result.emails[0]);
}
// Skip state update if emails haven't actually changed to avoid
// unnecessary re-renders that cause a visible list flicker
const hasChanged =
currentEmails.length !== result.emails.length ||
result.emails.some((email, i) => {
const curr = currentEmails[i];
return (
curr.id !== email.id ||
curr.threadId !== email.threadId ||
JSON.stringify(curr.keywords) !== JSON.stringify(email.keywords)
);
});
if (hasChanged) {
set({
emails: result.emails,
hasMoreEmails: result.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, mailboxes } = get();
// 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 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 client.createMailbox(name, parentId);
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 client.updateMailbox(mailboxId, { name });
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 client.deleteMailbox(mailboxId);
const { mailboxes, selectedMailbox } = get();
const newMailboxes = mailboxes.filter(mb => mb.id !== mailboxId);
const updates: Partial<EmailStore> = { mailboxes: newMailboxes };
// If the deleted mailbox was selected, switch to inbox
if (selectedMailbox === mailboxId) {
const inbox = newMailboxes.find(mb => mb.role === 'inbox' && !mb.isShared);
if (inbox) {
updates.selectedMailbox = inbox.id;
}
}
set(updates as EmailStore);
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to delete folder' });
throw error;
}
},
setMailboxRole: async (client, mailboxId, role) => {
try {
// If assigning a role, first clear that role from any other mailbox
if (role) {
const existingMailbox = get().mailboxes.find(mb => mb.role === role && !mb.isShared);
if (existingMailbox && existingMailbox.id !== mailboxId) {
await client.updateMailbox(existingMailbox.id, { role: null });
}
}
await client.updateMailbox(mailboxId, { role });
await get().fetchMailboxes(client);
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to update folder role' });
throw error;
}
},
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,
});
},
}));