Merge pull request #1482 from HOLOEYE-JZ/feat/plugin-task-links

feat: let plugins set task links and related mail
This commit is contained in:
Linus Rath
2026-11-01 18:10:57 +01:00
7 changed files with 243 additions and 8 deletions
@@ -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> = {}): 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<string, unknown>);
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: { '<offer-1@example.com>': { 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: { '<offer-1@example.com>': { '@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: { '<m@x>': { '@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: { '<m@x>': { relation: [] } },
}));
});
it('refuses bad schemes, bad keys and over-long fields', async () => {
const create = (over: Record<string, unknown>) => 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: { '<m@x>': { 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: { '<m/1~x@y>': { relation: ['parent'] } },
});
expect(updates()[0]).toEqual({
'links/mail': link('https://mail.example/m1'),
'links/old': null,
// No relatedTo yet: written whole.
relatedTo: { '<m/1~x@y>': { '@type': 'Relation', relation: { parent: true } } },
});
expect(Object.keys(useTaskStore.getState().tasks[0].links ?? {})).toEqual(['att', 'mail']);
await call(WRITE, 'tasks.update', 't1', { relatedTo: { '<m/1~x@y>': null } });
expect(updates()[1]).toEqual({ 'relatedTo/<m~11~0x@y>': 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']);
});
});
+1
View File
@@ -738,6 +738,7 @@ const CALENDAR_TASK_PROPERTIES = [
'useDefaultAlerts',
'alerts',
'relatedTo',
'links',
'percentComplete', // Task-only per RFC 8984 §5.2.4 - used in detection heuristic
] as const;
+1
View File
@@ -909,6 +909,7 @@ export interface CalendarTask {
useDefaultAlerts?: boolean;
alerts: Record<string, CalendarEventAlert> | null;
relatedTo: Record<string, CalendarRelation> | null;
links?: Record<string, CalendarLink> | null;
// Client-only, set on tasks from a shared calendar account. Their `id` and
// `calendarIds` are namespaced `accountId:id` like shared events.
originalId?: string;
+9
View File
@@ -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),
}])),
};
}
+100 -4
View File
@@ -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<void> {
// ─── 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<CalendarEventAlert>
});
}
/** A link's target: http(s), a `mailto:` address, or a path of this Bulwark made a full URL. */
function readHref(value: unknown, method: string, field: string): string {
const href = readString(value, method, field, MAX_HREF, { min: 1 });
if (/[\s\p{Cc}\\]/u.test(href)) throw new Error(`${method}: ${field} must not contain spaces, control characters or backslashes`);
if (href.startsWith('/') && !href.startsWith('//')) return new URL(href, window.location.origin).href;
let url: URL;
try {
url = new URL(href);
} catch {
throw new Error(`${method}: ${field} must be an http(s) URL, a mailto: address or a path starting with /`);
}
if (url.protocol === 'mailto:') {
let address: string;
try {
address = decodeURIComponent(url.pathname);
} catch {
address = '';
}
if (url.search || url.hash || !isValidEmail(address)) throw new Error(`${method}: ${field} must be mailto: and one address`);
return href;
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`${method}: ${field} must be an http(s) URL, a mailto: address or a path starting with /`);
}
if (url.username || url.password) throw new Error(`${method}: ${field} must not carry a user name or password`);
return href;
}
function linksOf(value: unknown, method: string): MapChange<CalendarLink> {
return readMapChange(value, method, 'links', MAX_PLUGIN_TASK_LINKS, (k) => MEMBER_ID_RE.test(k),
'1-64 letters, digits or . _ -', (entry, field) => {
const l = readObject(entry, method, field, ['href', 'rel', 'title']);
if (l.rel !== undefined && (typeof l.rel !== 'string' || !REL_RE.test(l.rel))) {
throw new Error(`${method}: ${field}.rel must be a lower-case link relation such as describedby`);
}
const title = l.title === undefined ? '' : readString(l.title, method, `${field}.title`, MAX_LINK_TITLE, { trim: true });
return {
'@type': 'Link',
href: readHref(l.href, method, `${field}.href`),
cid: null,
contentType: null,
size: null,
rel: (l.rel as string | undefined) ?? null,
display: null,
title: title || null,
};
});
}
function relatedToOf(value: unknown, method: string): MapChange<CalendarRelation> {
return readMapChange(value, method, 'relatedTo', MAX_PLUGIN_TASK_RELATIONS,
(k) => k.length <= MAX_RELATED_UID && RELATED_UID_RE.test(k),
`a Message-ID like <id@example.com> or a UID, at most ${MAX_RELATED_UID} printable characters without spaces`,
(entry, field) => {
const r = readObject(entry, method, field, ['relation']);
let relation: Record<string, boolean> | null = null;
if (r.relation !== undefined) {
if (!Array.isArray(r.relation) || r.relation.length > MAX_RELATIONS_PER_UID) {
throw new Error(`${method}: ${field}.relation must be an array of at most ${MAX_RELATIONS_PER_UID} relation types`);
}
r.relation.forEach((type, j) => {
if (typeof type !== 'string' || !RELATION_RE.test(type)) {
throw new Error(`${method}: ${field}.relation[${j}] must be a lower-case relation type such as parent`);
}
});
if (r.relation.length) relation = Object.fromEntries((r.relation as string[]).map((type) => [type, true]));
}
return { '@type': 'Relation', relation };
});
}
interface TaskMapChanges {
alerts?: MapChange<CalendarEventAlert>;
links?: MapChange<CalendarLink>;
relatedTo?: MapChange<CalendarRelation>;
}
/** The plugin's map changes, read and checked before anything is asked of the server. */
function mapChangesOf(data: Record<string, unknown>, method: string): TaskMapChanges {
const changes: TaskMapChanges = {};
if (data.alerts !== undefined) changes.alerts = alertsOf(data.alerts, method);
if (data.links !== undefined) changes.links = linksOf(data.links, method);
if (data.relatedTo !== undefined) changes.relatedTo = relatedToOf(data.relatedTo, method);
return changes;
}
@@ -644,6 +735,8 @@ function updateMaps(method: string, task: CalendarTask, changes: TaskMapChanges)
const patch: Record<string, unknown> = {};
const limits: Record<TaskMap, number> = {
alerts: MAX_PLUGIN_TASK_ALERTS,
links: MAX_PLUGIN_TASK_LINKS,
relatedTo: MAX_PLUGIN_TASK_RELATIONS,
};
for (const name of TASK_MAPS) {
const change = changes[name] as MapChange<unknown> | undefined;
@@ -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<CalendarTask> {
@@ -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,
};
}
+4 -3
View File
@@ -997,9 +997,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<TaskView | null>,
/**
* 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<TaskInput>) => callApi('tasks.update', [id, patch]) as Promise<void>,
/** Mark a task done or not done. */
+42
View File
@@ -597,6 +597,19 @@ export interface TaskInput {
* own under new ids; `{}` does only that.
*/
alerts?: Record<string, TaskAlertInput | null> | 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<string, TaskLinkInput | null> | 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<string, TaskRelationInput | null> | 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<string, TaskLinkView>;
/** By Message-ID or UID. */
relatedTo: Record<string, TaskRelationView>;
}
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;