From 5598c436f29759661ea0f46ace2cf67b5933c769 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:08:52 +0100 Subject: [PATCH] feat: add connection loss handling and update auth store --- app/[locale]/page.tsx | 12 +- lib/__tests__/jmap-client-resilience.test.ts | 280 +++++++++++++++++++ locales/en/common.json | 1 + stores/auth-store.ts | 17 ++ 4 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 lib/__tests__/jmap-client-resilience.test.ts diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index c3c8aadf..4b79695c 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -57,7 +57,7 @@ export default function Home() { const [conversationEmails, setConversationEmails] = useState([]); const [isLoadingConversation, setIsLoadingConversation] = useState(false); const markAsReadTimeoutRef = useRef(null); - const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost } = useAuthStore(); const { identities } = useIdentityStore(); // Mobile/tablet responsive hooks @@ -801,7 +801,14 @@ export default function Home() { return ( -
+
+ {connectionLost && ( +
+ + {tCommon('reconnecting')} +
+ )} +
{/* Desktop Navigation Rail */} {!isMobile && !isTablet && (
@@ -1305,6 +1312,7 @@ export default function Home() { )}
+
{/* Keyboard Shortcuts Modal */} ) { + return { + capabilities: { 'urn:ietf:params:jmap:core': {} }, + accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' }, + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: 'https://mail.example.com/jmap/eventsource', + ...overrides, + }; +} + +function mockFetchResponse(status: number, body?: unknown): Response { + return new Response(body ? JSON.stringify(body) : null, { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('JMAPClient resilience', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + vi.useRealTimers(); + }); + + /** + * Helper: create a connected basic-auth client by mocking the connect() flow + */ + async function createConnectedClient(mode: 'basic' | 'bearer' = 'basic'): Promise { + const session = makeSession(); + + if (mode === 'basic') { + // connect() calls authenticatedFetch → fetch for session + fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session)); + const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123'); + await client.connect(); + fetchSpy.mockReset(); + return client; + } + + // bearer + fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session)); + const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com'); + await client.connect(); + fetchSpy.mockReset(); + return client; + } + + describe('authenticatedFetch — network error retry', () => { + it('retries once on transient network error', async () => { + const client = await createConnectedClient(); + const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }; + + fetchSpy + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockResolvedValueOnce(mockFetchResponse(200, echoResponse)); + + // ping() calls request() which calls authenticatedFetch + await expect(client.ping()).resolves.toBeUndefined(); + // First call fails, delay, second call succeeds + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('throws on persistent network error after retry', async () => { + const client = await createConnectedClient(); + + fetchSpy + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockRejectedValueOnce(new TypeError('Failed to fetch')); + + await expect(client.ping()).rejects.toThrow('Failed to fetch'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('authenticatedFetch — basic auth 401 session refresh', () => { + it('refreshes session and retries on 401 for API requests', async () => { + const client = await createConnectedClient(); + const refreshedSession = makeSession({ apiUrl: 'https://mail.example.com/jmap/api-v2' }); + const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }; + + fetchSpy + // First API call → 401 + .mockResolvedValueOnce(mockFetchResponse(401)) + // refreshSession() fetches /.well-known/jmap + .mockResolvedValueOnce(mockFetchResponse(200, refreshedSession)) + // Retry of original request → 200 + .mockResolvedValueOnce(mockFetchResponse(200, echoResponse)); + + await expect(client.ping()).resolves.toBeUndefined(); + expect(fetchSpy).toHaveBeenCalledTimes(3); + + // Verify the session refresh hit the right URL + const refreshCall = fetchSpy.mock.calls[1]; + expect(refreshCall[0]).toBe('https://mail.example.com/.well-known/jmap'); + }); + + it('returns original 401 response when session refresh fails', async () => { + const client = await createConnectedClient(); + + fetchSpy + // First API call → 401 + .mockResolvedValueOnce(mockFetchResponse(401)) + // refreshSession() also fails + .mockResolvedValueOnce(mockFetchResponse(401)); + + // request() throws because response.ok is false + await expect(client.ping()).rejects.toThrow('Request failed: 401'); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + it('does NOT attempt session refresh for /.well-known/jmap requests', async () => { + // Connect will fail with 401 on the session URL itself + fetchSpy.mockResolvedValueOnce(mockFetchResponse(401)); + const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'wrong-pass'); + + // connect() should throw without trying to refresh session (would cause infinite recursion) + await expect(client.connect()).rejects.toThrow('Invalid username or password'); + // Only one fetch call — no refresh attempt + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('authenticatedFetch — bearer token refresh', () => { + it('refreshes token and retries on 401 for bearer mode', async () => { + const tokenRefresh = vi.fn().mockResolvedValue('new-token-456'); + const session = makeSession(); + + fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session)); + const client = JMAPClient.withBearer('https://mail.example.com', 'old-token', 'user@test.com', tokenRefresh); + await client.connect(); + fetchSpy.mockReset(); + + const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }; + + fetchSpy + // First API call → 401 + .mockResolvedValueOnce(mockFetchResponse(401)) + // Retry with new token → 200 + .mockResolvedValueOnce(mockFetchResponse(200, echoResponse)); + + await expect(client.ping()).resolves.toBeUndefined(); + expect(tokenRefresh).toHaveBeenCalledOnce(); + expect(fetchSpy).toHaveBeenCalledTimes(2); + + // Verify retry used the new token + const retryCall = fetchSpy.mock.calls[1]; + const retryHeaders = retryCall[1]?.headers as Record; + expect(retryHeaders['Authorization']).toBe('Bearer new-token-456'); + }); + }); + + describe('refreshSession', () => { + it('updates session fields from server response', async () => { + const client = await createConnectedClient(); + + const newSession = makeSession({ + apiUrl: 'https://mail.example.com/jmap/api-v2', + downloadUrl: 'https://mail.example.com/jmap/download-v2/{accountId}/{blobId}/{name}', + capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} }, + }); + + // Trigger a 401 → refreshSession flow + const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }; + fetchSpy + .mockResolvedValueOnce(mockFetchResponse(401)) + .mockResolvedValueOnce(mockFetchResponse(200, newSession)) + .mockResolvedValueOnce(mockFetchResponse(200, echoResponse)); + + await client.ping(); + + // After refresh, subsequent requests should go to the new apiUrl + fetchSpy.mockReset(); + fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, echoResponse)); + await client.ping(); + + const apiCall = fetchSpy.mock.calls[0]; + expect(apiCall[0]).toBe('https://mail.example.com/jmap/api-v2'); + }); + }); + + describe('onConnectionChange callback', () => { + it('fires with true on successful ping during keep-alive', async () => { + const client = await createConnectedClient(); + const callback = vi.fn(); + client.onConnectionChange(callback); + + const echoResponse = { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] }; + fetchSpy.mockResolvedValue(mockFetchResponse(200, echoResponse)); + + // Advance past keep-alive interval (30s) + await vi.advanceTimersByTimeAsync(30_000); + + expect(callback).toHaveBeenCalledWith(true); + }); + + it('fires with false on ping failure, then true on successful reconnect', async () => { + const client = await createConnectedClient(); + const callback = vi.fn(); + client.onConnectionChange(callback); + + const session = makeSession(); + + // ping() will call request() → authenticatedFetch → first fetch fails + // Then retry in authenticatedFetch also fails + // So ping throws, keep-alive catches it, fires false + // Then reconnect → connect() → authenticatedFetch(sessionUrl) succeeds + fetchSpy + // ping fails — network error, retry also fails + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + .mockRejectedValueOnce(new TypeError('Failed to fetch')) + // reconnect → connect() → session URL succeeds + .mockResolvedValueOnce(mockFetchResponse(200, session)); + + // Trigger the keep-alive interval + await vi.advanceTimersByTimeAsync(30_000); + // Flush the nested 1s retry delay inside authenticatedFetch + await vi.advanceTimersByTimeAsync(1_000); + // Allow microtasks to settle + await vi.advanceTimersByTimeAsync(0); + + expect(callback).toHaveBeenCalledWith(false); + expect(callback).toHaveBeenCalledWith(true); + // Verify ordering: false fired before true + const calls = callback.mock.calls.map((c) => c[0]); + const falseIdx = calls.indexOf(false); + const trueIdx = calls.lastIndexOf(true); + expect(falseIdx).toBeLessThan(trueIdx); + }); + + it('fires with false when ping and reconnect both fail', async () => { + const client = await createConnectedClient(); + const callback = vi.fn(); + client.onConnectionChange(callback); + + // All fetches fail + fetchSpy.mockRejectedValue(new TypeError('Failed to fetch')); + + // Trigger the keep-alive interval + await vi.advanceTimersByTimeAsync(30_000); + // Flush nested retry delays + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(1_000); + await vi.advanceTimersByTimeAsync(0); + + expect(callback).toHaveBeenCalledWith(false); + // Should not have fired true at any point + const trueCall = callback.mock.calls.find((c) => c[0] === true); + expect(trueCall).toBeUndefined(); + }); + }); + + describe('disconnect', () => { + it('stops keep-alive and cleans up', async () => { + const client = await createConnectedClient(); + const callback = vi.fn(); + client.onConnectionChange(callback); + + client.disconnect(); + + // Advancing timers should not trigger any ping + fetchSpy.mockResolvedValue(mockFetchResponse(200, { methodResponses: [['Core/echo', { ping: 'pong' }, '0']] })); + await vi.advanceTimersByTimeAsync(60_000); + + expect(callback).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/locales/en/common.json b/locales/en/common.json index e2aff706..b7e774c5 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -8,6 +8,7 @@ "sign_in": "Sign in", "signing_in": "Signing in...", "loading": "Loading...", + "reconnecting": "Connection lost. Attempting to reconnect\u2026", "error": { "invalid_credentials": "Invalid email or password. Please check your credentials and try again.", "connection_failed": "Unable to reach the server. Check your internet connection and try again.", diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 54712c89..509b4129 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -24,6 +24,7 @@ interface AuthState { rememberMe: boolean; accessToken: string | null; tokenExpiresAt: number | null; + connectionLost: boolean; login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise; loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise; @@ -131,6 +132,7 @@ export const useAuthStore = create()( rememberMe: false, accessToken: null, tokenExpiresAt: null, + connectionLost: false, login: async (serverUrl, username, password, totp, rememberMe) => { const effectivePassword = totp ? `${password}$${totp}` : password; @@ -138,6 +140,9 @@ export const useAuthStore = create()( try { const client = new JMAPClient(serverUrl, username, effectivePassword); + client.onConnectionChange((connected) => { + set({ connectionLost: !connected }); + }); await client.connect(); const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username); @@ -154,6 +159,7 @@ export const useAuthStore = create()( authMode: 'basic', accessToken: null, tokenExpiresAt: null, + connectionLost: false, error: null, }); @@ -210,6 +216,9 @@ export const useAuthStore = create()( const refreshFn = get().refreshAccessToken; const client = JMAPClient.withBearer(serverUrl, access_token, '', () => refreshFn()); + client.onConnectionChange((connected) => { + set({ connectionLost: !connected }); + }); await client.connect(); const username = client.getUsername(); @@ -227,6 +236,7 @@ export const useAuthStore = create()( authMode: 'oauth', accessToken: access_token, tokenExpiresAt: Date.now() + expires_in * 1000, + connectionLost: false, error: null, }); @@ -307,6 +317,7 @@ export const useAuthStore = create()( rememberMe: false, accessToken: null, tokenExpiresAt: null, + connectionLost: false, error: null, }); @@ -365,6 +376,9 @@ export const useAuthStore = create()( if (token && state.serverUrl) { const refreshFn = get().refreshAccessToken; const client = JMAPClient.withBearer(state.serverUrl, token, state.username || '', () => refreshFn()); + client.onConnectionChange((connected) => { + set({ connectionLost: !connected }); + }); await client.connect(); const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), state.username || ''); @@ -403,6 +417,9 @@ export const useAuthStore = create()( } const { serverUrl, username, password } = data; const client = new JMAPClient(serverUrl, username, password); + client.onConnectionChange((connected) => { + set({ connectionLost: !connected }); + }); await client.connect(); const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);