Merge pull request #677 from dealerweb/fix/calendar-fanout-access-denied

Fix: stop re-probing shared accounts without calendar access
This commit is contained in:
Linus Rath
2026-07-23 18:44:53 +02:00
committed by GitHub
2 changed files with 122 additions and 1 deletions
@@ -0,0 +1,97 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
// The calendar fan-out probes shared accounts on suspicion (Stalwart does not
// always advertise calendar capability on group accounts), so a shared account
// without any calendar access answers every probe with an access rejection.
// That rejection must be remembered and the account skipped afterwards -
// before, every calendar interaction re-probed it and logged a console error.
function makeSession() {
return {
capabilities: { 'urn:ietf:params:jmap:core': {} },
accounts: {
'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} },
// Shared account without calendar access: probed because it is
// non-personal, rejected by the server.
'ev': { name: 'shared', isPersonal: false, 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: '',
};
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('calendar fan-out to shared accounts without access', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
let consoleErrorSpy: ReturnType<typeof vi.spyOn>;
let client: JMAPClient;
beforeEach(async () => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession()));
client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
await client.connect();
fetchSpy.mockReset();
fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = JSON.parse(String(init?.body ?? '{}'));
const [method, args] = body.methodCalls?.[0] ?? [];
if (args?.accountId === 'ev') {
return jsonResponse({
methodResponses: [
['error', { type: 'accountNotFound', description: 'You do not have access to account ev' }, '0'],
],
});
}
if (method === 'CalendarEvent/query') {
return jsonResponse({ methodResponses: [['CalendarEvent/query', { ids: [] }, '0']] });
}
return jsonResponse({ methodResponses: [['Calendar/get', { list: [] }, '0']] });
});
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
client.disconnect();
consoleErrorSpy.mockRestore();
fetchSpy.mockRestore();
});
function callsForAccount(accountId: string): unknown[][] {
return fetchSpy.mock.calls.filter((call: unknown[]) => {
const body = (call[1] as RequestInit | undefined)?.body;
return typeof body === 'string' && body.includes(`"accountId":"${accountId}"`);
});
}
it('probes a no-access shared account once, then skips it without console noise', async () => {
await client.queryAllCalendarEvents({});
await client.queryAllCalendarEvents({});
expect(callsForAccount('ev')).toHaveLength(1);
// The primary account keeps being queried normally.
expect(callsForAccount('acct-1')).toHaveLength(2);
// An expected rejection is not an error worth red console output.
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
it('getAllCalendars skips an account already known to be inaccessible', async () => {
await client.queryAllCalendarEvents({});
fetchSpy.mockClear();
await client.getAllCalendars();
expect(callsForAccount('ev')).toHaveLength(0);
expect(callsForAccount('acct-1')).toHaveLength(1);
});
});
+25 -1
View File
@@ -4566,6 +4566,7 @@ export class JMAPClient implements IJMAPClient {
for (const accountId of accountIds) { for (const accountId of accountIds) {
const isPrimary = accountId === primaryId; const isPrimary = accountId === primaryId;
if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue;
const account = this.accounts[accountId]; const account = this.accounts[accountId];
try { try {
@@ -4772,6 +4773,11 @@ export class JMAPClient implements IJMAPClient {
.map((event) => normalizeCalendarEventLike(event)); .map((event) => normalizeCalendarEventLike(event));
} }
// Shared accounts the server rejected calendar access for - probed once,
// then skipped for the rest of the session (see getCalendarCapableAccountIds
// for why the fan-out has to probe on suspicion).
private calendarAccessDenied = new Set<string>();
async queryAllCalendarEvents( async queryAllCalendarEvents(
filter: CalendarEventFilter, filter: CalendarEventFilter,
sort?: Array<{ property: string; isAscending: boolean }>, sort?: Array<{ property: string; isAscending: boolean }>,
@@ -4784,6 +4790,7 @@ export class JMAPClient implements IJMAPClient {
for (const accountId of accountIds) { for (const accountId of accountIds) {
const isPrimary = accountId === primaryId; const isPrimary = accountId === primaryId;
if (!isPrimary && this.calendarAccessDenied.has(accountId)) continue;
const account = this.accounts[accountId]; const account = this.accounts[accountId];
try { try {
@@ -4849,7 +4856,12 @@ export class JMAPClient implements IJMAPClient {
if (queryResponse.methodResponses?.[0]?.[0] === "error") { if (queryResponse.methodResponses?.[0]?.[0] === "error") {
const error = queryResponse.methodResponses[0][1]; const error = queryResponse.methodResponses[0][1];
throw new Error(error?.description || error?.type || "CalendarEvent/query failed"); // Keep the JMAP error type so the catch below can tell an expected
// access rejection apart from a genuine failure.
throw Object.assign(
new Error(error?.description || error?.type || "CalendarEvent/query failed"),
{ jmapErrorType: error?.type },
);
} }
const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || []; const ids: string[] = queryResponse.methodResponses?.[0]?.[1]?.ids || [];
@@ -4893,6 +4905,18 @@ export class JMAPClient implements IJMAPClient {
return filtered; return filtered;
} catch (error) { } catch (error) {
// The fan-out over shared accounts probes on suspicion (see
// getCalendarCapableAccountIds) and may hit accounts that grant no
// calendar access at all. Remember the rejection and go quiet instead
// of re-probing - and re-logging - on every range change.
const type = (error as { jmapErrorType?: string } | null)?.jmapErrorType;
const denied = type === 'forbidden' || type === 'accountNotFound' ||
/not have access/i.test(error instanceof Error ? error.message : '');
if (targetAccountId && denied) {
this.calendarAccessDenied.add(targetAccountId);
debug.log('calendar', `No calendar access to account ${targetAccountId} - skipping it from now on`);
return [];
}
console.error('Failed to query calendar events:', error); console.error('Failed to query calendar events:', error);
return []; return [];
} }