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),
});