Merge pull request #1481 from HOLOEYE-JZ/feat/plugin-task-alerts

feat: let plugins set task alerts
This commit is contained in:
Linus Rath
2026-11-01 18:10:56 +01:00
11 changed files with 715 additions and 18 deletions
+30
View File
@@ -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', '<a/b~c@x>')).toBe('relatedTo/<a~1b~0c@x>');
});
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<string, unknown>).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 } } });
});
});
@@ -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> = {}): 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<string, unknown>);
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<string, unknown>) => 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<string, unknown> }[] = [];
const emitted: { title: string; alerts: Record<string, { acknowledged: string | null }> }[] = [];
taskHooks.onBeforeTaskUpdate.register('watch', (e: unknown) => { seen.push(e as { patch: Record<string, unknown> }); 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 });
});
});
@@ -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',
+3 -1
View File
@@ -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<string, unknown>;
const created = await this.createCalendarTask({ ...rest, ...patch } as Partial<CalendarTask>, destAccountId);
// The patch may name members by path ("alerts/<id>/acknowledged").
const created = await this.createCalendarTask(applyPatchObject(rest, patch) as Partial<CalendarTask>, destAccountId);
await this.deleteCalendarEvent(source.id, false, source.accountId);
return created.id;
}
+70
View File
@@ -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<string, unknown> {
return Object.fromEntries(Object.entries(object ?? {}));
}
/** Sets `key` as an own data property, never the prototype. */
function put(object: Record<string, unknown>, 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<T extends object>(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<string, unknown>, [head, ...rest]: string[], value: unknown): Record<string, unknown> {
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<string, unknown>) : {}, 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<T extends object>(target: T, patch: object): Partial<T> {
const after = applyPatchObject(target, patch) as Record<string, unknown>;
const changed: Record<string, unknown> = {};
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<T>;
}
+8
View File
@@ -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,
};
}
+285 -8
View File
@@ -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<void> {
// ─── 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<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,
};
});
}
interface TaskMapChanges {
alerts?: MapChange<CalendarEventAlert>;
}
/** 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);
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,
};
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';
@@ -505,6 +720,22 @@ export async function doTasksList(options: unknown): Promise<TaskView[]> {
.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;
@@ -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<CalendarTask> {
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<TaskView | null> {
const method = 'tasks.get';
const task = await loadTask(sessionClient(method), readId(id, method));
@@ -547,6 +792,7 @@ export async function doTasksCreate(input: unknown): Promise<TaskView | null> {
...(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<void>
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<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> {
@@ -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<void> {
+3 -1
View File
@@ -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<Record<string, Permission | null>> = {
'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();
+13 -1
View File
@@ -996,10 +996,22 @@ function buildPluginApi(manifest: PluginManifest) {
get: (id: string) => callApi('tasks.get', [id]) as Promise<TaskView | null>,
/** Resolves to the new task, or null when another plugin stopped it. */
create: (task: TaskInput) => callApi('tasks.create', [task]) as Promise<TaskView | null>,
/** 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<TaskInput>) => callApi('tasks.update', [id, patch]) as Promise<void>,
/** Mark a task done or not done. */
setComplete: (id: string, complete: boolean) => callApi('tasks.setComplete', [id, complete]) as Promise<void>,
/**
* 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<void>,
delete: (id: string) => callApi('tasks.delete', [id]) as Promise<void>,
},
// Message templates (permissions: templates:read / templates:write).
+43
View File
@@ -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<string, TaskAlertInput | null> | 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<string, TaskAlertView>;
/**
* 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 {
+19 -6
View File
@@ -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<CalendarTask>) => Promise<CalendarTask | null>;
/** false when a plugin stopped it (the plugin tells the user why). */
updateTask: (client: IJMAPClient, id: string, updates: Partial<CalendarTask>) => Promise<boolean>;
/**
* `updates` may name members by path (`alerts/<id>/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<CalendarTask>, base?: CalendarTask) => Promise<boolean>;
deleteTask: (client: IJMAPClient, id: string) => Promise<boolean>;
toggleTaskComplete: (client: IJMAPClient, task: CalendarTask) => Promise<void>;
clearTasks: () => void;
@@ -93,9 +99,12 @@ export const useTaskStore = create<TaskStore>((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/<id>/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<TaskStore>((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),
});