fix: resolve trash and archive in a background login's own folders #1132

The folder-list cache from #1132 published a reused list only when it
was freshly fetched. When the Pro folder tree had loaded a login first,
the unified build reused that list and never stored it under the
login's JMAP id, so trash and archive for that login's mail were
resolved against the active login's folders until it pushed a change.
A reused list now fills any key nothing has published yet, without
overwriting a live one.

The Pro folder tree also marked a login as loaded before its first
fetch finished and never retried a failure, which left that login's
tree empty until an account switch. A failed load is now retried on
the next run, as the identities hook already does.
This commit is contained in:
Linus Rath
2026-09-30 20:35:13 +02:00
parent 2d2b0753be
commit 770fb2c84a
4 changed files with 40 additions and 11 deletions
@@ -71,6 +71,18 @@ describe('useProMultiAccountMailboxes', () => {
expect(client('b').getMailboxes).toHaveBeenCalledTimes(2);
});
it('tries a login again when its first folder load failed', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
connected(['a', 'b']);
client('b').getMailboxes.mockRejectedValueOnce(new Error('offline'));
renderHook(() => useProMultiAccountMailboxes());
await waitFor(() => expect(client('b').getMailboxes).toHaveBeenCalledTimes(1));
await act(async () => { await new Promise((r) => setTimeout(r, 0)); });
act(() => connected(['a', 'b', 'c']));
await waitFor(() => expect(useEmailStore.getState().accountMailboxes.b).toBeDefined());
expect(client('b').getMailboxes).toHaveBeenCalledTimes(2);
});
it('stays idle outside the Pro shell', () => {
useSettingsStore.setState({ proInterface: false } as never);
connected(['a', 'b']);
+4 -1
View File
@@ -53,7 +53,10 @@ export function useProMultiAccountMailboxes(): void {
const client = getClientForAccount(id);
if (!client || loaded.current.get(id) === client) continue;
loaded.current.set(id, client);
void loadAccountMailboxes(client, id);
void loadAccountMailboxes(client, id).then((ok) => {
// Try a failed login again on the next run, as the identities hook does.
if (!ok && loaded.current.get(id) === client) loaded.current.delete(id);
});
}
}, [proInterface, isEmbedded, connectedSignature, activeAccountId]);
}
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { buildUnifiedAccountClients, invalidateUnifiedMailboxes, useEmailStore } from '../email-store';
import { buildUnifiedAccountClients, invalidateUnifiedMailboxes, loadAccountMailboxes, useEmailStore } from '../email-store';
import { useAccountStore } from '../account-store';
import { useAuthStore } from '../auth-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -109,6 +109,16 @@ describe('unified scope folder lists', () => {
expect(useEmailStore.getState().accountMailboxes.a[0].unreadEmails).toBe(4);
});
it('publishes a list the Pro shell loaded under the JMAP id too', async () => {
const b = fakeClient('b');
connect({ b });
await loadAccountMailboxes(b as unknown as IJMAPClient, 'b');
await buildUnifiedAccountClients();
expect(b.getMailboxes).toHaveBeenCalledTimes(1);
// Actions on b's mail resolve its folders by JMAP id.
expect(useEmailStore.getState().accountMailboxes['jmap-b']?.[0].id).toBe('b-inbox');
});
it('retries a login whose fetch failed', async () => {
const a = fakeClient('a');
a.getMailboxes.mockRejectedValueOnce(new Error('offline'));
+13 -9
View File
@@ -1065,15 +1065,17 @@ async function unifiedMailboxesFor(
* the unified scope uses, so the two never fetch the same list twice and a
* list already there is not fetched again until that login reports a change.
*/
export async function loadAccountMailboxes(client: IJMAPClient, accountId: string): Promise<void> {
export async function loadAccountMailboxes(client: IJMAPClient, accountId: string): Promise<boolean> {
try {
const { mailboxes, fresh } = await unifiedMailboxesFor(client, false);
if (!fresh && useEmailStore.getState().accountMailboxes[accountId]) return;
if (!fresh && useEmailStore.getState().accountMailboxes[accountId]) return true;
useEmailStore.setState((state) => ({
accountMailboxes: { ...state.accountMailboxes, [accountId]: mailboxes.slice() },
}));
return true;
} catch (error) {
console.error(`Failed to fetch mailboxes for account ${accountId}:`, error);
return false;
}
}
@@ -1110,6 +1112,10 @@ export async function buildUnifiedAccountClients(
// Skip the account on mailbox fetch failure.
if (result.status !== 'fulfilled' || !result.value) continue;
const { a, c, mailboxes, fresh } = result.value;
// A reused list never overwrites a live one, but fills a key nothing has published yet.
const publish = (key: string, list: Mailbox[]) => {
if (fresh || !useEmailStore.getState().accountMailboxes[key]) fetchedMailboxes[key] = list;
};
try {
const ownMailboxes = includeGroup
? mailboxes.filter((m) => !m.isShared)
@@ -1120,12 +1126,10 @@ export async function buildUnifiedAccountClients(
// resolution branch-free against shared sources.
const primaryJmapId = c.getAccountId();
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, clientAccountId: a.id, jmapAccountId: primaryJmapId, isShared: false, crossIncludedMailboxIds: resolveCrossIncludedMailboxIds(a.id, ownMailboxes) });
if (fresh) {
fetchedMailboxes[a.id] = ownMailboxes;
// Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]`
// resolves uniformly for personal and shared sources alike.
fetchedMailboxes[primaryJmapId] = ownMailboxes;
}
publish(a.id, ownMailboxes);
// Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]`
// resolves uniformly for personal and shared sources alike.
publish(primaryJmapId, ownMailboxes);
if (includeGroup) {
const sharedByOwner = new Map<string, Mailbox[]>();
@@ -1149,7 +1153,7 @@ export async function buildUnifiedAccountClients(
// Cache the owner's mailbox list keyed by its JMAP id so single-email
// and batch actions can resolve role-based destinations (trash/archive)
// in the owner account instead of falling back to the active account.
if (fresh) fetchedMailboxes[ownerId] = ownerMailboxes;
publish(ownerId, ownerMailboxes);
}
}
} catch {