mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-02 08:15:55 +00:00
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:
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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