Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions src/api/__tests__/jmap-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ describe('JMAPClient', () => {
await client.connect('https://mail.example.com///', 'user', 'pass');

expect(global.fetch).toHaveBeenCalledWith(
'https://mail.example.com/.well-known/jmap',
'https://mail.example.com/jmap/session',
expect.any(Object),
);
});
Expand Down Expand Up @@ -281,7 +281,9 @@ describe('JMAPClient', () => {
expect(client.accountId).toBe('only-acc');
});

it('should throw if no accounts at all', async () => {
it('should throw on an empty (unauthenticated) session', async () => {
// Stalwart returns 200 with empty accounts for invalid credentials;
// the client surfaces that as an auth failure.
const session = {
...MOCK_SESSION,
primaryAccounts: {},
Expand All @@ -290,7 +292,7 @@ describe('JMAPClient', () => {
global.fetch = mockFetch([{ status: 200, json: session }]) as any;

await expect(client.connect('https://mail.example.com', 'user', 'pass'))
.rejects.toThrow('No account found');
.rejects.toThrow('Invalid credentials');
});
});
});
32 changes: 27 additions & 5 deletions src/api/jmap-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,18 +361,30 @@ export class JMAPClient {
// ── Session Discovery ─────────────────────────────────

private async fetchSession(baseUrl: string): Promise<JMAPSession> {
const url = `${baseUrl}/.well-known/jmap`;
await this.ensureFreshToken();
const doFetch = () =>
// Stalwart's discovery endpoint /.well-known/jmap 307-redirects to
// /jmap/session. On iOS, NSURLSession drops the Authorization header when it
// auto-follows that redirect, so the app receives an unauthenticated,
// empty-accounts session and fails with "No account found in JMAP session".
// Request the session endpoint directly so the auth header stays attached;
// fall back to the standard well-known path for non-Stalwart servers.
const doFetch = (url: string) =>
secureFetch(url, {
headers: {
Authorization: this.authHeader,
Accept: 'application/json',
},
});
let response = await doFetch();
const primary = `${baseUrl}/jmap/session`;
const fallback = `${baseUrl}/.well-known/jmap`;
const run = async () => {
const r = await doFetch(primary);
return r.status === 404 ? doFetch(fallback) : r;
};

let response = await run();
if (response.status === 401 && (await this.forceRefreshToken())) {
response = await doFetch();
response = await run();
}

if (response.status === 401) {
Expand All @@ -382,7 +394,17 @@ export class JMAPClient {
throw new Error(`Session discovery failed: ${response.status} ${response.statusText}`);
}

return response.json();
const session: JMAPSession = await response.json();
// Stalwart returns HTTP 200 with empty accounts for missing/invalid
// credentials (rather than 401). Treat an empty session as an auth failure
// so the user sees "Invalid credentials" instead of "No account found".
const hasAccount =
Object.keys(session.primaryAccounts ?? {}).length > 0 ||
Object.keys(session.accounts ?? {}).length > 0;
if (!hasAccount) {
throw new AuthenticationError('Invalid credentials');
}
return session;
}

private resolveAccountId(session: JMAPSession): string {
Expand Down