fix: stop deleting the active account's mail after removing the account whose folder is open

This commit is contained in:
Linus Rath
2026-10-26 12:25:08 +01:00
parent eeb6ccc4db
commit 88b960d605
3 changed files with 129 additions and 1 deletions
+28
View File
@@ -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: "<login>/<account>:<id>".
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);
@@ -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<Email> = {}) => ({
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<typeof vi.fn>;
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();
});
});
+2 -1
View File
@@ -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<AuthState>()(
}
clients.delete(accountId);
evictAccount(accountId);
forgetAccountRows(accountId, get().client);
accountStore.removeAccount(accountId);
clearPluginStorageForAccount(accountId);
},