mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-03 08:06:59 +00:00
fix: stop a start-up crash, and stale views after sign-out or a new server
- The app crashed on every cold start whose cached folders held a
shared or group folder, which on Stalwart 1.0 every group member has:
the sidebar works out the accounts to span while it renders, before
the session is up, and the JMAP client's account id throws until one
is. The client now also names its account without throwing, for code
that runs while rendering; the mail list's account helpers (the
sidebar's accounts, a row's account, the viewer's pages) use it, and
a read receipt sent by itself waits for the session. API calls still
throw without a session.
- Signing out of the last account left the app on an empty mail view
("No mailboxes found") with nothing to retry: the account list was
read before the account was removed, so the app switched to the
account it had just signed out of, which did nothing. It is read
again now, and the sign-in screen shows. A failed switch to the next
account no longer puts the signed-out one back: when its server does
not answer, that account shows offline, as on a cold start, and one
whose sign-in is refused is skipped.
- After signing in with the same address on another server (another
port) or on a server reinstalled at the address (0.16 to 1.0), Files
kept showing the old server's drive without asking the new one, and
actions on its rows would have used the old server's ids. The local
account id leaves the port out and stays the same there, so the
drive now also starts over when the server behind the session
changes, and is read again with every new session. The account id
itself is unchanged: it keys the stored credentials and every
per-account cache, and changing it would sign such accounts out and
orphan their data.
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
});
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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<FilesViewMode | null>(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<string | null | undefined>(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<DriveScope | undefined>(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.
|
||||
|
||||
@@ -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<string, string> = { [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<string, Error>,
|
||||
},
|
||||
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, unknown>, 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<void> {
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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>): 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'] });
|
||||
});
|
||||
});
|
||||
+31
-10
@@ -566,17 +566,38 @@ export const useAuthStore = create<AuthState>((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({
|
||||
|
||||
@@ -559,12 +559,15 @@ export function spansAccounts(state: Pick<EmailState, 'searchQuery' | 'filters'>
|
||||
/**
|
||||
* 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<string | undefined> {
|
||||
const own = jmapClient.connectedAccountId;
|
||||
const out: Array<string | undefined> = [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<string | undefined>
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user