mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
fix: stop deleting the active account's mail after removing the account whose folder is open
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user