diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index b010dfa5..3f1b5c8f 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -70,9 +70,12 @@ export default function Home() { const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); const markAsReadTimeoutRef = useRef(null); - const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore(); + const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, activeAccountId } = useAuthStore(); const { identities } = useIdentityStore(); + // Track account switches to force data reload + const lastLoadedAccountRef = useRef(null); + // Mobile/tablet responsive hooks const { isMobile, isTablet } = useDeviceDetection(); const { activeView, sidebarOpen, setSidebarOpen, setActiveView, tabletListVisible, setTabletListVisible, sidebarWidth, emailListWidth, setSidebarWidth, setEmailListWidth, persistColumnWidths, sidebarCollapsed, resetSidebarWidth, resetEmailListWidth } = useUIStore(); @@ -289,9 +292,19 @@ export default function Home() { } }, [initialCheckDone, isAuthenticated, authLoading, router]); - // Load mailboxes and emails when authenticated (only if not already loaded) + // Load mailboxes and emails when authenticated + // Re-fetches on initial load (mailboxes empty) or when account switches useEffect(() => { - if (isAuthenticated && client && mailboxes.length === 0) { + const accountChanged = lastLoadedAccountRef.current !== null && lastLoadedAccountRef.current !== activeAccountId; + if (isAuthenticated && client && (mailboxes.length === 0 || accountChanged)) { + lastLoadedAccountRef.current = activeAccountId; + + // Clear stale selected email from the previous account so the viewer + // doesn't flash old content while fresh data loads. + if (accountChanged) { + selectEmail(null); + } + const loadData = async () => { try { // First fetch mailboxes and quota (inbox will be auto-selected in fetchMailboxes) @@ -337,15 +350,18 @@ export default function Home() { } }; loadData(); + } else if (isAuthenticated && client && lastLoadedAccountRef.current === null) { + // First render with existing data (e.g. restored from snapshot) — just record the account + lastLoadedAccountRef.current = activeAccountId; } - // Cleanup push notifications on unmount + // Cleanup push notifications on unmount or client change return () => { if (client) { client.closePushNotifications(); } }; - }, [isAuthenticated, client, mailboxes.length, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected]); + }, [isAuthenticated, client, mailboxes.length, activeAccountId, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, handleStateChange, setPushConnected, selectEmail]); // Auto-fetch full email content when an email is auto-selected (e.g. after delete/archive) useEffect(() => { diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index 6c39606d..b7661023 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -8,6 +8,7 @@ import { useEmailStore } from '@/stores/email-store'; import { useContactStore } from '@/stores/contact-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useFilterStore } from '@/stores/filter-store'; +import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; import { useIdentityStore } from '@/stores/identity-store'; import { useVacationStore } from '@/stores/vacation-store'; @@ -97,6 +98,19 @@ export function clearAllStores(): void { error: null, searchQuery: '', quota: null, + isPushConnected: false, + lastPushUpdate: null, + newEmailNotification: null, + selectedEmailIds: new Set(), + hasMoreEmails: false, + totalEmails: 0, + expandedThreadIds: new Set(), + threadEmailsCache: new Map(), + isLoadingThread: null, + selectedKeyword: null, + tagCounts: {}, + searchFilters: { ...DEFAULT_SEARCH_FILTERS }, + isAdvancedSearchOpen: false, }); useIdentityStore.getState().clearIdentities(); useContactStore.getState().clearContacts(); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 34f6db15..54d79816 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -34,7 +34,7 @@ interface AuthState { login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise; loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise; refreshAccessToken: () => Promise; - logout: () => void; + logout: () => Promise; logoutAll: () => void; switchAccount: (accountId: string) => Promise; checkAuth: () => Promise; @@ -264,10 +264,12 @@ export const useAuthStore = create()( ? (accountStore.getAccountById(accountId)?.cookieSlot ?? accountStore.getNextCookieSlot()) : accountStore.getNextCookieSlot(); - // Snapshot current account if switching away + // Snapshot current account if switching away and clear stores so + // the new account starts with a clean email/contact/calendar state. const prevAccountId = get().activeAccountId; if (prevAccountId && prevAccountId !== accountId) { snapshotAccount(prevAccountId); + clearAllStores(); } // Store client in multi-account map @@ -390,10 +392,12 @@ export const useAuthStore = create()( // Register in account store const accountId = generateAccountId(username, serverUrl); - // Snapshot current account if switching away + // Snapshot current account if switching away and clear stores so + // the new account starts with a clean email/contact/calendar state. const prevAccountId = get().activeAccountId; if (prevAccountId && prevAccountId !== accountId) { snapshotAccount(prevAccountId); + clearAllStores(); } clients.set(accountId, client); @@ -506,7 +510,7 @@ export const useAuthStore = create()( return promise; }, - logout: () => { + logout: async () => { const state = get(); const wasOAuth = state.authMode === 'oauth'; const accountId = state.activeAccountId; @@ -515,6 +519,11 @@ export const useAuthStore = create()( const slot = account?.cookieSlot ?? 0; clearRefreshTimer(accountId ?? undefined); + + // Null out the client BEFORE disconnecting so the page doesn't fire + // data-loading effects with the stale disconnected client while + // stores are being cleared. + set({ client: null }); state.client?.disconnect(); // Remove client from multi-account map @@ -536,11 +545,56 @@ export const useAuthStore = create()( clearAllStores(); // Restore next account - const nextClient = clients.get(nextAccount.id); + let nextClient = clients.get(nextAccount.id); + + // If the client isn't in memory, try to restore it from the session + if (!nextClient) { + try { + if (nextAccount.authMode === 'oauth') { + const res = await fetch(`/api/auth/token?slot=${nextAccount.cookieSlot}`, { method: 'PUT' }); + if (res.ok) { + const { access_token, expires_in } = await res.json(); + const refreshFn = get().refreshAccessToken; + nextClient = JMAPClient.withBearer(nextAccount.serverUrl, access_token, nextAccount.username, () => refreshFn()); + nextClient.onConnectionChange((connected) => { + if (get().activeAccountId === nextAccount.id) { + set({ connectionLost: !connected }); + } + accountStore.updateAccount(nextAccount.id, { isConnected: connected }); + }); + await nextClient.connect(); + clients.set(nextAccount.id, nextClient); + scheduleRefresh(expires_in, get().refreshAccessToken, nextAccount.id); + } + } else if (nextAccount.authMode === 'basic' && nextAccount.rememberMe) { + const res = await fetch(`/api/auth/session?slot=${nextAccount.cookieSlot}`); + if (res.ok) { + const { serverUrl: sUrl, username: uName, password: pwd } = await res.json(); + nextClient = new JMAPClient(sUrl, uName, pwd); + nextClient.onConnectionChange((connected) => { + if (get().activeAccountId === nextAccount.id) { + set({ connectionLost: !connected }); + } + accountStore.updateAccount(nextAccount.id, { isConnected: connected }); + }); + await nextClient.connect(); + clients.set(nextAccount.id, nextClient); + } + } + } catch (err) { + debug.error(`Failed to restore next account ${nextAccount.id} during logout:`, err); + nextClient = undefined; + } + } + if (nextClient) { const restored = restoreAccount(nextAccount.id); accountStore.setActiveAccount(nextAccount.id); + // Build identity state up front so the name updates atomically + const restoredIdentities = restored ? useIdentityStore.getState().identities : []; + const restoredPrimary = restoredIdentities[0] ?? null; + set({ isAuthenticated: true, isLoading: false, @@ -552,6 +606,8 @@ export const useAuthStore = create()( connectionLost: false, error: null, activeAccountId: nextAccount.id, + identities: restoredIdentities, + primaryIdentity: restoredPrimary, }); if (!restored) { @@ -560,13 +616,32 @@ export const useAuthStore = create()( const { identities, primaryIdentity } = loadIdentities(rawIds, nextAccount.username); set({ identities, primaryIdentity }); }).catch((err) => debug.error('Failed to load identities after switch:', err)); - } else { - const identityState = useIdentityStore.getState(); - set({ - identities: identityState.identities, - primaryIdentity: identityState.identities[0] ?? null, - }); } + } else { + // Could not restore the next account — remove it and do a full logout + debug.error(`Cannot restore next account ${nextAccount.id}, performing full logout`); + evictAccount(nextAccount.id); + accountStore.removeAccount(nextAccount.id); + + set({ + isAuthenticated: false, + serverUrl: null, + username: null, + client: null, + identities: [], + primaryIdentity: null, + authMode: 'basic', + rememberMe: false, + accessToken: null, + tokenExpiresAt: null, + connectionLost: false, + error: null, + activeAccountId: null, + }); + + localStorage.removeItem('auth-storage'); + clearAllStores(); + redirectToLogin(); } } else { // No accounts remaining — full logout @@ -784,6 +859,10 @@ export const useAuthStore = create()( accountStore.setActiveAccount(accountId); accountStore.updateAccount(accountId, { isConnected: true, hasError: false, errorMessage: undefined }); + // Build identity state up front so the name updates atomically + const restoredIdentities = restored ? useIdentityStore.getState().identities : []; + const restoredPrimary = restoredIdentities[0] ?? null; + set({ isAuthenticated: true, isLoading: false, @@ -795,6 +874,8 @@ export const useAuthStore = create()( connectionLost: false, error: null, activeAccountId: accountId, + identities: restoredIdentities, + primaryIdentity: restoredPrimary, }); if (!restored) { @@ -806,12 +887,6 @@ export const useAuthStore = create()( } catch (err) { debug.error(`Failed to load data for ${accountId}:`, err); } - } else { - const identityState = useIdentityStore.getState(); - set({ - identities: identityState.identities, - primaryIdentity: identityState.identities[0] ?? null, - }); } // Sync settings diff --git a/stores/email-store.ts b/stores/email-store.ts index 1822bf9e..01110c7e 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -239,15 +239,17 @@ export const useEmailStore = create((set, get) => ({ try { const mailboxes = await client.getAllMailboxes(); - // Auto-select inbox if no mailbox is currently selected + // 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; - if (!currentSelectedMailbox) { + const selectionValid = currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox); + 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, isLoading: false }); } else { - set({ mailboxes, isLoading: false }); + set({ mailboxes, selectedMailbox: '', isLoading: false }); } } else { set({ mailboxes, isLoading: false });