diff --git a/lib/__tests__/patch-object.test.ts b/lib/__tests__/patch-object.test.ts new file mode 100644 index 000000000..ece2627d4 --- /dev/null +++ b/lib/__tests__/patch-object.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { applyPatchObject, patchPath, patchedProperties } from '../jmap/patch-object'; + +describe('patch-object', () => { + it('escapes path segments as JSON Pointer does', () => { + expect(patchPath('relatedTo', '')).toBe('relatedTo/'); + }); + + it('applies plain keys and member paths without touching the target', () => { + const target = { title: 'T', alerts: { a: { acknowledged: null, action: 'display' }, b: { acknowledged: null } } }; + const after = applyPatchObject(target, { title: 'U', 'alerts/a/acknowledged': '2026-05-04T07:00:00Z', 'alerts/b': null, 'links/x~1y': { href: 'h' } }); + expect(after).toEqual({ + title: 'U', + alerts: { a: { acknowledged: '2026-05-04T07:00:00Z', action: 'display' } }, + links: { 'x/y': { href: 'h' } }, + }); + expect(target.alerts.b).toEqual({ acknowledged: null }); + }); + + it('keeps __proto__ as a plain key', () => { + const after = applyPatchObject({ alerts: {} }, { 'alerts/__proto__': { polluted: true } }) as { alerts: object }; + expect(Object.getPrototypeOf(after.alerts)).toBe(Object.prototype); + expect(Object.keys(after.alerts)).toEqual(['__proto__']); + expect(({} as Record).polluted).toBeUndefined(); + }); + + it('shows a path patch as whole properties', () => { + expect(patchedProperties({ alerts: { a: { x: 1 }, b: { x: 2 } } }, { 'alerts/a/x': 3 })).toEqual({ alerts: { a: { x: 3 }, b: { x: 2 } } }); + }); +}); diff --git a/lib/__tests__/plugin-host-calendar-tasks.test.ts b/lib/__tests__/plugin-host-calendar-tasks.test.ts index 4d561a38d..7023a6d15 100644 --- a/lib/__tests__/plugin-host-calendar-tasks.test.ts +++ b/lib/__tests__/plugin-host-calendar-tasks.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { InstalledPlugin } from '../plugin-types'; import type { IJMAPClient } from '../jmap/client-interface'; -import type { Calendar, CalendarEvent, CalendarTask } from '../jmap/types'; +import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarTask } from '../jmap/types'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useTaskStore } from '@/stores/task-store'; @@ -243,3 +243,242 @@ describe('api.tasks', () => { await expect(call(WRITE, 'tasks.delete', 'nope')).rejects.toThrow(/no task with id "nope"/); }); }); + +describe('api.tasks alerts', () => { + const at = (when: string, over: Partial = {}): CalendarEventAlert => ({ + '@type': 'Alert', trigger: { '@type': 'AbsoluteTrigger', when }, action: 'display', acknowledged: null, relatedTo: null, ...over, + }); + const updates = () => client.updateCalendarTask.mock.calls.map((c) => c[1] as Record); + + it('creates a task with alerts, turning the calendar\'s defaults off', async () => { + await call(WRITE, 'tasks.create', { + title: 'Follow up: Offer', + due: '2026-05-04', + alerts: { + 'follow-up': { trigger: { at: '2026-05-04T07:00:00Z' } }, + 'follow-up-mail': { trigger: { offset: '-PT1H' }, action: 'email' }, + }, + }); + expect(client.createCalendarTask).toHaveBeenCalledWith(expect.objectContaining({ + due: '2026-05-04', showWithoutTime: true, useDefaultAlerts: false, + alerts: { + 'follow-up': at('2026-05-04T07:00:00Z'), + 'follow-up-mail': { + '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT1H', relativeTo: 'end' }, + action: 'email', acknowledged: null, relatedTo: null, + }, + }, + })); + }); + + it('returns them, each alert with whether it was acknowledged, and whether the defaults are in use', async () => { + client.getCalendarTasks.mockResolvedValue([task({ + alerts: { + a: at('2026-05-04T07:00:00Z', { acknowledged: '2026-05-04T07:01:00Z' }), + b: { '@type': 'Alert', trigger: { '@type': 'OffsetTrigger', offset: '-PT15M', relativeTo: 'start' }, action: 'email', acknowledged: null, relatedTo: null }, + }, + })]); + expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({ + alerts: { + a: { trigger: { at: '2026-05-04T07:00:00Z' }, action: 'display', acknowledged: '2026-05-04T07:01:00Z' }, + b: { trigger: { offset: '-PT15M', relativeTo: 'start' }, action: 'email', acknowledged: null }, + }, + useDefaultAlerts: false, + })); + const [listed] = await call(READ, 'tasks.list') as { alerts: object }[]; + expect(Object.keys(listed.alerts)).toEqual(['a', 'b']); + }); + + it('refuses bad triggers, ids and too many alerts', async () => { + const create = (over: Record) => call(WRITE, 'tasks.create', { title: 'x', ...over }); + await expect(create({ alerts: [{ id: 'a', trigger: { offset: 'PT0S' } }] })).rejects.toThrow(/object keyed by id/); + const six = Object.fromEntries([0, 1, 2, 3, 4, 5].map((i) => [`a${i}`, { trigger: { offset: 'PT0S' } }])); + await expect(create({ alerts: six })).rejects.toThrow(/at most 5 entries/); + await expect(create({ alerts: { a: { trigger: { offset: 'P400D' } } } })).rejects.toThrow(/at most 366 days/); + await expect(create({ alerts: { a: { trigger: { offset: 'P' } } } })).rejects.toThrow(/ISO 8601 duration/); + await expect(create({ alerts: { a: { trigger: { at: '2026-05-04T09:00' } } } })).rejects.toThrow(/in UTC/); + await expect(create({ alerts: { a: { trigger: { offset: 'PT0S' }, action: 'audio' } } })).rejects.toThrow(/display or email/); + await expect(create({ alerts: { 'a b': { trigger: { offset: 'PT0S' } } } })).rejects.toThrow(/letters, digits/); + expect(client.createCalendarTask).not.toHaveBeenCalled(); + }); + + it('merges by id with patch paths, leaving alerts it did not name and other fields alone', async () => { + useTaskStore.setState({ tasks: [task({ + title: 'Kept', + alerts: { foreign: at('2026-05-04T06:00:00Z', { action: 'email' }), old: at('2026-05-04T05:00:00Z') }, + })] }); + await call(WRITE, 'tasks.update', 't1', { + alerts: { 'follow-up': { trigger: { at: '2026-05-04T07:00:00Z' } }, old: null }, + }); + expect(updates()[0]).toEqual({ + 'alerts/follow-up': at('2026-05-04T07:00:00Z'), + 'alerts/old': null, + }); + const held = useTaskStore.getState().tasks[0]; + expect(held.title).toBe('Kept'); + expect(Object.keys(held.alerts ?? {})).toEqual(['foreign', 'follow-up']); + + await call(WRITE, 'tasks.update', 't1', { alerts: null }); + expect(updates()[1]).toEqual({ alerts: null }); + }); + + it('writes the alerts whole on a task that has none yet', async () => { + useTaskStore.setState({ tasks: [task({ alerts: null })] }); + await call(WRITE, 'tasks.update', 't1', { alerts: { a: { trigger: { at: '2026-05-04T07:00:00Z' } } } }); + expect(updates()[0]).toEqual({ alerts: { a: at('2026-05-04T07:00:00Z') } }); + }); + + it('keeps an unchanged alert acknowledged and lets a snoozed one fire again', async () => { + useTaskStore.setState({ tasks: [task({ + alerts: { + a: at('2026-05-04T07:00:00Z', { acknowledged: '2026-05-04T07:01:00Z' }), + b: at('2026-05-04T07:00:00Z', { acknowledged: '2026-05-04T07:01:00Z' }), + }, + })] }); + await call(WRITE, 'tasks.update', 't1', { alerts: { + a: { trigger: { at: '2026-05-04T07:00:00Z' } }, + b: { trigger: { at: '2026-05-04T08:00:00Z' } }, + } }); + expect(updates()[0]).toEqual({ 'alerts/b': at('2026-05-04T08:00:00Z') }); + const held = useTaskStore.getState().tasks[0]; + expect(held.alerts?.a.acknowledged).toBe('2026-05-04T07:01:00Z'); + expect(held.alerts?.b.acknowledged).toBeNull(); + }); + + it('refuses a sixth alert counting those another app wrote', async () => { + useTaskStore.setState({ tasks: [task({ + alerts: Object.fromEntries([1, 2, 3, 4, 5].map((i) => [`x${i}`, at('2026-05-04T07:00:00Z')])), + })] }); + await expect(call(WRITE, 'tasks.update', 't1', { alerts: { new: { trigger: { offset: 'PT0S' } } } })).rejects.toThrow(/more than 5 alerts/); + await call(WRITE, 'tasks.update', 't1', { alerts: { x1: null, new: { trigger: { offset: 'PT0S' } } } }); + expect(updates()[0]).toEqual({ 'alerts/x1': null, 'alerts/new': expect.objectContaining({ action: 'display' }) }); + }); + + it('makes the calendar\'s default alerts the task\'s own under new ids, refusing a plugin alert under a default\'s id', async () => { + useTaskStore.setState({ tasks: [task({ + useDefaultAlerts: true, + alerts: { d1: at('2026-05-04T06:00:00Z'), d2: at('2026-05-04T06:30:00Z') }, + })] }); + expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({ useDefaultAlerts: true })); + await expect(call(WRITE, 'tasks.update', 't1', { alerts: { d1: { trigger: { offset: 'PT0S' } } } })).rejects.toThrow(/default alerts.*another id/); + await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'd1')).rejects.toThrow(/useDefaultAlerts/); + expect(client.updateCalendarTask).not.toHaveBeenCalled(); + + await call(WRITE, 'tasks.update', 't1', { alerts: { d2: null, mine: { trigger: { at: '2026-05-04T07:00:00Z' } } } }); + expect(updates()[0]).toEqual({ + useDefaultAlerts: false, + alerts: { alert1: at('2026-05-04T06:00:00Z'), mine: at('2026-05-04T07:00:00Z') }, + }); + useTaskStore.setState({ tasks: [task({ useDefaultAlerts: true, alerts: { d1: at('2026-05-04T06:00:00Z') } })] }); + await call(WRITE, 'tasks.update', 't1', { alerts: {} }); + expect(updates()[1]).toEqual({ useDefaultAlerts: false, alerts: { alert1: at('2026-05-04T06:00:00Z') } }); + }); + + it('takes ids such as __proto__ and constructor as plain keys', async () => { + await call(WRITE, 'tasks.create', { + title: 'x', + alerts: JSON.parse('{"__proto__": {"trigger": {"offset": "PT0S"}}, "constructor": {"trigger": {"offset": "PT0S"}}}'), + }); + const created = client.createCalendarTask.mock.calls[0][0] as CalendarTask; + expect(Object.getPrototypeOf(created.alerts)).toBe(Object.prototype); + expect(Object.keys(created.alerts ?? {})).toEqual(['__proto__', 'constructor']); + + useTaskStore.setState({ tasks: [task({ alerts: { a: at('2026-05-04T07:00:00Z') } })] }); + await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'constructor')).rejects.toThrow(/no alert "constructor"/); + await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', '__proto__')).rejects.toThrow(/no alert "__proto__"/); + await call(WRITE, 'tasks.update', 't1', { alerts: JSON.parse('{"__proto__": {"trigger": {"offset": "PT0S"}}}') }); + expect(updates()[0]).toEqual(JSON.parse('{"alerts/__proto__": {"@type": "Alert", "trigger": {"@type": "OffsetTrigger", "offset": "PT0S", "relativeTo": "end"}, "action": "display", "acknowledged": null, "relatedTo": null}}')); + const held = useTaskStore.getState().tasks[0]; + expect(Object.getPrototypeOf(held.alerts)).toBe(Object.prototype); + expect(Object.keys(held.alerts ?? {})).toEqual(['a', '__proto__']); + }); + + it('acknowledges an alert by its path, now or at a given time, and undoes it', async () => { + vi.useFakeTimers({ now: new Date('2026-05-04T07:02:03.456Z'), toFake: ['Date'] }); + try { + useTaskStore.setState({ tasks: [task({ alerts: { a: at('2026-05-04T07:00:00Z'), b: at('2026-05-04T07:00:00Z', { action: 'email' }) } })] }); + await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a'); + expect(client.updateCalendarTask).toHaveBeenLastCalledWith('t1', { 'alerts/a/acknowledged': '2026-05-04T07:02:03Z' }); + expect(useTaskStore.getState().tasks[0].alerts?.a.acknowledged).toBe('2026-05-04T07:02:03Z'); + await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'b', '2026-05-04T06:00:00Z'); + expect(useTaskStore.getState().tasks[0].alerts?.b.acknowledged).toBe('2026-05-04T06:00:00Z'); + await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', null); + expect(client.updateCalendarTask).toHaveBeenLastCalledWith('t1', { 'alerts/a/acknowledged': null }); + expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({ + alerts: expect.objectContaining({ a: expect.objectContaining({ acknowledged: null }) }), + })); + + client.updateCalendarTask.mockClear(); + await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', null); + expect(client.updateCalendarTask).not.toHaveBeenCalled(); + await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'zz')).rejects.toThrow(/no alert "zz"/); + await expect(call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', '2026-05-04T06:00')).rejects.toThrow(/in UTC/); + await expect(call(READ, 'tasks.acknowledgeAlert', 't1', 'a')).rejects.toThrow(/lacks permission "tasks:write"/); + } finally { + vi.useRealTimers(); + } + }); + + it('acknowledges two alerts at once one after the other, neither undoing the other', async () => { + useTaskStore.setState({ tasks: [task({ alerts: { a: at('2026-05-04T07:00:00Z'), b: at('2026-05-04T07:00:00Z') } })] }); + let inFlight = 0; + let most = 0; + client.updateCalendarTask.mockImplementation(async (id: string) => { + most = Math.max(most, ++inFlight); + await new Promise((resolve) => setTimeout(resolve, 5)); + inFlight -= 1; + return id; + }); + await Promise.all([ + call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', '2026-05-04T07:01:00Z'), + call(WRITE, 'tasks.acknowledgeAlert', 't1', 'b', '2026-05-04T07:02:00Z'), + ]); + // Stalwart refuses a second set on an object while one is under way. + expect(most).toBe(1); + expect(updates()).toEqual([ + { 'alerts/a/acknowledged': '2026-05-04T07:01:00Z' }, + { 'alerts/b/acknowledged': '2026-05-04T07:02:00Z' }, + ]); + const alerts = useTaskStore.getState().tasks[0].alerts; + expect([alerts?.a.acknowledged, alerts?.b.acknowledged]).toEqual(['2026-05-04T07:01:00Z', '2026-05-04T07:02:00Z']); + }); + + it('acknowledges an alert of a task the list does not hold, emitting the whole task', async () => { + const full = task({ title: 'From the server', alerts: { a: at('2026-05-04T07:00:00Z'), b: at('2026-05-04T08:00:00Z') } }); + client.getCalendarTasks.mockResolvedValue([full]); + const seen: { patch: Record }[] = []; + const emitted: { title: string; alerts: Record }[] = []; + taskHooks.onBeforeTaskUpdate.register('watch', (e: unknown) => { seen.push(e as { patch: Record }); return true; }); + taskHooks.onTaskUpdate.register('watch', (v: unknown) => { emitted.push(v as never); }); + try { + await call(WRITE, 'tasks.acknowledgeAlert', 't1', 'a', '2026-05-04T07:01:00Z'); + expect(updates()).toEqual([{ 'alerts/a/acknowledged': '2026-05-04T07:01:00Z' }]); + expect(Object.keys(seen[0].patch.alerts as object)).toEqual(['a', 'b']); + await vi.waitFor(() => expect(emitted).toHaveLength(1)); + expect(emitted[0].title).toBe('From the server'); + expect(emitted[0].alerts.a.acknowledged).toBe('2026-05-04T07:01:00Z'); + expect(emitted[0].alerts.b.acknowledged).toBeNull(); + } finally { + taskHooks.onBeforeTaskUpdate.removePlugin('watch'); + taskHooks.onTaskUpdate.removePlugin('watch'); + } + }); + + it('decides on the alerts as the server holds them, not an older copy in the list', async () => { + useTaskStore.setState({ tasks: [task({ alerts: null })] }); + client.getCalendarEvent.mockImplementation(async (id: string) => (id === 't1' + ? { id: 't1', alerts: { other: at('2026-05-04T06:00:00Z', { action: 'email' }) } } + : null)); + await call(WRITE, 'tasks.update', 't1', { alerts: { mine: { trigger: { at: '2026-05-04T07:00:00Z' } } } }); + expect(updates()[0]).toEqual({ 'alerts/mine': at('2026-05-04T07:00:00Z') }); + const held = useTaskStore.getState().tasks[0]; + expect(Object.keys(held.alerts ?? {})).toEqual(['other', 'mine']); + expect(client.getCalendarEvent).toHaveBeenCalledWith('t1', undefined); + }); + + it('turns the calendar\'s default alerts off when every alert is removed', async () => { + useTaskStore.setState({ tasks: [task({ useDefaultAlerts: true, alerts: { d1: at('2026-05-04T06:00:00Z') } })] }); + await call(WRITE, 'tasks.update', 't1', { alerts: null }); + expect(updates()[0]).toEqual({ alerts: null, useDefaultAlerts: false }); + }); +}); diff --git a/lib/__tests__/plugin-permission-coverage.test.ts b/lib/__tests__/plugin-permission-coverage.test.ts index c34925f2f..1ac16d526 100644 --- a/lib/__tests__/plugin-permission-coverage.test.ts +++ b/lib/__tests__/plugin-permission-coverage.test.ts @@ -67,6 +67,7 @@ describe('plugin permissions', () => { 'calendar.createEvent': 'calendar:write', 'tasks.list': 'tasks:read', 'tasks.setComplete': 'tasks:write', + 'tasks.acknowledgeAlert': 'tasks:write', 'templates.list': 'templates:read', 'templates.create': 'templates:write', 'identities.update': 'identity:write', diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 2d7adf72b..d5314bbb8 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -30,6 +30,7 @@ import { buildShareSetCalls, failAll, parseShareTargets, readShareSetResults, SE import type { ShareKind } from "./share-rights"; import { ARCHIVED_ITEM_USING, STALWART_CAPABILITY, buildRestoreCalls, hasArchivedEmail, listArchivedEmailsPaged, readRestoreResults, type ArchiveIo, type ArchiveListResult, type ArchiveQueryPage, type RestoreResult } from "@/lib/recovery/archived-items"; import { RescheduleRestoredError } from "./reschedule-error"; +import { applyPatchObject } from "./patch-object"; export { SchedulingDeniedError, RescheduleRestoredError }; import { acceptedFileName, fileNameRulesFrom, type FileNameRules } from "@/lib/file-name-rules"; @@ -9933,7 +9934,8 @@ export class JMAPClient implements IJMAPClient { // Drop what the server sets itself; keep the uid so it stays the same task. const { id: _id, created: _cr, updated: _up, sequence: _sq, isDraft: _idr, isOrigin: _io, baseEventId: _be, blobId: _bl, utcStart: _us, utcEnd: _ue, ...rest } = original as Record; - const created = await this.createCalendarTask({ ...rest, ...patch } as Partial, destAccountId); + // The patch may name members by path ("alerts//acknowledged"). + const created = await this.createCalendarTask(applyPatchObject(rest, patch) as Partial, destAccountId); await this.deleteCalendarEvent(source.id, false, source.accountId); return created.id; } diff --git a/lib/jmap/patch-object.ts b/lib/jmap/patch-object.ts new file mode 100644 index 000000000..20c9b0dae --- /dev/null +++ b/lib/jmap/patch-object.ts @@ -0,0 +1,70 @@ +/** + * JMAP PatchObject paths (RFC 8620 §5.3): a key such as + * "alerts/a1/acknowledged" changes that one member and leaves the rest of the + * object as the server holds it, so a change made meanwhile elsewhere (another + * device, another plugin call) is not overwritten. + */ + +/** The JSON Pointer path of nested members, each segment escaped (RFC 6901). */ +export function patchPath(...segments: string[]): string { + return segments.map((s) => s.replace(/~/g, '~0').replace(/\//g, '~1')).join('/'); +} + +function unescapeSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +/** A shallow copy that keeps every own key, `__proto__` included, as data. */ +function copyOf(object: object | null | undefined): Record { + return Object.fromEntries(Object.entries(object ?? {})); +} + +/** Sets `key` as an own data property, never the prototype. */ +function put(object: Record, key: string, value: unknown): void { + Object.defineProperty(object, key, { value, enumerable: true, configurable: true, writable: true }); +} + +/** + * The object as the server holds it after `patch`: plain keys replace the + * property, path keys change the member they name (null removes a map + * member). Nothing is changed in place. + */ +export function applyPatchObject(target: T, patch: object): T { + let result = copyOf(target); + for (const [key, value] of Object.entries(patch)) { + if (!key.includes('/')) { + put(result, key, value); + continue; + } + result = setIn(result, key.split('/').map(unescapeSegment), value); + } + return result as T; +} + +function setIn(object: Record, [head, ...rest]: string[], value: unknown): Record { + const copy = copyOf(object); + if (rest.length === 0) { + if (value === null) delete copy[head]; + else put(copy, head, value); + return copy; + } + const child = Object.hasOwn(copy, head) ? copy[head] : undefined; + put(copy, head, setIn(child && typeof child === 'object' ? (child as Record) : {}, rest, value)); + return copy; +} + +/** + * The top-level properties `patch` changes, each whole as it is after the + * patch: a path key ("alerts/a1/acknowledged") becomes its property + * ("alerts") with every member as the patch leaves it. For whoever reads a + * patch as plain properties (plugin hooks), while the server gets the paths. + */ +export function patchedProperties(target: T, patch: object): Partial { + const after = applyPatchObject(target, patch) as Record; + const changed: Record = {}; + for (const key of Object.keys(patch)) { + const property = unescapeSegment(key.split('/')[0]); + put(changed, property, Object.hasOwn(after, property) ? after[property] : null); + } + return changed as Partial; +} diff --git a/lib/plugin-projection.ts b/lib/plugin-projection.ts index c950b4d02..c8cbf838b 100644 --- a/lib/plugin-projection.ts +++ b/lib/plugin-projection.ts @@ -175,6 +175,14 @@ export function taskToView(task: CalendarTask): TaskView { priority: task.priority ?? 0, calendarId: trueKeys(task.calendarIds)[0] || '', keywords: trueKeys(task.keywords), + alerts: Object.fromEntries(Object.entries(task.alerts || {}).filter(([, alert]) => alert?.trigger).map(([id, alert]) => [id, { + trigger: alert.trigger['@type'] === 'AbsoluteTrigger' + ? { at: alert.trigger.when } + : { offset: alert.trigger.offset, relativeTo: alert.trigger.relativeTo === 'end' ? 'due' as const : 'start' as const }, + action: alert.action === 'email' ? 'email' as const : 'display' as const, + acknowledged: alert.acknowledged || null, + }])), + useDefaultAlerts: !!task.useDefaultAlerts, }; } diff --git a/lib/plugin-sandbox/host-api-calendar.ts b/lib/plugin-sandbox/host-api-calendar.ts index 4c6a36be2..c3574975a 100644 --- a/lib/plugin-sandbox/host-api-calendar.ts +++ b/lib/plugin-sandbox/host-api-calendar.ts @@ -10,7 +10,9 @@ import { createRecurrenceRule } from '@/lib/recurrence-rule'; import type { CalendarEventInput, CalendarEventView, CalendarView, TaskInput, TaskView, } from '../plugin-types'; -import type { Calendar, CalendarEvent, CalendarParticipant, CalendarRecurrenceRule, CalendarTask } from '../jmap/types'; +import type { + Calendar, CalendarEvent, CalendarEventAlert, CalendarParticipant, CalendarRecurrenceRule, CalendarTask, +} from '../jmap/types'; import type { IJMAPClient } from '../jmap/client-interface'; import { useCalendarStore } from '@/stores/calendar-store'; import { useTaskStore } from '@/stores/task-store'; @@ -19,10 +21,12 @@ 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 { patchPath } from '../jmap/patch-object'; import { getEffectiveTimeZone } from '../timezone'; import { generateUUID } from '../utils'; import { - readBoolean, readId, readInteger, readObject, readString, sessionClient, + isPlainRecord, readBoolean, readId, readInteger, readObject, readString, sessionClient, } from './host-api-input'; /** Longest range calendar.getEvents covers in one call. */ @@ -486,7 +490,218 @@ export async function doCalendarDeleteEvent(id: unknown): Promise { // ─── Tasks ──────────────────────────────────────────────────── -const TASK_FIELDS = ['title', 'description', 'due', 'priority', 'calendarId'] as const; +const TASK_FIELDS = ['title', 'description', 'due', 'priority', 'calendarId', 'alerts'] as const; +/** Most alerts a plugin may give a task. */ +export const MAX_PLUGIN_TASK_ALERTS = 5; +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 CONTROL_RE = /\p{Cc}/u; + +/** The maps a plugin edits on a task, each keyed as JSCalendar keys it. */ +const TASK_MAPS = ['alerts'] 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 = 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( + value: unknown, + method: string, + field: string, + max: number, + keyOk: (key: string) => boolean, + keyRule: string, + read: (value: unknown, field: string) => T, +): MapChange { + 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 { + 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, + }; + }); +} + +interface TaskMapChanges { + alerts?: MapChange; +} + +/** The plugin's map changes, read and checked before anything is asked of the server. */ +function mapChangesOf(data: Record, method: string): TaskMapChanges { + const changes: TaskMapChanges = {}; + if (data.alerts !== undefined) changes.alerts = alertsOf(data.alerts, method); + return changes; +} + +/** The entries set, as a map (null when none), for a task that has none yet. */ +function wholeMap(change: MapChange): Record | 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 { + const task: Partial = {}; + for (const name of TASK_MAPS) { + const change = changes[name]; + if (change !== undefined) Object.assign(task, { [name]: wholeMap(change as MapChange) }); + } + 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 { + const patch: Record = {}; + const limits: Record = { + alerts: MAX_PLUGIN_TASK_ALERTS, + }; + for (const name of TASK_MAPS) { + const change = changes[name] as MapChange | 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 | 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 { + const defaults = task.alerts ?? {}; + const removed = new Set(); + 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'; @@ -505,6 +720,22 @@ export async function doTasksList(options: unknown): Promise { .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>(); + +function oneAtATime(taskId: string, write: () => Promise): Promise { + 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 { const inStore = useTaskStore.getState().tasks.find((t) => t.id === id); if (inStore) return inStore; @@ -517,6 +748,20 @@ async function requireTask(client: IJMAPClient, method: string, id: string): Pro return task; } +/** + * The task with its alerts 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 { + const server = await client.getCalendarEvent(task.originalId ?? task.id, task.accountId); + if (!server) return task; + return { + ...task, + alerts: server.alerts ?? null, + useDefaultAlerts: server.useDefaultAlerts, + }; +} + export async function doTasksGet(id: unknown): Promise { const method = 'tasks.get'; const task = await loadTask(sessionClient(method), readId(id, method)); @@ -547,6 +792,7 @@ export async function doTasksCreate(input: unknown): Promise { ...(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); @@ -569,8 +815,37 @@ export async function doTasksUpdate(id: unknown, patch: unknown): Promise if (data.calendarId !== undefined) { updates.calendarIds = { [await pickCalendar(client, method, data.calendarId, true)]: true }; } - await requireTask(client, method, taskId); - await useTaskStore.getState().updateTask(client, taskId, updates); + 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 { + 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; + await useTaskStore.getState().updateTask(client, taskId, patch, task); + }); } export async function doTasksSetComplete(id: unknown, complete: unknown): Promise { @@ -578,9 +853,11 @@ export async function doTasksSetComplete(id: unknown, complete: unknown): Promis const taskId = readId(id, method); const done = readBoolean(complete, method, 'complete'); const client = sessionClient(method); - const task = await requireTask(client, method, taskId); - if ((task.progress === 'completed') === done) return; - await useTaskStore.getState().toggleTaskComplete(client, task); + 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 { diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 7c611e4b7..cefeb157c 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -51,7 +51,7 @@ import { EncryptionAtRestConfig, PublicKeyInfo, PublicKeyInput, useAccountSecuri import { doCalendarCreateEvent, doCalendarDeleteEvent, doCalendarGetEvent, doCalendarGetEvents, doCalendarListCalendars, doCalendarUpdateEvent, doTasksCreate, doTasksDelete, doTasksGet, - doTasksList, doTasksSetComplete, doTasksUpdate, + doTasksAcknowledgeAlert, doTasksList, doTasksSetComplete, doTasksUpdate, } from './host-api-calendar'; import { doIdentitiesUpdate, doTemplatesCreate, doTemplatesDelete, doTemplatesGet, doTemplatesList, @@ -277,6 +277,7 @@ export const PERM_PER_METHOD: Readonly> = { 'tasks.create': 'tasks:write', 'tasks.update': 'tasks:write', 'tasks.setComplete': 'tasks:write', + 'tasks.acknowledgeAlert': 'tasks:write', 'tasks.delete': 'tasks:write', // message templates 'templates.list': 'templates:read', @@ -2336,6 +2337,7 @@ export async function dispatchApiCall( case 'tasks.create': return doTasksCreate(args[0]); case 'tasks.update': return doTasksUpdate(args[0], args[1]); case 'tasks.setComplete': return doTasksSetComplete(args[0], args[1]); + case 'tasks.acknowledgeAlert': return doTasksAcknowledgeAlert(args[0], args[1], args[2]); case 'tasks.delete': return doTasksDelete(args[0]); case 'templates.list': return doTemplatesList(); diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index eba4da5a2..a39b8c79e 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -996,10 +996,22 @@ function buildPluginApi(manifest: PluginManifest) { get: (id: string) => callApi('tasks.get', [id]) as Promise, /** Resolves to the new task, or null when another plugin stopped it. */ create: (task: TaskInput) => callApi('tasks.create', [task]) as Promise, - /** Change the given fields. */ + /** + * Change the given fields. `alerts` merges by id: an alert given is + * set, one given null is removed, the rest stay as they are (each + * written by its own JMAP patch path); null for the whole map clears it. + */ update: (id: string, patch: Partial) => callApi('tasks.update', [id, patch]) as Promise, /** Mark a task done or not done. */ setComplete: (id: string, complete: boolean) => callApi('tasks.setComplete', [id, complete]) as Promise, + /** + * Dismiss one of a task's alerts at `when` (UTC, `2026-05-01T07:30:00Z`; + * now when left out), or undo that with null. Bulwark shows no reminder + * for a dismissed alert. Refused while the task uses its calendar's + * default alerts (TaskView.useDefaultAlerts). + */ + acknowledgeAlert: (id: string, alertId: string, when?: string | null) => + callApi('tasks.acknowledgeAlert', when === undefined ? [id, alertId] : [id, alertId, when]) as Promise, delete: (id: string) => callApi('tasks.delete', [id]) as Promise, }, // Message templates (permissions: templates:read / templates:write). diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 7ed094b70..506fb6e1d 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -586,6 +586,33 @@ export interface TaskInput { priority?: number; /** Defaults to the tasks calendar, else the default calendar. */ calendarId?: string; + /** + * The task's reminders by id (1-64 of `A-Z a-z 0-9 . _ -`); a task holds + * at most 5. On update only the ids named change: an alert given is set + * (one kept with the same trigger and action stays acknowledged; a new + * trigger, a snooze, makes it fire again), an id given null is removed, + * and alerts not named are left as they are. null for the whole map + * removes every alert. While the task uses its calendar's default alerts + * (TaskView.useDefaultAlerts), any change makes the defaults the task's + * own under new ids; `{}` does only that. + */ + alerts?: Record | null; +} + +/** + * When a task's alert fires: an ISO 8601 duration from the task's due + * (`-PT15M` before, `PT0S` at it; `relativeTo: 'start'` counts from its + * start, else from its due), at most a year either way; or an instant in + * UTC (`2026-05-01T07:30:00Z`). + */ +export type TaskAlertTrigger = + | { offset: string; relativeTo?: 'due' | 'start' } + | { at: string }; + +export interface TaskAlertInput { + trigger: TaskAlertTrigger; + /** `display` (the default) shows a reminder; `email` has the server send one. */ + action?: 'display' | 'email'; } export interface ContactView { @@ -651,6 +678,22 @@ export interface TaskView { priority: number; calendarId: string; keywords: string[]; + /** By id; as written by any app, not only by plugins. */ + alerts: Record; + /** + * True while the task uses its calendar's default alerts: `alerts` then + * shows those defaults, which can't be acknowledged until an update of + * `alerts` makes them the task's own (under new ids). + */ + useDefaultAlerts: boolean; +} + +export interface TaskAlertView { + /** As in TaskAlertInput; `relativeTo` is always given for an offset. */ + trigger: { offset: string; relativeTo: 'due' | 'start' } | { at: string }; + action: 'display' | 'email'; + /** When the alert was dismissed (UTC), or null while it is not. */ + acknowledged: string | null; } export interface TemplateView { diff --git a/stores/task-store.ts b/stores/task-store.ts index e5caa40c4..478c6e1a1 100644 --- a/stores/task-store.ts +++ b/stores/task-store.ts @@ -4,6 +4,7 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface'; import { debug } from '@/lib/debug'; import { taskHooks } from '@/lib/plugin-hooks'; import { taskToView } from '@/lib/plugin-projection'; +import { applyPatchObject, patchedProperties } from '@/lib/jmap/patch-object'; export type TaskViewFilter = 'all' | 'pending' | 'completed' | 'overdue'; @@ -28,7 +29,12 @@ interface TaskStore { /** Resolves to null when a plugin stopped the create. */ createTask: (client: IJMAPClient, task: Partial) => Promise; /** false when a plugin stopped it (the plugin tells the user why). */ - updateTask: (client: IJMAPClient, id: string, updates: Partial) => Promise; + /** + * `updates` may name members by path (`alerts//acknowledged`). `base` + * is the task as the caller read it, for a task the list does not hold + * or holds in an older state. + */ + updateTask: (client: IJMAPClient, id: string, updates: Partial, base?: CalendarTask) => Promise; deleteTask: (client: IJMAPClient, id: string) => Promise; toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise; clearTasks: () => void; @@ -93,9 +99,12 @@ export const useTaskStore = create((set, get) => ({ return created; }, - updateTask: async (client, id, updates) => { - if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch: updates }))) return false; - const before = get().tasks.find(t => t.id === id); + updateTask: async (client, id, updates, base) => { + const before = base ?? get().tasks.find(t => t.id === id); + // `updates` may name members by path (`alerts//acknowledged`); + // plugins read the patch as whole properties. + const patch = patchedProperties(before ?? { id }, updates); + if (!(await taskHooks.onBeforeTaskUpdate.intercept({ taskId: id, patch }))) return false; const newId = await client.updateCalendarTask(id, updates); if (newId && newId !== id) { // Moved into a calendar of another account: the server recreated it @@ -103,11 +112,15 @@ export const useTaskStore = create((set, get) => ({ set({ selectedTaskId: get().selectedTaskId === id ? newId : get().selectedTaskId }); await get().fetchTasks(client); const moved = get().tasks.find(t => t.id === newId) - ?? ({ ...before, ...updates, id: newId } as CalendarTask); + ?? ({ ...applyPatchObject(before ?? {}, updates), id: newId } as CalendarTask); void taskHooks.onTaskUpdate.emit(taskToView(moved)); return true; } - const updated = { ...(before ?? { id }), ...updates, updated: new Date().toISOString() } as CalendarTask; + // Applied to the task the caller read, else as the store holds it now: + // another update may have finished meanwhile (two alerts acknowledged + // at once). + const current = base ?? get().tasks.find(t => t.id === id) ?? { id }; + const updated = { ...applyPatchObject(current, updates), updated: new Date().toISOString() } as CalendarTask; set({ tasks: get().tasks.map(t => t.id === id ? updated : t), });