diff --git a/components/calendar/__tests__/participant-availability.test.ts b/components/calendar/__tests__/participant-availability.test.ts index 8683510b6..9b2ffd750 100644 --- a/components/calendar/__tests__/participant-availability.test.ts +++ b/components/calendar/__tests__/participant-availability.test.ts @@ -46,6 +46,7 @@ type MethodResponse = [string, Record, string]; const DIRECTORY = [ { id: 'me', type: 'individual', name: 'me@example.com', description: 'Me', email: 'me@example.com' }, { id: 'anna', type: 'individual', name: 'anna@example.com', description: 'Anna', email: 'anna@example.com' }, + { id: 'bob', type: 'individual', name: 'bob@example.com', description: 'Bob', email: 'bob@example.com' }, ]; // The user's own principal as each server version returns it for @@ -220,6 +221,58 @@ describe('ParticipantAvailability', () => { await waitFor(() => expect(screen.getByText('error')).toBeInTheDocument(), { timeout: 2000 }); }); + it('shows "no busy times shared", not "available", for an empty answer', async () => { + // Both versions answer list:[] as well for someone whose free/busy the + // user may not see. + const { client } = makeClient({ + ownPrincipal: OWN_PRINCIPAL_0_16, + availability: (_id, callId) => ['Principal/getAvailability', { list: [] }, callId], + }); + renderFor(client); + + await waitFor(() => expect(screen.getByText('no_busy_times')).toBeInTheDocument(), { timeout: 2000 }); + expect(screen.queryByText('free')).toBeNull(); + }); + + it('asks for all attendees in one request and answers a new time on the same day from the cache', async () => { + // Local times: the cache holds whole local days. + const tenOclock = new Date(2026, 8, 1, 10); + const { client, requests } = makeClient({ + ownPrincipal: OWN_PRINCIPAL_0_16, + availability: (id, callId) => ['Principal/getAvailability', { + list: [{ utcStart: new Date(2026, 8, 1, 10, 30).toISOString(), utcEnd: new Date(2026, 8, 1, 11, 30).toISOString(), busyStatus: id === 'anna' ? 'confirmed' : 'tentative' }], + }, callId], + }); + useAuthStore.setState({ client } as never); + const people = [{ name: 'Anna', email: 'anna@example.com' }, { name: 'Bob', email: 'bob@example.com' }]; + const view = render(createElement(ParticipantAvailability, { attendees: people, start: tenOclock, end: new Date(2026, 8, 1, 11) })); + + await waitFor(() => expect(screen.getByText('busy')).toBeInTheDocument(), { timeout: 2000 }); + expect(screen.getByText('tentative')).toBeInTheDocument(); + const lookups = requests.filter((calls) => calls[0][0] === 'Principal/getAvailability'); + expect(lookups).toHaveLength(1); + expect(lookups[0].map(([, args]) => args.id)).toEqual(['anna', 'bob']); + + view.rerender(createElement(ParticipantAvailability, { attendees: people, start: new Date(2026, 8, 1, 14), end: new Date(2026, 8, 1, 15) })); + + await waitFor(() => expect(screen.getAllByText('free')).toHaveLength(2), { timeout: 2000 }); + expect(requests.filter((calls) => calls[0][0] === 'Principal/getAvailability')).toHaveLength(1); + }); + + it('stops asking after rateLimit on Stalwart 1.0 and keeps saying it could not check', async () => { + const { client, requests } = makeClient({ ownPrincipal: OWN_PRINCIPAL_0_16, availability: () => RATE_LIMITED }); + useAuthStore.setState({ client } as never); + const people = [{ name: 'Anna', email: 'anna@example.com' }]; + const view = render(createElement(ParticipantAvailability, { attendees: people, start: new Date(2026, 8, 1, 10), end: new Date(2026, 8, 1, 11) })); + await waitFor(() => expect(screen.getByText('error')).toBeInTheDocument(), { timeout: 2000 }); + + view.rerender(createElement(ParticipantAvailability, { attendees: people, start: new Date(2026, 8, 1, 14), end: new Date(2026, 8, 1, 15) })); + expect(screen.queryByText('error')).toBeNull(); + + await waitFor(() => expect(screen.getByText('error')).toBeInTheDocument(), { timeout: 2000 }); + expect(requests.filter((calls) => calls[0][0] === 'Principal/getAvailability')).toHaveLength(1); + }); + it('calls no one "not a user" when the directory listing failed', async () => { const { client } = makeClient({ ownPrincipal: OWN_PRINCIPAL_0_16, diff --git a/components/calendar/participant-availability.tsx b/components/calendar/participant-availability.tsx index a569b651d..b8096918f 100644 --- a/components/calendar/participant-availability.tsx +++ b/components/calendar/participant-availability.tsx @@ -6,9 +6,8 @@ import { Loader2 } from "@/components/icons"; import { useAuthStore } from "@/stores/auth-store"; import type { IJMAPClient } from "@/lib/jmap/client-interface"; import type { BusyPeriod, Principal } from "@/lib/jmap/types"; -import { jmapErrorType } from "@/lib/jmap/jmap-error"; import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory"; -import { checkAvailabilityAccess, knownAvailabilityAccess, revokeAvailabilityAccess } from "@/lib/jmap/free-busy"; +import { checkAvailabilityAccess, knownAvailabilityAccess, lookUpAvailability, revokeAvailabilityAccess } from "@/lib/jmap/free-busy"; import { cn } from "@/lib/utils"; interface Attendee { @@ -18,9 +17,11 @@ interface Attendee { /** * `unknown`: the address is not a user in a complete directory listing. + * `no_busy_times`: the server returned no busy time for the attendee's day. + * It says the same when the user may not see that person's free/busy. * `error`: the lookup failed or was rate-limited, so nothing is known. */ -export type AvailabilityStatus = "free" | "busy" | "tentative" | "unknown" | "error" | "checking"; +export type AvailabilityStatus = "free" | "busy" | "tentative" | "no_busy_times" | "unknown" | "error" | "checking"; interface ParticipantAvailabilityProps { attendees: Attendee[]; @@ -33,7 +34,7 @@ interface ParticipantAvailabilityProps { * The status of one attendee for the window, given their busy periods: * any confirmed/unavailable overlap wins over a tentative one. */ -export function availabilityFor(periods: BusyPeriod[], start: Date, end: Date): Exclude { +export function availabilityFor(periods: BusyPeriod[], start: Date, end: Date): "free" | "busy" | "tentative" { let status: "free" | "busy" | "tentative" = "free"; for (const p of periods) { const pStart = new Date(p.utcStart).getTime(); @@ -66,22 +67,27 @@ async function lookUpStatuses( for (const p of directory.principals) { if (p.email) byEmail.set(p.email.toLowerCase(), p); } - let refused = false; - const results = await Promise.all( - emails.map(async (email): Promise<[string, AvailabilityStatus]> => { - const principal = byEmail.get(email); + const principalIds = emails.flatMap((email) => byEmail.get(email)?.id ?? []); + const answers = await lookUpAvailability(client, principalIds, start, end); + const statuses: Record = {}; + for (const email of emails) { + const principal = byEmail.get(email); + if (!principal) { // Only a complete listing shows that an address is not a user here. - if (!principal) return [email, directory.status === "ok" ? "unknown" : "error"]; - try { - const periods = await client.getPrincipalAvailability!(principal.id, start, end); - return [email, availabilityFor(periods, start, end)]; - } catch (err) { - if (jmapErrorType(err) === "forbidden") refused = true; - return [email, "error"]; - } - }), - ); - return refused ? null : Object.fromEntries(results); + statuses[email] = directory.status === "ok" ? "unknown" : "error"; + continue; + } + const answer = answers[principal.id]; + if ("error" in answer) { + if (answer.error === "forbidden") return null; + statuses[email] = "error"; + } else { + // An empty answer is also what someone gets who may not see this + // person's free/busy, so it doesn't mean "available". + statuses[email] = answer.periods.length === 0 ? "no_busy_times" : availabilityFor(answer.periods, start, end); + } + } + return statuses; } /** @@ -93,7 +99,7 @@ async function lookUpStatuses( export function ParticipantAvailability({ attendees, start, end }: ParticipantAvailabilityProps) { const t = useTranslations("calendar.participants.availability"); const client = useAuthStore((s) => s.client); - const supported = !!client?.supportsAvailability?.() && !!client.getPrincipalAvailability; + const supported = !!client?.supportsAvailability?.() && !!client.getPrincipalsAvailability; const [statuses, setStatuses] = useState>({}); const [access, setAccess] = useState<{ client: IJMAPClient; allowed: boolean } | null>(null); const requestSeq = useRef(0); @@ -161,7 +167,7 @@ export function ParticipantAvailability({ attendees, start, end }: ParticipantAv status === "free" && "bg-emerald-500", status === "busy" && "bg-red-500", status === "tentative" && "bg-amber-500", - (status === "unknown" || status === "error" || status === "checking") && "bg-muted-foreground/40", + (status === "no_busy_times" || status === "unknown" || status === "error" || status === "checking") && "bg-muted-foreground/40", )} aria-hidden="true" /> diff --git a/lib/__tests__/free-busy.test.ts b/lib/__tests__/free-busy.test.ts new file mode 100644 index 000000000..2cac0b9ef --- /dev/null +++ b/lib/__tests__/free-busy.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + AVAILABILITY_TTL_MS, + RATE_LIMIT_BACKOFF_MS, + availabilityWindow, + lookUpAvailability, +} from '../jmap/free-busy'; +import type { IJMAPClient } from '../jmap/client-interface'; +import type { PrincipalAvailability } from '../jmap/types'; + +const BUSY: PrincipalAvailability = { periods: [{ utcStart: '2026-09-01T10:30:00Z', utcEnd: '2026-09-01T11:30:00Z', busyStatus: 'confirmed' }] }; +const EMPTY: PrincipalAvailability = { periods: [] }; + +// A client whose getPrincipalsAvailability answers every principal with +// `answer(id)`, as one batched request would. +function makeClient(answer: (id: string) => PrincipalAvailability = () => BUSY) { + const getPrincipalsAvailability = vi.fn(async (ids: string[]) => + Object.fromEntries(ids.map((id) => [id, answer(id)])) as Record); + return { client: { getPrincipalsAvailability } as unknown as IJMAPClient, getPrincipalsAvailability }; +} + +const at = (time: string) => new Date(`2026-09-01T${time}`); + +describe('availabilityWindow', () => { + it('covers the local days the event touches', () => { + const { start, end } = availabilityWindow(at('10:00:00'), at('11:00:00')); + expect(start).toEqual(new Date(2026, 8, 1)); + expect(end).toEqual(new Date(2026, 8, 2)); + }); + + it('ends at the midnight an event ends on, and spans several days', () => { + expect(availabilityWindow(new Date(2026, 8, 1, 22), new Date(2026, 8, 2)).end).toEqual(new Date(2026, 8, 2)); + const { start, end } = availabilityWindow(new Date(2026, 8, 1, 22), new Date(2026, 8, 3, 1)); + expect(start).toEqual(new Date(2026, 8, 1)); + expect(end).toEqual(new Date(2026, 8, 4)); + }); +}); + +describe('lookUpAvailability', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(new Date(2026, 8, 1, 9)); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('asks for every principal in one call over the whole day', async () => { + const { client, getPrincipalsAvailability } = makeClient((id) => (id === 'anna' ? BUSY : EMPTY)); + + const answers = await lookUpAvailability(client, ['anna', 'bob', 'anna'], at('10:00:00'), at('11:00:00')); + + expect(answers).toEqual({ anna: BUSY, bob: EMPTY }); + expect(getPrincipalsAvailability).toHaveBeenCalledTimes(1); + const days = availabilityWindow(at('10:00:00'), at('11:00:00')); + expect(getPrincipalsAvailability).toHaveBeenCalledWith(['anna', 'bob'], days.start, days.end); + }); + + it('answers another time on the same day from the cache until it is stale', async () => { + const { client, getPrincipalsAvailability } = makeClient(); + + await lookUpAvailability(client, ['anna'], at('10:00:00'), at('11:00:00')); + expect(await lookUpAvailability(client, ['anna'], at('14:00:00'), at('15:00:00'))).toEqual({ anna: BUSY }); + expect(getPrincipalsAvailability).toHaveBeenCalledTimes(1); + + vi.setSystemTime(Date.now() + AVAILABILITY_TTL_MS); + await lookUpAvailability(client, ['anna'], at('14:00:00'), at('15:00:00')); + expect(getPrincipalsAvailability).toHaveBeenCalledTimes(2); + }); + + it('asks only for the principals it has no answer for', async () => { + const { client, getPrincipalsAvailability } = makeClient(); + + await lookUpAvailability(client, ['anna'], at('10:00:00'), at('11:00:00')); + await lookUpAvailability(client, ['anna', 'bob'], at('10:00:00'), at('11:00:00')); + + expect(getPrincipalsAvailability).toHaveBeenCalledTimes(2); + expect(getPrincipalsAvailability.mock.calls[1][0]).toEqual(['bob']); + }); + + it('shares a lookup that is already on its way', async () => { + const { client, getPrincipalsAvailability } = makeClient(); + + const [first, second] = await Promise.all([ + lookUpAvailability(client, ['anna', 'bob'], at('10:00:00'), at('11:00:00')), + lookUpAvailability(client, ['bob'], at('10:30:00'), at('11:00:00')), + ]); + + expect(getPrincipalsAvailability).toHaveBeenCalledTimes(1); + expect(first.bob).toEqual(BUSY); + expect(second.bob).toEqual(BUSY); + }); + + it('stops asking for a minute after Stalwart 1.0 answers rateLimit', async () => { + let limited = true; + const { client, getPrincipalsAvailability } = makeClient(() => (limited ? { error: 'rateLimit' } : BUSY)); + + expect(await lookUpAvailability(client, ['anna'], at('10:00:00'), at('11:00:00'))).toEqual({ anna: { error: 'rateLimit' } }); + limited = false; + + vi.setSystemTime(Date.now() + RATE_LIMIT_BACKOFF_MS - 1); + expect(await lookUpAvailability(client, ['anna', 'bob'], at('10:00:00'), at('11:00:00'))).toEqual({ + anna: { error: 'rateLimit' }, + bob: { error: 'rateLimit' }, + }); + expect(getPrincipalsAvailability).toHaveBeenCalledTimes(1); + + vi.setSystemTime(Date.now() + 1); + expect(await lookUpAvailability(client, ['anna'], at('10:00:00'), at('11:00:00'))).toEqual({ anna: BUSY }); + expect(getPrincipalsAvailability).toHaveBeenCalledTimes(2); + }); + + it('keeps no refusals: forbidden and a failed request are asked again next time', async () => { + const { client, getPrincipalsAvailability } = makeClient(() => ({ error: 'forbidden' })); + + expect(await lookUpAvailability(client, ['anna'], at('10:00:00'), at('11:00:00'))).toEqual({ anna: { error: 'forbidden' } }); + getPrincipalsAvailability.mockRejectedValueOnce(new Error('Request failed: 500 - Internal Server Error')); + expect(await lookUpAvailability(client, ['anna'], at('10:00:00'), at('11:00:00'))).toEqual({ anna: { error: 'serverUnavailable' } }); + await lookUpAvailability(client, ['anna'], at('10:00:00'), at('11:00:00')); + + expect(getPrincipalsAvailability).toHaveBeenCalledTimes(3); + }); + + it('keeps caches apart per client', async () => { + const one = makeClient(); + const two = makeClient(); + + await lookUpAvailability(one.client, ['anna'], at('10:00:00'), at('11:00:00')); + await lookUpAvailability(two.client, ['anna'], at('10:00:00'), at('11:00:00')); + + expect(one.getPrincipalsAvailability).toHaveBeenCalledTimes(1); + expect(two.getPrincipalsAvailability).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/__tests__/jmap-client-extensions.test.ts b/lib/__tests__/jmap-client-extensions.test.ts index 665095f70..85f774fac 100644 --- a/lib/__tests__/jmap-client-extensions.test.ts +++ b/lib/__tests__/jmap-client-extensions.test.ts @@ -396,6 +396,34 @@ describe('JMAP client extensions', () => { })); await expect(client.getPrincipalAvailability('p1', ...window)).rejects.toMatchObject({ jmapType: 'rateLimit' }); }); + + it('batches several principals per request up to maxCallsInRequest and keeps each answer apart', async () => { + await setupAvailability(); + (client as unknown as { capabilities: Record }).capabilities['urn:ietf:params:jmap:core'] = { maxCallsInRequest: 2 }; + answer((body) => ({ + methodResponses: body.methodCalls.map(([, args, callId]) => (args.id === 'p2' + // Stalwart 1.0 past 100 calls a minute; the calls before it still answer. + ? ['error', { type: 'rateLimit', description: 'Too many availability requests, please try again later' }, callId] + : ['Principal/getAvailability', { list: args.id === 'p1' + ? [{ utcStart: '2026-09-01T10:00:00Z', utcEnd: '2026-09-01T11:00:00Z', busyStatus: 'confirmed' }] + : [] }, callId])), + })); + + const answers = await client.getPrincipalsAvailability(['p1', 'p2', 'p3'], new Date('2026-09-01T00:00:00Z'), new Date('2026-09-02T00:00:00Z')); + + expect(requests.map((r) => r.methodCalls.map(([method, args, callId]) => [method, args.id, callId]))).toEqual([ + [['Principal/getAvailability', 'p1', '0'], ['Principal/getAvailability', 'p2', '1']], + [['Principal/getAvailability', 'p3', '0']], + ]); + expect(requests[0].methodCalls[0][1]).toEqual({ + accountId: 'acct-1', id: 'p1', utcStart: '2026-09-01T00:00:00Z', utcEnd: '2026-09-02T00:00:00Z', showDetails: false, + }); + expect(answers).toEqual({ + p1: { periods: [{ utcStart: '2026-09-01T10:00:00Z', utcEnd: '2026-09-01T11:00:00Z', busyStatus: 'confirmed' }] }, + p2: { error: 'rateLimit' }, + p3: { periods: [] }, + }); + }); }); describe('mailbox sharing', () => { diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 8182794c5..d3730b047 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -1,4 +1,4 @@ -import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PrincipalListing, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; +import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { FileNameRules } from "@/lib/file-name-rules"; import type { SortLevel } from "@/lib/message-list-order"; @@ -161,6 +161,7 @@ export interface IJMAPClient { supportsAvailability?(): boolean; mayGetAvailability?(): Promise; getPrincipalAvailability?(principalId: string, utcStart: Date, utcEnd: Date): Promise; + getPrincipalsAvailability?(principalIds: string[], utcStart: Date, utcEnd: Date): Promise>; // ShareNotification (RFC 9670 §3): who shared what with this user. Optional // (demo client). supportsShareNotifications?(): boolean; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6525f3da6..790a2d78d 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,5 +1,5 @@ import { generateUUID } from '@/lib/utils'; -import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalListing, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; +import type { Attachment, Email, Mailbox, MailboxRights, StateChange, AccountStates, CollectionChanges, ShareNotification, BusyPeriod, CalendarParticipantIdentity, CalendarEventNotification, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarComponentType, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, CreateCalendarOptions, FileNode, FileNodeFilter, FileNodeRights, Principal, PrincipalAvailability, PrincipalListing, PushSubscription, EmailPushConfig, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types"; import type { SieveScript, SieveCapabilities } from "./sieve-types"; import type { CalendarEventUpdateOptions, IJMAPClient, KeywordDiscoveryResult, KeywordInfo, KeywordMigration } from "./client-interface"; import { attachSearchSnippets, filterHasSnippetTerms, snippetFilterFor, type SearchSnippetResult } from "@/lib/search-snippet"; @@ -803,6 +803,12 @@ function splitRefusedChunk(chunk: RangeChunk, error: unknown): RangeChunk[] | nu ]; } +/** The busy periods of a Principal/getAvailability response. */ +function busyPeriodsOf(result: JMAPResponseResult | undefined): BusyPeriod[] { + const list = (result?.list ?? []) as Array<{ utcStart: string; utcEnd: string; busyStatus?: BusyPeriod['busyStatus'] }>; + return list.map((p) => ({ utcStart: p.utcStart, utcEnd: p.utcEnd, busyStatus: p.busyStatus ?? null })); +} + function hasSubmissionMethod(methodCalls: JMAPMethodCall[]): boolean { return methodCalls.some(([method]) => method.startsWith('Identity/') || method.startsWith('EmailSubmission/')); } @@ -6009,21 +6015,50 @@ export class JMAPClient implements IJMAPClient { async getPrincipalAvailability(principalId: string, utcStart: Date, utcEnd: Date): Promise { if (!this.supportsAvailability()) return []; const response = await this.request([ - ["Principal/getAvailability", { - accountId: this.accountId, - id: principalId, - utcStart: utcStart.toISOString().replace(/\.\d{3}Z$/, 'Z'), - utcEnd: utcEnd.toISOString().replace(/\.\d{3}Z$/, 'Z'), - showDetails: false, - }, "0"], + ["Principal/getAvailability", this.availabilityArgs(principalId, utcStart, utcEnd), "0"], ], [...this.principalsUsing(), JMAPClient.PRINCIPALS_AVAILABILITY_CAPABILITY]); const [method, result] = response.methodResponses?.[0] ?? []; if (method !== "Principal/getAvailability") { const error = result as JmapErrorInfo | undefined; throw jmapError(error?.description || "Failed to query availability", error); } - return ((result as { list?: Array<{ utcStart: string; utcEnd: string; busyStatus?: BusyPeriod['busyStatus'] }> }).list ?? []) - .map((p) => ({ utcStart: p.utcStart, utcEnd: p.utcEnd, busyStatus: p.busyStatus ?? null })); + return busyPeriodsOf(result); + } + + /** + * The busy periods of several principals over one window, batched into as + * few requests as maxCallsInRequest allows: Principal/getAvailability takes + * a single id. Each principal gets its periods, or the error type its call + * was refused with. Batching saves round trips only: Stalwart 1.0 counts + * every call against its limit of 100 a minute. A failed request throws. + */ + async getPrincipalsAvailability(principalIds: string[], utcStart: Date, utcEnd: Date): Promise> { + const results: Record = {}; + if (!this.supportsAvailability()) return results; + for (const batch of batched(principalIds, this.getMaxCallsInRequest())) { + const response = await this.request( + batch.map((id, i): JMAPMethodCall => ["Principal/getAvailability", this.availabilityArgs(id, utcStart, utcEnd), String(i)]), + [...this.principalsUsing(), JMAPClient.PRINCIPALS_AVAILABILITY_CAPABILITY], + ); + const byCallId = new Map((response.methodResponses ?? []).map((r) => [r[2], r])); + batch.forEach((id, i) => { + const [method, result] = byCallId.get(String(i)) ?? []; + results[id] = method === "Principal/getAvailability" + ? { periods: busyPeriodsOf(result) } + : { error: (result as JmapErrorInfo | undefined)?.type || "serverFail" }; + }); + } + return results; + } + + private availabilityArgs(principalId: string, utcStart: Date, utcEnd: Date): Record { + return { + accountId: this.accountId, + id: principalId, + utcStart: utcStart.toISOString().replace(/\.\d{3}Z$/, 'Z'), + utcEnd: utcEnd.toISOString().replace(/\.\d{3}Z$/, 'Z'), + showDetails: false, + }; } // ── ShareNotification (RFC 9670 §3) ──────────────────────────── diff --git a/lib/jmap/free-busy.ts b/lib/jmap/free-busy.ts index 30c1fa4e6..8faaf747f 100644 --- a/lib/jmap/free-busy.ts +++ b/lib/jmap/free-busy.ts @@ -1,14 +1,29 @@ /** - * Whether the server answers free/busy (Principal/getAvailability) for this - * user, asked once per client. + * Free/busy (Principal/getAvailability) lookups, per client. * + * Whether the server answers free/busy for this user at all is asked once. * Stalwart 1.0 refuses every availability lookup while the administrator has * directory queries off, which is its default, and says so up front on the * user's own principal (`mayGetAvailability: false`). Knowing that before the * first lookup keeps the event form from listing every attendee as unknown. + * + * Busy periods are cached per principal and window for a few minutes. The + * event form looks everyone up again whenever the time or the attendees + * change, and Stalwart 1.0 allows 100 getAvailability calls per account a + * minute, counting every call however they are batched. */ import type { IJMAPClient } from "./client-interface"; +import type { PrincipalAvailability } from "./types"; + +export const AVAILABILITY_TTL_MS = 3 * 60 * 1000; + +/** + * Stalwart counts availability calls in fixed one-minute windows and refuses + * every call until the window is over, without saying when that is. A full + * minute after a refusal is always past it. + */ +export const RATE_LIMIT_BACKOFF_MS = 60 * 1000; const access = new WeakMap(); const pendingChecks = new WeakMap>(); @@ -42,3 +57,94 @@ export function checkAvailabilityAccess(client: IJMAPClient): Promise { export function revokeAvailabilityAccess(client: IJMAPClient): void { access.set(client, false); } + +interface CachedLookup { + requestedAt: number; + result: Promise; +} + +interface LookupState { + cache: Map; + backoffUntil: number; +} + +const lookups = new WeakMap(); + +function lookupStateOf(client: IJMAPClient): LookupState { + let state = lookups.get(client); + if (!state) { + state = { cache: new Map(), backoffUntil: 0 }; + lookups.set(client, state); + } + return state; +} + +/** + * The window a lookup asks the server for: the local days from `start` to + * `end`, so that moving an event within its day is answered from the cache. + */ +export function availabilityWindow(start: Date, end: Date): { start: Date; end: Date } { + const from = new Date(start.getFullYear(), start.getMonth(), start.getDate()); + const to = new Date(end.getFullYear(), end.getMonth(), end.getDate()); + if (to.getTime() < end.getTime()) to.setDate(to.getDate() + 1); + return { start: from, end: to }; +} + +/** + * The busy periods of each principal over the local days from `start` to + * `end`, for the caller to match against the exact event window. Fresh + * answers come from the cache, lookups already on their way are shared, and + * the rest go out as one batched request. Never rejects: a failed request + * counts as `serverUnavailable`, and while the server is rate limiting, the + * answer is `rateLimit` without asking. + */ +export async function lookUpAvailability( + client: IJMAPClient, + principalIds: string[], + start: Date, + end: Date, +): Promise> { + const state = lookupStateOf(client); + const { cache } = state; + const now = Date.now(); + for (const [key, entry] of cache) { + if (now - entry.requestedAt >= AVAILABILITY_TTL_MS) cache.delete(key); + } + + const days = availabilityWindow(start, end); + const keyOf = (id: string) => `${id}|${days.start.getTime()}|${days.end.getTime()}`; + const results = new Map>(); + const missing: string[] = []; + for (const id of new Set(principalIds)) { + const cached = cache.get(keyOf(id)); + if (cached) results.set(id, cached.result); + else missing.push(id); + } + + if (missing.length > 0 && now < state.backoffUntil) { + for (const id of missing) results.set(id, Promise.resolve({ error: "rateLimit" })); + } else if (missing.length > 0) { + const batch = client.getPrincipalsAvailability!(missing, days.start, days.end) + .catch((): Record => ({})); + for (const id of missing) { + const key = keyOf(id); + const entry: CachedLookup = { + requestedAt: now, + result: batch.then((answers) => answers[id] ?? { error: "serverUnavailable" }), + }; + cache.set(key, entry); + results.set(id, entry.result); + void entry.result.then((result) => { + if (!("error" in result)) return; + // Only answers are kept: a refused lookup is asked again next time. + if (cache.get(key) === entry) cache.delete(key); + if (result.error === "rateLimit") { + state.backoffUntil = Math.max(state.backoffUntil, Date.now() + RATE_LIMIT_BACKOFF_MS); + } + }); + } + } + + const settled = await Promise.all([...results].map(async ([id, result]) => [id, await result] as const)); + return Object.fromEntries(settled); +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 1bf167df6..8c2a247d5 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -561,6 +561,13 @@ export interface BusyPeriod { busyStatus: 'confirmed' | 'tentative' | 'unavailable' | null; } +/** + * One principal's answer to Principal/getAvailability: its busy periods, or + * the JMAP error type the call was refused with (`forbidden`, `rateLimit`, + * `notFound`, ...). + */ +export type PrincipalAvailability = { periods: BusyPeriod[] } | { error: string }; + export interface VacationResponse { id: string; isEnabled: boolean; diff --git a/locales/ar/common.json b/locales/ar/common.json index 67c3b7545..5c01bfd1a 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -2859,6 +2859,7 @@ "free": "متاح", "busy": "مشغول", "tentative": "مبدئي", + "no_busy_times": "لا توجد أوقات انشغال مُشارَكة", "unknown": "ليس مستخدمًا على هذا الخادم", "error": "تعذّر التحقق", "checking": "جارٍ التحقق…" diff --git a/locales/ca/common.json b/locales/ca/common.json index 3ca531189..79e931b31 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -2859,6 +2859,7 @@ "free": "Disponible", "busy": "Ocupat", "tentative": "Provisional", + "no_busy_times": "No hi ha hores ocupades compartides", "unknown": "No és un usuari d'aquest servidor", "error": "No s'ha pogut comprovar", "checking": "Comprovant…" diff --git a/locales/cs/common.json b/locales/cs/common.json index 61e525085..bc9ed6664 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -2859,6 +2859,7 @@ "free": "Volný", "busy": "Zaneprázdněn", "tentative": "Předběžně", + "no_busy_times": "Žádné sdílené obsazené časy", "unknown": "Není uživatelem tohoto serveru", "error": "Nepodařilo se ověřit", "checking": "Ověřuje se…" diff --git a/locales/da/common.json b/locales/da/common.json index 0a022b465..25a68d1f3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -2859,6 +2859,7 @@ "free": "Ledig", "busy": "Optaget", "tentative": "Foreløbig", + "no_busy_times": "Ingen delte optagede tider", "unknown": "Ikke en bruger på denne server", "error": "Kunne ikke kontrollere", "checking": "Kontrollerer…" diff --git a/locales/de/common.json b/locales/de/common.json index 58f133c21..7820075ff 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -2859,6 +2859,7 @@ "free": "Verfügbar", "busy": "Beschäftigt", "tentative": "Vorläufig", + "no_busy_times": "Keine belegten Zeiten freigegeben", "unknown": "Kein Benutzer auf diesem Server", "error": "Konnte nicht geprüft werden", "checking": "Wird geprüft…" diff --git a/locales/en/common.json b/locales/en/common.json index f4d4a08ff..f4ca008e5 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -2859,6 +2859,7 @@ "free": "Available", "busy": "Busy", "tentative": "Tentative", + "no_busy_times": "No busy times shared", "unknown": "Not a user on this server", "error": "Couldn't check", "checking": "Checking…" diff --git a/locales/es/common.json b/locales/es/common.json index 109544269..0ccb096f1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -2859,6 +2859,7 @@ "free": "Disponible", "busy": "Ocupado", "tentative": "Provisional", + "no_busy_times": "No hay horas ocupadas compartidas", "unknown": "No es un usuario de este servidor", "error": "No se pudo comprobar", "checking": "Comprobando…" diff --git a/locales/fa/common.json b/locales/fa/common.json index 881343805..bd1769937 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -2859,6 +2859,7 @@ "free": "در دسترس", "busy": "مشغول", "tentative": "احتمالی", + "no_busy_times": "هیچ زمان مشغولی به اشتراک گذاشته نشده است", "unknown": "کاربر این سرور نیست", "error": "بررسی ممکن نشد", "checking": "در حال بررسی…" diff --git a/locales/fr/common.json b/locales/fr/common.json index 1d786d852..d6bbb8335 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -2859,6 +2859,7 @@ "free": "Disponible", "busy": "Occupé", "tentative": "Provisoire", + "no_busy_times": "Aucune plage occupée partagée", "unknown": "Pas un utilisateur de ce serveur", "error": "Vérification impossible", "checking": "Vérification…" diff --git a/locales/he/common.json b/locales/he/common.json index eb133a0d7..1c05411b5 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -2783,6 +2783,7 @@ "free": "פנוי", "busy": "עסוק", "tentative": "טנטטיבי", + "no_busy_times": "לא שותפו זמנים תפוסים", "unknown": "לא משתמש בשרת זה", "error": "לא ניתן היה לבדוק", "checking": "בודק…" diff --git a/locales/hu/common.json b/locales/hu/common.json index ef3d08361..84c3ba559 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -2859,6 +2859,7 @@ "free": "Szabad", "busy": "Foglalt", "tentative": "Feltételes", + "no_busy_times": "Nincsenek megosztott foglalt időszakok", "unknown": "Nem felhasználó ezen a kiszolgálón", "error": "Nem sikerült ellenőrizni", "checking": "Ellenőrzés…" diff --git a/locales/it/common.json b/locales/it/common.json index 703841eaf..4815027e4 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -2859,6 +2859,7 @@ "free": "Disponibile", "busy": "Occupato", "tentative": "Provvisorio", + "no_busy_times": "Nessun orario occupato condiviso", "unknown": "Non è un utente di questo server", "error": "Impossibile verificare", "checking": "Verifica in corso…" diff --git a/locales/ja/common.json b/locales/ja/common.json index d8013a019..080734923 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -2859,6 +2859,7 @@ "free": "空き", "busy": "予定あり", "tentative": "仮", + "no_busy_times": "共有された予定あり時間はありません", "unknown": "このサーバーのユーザーではありません", "error": "確認できませんでした", "checking": "確認中…" diff --git a/locales/ko/common.json b/locales/ko/common.json index 8965300bc..5909bf3cc 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -2859,6 +2859,7 @@ "free": "가능", "busy": "바쁨", "tentative": "미정", + "no_busy_times": "공유된 바쁜 시간 없음", "unknown": "이 서버의 사용자가 아님", "error": "확인할 수 없음", "checking": "확인 중…" diff --git a/locales/lv/common.json b/locales/lv/common.json index c08563c71..7576c63b0 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -2858,6 +2858,7 @@ "free": "Pieejams", "busy": "Aizņemts", "tentative": "Provizoriski", + "no_busy_times": "Nav kopīgotu aizņemto laiku", "unknown": "Nav šī servera lietotājs", "error": "Neizdevās pārbaudīt", "checking": "Pārbauda…" diff --git a/locales/mn/common.json b/locales/mn/common.json index 0c3f539f9..5fc9779da 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -2859,6 +2859,7 @@ "free": "Боломжтой", "busy": "Завгүй", "tentative": "Урьдчилсан", + "no_busy_times": "Хуваалцсан завгүй цаг байхгүй", "unknown": "Энэ серверийн хэрэглэгч биш", "error": "Шалгаж чадсангүй", "checking": "Шалгаж байна…" diff --git a/locales/nb/common.json b/locales/nb/common.json index cfe859321..0e65e5fde 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -2859,6 +2859,7 @@ "free": "Ledig", "busy": "Opptatt", "tentative": "Foreløpig", + "no_busy_times": "Ingen delte opptatt-tider", "unknown": "Ikke en bruker på denne serveren", "error": "Kunne ikke sjekke", "checking": "Sjekker…" diff --git a/locales/nl/common.json b/locales/nl/common.json index 9d1303c0e..581468d86 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -2859,6 +2859,7 @@ "free": "Beschikbaar", "busy": "Bezet", "tentative": "Voorlopig", + "no_busy_times": "Geen bezette tijden gedeeld", "unknown": "Geen gebruiker op deze server", "error": "Kon niet controleren", "checking": "Controleren…" diff --git a/locales/pl/common.json b/locales/pl/common.json index 2b3c7ef59..0892915ce 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -2859,6 +2859,7 @@ "free": "Dostępny", "busy": "Zajęty", "tentative": "Wstępnie", + "no_busy_times": "Brak udostępnionych zajętych terminów", "unknown": "Nie jest użytkownikiem tego serwera", "error": "Nie udało się sprawdzić", "checking": "Sprawdzanie…" diff --git a/locales/pt/common.json b/locales/pt/common.json index 94c8df4fb..338cc7ec5 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -2859,6 +2859,7 @@ "free": "Disponível", "busy": "Ocupado", "tentative": "Provisório", + "no_busy_times": "Nenhum horário ocupado compartilhado", "unknown": "Não é um utilizador deste servidor", "error": "Não foi possível verificar", "checking": "A verificar…" diff --git a/locales/ro/common.json b/locales/ro/common.json index 9af079720..b854035da 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -2859,6 +2859,7 @@ "free": "Disponibil", "busy": "Ocupat", "tentative": "Provizoriu", + "no_busy_times": "Niciun interval ocupat partajat", "unknown": "Nu este un utilizator al acestui server", "error": "Nu s-a putut verifica", "checking": "Se verifică…" diff --git a/locales/ru/common.json b/locales/ru/common.json index e1b1a97e5..2ea5f5107 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -2859,6 +2859,7 @@ "free": "Свободен", "busy": "Занят", "tentative": "Предварительно", + "no_busy_times": "Нет общих данных о занятости", "unknown": "Не пользователь этого сервера", "error": "Не удалось проверить", "checking": "Проверка…" diff --git a/locales/sk/common.json b/locales/sk/common.json index 711fb5491..759ceb62a 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -2859,6 +2859,7 @@ "free": "Voľný", "busy": "Zaneprázdnený", "tentative": "Predbežne", + "no_busy_times": "Žiadne zdieľané obsadené časy", "unknown": "Nie je používateľom tohto servera", "error": "Nepodarilo sa overiť", "checking": "Overuje sa…" diff --git a/locales/tr/common.json b/locales/tr/common.json index 6b4f64df3..1a847f4aa 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -2859,6 +2859,7 @@ "free": "Uygun", "busy": "Meşgul", "tentative": "Geçici", + "no_busy_times": "Paylaşılan meşgul zaman yok", "unknown": "Bu sunucuda kullanıcı değil", "error": "Kontrol edilemedi", "checking": "Kontrol ediliyor…" diff --git a/locales/uk/common.json b/locales/uk/common.json index 7dac42481..6ac60e0e4 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -2859,6 +2859,7 @@ "free": "Вільний", "busy": "Зайнятий", "tentative": "Попередньо", + "no_busy_times": "Немає спільних даних про зайнятість", "unknown": "Не користувач цього сервера", "error": "Не вдалося перевірити", "checking": "Перевірка…" diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 96d7aa049..f2a70108f 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -2859,6 +2859,7 @@ "free": "有空", "busy": "忙碌", "tentative": "暫定", + "no_busy_times": "未分享忙碌時段", "unknown": "不是此伺服器上的使用者", "error": "無法檢查", "checking": "檢查中…" diff --git a/locales/zh/common.json b/locales/zh/common.json index 31114f78e..27e585317 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -2859,6 +2859,7 @@ "free": "空闲", "busy": "忙碌", "tentative": "暂定", + "no_busy_times": "未共享忙碌时间", "unknown": "不是此服务器上的用户", "error": "无法检查", "checking": "正在检查…"