feat: multi-account mail sidebar and client routing for Pro shell

This commit is contained in:
Linus Rath
2026-05-21 16:52:03 +02:00
parent ed90e096b5
commit 426d344aa8
3 changed files with 377 additions and 122 deletions
+196 -85
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types";
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";
@@ -200,6 +200,54 @@ function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | n
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.
*/
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.
@@ -322,7 +370,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return;
}
const tagIds = keywords.map(k => k.id);
const counts = await client.getTagCounts(tagIds);
const counts = await resolveActionClient(client).getTagCounts(tagIds);
set({ tagCounts: counts });
} catch (error) {
console.error('Failed to fetch tag counts:', error);
@@ -446,9 +494,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
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 = get().mailboxes;
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;
@@ -464,7 +513,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// When filtering by tag, omit the mailbox constraint so emails across
// all folders that carry the tag are returned.
const result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
@@ -532,6 +581,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoadingMore: true, error: null });
try {
const effectiveClient = resolveActionClient(client);
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
@@ -544,21 +594,21 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const hasFilters = !isFilterEmpty(searchFilters);
if (searchQuery || hasFilters) {
const mailboxes = get().mailboxes;
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 client.advancedSearchEmails(filter, accountId, emailsPerPage, position);
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, position);
} else {
result = await client.searchEmails(searchQuery, jmapMailboxId, accountId, emailsPerPage, position);
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 = get().mailboxes;
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;
@@ -566,7 +616,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// When filtering by tag, omit the mailbox constraint (same rationale as fetchEmails).
result = await client.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
}
// Use fresh state when merging to avoid overwriting concurrent updates
@@ -597,13 +647,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
// Find the selected mailbox to determine accountId (for shared folders)
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
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 client.getEmail(emailId, accountId);
const email = await resolveActionClient(client).getEmail(emailId, accountId);
if (email) {
set({ selectedEmail: email });
@@ -619,7 +669,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
fetchQuota: async (client) => {
try {
const quota = await client.getQuota();
const quota = await resolveActionClient(client).getQuota();
set({ quota });
} catch {
// Don't set error state as quota is optional
@@ -666,6 +716,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (!email) return;
const isUnread = !email.keywords?.$seen;
const effectiveClient = resolveActionClient(client);
// Get delete action preference from settings
const deleteAction = useSettingsStore.getState().deleteAction;
@@ -673,7 +724,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
@@ -690,7 +741,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (trashMailbox) {
// Use originalId for shared mailboxes if available
const trashId = trashMailbox.originalId || trashMailbox.id;
await client.moveToTrash(emailId, trashId, accountId);
await effectiveClient.moveToTrash(emailId, trashId, accountId);
// Remove from local state (email moved to trash, not in current view)
set((state) => {
@@ -737,7 +788,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
// Permanent delete
await client.deleteEmail(emailId);
await effectiveClient.deleteEmail(emailId);
// Remove from local state and update mailbox counters if needed
set((state) => {
@@ -811,11 +862,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Determine accountId for shared folders
const selectedMailboxId = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
await client.markAsRead(emailId, read, accountId);
await resolveActionClient(client).markAsRead(emailId, read, accountId);
// Update local state including mailbox counters
set((state) => {
@@ -879,14 +930,15 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const isUnread = !email.keywords?.$seen;
const currentMailboxIds = email.mailboxIds ? Object.keys(email.mailboxIds) : [];
const { selectedMailbox, mailboxes } = get();
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 client.moveEmail(emailId, jmapDestId, accountId);
await resolveActionClient(client).moveEmail(emailId, jmapDestId, accountId);
set((state) => {
const updatedMailboxes = state.mailboxes.map(mailbox => {
@@ -933,7 +985,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
try {
const { emails, mailboxes, selectedMailbox, isUnifiedView } = get();
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);
@@ -955,7 +1008,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} else {
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
await client.batchMoveEmails(emailIds, jmapDestId, accountId);
await resolveActionClient(client).batchMoveEmails(emailIds, jmapDestId, accountId);
}
// Adjust counters and drop moved emails from the current view.
@@ -1010,12 +1063,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return;
}
const currentMailbox = state.mailboxes.find(mb => mb.id === state.selectedMailbox);
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 = state.mailboxes.find(mb => mb.id === destinationMailboxId);
const destMailbox = mailboxes.find(mb => mb.id === destinationMailboxId);
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
const thread = await client.getThread(email.threadId, accountId);
const thread = await effectiveClient.getThread(email.threadId, accountId);
const threadEmailIds = thread?.emailIds?.length ? thread.emailIds : [emailId];
if (threadEmailIds.length <= 1) {
@@ -1023,7 +1078,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return;
}
await client.batchMoveEmails(threadEmailIds, jmapDestId, accountId);
await effectiveClient.batchMoveEmails(threadEmailIds, jmapDestId, accountId);
const removedEmailIds = new Set(threadEmailIds);
set((currentState) => {
@@ -1057,7 +1112,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
// Get the current mailbox to scope the search
const selectedMailbox = get().selectedMailbox;
const mailboxes = get().mailboxes;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Use originalId for shared mailboxes
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
@@ -1066,7 +1121,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Get emails per page from settings
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
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,
@@ -1088,7 +1143,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
advancedSearch: async (client) => {
const { searchQuery, searchFilters, selectedMailbox, mailboxes, searchAbortController } = get();
const { searchQuery, searchFilters, selectedMailbox, searchAbortController } = get();
const mailboxes = resolveActionMailboxes();
if (searchAbortController) {
searchAbortController.abort();
@@ -1111,7 +1167,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
if (controller.signal.aborted) return;
@@ -1159,7 +1215,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (!email) return;
const isFlagged = email.keywords.$flagged || false;
await client.toggleStar(emailId, !isFlagged);
await resolveActionClient(client).toggleStar(emailId, !isFlagged);
// Update local state
set((state) => ({
@@ -1191,7 +1247,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Batch operations
batchMarkAsRead: async (client, read) => {
const { selectedEmailIds, emails, mailboxes } = get();
const { selectedEmailIds, emails } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
@@ -1215,7 +1272,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
await Promise.allSettled(promises);
} else {
await client.batchMarkAsRead(emailIdsArray, read);
await resolveActionClient(client).batchMarkAsRead(emailIdsArray, read);
}
// Update local state
@@ -1260,7 +1317,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchDelete: async (client, permanent = false) => {
const { selectedEmailIds, emails, mailboxes, selectedMailbox } = get();
const { selectedEmailIds, emails, selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
set({ isLoading: true, error: null });
@@ -1423,7 +1481,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
await Promise.allSettled(promises);
} else {
await client.batchMoveEmails(emailIdsArray, toMailboxId);
await resolveActionClient(client).batchMoveEmails(emailIdsArray, toMailboxId);
}
// Update local state - remove from current view since they moved
@@ -1448,7 +1506,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchArchive: async (client) => {
const { selectedEmailIds, emails, mailboxes, fetchMailboxes } = get();
const { selectedEmailIds, emails } = get();
const mailboxes = resolveActionMailboxes();
if (selectedEmailIds.size === 0) return;
const archiveMailbox = mailboxes.find(m => m.role === 'archive' || m.name.toLowerCase() === 'archive');
@@ -1462,7 +1521,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoading: true, error: null });
try {
await client.batchArchiveEmails(
await resolveActionClient(client).batchArchiveEmails(
selected.map(e => ({ id: e.id, receivedAt: e.receivedAt })),
archiveId,
mode,
@@ -1473,8 +1532,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const remaining = emails.filter(e => !selectedEmailIds.has(e.id));
set({ emails: remaining, selectedEmailIds: new Set(), isLoading: false });
await fetchMailboxes(client);
// Refresh the current mailbox view (honors active search/filters)
// 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({
@@ -1487,7 +1547,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Spam operations
markAsSpam: async (client, emailId) => {
const { selectedMailbox, mailboxes, emails } = get();
const { selectedMailbox, emails } = get();
const mailboxes = resolveActionMailboxes();
const email = emails.find(e => e.id === emailId);
if (!email) return;
@@ -1501,7 +1562,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
try {
await client.markAsSpam(emailId, currentMailbox.accountId);
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId);
set(state => ({
emails: state.emails.filter(e => e.id !== emailId),
@@ -1514,7 +1575,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
undoSpam: async (client, emailId) => {
const { mailboxes, selectedMailbox } = get();
const { selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
// Try cache first (preserves exact original mailbox for toast undo)
const cachedData = get().spamUndoCache.get(emailId);
@@ -1546,7 +1608,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
try {
await client.undoSpam(emailId, targetMailboxId, accountId);
await resolveActionClient(client).undoSpam(emailId, targetMailboxId, accountId);
await get().fetchEmails(client, selectedMailbox);
} catch (error) {
console.error('Failed to restore email:', error);
@@ -1555,14 +1617,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchMarkAsSpam: async (client, emailIds) => {
const { selectedMailbox, mailboxes } = get();
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 client.markAsSpam(emailId, currentMailbox.accountId);
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId);
}
set(state => ({
@@ -1577,7 +1641,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
batchUndoSpam: async (client: IJMAPClient, emailIds: string[]) => {
const { mailboxes, selectedMailbox } = get();
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);
@@ -1594,7 +1660,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
for (const emailId of emailIds) {
await client.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId);
await effectiveClient.undoSpam(emailId, inboxMailbox.originalId || inboxMailbox.id, accountId);
}
set(state => ({
@@ -1681,7 +1747,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
// Fetch emails for the current mailbox without clearing the list first
// This provides a smoother update experience
const mailboxes = get().mailboxes;
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;
@@ -1697,9 +1764,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
let result;
if (hasFilters || searchQuery) {
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await client.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
result = await effectiveClient.advancedSearchEmails(filter, accountId, emailsPerPage, 0);
} else {
result = await client.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0);
}
const currentEmails = get().emails;
@@ -1789,7 +1856,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
fetchThreadEmails: async (client, threadId) => {
const { threadEmailsCache, selectedMailbox, mailboxes } = get();
const { threadEmailsCache, selectedMailbox } = get();
const mailboxes = resolveActionMailboxes();
// Check if we already have this thread cached
const cachedEmails = threadEmailsCache.get(threadId);
@@ -1806,7 +1874,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Fetch all emails in the thread
const emails = await client.getThreadEmails(threadId, accountId);
const emails = await resolveActionClient(client).getThreadEmails(threadId, accountId);
// Update cache
const newCache = new Map(get().threadEmailsCache);
@@ -1841,8 +1909,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Mailbox management
createMailbox: async (client, name, parentId) => {
try {
await client.createMailbox(name, parentId);
await get().fetchMailboxes(client);
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;
@@ -1851,12 +1923,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
renameMailbox: async (client, mailboxId, name) => {
try {
await client.updateMailbox(mailboxId, { name });
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId ? { ...mb, name } : mb
),
});
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;
@@ -1865,18 +1949,27 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
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;
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);
}
set(updates as EmailStore);
} catch (error) {
set({ error: error instanceof Error ? error.message : 'Failed to delete folder' });
throw error;
@@ -1885,15 +1978,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
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 = get().mailboxes.filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId);
const existingMailboxes = resolveActionMailboxes().filter(mb => mb.role === role && !mb.isShared && mb.id !== mailboxId);
for (const existing of existingMailboxes) {
await client.updateMailbox(existing.id, { role: null });
await effectiveClient.updateMailbox(existing.id, { role: null });
}
}
await client.updateMailbox(mailboxId, { role });
await get().fetchMailboxes(client);
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;
@@ -1903,7 +2001,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
emptyMailbox: async (client, mailboxId) => {
try {
set({ isLoading: true, error: null });
await client.emptyMailbox(mailboxId);
await resolveActionClient(client).emptyMailbox(mailboxId);
// Clear emails from local state if we're viewing this mailbox
const currentMailbox = get().selectedMailbox;
@@ -1911,15 +2009,28 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ emails: [], selectedEmail: null });
}
// Update mailbox counters
set({
mailboxes: get().mailboxes.map(mb =>
mb.id === mailboxId
? { ...mb, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0 }
: mb
),
isLoading: false,
});
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',
@@ -1931,11 +2042,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
markMailboxAsRead: async (client, mailboxId) => {
try {
const mailbox = get().mailboxes.find(mb => mb.id === mailboxId);
const mailbox = resolveActionMailboxes().find(mb => mb.id === mailboxId);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const jmapMailboxId = mailbox?.originalId || mailboxId;
const count = await client.markMailboxAsRead(jmapMailboxId, accountId);
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.