mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-02 08:07:11 +00:00
Merge branch 'cu-calx' into catch-up-2026-11
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../jmap-client', () => ({
|
||||
jmapClient: {
|
||||
accountId: 'u',
|
||||
request: vi.fn(),
|
||||
currentSession: { apiUrl: 'https://mail.example/jmap/' } as unknown,
|
||||
hasCapability: vi.fn(() => true),
|
||||
getAccountCapability: vi.fn(() => ({ currentUserPrincipalId: 'u' })),
|
||||
getMaxCallsInRequest: vi.fn(() => 16),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../files', () => ({
|
||||
listPrincipals: vi.fn(),
|
||||
}));
|
||||
|
||||
import { jmapClient } from '../jmap-client';
|
||||
import { listPrincipals } from '../files';
|
||||
import {
|
||||
checkAvailabilityAccess,
|
||||
getPrincipalsAvailability,
|
||||
lookUpAvailabilityStatuses,
|
||||
mayGetAvailability,
|
||||
resetAvailabilityCaches,
|
||||
supportsAvailability,
|
||||
} from '../calendar-availability';
|
||||
import { editorAvailabilityWindow, availabilityFor } from '../../lib/calendar-free-busy';
|
||||
|
||||
// Ported from the webmail's lib/jmap/__tests__/free-busy.test.ts and the
|
||||
// availability cases of components/calendar/__tests__/participant-availability.test.tsx.
|
||||
|
||||
type Call = [string, Record<string, unknown>, string];
|
||||
const mockRequest = jmapClient.request as ReturnType<typeof vi.fn>;
|
||||
const mockHasCapability = jmapClient.hasCapability as ReturnType<typeof vi.fn>;
|
||||
const mockMaxCalls = jmapClient.getMaxCallsInRequest as ReturnType<typeof vi.fn>;
|
||||
const mockListPrincipals = listPrincipals as ReturnType<typeof vi.fn>;
|
||||
|
||||
const DIRECTORY = {
|
||||
status: 'ok',
|
||||
principals: [
|
||||
{ id: 'a', type: 'individual', name: 'Alice', email: 'alice@example.org' },
|
||||
{ id: 'b', type: 'individual', name: 'Bob', email: 'Bob@example.org' },
|
||||
{ id: 'c', type: 'individual', name: 'Carol', email: 'carol@example.org' },
|
||||
],
|
||||
};
|
||||
|
||||
// The busy periods each principal has on the server.
|
||||
let busy: Record<string, Array<{ utcStart: string; utcEnd: string; busyStatus?: string }>> = {};
|
||||
let refuse: Record<string, string> = {};
|
||||
|
||||
function answerAvailability(calls: Call[]) {
|
||||
return {
|
||||
methodResponses: calls.map(([method, args, id]) => {
|
||||
if (method !== 'Principal/getAvailability') return ['error', { type: 'unknownMethod' }, id];
|
||||
const pid = args.id as string;
|
||||
if (refuse[pid]) return ['error', { type: refuse[pid] }, id];
|
||||
return ['Principal/getAvailability', { list: busy[pid] ?? [] }, id];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const start = new Date('2027-03-10T09:00:00Z');
|
||||
const end = new Date('2027-03-10T10:00:00Z');
|
||||
|
||||
beforeEach(() => {
|
||||
resetAvailabilityCaches();
|
||||
mockRequest.mockReset();
|
||||
mockRequest.mockImplementation(async (calls: Call[]) => answerAvailability(calls));
|
||||
mockHasCapability.mockImplementation(() => true);
|
||||
mockMaxCalls.mockReturnValue(16);
|
||||
mockListPrincipals.mockReset();
|
||||
mockListPrincipals.mockResolvedValue(DIRECTORY);
|
||||
busy = {};
|
||||
refuse = {};
|
||||
});
|
||||
|
||||
describe('free/busy statuses of the invited attendees', () => {
|
||||
it('tells busy, tentative, free, no busy times and outside users apart', async () => {
|
||||
busy = {
|
||||
a: [{ utcStart: '2027-03-10T09:30:00Z', utcEnd: '2027-03-10T11:00:00Z', busyStatus: 'confirmed' }],
|
||||
b: [{ utcStart: '2027-03-10T08:00:00Z', utcEnd: '2027-03-10T09:15:00Z', busyStatus: 'tentative' }],
|
||||
// Busy only after the event: free for its window.
|
||||
c: [{ utcStart: '2027-03-10T10:00:00Z', utcEnd: '2027-03-10T11:00:00Z' }],
|
||||
};
|
||||
const statuses = await lookUpAvailabilityStatuses(
|
||||
['alice@example.org', 'bob@example.org', 'carol@example.org', 'dave@elsewhere.net'],
|
||||
start,
|
||||
end,
|
||||
);
|
||||
expect(statuses).toEqual({
|
||||
'alice@example.org': 'busy',
|
||||
'bob@example.org': 'tentative',
|
||||
'carol@example.org': 'free',
|
||||
'dave@elsewhere.net': 'unknown',
|
||||
});
|
||||
});
|
||||
|
||||
it('says no busy times rather than available for an empty answer', async () => {
|
||||
const statuses = await lookUpAvailabilityStatuses(['alice@example.org'], start, end);
|
||||
expect(statuses).toEqual({ 'alice@example.org': 'no_busy_times' });
|
||||
});
|
||||
|
||||
it('asks for the whole local days, without event details, and in one request', async () => {
|
||||
await lookUpAvailabilityStatuses(['alice@example.org', 'bob@example.org'], start, end);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
const [calls, using] = mockRequest.mock.calls[0] as [Call[], string[]];
|
||||
expect(using).toContain('urn:ietf:params:jmap:principals:availability');
|
||||
expect(calls.map((c) => c[1].id)).toEqual(['a', 'b']);
|
||||
for (const [, args] of calls) {
|
||||
expect(args.showDetails).toBe(false);
|
||||
expect(args.utcStart).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$/);
|
||||
expect(new Date(args.utcStart as string).getTime()).toBeLessThanOrEqual(start.getTime());
|
||||
expect(new Date(args.utcEnd as string).getTime()).toBeGreaterThanOrEqual(end.getTime());
|
||||
}
|
||||
});
|
||||
|
||||
it('answers a move within the same day from the cache', async () => {
|
||||
await lookUpAvailabilityStatuses(['alice@example.org'], start, end);
|
||||
await lookUpAvailabilityStatuses(['alice@example.org'], new Date('2027-03-10T13:00:00Z'), new Date('2027-03-10T14:00:00Z'));
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('splits the lookups across requests by maxCallsInRequest', async () => {
|
||||
mockMaxCalls.mockReturnValue(2);
|
||||
const result = await getPrincipalsAvailability(['a', 'b', 'c'], start, end);
|
||||
expect(mockRequest).toHaveBeenCalledTimes(2);
|
||||
expect(Object.keys(result)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('stops asking for a minute after the server rate-limits', async () => {
|
||||
refuse = { a: 'rateLimit' };
|
||||
expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toEqual({ 'alice@example.org': 'error' });
|
||||
refuse = {};
|
||||
expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toEqual({ 'alice@example.org': 'error' });
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gives up on free/busy when Stalwart 1.0 has directory queries off', async () => {
|
||||
mockListPrincipals.mockResolvedValue({ status: 'restricted', directoryQueriesOff: true, principals: [] });
|
||||
expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toBeNull();
|
||||
expect(mockRequest).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gives up on free/busy when a lookup is refused as forbidden', async () => {
|
||||
refuse = { a: 'forbidden' };
|
||||
expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toBeNull();
|
||||
});
|
||||
|
||||
it('cannot call an address unknown when the directory listing was incomplete', async () => {
|
||||
mockListPrincipals.mockResolvedValue({ status: 'error', principals: DIRECTORY.principals.slice(0, 1) });
|
||||
expect(await lookUpAvailabilityStatuses(['zed@example.org'], start, end)).toEqual({ 'zed@example.org': 'error' });
|
||||
});
|
||||
|
||||
it('counts a failed request as unknown, never as available', async () => {
|
||||
mockRequest.mockRejectedValue(new Error('offline'));
|
||||
expect(await lookUpAvailabilityStatuses(['alice@example.org'], start, end)).toEqual({ 'alice@example.org': 'error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('whether free/busy is offered', () => {
|
||||
it('needs the principals:availability capability', () => {
|
||||
mockHasCapability.mockImplementation((urn: string) => urn !== 'urn:ietf:params:jmap:principals:availability');
|
||||
expect(supportsAvailability()).toBe(false);
|
||||
});
|
||||
|
||||
it('reads mayGetAvailability from the user\'s own principal (1.0)', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Principal/get', {
|
||||
list: [{ id: 'u', capabilities: { 'urn:ietf:params:jmap:calendars': { mayGetAvailability: false } } }],
|
||||
}, '0']],
|
||||
});
|
||||
expect(await mayGetAvailability()).toBe(false);
|
||||
});
|
||||
|
||||
it('goes ahead on an empty calendars capability (0.16)', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Principal/get', { list: [{ id: 'u', capabilities: { 'urn:ietf:params:jmap:calendars': {} } }] }, '0']],
|
||||
});
|
||||
expect(await mayGetAvailability()).toBe(true);
|
||||
});
|
||||
|
||||
it('asks once per session', async () => {
|
||||
mockRequest.mockResolvedValue({
|
||||
methodResponses: [['Principal/get', { list: [{ id: 'u', capabilities: {} }] }, '0']],
|
||||
});
|
||||
await Promise.all([checkAvailabilityAccess(), checkAvailabilityAccess()]);
|
||||
await checkAvailabilityAccess();
|
||||
expect(mockRequest).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the event window of the editor', () => {
|
||||
it('spans the whole days of an all-day event, its inclusive last day included', () => {
|
||||
const window = editorAvailabilityWindow({
|
||||
allDay: true,
|
||||
start: new Date(2027, 2, 10, 15, 0),
|
||||
end: new Date(2027, 2, 11, 15, 0),
|
||||
})!;
|
||||
expect(window.end.getTime() - window.start.getTime()).toBe(2 * 86_400_000 - 1000);
|
||||
});
|
||||
|
||||
it('has no window while the end is before the start', () => {
|
||||
expect(editorAvailabilityWindow({ allDay: false, start: new Date(2027, 2, 10, 10), end: new Date(2027, 2, 10, 9) })).toBeNull();
|
||||
});
|
||||
|
||||
it('lets an unavailable overlap win over a tentative one', () => {
|
||||
expect(availabilityFor([
|
||||
{ utcStart: '2027-03-10T09:00:00Z', utcEnd: '2027-03-10T09:30:00Z', busyStatus: 'tentative' },
|
||||
{ utcStart: '2027-03-10T09:30:00Z', utcEnd: '2027-03-10T10:00:00Z', busyStatus: 'unavailable' },
|
||||
], start, end)).toBe('busy');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Live CalendarEventNotification checks against a real Stalwart.
|
||||
*
|
||||
* Opt-in like live-stalwart-calendar.test.ts: skipped unless
|
||||
* `BULWARK_LIVE_JMAP`, `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` (same
|
||||
* password) are set. The second account invites the first one to an event in
|
||||
* 2027 and then cancels it; the first one reads the notifications the server
|
||||
* keeps for that and acknowledges them. Only the notifications of this run
|
||||
* are destroyed, and the event carries the run's marker and is removed from
|
||||
* both accounts afterwards.
|
||||
*
|
||||
* BULWARK_LIVE_JMAP=http://10.10.10.62:8016 \
|
||||
* BULWARK_LIVE_USER=e2e-perf@example.org \
|
||||
* BULWARK_LIVE_USER2=e2e-tablet-land@example.org \
|
||||
* BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-event-notifications.test.ts
|
||||
*
|
||||
* Verified against Stalwart 0.16.27 and 1.0.1.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
import { JMAPClient } from '../jmap-client';
|
||||
import { CAPABILITIES } from '../types';
|
||||
import {
|
||||
destroyCalendarEventNotifications,
|
||||
getCalendarEventNotifications,
|
||||
type CalendarEventNotification,
|
||||
} from '../calendar-event-notifications';
|
||||
import { eventNotificationToast } from '../../lib/calendar-event-notifications';
|
||||
|
||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||
const USER2 = process.env.BULWARK_LIVE_USER2;
|
||||
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||
const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip;
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
|
||||
const RUN = `live-cen-${Date.now().toString(36)}`;
|
||||
|
||||
type Response = [string, Record<string, any>, string];
|
||||
|
||||
async function raw(c: JMAPClient, calls: [string, Record<string, unknown>, string][]): Promise<Response[]> {
|
||||
return (await c.request(calls, USING)).methodResponses as Response[];
|
||||
}
|
||||
|
||||
async function waitFor<T>(read: () => Promise<T>, done: (value: T) => boolean, ms = 15_000): Promise<T> {
|
||||
const until = Date.now() + ms;
|
||||
let value = await read();
|
||||
while (!done(value) && Date.now() < until) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
value = await read();
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Destroy every event of `c`'s account whose title carries the run's marker. */
|
||||
async function removeRunEvents(c: JMAPClient): Promise<void> {
|
||||
const res = await raw(c, [
|
||||
['CalendarEvent/query', { accountId: c.accountId }, 'q'],
|
||||
['CalendarEvent/get', {
|
||||
accountId: c.accountId,
|
||||
'#ids': { resultOf: 'q', name: 'CalendarEvent/query', path: '/ids' },
|
||||
properties: ['id', 'title'],
|
||||
}, 'g'],
|
||||
]);
|
||||
const ids = ((res.find((r) => r[2] === 'g')?.[1].list ?? []) as { id: string; title?: string }[])
|
||||
.filter((e) => e.title?.includes(RUN))
|
||||
.map((e) => e.id);
|
||||
if (ids.length > 0) {
|
||||
await raw(c, [['CalendarEvent/set', { accountId: c.accountId, destroy: ids, sendSchedulingMessages: false }, 'd']]);
|
||||
}
|
||||
}
|
||||
|
||||
const t = (_key: string, fallback?: string, params?: Record<string, string | number>) =>
|
||||
(fallback ?? '').replace(/\{(\w+)\}/g, (_, k: string) => String(params?.[k] ?? ''));
|
||||
|
||||
liveTwo('live Stalwart calendar event notifications', () => {
|
||||
let other: JMAPClient;
|
||||
let before = new Set<string>();
|
||||
const ours = new Set<string>();
|
||||
let organizerEventId = '';
|
||||
let attendeeEventId = '';
|
||||
|
||||
const fresh = async (): Promise<CalendarEventNotification[]> =>
|
||||
(await getCalendarEventNotifications()).filter((n) => !before.has(n.id));
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../jmap-client');
|
||||
const client = new JMAPClient();
|
||||
await client.connect(SERVER!, USER, PASS);
|
||||
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
|
||||
other = new JMAPClient();
|
||||
await other.connect(SERVER!, USER2!, PASS);
|
||||
// Whatever is pending already belongs to someone else's run.
|
||||
before = new Set((await getCalendarEventNotifications()).map((n) => n.id));
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
const leftover = (await getCalendarEventNotifications().catch(() => []))
|
||||
.filter((n) => ours.has(n.id) || (!before.has(n.id) && n.event?.title?.includes(RUN)));
|
||||
await destroyCalendarEventNotifications(leftover.map((n) => n.id)).catch(() => undefined);
|
||||
await removeRunEvents(other).catch(() => undefined);
|
||||
const mod = await import('../jmap-client');
|
||||
await removeRunEvents(mod.jmapClient as unknown as JMAPClient).catch(() => undefined);
|
||||
}, 60_000);
|
||||
|
||||
it('lists the invitation another account sent, with who sent it and the event’s title', async () => {
|
||||
const cals = await raw(other, [['Calendar/get', { accountId: other.accountId, properties: ['id', 'isDefault'] }, '0']]);
|
||||
const list = cals[0][1].list as { id: string; isDefault?: boolean }[];
|
||||
const calendarId = (list.find((c) => c.isDefault) ?? list[0]).id;
|
||||
const created = await raw(other, [['CalendarEvent/set', {
|
||||
accountId: other.accountId,
|
||||
create: {
|
||||
inv: {
|
||||
'@type': 'Event',
|
||||
title: `${RUN} review`,
|
||||
calendarIds: { [calendarId]: true },
|
||||
start: '2027-06-08T10:00:00',
|
||||
timeZone: 'Europe/Berlin',
|
||||
duration: 'PT1H',
|
||||
organizerCalendarAddress: `mailto:${USER2}`,
|
||||
participants: {
|
||||
org: {
|
||||
'@type': 'Participant', calendarAddress: `mailto:${USER2}`, email: USER2,
|
||||
roles: { owner: true, attendee: true }, participationStatus: 'accepted', kind: 'individual',
|
||||
},
|
||||
att: {
|
||||
'@type': 'Participant', calendarAddress: `mailto:${USER}`, email: USER,
|
||||
roles: { attendee: true }, participationStatus: 'needs-action', expectReply: true, kind: 'individual',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
sendSchedulingMessages: true,
|
||||
}, '0']]);
|
||||
organizerEventId = created[0][1].created?.inv?.id ?? '';
|
||||
expect(organizerEventId, JSON.stringify(created[0][1].notCreated)).toBeTruthy();
|
||||
|
||||
const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'created'));
|
||||
const invite = notes.find((n) => n.type === 'created');
|
||||
expect(invite, JSON.stringify(notes)).toBeTruthy();
|
||||
ours.add(invite!.id);
|
||||
attendeeEventId = invite!.calendarEventId;
|
||||
expect(invite!.changedBy?.email?.toLowerCase()).toBe(USER2!.toLowerCase());
|
||||
expect(invite!.isDraft).toBe(false);
|
||||
// 1.0 returns the event with the notification; 0.16 doesn't, and the
|
||||
// title is read from the attendee's copy instead.
|
||||
expect(invite!.event?.title).toBe(`${RUN} review`);
|
||||
expect(eventNotificationToast(invite!, t)!.title).toContain(`invited you to "${RUN} review"`);
|
||||
});
|
||||
|
||||
it('lists the cancellation, and forgets both once acknowledged', async () => {
|
||||
expect(organizerEventId).toBeTruthy();
|
||||
await raw(other, [['CalendarEvent/set', {
|
||||
accountId: other.accountId,
|
||||
destroy: [organizerEventId],
|
||||
sendSchedulingMessages: true,
|
||||
}, '0']]);
|
||||
const notes = await waitFor(fresh, (l) => l.some((n) => n.type === 'destroyed' || (n.type === 'updated' && n.calendarEventId === attendeeEventId && !ours.has(n.id))));
|
||||
const cancel = notes.find((n) => n.calendarEventId === attendeeEventId && !ours.has(n.id));
|
||||
expect(cancel, JSON.stringify(notes)).toBeTruthy();
|
||||
ours.add(cancel!.id);
|
||||
const toast = eventNotificationToast(cancel!, t)!;
|
||||
if (cancel!.type === 'destroyed') expect(toast.type).toBe('warning');
|
||||
|
||||
await destroyCalendarEventNotifications([...ours]);
|
||||
const after = (await getCalendarEventNotifications()).map((n) => n.id);
|
||||
for (const id of ours) expect(after).not.toContain(id);
|
||||
// Nobody else's notifications went with them.
|
||||
for (const id of before) expect(after).toContain(id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Live free/busy checks (Principal/getAvailability) against a real Stalwart.
|
||||
*
|
||||
* Opt-in like live-stalwart-calendar.test.ts: skipped unless
|
||||
* `BULWARK_LIVE_JMAP`, `BULWARK_LIVE_PASS` and `BULWARK_LIVE_USER2` (same
|
||||
* password) are set. The second account gets a busy event in 2027, which the
|
||||
* first one looks up the way the event editor does; the event carries the
|
||||
* run's marker and is removed afterwards.
|
||||
*
|
||||
* BULWARK_LIVE_JMAP=http://10.10.10.62:8016 \
|
||||
* BULWARK_LIVE_USER=e2e-perf@example.org \
|
||||
* BULWARK_LIVE_USER2=e2e-tablet-land@example.org \
|
||||
* BULWARK_LIVE_PASS=… npx vitest run src/api/__tests__/live-stalwart-free-busy.test.ts
|
||||
*
|
||||
* Verified against Stalwart 0.16.27 and 1.0.1.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
|
||||
import { JMAPClient } from '../jmap-client';
|
||||
import { CAPABILITIES } from '../types';
|
||||
import {
|
||||
checkAvailabilityAccess,
|
||||
loadPrincipalDirectory,
|
||||
lookUpAvailabilityStatuses,
|
||||
resetAvailabilityCaches,
|
||||
supportsAvailability,
|
||||
} from '../calendar-availability';
|
||||
|
||||
const SERVER = process.env.BULWARK_LIVE_JMAP;
|
||||
const USER = process.env.BULWARK_LIVE_USER ?? 'usera@example.org';
|
||||
const USER2 = process.env.BULWARK_LIVE_USER2;
|
||||
const PASS = process.env.BULWARK_LIVE_PASS ?? '';
|
||||
const liveTwo = SERVER && PASS && USER2 ? describe : describe.skip;
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
|
||||
const RUN = `live-fb-${Date.now().toString(36)}`;
|
||||
|
||||
type Response = [string, Record<string, any>, string];
|
||||
|
||||
async function raw(c: JMAPClient, calls: [string, Record<string, unknown>, string][]): Promise<Response[]> {
|
||||
return (await c.request(calls, USING)).methodResponses as Response[];
|
||||
}
|
||||
|
||||
/** Stalwart 1.0 advertises JMAP Metadata; 0.16 does not. */
|
||||
function isStalwart10(c: JMAPClient): boolean {
|
||||
return Object.values(c.currentSession?.accounts ?? {}).some(
|
||||
(account) => 'urn:ietf:params:jmap:metadata' in (account.accountCapabilities ?? {}),
|
||||
);
|
||||
}
|
||||
|
||||
liveTwo('live Stalwart free/busy', () => {
|
||||
let other: JMAPClient;
|
||||
let client: JMAPClient;
|
||||
const busyIds: string[] = [];
|
||||
let sharedCalendarId = '';
|
||||
|
||||
async function addBusy(calendarId: string, start: string): Promise<void> {
|
||||
const created = await raw(other, [['CalendarEvent/set', {
|
||||
accountId: other.accountId,
|
||||
create: {
|
||||
busy: {
|
||||
'@type': 'Event',
|
||||
title: `${RUN} busy`,
|
||||
calendarIds: { [calendarId]: true },
|
||||
start,
|
||||
timeZone: 'UTC',
|
||||
duration: 'PT1H',
|
||||
freeBusyStatus: 'busy',
|
||||
},
|
||||
},
|
||||
}, '0']]);
|
||||
const id = created[0][1].created?.busy?.id;
|
||||
expect(id, JSON.stringify(created[0][1].notCreated)).toBeTruthy();
|
||||
busyIds.push(id);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../jmap-client');
|
||||
client = new JMAPClient();
|
||||
await client.connect(SERVER!, USER, PASS);
|
||||
(mod.jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
|
||||
resetAvailabilityCaches();
|
||||
|
||||
other = new JMAPClient();
|
||||
await other.connect(SERVER!, USER2!, PASS);
|
||||
const cals = await raw(other, [['Calendar/get', { accountId: other.accountId, properties: ['id', 'isDefault'] }, '0']]);
|
||||
const list = cals[0][1].list as { id: string; isDefault?: boolean }[];
|
||||
// A busy hour in the default calendar, which shares nothing.
|
||||
await addBusy((list.find((c) => c.isDefault) ?? list[0]).id, '2027-04-14T10:00:00');
|
||||
if (!isStalwart10(client)) {
|
||||
// And one in a calendar of its own that lets the first account see
|
||||
// free/busy, which is what 0.16 asks before it answers.
|
||||
const found = await other.request([['Principal/query', { accountId: other.accountId, filter: { email: USER } }, '0']],
|
||||
[CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS]);
|
||||
const me = (found.methodResponses[0][1].ids as string[])[0];
|
||||
const made = await raw(other, [['Calendar/set', {
|
||||
accountId: other.accountId,
|
||||
create: { fb: { name: `${RUN} free/busy`, shareWith: { [me]: { mayReadFreeBusy: true } } } },
|
||||
}, '0']]);
|
||||
sharedCalendarId = made[0][1].created?.fb?.id ?? '';
|
||||
expect(sharedCalendarId, JSON.stringify(made[0][1].notCreated)).toBeTruthy();
|
||||
await addBusy(sharedCalendarId, '2027-04-15T10:00:00');
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (busyIds.length > 0) {
|
||||
await raw(other, [['CalendarEvent/set', { accountId: other.accountId, destroy: busyIds }, '0']]).catch(() => undefined);
|
||||
}
|
||||
if (sharedCalendarId) {
|
||||
await raw(other, [['Calendar/set', { accountId: other.accountId, destroy: [sharedCalendarId], onDestroyRemoveEvents: true }, '0']])
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
it('is advertised by the server', () => {
|
||||
expect(supportsAvailability()).toBe(true);
|
||||
});
|
||||
|
||||
it('says free/busy is not available on 1.0 with directory queries off, without looking anyone up', async () => {
|
||||
if (!isStalwart10(client)) return;
|
||||
// 1.0 keeps directory queries off by default: no address can be
|
||||
// matched to a principal, and the editor says so in one line.
|
||||
const directory = await loadPrincipalDirectory();
|
||||
expect(directory.status).toBe('restricted');
|
||||
expect(directory.directoryQueriesOff).toBe(true);
|
||||
expect(await lookUpAvailabilityStatuses(
|
||||
[USER2!.toLowerCase()],
|
||||
new Date('2027-04-14T10:30:00Z'),
|
||||
new Date('2027-04-14T11:30:00Z'),
|
||||
)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows no busy times for someone whose calendars share no free/busy (0.16)', async () => {
|
||||
if (isStalwart10(client)) return;
|
||||
expect(await checkAvailabilityAccess()).toBe(true);
|
||||
expect(await lookUpAvailabilityStatuses(
|
||||
[USER2!.toLowerCase()],
|
||||
new Date('2027-04-14T10:30:00Z'),
|
||||
new Date('2027-04-14T11:30:00Z'),
|
||||
)).toEqual({ [USER2!.toLowerCase()]: 'no_busy_times' });
|
||||
});
|
||||
|
||||
it('shows the attendee busy during a shared busy hour and free next to it (0.16)', async () => {
|
||||
if (isStalwart10(client)) return;
|
||||
expect(await lookUpAvailabilityStatuses(
|
||||
[USER2!.toLowerCase()],
|
||||
new Date('2027-04-15T10:30:00Z'),
|
||||
new Date('2027-04-15T11:30:00Z'),
|
||||
)).toEqual({ [USER2!.toLowerCase()]: 'busy' });
|
||||
expect(await lookUpAvailabilityStatuses(
|
||||
[USER2!.toLowerCase(), 'nobody@elsewhere.invalid'],
|
||||
new Date('2027-04-15T12:00:00Z'),
|
||||
new Date('2027-04-15T13:00:00Z'),
|
||||
)).toEqual({ [USER2!.toLowerCase()]: 'free', 'nobody@elsewhere.invalid': 'unknown' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { Principal } from './types';
|
||||
import { batched } from './jmap-result';
|
||||
import { listPrincipals, type PrincipalListing } from './files';
|
||||
import {
|
||||
availabilityWindow,
|
||||
statusFromAnswer,
|
||||
type AvailabilityStatus,
|
||||
type BusyPeriod,
|
||||
type PrincipalAvailability,
|
||||
} from '../lib/calendar-free-busy';
|
||||
|
||||
// Free/busy (Principal/getAvailability) lookups for the event editor. Ported
|
||||
// from the webmail's lib/jmap/free-busy.ts, lib/jmap/principal-directory.ts,
|
||||
// the availability methods of lib/jmap/client.ts and lookUpStatuses in
|
||||
// components/calendar/participant-availability.tsx.
|
||||
//
|
||||
// Whether the server answers free/busy for this user at all is asked once
|
||||
// per session. Stalwart 1.0 says so on the user's own principal
|
||||
// (`mayGetAvailability`), and refuses to list its directory while the
|
||||
// administrator keeps directory queries off (its default): without the
|
||||
// directory no address can be matched to a principal, so the editor says
|
||||
// free/busy isn't available. Busy periods are cached per principal and window
|
||||
// for a few minutes: the editor 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.
|
||||
|
||||
export const PRINCIPALS_AVAILABILITY_CAPABILITY = 'urn:ietf:params:jmap:principals:availability';
|
||||
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;
|
||||
export const PRINCIPAL_DIRECTORY_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
function principalsUsing(): string[] {
|
||||
return [CAPABILITIES.CORE, CAPABILITIES.PRINCIPALS];
|
||||
}
|
||||
|
||||
function availabilityUsing(): string[] {
|
||||
return [...principalsUsing(), PRINCIPALS_AVAILABILITY_CAPABILITY];
|
||||
}
|
||||
|
||||
/** The session the caches belong to: another account or server starts over. */
|
||||
function sessionKey(): string {
|
||||
return `${jmapClient.currentSession?.apiUrl ?? ''}|${jmapClient.accountId}`;
|
||||
}
|
||||
|
||||
/** Whether the server can answer free/busy queries for its principals. */
|
||||
export function supportsAvailability(): boolean {
|
||||
return jmapClient.hasCapability(CAPABILITIES.PRINCIPALS)
|
||||
&& jmapClient.hasCapability(PRINCIPALS_AVAILABILITY_CAPABILITY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this user may ask for other people's free/busy, from the user's
|
||||
* own principal: Stalwart 1.0 sets `capabilities[calendars].mayGetAvailability`
|
||||
* to false when it won't answer. 0.16 leaves that object empty, which means
|
||||
* go ahead. A `forbidden` answer (a role without the principal permissions)
|
||||
* means no as well; other failures throw.
|
||||
*/
|
||||
export async function mayGetAvailability(): Promise<boolean> {
|
||||
if (!supportsAvailability()) return false;
|
||||
const accountId = jmapClient.accountId;
|
||||
const principals = jmapClient.getAccountCapability(CAPABILITIES.PRINCIPALS, accountId) as
|
||||
{ currentUserPrincipalId?: string | null } | undefined;
|
||||
const ownId = principals?.currentUserPrincipalId || accountId;
|
||||
const response = await jmapClient.request(
|
||||
[['Principal/get', { accountId, ids: [ownId], properties: ['id', 'capabilities'] }, '0']],
|
||||
principalsUsing(),
|
||||
);
|
||||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||||
if (method !== 'Principal/get') {
|
||||
if (result?.type === 'forbidden') return false;
|
||||
throw new Error(result?.description || "Failed to read the user's principal");
|
||||
}
|
||||
const own = ((result?.list ?? []) as Array<{ id: string; capabilities?: Record<string, unknown> }>)
|
||||
.find((p) => p.id === ownId);
|
||||
const calendars = own?.capabilities?.[CAPABILITIES.CALENDARS] as { mayGetAvailability?: boolean } | undefined;
|
||||
return calendars?.mayGetAvailability !== false;
|
||||
}
|
||||
|
||||
/** The busy periods of a Principal/getAvailability response. */
|
||||
function busyPeriodsOf(result: { list?: unknown } | 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 availabilityArgs(principalId: string, utcStart: Date, utcEnd: Date): Record<string, unknown> {
|
||||
return {
|
||||
accountId: jmapClient.accountId,
|
||||
id: principalId,
|
||||
utcStart: utcStart.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
utcEnd: utcEnd.toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
// Only whether someone is free, never what they are doing.
|
||||
showDetails: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. A failed request throws.
|
||||
*/
|
||||
export async function getPrincipalsAvailability(
|
||||
principalIds: string[],
|
||||
utcStart: Date,
|
||||
utcEnd: Date,
|
||||
): Promise<Record<string, PrincipalAvailability>> {
|
||||
const results: Record<string, PrincipalAvailability> = {};
|
||||
if (!supportsAvailability()) return results;
|
||||
for (const batch of batched(principalIds, jmapClient.getMaxCallsInRequest())) {
|
||||
const response = await jmapClient.request(
|
||||
batch.map((id, i): [string, Record<string, unknown>, string] => [
|
||||
'Principal/getAvailability', availabilityArgs(id, utcStart, utcEnd), String(i),
|
||||
]),
|
||||
availabilityUsing(),
|
||||
);
|
||||
const byCallId = new Map(((response.methodResponses ?? []) as [string, any, string][]).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 { type?: string } | undefined)?.type || 'serverFail' };
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// ── Access check, once per session ───────────────────────────────
|
||||
|
||||
const access = new Map<string, boolean>();
|
||||
const pendingChecks = new Map<string, Promise<boolean>>();
|
||||
|
||||
/** What an earlier check learned, or undefined when nothing has been asked yet. */
|
||||
export function knownAvailabilityAccess(): boolean | undefined {
|
||||
return access.get(sessionKey());
|
||||
}
|
||||
|
||||
/** Asks the server once per session whether free/busy lookups are allowed. Never rejects. */
|
||||
export function checkAvailabilityAccess(): Promise<boolean> {
|
||||
const key = sessionKey();
|
||||
const known = access.get(key);
|
||||
if (known !== undefined) return Promise.resolve(known);
|
||||
let check = pendingChecks.get(key);
|
||||
if (!check) {
|
||||
check = mayGetAvailability()
|
||||
.then((allowed) => {
|
||||
access.set(key, allowed);
|
||||
return allowed;
|
||||
})
|
||||
// A failed check proves nothing either way: look up this time and ask
|
||||
// again next time.
|
||||
.catch(() => true)
|
||||
.finally(() => pendingChecks.delete(key));
|
||||
pendingChecks.set(key, check);
|
||||
}
|
||||
return check;
|
||||
}
|
||||
|
||||
/** The server refused a lookup outright; stop asking in this session. */
|
||||
export function revokeAvailabilityAccess(): void {
|
||||
access.set(sessionKey(), false);
|
||||
}
|
||||
|
||||
// ── Principal directory, shared while fresh ──────────────────────
|
||||
|
||||
const directories = new Map<string, { loadedAt: number; listing: Promise<PrincipalListing> }>();
|
||||
|
||||
/**
|
||||
* The server's principal listing, shared while it is fresh; callers that
|
||||
* arrive while it loads get the same request. Never rejects.
|
||||
*/
|
||||
export function loadPrincipalDirectory(): Promise<PrincipalListing> {
|
||||
const key = sessionKey();
|
||||
const cached = directories.get(key);
|
||||
if (cached && Date.now() - cached.loadedAt < PRINCIPAL_DIRECTORY_TTL_MS) return cached.listing;
|
||||
const listing = listPrincipals().catch((): PrincipalListing => ({ principals: [], status: 'error' }));
|
||||
const entry = { loadedAt: Date.now(), listing };
|
||||
directories.set(key, entry);
|
||||
void listing.then(({ status }) => {
|
||||
// A failed listing isn't kept, so the next caller asks again.
|
||||
if (status === 'error' && directories.get(key) === entry) directories.delete(key);
|
||||
});
|
||||
return listing;
|
||||
}
|
||||
|
||||
// ── Cached lookups ───────────────────────────────────────────────
|
||||
|
||||
interface CachedLookup {
|
||||
requestedAt: number;
|
||||
result: Promise<PrincipalAvailability>;
|
||||
}
|
||||
|
||||
interface LookupState {
|
||||
cache: Map<string, CachedLookup>;
|
||||
backoffUntil: number;
|
||||
}
|
||||
|
||||
const lookups = new Map<string, LookupState>();
|
||||
|
||||
function lookupState(): LookupState {
|
||||
const key = sessionKey();
|
||||
let state = lookups.get(key);
|
||||
if (!state) {
|
||||
state = { cache: new Map(), backoffUntil: 0 };
|
||||
lookups.set(key, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
principalIds: string[],
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<Record<string, PrincipalAvailability>> {
|
||||
const state = lookupState();
|
||||
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 = 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* The status of every address for the window, or null when the server won't
|
||||
* answer free/busy for this user: Stalwart 1.0 with directory queries off,
|
||||
* or a lookup refused with `forbidden`.
|
||||
*/
|
||||
export async function lookUpAvailabilityStatuses(
|
||||
emails: string[],
|
||||
start: Date,
|
||||
end: Date,
|
||||
): Promise<Record<string, AvailabilityStatus> | null> {
|
||||
const directory = await loadPrincipalDirectory();
|
||||
// With directory queries off, Stalwart 1.0 can't match an address to a
|
||||
// principal. Any other refused listing is a role without the permission
|
||||
// to list principals, which is not the one for free/busy: the principals
|
||||
// Principal/get still returned are looked up, and a lookup refused in turn
|
||||
// says it.
|
||||
if (directory.status === 'restricted' && directory.directoryQueriesOff) return null;
|
||||
const byEmail = new Map<string, Principal>();
|
||||
for (const p of directory.principals) {
|
||||
if (p.email) byEmail.set(p.email.toLowerCase(), p);
|
||||
}
|
||||
const principalIds = emails.flatMap((email) => byEmail.get(email)?.id ?? []);
|
||||
const answers = await lookUpAvailability(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.
|
||||
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 {
|
||||
statuses[email] = statusFromAnswer(answer.periods, start, end);
|
||||
}
|
||||
}
|
||||
return statuses;
|
||||
}
|
||||
|
||||
/** Forget every cached answer (tests, sign-out). */
|
||||
export function resetAvailabilityCaches(): void {
|
||||
access.clear();
|
||||
pendingChecks.clear();
|
||||
directories.clear();
|
||||
lookups.clear();
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import { batched } from './jmap-result';
|
||||
|
||||
// CalendarEventNotification (draft-ietf-jmap-calendars §7): the server
|
||||
// records every invitation, update and cancellation another participant made
|
||||
// to an event this user takes part in. Ported from the webmail's
|
||||
// getCalendarEventNotifications / destroyCalendarEventNotifications in
|
||||
// lib/jmap/client.ts.
|
||||
|
||||
export interface CalendarEventNotification {
|
||||
id: string;
|
||||
created: string;
|
||||
changedBy?: {
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
principalId?: string | null;
|
||||
} | null;
|
||||
comment?: string | null;
|
||||
type: 'created' | 'updated' | 'destroyed';
|
||||
calendarEventId: string;
|
||||
isDraft?: boolean;
|
||||
/** The event as it is now; 1.0 returns it, 0.16 leaves it out. */
|
||||
event?: { title?: string | null } | null;
|
||||
}
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS];
|
||||
|
||||
/**
|
||||
* Pending event notifications, oldest first. `event` is asked for its title
|
||||
* only; Stalwart 0.16 doesn't return it, so the titles it lacks are read from
|
||||
* the events themselves (a cancelled event is gone and stays untitled).
|
||||
* Throws when the server refuses the listing.
|
||||
*/
|
||||
export async function getCalendarEventNotifications(): Promise<CalendarEventNotification[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const response = await jmapClient.request([
|
||||
['CalendarEventNotification/query', {
|
||||
accountId,
|
||||
sort: [{ property: 'created', isAscending: true }],
|
||||
}, '0'],
|
||||
['CalendarEventNotification/get', {
|
||||
accountId,
|
||||
'#ids': { resultOf: '0', name: 'CalendarEventNotification/query', path: '/ids' },
|
||||
properties: ['id', 'created', 'changedBy', 'comment', 'type', 'calendarEventId', 'isDraft', 'event'],
|
||||
}, '1'],
|
||||
], USING);
|
||||
const responses = (response.methodResponses ?? []) as [string, any, string][];
|
||||
const getResp = responses.find((r) => r[0] === 'CalendarEventNotification/get');
|
||||
if (!getResp) {
|
||||
const error = responses.find((r) => r[0] === 'error')?.[1] as { description?: string } | undefined;
|
||||
throw new Error(error?.description || 'Failed to load calendar event notifications');
|
||||
}
|
||||
const list = (getResp[1]?.list ?? []) as CalendarEventNotification[];
|
||||
await fillMissingTitles(accountId, list);
|
||||
return list;
|
||||
}
|
||||
|
||||
async function fillMissingTitles(accountId: string, list: CalendarEventNotification[]): Promise<void> {
|
||||
const ids = Array.from(new Set(
|
||||
list.filter((n) => !n.isDraft && n.type !== 'destroyed' && !n.event?.title && n.calendarEventId)
|
||||
.map((n) => n.calendarEventId),
|
||||
));
|
||||
if (ids.length === 0) return;
|
||||
try {
|
||||
const response = await jmapClient.request(
|
||||
[['CalendarEvent/get', { accountId, ids, properties: ['id', 'title'] }, '0']],
|
||||
USING,
|
||||
);
|
||||
const [method, result] = response.methodResponses?.[0] ?? [];
|
||||
if (method !== 'CalendarEvent/get') return;
|
||||
const titles = new Map<string, string>();
|
||||
for (const event of (result?.list ?? []) as { id: string; title?: string | null }[]) {
|
||||
if (event.title) titles.set(event.id, event.title);
|
||||
}
|
||||
for (const n of list) {
|
||||
const title = titles.get(n.calendarEventId);
|
||||
if (title && !n.event?.title) n.event = { ...(n.event ?? {}), title };
|
||||
}
|
||||
} catch {
|
||||
// Untitled it is.
|
||||
}
|
||||
}
|
||||
|
||||
/** Acknowledges (destroys) event notifications that have been shown. */
|
||||
export async function destroyCalendarEventNotifications(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
for (const batch of batched(ids, jmapClient.getMaxObjectsInSet())) {
|
||||
await jmapClient.request([
|
||||
['CalendarEventNotification/set', { accountId, destroy: batch }, '0'],
|
||||
], USING);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user