From 88b960d6053e82f081bea057c2d1ae5760cfc53d Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:12:33 +0100 Subject: [PATCH] fix: stop deleting the active account's mail after removing the account whose folder is open --- lib/account-state-manager.ts | 28 ++++++ .../auth-store-remove-viewed-account.test.ts | 99 +++++++++++++++++++ stores/auth-store.ts | 3 +- 3 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 stores/__tests__/auth-store-remove-viewed-account.test.ts diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index c32b8b106..ac45c4afa 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -15,6 +15,7 @@ import { useFileStore } from '@/stores/file-store'; import { clearContactPhotoCache } from '@/lib/contact-photo-cache'; import { bumpStoreEpoch } from '@/lib/store-epoch'; import { useAccountSecurityStore } from '@/stores/account-security-store'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; // Minimal snapshot shapes - we only capture what we need // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -167,6 +168,33 @@ export function clearAllStores(): void { useFileStore.getState().clearClient(); } +/** + * Drops what the mail list still shows of a login that was signed out while + * another one stays active: the login's folder, when the Pro sidebar had it + * open (`viewingAccountId`), for the active login's Inbox, and the login's + * rows of a list that mixes accounts. Without the login's client every + * action on those rows fell back to the active login's, where the same ids + * name other messages: deleting them deleted the active login's mail. + */ +export function forgetAccountRows(accountId: string, activeClient: IJMAPClient | null): void { + const state = useEmailStore.getState(); + if (state.viewingAccountId === accountId) { + const inbox = state.mailboxes.find((m) => m.role === 'inbox' && !m.isShared); + state.selectAccountMailbox(null, inbox?.id ?? ''); + useEmailStore.setState({ emails: [] }); + if (activeClient && inbox) void useEmailStore.getState().fetchEmails(activeClient, inbox.id); + return; + } + const gone = (email: { sourceClientAccountId?: string } | null) => email?.sourceClientAccountId === accountId; + if (!state.emails.some(gone) && !gone(state.selectedEmail)) return; + useEmailStore.setState((s) => ({ + emails: s.emails.filter((email) => !gone(email)), + selectedEmail: gone(s.selectedEmail) ? null : s.selectedEmail, + // emailKeyFor: "/:". + selectedEmailKeys: new Set([...s.selectedEmailKeys].filter((key) => !key.startsWith(`${accountId}/`))), + })); +} + /** Evict cached state for one account */ export function evictAccount(accountId: string): void { cache.delete(accountId); diff --git a/stores/__tests__/auth-store-remove-viewed-account.test.ts b/stores/__tests__/auth-store-remove-viewed-account.test.ts new file mode 100644 index 000000000..96008227d --- /dev/null +++ b/stores/__tests__/auth-store-remove-viewed-account.test.ts @@ -0,0 +1,99 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useAuthStore } from '../auth-store'; +import { useAccountStore } from '../account-store'; +import { useEmailStore } from '../email-store'; +import { useSettingsStore } from '../settings-store'; +import { emailKeyFor } from '@/lib/thread-utils'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Two logins on servers that number folders and messages alike: the Inbox is +// `a` and the Deleted Items `b` in both, and both hold a message `eaaaaab`. +const entry = (username: string, isDefault: boolean) => ({ + label: username, serverUrl: 'https://mail.example.org', username, authMode: 'basic' as const, + rememberMe: false, displayName: username, email: username, lastLoginAt: 0, + isConnected: true, hasError: false, isDefault, +}); +const folders = (accountId: string): Mailbox[] => [ + { id: 'a', name: 'Inbox', role: 'inbox', accountId }, + { id: 'b', name: 'Deleted Items', role: 'trash', accountId }, +].map((m) => ({ + ...m, sortOrder: 0, totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, + myRights: { mayReadItems: true, mayAddItems: true, mayRemoveItems: true }, isSubscribed: true, isShared: false, +}) as Mailbox); +const message = (stamps: Partial = {}) => ({ + id: 'eaaaaab', threadId: 't', receivedAt: '2026-10-26T10:00:00Z', keywords: {}, mailboxIds: { a: true }, ...stamps, +}) as Email; + +const initialEmail = useEmailStore.getState(); + +describe('removing a login that is not the active one', () => { + let active: IJMAPClient; + let a: string; + let b: string; + let fetchEmails: ReturnType; + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 200 }))); + active = { + batchMoveEmails: vi.fn().mockResolvedValue(undefined), + batchDeleteEmails: vi.fn().mockResolvedValue(undefined), + getAccountId: () => 'br', + } as unknown as IJMAPClient; + useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null }); + a = useAccountStore.getState().addAccount(entry('a@example.org', true)); + b = useAccountStore.getState().addAccount(entry('b@example.org', false)); + useAccountStore.getState().setActiveAccount(a); + // b's client is gone once it is removed. + useAuthStore.setState({ + isAuthenticated: true, activeAccountId: a, client: active, + getClientForAccount: (id: string) => (id === a ? active : undefined), + } as never); + useSettingsStore.setState({ deleteAction: 'trash' } as never); + fetchEmails = vi.fn().mockResolvedValue(undefined); + useEmailStore.setState({ fetchEmails } as never); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + useEmailStore.setState(initialEmail); + }); + + it('leaves its folder open in the Pro sidebar, so deleting the rows shown deletes nothing in the active login', async () => { + const row = message(); + useEmailStore.setState({ + viewingAccountId: b, selectedMailbox: 'a', isUnifiedView: false, selectedKeyword: null, searchQuery: '', + mailboxes: folders('br'), accountMailboxes: { [b]: folders('bs') }, + emails: [row], selectedEmail: row, selectedEmailKeys: new Set([emailKeyFor(row)]), + }); + + useAuthStore.getState().removeAccount(b); + await useEmailStore.getState().batchDelete(active); + await useEmailStore.getState().batchDelete(active, true); + + expect(active.batchMoveEmails).not.toHaveBeenCalled(); + expect(active.batchDeleteEmails).not.toHaveBeenCalled(); + const state = useEmailStore.getState(); + expect(state.viewingAccountId).toBeNull(); + expect(state.emails).toEqual([]); + expect(state.selectedEmail).toBeNull(); + expect(fetchEmails).toHaveBeenCalledWith(active, 'a'); + }); + + it('drops its rows from a list that mixes accounts, keeping the active login\'s namesake', () => { + const own = message({ sourceClientAccountId: a, sourceAccountId: 'br' }); + const gone = message({ sourceClientAccountId: b, sourceAccountId: 'bs' }); + useEmailStore.setState({ + viewingAccountId: null, isUnifiedView: true, selectedMailbox: '__unified_inbox__', + emails: [own, gone], selectedEmail: gone, selectedEmailKeys: new Set([emailKeyFor(own), emailKeyFor(gone)]), + }); + + useAuthStore.getState().removeAccount(b); + + const state = useEmailStore.getState(); + expect(state.emails).toEqual([own]); + expect(state.selectedEmail).toBeNull(); + expect([...state.selectedEmailKeys]).toEqual([emailKeyFor(own)]); + expect(fetchEmails).not.toHaveBeenCalled(); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index c8a9088a4..1644a5e7c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -17,7 +17,7 @@ import { setServerAuthIssue } from '@/lib/server-auth-status'; import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; -import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll } from '@/lib/account-state-manager'; +import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/lib/sign-out-cleanup'; import { useSearchHistoryStore } from './search-history-store'; @@ -2280,6 +2280,7 @@ export const useAuthStore = create()( } clients.delete(accountId); evictAccount(accountId); + forgetAccountRows(accountId, get().client); accountStore.removeAccount(accountId); clearPluginStorageForAccount(accountId); },