fix: cache and batch free/busy lookups under Stalwart 1.0's rate limit

Stalwart 1.0 allows 100 Principal/getAvailability calls per account a
minute and counts every call, however they are batched. The event form
sent one request per attendee whenever the time or the attendees
changed, and showed "Available" for an empty answer, which is also what
the server returns when the user may not see that person's free/busy.

- Answers are cached for three minutes per principal and the local
  days the event touches, so moving an event within its day asks
  nothing new; the status for the exact time is worked out locally.
- Lookups already on their way are shared, and the principals without
  an answer go out in one request, split by `maxCallsInRequest`.
- After `rateLimit` nothing is sent for a minute, and attendees without
  an answer show "Couldn't check".
- An empty answer shows "No busy times shared" instead of "Available".

0.16 has no rate limit; it gets the same labels with fewer requests.
This commit is contained in:
Linus Rath
2026-09-25 18:02:55 +02:00
parent ffa909600c
commit f8e42a4a24
35 changed files with 432 additions and 34 deletions
@@ -46,6 +46,7 @@ type MethodResponse = [string, Record<string, unknown>, 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,
@@ -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<AvailabilityStatus, "unknown" | "error" | "checking"> {
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<string, AvailabilityStatus> = {};
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<Record<string, AvailabilityStatus>>({});
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"
/>
+135
View File
@@ -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<string, PrincipalAvailability>);
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);
});
});
@@ -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<string, unknown> }).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', () => {
+2 -1
View File
@@ -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<boolean>;
getPrincipalAvailability?(principalId: string, utcStart: Date, utcEnd: Date): Promise<BusyPeriod[]>;
getPrincipalsAvailability?(principalIds: string[], utcStart: Date, utcEnd: Date): Promise<Record<string, PrincipalAvailability>>;
// ShareNotification (RFC 9670 §3): who shared what with this user. Optional
// (demo client).
supportsShareNotifications?(): boolean;
+45 -10
View File
@@ -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<BusyPeriod[]> {
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<Record<string, PrincipalAvailability>> {
const results: Record<string, PrincipalAvailability> = {};
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<string, unknown> {
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) ────────────────────────────
+108 -2
View File
@@ -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<IJMAPClient, boolean>();
const pendingChecks = new WeakMap<IJMAPClient, Promise<boolean>>();
@@ -42,3 +57,94 @@ export function checkAvailabilityAccess(client: IJMAPClient): Promise<boolean> {
export function revokeAvailabilityAccess(client: IJMAPClient): void {
access.set(client, false);
}
interface CachedLookup {
requestedAt: number;
result: Promise<PrincipalAvailability>;
}
interface LookupState {
cache: Map<string, CachedLookup>;
backoffUntil: number;
}
const lookups = new WeakMap<IJMAPClient, LookupState>();
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<Record<string, PrincipalAvailability>> {
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<string, Promise<PrincipalAvailability>>();
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<string, PrincipalAvailability> => ({}));
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);
}
+7
View File
@@ -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;
+1
View File
@@ -2859,6 +2859,7 @@
"free": "متاح",
"busy": "مشغول",
"tentative": "مبدئي",
"no_busy_times": "لا توجد أوقات انشغال مُشارَكة",
"unknown": "ليس مستخدمًا على هذا الخادم",
"error": "تعذّر التحقق",
"checking": "جارٍ التحقق…"
+1
View File
@@ -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…"
+1
View File
@@ -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…"
+1
View File
@@ -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…"
+1
View File
@@ -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…"
+1
View File
@@ -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…"
+1
View File
@@ -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…"
+1
View File
@@ -2859,6 +2859,7 @@
"free": "در دسترس",
"busy": "مشغول",
"tentative": "احتمالی",
"no_busy_times": "هیچ زمان مشغولی به اشتراک گذاشته نشده است",
"unknown": "کاربر این سرور نیست",
"error": "بررسی ممکن نشد",
"checking": "در حال بررسی…"
+1
View File
@@ -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…"
+1
View File
@@ -2783,6 +2783,7 @@
"free": "פנוי",
"busy": "עסוק",
"tentative": "טנטטיבי",
"no_busy_times": "לא שותפו זמנים תפוסים",
"unknown": "לא משתמש בשרת זה",
"error": "לא ניתן היה לבדוק",
"checking": "בודק…"
+1
View File
@@ -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…"
+1
View File
@@ -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…"
+1
View File
@@ -2859,6 +2859,7 @@
"free": "空き",
"busy": "予定あり",
"tentative": "仮",
"no_busy_times": "共有された予定あり時間はありません",
"unknown": "このサーバーのユーザーではありません",
"error": "確認できませんでした",
"checking": "確認中…"
+1
View File
@@ -2859,6 +2859,7 @@
"free": "가능",
"busy": "바쁨",
"tentative": "미정",
"no_busy_times": "공유된 바쁜 시간 없음",
"unknown": "이 서버의 사용자가 아님",
"error": "확인할 수 없음",
"checking": "확인 중…"
+1
View File
@@ -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…"
+1
View File
@@ -2859,6 +2859,7 @@
"free": "Боломжтой",
"busy": "Завгүй",
"tentative": "Урьдчилсан",
"no_busy_times": "Хуваалцсан завгүй цаг байхгүй",
"unknown": "Энэ серверийн хэрэглэгч биш",
"error": "Шалгаж чадсангүй",
"checking": "Шалгаж байна…"
+1
View File
@@ -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…"
+1
View File
@@ -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…"
+1
View File
@@ -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…"
+1
View File
@@ -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…"
+1
View File
@@ -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ă…"
+1
View File
@@ -2859,6 +2859,7 @@
"free": "Свободен",
"busy": "Занят",
"tentative": "Предварительно",
"no_busy_times": "Нет общих данных о занятости",
"unknown": "Не пользователь этого сервера",
"error": "Не удалось проверить",
"checking": "Проверка…"
+1
View File
@@ -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…"
+1
View File
@@ -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…"
+1
View File
@@ -2859,6 +2859,7 @@
"free": "Вільний",
"busy": "Зайнятий",
"tentative": "Попередньо",
"no_busy_times": "Немає спільних даних про зайнятість",
"unknown": "Не користувач цього сервера",
"error": "Не вдалося перевірити",
"checking": "Перевірка…"
+1
View File
@@ -2859,6 +2859,7 @@
"free": "有空",
"busy": "忙碌",
"tentative": "暫定",
"no_busy_times": "未分享忙碌時段",
"unknown": "不是此伺服器上的使用者",
"error": "無法檢查",
"checking": "檢查中…"
+1
View File
@@ -2859,6 +2859,7 @@
"free": "空闲",
"busy": "忙碌",
"tentative": "暂定",
"no_busy_times": "未共享忙碌时间",
"unknown": "不是此服务器上的用户",
"error": "无法检查",
"checking": "正在检查…"