diff --git a/src/api/__tests__/jmap-client.test.ts b/src/api/__tests__/jmap-client.test.ts index 4edf77b4..af895bd8 100644 --- a/src/api/__tests__/jmap-client.test.ts +++ b/src/api/__tests__/jmap-client.test.ts @@ -268,6 +268,21 @@ describe('JMAPClient', () => { expect(client.accountId).toBe('acc-2'); }); + it('names its account without throwing, and only while a session is up', async () => { + // Read at render (the sidebar, the viewer), also before the first session. + expect(client.connectedAccountId).toBeNull(); + global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any; + await client.connect('https://mail.example.com', 'user', 'pass'); + expect(client.connectedAccountId).toBe('acc-1'); + + (SecureStore.getItemAsync as any).mockResolvedValue(null); + await client.loadAccount('gone@other.example.com'); + + expect(client.connectedAccountId).toBeNull(); + // API calls still refuse to run without one. + expect(() => client.accountId).toThrow('Not authenticated'); + }); + it('leaves the previous account to the snapshot when the next one cannot load', async () => { global.fetch = mockFetch([{ status: 200, json: MOCK_SESSION }]) as any; await client.connect('https://mail.example.com', 'user', 'pass'); diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index b8c73cae..48c3e89b 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -165,6 +165,16 @@ export class JMAPClient { return this._accountId; } + /** + * The primary account id while a session is up; null before one is (a + * cold start shows the mail view persisted from the last session first) + * and while another account loads. For code that runs at render or in + * selectors; API calls use `accountId`, which throws. + */ + get connectedAccountId(): string | null { + return this._accountId; + } + get currentSession(): JMAPSession | null { return this.session; } diff --git a/src/components/email/ReadReceiptBanner.tsx b/src/components/email/ReadReceiptBanner.tsx index a8254406..0880b5eb 100644 --- a/src/components/email/ReadReceiptBanner.tsx +++ b/src/components/email/ReadReceiptBanner.tsx @@ -100,10 +100,14 @@ export function ReadReceiptBanner({ email, requestedBy, jmapAccountId, currentMa await flagSent(); }, [identity, mailboxes, requestedBy, jmapAccountId, email.messageId, email.subject, t, flagSent]); - // "always" mode: auto-send once when the message is actually displayed. + // "always" mode: auto-send once when the message is actually displayed, + // and the session is up (a message can be open while another account + // loads, or while none has loaded yet). React.useEffect(() => { if (readReceiptResponse !== 'always' || !shouldOffer || !active) return; - const key = `${jmapAccountId ?? jmapClient.accountId}:${email.id}`; + const account = jmapAccountId ?? jmapClient.connectedAccountId; + if (!account) return; + const key = `${account}:${email.id}`; if (autoAttempted.has(key)) return; autoAttempted.add(key); setHandledLocally(true); diff --git a/src/lib/__tests__/files-drive.test.ts b/src/lib/__tests__/files-drive.test.ts new file mode 100644 index 00000000..58fb4eed --- /dev/null +++ b/src/lib/__tests__/files-drive.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import type { JMAPSession } from '../../api/types'; +import { driveChange, type DriveScope } from '../files-drive'; +import { sessionFingerprint } from '../server-fingerprint'; + +// When the Files screen starts its drive over, reads it again in place, or +// leaves it. It used to start over only when the local account id changed, +// which leaves the port out and stays the same when the server at the +// address is reinstalled: the old server's folders stayed on screen, with +// ids that name other files on the new one. + +const session = (apiUrl: string, version: '0.16.23' | '1.0'): JMAPSession => ({ + apiUrl, + downloadUrl: '', + uploadUrl: '', + eventSourceUrl: '', + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'f' }, + accounts: { + f: { + name: 'userc@example.org', + isPersonal: true, + isReadOnly: false, + accountCapabilities: { + 'urn:ietf:params:jmap:mail': {}, + 'urn:ietf:params:jmap:filenode': {}, + ...(version === '1.0' ? { 'urn:ietf:params:jmap:principals:owner': {}, 'urn:ietf:params:jmap:metadata': {} } : {}), + }, + }, + }, + capabilities: { + 'urn:ietf:params:jmap:core': {}, + ...(version === '1.0' ? { 'urn:ietf:params:jmap:metadata': {} } : {}), + }, + state: 'b3bed0bb', +} as unknown as JMAPSession); + +const ACCOUNT = 'userc@10.10.10.61'; +const v016 = session('http://10.10.10.61:8016/jmap/', '0.16.23'); +const v10 = session('http://10.10.10.61:8110/jmap/', '1.0'); +const loadedFrom = (s: JMAPSession | null, account = ACCOUNT): DriveScope => ({ + account, + server: s ? sessionFingerprint(s) : null, +}); + +describe('driveChange', () => { + it('reads the drive when the screen opens', () => { + expect(driveChange(undefined, ACCOUNT, v016)).toEqual({ action: 'load', scope: loadedFrom(v016) }); + }); + + it('starts over for another server at the same address: another port (0.16.23 to 1.0)', () => { + expect(driveChange(loadedFrom(v016), ACCOUNT, v10)).toEqual({ action: 'reset', scope: loadedFrom(v10) }); + }); + + it('starts over for a server reinstalled at the same address (0.16.23 to 1.0)', () => { + const reinstalled = session('http://10.10.10.61:8016/jmap/', '1.0'); + expect(driveChange(loadedFrom(v016), ACCOUNT, reinstalled).action).toBe('reset'); + }); + + it('starts over for another account', () => { + expect(driveChange(loadedFrom(v10), 'usera@10.10.10.61', v10).action).toBe('reset'); + expect(driveChange(loadedFrom(v10), null, null).action).toBe('reset'); + }); + + it('reads the drive again in place for a new session of the same server (Stalwart 1.0)', () => { + const again = { ...v10 }; + expect(driveChange(loadedFrom(v10), ACCOUNT, again)).toEqual({ action: 'reload', scope: loadedFrom(v10) }); + // Opened offline: the first session reads it. + expect(driveChange(loadedFrom(null), ACCOUNT, v10)).toEqual({ action: 'reload', scope: loadedFrom(v10) }); + }); + + it('leaves the drive alone when the session is lost', () => { + expect(driveChange(loadedFrom(v10), ACCOUNT, null)).toEqual({ action: 'keep', scope: loadedFrom(v10) }); + }); +}); diff --git a/src/lib/files-drive.ts b/src/lib/files-drive.ts new file mode 100644 index 00000000..e59edc2e --- /dev/null +++ b/src/lib/files-drive.ts @@ -0,0 +1,41 @@ +import type { JMAPSession } from '../api/types'; +import { sessionFingerprint } from './server-fingerprint'; + +/** The signed-in account and the server the Files drive on screen was read from. */ +export interface DriveScope { + /** Local account id (username@host). */ + account: string | null; + /** Fingerprint of the session it was read with (lib/server-fingerprint); null without one. */ + server: string | null; +} + +export type DriveAction = + /** First read, when the screen mounts. */ + | 'load' + /** Start over: the nodes, the folder stack and the selection are another drive's. */ + | 'reset' + /** Read again in place: a new session of the same server. */ + | 'reload' + /** Leave it: the session was lost, there is nothing to read with. */ + | 'keep'; + +/** + * What a change of the signed-in account or of its session means for the + * drive on screen. The local account id leaves the port out, and a server + * reinstalled at the same address (Stalwart 1.0 has no migration from 0.16) + * keeps it too; there the node ids name other files, and acting on the rows + * on screen would reach them. The session fingerprint tells those servers + * apart. Resolves to the action and the scope the drive then belongs to. + */ +export function driveChange( + loaded: DriveScope | undefined, + account: string | null, + session: JMAPSession | null, +): { action: DriveAction; scope: DriveScope } { + const server = session ? sessionFingerprint(session) : null; + if (!loaded) return { action: 'load', scope: { account, server } }; + if (loaded.account !== account) return { action: 'reset', scope: { account, server } }; + if (!server) return { action: 'keep', scope: loaded }; + if (loaded.server && loaded.server !== server) return { action: 'reset', scope: { account, server } }; + return { action: 'reload', scope: { account, server } }; +} diff --git a/src/screens/FilesScreen.tsx b/src/screens/FilesScreen.tsx index bebfeaf5..94730e2d 100644 --- a/src/screens/FilesScreen.tsx +++ b/src/screens/FilesScreen.tsx @@ -56,6 +56,7 @@ import { fileNameProblem, type FileNameProblem } from '../lib/file-name-rules'; import { fileErrorMessage } from '../lib/file-errors'; import { useBackWhileFocused } from '../lib/use-back-while-focused'; import { filesBackStep } from '../lib/files-back'; +import { driveChange, type DriveScope } from '../lib/files-drive'; import type { FileNode } from '../api/types'; import { spacing, radius, typography, type ThemePalette } from '../theme/tokens'; import { useColors } from '../theme/colors'; @@ -225,6 +226,7 @@ export default function FilesScreen() { const defaultViewMode = useSettingsStore((s) => s.filesDefaultViewMode); const setSetting = useSettingsStore((s) => s.updateSetting); const activeAccountId = useAuthStore((s) => s.activeAccountId); + const session = useAuthStore((s) => s.session); const [viewOverride, setViewOverride] = useState(null); const viewMode: FilesViewMode = viewOverride ?? defaultViewMode; @@ -260,12 +262,18 @@ export default function FilesScreen() { [t], ); - // Load on mount and reset the whole drive whenever the active account - // changes: the node cache, folder stack and selection all belong to the - // previous account (webmail 1.9.0 "reset the account-scoped Files drive"). - const loadedForAccountRef = useRef(undefined); + // Load on mount and reset the whole drive whenever the active account, or + // the server behind it, changes: the node cache, folder stack and + // selection all belong to the previous one (webmail 1.9.0 "reset the + // account-scoped Files drive"). The same address can front another server + // (another port, or Stalwart reinstalled there), which the account id does + // not tell. Every new session reads the drive again. + const driveRef = useRef(undefined); useEffect(() => { - if (loadedForAccountRef.current !== undefined && loadedForAccountRef.current !== activeAccountId) { + const { action, scope } = driveChange(driveRef.current, activeAccountId, session); + driveRef.current = scope; + if (action === 'keep') return; + if (action === 'reset') { uploadAbortRef.current?.abort(); setAllNodes([]); setPath([]); @@ -276,9 +284,10 @@ export default function FilesScreen() { setPreviewTarget(null); setSearchQuery(''); } - loadedForAccountRef.current = activeAccountId; - void loadFiles(); - }, [activeAccountId, loadFiles]); + // Signed out: nothing to read. + if (action !== 'load' && !activeAccountId) return; + void loadFiles(action === 'reload' ? 'refresh' : 'initial'); + }, [activeAccountId, session, loadFiles]); // If we navigated into a folder that no longer exists (deleted, or its // share was revoked), cut the stack back to the deepest ancestor that does. diff --git a/src/stores/__tests__/auth-store-logout.test.ts b/src/stores/__tests__/auth-store-logout.test.ts new file mode 100644 index 00000000..94f2750a --- /dev/null +++ b/src/stores/__tests__/auth-store-logout.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Signing out of the active account: the app moves on to the next signed-in +// account, and to the sign-in screen after the last one. + +const { client, SessionSupersededError } = vi.hoisted(() => { + const A = 'usera@mail.example.org'; + const B = 'userb@mail.example.org'; + const C = 'userc@mail.example.org'; + const jmapIds: Record = { [A]: 'd', [B]: 'e', [C]: 'f' }; + const session = (jmapId: string) => ({ + apiUrl: 'https://mail.example.org/jmap/', + downloadUrl: '', + uploadUrl: '', + eventSourceUrl: '', + primaryAccounts: { 'urn:ietf:params:jmap:mail': jmapId }, + accounts: { + [jmapId]: { + name: jmapId, + isPersonal: true, + isReadOnly: false, + accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:principals:owner': {} }, + }, + }, + capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:metadata': {} }, + state: 'b3bed0bb', + }); + return { + client: { + A, + B, + C, + jmapIds, + session, + // The local account (username@host) the client serves; null while it + // serves none. + served: null as string | null, + epoch: 0, + loadErrors: {} as Record, + }, + SessionSupersededError: class SessionSupersededError extends Error {}, + }; +}); + +vi.mock('../../api/jmap-client', () => { + class AuthenticationError extends Error {} + class NetworkError extends Error {} + return { + jmapClient: { + get isConnected() { return client.served !== null; }, + get accountId() { + if (!client.served) throw new Error('Not authenticated - call connect() first'); + return client.jmapIds[client.served]; + }, + get connectedAccountId() { return client.served ? client.jmapIds[client.served] : null; }, + get currentSession() { return client.served ? client.session(client.jmapIds[client.served]) : null; }, + get username() { return client.served ? client.served.split('@')[0] : null; }, + get serverUrl() { return client.served ? 'https://mail.example.org' : null; }, + loadAccount: vi.fn(async (accountId: string) => { + const epoch = ++client.epoch; + client.served = null; + await Promise.resolve(); + if (epoch !== client.epoch) throw new SessionSupersededError(); + const error = client.loadErrors[accountId]; + if (error) throw error; + client.served = accountId; + return true; + }), + snapshot: vi.fn(() => ({ served: client.served })), + restoreSnapshot: vi.fn((snap: { served: string | null }) => { + client.epoch += 1; + client.served = snap.served; + }), + reset: vi.fn(() => { + client.epoch += 1; + client.served = null; + }), + logout: vi.fn(async () => undefined), + consumeLegacyCredentials: vi.fn(async () => null), + clearAccountCredentials: vi.fn(async () => undefined), + getStoredOAuthTokens: vi.fn(async () => null), + onAuthFailure: vi.fn(() => () => undefined), + onTokenRefresh: vi.fn(() => () => undefined), + hasAccountCapability: vi.fn(() => true), + hasCapability: vi.fn(() => false), + getMaxObjectsInGet: () => 500, + getMaxObjectsInSet: () => 500, + getMaxCallsInRequest: () => 16, + getSharedMailAccounts: () => [], + getAccountName: () => 'user', + request: vi.fn(async (calls: Array<[string, Record, string]>) => { + if (!client.served) throw new Error('Not connected'); + return { + methodResponses: calls.map(([name, args, id]) => [name, { + accountId: args.accountId, state: 's1', list: [], notFound: [], + }, id]), + }; + }), + }, + AuthenticationError, + NetworkError, + SessionSupersededError, + }; +}); + +vi.mock('../../lib/push-notifications', () => ({ + teardownPushNotifications: vi.fn(async () => undefined), + teardownPushNotificationsForAccount: vi.fn(async () => undefined), + forgetPushServerState: vi.fn(async () => undefined), + forgetPushSubscriptionExpiry: vi.fn(async () => undefined), +})); + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import type { JMAPSession } from '../../api/types'; +import { AuthenticationError, NetworkError } from '../../api/jmap-client'; +import { sessionFingerprint, writeServerFingerprint } from '../../lib/server-fingerprint'; +import { useAccountStore } from '../account-store'; +import { useAuthStore } from '../auth-store'; +import { useEmailStore } from '../email-store'; +import { useOutboxStore } from '../outbox-store'; + +const { A, B, C } = client; +const settle = async () => { + for (let i = 0; i < 5; i++) await new Promise((r) => setTimeout(r, 0)); +}; + +async function signedIn(...accounts: string[]): Promise { + const entry = (id: string) => ({ + id, serverUrl: 'https://mail.example.org', username: id.split('@')[0], displayName: id, email: id, + avatarColor: '#000', lastLoginAt: 0, isConnected: true, hasError: false, isDefault: id === accounts[0], + }); + useAccountStore.setState({ accounts: accounts.map(entry), activeAccountId: accounts[0], defaultAccountId: accounts[0] }); + for (const account of accounts) { + await writeServerFingerprint(account, sessionFingerprint(client.session(client.jmapIds[account]) as unknown as JMAPSession)); + } + client.served = accounts[0]; + await useOutboxStore.getState().setAccount(null); + useEmailStore.setState({ activeAccountId: null, accountSnapshots: {} }); + useEmailStore.getState().setActiveAccount(accounts[0]); + await useOutboxStore.getState().resume(accounts[0]); + useAuthStore.setState({ + isAuthenticated: true, + isLoading: false, + hasRestoredSession: true, + session: client.session(client.jmapIds[accounts[0]]) as unknown as JMAPSession, + activeAccountId: accounts[0], + accountId: client.jmapIds[accounts[0]], + serverUrl: 'https://mail.example.org', + username: accounts[0].split('@')[0], + client: null, + }); +} + +const where = () => ({ + signedIn: useAuthStore.getState().isAuthenticated, + auth: useAuthStore.getState().activeAccountId, + registry: useAccountStore.getState().activeAccountId, + email: useEmailStore.getState().activeAccountId, + client: client.served, +}); + +beforeEach(async () => { + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await AsyncStorage.clear(); + client.loadErrors = {}; +}); + +describe('signing out of the active account (Stalwart 1.0)', () => { + it('shows the sign-in screen after the last account', async () => { + await signedIn(A); + + await useAuthStore.getState().logout(); + + expect(where()).toEqual({ signedIn: false, auth: null, registry: null, email: null, client: null }); + expect(useAccountStore.getState().accounts).toEqual([]); + }); + + it('moves on to the next account', async () => { + await signedIn(A, B); + + await useAuthStore.getState().logout(); + await settle(); + + expect(where()).toEqual({ signedIn: true, auth: B, registry: B, email: B, client: B }); + expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([B]); + }); + + it('shows the next account offline when its server does not answer, not the one signed out', async () => { + await signedIn(A, B); + client.loadErrors = { [B]: new NetworkError('Network request failed') }; + + await useAuthStore.getState().logout(); + await settle(); + + expect(where()).toEqual({ signedIn: true, auth: B, registry: B, email: B, client: null }); + expect(useAuthStore.getState().session).toBeNull(); + expect(useOutboxStore.getState().activeAccountId).toBe(B); + }); + + it('skips a next account whose sign-in is refused', async () => { + await signedIn(A, B, C); + client.loadErrors = { [B]: new AuthenticationError('Invalid credentials') }; + + await useAuthStore.getState().logout(); + await settle(); + + expect(where()).toEqual({ signedIn: true, auth: C, registry: C, email: C, client: C }); + expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([C]); + }); +}); diff --git a/src/stores/__tests__/email-store-all-folders.test.ts b/src/stores/__tests__/email-store-all-folders.test.ts index f3d4ad57..0c29887e 100644 --- a/src/stores/__tests__/email-store-all-folders.test.ts +++ b/src/stores/__tests__/email-store-all-folders.test.ts @@ -13,6 +13,7 @@ vi.mock('../../api/jmap-client', () => ({ jmapClient: { isConnected: true, accountId: 'c', + connectedAccountId: 'c', username: 'me@example.com', serverUrl: 'https://mail.example.com', // No hasKeyword sort option: the list sort stays plain receivedAt and no diff --git a/src/stores/__tests__/email-store-before-connect.test.ts b/src/stores/__tests__/email-store-before-connect.test.ts new file mode 100644 index 00000000..0543af52 --- /dev/null +++ b/src/stores/__tests__/email-store-before-connect.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from 'vitest'; + +// A cold start renders the mail view persisted from the last session before +// the session is up, with the JMAP client (the real one here) signed out. On +// Stalwart 1.0 every member of a group sees its folders, so the persisted +// folders hold shared ones, and the sidebar spans them while it renders: the +// app crashed on every such start ("Not authenticated - call connect() +// first"). + +import type { Email, Mailbox } from '../../api/types'; +import { jmapClient } from '../../api/jmap-client'; +import { + accountIdOfRow, + listRowsOfAccount, + spannedAccounts, + useEmailStore, + viewerParamsForRow, +} from '../email-store'; + +const folder = (over: Partial): Mailbox => ({ + id: 'a', name: 'Inbox', role: 'inbox', totalEmails: 1, unreadEmails: 0, totalThreads: 1, unreadThreads: 0, + myRights: {} as never, isShared: false, ...over, +} as Mailbox); + +const own = folder({}); +// The group "team" (JMAP account "c" on the test servers) and its Inbox. +const team = folder({ id: 'c:a', originalId: 'a', accountId: 'c', isShared: true }); + +describe('the persisted mail view before the session is up (Stalwart 1.0 group folders)', () => { + it('lists the accounts a tag view spans, as the sidebar does while it renders', () => { + expect(jmapClient.isConnected).toBe(false); + expect(() => jmapClient.accountId).toThrow('Not authenticated'); + + expect(spannedAccounts([own, team])).toEqual([undefined, 'c']); + }); + + it('tells the account of a row of a list spanning accounts, as the viewer does while it opens', () => { + const row = { id: 'x1', threadId: 't1', jmapAccountId: 'c', receivedAt: '2026-09-20T10:00:00Z' } as Email; + useEmailStore.setState({ mailboxes: [own, team], emails: [row] }); + + expect(accountIdOfRow(row)).toBe('c'); + expect(listRowsOfAccount('c').map((e) => e.id)).toEqual(['x1']); + expect(viewerParamsForRow(row)).toEqual({ jmapAccountId: 'c', emailIds: ['x1'] }); + }); +}); diff --git a/src/stores/auth-store.ts b/src/stores/auth-store.ts index 67aa146b..36543510 100644 --- a/src/stores/auth-store.ts +++ b/src/stores/auth-store.ts @@ -566,17 +566,38 @@ export const useAuthStore = create((set, get) => ({ jmapClient.reset(); clearAccountFeatureStores(currentId); + // Neither the switch below nor a failed one, which puts back the account + // it left, may bring the signed-out account back. + set({ serverUrl: null, username: null, session: null, accountId: null, activeAccountId: null }); - // Switch to next remaining account, if any - const remaining = accountStore.accounts; - if (remaining.length > 0) { - const next = accountStore.getDefaultAccount() ?? remaining[0]; - try { - await get().switchAccount(next.id); - return; - } catch { - // fall through to full logout below - } + // Switch to the next remaining account, if any. The registry is read + // afresh each time: the state taken above still lists the account just + // removed, and signing out of the last one "switched" to it, which did + // nothing, and left the app on an empty mail view. + for (let tries = useAccountStore.getState().accounts.length; tries > 0; tries--) { + const registry = useAccountStore.getState(); + const next = registry.getDefaultAccount() ?? registry.accounts[0]; + if (!next) break; + await get().switchAccount(next.id); + // Landed, or another switch has begun and decides. + if (get().activeAccountId === next.id || pendingSwitch) return; + if (!useAccountStore.getState().getAccountById(next.id)) continue; // its sign-in was refused + // Its server did not answer: show what is cached for it, as a cold + // start does, and let the session retry bring it up. + useAccountStore.getState().setActiveAccount(next.id); + useEmailStore.getState().setActiveAccount(next.id); + set({ + isAuthenticated: true, + isLoading: false, + hasRestoredSession: true, + serverUrl: next.serverUrl, + username: next.username, + session: null, + accountId: null, + activeAccountId: next.id, + client: jmapClient, + }); + return; } set({ diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 04d4cb76..0968034b 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -559,12 +559,15 @@ export function spansAccounts(state: Pick /** * The JMAP accounts a list spanning accounts covers, and a tag badge counts: * the user's own (undefined) and every shared account with folders in the - * sidebar. + * sidebar. The sidebar asks while it renders, also on a cold start before + * the session is up: the client's throwing `accountId` crashed the app then, + * whenever the persisted folders held a shared or group one. */ export function spannedAccounts(mailboxes: Mailbox[]): Array { + const own = jmapClient.connectedAccountId; const out: Array = [undefined]; for (const m of mailboxes) { - if (m.isShared && m.accountId && m.accountId !== jmapClient.accountId && !out.includes(m.accountId)) { + if (m.isShared && m.accountId && m.accountId !== own && !out.includes(m.accountId)) { out.push(m.accountId); } } @@ -572,10 +575,11 @@ export function spannedAccounts(mailboxes: Mailbox[]): Array } // The JMAP account a list row lives under (undefined = the user's own): its -// stamp in a list that spans accounts, else the folder on screen. +// stamp in a list that spans accounts, else the folder on screen. Asked at +// render as well (the viewer's pages), before the session may be up. function rowAccountId(state: EmailState, email: Email | undefined): string | undefined { if (email?.jmapAccountId) { - return email.jmapAccountId === jmapClient.accountId ? undefined : email.jmapAccountId; + return email.jmapAccountId === jmapClient.connectedAccountId ? undefined : email.jmapAccountId; } return currentAccountId(state); }