mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 00:14:23 +00:00
feat(plugins): tasks carry links and related mail
api.tasks.create and update take links by id (an http(s) URL, a mailto: address or a path of this Bulwark, stored as its full URL; at most ten) and relatedTo (by a mail's Message-ID or another object's UID, with its relation types; at most ten). They merge on update as alerts do: an entry given is set, one given null is removed, and entries the plugin did not name, such as an attachment's link another app wrote, stay as the server holds them. TaskView returns both as any app wrote them. Lengths, ids and URL schemes are checked as the rest of the calendar API checks them. Task reads now ask the server for links too.
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -894,6 +894,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;
|
||||
|
||||
@@ -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),
|
||||
}])),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user