diff --git a/lib/__tests__/plugin-host-calendar-tasks.test.ts b/lib/__tests__/plugin-host-calendar-tasks.test.ts index 7023a6d15..2ae3d3d97 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, CalendarEventAlert, CalendarTask } from '../jmap/types'; +import type { Calendar, CalendarEvent, CalendarEventAlert, CalendarLink, CalendarTask } from '../jmap/types'; import { useAuthStore } from '@/stores/auth-store'; import { useCalendarStore } from '@/stores/calendar-store'; import { useTaskStore } from '@/stores/task-store'; @@ -482,3 +482,88 @@ describe('api.tasks alerts', () => { expect(updates()[0]).toEqual({ alerts: null, useDefaultAlerts: false }); }); }); + +describe('api.tasks links and relations', () => { + const link = (href: string, over: Partial = {}): CalendarLink => ({ + '@type': 'Link', href, cid: null, contentType: null, size: null, rel: null, display: null, title: null, ...over, + }); + const updates = () => client.updateCalendarTask.mock.calls.map((c) => c[1] as Record); + + it('creates a task with links and a related mail, a path of this Bulwark made a full URL', async () => { + await call(WRITE, 'tasks.create', { + title: 'Follow up: Offer', + links: { + mail: { href: '/mail/message/m1?mailAccount=a1', rel: 'describedby', title: 'Offer' }, + from: { href: 'mailto:bob@example.com', rel: 'author', title: 'Bob' }, + }, + relatedTo: { '': { relation: ['parent'] } }, + }); + expect(client.createCalendarTask).toHaveBeenCalledWith(expect.objectContaining({ + links: { + mail: link(`${window.location.origin}/mail/message/m1?mailAccount=a1`, { rel: 'describedby', title: 'Offer' }), + from: link('mailto:bob@example.com', { rel: 'author', title: 'Bob' }), + }, + relatedTo: { '': { '@type': 'Relation', relation: { parent: true } } }, + })); + }); + + it('returns them as any app wrote them, an attachment\'s link too', async () => { + client.getCalendarTasks.mockResolvedValue([task({ + links: { file: link('cid:part1', { rel: 'enclosure', contentType: 'application/pdf', size: 12 }) }, + relatedTo: { '': { '@type': 'Relation', relation: null } }, + })]); + expect(await call(READ, 'tasks.get', 't1')).toEqual(expect.objectContaining({ + links: { file: { href: 'cid:part1', rel: 'enclosure', title: null, contentType: 'application/pdf' } }, + relatedTo: { '': { relation: [] } }, + })); + }); + + it('refuses bad schemes, bad keys and over-long fields', async () => { + const create = (over: Record) => call(WRITE, 'tasks.create', { title: 'x', ...over }); + await expect(create({ links: { l: { href: 'javascript:alert(1)' } } })).rejects.toThrow(/http\(s\) URL, a mailto: address or a path/); + await expect(create({ links: { l: { href: '//evil.example/x' } } })).rejects.toThrow(/http\(s\) URL/); + await expect(create({ links: { l: { href: 'https://user:pw@example.com/' } } })).rejects.toThrow(/user name or password/); + await expect(create({ links: { l: { href: 'mailto:a@example.com?body=hi' } } })).rejects.toThrow(/one address/); + await expect(create({ links: { l: { href: `https://example.com/${'a'.repeat(2048)}` } } })).rejects.toThrow(/1-2048 characters/); + await expect(create({ links: { l: { href: 'https://example.com', rel: 'Described By' } } })).rejects.toThrow(/rel must be/); + await expect(create({ links: { l: { href: 'https://example.com', title: 'x'.repeat(501) } } })).rejects.toThrow(/title must be at most 500/); + await expect(create({ links: [{ id: 'l', href: 'https://a.example' }] })).rejects.toThrow(/object keyed by id/); + const eleven = Object.fromEntries(Array.from({ length: 11 }, (_, i) => [`l${i}`, { href: 'https://a.example' }])); + await expect(create({ links: eleven })).rejects.toThrow(/at most 10 entries/); + await expect(create({ relatedTo: { 'has space': {} } })).rejects.toThrow(/Message-ID/); + await expect(create({ relatedTo: { [`<${'a'.repeat(1000)}>`]: {} } })).rejects.toThrow(/at most 998/); + await expect(create({ relatedTo: { '': { relation: ['Parent'] } } })).rejects.toThrow(/relation\[0\]/); + expect(client.createCalendarTask).not.toHaveBeenCalled(); + }); + + it('merges by id with patch paths, keeping links another app wrote', async () => { + useTaskStore.setState({ tasks: [task({ + links: { att: link('cid:part1', { rel: 'enclosure', contentType: 'application/pdf' }), old: link('https://old.example') }, + relatedTo: null, + })] }); + await call(WRITE, 'tasks.update', 't1', { + links: { mail: { href: 'https://mail.example/m1' }, old: null }, + relatedTo: { '': { relation: ['parent'] } }, + }); + expect(updates()[0]).toEqual({ + 'links/mail': link('https://mail.example/m1'), + 'links/old': null, + // No relatedTo yet: written whole. + relatedTo: { '': { '@type': 'Relation', relation: { parent: true } } }, + }); + expect(Object.keys(useTaskStore.getState().tasks[0].links ?? {})).toEqual(['att', 'mail']); + + await call(WRITE, 'tasks.update', 't1', { relatedTo: { '': null } }); + expect(updates()[1]).toEqual({ 'relatedTo/': null }); + }); + + it('decides on the links as the server holds them, not an older copy in the list', async () => { + useTaskStore.setState({ tasks: [task({ links: null })] }); + client.getCalendarEvent.mockImplementation(async (id: string) => (id === 't1' + ? { id: 't1', links: { att: link('cid:part1', { rel: 'enclosure' }) }, alerts: null, relatedTo: null } + : null)); + await call(WRITE, 'tasks.update', 't1', { links: { mail: { href: 'https://mail.example/m1' } } }); + expect(updates()[0]).toEqual({ 'links/mail': link('https://mail.example/m1') }); + expect(Object.keys(useTaskStore.getState().tasks[0].links ?? {})).toEqual(['att', 'mail']); + }); +}); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 67f65506c..0e67c488f 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -710,6 +710,7 @@ const CALENDAR_TASK_PROPERTIES = [ 'useDefaultAlerts', 'alerts', 'relatedTo', + 'links', 'percentComplete', // Task-only per RFC 8984 §5.2.4 - used in detection heuristic ] as const; diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 9f1db48d7..7cc486099 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -894,6 +894,7 @@ export interface CalendarTask { useDefaultAlerts?: boolean; alerts: Record | null; relatedTo: Record | null; + links?: Record | null; // Client-only, set on tasks from a shared calendar account. Their `id` and // `calendarIds` are namespaced `accountId:id` like shared events. originalId?: string; diff --git a/lib/plugin-projection.ts b/lib/plugin-projection.ts index c8cbf838b..7f2ec5aa9 100644 --- a/lib/plugin-projection.ts +++ b/lib/plugin-projection.ts @@ -183,6 +183,15 @@ export function taskToView(task: CalendarTask): TaskView { acknowledged: alert.acknowledged || null, }])), useDefaultAlerts: !!task.useDefaultAlerts, + links: Object.fromEntries(Object.entries(task.links || {}).map(([id, link]) => [id, { + href: link.href, + rel: link.rel || null, + title: link.title || null, + contentType: link.contentType || null, + }])), + relatedTo: Object.fromEntries(Object.entries(task.relatedTo || {}).map(([uid, relation]) => [uid, { + relation: trueKeys(relation?.relation), + }])), }; } diff --git a/lib/plugin-sandbox/host-api-calendar.ts b/lib/plugin-sandbox/host-api-calendar.ts index c3574975a..fcaf989b7 100644 --- a/lib/plugin-sandbox/host-api-calendar.ts +++ b/lib/plugin-sandbox/host-api-calendar.ts @@ -11,7 +11,8 @@ import type { CalendarEventInput, CalendarEventView, CalendarView, TaskInput, TaskView, } from '../plugin-types'; import type { - Calendar, CalendarEvent, CalendarEventAlert, CalendarParticipant, CalendarRecurrenceRule, CalendarTask, + Calendar, CalendarEvent, CalendarEventAlert, CalendarLink, CalendarParticipant, CalendarRecurrenceRule, + CalendarRelation, CalendarTask, } from '../jmap/types'; import type { IJMAPClient } from '../jmap/client-interface'; import { useCalendarStore } from '@/stores/calendar-store'; @@ -23,6 +24,7 @@ import { buildParticipantMap } from '../calendar-participants'; import { parseDurationSeconds } from '../calendar-event-normalization'; import { parseAlertOffset, withOwnAlertIds } from '../calendar-alerts'; import { patchPath } from '../jmap/patch-object'; +import { isValidEmail } from '../validation'; import { getEffectiveTimeZone } from '../timezone'; import { generateUUID } from '../utils'; import { @@ -490,19 +492,33 @@ export async function doCalendarDeleteEvent(id: unknown): Promise { // ─── Tasks ──────────────────────────────────────────────────── -const TASK_FIELDS = ['title', 'description', 'due', 'priority', 'calendarId', 'alerts'] as const; +const TASK_FIELDS = [ + 'title', 'description', 'due', 'priority', 'calendarId', 'alerts', 'links', 'relatedTo', +] as const; /** Most alerts a plugin may give a task. */ export const MAX_PLUGIN_TASK_ALERTS = 5; +/** Most links a plugin may give a task. */ +export const MAX_PLUGIN_TASK_LINKS = 10; +/** Most relations a plugin may give a task. */ +export const MAX_PLUGIN_TASK_RELATIONS = 10; +const MAX_HREF = 2048; +const MAX_LINK_TITLE = 500; +const MAX_RELATED_UID = 998; +const MAX_RELATIONS_PER_UID = 4; const MAX_ALERT_OFFSET_MS = MAX_PLUGIN_EVENT_RANGE_DAYS * DAY_MS; /** Longest key a plugin may name to remove an entry it did not write. */ const MAX_REMOVED_KEY = 1024; const MEMBER_ID_RE = /^[A-Za-z0-9._-]{1,64}$/; const OFFSET_RE = /^-?P(?:\d+W|(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+S)?)?)$/; +const REL_RE = /^[a-z][a-z0-9.-]{0,63}$/; +const RELATION_RE = /^[a-z][a-z0-9-]{0,31}$/; +// Printable ASCII without spaces; a Message-ID keeps its angle brackets. +const RELATED_UID_RE = /^(?:<[!-;=?-~]+>|[!-;=?-~]+)$/; const CONTROL_RE = /\p{Cc}/u; /** The maps a plugin edits on a task, each keyed as JSCalendar keys it. */ -const TASK_MAPS = ['alerts'] as const; +const TASK_MAPS = ['alerts', 'links', 'relatedTo'] as const; type TaskMap = typeof TASK_MAPS[number]; /** @@ -589,14 +605,89 @@ function alertsOf(value: unknown, method: string): MapChange }); } +/** A link's target: http(s), a `mailto:` address, or a path of this Bulwark made a full URL. */ +function readHref(value: unknown, method: string, field: string): string { + const href = readString(value, method, field, MAX_HREF, { min: 1 }); + if (/[\s\p{Cc}\\]/u.test(href)) throw new Error(`${method}: ${field} must not contain spaces, control characters or backslashes`); + if (href.startsWith('/') && !href.startsWith('//')) return new URL(href, window.location.origin).href; + let url: URL; + try { + url = new URL(href); + } catch { + throw new Error(`${method}: ${field} must be an http(s) URL, a mailto: address or a path starting with /`); + } + if (url.protocol === 'mailto:') { + let address: string; + try { + address = decodeURIComponent(url.pathname); + } catch { + address = ''; + } + if (url.search || url.hash || !isValidEmail(address)) throw new Error(`${method}: ${field} must be mailto: and one address`); + return href; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error(`${method}: ${field} must be an http(s) URL, a mailto: address or a path starting with /`); + } + if (url.username || url.password) throw new Error(`${method}: ${field} must not carry a user name or password`); + return href; +} + +function linksOf(value: unknown, method: string): MapChange { + return readMapChange(value, method, 'links', MAX_PLUGIN_TASK_LINKS, (k) => MEMBER_ID_RE.test(k), + '1-64 letters, digits or . _ -', (entry, field) => { + const l = readObject(entry, method, field, ['href', 'rel', 'title']); + if (l.rel !== undefined && (typeof l.rel !== 'string' || !REL_RE.test(l.rel))) { + throw new Error(`${method}: ${field}.rel must be a lower-case link relation such as describedby`); + } + const title = l.title === undefined ? '' : readString(l.title, method, `${field}.title`, MAX_LINK_TITLE, { trim: true }); + return { + '@type': 'Link', + href: readHref(l.href, method, `${field}.href`), + cid: null, + contentType: null, + size: null, + rel: (l.rel as string | undefined) ?? null, + display: null, + title: title || null, + }; + }); +} + +function relatedToOf(value: unknown, method: string): MapChange { + return readMapChange(value, method, 'relatedTo', MAX_PLUGIN_TASK_RELATIONS, + (k) => k.length <= MAX_RELATED_UID && RELATED_UID_RE.test(k), + `a Message-ID like or a UID, at most ${MAX_RELATED_UID} printable characters without spaces`, + (entry, field) => { + const r = readObject(entry, method, field, ['relation']); + let relation: Record | null = null; + if (r.relation !== undefined) { + if (!Array.isArray(r.relation) || r.relation.length > MAX_RELATIONS_PER_UID) { + throw new Error(`${method}: ${field}.relation must be an array of at most ${MAX_RELATIONS_PER_UID} relation types`); + } + r.relation.forEach((type, j) => { + if (typeof type !== 'string' || !RELATION_RE.test(type)) { + throw new Error(`${method}: ${field}.relation[${j}] must be a lower-case relation type such as parent`); + } + }); + if (r.relation.length) relation = Object.fromEntries((r.relation as string[]).map((type) => [type, true])); + } + return { '@type': 'Relation', relation }; + }); +} + interface TaskMapChanges { alerts?: MapChange; + links?: MapChange; + relatedTo?: 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); + if (data.links !== undefined) changes.links = linksOf(data.links, method); + if (data.relatedTo !== undefined) changes.relatedTo = relatedToOf(data.relatedTo, method); return changes; } @@ -644,6 +735,8 @@ function updateMaps(method: string, task: CalendarTask, changes: TaskMapChanges) const patch: Record = {}; const limits: Record = { alerts: MAX_PLUGIN_TASK_ALERTS, + links: MAX_PLUGIN_TASK_LINKS, + relatedTo: MAX_PLUGIN_TASK_RELATIONS, }; for (const name of TASK_MAPS) { const change = changes[name] as MapChange | undefined; @@ -749,7 +842,8 @@ async function requireTask(client: IJMAPClient, method: string, id: string): Pro } /** - * The task with its alerts as the server holds them now: the store's copy may be older, and a map the task seems + * The task with its alerts, links and relations as the server + * holds them now: the store's copy may be older, and a map the task seems * not to have is written whole, which would drop what the server gained. */ async function withServerMaps(client: IJMAPClient, task: CalendarTask): Promise { @@ -758,6 +852,8 @@ async function withServerMaps(client: IJMAPClient, task: CalendarTask): Promise< return { ...task, alerts: server.alerts ?? null, + links: server.links ?? null, + relatedTo: server.relatedTo ?? null, useDefaultAlerts: server.useDefaultAlerts, }; } diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index f10a720fd..126e1d984 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -994,9 +994,10 @@ function buildPluginApi(manifest: PluginManifest) { /** 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. `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. + * Change the given fields. `alerts`, `links` and `relatedTo` merge + * by id: an entry 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. */ diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 497bb892e..265102ab6 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -597,6 +597,19 @@ export interface TaskInput { * own under new ids; `{}` does only that. */ alerts?: Record | null; + /** + * Links by id (1-64 of `A-Z a-z 0-9 . _ -`), at most 10: an http(s) URL, + * a `mailto:` address or a path of this Bulwark (`/mail/...`), stored as + * its full URL. Merged by id on update as `alerts` is; links another app + * wrote stay unless named with null. + */ + links?: Record | null; + /** + * What the task relates to, by a mail's Message-ID (with its angle + * brackets) or another calendar object's UID; at most 10. Merged by key on + * update as `alerts` is. + */ + relatedTo?: Record | null; } /** @@ -615,6 +628,20 @@ export interface TaskAlertInput { action?: 'display' | 'email'; } +export interface TaskLinkInput { + /** Up to 2048 characters. */ + href: string; + /** A link relation such as `describedby` or `author` (RFC 8288), lower case. */ + rel?: string; + /** Up to 500 characters. */ + title?: string; +} + +export interface TaskRelationInput { + /** How it relates, as in JSCalendar: `parent`, `child`, `first`, `next` or another lower-case type; at most 4. */ + relation?: string[]; +} + export interface ContactView { id: string; uid: string; @@ -686,6 +713,10 @@ export interface TaskView { * `alerts` makes them the task's own (under new ids). */ useDefaultAlerts: boolean; + /** By id; as written by any app (an attachment's link too). */ + links: Record; + /** By Message-ID or UID. */ + relatedTo: Record; } export interface TaskAlertView { @@ -696,6 +727,17 @@ export interface TaskAlertView { acknowledged: string | null; } +export interface TaskLinkView { + href: string; + rel: string | null; + title: string | null; + contentType: string | null; +} + +export interface TaskRelationView { + relation: string[]; +} + export interface TemplateView { id: string; name: string;