import { jmapClient } from './jmap-client'; import { CAPABILITIES } from './types'; import type { Alert, Calendar, CalendarEvent, CalendarRights } from './types'; import { JMAPSetError, assertSetResult, batched, jmapErrorType, requireMethodResult } from './jmap-result'; import { formatWallClock, getEffectiveTimeZone, getTimeZoneOffsetMs } from '../lib/calendar-timezone'; import { SCAN_PROPERTIES, isTaskLikeObject, type ScannedCalendarObject } from '../lib/calendar-component-detection'; import { RECURRENCE_BASE_PROPERTIES, SYNTHETIC_ID_PROBE, hydrateRecurrenceInstances, isServerRecurrenceInstance, parseDurationSeconds, } from '../lib/recurrence-instances'; const USING = [CAPABILITIES.CORE, CAPABILITIES.CALENDARS]; /** * IANA time zone of the user — their calendar time-zone setting when set * (#755), otherwise the device's. Stalwart interprets LocalDateTime filter * values and computes utcStart/utcEnd for floating events in the `timeZone` * argument of CalendarEvent/query and CalendarEvent/get, defaulting to UTC * when absent — which shifts range boundaries and floating-event times for * any user not in UTC. A zone the server's time zone database lacks is read * as UTC by 0.16 and refused by 1.0, which fails the whole call; * calendarTimeZone decides what is actually sent. */ function getUserTimeZone(): string | undefined { try { return getEffectiveTimeZone() || undefined; } catch { return undefined; } } // ─── Time zone check ───────────────────────────────────── // Stalwart 1.0 answers a CalendarEvent/get or /query whose `timeZone` it // does not know with invalidArguments "Invalid timeZone \"X\"". Its database // holds the IANA names, matched case-sensitively, and no offsets, so a // "+01:00" or "europe/berlin" that Intl may accept is refused. const TIME_ZONE_PROBE_CALL_ID = 'time-zone-probe'; // Per server and zone: the check of the zone, settled or in flight. const timeZoneProbes = new Map>(); // Per server and zone: refused by the server. const refusedTimeZones = new Set(); function timeZoneKey(timeZone: string): string { return `${jmapClient.currentSession?.apiUrl ?? ''}|${timeZone}`; } function isTimeZoneRefusal(type: string | undefined, description: string | undefined): boolean { return type === 'invalidArguments' && /invalid timeZone/i.test(description ?? ''); } /** A get of no events, which the server only answers if it knows `timeZone`. */ function timeZoneProbeCall(accountId: string, timeZone: string): [string, Record, string] { return ['CalendarEvent/get', { accountId, ids: [], properties: ['id'], timeZone }, TIME_ZONE_PROBE_CALL_ID]; } function refuseTimeZone(key: string, timeZone: string): void { if (refusedTimeZones.has(key)) return; refusedTimeZones.add(key); console.warn( `[calendar] The server does not know the time zone "${timeZone}"; asking it for UTC times instead. ` + 'Events without a time zone of their own may show shifted.', ); } /** * Take the verdict on `timeZone` from a response that carried its probe. A * request that failed outright is not remembered, so the next calendar list * checks again. */ function rememberTimeZoneProbe(key: string, timeZone: string, pending: Promise): void { const probe: Promise = pending.then( (res) => { const entry = ((res?.methodResponses ?? []) as [string, any, string][]).find( (r) => r[2] === TIME_ZONE_PROBE_CALL_ID, ); if (entry?.[0] === 'error' && isTimeZoneRefusal(entry[1]?.type, entry[1]?.description)) { refuseTimeZone(key, timeZone); } }, () => { if (timeZoneProbes.get(key) === probe) timeZoneProbes.delete(key); }, ); timeZoneProbes.set(key, probe); } /** * The `timeZone` argument for CalendarEvent/get and /query: the user's zone, * or UTC once the server refused it. The first calendar list checks the zone * on the way (getCalendars); a read that starts without that answer sends * the user's zone and is repeated in UTC if refused (withCalendarTimeZone). */ async function calendarTimeZone(): Promise { const timeZone = getUserTimeZone(); if (!timeZone) return undefined; const key = timeZoneKey(timeZone); await timeZoneProbes.get(key); return refusedTimeZones.has(key) ? 'UTC' : timeZone; } /** Run a calendar read in calendarTimeZone, and once more in UTC when the server refuses that zone. */ async function withCalendarTimeZone(read: (timeZone: string | undefined) => Promise): Promise { const timeZone = await calendarTimeZone(); try { return await read(timeZone); } catch (error) { if (!timeZone || timeZone === 'UTC') throw error; if (!isTimeZoneRefusal(jmapErrorType(error), error instanceof Error ? error.message : undefined)) throw error; refuseTimeZone(timeZoneKey(timeZone), timeZone); return read('UTC'); } } /** Forget the time zone checks: a new session may reach an upgraded server. */ export function resetTimeZoneChecks(): void { timeZoneProbes.clear(); refusedTimeZones.clear(); } /** * Render an instant as the LocalDateTime ("yyyy-MM-ddTHH:mm:ss") wall-clock * in `timeZone` — the form CalendarEvent/query's `after`/`before` expect, and * which Stalwart interprets in the query's `timeZone`. Accepts ISO strings * (with or without a zone) so callers can pass `Date.toISOString()` output. */ export function toLocalDateTime(value: string | Date, timeZone?: string): string { const date = typeof value === 'string' ? new Date(value) : value; if (isNaN(date.getTime())) return typeof value === 'string' ? value.replace(/(\.\d+)?Z$/, '') : ''; try { const parts = new Intl.DateTimeFormat('en-CA', { timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', }).formatToParts(date); const get = (type: string) => parts.find((p) => p.type === type)?.value ?? '00'; const hour = get('hour') === '24' ? '00' : get('hour'); return `${get('year')}-${get('month')}-${get('day')}T${hour}:${get('minute')}:${get('second')}`; } catch { const pad = (n: number) => String(n).padStart(2, '0'); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` + `T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; } } // Shared accounts the server rejected calendar access for — probed once, then // skipped for the rest of the session (the session lists every account with // the calendars capability, including ones that only share mail with us). const calendarAccessDenied = new Set(); // One up with every reset (a new login): a refusal answered after one was // given to the login before, whose shared accounts the next one may read. let calendarAccessGeneration = 0; export function isCalendarAccessDenied(accountId: string): boolean { return calendarAccessDenied.has(accountId); } export function resetCalendarAccessDenied(): void { calendarAccessDenied.clear(); calendarAccessGeneration += 1; } // Stalwart 1.0 answers `accountNotSupportedByMethod` for an account that // shares only a mail folder with the user, where 0.16 says `forbidden`. function isAccessDeniedError(err: unknown): boolean { const type = jmapErrorType(err); return type === 'forbidden' || type === 'accountNotFound' || type === 'accountNotSupportedByMethod'; } /** Remember an access rejection for a shared account; returns true when it was one. */ export function noteCalendarAccessError(accountId: string, err: unknown): boolean { if (!isAccessDeniedError(err)) return false; calendarAccessDenied.add(accountId); return true; } /** * Build a CalendarEvent/query filter restricting results to the given * calendars. Stalwart implements the singular `inCalendar` condition (one * calendar id per condition), not the draft's plural `inCalendars` array — * sending the plural form fails the whole query with `unsupportedFilter`. * Multiple calendars are expressed as an OR of singular conditions. */ function buildInCalendarFilter(calendarIds: string[]): Record { if (calendarIds.length === 1) { return { inCalendar: calendarIds[0] }; } return { operator: 'OR', conditions: calendarIds.map((id) => ({ inCalendar: id })), }; } // Stalwart's calcard crate implements JSCalendar 2.0 (jscalendarbis) property // names: singular `recurrenceRule` / `excludedRecurrenceRule` holding a single // object, not RFC 8984's plural array forms. Requesting the plural names // returns no recurrence data at all, so recurring events silently render as // one-off events (#13). Request the singular names and normalize below. // // No `recurrenceOverrides`: Stalwart 1.0 fails a CalendarEvent/get that asks // for them together with `utcStart` or `utcEnd` (invalidArguments, before any // id is read). getEvents reads them with a get of their own. const CALENDAR_EVENT_PROPERTIES = [ 'id', '@type', 'uid', 'calendarIds', 'title', 'description', 'start', 'duration', 'timeZone', 'showWithoutTime', 'utcStart', 'utcEnd', 'status', 'freeBusyStatus', 'participants', 'alerts', 'useDefaultAlerts', 'recurrenceRule', 'excludedRecurrenceRule', 'recurrenceId', 'replyTo', 'organizerCalendarAddress', 'isOrigin', 'sequence', 'locations', 'virtualLocations', 'progress', 'due', 'priority', 'percentComplete', 'links', 'created', 'updated', ]; /** The second get of getEvents: the overrides, by id. */ const OVERRIDE_PROPERTIES = ['id', 'recurrenceOverrides']; // Asked for only on server-expanded ranges: servers that expand recurrences // for the app (see supportsSyntheticCalendarIds) know these. An occurrence // gets its series' overrides from hydrateExpandedOccurrences instead. const EXPANDED_EVENT_PROPERTIES = [...CALENDAR_EVENT_PROPERTIES, 'baseEventId', 'recurrenceIdTimeZone']; // What a UID look-up compares (import de-duplication, iCal subscription sync): // reading whole events of a whole account for it would be wasted. const EVENT_IDENTITY_PROPERTIES = ['id', 'uid', 'calendarIds']; // Client-side fields that must never reach CalendarEvent/set. const CLIENT_ONLY_EVENT_KEYS = [ 'originalId', 'baseEventId', 'originalCalendarIds', 'accountId', 'isShared', 'localAccountId', ]; function stripClientOnlyFields(payload: Record): void { for (const key of CLIENT_ONLY_EVENT_KEYS) delete payload[key]; } /** * Normalize Stalwart's singular recurrence property names to the RFC 8984 * plural array forms the client uses internally. JSCalendar 2.0 defines * recurrenceRule as a single object, but Stalwart may also return an array * (for events created via JMAP), so both forms are handled. Mirrors webmail's * normalizeStalwartPropertyNames. */ export function normalizeRecurrenceProperties>(event: T): T { const raw = event as Record; let patched = false; const updates: Partial = {}; if ('recurrenceRule' in raw && !('recurrenceRules' in raw)) { const val = raw.recurrenceRule; if (val != null && !Array.isArray(val) && typeof val === 'object') { updates.recurrenceRules = [val] as CalendarEvent['recurrenceRules']; } else { updates.recurrenceRules = val as CalendarEvent['recurrenceRules']; } patched = true; } if ('excludedRecurrenceRule' in raw && !('excludedRecurrenceRules' in raw)) { const val = raw.excludedRecurrenceRule; if (val != null && !Array.isArray(val) && typeof val === 'object') { updates.excludedRecurrenceRules = [val] as CalendarEvent['excludedRecurrenceRules']; } else { updates.excludedRecurrenceRules = val as CalendarEvent['excludedRecurrenceRules']; } patched = true; } if (!patched) return event; const result = { ...event, ...updates } as T; delete (result as Record).recurrenceRule; delete (result as Record).excludedRecurrenceRule; return result; } const TASK_PROGRESS_NORMALIZATION = new Map([ ['needs-action', 'needs-action'], ['needs_action', 'needs-action'], ['in-process', 'in-process'], ['in_process', 'in-process'], ['completed', 'completed'], ['cancelled', 'cancelled'], ['canceled', 'cancelled'], ]); /** * CalDAV-created tasks can come back with their iCalendar STATUS spelling * (`COMPLETED`, `NEEDS_ACTION`) or as `canceled` (stalwartlabs/calcard#20). * Map the known spellings onto the JSCalendar values the task filters, the * checkbox and the reminders compare against; leave unknown values alone. * Mirrors webmail's normalizeCalendarTask. */ export function normalizeTaskProgress>(event: T): T { const progress = (event as { progress?: unknown }).progress; if (typeof progress !== 'string') return event; const normalized = TASK_PROGRESS_NORMALIZATION.get(progress.toLowerCase()); return normalized === undefined || normalized === progress ? event : { ...event, progress: normalized }; } /** * A timed event without a duration lasts zero seconds (RFC 8984's default * PT0S). Stalwart drops a DTEND equal to DTSTART and then ends such an event * at 23:59:59 of its start day, so its utcEnd is wrong (#837). Tasks carry * no duration and are left alone. Mirrors the webmail's withZeroDurationEnd. */ export function withZeroDurationEnd>(event: T): T { if (event.showWithoutTime || event.duration || !event.start || !event.utcStart) return event; if (isTaskLikeObject(event as ScannedCalendarObject)) return event; return { ...event, duration: 'PT0S', utcEnd: event.utcStart }; } /** * Convert the client's plural recurrence arrays to the singular JSCalendar 2.0 * properties Stalwart expects on write, dropping null rule fields. An empty * array (or null) becomes an explicit null so "remove recurrence" round-trips. * Mirrors webmail's cleanRecurrenceRules. */ function cleanRecurrenceRules(event: Record): void { const keyMap: Record = { recurrenceRules: 'recurrenceRule', excludedRecurrenceRules: 'excludedRecurrenceRule', }; for (const [pluralKey, singularKey] of Object.entries(keyMap)) { const rules = event[pluralKey]; if (rules === undefined) continue; delete event[pluralKey]; if (!Array.isArray(rules)) { event[singularKey] = rules; continue; } if (rules.length === 0) { event[singularKey] = null; continue; } const rule = rules[0] as Record; const cleaned: Record = {}; for (const [k, v] of Object.entries(rule)) { if (v !== null) cleaned[k] = v; } event[singularKey] = cleaned; } } /** * The answer to call `callId` (the first call when omitted). A method error * throws a JMAPMethodError with the JMAP type, so callers can tell an * expected access rejection (forbidden / accountNotFound on a shared * account) apart from a genuine failure. */ function methodResult(res: any, callId?: string): T { return requireMethodResult(res, callId); } // Other session accounts that expose the calendars capability hold calendars // shared with the user (JMAP surfaces shared data under the sharer's account). function sharedCalendarAccountIds(): string[] { const session = jmapClient.currentSession; const primary = jmapClient.accountId; return Object.entries(session?.accounts ?? {}) .filter(([id, info]) => id !== primary && !!info.accountCapabilities?.[CAPABILITIES.CALENDARS]) .map(([id]) => id); } // ─── Synthetic ids ─────────────────────────────────────── // Whether CalendarEvent/set accepts the synthetic ids that // CalendarEvent/query?expandRecurrences=true hands out (Stalwart >= 0.16.20), // per server and account. Mirrors the webmail's supportsSyntheticCalendarIds. const SYNTHETIC_ID_PROBE_CALL_ID = 'synthetic-id-probe'; const syntheticIdSupport = new Map>(); function syntheticIdProbeKey(): string | null { try { return `${jmapClient.currentSession?.apiUrl ?? ''}|${jmapClient.accountId}`; } catch { return null; } } function syntheticIdProbeCall(accountId: string): [string, Record, string] { return ['CalendarEvent/set', { accountId, update: { [SYNTHETIC_ID_PROBE]: {} } }, SYNTHETIC_ID_PROBE_CALL_ID]; } /** * Derive (and cache) the probe verdict from a response that carried the * probe call. A server that still rejects synthetic ids answers * `invalidProperties` before looking the event up, one that supports them * answers `notFound`; anything else counts as unsupported. A request that * failed outright is not remembered, so the next range load probes again. */ function rememberSyntheticIdProbe(key: string, pending: Promise): void { const verdict: Promise = pending.then( (res) => { const entry = ((res?.methodResponses ?? []) as [string, any, string][]).find( (r) => r[2] === SYNTHETIC_ID_PROBE_CALL_ID, ); if (!entry || entry[0] !== 'CalendarEvent/set') return false; return entry[1]?.notUpdated?.[SYNTHETIC_ID_PROBE]?.type === 'notFound'; }, () => { if (syntheticIdSupport.get(key) === verdict) syntheticIdSupport.delete(key); return false; }, ); syntheticIdSupport.set(key, verdict); } /** * Whether the server expands recurring events for the app: it accepts the * synthetic occurrence ids in CalendarEvent/set, so each occurrence can be * edited through its own id. Probed once per server and account with a * side-effect-free update of an id that cannot exist; the first Calendar/get * carries the probe along (getCalendars), so the usual start-up pays no * extra round trip. */ export function supportsSyntheticCalendarIds(): Promise { const key = syntheticIdProbeKey(); if (!key) return Promise.resolve(false); if (!syntheticIdSupport.has(key)) { rememberSyntheticIdProbe( key, jmapClient.request([syntheticIdProbeCall(jmapClient.accountId)], USING), ); } return syntheticIdSupport.get(key)!; } export function resetSyntheticIdSupport(): void { syntheticIdSupport.clear(); } // The Calendar properties the app reads, which the default list on 0.16 and // 1.0 covers. The pre-releases of Stalwart 1.0 advertised JMAP Metadata with // handlers that were still `todo!()`: a get that selected `metadata` or // `privateMetadata` panicked on the server (1.0.0 answers it). An explicit // list keeps them, and whatever else a release adds to the default, out of // every calendar load. 0.16.6 knows every one of these. const CALENDAR_PROPERTIES = [ 'id', 'name', 'description', 'color', 'sortOrder', 'isSubscribed', 'isVisible', 'isDefault', 'myRights', 'shareWith', 'defaultAlertsWithTime', 'defaultAlertsWithoutTime', ]; export async function getCalendars(): Promise { const accountId = jmapClient.accountId; const accessGeneration = calendarAccessGeneration; const probeKey = syntheticIdProbeKey(); const carriesProbe = !!probeKey && !syntheticIdSupport.has(probeKey); const methodCalls: [string, Record, string][] = [ ['Calendar/get', { accountId, properties: CALENDAR_PROPERTIES }, '0'], ]; if (carriesProbe) methodCalls.push(syntheticIdProbeCall(accountId)); // The user's time zone is checked on the way too (calendarTimeZone). const timeZone = getUserTimeZone(); const zoneKey = timeZone && timeZone !== 'UTC' ? timeZoneKey(timeZone) : null; const checksTimeZone = !!zoneKey && !timeZoneProbes.has(zoneKey); if (checksTimeZone) methodCalls.push(timeZoneProbeCall(accountId, timeZone!)); const pending = jmapClient.request(methodCalls, USING); if (carriesProbe) rememberSyntheticIdProbe(probeKey!, pending); if (checksTimeZone) rememberTimeZoneProbe(zoneKey!, timeZone!, pending); const res = await pending; const own = methodResult<{ list: Calendar[] }>(res).list ?? []; // Calendars shared with the user live in other session accounts. Failures // there (revoked share, transient error) must not hide the user's own // calendars, so each shared account is fetched best-effort. const shared = await Promise.all( sharedCalendarAccountIds().map(async (sharedAccountId) => { // Don't re-probe an account the server already rejected this session. if (calendarAccessDenied.has(sharedAccountId)) return []; try { const sharedRes = await jmapClient.request( [['Calendar/get', { accountId: sharedAccountId, properties: CALENDAR_PROPERTIES }, '0']], USING, ); const list = methodResult<{ list: Calendar[] }>(sharedRes).list ?? []; // JMAP calendar ids are only unique within an account, so a shared // "default" calendar collides with the user's own "default". Namespace // the id (keeping the real id in originalId) so visibility toggles, // event-query routing, and create/default mutations don't cross wires. // Mirrors webmail's getAllCalendars. return list.map((cal) => ({ ...cal, id: `${sharedAccountId}:${cal.id}`, originalId: cal.id, accountId: sharedAccountId, isShared: true, })); } catch (err) { if (accessGeneration === calendarAccessGeneration) noteCalendarAccessError(sharedAccountId, err); return []; } }), ); return [...own, ...shared.flat()]; } const QUERY_PAGE_SIZE = 1000; // Backstop against a server that keeps returning full pages; 20 pages is far // beyond any real calendar window and keeps a bug from looping forever. const QUERY_MAX_PAGES = 20; /** * Query event ids in a date window. `after`/`before` are instants (ISO, as * from `Date.toISOString()`) or LocalDateTime strings; they're sent as * LocalDateTime in the query's `timeZone` so Stalwart interprets the bounds * in the user's zone. Calendars are restricted via the singular `inCalendar` * condition (the plural `inCalendars` fails the whole query with * unsupportedFilter on Stalwart); the range and calendar conditions are * combined with an AND operator. Without bounds every object is returned. * The result set is paged: a window can hold more events than one page, and * a truncated page must never pass for a complete answer. */ export async function queryEvents( calendarIds: string[], after: string, before: string, accountId?: string, ): Promise { const account = accountId || jmapClient.accountId; return withCalendarTimeZone(async (timeZone) => { const conditions: Record[] = []; if (calendarIds.length > 0) conditions.push(buildInCalendarFilter(calendarIds)); const range: Record = {}; if (after) range.after = placeableLocalBound(toLocalDateTime(after, timeZone), timeZone, -1); if (before) range.before = placeableLocalBound(toLocalDateTime(before, timeZone), timeZone, 1); if (Object.keys(range).length > 0) conditions.push(range); const filter = conditions.length === 1 ? conditions[0] : conditions.length > 1 ? { operator: 'AND', conditions } : undefined; const ids: string[] = []; for (let page = 0; page < QUERY_MAX_PAGES; page++) { const args: Record = { accountId: account, limit: QUERY_PAGE_SIZE, position: ids.length, }; if (timeZone) args.timeZone = timeZone; if (filter) args.filter = filter; const res = await jmapClient.request( [['CalendarEvent/query', args, '0']], USING, ); const batch = methodResult<{ ids: string[] }>(res).ids ?? []; ids.push(...batch); if (batch.length < QUERY_PAGE_SIZE) break; } return ids; }); } // ─── Expanded ranges ───────────────────────────────────── // Stalwart 1.0 refuses an expanded CalendarEvent/query that spans more than // the account's `maxExpandedQueryDuration` (expandDurationTooLarge), and // one that expands to more occurrences than it allows // (cannotCalculateOccurrences; 0.16 said invalidArguments). The loaded // window of a freely scrolling view grows well past a year, so a range is // queried in chunks within the limit. const DAY_SECONDS = 86_400; // Chunks stay a day below maxExpandedQueryDuration: the server measures the // span in UTC after resolving the bounds in the request time zone, so a DST // change or an ambiguous local time moves it. const EXPANDED_CHUNK_SLACK = DAY_SECONDS; // A chunk that still expands to too many occurrences is halved down to about // a week, one the server finds too long down to an hour; past that, and past // this many queries for one range, the range is expanded on the device. const MIN_OCCURRENCE_CHUNK = 7 * DAY_SECONDS; const MIN_DURATION_CHUNK = 3600; const MAX_EXPANDED_CHUNKS = 64; /** * Part of an expanded range, in seconds of the request time zone's wall * clock (the LocalDateTime bounds counted as if they were UTC). */ interface RangeChunk { start: number; end: number; /** Where the next page of this chunk starts. */ position: number; } const LOCAL_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})$/; /** A LocalDateTime such as "2026-09-01T00:00:00" as wall-clock seconds, or null. */ function wallClockSeconds(value: string): number | null { const match = LOCAL_DATE_TIME.exec(value); if (!match) return null; const [year, month, day, hour, minute, second] = match.slice(1).map(Number); const date = new Date(0); date.setUTCFullYear(year, month - 1, day); date.setUTCHours(hour, minute, second, 0); return date.getTime() / 1000; } function localDateTimeOf(seconds: number): string { const date = new Date(seconds * 1000); const pad = (n: number, length = 2) => String(n).padStart(length, '0'); return `${pad(date.getUTCFullYear(), 4)}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}` + `T${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`; } // Stalwart 0.16 cannot place a range bound whose wall clock names no instant // in the request's time zone (in the hour the clocks skip) or two (in the // hour they repeat): an expanded query fails with "Both 'after' and 'before' // filters are required when expanding recurrences", and a plain one leaves // the bound out (live on 0.16.27; 1.0 places both). Where the clocks change // at midnight - Havana, the Azores, Santiago - the start of a day is such a // time, and a chunk border can be one anywhere. Such a bound moves out of // the range until it names one instant: `after` earlier, `before` later. const BOUND_STEP = 15 * 60; const MAX_BOUND_SHIFT = 3 * 3600; /** How many instants the wall clock `seconds` (wallClockSeconds) names in `timeZone`. */ function instantsAt(seconds: number, timeZone: string): number { const wall = localDateTimeOf(seconds); // The offsets a day either side: a change of the clocks in between is // the only one near. const offsets = new Set([seconds - DAY_SECONDS, seconds + DAY_SECONDS].map( (at) => getTimeZoneOffsetMs(new Date(at * 1000), timeZone) / 1000, )); let count = 0; for (const offset of offsets) { if (formatWallClock(new Date((seconds - offset) * 1000), timeZone) === wall) count++; } return count; } /** The wall clock `seconds`, or the nearest one in `direction` that names one instant in `timeZone`. */ function placeableBound(seconds: number, timeZone: string | undefined, direction: -1 | 1): number { if (!timeZone || timeZone === 'UTC') return seconds; try { for (let shift = 0; shift <= MAX_BOUND_SHIFT; shift += BOUND_STEP) { const candidate = seconds + direction * shift; if (instantsAt(candidate, timeZone) === 1) return candidate; } } catch { // A zone Intl does not know either: the server has the last word. } return seconds; } /** placeableBound of a LocalDateTime; anything else as it is. */ function placeableLocalBound(value: string, timeZone: string | undefined, direction: -1 | 1): string { const seconds = wallClockSeconds(value); return seconds === null ? value : localDateTimeOf(placeableBound(seconds, timeZone, direction)); } /** * [start, end] cut into chunks of at most `span` seconds, or null when that * takes more than MAX_EXPANDED_CHUNKS. Neighbours share a second: the server * counts an occurrence in a range only when it overlaps it strictly, so one * that sits exactly on a border would belong to neither chunk. */ function chunkRange(start: number, end: number, span: number): RangeChunk[] | null { if (span < 2) return null; const chunks: RangeChunk[] = []; for (let from = start; chunks.length < MAX_EXPANDED_CHUNKS; from = chunks[chunks.length - 1].end - 1) { const to = Math.min(from + span, end); chunks.push({ start: from, end: to, position: 0 }); if (to >= end) return chunks; } return null; } /** * Whether an expanded query failed on the server's cap on occurrences per * query: `cannotCalculateOccurrences` on Stalwart 1.0, `invalidArguments` * ("The number of expanded recurrences exceeds the server limit") on 0.16. */ function isOccurrenceLimitError(error: unknown): boolean { const type = jmapErrorType(error); if (type === 'cannotCalculateOccurrences') return true; return type === 'invalidArguments' && /exceeds/i.test(error instanceof Error ? error.message : ''); } /** * What to query instead of a chunk the server refused: its two halves, or * null when it is already as small as it gets and the range has to be * expanded on the device. Any other refusal is rethrown. */ function splitRefusedChunk(chunk: RangeChunk, error: unknown): RangeChunk[] | null { let minimum: number; if (jmapErrorType(error) === 'expandDurationTooLarge') minimum = MIN_DURATION_CHUNK; else if (isOccurrenceLimitError(error)) minimum = MIN_OCCURRENCE_CHUNK; else throw error; if (chunk.end - chunk.start <= minimum) return null; const middle = Math.floor((chunk.start + chunk.end) / 2); return [ { start: chunk.start, end: middle, position: 0 }, { start: middle - 1, end: chunk.end, position: 0 }, ]; } /** * The most participants an event may have, from the account's calendars * capability (`maxParticipantsPerEvent`; the primary account's when the * account names none), or null when it is not advertised. Stalwart 1.0 * refuses to create or update an event with more, an answer to an * invitation included, and counts the organizer; 0.16 advertises the same * value without enforcing it. */ export function maxParticipantsPerEvent(accountId?: string): number | null { if (!jmapClient.isConnected) return null; const accounts = jmapClient.currentSession?.accounts; for (const id of [accountId, jmapClient.accountId]) { if (!id) continue; const capability = accounts?.[id]?.accountCapabilities?.[CAPABILITIES.CALENDARS] as | { maxParticipantsPerEvent?: unknown } | undefined; const max = capability?.maxParticipantsPerEvent; if (typeof max === 'number' && max > 0) return max; } return null; } /** * The longest range an expanded query may span, in seconds: * `maxExpandedQueryDuration` of the account's calendars capability (the * primary account's when the account has none), an iCalendar duration such * as "P52W1D". Every 0.16 advertises 365 days without enforcing it. */ function maxExpandedQuerySeconds(accountId: string): number | null { const accounts = jmapClient.currentSession?.accounts; for (const id of [accountId, jmapClient.accountId]) { const capability = accounts?.[id]?.accountCapabilities?.[CAPABILITIES.CALENDARS] as | { maxExpandedQueryDuration?: unknown } | undefined; const duration = capability?.maxExpandedQueryDuration; const seconds = typeof duration === 'string' ? parseDurationSeconds(duration) : null; if (seconds) return seconds; } return null; } /** * Occurrence ids in a date window, with recurring series expanded by the * server into one synthetic id per occurrence (CalendarEvent/query * `expandRecurrences`). Only for servers that accept those ids in * CalendarEvent/set (supportsSyntheticCalendarIds). * * Each query's filter is one FilterCondition of the two bounds - Stalwart * 1.0 refuses operators in an expanded query - so the caller keeps the * calendars it asked for. The window goes out in chunks within the server's * maxExpandedQueryDuration, as many per request as maxCallsInRequest * allows; a synthetic id does not depend on the chunk it was found in, so * an occurrence two chunks share is kept once. A chunk the server still * refuses is halved: when it is too long, or when it expands to too many * occurrences, down to about a week. `null` when even that is refused: the * caller then expands the whole window on the device (mixing in occurrences * found so far would show series twice). */ export async function queryExpandedEvents( after: string, before: string, accountId?: string, ): Promise { const account = accountId || jmapClient.accountId; return withCalendarTimeZone((timeZone) => queryExpandedEventsIn(account, after, before, timeZone)); } /** queryExpandedEvents with the bounds and the request in `timeZone`. */ async function queryExpandedEventsIn( account: string, after: string, before: string, timeZone: string | undefined, ): Promise { const bounds = { after: toLocalDateTime(after, timeZone), before: toLocalDateTime(before, timeZone) }; const baseArgs: Record = { accountId: account, expandRecurrences: true }; if (timeZone) baseArgs.timeZone = timeZone; const start = wallClockSeconds(bounds.after); const end = wallClockSeconds(bounds.before); if (start === null || end === null || end <= start) return queryExpandedRange(baseArgs, bounds); const maxSeconds = maxExpandedQuerySeconds(account); const span = maxSeconds === null ? end - start : maxSeconds - Math.min(EXPANDED_CHUNK_SLACK, Math.floor(maxSeconds / 2)); const chunks = chunkRange(start, end, span); if (!chunks) return null; const ids = new Set(); let pending = chunks; let chunksSent = 0; while (pending.length > 0) { const batch = pending.splice(0, Math.max(1, jmapClient.getMaxCallsInRequest())); // Pages are bounded below; splitting is what has to stop somewhere. chunksSent += batch.filter((chunk) => chunk.position === 0).length; if (chunksSent > MAX_EXPANDED_CHUNKS) return null; const res = await jmapClient.request(batch.map((chunk, i): [string, Record, string] => [ 'CalendarEvent/query', { ...baseArgs, filter: { after: localDateTimeOf(placeableBound(chunk.start, timeZone, -1)), before: localDateTimeOf(placeableBound(chunk.end, timeZone, 1)), }, position: chunk.position, limit: QUERY_PAGE_SIZE, }, String(i), ]), USING); // The halves of a refused chunk, and the next page of a full one, go to // the front of the queue. const again: RangeChunk[] = []; for (const [i, chunk] of batch.entries()) { let page: string[]; try { page = methodResult<{ ids?: string[] }>(res, String(i)).ids ?? []; } catch (error) { const halves = splitRefusedChunk(chunk, error); if (!halves) return null; again.push(...halves); continue; } for (const id of page) ids.add(id); // A server that keeps returning full pages stops after QUERY_MAX_PAGES. const next = chunk.position + page.length; if (page.length === QUERY_PAGE_SIZE && next < QUERY_MAX_PAGES * QUERY_PAGE_SIZE) { again.push({ ...chunk, position: next }); } } pending = [...again, ...pending]; } return [...ids]; } /** * One expanded query for `filter`, paged: for bounds that cannot be cut into * chunks. `null` when the server will not expand it. */ async function queryExpandedRange( baseArgs: Record, filter: Record, ): Promise { const ids: string[] = []; for (let page = 0; page < QUERY_MAX_PAGES; page++) { const res = await jmapClient.request([['CalendarEvent/query', { ...baseArgs, filter, position: ids.length, limit: QUERY_PAGE_SIZE, }, '0']], USING); let batch: string[]; try { batch = methodResult<{ ids?: string[] }>(res).ids ?? []; } catch (error) { if (jmapErrorType(error) === 'expandDurationTooLarge' || isOccurrenceLimitError(error)) return null; throw error; } ids.push(...batch); if (batch.length < QUERY_PAGE_SIZE) break; } return ids; } /** * Server-expanded occurrences come back without their series' recurrence * rule / overrides (and all-day ones without showWithoutTime). Fetch those * from the base events in one batched get and merge them in, so an * occurrence carries the same series context the device-side expansion * copies from its master. */ export async function hydrateExpandedOccurrences( events: CalendarEvent[], accountId?: string, ): Promise { const baseIds = Array.from(new Set( events .filter((event) => isServerRecurrenceInstance(event) && event.recurrenceId) .map((event) => event.baseEventId as string), )); if (baseIds.length === 0) return events; const account = accountId || jmapClient.accountId; const bases = new Map>(); const list = await withCalendarTimeZone((timeZone) => fetchEventsById(account, baseIds, RECURRENCE_BASE_PROPERTIES, { timeZone })); for (const base of list as Partial[]) { if (base.id) bases.set(base.id, normalizeRecurrenceProperties(base)); } return hydrateRecurrenceInstances(events, bases); } /** * CalendarEvent/get of `ids`, batched by maxObjectsInGet. With * `withOverrides`, every request carries a second get of OVERRIDE_PROPERTIES * and its overrides are merged in by id: Stalwart 1.0 refuses * `recurrenceOverrides` in the same get as `utcStart`/`utcEnd`. An error * answer to either get fails the whole read - taken as an empty list, it * would pass for "no events". */ async function fetchEventsById( accountId: string, ids: string[], properties: readonly string[], options: { withOverrides?: boolean; timeZone?: string } = {}, ): Promise[]> { const { withOverrides = false, timeZone } = options; const objects: Record[] = []; for (const batch of batched(ids, jmapClient.getMaxObjectsInGet())) { const args = { accountId, ids: batch, ...(timeZone ? { timeZone } : {}) }; const calls: [string, Record, string][] = [ ['CalendarEvent/get', { ...args, properties: [...properties] }, '0'], ]; if (withOverrides) { calls.push(['CalendarEvent/get', { ...args, properties: [...OVERRIDE_PROPERTIES] }, '1']); } const res = await jmapClient.request(calls, USING); const list = methodResult<{ list?: Record[] }>(res, '0').list ?? []; if (withOverrides) { const overrides = new Map(); for (const entry of methodResult<{ list?: Record[] }>(res, '1').list ?? []) { if ('recurrenceOverrides' in entry) overrides.set(entry.id, entry.recurrenceOverrides); } for (const object of list) { if (overrides.has(object.id)) object.recurrenceOverrides = overrides.get(object.id); } } objects.push(...list); } return objects; } /** * Every calendar object of an account with just the properties needed to tell * tasks from events (see lib/calendar-component-detection). Used to find * tasks-only calendars and the task ids; pages through the query so accounts * with more than 1000 objects are covered. */ export async function scanCalendarObjects(accountId?: string): Promise { const account = accountId || jmapClient.accountId; const pageSize = 1000; const ids: string[] = []; for (let position = 0; ; position += pageSize) { const res = await jmapClient.request( [['CalendarEvent/query', { accountId: account, position, limit: pageSize }, '0']], USING, ); const page = methodResult<{ ids: string[] }>(res).ids ?? []; ids.push(...page); if (page.length < pageSize) break; } if (ids.length === 0) return []; const batchSize = jmapClient.getMaxObjectsInGet(); const all: ScannedCalendarObject[] = []; for (let i = 0; i < ids.length; i += batchSize) { const res = await jmapClient.request( [['CalendarEvent/get', { accountId: account, ids: ids.slice(i, i + batchSize), properties: SCAN_PROPERTIES, }, '0']], USING, ); all.push(...(methodResult<{ list: ScannedCalendarObject[] }>(res).list ?? [])); } return all; } /** * Events (or tasks) by id. A stored event comes with its recurrence * overrides, read by a second get in the same request (fetchEventsById). */ export async function getEvents( ids: string[], accountId?: string, // `expanded`: the ids are server-expanded occurrences (queryExpandedEvents), // which get their series' overrides from hydrateExpandedOccurrences. options?: { expanded?: boolean }, ): Promise { if (ids.length === 0) return []; const account = accountId || jmapClient.accountId; const list = await withCalendarTimeZone((timeZone) => fetchEventsById( account, ids, options?.expanded ? EXPANDED_EVENT_PROPERTIES : CALENDAR_EVENT_PROPERTIES, { withOverrides: !options?.expanded, timeZone }, )); return (list as unknown as CalendarEvent[]).map((e) => withZeroDurationEnd(normalizeTaskProgress(normalizeRecurrenceProperties(e)))); } export type EventIdentityFields = Pick; /** Just the id, uid and calendars of `ids`: what a UID look-up compares. */ export async function getEventIdentities(ids: string[], accountId?: string): Promise { if (ids.length === 0) return []; const account = accountId || jmapClient.accountId; return (await fetchEventsById(account, ids, EVENT_IDENTITY_PROPERTIES)) as unknown as EventIdentityFields[]; } /** * The calendar objects carrying an iCalendar UID, found on the server with a * `uid` filter — unlike the store, not limited to the loaded date window. * Mirrors webmail's `queryCalendarEvents({ uid })`. */ export async function findEventsByUid(uid: string, accountId?: string): Promise { const account = accountId || jmapClient.accountId; const res = await jmapClient.request( [['CalendarEvent/query', { accountId: account, filter: { uid } }, '0']], USING, ); const ids = methodResult<{ ids: string[] }>(res).ids ?? []; return getEvents(ids, accountId); } /** * The ids of the events a CalendarEvent/query `filter` finds (the event * search's `text` conditions), latest start first, at most `limit`. Not * limited to a date window: a series is found by its stored event. */ export async function searchEventIds( filter: Record, accountId?: string, limit = 100, ): Promise { const account = accountId || jmapClient.accountId; return withCalendarTimeZone(async (timeZone) => { const args: Record = { accountId: account, filter, sort: [{ property: 'start', isAscending: false }], limit, }; if (timeZone) args.timeZone = timeZone; const res = await jmapClient.request([['CalendarEvent/query', args, '0']], USING); return methodResult<{ ids: string[] }>(res).ids ?? []; }); } // `sendSchedulingMessages` asks Stalwart to deliver iMIP (RFC 6047) invitation // / reply / cancellation emails to participants. We pass it whenever an event // has participants so creating/updating/deleting a meeting notifies attendees. function setArgs( accountId: string, payload: Record, sendSchedulingMessages?: boolean, ): Record { const args: Record = { accountId, ...payload }; if (sendSchedulingMessages !== undefined) { args.sendSchedulingMessages = sendSchedulingMessages; } return args; } export async function createEvent( event: Partial, calendarId: string, sendSchedulingMessages?: boolean, targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; const payload: Record = { ...event, calendarIds: { [calendarId]: true } }; stripClientOnlyFields(payload); cleanRecurrenceRules(payload); const res = await jmapClient.request( [['CalendarEvent/set', setArgs(accountId, { create: { 'new-event': payload }, }, sendSchedulingMessages), '0']], USING, ); const result = methodResult<{ created?: Record; notCreated?: Record; }>(res); const created = result.created?.['new-event']; if (!created) { const err = result.notCreated?.['new-event']; // With its type: the caller names Stalwart 1.0's participant limit. throw new JMAPSetError(err?.type ?? 'setError', err?.description || err?.type || 'Failed to create event', err?.properties); } // The /set response echoes only server-set properties; merge them over the // submitted payload so the store's optimistic insert keeps recurrence data. return normalizeRecurrenceProperties({ ...payload, ...created } as CalendarEvent); } /** A JMAP /set error as one line: its description, else its type and the properties it names. */ export function describeSetError( err: { type?: string; description?: string; properties?: string[] } | null | undefined, ): string { if (err?.description) return err.description; const type = err?.type || 'unknown error'; return err?.properties?.length ? `${type} (${err.properties.join(', ')})` : type; } export interface BatchCreateResult { created: number; /** The events the server refused, by their index in the submitted list. */ refused: { index: number; reason: string }[]; } // Batch-create many events in one CalendarEvent/set. Per-event refusals come // back in `refused` so the caller can report them; a method-level error // (unknown calendar, missing capability) throws. export async function batchCreateEvents( events: Partial[], calendarId: string, targetAccountId?: string, ): Promise { if (events.length === 0) return { created: 0, refused: [] }; const accountId = targetAccountId || jmapClient.accountId; const create: Record> = {}; events.forEach((e, i) => { const payload: Record = { ...e, calendarIds: { [calendarId]: true } }; stripClientOnlyFields(payload); cleanRecurrenceRules(payload); create[`evt-${i}`] = payload as Partial; }); const res = await jmapClient.request( [['CalendarEvent/set', { accountId, create }, '0']], USING, ); const result = methodResult<{ created?: Record; notCreated?: Record; }>(res); const refused: BatchCreateResult['refused'] = []; events.forEach((_, i) => { const err = result.notCreated?.[`evt-${i}`]; if (err) refused.push({ index: i, reason: describeSetError(err) }); }); return { created: result.created ? Object.keys(result.created).length : 0, refused }; } export async function updateEvent( id: string, changes: Partial | Record, sendSchedulingMessages?: boolean, targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; const patch: Record = { ...changes }; stripClientOnlyFields(patch); cleanRecurrenceRules(patch); const res = await jmapClient.request( [['CalendarEvent/set', setArgs(accountId, { update: { [id]: patch } }, sendSchedulingMessages), '0']], USING, ); const result = methodResult<{ notUpdated?: Record }>(res); const err = result.notUpdated?.[id]; // Keep the SetError type: the store retries some refusals differently // (isOverrideSeriesKeyRejection). if (err) throw new JMAPSetError(err.type ?? 'setError', err.description || err.type || 'Failed to update event', err.properties); } export async function deleteEvents( ids: string[], sendSchedulingMessages?: boolean, targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['CalendarEvent/set', setArgs(accountId, { destroy: ids }, sendSchedulingMessages), '0']], USING, ); assertSetResult(methodResult(res), ids, 'event'); } // RSVP to an invitation: patch the participant's participationStatus via a JSON // Pointer (RFC 6901) and let Stalwart send the iTIP REPLY (sendSchedulingMessages). // Stalwart routes the REPLY to the stored ORGANIZER (organizerCalendarAddress); // the RFC 8984 replyTo property is retired in jscalendarbis and ignored. The // caller passes `repairOrganizerAddress` only for events that lack an // organizer (e.g. imported ones) — attendees may not modify an existing one. export async function rsvpEvent( eventId: string, participantId: string, status: 'accepted' | 'declined' | 'tentative', repairOrganizerAddress?: string | null, targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; // Escape per RFC 6901: ~ → ~0, / → ~1. const escaped = participantId.replace(/~/g, '~0').replace(/\//g, '~1'); const patch: Record = { [`participants/${escaped}/participationStatus`]: status, }; if (repairOrganizerAddress) patch.organizerCalendarAddress = repairOrganizerAddress; const res = await jmapClient.request( [['CalendarEvent/set', { accountId, update: { [eventId]: patch }, sendSchedulingMessages: true }, '0']], USING, ); const result = methodResult<{ notUpdated?: Record }>(res); const err = result.notUpdated?.[eventId]; if (err) throw new JMAPSetError(err.type ?? 'setError', err.description || err.type || 'Failed to send RSVP', err.properties); } // Parse an uploaded .ics blob into one or more JSCalendar events (server-side). // `targetAccountId` routes the parse to the account that owns the blob (an // email in a shared mailbox lives in the sharer's account). export async function parseCalendarBlob( blobId: string, targetAccountId?: string, ): Promise[]> { const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['CalendarEvent/parse', { accountId, blobIds: [blobId] }, '0']], USING, ); const result = methodResult<{ parsed?: Record; notParsable?: string[]; notFound?: string[]; }>(res); if (result.notParsable?.includes(blobId)) throw new Error('Invalid calendar file format'); if (result.notFound?.includes(blobId)) throw new Error('Uploaded file not found'); const parsed = result.parsed?.[blobId]; if (!parsed) return []; const list = Array.isArray(parsed) ? parsed : [parsed]; return list.map(normalizeRecurrenceProperties); } // Download the raw text of a calendar blob (the .ics itself) so the iTIP // METHOD line can be read — JMAP strips the `method=` Content-Type parameter. // Best-effort: returns null when the download fails. export async function fetchCalendarBlobText( blobId: string, targetAccountId?: string, ): Promise { try { await jmapClient.ensureFreshToken(); // Lazy: blob/client-cert pull in native Expo modules, which the pure // JMAP layer (and its node tests) must not load eagerly. const [{ getDownloadUrl }, { secureFetch }] = await Promise.all([ import('./blob'), import('../lib/client-cert'), ]); const url = getDownloadUrl(blobId, 'invite.ics', 'text/calendar', targetAccountId); const res = await secureFetch(url, { headers: { Authorization: jmapClient.authHeader } }); if (!res.ok) return null; const text = await res.text(); return text.length > 2 * 1024 * 1024 ? null : text; } catch { return null; } } export async function createCalendar( name: string, color?: string, description?: string, targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; const props: Record = { name, color, isVisible: true, isSubscribed: true }; if (description?.trim()) props.description = description.trim(); const res = await jmapClient.request( [['Calendar/set', { accountId, create: { 'new-cal': props }, }, '0']], USING, ); const result = methodResult<{ created?: Record; notCreated?: Record; }>(res); const created = result.created?.['new-cal']; if (!created) { const err = result.notCreated?.['new-cal']; throw new Error(err?.description || err?.type || 'Failed to create calendar'); } return created; } /** * Mark a calendar as the account default. `isDefault` is read-only in * Stalwart's Calendar/set — the default is changed via the * `onSuccessSetIsDefault` request argument instead. */ export async function setDefaultCalendar( calendarId: string, targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['Calendar/set', { accountId, onSuccessSetIsDefault: calendarId }, '0']], USING, ); methodResult(res); } /** * The name of the user's calendar `id`, or null when the account has no * calendar by that id. */ export async function getCalendarName(id: string, targetAccountId?: string): Promise { const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['Calendar/get', { accountId, ids: [id], properties: ['id', 'name'] }, '0']], USING, ); const list = methodResult<{ list?: Calendar[] }>(res).list ?? []; return list.find((cal) => cal.id === id)?.name ?? null; } // Destroy a calendar. `onDestroyRemoveEvents` removes its events too — // Stalwart otherwise refuses to delete a non-empty calendar. export async function deleteCalendar(id: string, targetAccountId?: string): Promise { const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['Calendar/set', { accountId, destroy: [id], onDestroyRemoveEvents: true }, '0']], USING, ); const result = methodResult<{ notDestroyed?: Record }>(res); const err = result.notDestroyed?.[id]; if (err) throw new Error(err.description || err.type || 'Failed to delete calendar'); } export interface CalendarUpdates { name?: string; color?: string | null; description?: string | null; sortOrder?: number; isSubscribed?: boolean; // `{}` clears them: Stalwart refuses null for these maps. defaultAlertsWithTime?: Record; defaultAlertsWithoutTime?: Record; } // Rename / recolour / describe a calendar (Calendar/set update). export async function updateCalendar( id: string, updates: CalendarUpdates, targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['Calendar/set', { accountId, update: { [id]: updates } }, '0']], USING, ); const result = methodResult<{ notUpdated?: Record }>(res); const err = result.notUpdated?.[id]; if (err) throw new Error(err.description || err.type || 'Failed to update calendar'); } // Add, update, or remove a principal's rights on an owned calendar via a // `shareWith/{principalId}` patch (null removes the share). export async function setCalendarShare( calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; const res = await jmapClient.request( [['Calendar/set', { accountId, update: { [calendarId]: { [`shareWith/${principalId}`]: rights } }, }, '0']], USING, ); const result = methodResult<{ notUpdated?: Record }>(res); const err = result.notUpdated?.[calendarId]; if (err) throw new Error(err.description || err.type || 'Failed to update sharing'); } /** * Remove every event from a calendar. Events that live ONLY in this calendar * are destroyed; events also linked to another calendar are unlinked instead * (their `calendarIds` loses this calendar) so the user's copy elsewhere is * not cascade-deleted. Returns the number of events removed. */ export async function clearCalendarEvents( calendarId: string, targetAccountId?: string, ): Promise { const accountId = targetAccountId || jmapClient.accountId; let removed = 0; // Loop for accounts with more than one query page of objects. for (let pass = 0; pass < 20; pass++) { const ids = await queryEvents([calendarId], '', '', accountId); if (ids.length === 0) break; const batchSize = jmapClient.getMaxObjectsInGet(); const objects: Array<{ id: string; calendarIds?: Record }> = []; for (let i = 0; i < ids.length; i += batchSize) { const res = await jmapClient.request( [['CalendarEvent/get', { accountId, ids: ids.slice(i, i + batchSize), properties: ['id', 'calendarIds'], }, '0']], USING, ); objects.push(...(methodResult<{ list: typeof objects }>(res).list ?? [])); } const toDestroy: string[] = []; const toUnlink: Array<{ id: string; calendarIds: Record }> = []; for (const obj of objects) { const others = { ...(obj.calendarIds || {}) }; delete others[calendarId]; if (Object.keys(others).length === 0) toDestroy.push(obj.id); else toUnlink.push({ id: obj.id, calendarIds: others }); } if (toDestroy.length > 0) { const res = await jmapClient.request( [['CalendarEvent/set', { accountId, destroy: toDestroy }, '0']], USING, ); const result = methodResult<{ destroyed?: string[] }>(res); assertSetResult(result, toDestroy, 'event'); removed += (result.destroyed ?? []).length; } if (toUnlink.length > 0) { const update: Record = {}; for (const { id, calendarIds } of toUnlink) update[id] = { calendarIds }; const res = await jmapClient.request( [['CalendarEvent/set', { accountId, update }, '0']], USING, ); const result = methodResult<{ updated?: Record }>(res); assertSetResult(result, Object.keys(update), 'event'); removed += Object.keys(result.updated ?? {}).length; } if (toDestroy.length === 0 && toUnlink.length === 0) break; if (ids.length < 1000) break; } return removed; }