Files
webmail/lib/plugin-sandbox/host-api-calendar.ts
T

1043 lines
47 KiB
TypeScript

// Host side of api.calendar and api.tasks. Plugins read through the JMAP
// client (a read must not move the calendar the user is looking at) and
// write through the calendar and task stores, so the plugin hooks wired into
// those actions run and the open calendar shows the change.
//
// Plugins describe events in a small shape of their own (CalendarEventInput
// in plugin-types.ts); arbitrary JSCalendar objects are not accepted.
import { addDays, parseISO, subDays } from 'date-fns';
import { createRecurrenceRule } from '@/lib/recurrence-rule';
import type {
CalendarEventInput, CalendarEventView, CalendarView, InstalledPlugin, TaskInput, TaskView,
} from '../plugin-types';
import type {
Calendar, CalendarEvent, CalendarEventAlert, CalendarLink, CalendarParticipant, CalendarRecurrenceRule,
CalendarRelation, CalendarTask, ContactCard,
} from '../jmap/types';
import type { IJMAPClient } from '../jmap/client-interface';
import { useCalendarStore } from '@/stores/calendar-store';
import { useTaskStore } from '@/stores/task-store';
import { useIdentityStore } from '@/stores/identity-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useContactStore } from '@/stores/contact-store';
import {
BIRTHDAY_CALENDAR_ID,
createBirthdayCalendar,
generateBirthdayEvents,
} from '@/lib/birthday-calendar';
import { getEffectiveLocale } from '@/i18n/detect-locale';
import { messagesFor } from './host-api-app';
import { pluginHasPermission } from './permissions';
import { eventToView, taskToView } from '../plugin-projection';
import { expandRecurringEvents } from '../recurrence-expansion';
import { buildParticipantMap } from '../calendar-participants';
import { parseDurationSeconds } from '../calendar-event-normalization';
import { parseAlertOffset, withOwnAlertIds } from '../calendar-alerts';
import { plainDescriptionFields } from '../calendar-description';
import { patchPath } from '../jmap/patch-object';
import { isValidEmail } from '../validation';
import { getEffectiveTimeZone } from '../timezone';
import { generateUUID } from '../utils';
import {
isPlainRecord, readBoolean, readId, readInteger, readObject, readString, sessionClient,
} from './host-api-input';
/** Longest range calendar.getEvents covers in one call. */
export const MAX_PLUGIN_EVENT_RANGE_DAYS = 366;
/** Most events calendar.getEvents returns; the earliest ones win. */
export const MAX_PLUGIN_EVENTS = 1000;
/** Most tasks tasks.list returns. */
export const MAX_PLUGIN_TASKS = 1000;
const MAX_TITLE = 500;
const MAX_DESCRIPTION = 10_000;
const MAX_LOCATION = 500;
const MAX_PARTICIPANTS = 50;
const DAY_MS = 86_400_000;
// ─── Dates and times ──────────────────────────────────────────
interface WhenParts {
/** YYYY-MM-DD */
date: string;
/** HH:MM:SS, or null for a date without a time. */
time: string | null;
/** True when the value ended in Z. */
utc: boolean;
}
const WHEN_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z)?)?$/;
/**
* `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM[:SS]` (a wall-clock time) or the same
* followed by `Z` (UTC). Offsets like `+02:00` are refused: pass a timeZone.
*/
function readWhen(value: unknown, method: string, field: string): WhenParts {
if (typeof value !== 'string') throw new Error(`${method}: ${field} must be a date string`);
const m = WHEN_RE.exec(value);
if (!m) {
throw new Error(`${method}: ${field} must look like 2026-05-01, 2026-05-01T09:30 or 2026-05-01T07:30:00Z`);
}
const [, y, mo, d, h, mi, s, z] = m;
const check = new Date(Date.UTC(+y, +mo - 1, +d, h ? +h : 0, mi ? +mi : 0, s ? +s : 0));
if (
check.getUTCFullYear() !== +y || check.getUTCMonth() !== +mo - 1 || check.getUTCDate() !== +d ||
(h !== undefined && (check.getUTCHours() !== +h || check.getUTCMinutes() !== +mi || check.getUTCSeconds() !== +(s ?? 0)))
) {
throw new Error(`${method}: ${field} is not a real date or time`);
}
return {
date: `${y}-${mo}-${d}`,
time: h === undefined ? null : `${h}:${mi}:${s ?? '00'}`,
utc: z === 'Z',
};
}
/** Wall-clock milliseconds, for differences only (time zones don't matter). */
function wallMs(when: WhenParts): number {
return Date.parse(`${when.date}T${when.time ?? '00:00:00'}Z`);
}
function readTimeZone(value: unknown, method: string): string {
const zone = readString(value, method, 'timeZone', 64, { min: 1 });
try {
new Intl.DateTimeFormat('en-US', { timeZone: zone });
} catch {
throw new Error(`${method}: timeZone "${zone}" is not a known time zone`);
}
return zone;
}
function readDurationSeconds(value: unknown, method: string): number {
if (typeof value !== 'string') throw new Error(`${method}: duration must be an ISO 8601 duration such as PT1H`);
const seconds = parseDurationSeconds(value);
if (seconds === null || seconds <= 0) {
throw new Error(`${method}: duration must be an ISO 8601 duration such as PT1H or P1D`);
}
return seconds;
}
function formatDuration(seconds: number): string {
const days = Math.floor(seconds / 86_400);
let rest = seconds % 86_400;
const hours = Math.floor(rest / 3600);
rest %= 3600;
const minutes = Math.floor(rest / 60);
const secs = rest % 60;
const time = `${hours ? `${hours}H` : ''}${minutes ? `${minutes}M` : ''}${secs ? `${secs}S` : ''}`;
return `P${days ? `${days}D` : ''}${time ? `T${time}` : ''}`;
}
interface Timing {
start: string;
duration: string;
timeZone: string | null;
showWithoutTime: boolean;
}
/** Turn the plugin's start/end/duration/timeZone/isAllDay into JSCalendar fields. */
function buildTiming(
method: string,
input: { start: unknown; end?: unknown; duration?: unknown; timeZone?: unknown; isAllDay?: unknown },
): Timing {
const start = readWhen(input.start, method, 'start');
const isAllDay = input.isAllDay === undefined ? start.time === null : readBoolean(input.isAllDay, method, 'isAllDay');
if (input.end !== undefined && input.duration !== undefined) {
throw new Error(`${method}: pass end or duration, not both`);
}
const maxSeconds = MAX_PLUGIN_EVENT_RANGE_DAYS * 86_400;
if (isAllDay) {
if (input.timeZone !== undefined && input.timeZone !== null) {
throw new Error(`${method}: an all-day event has no timeZone`);
}
let days = 1;
if (input.end !== undefined) {
const end = readWhen(input.end, method, 'end');
days = Math.round((Date.parse(`${end.date}T00:00:00Z`) - Date.parse(`${start.date}T00:00:00Z`)) / DAY_MS);
if (days < 1) throw new Error(`${method}: end must be at least one day after start (end is exclusive)`);
} else if (input.duration !== undefined) {
const seconds = readDurationSeconds(input.duration, method);
if (seconds % 86_400 !== 0) throw new Error(`${method}: an all-day event lasts whole days (P1D, P2D, ...)`);
days = seconds / 86_400;
}
if (days * 86_400 > maxSeconds) throw new Error(`${method}: an event may last at most ${MAX_PLUGIN_EVENT_RANGE_DAYS} days`);
return { start: `${start.date}T00:00:00`, duration: `P${days}D`, timeZone: null, showWithoutTime: true };
}
if (start.time === null) throw new Error(`${method}: start needs a time unless isAllDay is true`);
let timeZone: string;
if (start.utc) {
if (input.timeZone !== undefined && input.timeZone !== null && input.timeZone !== 'Etc/UTC' && input.timeZone !== 'UTC') {
throw new Error(`${method}: start is in UTC (ends in Z), so timeZone must be left out`);
}
timeZone = 'Etc/UTC';
} else {
timeZone = input.timeZone === undefined || input.timeZone === null
? getEffectiveTimeZone()
: readTimeZone(input.timeZone, method);
}
let seconds = 3600;
if (input.end !== undefined) {
const end = readWhen(input.end, method, 'end');
if (end.time === null || end.utc !== start.utc) {
throw new Error(`${method}: end must be written like start (with a time${start.utc ? ', in UTC' : ''})`);
}
seconds = (wallMs(end) - wallMs(start)) / 1000;
if (seconds <= 0) throw new Error(`${method}: end must be after start`);
} else if (input.duration !== undefined) {
seconds = readDurationSeconds(input.duration, method);
}
if (seconds > maxSeconds) throw new Error(`${method}: an event may last at most ${MAX_PLUGIN_EVENT_RANGE_DAYS} days`);
return { start: `${start.date}T${start.time}`, duration: formatDuration(seconds), timeZone, showWithoutTime: false };
}
// ─── Calendars ────────────────────────────────────────────────
/** The store's calendars, loaded first when the calendar was never opened. */
async function ensureCalendars(client: IJMAPClient): Promise<Calendar[]> {
if (useCalendarStore.getState().calendars.length === 0) {
await useCalendarStore.getState().fetchCalendars(client);
}
return useCalendarStore.getState().calendars;
}
function calendarToView(calendar: Calendar): CalendarView {
return {
id: calendar.id,
name: calendar.name,
color: calendar.color || '',
isVisible: calendar.isVisible !== false,
isDefault: !!calendar.isDefault,
isTasksOnly: !!calendar.isTasksOnly,
};
}
async function pickCalendar(
client: IJMAPClient,
method: string,
requested: unknown,
forTasks: boolean,
): Promise<string> {
const calendars = await ensureCalendars(client);
if (requested !== undefined) {
const id = readId(requested, method, 'calendarId');
if (!calendars.some((c) => c.id === id)) throw new Error(`${method}: no calendar with id "${id}"`);
return id;
}
const choice = forTasks
? calendars.find((c) => c.isTasksOnly) ?? calendars.find((c) => c.isDefault) ?? calendars[0]
: calendars.find((c) => c.isDefault && !c.isTasksOnly) ?? calendars.find((c) => !c.isTasksOnly);
if (!choice) throw new Error(`${method}: there is no calendar to put it in`);
return choice.id;
}
async function birthdayCalendarName(): Promise<string> {
try {
const locale = getEffectiveLocale();
const msgs = await messagesFor(locale);
if (typeof msgs?.birthday_calendar === 'string') return msgs.birthday_calendar;
} catch {
// fallback
}
return 'Birthdays';
}
async function ensureContacts(client: IJMAPClient): Promise<ContactCard[]> {
const store = useContactStore.getState();
if (!store.contactsLoaded && store.contacts.length === 0 && client?.supportsContacts?.()) {
try {
await store.fetchContacts(client);
} catch {
// Ignore network errors; proceed with current contacts
}
}
return useContactStore.getState().contacts;
}
export async function doCalendarListCalendars(plugin?: InstalledPlugin): Promise<CalendarView[]> {
const client = sessionClient('calendar.listCalendars');
const calendars = (await ensureCalendars(client)).map(calendarToView);
const settings = useSettingsStore.getState();
const canReadContacts = plugin ? pluginHasPermission(plugin, 'contacts:read') : false;
if (settings.showBirthdayCalendar && canReadContacts) {
const name = await birthdayCalendarName();
calendars.push(calendarToView(createBirthdayCalendar(name, settings.birthdayCalendarColor)));
}
return calendars;
}
// ─── Reading events ───────────────────────────────────────────
/** Bounds of an event query: wall clock without a zone, or UTC with Z. */
function rangeBound(when: WhenParts): string {
return `${when.date}T${when.time ?? '00:00:00'}${when.utc ? 'Z' : ''}`;
}
export async function doCalendarGetEvents(options: unknown, plugin?: InstalledPlugin): Promise<CalendarEventView[]> {
const method = 'calendar.getEvents';
const opts = readObject(options, method, 'options', ['from', 'to', 'calendarIds']);
const from = readWhen(opts.from, method, 'from');
const to = readWhen(opts.to, method, 'to');
if (from.utc !== to.utc) throw new Error(`${method}: from and to must both be in UTC or both without a zone`);
const span = wallMs(to) - wallMs(from);
if (span <= 0) throw new Error(`${method}: to must be after from`);
if (span > MAX_PLUGIN_EVENT_RANGE_DAYS * DAY_MS) {
throw new Error(`${method}: the range may cover at most ${MAX_PLUGIN_EVENT_RANGE_DAYS} days`);
}
let calendarIds: Set<string> | null = null;
if (opts.calendarIds !== undefined) {
if (!Array.isArray(opts.calendarIds) || opts.calendarIds.length > 100) {
throw new Error(`${method}: calendarIds must be an array of at most 100 ids`);
}
calendarIds = new Set(opts.calendarIds.map((id, i) => readId(id, method, `calendarIds[${i}]`)));
}
const client = sessionClient(method);
const start = rangeBound(from);
const end = rangeBound(to);
const raw = await client.queryAllCalendarEvents({ after: start, before: end });
// Same clean-up as the calendar's own range fetch (calendar-store.ts).
const valid = raw.filter((e) => typeof e.start === 'string' && e.start && !Number.isNaN(Date.parse(e.start)));
const events = expandRecurringEvents(valid, start, end)
.filter((e) => !calendarIds || Object.keys(e.calendarIds || {}).some((id) => e.calendarIds[id] && calendarIds.has(id)));
const canReadContacts = plugin ? pluginHasPermission(plugin, 'contacts:read') : false;
const { showBirthdayCalendar } = useSettingsStore.getState();
if (showBirthdayCalendar && canReadContacts && (!calendarIds || calendarIds.has(BIRTHDAY_CALENDAR_ID))) {
const contacts = await ensureContacts(client);
// A birthday lasts its whole day. Keep the ones whose day overlaps the
// range the way the server does for regular events: the day ends after
// `from` and starts before `to`, so `to` itself is excluded.
const rangeStart = parseISO(start);
const rangeEnd = parseISO(end);
const birthdays = generateBirthdayEvents(contacts, subDays(rangeStart, 1).toISOString(), end)
.filter((b) => {
const dayStart = parseISO(b.start);
return dayStart < rangeEnd && addDays(dayStart, 1) > rangeStart;
});
events.push(...birthdays);
}
const sortKey = (e: CalendarEvent) => e.utcStart ?? e.start;
events.sort((a, b) => (sortKey(a) < sortKey(b) ? -1 : sortKey(a) > sortKey(b) ? 1 : 0));
return events.slice(0, MAX_PLUGIN_EVENTS).map(eventToView);
}
/**
* The event as the store addresses it. Events of shared calendars carry
* `<accountId>:<id>` ids (see queryAllCalendarEvents), which is resolved
* here the same way.
*/
async function loadEvent(client: IJMAPClient, id: string): Promise<CalendarEvent | null> {
const inStore = useCalendarStore.getState().events.find((e) => e.id === id);
if (inStore) return inStore;
const own = await client.getCalendarEvent(id);
if (own) return own;
const colon = id.indexOf(':');
if (colon <= 0) return null;
const accountId = id.slice(0, colon);
const rawId = id.slice(colon + 1);
const shared = await client.getCalendarEvent(rawId, accountId);
if (!shared) return null;
return {
...shared,
id,
originalId: rawId,
originalCalendarIds: shared.calendarIds,
calendarIds: Object.fromEntries(
Object.entries(shared.calendarIds || {}).map(([calId, v]) => [`${accountId}:${calId}`, v]),
),
accountId,
isShared: true,
};
}
export async function doCalendarGetEvent(id: unknown, plugin?: InstalledPlugin): Promise<CalendarEventView | null> {
const method = 'calendar.getEvent';
const eventId = readId(id, method);
if (eventId.startsWith('birthday-')) {
const canReadContacts = plugin ? pluginHasPermission(plugin, 'contacts:read') : false;
const { showBirthdayCalendar } = useSettingsStore.getState();
if (!showBirthdayCalendar || !canReadContacts) return null;
const client = sessionClient(method);
const contacts = await ensureContacts(client);
const match = eventId.match(/^birthday-(.+)-(\d{4})$/);
const year = match ? parseInt(match[2], 10) : new Date().getFullYear();
const birthdays = generateBirthdayEvents(contacts, `${year}-01-01T00:00:00`, `${year}-12-31T23:59:59`);
const found = birthdays.find((b) => b.id === eventId);
return found ? eventToView(found) : null;
}
const event = await loadEvent(sessionClient(method), eventId);
return event ? eventToView(event) : null;
}
/**
* Store mutations look the event up in the store's list to route it (shared
* calendars, occurrences) and to tell hooks its title. An event outside the
* range the calendar has loaded is added to that list first.
*/
async function eventForMutation(client: IJMAPClient, method: string, id: string): Promise<CalendarEvent> {
if (id.startsWith('birthday-')) {
throw new Error(`${method}: birthday events cannot be modified`);
}
const event = await loadEvent(client, id);
if (!event) throw new Error(`${method}: no event with id "${id}"`);
if (!useCalendarStore.getState().events.some((e) => e.id === id)) {
useCalendarStore.setState((state) => ({ events: [...state.events, event] }));
}
return event;
}
// ─── Writing events ───────────────────────────────────────────
const EVENT_FIELDS = [
'title', 'description', 'start', 'end', 'duration', 'timeZone', 'isAllDay', 'location', 'calendarId', 'participants', 'repeat',
] as const;
const CREATE_FIELDS = [...EVENT_FIELDS, 'uid'] as const;
const UID_RE = /^[A-Za-z0-9._:-]{1,128}$/;
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const FREQUENCIES = ['daily', 'weekly', 'monthly', 'yearly'] as const;
/**
* The uid an event a plugin created is stored under: the plugin's own uid in
* its own namespace, so it can't collide with events from other apps and the
* plugin finds its events again by uid (CalendarEventView.uid).
*/
export function pluginEventUid(pluginId: string, uid: string): string {
return `${uid}@${pluginId}.plugin.bulwark`;
}
/** `repeat: { frequency, interval?, count?, until? }` → JSCalendar rules; null removes them. */
function recurrenceOf(value: unknown, method: string): CalendarEvent['recurrenceRules'] {
if (value === null) return null;
const r = readObject(value, method, 'repeat', ['frequency', 'interval', 'count', 'until']);
if (!FREQUENCIES.includes(r.frequency as never)) throw new Error(`${method}: repeat.frequency must be one of ${FREQUENCIES.join(', ')}`);
const overrides: Partial<CalendarRecurrenceRule> = {};
if (r.interval !== undefined) {
if (!Number.isInteger(r.interval) || (r.interval as number) < 1 || (r.interval as number) > 99) throw new Error(`${method}: repeat.interval must be 1-99`);
overrides.interval = r.interval as number;
}
if (r.count !== undefined && r.until !== undefined) throw new Error(`${method}: give repeat.count or repeat.until, not both`);
if (r.count !== undefined) {
if (!Number.isInteger(r.count) || (r.count as number) < 1 || (r.count as number) > 1000) throw new Error(`${method}: repeat.count must be 1-1000`);
overrides.count = r.count as number;
}
if (r.until !== undefined) {
if (typeof r.until !== 'string' || !DATE_RE.test(r.until)) throw new Error(`${method}: repeat.until must be YYYY-MM-DD`);
overrides.until = `${r.until}T23:59:59`;
}
return [createRecurrenceRule(r.frequency as CalendarRecurrenceRule['frequency'], overrides)];
}
function locationsOf(location: string): CalendarEvent['locations'] {
if (!location) return null;
return {
loc1: {
'@type': 'Location', name: location, description: null, locationTypes: null,
coordinates: null, timeZone: null, links: null, relativeTo: null,
},
} as CalendarEvent['locations'];
}
/** Attendees as stored on the event, with the user as organizer. No invitations are sent. */
function participantsOf(value: unknown, method: string): Pick<CalendarEvent, 'participants' | 'organizerCalendarAddress'> {
if (!Array.isArray(value)) throw new Error(`${method}: participants must be an array`);
if (value.length > MAX_PARTICIPANTS) throw new Error(`${method}: at most ${MAX_PARTICIPANTS} participants are allowed`);
const attendees = value.map((entry, i) => {
const p = readObject(entry, method, `participants[${i}]`, ['email', 'name']);
const email = readString(p.email, method, `participants[${i}].email`, 254, { min: 3, trim: true });
if (!/^[^\s@<>]+@[^\s@<>]+$/.test(email)) throw new Error(`${method}: participants[${i}].email is not an address`);
const name = p.name === undefined ? '' : readString(p.name, method, `participants[${i}].name`, 200, { trim: true });
return { email, name };
});
if (attendees.length === 0) return { participants: null, organizerCalendarAddress: null };
const { identities, preferredPrimaryId } = useIdentityStore.getState();
const organizer = identities.find((i) => i.id === preferredPrimaryId) ?? identities[0];
if (!organizer) throw new Error(`${method}: the account has no sender address to organise the event from`);
return {
participants: buildParticipantMap(
{ name: organizer.name || '', email: organizer.email },
attendees,
) as Record<string, CalendarParticipant>,
organizerCalendarAddress: `mailto:${organizer.email}`,
};
}
/**
* Create an event. Resolves to the new event, or null when it was not
* created (another plugin's onBeforeEventCreate stopped it, or the server
* refused it). Never sends invitations.
*/
export async function doCalendarCreateEvent(input: unknown, pluginId = 'unknown'): Promise<CalendarEventView | null> {
const method = 'calendar.createEvent';
const data = readObject(input, method, 'event', CREATE_FIELDS) as CalendarEventInput & Record<string, unknown>;
const title = readString(data.title, method, 'title', MAX_TITLE, { min: 1, trim: true });
const timing = buildTiming(method, data);
const client = sessionClient(method);
const calendarId = await pickCalendar(client, method, data.calendarId, false);
let uid = generateUUID();
if (data.uid !== undefined) {
if (typeof data.uid !== 'string' || !UID_RE.test(data.uid)) throw new Error(`${method}: uid must be 1-128 letters, digits or . _ : -`);
uid = pluginEventUid(pluginId, data.uid);
}
const event: Partial<CalendarEvent> = {
uid,
title,
...timing,
calendarIds: { [calendarId]: true },
status: 'confirmed',
freeBusyStatus: 'busy',
privacy: 'public',
};
if (data.description !== undefined) {
const description = readString(data.description, method, 'description', MAX_DESCRIPTION, { trim: true });
if (description) event.description = description;
}
if (data.location !== undefined) {
const location = readString(data.location, method, 'location', MAX_LOCATION, { trim: true });
if (location) event.locations = locationsOf(location);
}
if (data.participants !== undefined) {
const p = participantsOf(data.participants, method);
if (p.participants) Object.assign(event, p);
}
if (data.repeat !== undefined) event.recurrenceRules = recurrenceOf(data.repeat, method);
const created = await useCalendarStore.getState().createEvent(client, event, false);
return created ? eventToView(created) : null;
}
/**
* Change an event. Only the fields in `patch` change; `start`, `end`,
* `duration`, `timeZone` and `isAllDay` are worked out together against the
* event's current times. Never sends updates to participants.
*/
export async function doCalendarUpdateEvent(id: unknown, patch: unknown): Promise<void> {
const method = 'calendar.updateEvent';
const eventId = readId(id, method);
const data = readObject(patch, method, 'patch', EVENT_FIELDS);
if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`);
const updates: Partial<CalendarEvent> = {};
if (data.title !== undefined) updates.title = readString(data.title, method, 'title', MAX_TITLE, { min: 1, trim: true });
if (data.description !== undefined) {
updates.description = readString(data.description, method, 'description', MAX_DESCRIPTION, { trim: true });
}
if (data.location !== undefined) {
updates.locations = locationsOf(readString(data.location, method, 'location', MAX_LOCATION, { trim: true }));
}
if (data.participants !== undefined) Object.assign(updates, participantsOf(data.participants, method));
if (data.repeat !== undefined) updates.recurrenceRules = recurrenceOf(data.repeat, method);
const client = sessionClient(method);
if (data.calendarId !== undefined) {
updates.calendarIds = { [await pickCalendar(client, method, data.calendarId, false)]: true };
}
const existing = await eventForMutation(client, method, eventId);
// A plugin writes plain text, also over an HTML description.
if (updates.description !== undefined) Object.assign(updates, plainDescriptionFields(existing, updates.description));
const timeKeys = ['start', 'end', 'duration', 'timeZone', 'isAllDay'] as const;
if (timeKeys.some((k) => data[k] !== undefined)) {
const wasAllDay = !!existing.showWithoutTime;
const isAllDay = data.isAllDay === undefined ? wasAllDay : data.isAllDay;
const keepLength = data.end === undefined && data.duration === undefined && isAllDay === wasAllDay;
Object.assign(updates, buildTiming(method, {
start: data.start ?? (wasAllDay ? existing.start.slice(0, 10) : existing.start.slice(0, 19)),
end: data.end,
duration: keepLength ? existing.duration : data.duration,
timeZone: data.timeZone !== undefined ? data.timeZone : (isAllDay ? undefined : existing.timeZone ?? undefined),
isAllDay,
}));
}
await useCalendarStore.getState().updateEvent(client, eventId, updates, false);
}
/** Delete an event (one occurrence when the id is an occurrence's). Never sends cancellations. */
export async function doCalendarDeleteEvent(id: unknown): Promise<void> {
const method = 'calendar.deleteEvent';
const eventId = readId(id, method);
const client = sessionClient(method);
await eventForMutation(client, method, eventId);
await useCalendarStore.getState().deleteEvent(client, eventId, false);
}
// ─── Tasks ────────────────────────────────────────────────────
const TASK_FIELDS = [
'title', 'description', 'due', 'priority', 'calendarId', 'alerts', 'links', 'relatedTo',
] as const;
/** Most alerts a plugin may give a task. */
export const MAX_PLUGIN_TASK_ALERTS = 5;
/** Most links a plugin may give a task. */
export const MAX_PLUGIN_TASK_LINKS = 10;
/** Most relations a plugin may give a task. */
export const MAX_PLUGIN_TASK_RELATIONS = 10;
const MAX_HREF = 2048;
const MAX_LINK_TITLE = 500;
const MAX_RELATED_UID = 998;
const MAX_RELATIONS_PER_UID = 4;
const MAX_ALERT_OFFSET_MS = MAX_PLUGIN_EVENT_RANGE_DAYS * DAY_MS;
/** Longest key a plugin may name to remove an entry it did not write. */
const MAX_REMOVED_KEY = 1024;
const MEMBER_ID_RE = /^[A-Za-z0-9._-]{1,64}$/;
const OFFSET_RE = /^-?P(?:\d+W|(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?)$/;
const REL_RE = /^[a-z][a-z0-9.-]{0,63}$/;
const RELATION_RE = /^[a-z][a-z0-9-]{0,31}$/;
// Printable ASCII without spaces; a Message-ID keeps its angle brackets.
const RELATED_UID_RE = /^(?:<[!-;=?-~]+>|[!-;=?-~]+)$/;
const CONTROL_RE = /\p{Cc}/u;
/** The maps a plugin edits on a task, each keyed as JSCalendar keys it. */
const TASK_MAPS = ['alerts', 'links', 'relatedTo'] as const;
type TaskMap = typeof TASK_MAPS[number];
/**
* A map change: `null` for the whole map (removes every entry), else the
* entries named, each a value to set or null to remove. Entries not named
* are left as they are.
*/
type MapChange<T> = null | [key: string, value: T | null][];
/** An instant in UTC, as JSCalendar's UTCDateTime (`2026-05-01T07:30:00Z`). */
function readInstant(value: unknown, method: string, field: string): string {
const when = readWhen(value, method, field);
if (!when.utc || when.time === null) throw new Error(`${method}: ${field} must be a time in UTC, like 2026-05-01T07:30:00Z`);
return `${when.date}T${when.time}Z`;
}
function utcNow(): string {
return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
}
/**
* `{ key: value | null }` → its entries, each value read by `read`. A key
* set must pass `keyOk`; a key removed (null) may be any key the task could
* hold, so a plugin can remove entries another app wrote.
*/
function readMapChange<T>(
value: unknown,
method: string,
field: string,
max: number,
keyOk: (key: string) => boolean,
keyRule: string,
read: (value: unknown, field: string) => T,
): MapChange<T> {
if (value === null) return null;
if (!isPlainRecord(value)) throw new Error(`${method}: ${field} must be an object keyed by id, or null`);
const entries = Object.entries(value);
if (entries.length > max) throw new Error(`${method}: ${field} may name at most ${max} entries`);
return entries.map(([key, entry]) => {
const where = `${field}["${key.slice(0, 64)}"]`;
if (entry === null) {
if (!key || key.length > MAX_REMOVED_KEY || CONTROL_RE.test(key)) throw new Error(`${method}: ${where} is not a key a task holds`);
return [key, null];
}
if (!keyOk(key)) throw new Error(`${method}: ${field} key "${key.slice(0, 64)}" must be ${keyRule}`);
return [key, read(entry, where)];
});
}
function readTrigger(value: unknown, method: string, field: string): CalendarEventAlert['trigger'] {
if (isPlainRecord(value) && Object.hasOwn(value, 'at')) {
const t = readObject(value, method, field, ['at']);
return { '@type': 'AbsoluteTrigger', when: readInstant(t.at, method, `${field}.at`) };
}
const t = readObject(value, method, field, ['offset', 'relativeTo']);
if (typeof t.offset !== 'string' || !OFFSET_RE.test(t.offset) || !/\d/.test(t.offset) || t.offset.endsWith('T')) {
throw new Error(`${method}: ${field}.offset must be an ISO 8601 duration such as -PT15M, or give ${field}.at`);
}
const ms = parseAlertOffset(t.offset);
if (ms === null || Math.abs(ms) > MAX_ALERT_OFFSET_MS) {
throw new Error(`${method}: ${field}.offset may be at most ${MAX_PLUGIN_EVENT_RANGE_DAYS} days`);
}
if (t.relativeTo !== undefined && t.relativeTo !== 'due' && t.relativeTo !== 'start') {
throw new Error(`${method}: ${field}.relativeTo must be due or start`);
}
// JSCalendar names a task's due its "end".
return { '@type': 'OffsetTrigger', offset: t.offset, relativeTo: t.relativeTo === 'start' ? 'start' : 'end' };
}
function alertsOf(value: unknown, method: string): MapChange<CalendarEventAlert> {
return readMapChange(value, method, 'alerts', MAX_PLUGIN_TASK_ALERTS, (k) => MEMBER_ID_RE.test(k),
'1-64 letters, digits or . _ -', (entry, field) => {
const a = readObject(entry, method, field, ['trigger', 'action']);
if (a.action !== undefined && a.action !== 'display' && a.action !== 'email') {
throw new Error(`${method}: ${field}.action must be display or email`);
}
return {
'@type': 'Alert',
trigger: readTrigger(a.trigger, method, `${field}.trigger`),
action: a.action === 'email' ? 'email' : 'display',
acknowledged: null,
relatedTo: null,
};
});
}
/** A link's target: http(s), a `mailto:` address, or a path of this Bulwark made a full URL. */
function readHref(value: unknown, method: string, field: string): string {
const href = readString(value, method, field, MAX_HREF, { min: 1 });
if (/[\s\p{Cc}\\]/u.test(href)) throw new Error(`${method}: ${field} must not contain spaces, control characters or backslashes`);
if (href.startsWith('/') && !href.startsWith('//')) return new URL(href, window.location.origin).href;
let url: URL;
try {
url = new URL(href);
} catch {
throw new Error(`${method}: ${field} must be an http(s) URL, a mailto: address or a path starting with /`);
}
if (url.protocol === 'mailto:') {
let address: string;
try {
address = decodeURIComponent(url.pathname);
} catch {
address = '';
}
if (url.search || url.hash || !isValidEmail(address)) throw new Error(`${method}: ${field} must be mailto: and one address`);
return href;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`${method}: ${field} must be an http(s) URL, a mailto: address or a path starting with /`);
}
if (url.username || url.password) throw new Error(`${method}: ${field} must not carry a user name or password`);
return href;
}
function linksOf(value: unknown, method: string): MapChange<CalendarLink> {
return readMapChange(value, method, 'links', MAX_PLUGIN_TASK_LINKS, (k) => MEMBER_ID_RE.test(k),
'1-64 letters, digits or . _ -', (entry, field) => {
const l = readObject(entry, method, field, ['href', 'rel', 'title']);
if (l.rel !== undefined && (typeof l.rel !== 'string' || !REL_RE.test(l.rel))) {
throw new Error(`${method}: ${field}.rel must be a lower-case link relation such as describedby`);
}
const title = l.title === undefined ? '' : readString(l.title, method, `${field}.title`, MAX_LINK_TITLE, { trim: true });
return {
'@type': 'Link',
href: readHref(l.href, method, `${field}.href`),
cid: null,
contentType: null,
size: null,
rel: (l.rel as string | undefined) ?? null,
display: null,
title: title || null,
};
});
}
function relatedToOf(value: unknown, method: string): MapChange<CalendarRelation> {
return readMapChange(value, method, 'relatedTo', MAX_PLUGIN_TASK_RELATIONS,
(k) => k.length <= MAX_RELATED_UID && RELATED_UID_RE.test(k),
`a Message-ID like <id@example.com> or a UID, at most ${MAX_RELATED_UID} printable characters without spaces`,
(entry, field) => {
const r = readObject(entry, method, field, ['relation']);
let relation: Record<string, boolean> | null = null;
if (r.relation !== undefined) {
if (!Array.isArray(r.relation) || r.relation.length > MAX_RELATIONS_PER_UID) {
throw new Error(`${method}: ${field}.relation must be an array of at most ${MAX_RELATIONS_PER_UID} relation types`);
}
r.relation.forEach((type, j) => {
if (typeof type !== 'string' || !RELATION_RE.test(type)) {
throw new Error(`${method}: ${field}.relation[${j}] must be a lower-case relation type such as parent`);
}
});
if (r.relation.length) relation = Object.fromEntries((r.relation as string[]).map((type) => [type, true]));
}
return { '@type': 'Relation', relation };
});
}
interface TaskMapChanges {
alerts?: MapChange<CalendarEventAlert>;
links?: MapChange<CalendarLink>;
relatedTo?: MapChange<CalendarRelation>;
}
/** The plugin's map changes, read and checked before anything is asked of the server. */
function mapChangesOf(data: Record<string, unknown>, method: string): TaskMapChanges {
const changes: TaskMapChanges = {};
if (data.alerts !== undefined) changes.alerts = alertsOf(data.alerts, method);
if (data.links !== undefined) changes.links = linksOf(data.links, method);
if (data.relatedTo !== undefined) changes.relatedTo = relatedToOf(data.relatedTo, method);
return changes;
}
/** The entries set, as a map (null when none), for a task that has none yet. */
function wholeMap<T>(change: MapChange<T>): Record<string, T> | null {
const set = (change ?? []).filter((entry): entry is [string, T] => entry[1] !== null);
return set.length ? Object.fromEntries(set) : null;
}
/** Map changes on a new task: the entries set. Alerts turn the calendar's defaults off. */
function createMaps(changes: TaskMapChanges): Partial<CalendarTask> {
const task: Partial<CalendarTask> = {};
for (const name of TASK_MAPS) {
const change = changes[name];
if (change !== undefined) Object.assign(task, { [name]: wholeMap(change as MapChange<unknown>) });
}
if (task.alerts) task.useDefaultAlerts = false;
return task;
}
function sameAlert(a: CalendarEventAlert, b: CalendarEventAlert): boolean {
const key = (t: CalendarEventAlert['trigger']) => t['@type'] === 'AbsoluteTrigger'
? `at ${Date.parse(t.when)}`
: `offset ${parseAlertOffset(t.offset)} ${t.relativeTo === 'end' ? 'end' : 'start'}`;
return a.action === b.action && key(a.trigger) === key(b.trigger);
}
function countAfter(held: object | null | undefined, change: [string, unknown][]): number {
const keys = new Set(Object.keys(held ?? {}));
for (const [key, value] of change) {
if (value === null) keys.delete(key);
else keys.add(key);
}
return keys.size;
}
/**
* The patch for a task's maps: each entry named by its JMAP path, so entries
* the plugin did not name, and members changed meanwhile elsewhere, stay as
* the server holds them. A map the task does not have yet is written whole.
* An alert set with the trigger and action it has keeps its `acknowledged`;
* a new trigger (a snooze) makes it fire again.
*/
function updateMaps(method: string, task: CalendarTask, changes: TaskMapChanges): Record<string, unknown> {
const patch: Record<string, unknown> = {};
const limits: Record<TaskMap, number> = {
alerts: MAX_PLUGIN_TASK_ALERTS,
links: MAX_PLUGIN_TASK_LINKS,
relatedTo: MAX_PLUGIN_TASK_RELATIONS,
};
for (const name of TASK_MAPS) {
const change = changes[name] as MapChange<unknown> | undefined;
if (change === undefined) continue;
if (change === null) {
patch[name] = null;
// Else the calendar's defaults would still show and fire.
if (name === 'alerts' && task.useDefaultAlerts) patch.useDefaultAlerts = false;
continue;
}
const held = task[name] as Record<string, unknown> | null | undefined;
const adds = change.some(([key, value]) => value !== null && !(held && Object.hasOwn(held, key)));
if (adds && countAfter(held, change) > limits[name]) {
throw new Error(`${method}: the task would hold more than ${limits[name]} ${name}`);
}
if (name === 'alerts' && task.useDefaultAlerts) {
Object.assign(patch, ownAlerts(method, task, change as [string, CalendarEventAlert | null][]));
continue;
}
if (!held || Object.keys(held).length === 0) {
patch[name] = wholeMap(change);
continue;
}
for (const [key, value] of change) {
if (name === 'alerts' && value !== null && Object.hasOwn(held, key) && sameAlert(held[key] as CalendarEventAlert, value as CalendarEventAlert)) {
continue;
}
patch[patchPath(name, key)] = value;
}
}
return patch;
}
/**
* Alerts of a task that uses its calendar's default alerts. Stalwart shows
* the defaults in `alerts` under the defaults' ids and drops on save every
* alert under one of those ids, so the defaults the plugin keeps become the
* task's own under new ids (as the task form does), and an alert of the
* plugin's under a default's id is refused rather than lost.
*/
function ownAlerts(
method: string,
task: CalendarTask,
change: [string, CalendarEventAlert | null][],
): Partial<CalendarTask> {
const defaults = task.alerts ?? {};
const removed = new Set<string>();
const set: [string, CalendarEventAlert][] = [];
for (const [key, value] of change) {
if (value === null) removed.add(key);
else if (Object.hasOwn(defaults, key)) {
throw new Error(`${method}: alert "${key}" is one of the calendar's default alerts the task uses (useDefaultAlerts); give yours another id`);
} else set.push([key, value]);
}
const kept = Object.entries(defaults).filter(([key]) => !removed.has(key)).map(([, alert]) => alert);
const alerts = { ...withOwnAlertIds(kept, [...Object.keys(defaults), ...set.map(([key]) => key)]), ...Object.fromEntries(set) };
return { useDefaultAlerts: false, alerts: Object.keys(alerts).length ? alerts : null };
}
function isDone(task: CalendarTask): boolean {
return task.progress === 'completed' || task.progress === 'cancelled';
}
export async function doTasksList(options: unknown): Promise<TaskView[]> {
const method = 'tasks.list';
const opts = readObject(options, method, 'options', ['includeCompleted'], true);
const includeCompleted = opts.includeCompleted === undefined
? false
: readBoolean(opts.includeCompleted, method, 'includeCompleted');
const tasks = await sessionClient(method).getCalendarTasks();
return tasks
.filter((t) => includeCompleted || !isDone(t))
.slice(0, MAX_PLUGIN_TASKS)
.map(taskToView);
}
// A plugin's writes to one task, one after another: Stalwart refuses a
// CalendarEvent/set while another is under way on the same object, and each
// write is worked out against the task as the one before left it.
const taskWrites = new Map<string, Promise<unknown>>();
function oneAtATime<T>(taskId: string, write: () => Promise<T>): Promise<T> {
const before = taskWrites.get(taskId) ?? Promise.resolve();
const next = before.catch(() => undefined).then(write);
const settled = next.catch(() => undefined);
taskWrites.set(taskId, settled);
void settled.then(() => {
if (taskWrites.get(taskId) === settled) taskWrites.delete(taskId);
});
return next;
}
async function loadTask(client: IJMAPClient, id: string): Promise<CalendarTask | null> {
const inStore = useTaskStore.getState().tasks.find((t) => t.id === id);
if (inStore) return inStore;
return (await client.getCalendarTasks()).find((t) => t.id === id) ?? null;
}
async function requireTask(client: IJMAPClient, method: string, id: string): Promise<CalendarTask> {
const task = await loadTask(client, id);
if (!task) throw new Error(`${method}: no task with id "${id}"`);
return task;
}
/**
* The task with its alerts, links and relations as the server
* holds them now: the store's copy may be older, and a map the task seems
* not to have is written whole, which would drop what the server gained.
*/
async function withServerMaps(client: IJMAPClient, task: CalendarTask): Promise<CalendarTask> {
const server = await client.getCalendarEvent(task.originalId ?? task.id, task.accountId);
if (!server) return task;
return {
...task,
alerts: server.alerts ?? null,
links: server.links ?? null,
relatedTo: server.relatedTo ?? null,
useDefaultAlerts: server.useDefaultAlerts,
};
}
export async function doTasksGet(id: unknown): Promise<TaskView | null> {
const method = 'tasks.get';
const task = await loadTask(sessionClient(method), readId(id, method));
return task ? taskToView(task) : null;
}
/** `due` as a task stores it: a date (no time) or a wall-clock time. */
function dueOf(value: unknown, method: string): Pick<CalendarTask, 'due' | 'showWithoutTime'> {
if (value === null) return { due: null, showWithoutTime: true };
const when = readWhen(value, method, 'due');
if (when.utc) throw new Error(`${method}: due is a local date or time, without Z`);
return when.time === null
? { due: when.date, showWithoutTime: true }
: { due: `${when.date}T${when.time}`, showWithoutTime: false };
}
/** Resolves to the new task, or null when another plugin's onBeforeTaskCreate stopped it. */
export async function doTasksCreate(input: unknown): Promise<TaskView | null> {
const method = 'tasks.create';
const data = readObject(input, method, 'task', TASK_FIELDS) as TaskInput & Record<string, unknown>;
const client = sessionClient(method);
const task: Partial<CalendarTask> = {
'@type': 'Task',
title: readString(data.title, method, 'title', MAX_TITLE, { min: 1, trim: true }),
description: data.description === undefined
? ''
: readString(data.description, method, 'description', MAX_DESCRIPTION, { trim: true }),
...(data.due === undefined ? { due: null, showWithoutTime: true } : dueOf(data.due, method)),
priority: data.priority === undefined ? 0 : readInteger(data.priority, method, 'priority', 0, 9),
progress: 'needs-action',
...createMaps(mapChangesOf(data, method)),
calendarIds: { [await pickCalendar(client, method, data.calendarId, true)]: true },
};
const created = await useTaskStore.getState().createTask(client, task);
return created ? taskToView(created) : null;
}
export async function doTasksUpdate(id: unknown, patch: unknown): Promise<void> {
const method = 'tasks.update';
const taskId = readId(id, method);
const data = readObject(patch, method, 'patch', TASK_FIELDS);
if (Object.keys(data).length === 0) throw new Error(`${method}: patch is empty`);
const updates: Partial<CalendarTask> = {};
if (data.title !== undefined) updates.title = readString(data.title, method, 'title', MAX_TITLE, { min: 1, trim: true });
if (data.description !== undefined) {
updates.description = readString(data.description, method, 'description', MAX_DESCRIPTION, { trim: true });
}
if (data.due !== undefined) Object.assign(updates, dueOf(data.due, method));
if (data.priority !== undefined) updates.priority = readInteger(data.priority, method, 'priority', 0, 9);
const client = sessionClient(method);
if (data.calendarId !== undefined) {
updates.calendarIds = { [await pickCalendar(client, method, data.calendarId, true)]: true };
}
const maps = mapChangesOf(data, method);
await oneAtATime(taskId, async () => {
let task = await requireTask(client, method, taskId);
if (Object.keys(maps).length) task = await withServerMaps(client, task);
Object.assign(updates, updateMaps(method, task, maps));
if (Object.keys(updates).length === 0) return;
await useTaskStore.getState().updateTask(client, taskId, updates, task);
});
}
/**
* Mark one of a task's alerts dismissed at `when` (UTC; now when left out),
* or not dismissed with null. Only that alert's `acknowledged` is written.
*/
export async function doTasksAcknowledgeAlert(id: unknown, alertId: unknown, when: unknown): Promise<void> {
const method = 'tasks.acknowledgeAlert';
const taskId = readId(id, method);
const key = readId(alertId, method, 'alertId');
const acknowledged = when === undefined ? utcNow() : when === null ? null : readInstant(when, method, 'when');
const client = sessionClient(method);
await oneAtATime(taskId, async () => {
const task = await withServerMaps(client, await requireTask(client, method, taskId));
const alert = task.alerts && Object.hasOwn(task.alerts, key) ? task.alerts[key] : null;
if (!alert) throw new Error(`${method}: the task has no alert "${key}"`);
if (task.useDefaultAlerts) {
throw new Error(`${method}: the task uses its calendar's default alerts (useDefaultAlerts); make them its own with tasks.update(id, { alerts: {} }) first`);
}
if ((alert.acknowledged ?? null) === acknowledged) return;
const patch = { [patchPath('alerts', key, 'acknowledged')]: acknowledged } as Partial<CalendarTask>;
await useTaskStore.getState().updateTask(client, taskId, patch, task);
});
}
export async function doTasksSetComplete(id: unknown, complete: unknown): Promise<void> {
const method = 'tasks.setComplete';
const taskId = readId(id, method);
const done = readBoolean(complete, method, 'complete');
const client = sessionClient(method);
await oneAtATime(taskId, async () => {
const task = await requireTask(client, method, taskId);
if ((task.progress === 'completed') === done) return;
await useTaskStore.getState().toggleTaskComplete(client, task);
});
}
export async function doTasksDelete(id: unknown): Promise<void> {
const method = 'tasks.delete';
const taskId = readId(id, method);
const client = sessionClient(method);
await requireTask(client, method, taskId);
await useTaskStore.getState().deleteTask(client, taskId);
}