mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
feat: let plugins add buttons, calendar day labels and pages, and use connected accounts from the sandbox
This commit is contained in:
@@ -71,7 +71,9 @@ export async function POST(request: NextRequest) {
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ProxyError) {
|
||||
const headers: Record<string, string> = { 'cache-control': 'no-store' };
|
||||
// Marks the proxy's own refusals, so the plugin can tell them from the
|
||||
// upstream server's errors.
|
||||
const headers: Record<string, string> = { 'cache-control': 'no-store', 'x-bulwark-proxy-error': error.code };
|
||||
const retryAfter = (error as ProxyError & { retryAfter?: number }).retryAfter;
|
||||
if (retryAfter) headers['retry-after'] = String(retryAfter);
|
||||
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status, headers });
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { PluginActions } from '../plugin-actions';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
beforeEach(() => {
|
||||
resetActions();
|
||||
dispatch.mockClear();
|
||||
setActionDispatcher(dispatch);
|
||||
});
|
||||
|
||||
describe('PluginActions', () => {
|
||||
it('renders nothing when no plugin declared actions', () => {
|
||||
const { container } = render(<PluginActions surface="message-bulk-actions" context={{ emailIds: ['e1'] }} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('draws declared buttons and sends the click with the context', async () => {
|
||||
render(<PluginActions surface="message-bulk-actions" context={{ emailIds: ['e1', 'e2'] }} />);
|
||||
act(() => setPluginActions('tasks', 'Email to task', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Make tasks' })); });
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['e1', 'e2'] });
|
||||
});
|
||||
|
||||
it('shows only actions meant for the folder or file type', () => {
|
||||
act(() => setPluginActions('p', 'P', 'attachment-menu', [
|
||||
{ id: 'pdf', label: 'Send PDF', mimeTypes: ['application/pdf'] },
|
||||
{ id: 'img', label: 'Edit image', mimeTypes: ['image/'] },
|
||||
]));
|
||||
|
||||
render(<PluginActions surface="attachment-menu" context={{}} variant="menu" mimeType="image/png" />);
|
||||
|
||||
expect(screen.queryByRole('menuitem', { name: 'Send PDF' })).toBeNull();
|
||||
expect(screen.getByRole('menuitem', { name: 'Edit image' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore, useState } from 'react';
|
||||
import { iconForName, Puzzle } from '@/components/icons';
|
||||
import { ContextMenuItem } from '@/components/ui/context-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
actionApplies,
|
||||
actionsFor,
|
||||
runAction,
|
||||
subscribeActions,
|
||||
type RegisteredAction,
|
||||
} from '@/lib/plugin-sandbox/actions';
|
||||
|
||||
const EMPTY: RegisteredAction[] = [];
|
||||
|
||||
/** The actions plugins declared for a surface, filtered by folder role or MIME type. */
|
||||
export function usePluginActions(surface: string, filter: { mailboxRole?: string | null; mimeType?: string | null } = {}) {
|
||||
const all = useSyncExternalStore(subscribeActions, () => actionsFor(surface), () => EMPTY);
|
||||
return all.filter((a) => actionApplies(a, filter));
|
||||
}
|
||||
|
||||
interface PluginActionsProps {
|
||||
/** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */
|
||||
surface: string;
|
||||
/** What the click is about (ids of the message, files, ...). Filtered per plugin before it is sent. */
|
||||
context: Record<string, unknown>;
|
||||
/** Buttons in a toolbar row, or entries in a menu. */
|
||||
variant?: 'buttons' | 'menu';
|
||||
mailboxRole?: string | null;
|
||||
mimeType?: string | null;
|
||||
className?: string;
|
||||
/** Icon-only buttons with the label as tooltip (narrow toolbars). */
|
||||
compact?: boolean;
|
||||
/** Called after a click, e.g. to close the menu the entry sits in. */
|
||||
onRan?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buttons or menu entries plugins declared with api.ui.setActions. The host
|
||||
* draws them so they match the app; the plugin gets the click.
|
||||
*/
|
||||
export function PluginActions({ surface, context, variant = 'buttons', mailboxRole, mimeType, className, compact, onRan }: PluginActionsProps) {
|
||||
const actions = usePluginActions(surface, { mailboxRole, mimeType });
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
const run = async (action: RegisteredAction) => {
|
||||
const key = `${action.pluginId}:${action.id}`;
|
||||
setBusy(key);
|
||||
try {
|
||||
onRan?.();
|
||||
await runAction(action, surface, context);
|
||||
} finally {
|
||||
setBusy((current) => (current === key ? null : current));
|
||||
}
|
||||
};
|
||||
|
||||
if (variant === 'menu') {
|
||||
return (
|
||||
<>
|
||||
{actions.map((action) => (
|
||||
<ContextMenuItem
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
icon={iconForName(action.icon) ?? Puzzle}
|
||||
label={action.label}
|
||||
title={action.pluginName}
|
||||
destructive={action.danger}
|
||||
onClick={() => { void run(action); }}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('inline-flex flex-wrap items-center gap-1', className)} data-plugin-actions={surface}>
|
||||
{actions.map((action) => {
|
||||
const Icon = iconForName(action.icon) ?? Puzzle;
|
||||
const key = `${action.pluginId}:${action.id}`;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={busy === key}
|
||||
title={compact ? `${action.label} (${action.pluginName})` : action.pluginName}
|
||||
aria-label={action.label}
|
||||
onClick={(e) => { e.stopPropagation(); void run(action); }}
|
||||
className={cn(
|
||||
'inline-flex h-8 items-center gap-1.5 rounded-md px-2 text-sm text-muted-foreground transition-colors',
|
||||
'hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
'disabled:opacity-50',
|
||||
action.danger && 'text-destructive hover:bg-destructive/10 hover:text-destructive',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4 flex-shrink-0" />
|
||||
{!compact && <span className="whitespace-nowrap">{action.label}</span>}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest';
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
|
||||
const apiFetch = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/browser-navigation', async (orig) => ({
|
||||
...(await orig<typeof import('@/lib/browser-navigation')>()),
|
||||
apiFetch: (...args: unknown[]) => apiFetch(...args),
|
||||
}));
|
||||
const registryEntries = vi.hoisted(() => [] as unknown[]);
|
||||
vi.mock('../plugin-sandbox/registry', async (orig) => ({
|
||||
...(await orig<typeof import('../plugin-sandbox/registry')>()),
|
||||
all: () => registryEntries,
|
||||
}));
|
||||
|
||||
import { dispatchApiCall } from '../plugin-sandbox/host-api';
|
||||
import { actionsFor, resetActions, runAction, checkActions } from '../plugin-sandbox/actions';
|
||||
import { dayLabelsByDate, resetDayLabels } from '../plugin-sandbox/day-labels';
|
||||
import { allPages, resetPages } from '../plugin-sandbox/pages';
|
||||
import type { StreamSink } from '../plugin-sandbox/proxy-client';
|
||||
import '../plugin-sandbox/loader';
|
||||
|
||||
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
|
||||
return {
|
||||
id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook',
|
||||
permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiFetch.mockReset();
|
||||
resetActions();
|
||||
resetDayLabels();
|
||||
resetPages();
|
||||
registryEntries.length = 0;
|
||||
});
|
||||
|
||||
describe('ui.setActions', () => {
|
||||
it('puts a plugin\'s buttons on a surface it may use', async () => {
|
||||
await dispatchApiCall(plugin({ permissions: ['ui:message-list'] }), 'ui.setActions', ['message-row-actions', [
|
||||
{ id: 'b', label: 'Second', order: 2 }, { id: 'a', label: 'First', order: 1, icon: 'archive' },
|
||||
]]);
|
||||
|
||||
expect(actionsFor('message-row-actions').map((a) => [a.pluginId, a.id])).toEqual([['p1', 'a'], ['p1', 'b']]);
|
||||
});
|
||||
|
||||
it('refuses a surface the plugin has no permission for, and frame slots', async () => {
|
||||
await expect(dispatchApiCall(plugin({ permissions: ['ui:toolbar'] }), 'ui.setActions', ['message-row-actions', []]))
|
||||
.rejects.toThrow(/may not add actions/);
|
||||
await expect(dispatchApiCall(plugin({ permissions: ['ui:email-banner'] }), 'ui.setActions', ['email-banner', []]))
|
||||
.rejects.toThrow(/not a surface that takes actions/);
|
||||
expect(actionsFor('message-row-actions')).toEqual([]);
|
||||
});
|
||||
|
||||
it('checks what it is given', () => {
|
||||
expect(() => checkActions([{ id: 'x', label: '' }])).toThrow(/label/);
|
||||
expect(() => checkActions([{ id: 'x', label: 'X' }, { id: 'x', label: 'Y' }])).toThrow(/twice/);
|
||||
expect(() => checkActions(Array.from({ length: 9 }, (_, i) => ({ id: `a${i}`, label: 'A' })))).toThrow(/At most/);
|
||||
expect(() => checkActions([{ id: 'x', label: 'X', icon: '<svg>' }])).toThrow(/icon/);
|
||||
});
|
||||
|
||||
it('sends a click to the plugin with only the context it may see', async () => {
|
||||
const invokeHook = vi.fn().mockResolvedValue(undefined);
|
||||
const p = plugin({ permissions: ['ui:message-list'] });
|
||||
registryEntries.push({ plugin: p, background: { invokeHook } });
|
||||
await dispatchApiCall(p, 'ui.setActions', ['message-row-actions', [{ id: 'task', label: 'Make a task' }]]);
|
||||
|
||||
await runAction(actionsFor('message-row-actions')[0], 'message-row-actions', { emailId: 'e1', secretStuff: 1 });
|
||||
|
||||
// emailId needs email:read, which this plugin lacks.
|
||||
expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'message-row-actions', actionId: 'task', context: {} }]);
|
||||
|
||||
p.permissions.push('email:read');
|
||||
await runAction(actionsFor('message-row-actions')[0], 'message-row-actions', { emailId: 'e1', secretStuff: 1 });
|
||||
expect(invokeHook).toHaveBeenLastCalledWith('onAction', [{ surface: 'message-row-actions', actionId: 'task', context: { emailId: 'e1' } }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('day labels and pages', () => {
|
||||
it('stores labels by date, behind ui:calendar-action', async () => {
|
||||
await expect(dispatchApiCall(plugin(), 'ui.setDayLabels', [[{ date: '2026-12-24', text: 'Holiday' }]])).rejects.toThrow(/lacks permission/);
|
||||
await dispatchApiCall(plugin({ permissions: ['ui:calendar-action'] }), 'ui.setDayLabels', [[{ date: '2026-12-24', text: 'Holiday', dot: true }]]);
|
||||
|
||||
expect(dayLabelsByDate().get('2026-12-24')).toEqual([expect.objectContaining({ text: 'Holiday', dot: true, pluginId: 'p1' })]);
|
||||
await expect(dispatchApiCall(plugin({ permissions: ['ui:calendar-action'] }), 'ui.setDayLabels', [[{ date: '24.12.2026', text: 'x' }]])).rejects.toThrow(/YYYY-MM-DD/);
|
||||
});
|
||||
|
||||
it('registers pages behind ui:app-page', async () => {
|
||||
await dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'files', title: 'Nextcloud', icon: 'cloud' }]]);
|
||||
|
||||
expect(allPages()).toEqual([expect.objectContaining({ pluginId: 'p1', id: 'files', title: 'Nextcloud' })]);
|
||||
await expect(dispatchApiCall(plugin({ permissions: ['ui:app-page'] }), 'ui.setPages', [[{ id: 'A B', title: 'x' }]])).rejects.toThrow(/id/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proxy.fetch', () => {
|
||||
const proxied = plugin({
|
||||
permissions: ['http:proxy'],
|
||||
credentials: { cloud: { label: 'Nextcloud', scope: 'user', inject: { type: 'basic' } } },
|
||||
});
|
||||
|
||||
it('sends the request to the server proxy and returns the answer', async () => {
|
||||
apiFetch.mockResolvedValue(new Response('{"ok":1}', { status: 200, headers: { 'content-type': 'application/json' } }));
|
||||
|
||||
const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/ocs/v2.php/cloud/user', {
|
||||
method: 'GET', headers: { 'OCS-APIRequest': 'true' }, responseType: 'json',
|
||||
}]);
|
||||
|
||||
expect(res).toMatchObject({ status: 200, ok: true, body: { ok: 1 } });
|
||||
const [url, init] = apiFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/plugin-proxy?plugin=p1&credential=cloud&url=https%3A%2F%2Fcloud.example.org%2Focs%2Fv2.php%2Fcloud%2Fuser');
|
||||
expect(init.headers).toMatchObject({ 'OCS-APIRequest': 'true', 'x-bulwark-method': 'GET' });
|
||||
});
|
||||
|
||||
it('refuses a credential the manifest does not declare, and plugins without http:proxy', async () => {
|
||||
await expect(dispatchApiCall(proxied, 'proxy.fetch', ['other', 'https://a.example', {}])).rejects.toThrow(/declares no credential/);
|
||||
await expect(dispatchApiCall({ ...proxied, permissions: [] }, 'proxy.fetch', ['cloud', 'https://a.example', {}])).rejects.toThrow(/lacks permission/);
|
||||
expect(apiFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the plugin when the proxy itself refused', async () => {
|
||||
apiFetch.mockResolvedValue(new Response('{"error":"Too many requests","code":"rate_limited"}', {
|
||||
status: 429, headers: { 'content-type': 'application/json', 'x-bulwark-proxy-error': 'rate_limited' },
|
||||
}));
|
||||
|
||||
const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/x', {}]);
|
||||
|
||||
expect(res).toMatchObject({ status: 429, ok: false, proxyError: 'rate_limited', body: 'Too many requests' });
|
||||
});
|
||||
|
||||
it('streams a large answer to the iframe in chunks', async () => {
|
||||
const encoder = new TextEncoder();
|
||||
apiFetch.mockResolvedValue(new Response(new ReadableStream({
|
||||
start(c) { c.enqueue(encoder.encode('data: one\n\n')); c.enqueue(encoder.encode('data: two\n\n')); c.close(); },
|
||||
}), { status: 200, headers: { 'content-type': 'text/event-stream' } }));
|
||||
const sent: Parameters<StreamSink['sendStream']>[0][] = [];
|
||||
const sink: StreamSink = { sendStream: (m) => { sent.push(m); } };
|
||||
|
||||
const res = await dispatchApiCall(proxied, 'proxy.fetch', ['cloud', 'https://cloud.example.org/x', { responseType: 'stream', streamId: 'abc123' }], { sink });
|
||||
await vi.waitFor(() => expect(sent.at(-1)?.type).toBe('stream-end'));
|
||||
|
||||
expect(res).toMatchObject({ status: 200, ok: true });
|
||||
const text = sent.filter((m) => m.type === 'stream-chunk').map((m) => new TextDecoder().decode((m as { chunk: ArrayBuffer }).chunk)).join('');
|
||||
expect(text).toBe('data: one\n\ndata: two\n\n');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// Buttons and menu entries plugins declare as data (`api.ui.setActions`).
|
||||
// The host draws them in its own style on the surface named in the slot
|
||||
// catalogue (kind: 'action'); a click goes back to the plugin with the
|
||||
// surface's context, filtered by the plugin's permissions.
|
||||
|
||||
import { slotSpec } from '../plugin-slot-catalog';
|
||||
|
||||
export interface PluginAction {
|
||||
id: string;
|
||||
label: string;
|
||||
/** Name of a host icon (see components/icons); unknown names show none. */
|
||||
icon?: string;
|
||||
/** Lower comes first; default 100. */
|
||||
order?: number;
|
||||
/** Show only for these folder roles (message surfaces), e.g. ["inbox"]. */
|
||||
mailboxRoles?: string[];
|
||||
/** Show only for attachments or files of these MIME types (prefix match, "image/"). */
|
||||
mimeTypes?: string[];
|
||||
/** A danger action is drawn in red. */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
export interface RegisteredAction extends PluginAction {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export const MAX_ACTIONS_PER_SURFACE = 8;
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-_.]{0,39}$/i;
|
||||
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
|
||||
const ROLE_RE = /^[a-z]{1,20}$/;
|
||||
const MIME_RE = /^[a-z0-9.+-]+\/[a-z0-9.+*-]*$/i;
|
||||
|
||||
/** Surfaces that take actions, from the slot catalogue. */
|
||||
export function isActionSurface(surface: string): boolean {
|
||||
return slotSpec(surface)?.kind === 'action';
|
||||
}
|
||||
|
||||
/** Check and copy what a plugin sent; throws with a message the plugin sees. */
|
||||
export function checkActions(value: unknown): PluginAction[] {
|
||||
if (!Array.isArray(value)) throw new Error('actions must be a list');
|
||||
if (value.length > MAX_ACTIONS_PER_SURFACE) throw new Error(`At most ${MAX_ACTIONS_PER_SURFACE} actions per surface`);
|
||||
const seen = new Set<string>();
|
||||
return value.map((raw, i) => {
|
||||
if (!raw || typeof raw !== 'object') throw new Error(`actions[${i}] must be an object`);
|
||||
const a = raw as Record<string, unknown>;
|
||||
if (typeof a.id !== 'string' || !ID_RE.test(a.id)) throw new Error(`actions[${i}].id is invalid`);
|
||||
if (seen.has(a.id)) throw new Error(`actions[${i}].id is used twice`);
|
||||
seen.add(a.id);
|
||||
if (typeof a.label !== 'string' || !a.label.trim() || a.label.length > 60) throw new Error(`actions[${i}].label is required (max 60 characters)`);
|
||||
const out: PluginAction = { id: a.id, label: a.label.trim() };
|
||||
if (a.icon !== undefined) {
|
||||
if (typeof a.icon !== 'string' || !ICON_RE.test(a.icon)) throw new Error(`actions[${i}].icon is invalid`);
|
||||
out.icon = a.icon;
|
||||
}
|
||||
if (a.order !== undefined) {
|
||||
if (typeof a.order !== 'number' || !Number.isFinite(a.order)) throw new Error(`actions[${i}].order must be a number`);
|
||||
out.order = a.order;
|
||||
}
|
||||
if (a.mailboxRoles !== undefined) {
|
||||
if (!Array.isArray(a.mailboxRoles) || a.mailboxRoles.length > 10 || !a.mailboxRoles.every((r) => typeof r === 'string' && ROLE_RE.test(r))) {
|
||||
throw new Error(`actions[${i}].mailboxRoles must be a list of folder roles`);
|
||||
}
|
||||
out.mailboxRoles = [...a.mailboxRoles] as string[];
|
||||
}
|
||||
if (a.mimeTypes !== undefined) {
|
||||
if (!Array.isArray(a.mimeTypes) || a.mimeTypes.length > 20 || !a.mimeTypes.every((m) => typeof m === 'string' && MIME_RE.test(m))) {
|
||||
throw new Error(`actions[${i}].mimeTypes must be a list of MIME types`);
|
||||
}
|
||||
out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase());
|
||||
}
|
||||
if (a.danger !== undefined) out.danger = a.danger === true;
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
// pluginId → surface → actions
|
||||
const registry = new Map<string, Map<string, RegisteredAction[]>>();
|
||||
const listeners = new Set<() => void>();
|
||||
const snapshots = new Map<string, RegisteredAction[]>();
|
||||
|
||||
function changed(): void {
|
||||
snapshots.clear();
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeActions(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
export function setPluginActions(pluginId: string, pluginName: string, surface: string, actions: PluginAction[]): void {
|
||||
let surfaces = registry.get(pluginId);
|
||||
if (!surfaces) {
|
||||
surfaces = new Map();
|
||||
registry.set(pluginId, surfaces);
|
||||
}
|
||||
if (actions.length === 0) surfaces.delete(surface);
|
||||
else surfaces.set(surface, actions.map((a) => ({ ...a, pluginId, pluginName })));
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Drop everything a plugin declared (it was turned off or removed). */
|
||||
export function clearPluginActions(pluginId: string): void {
|
||||
if (registry.delete(pluginId)) changed();
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions on a surface, in order. The same array is returned until
|
||||
* something changes, so it can feed useSyncExternalStore.
|
||||
*/
|
||||
export function actionsFor(surface: string): RegisteredAction[] {
|
||||
const cached = snapshots.get(surface);
|
||||
if (cached) return cached;
|
||||
const out: RegisteredAction[] = [];
|
||||
for (const surfaces of registry.values()) out.push(...(surfaces.get(surface) ?? []));
|
||||
out.sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.label.localeCompare(b.label));
|
||||
snapshots.set(surface, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether an action applies to a folder role and/or a MIME type. */
|
||||
export function actionApplies(action: PluginAction, ctx: { mailboxRole?: string | null; mimeType?: string | null }): boolean {
|
||||
if (action.mailboxRoles && ctx.mailboxRole !== undefined && !action.mailboxRoles.includes(ctx.mailboxRole ?? '')) return false;
|
||||
if (action.mimeTypes && ctx.mimeType !== undefined) {
|
||||
const type = (ctx.mimeType ?? '').toLowerCase();
|
||||
if (!action.mimeTypes.some((m) => (m.endsWith('/') || m.endsWith('/*') ? type.startsWith(m.replace(/\*$/, '')) : type === m))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetActions(): void {
|
||||
registry.clear();
|
||||
changed();
|
||||
}
|
||||
|
||||
// ─── Clicks ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Delivers a click to the plugin. Set by the loader, which owns the running
|
||||
* plugins; it filters `context` by the surface's props and the plugin's
|
||||
* permissions before anything crosses into the sandbox.
|
||||
*/
|
||||
export type ActionDispatcher = (pluginId: string, surface: string, actionId: string, context: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
let dispatcher: ActionDispatcher | null = null;
|
||||
|
||||
export function setActionDispatcher(next: ActionDispatcher | null): void {
|
||||
dispatcher = next;
|
||||
}
|
||||
|
||||
export async function runAction(action: RegisteredAction, surface: string, context: Record<string, unknown>): Promise<void> {
|
||||
if (!dispatcher) return;
|
||||
await dispatcher(action.pluginId, surface, action.id, context);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Short labels and dots plugins put on calendar days (`api.ui.setDayLabels`):
|
||||
// a holiday name under the day header, a dot in the small month calendar.
|
||||
// Data only; the host draws them.
|
||||
|
||||
export interface DayLabel {
|
||||
/** YYYY-MM-DD */
|
||||
date: string;
|
||||
/** Shown under the day's name in the day and week views (max 24 characters). */
|
||||
text?: string;
|
||||
/** Tooltip (max 120 characters). */
|
||||
title?: string;
|
||||
/** A dot on the day in the small month calendar. */
|
||||
dot?: boolean;
|
||||
}
|
||||
|
||||
export interface RegisteredDayLabel extends DayLabel {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export const MAX_DAY_LABELS = 400;
|
||||
const DATE_RE = /^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/;
|
||||
|
||||
export function checkDayLabels(value: unknown): DayLabel[] {
|
||||
if (!Array.isArray(value)) throw new Error('labels must be a list');
|
||||
if (value.length > MAX_DAY_LABELS) throw new Error(`At most ${MAX_DAY_LABELS} day labels`);
|
||||
return value.map((raw, i) => {
|
||||
if (!raw || typeof raw !== 'object') throw new Error(`labels[${i}] must be an object`);
|
||||
const l = raw as Record<string, unknown>;
|
||||
if (typeof l.date !== 'string' || !DATE_RE.test(l.date)) throw new Error(`labels[${i}].date must be YYYY-MM-DD`);
|
||||
const out: DayLabel = { date: l.date };
|
||||
if (l.text !== undefined) {
|
||||
if (typeof l.text !== 'string' || l.text.length > 24) throw new Error(`labels[${i}].text must be at most 24 characters`);
|
||||
if (l.text.trim()) out.text = l.text.trim();
|
||||
}
|
||||
if (l.title !== undefined) {
|
||||
if (typeof l.title !== 'string' || l.title.length > 120) throw new Error(`labels[${i}].title must be at most 120 characters`);
|
||||
if (l.title.trim()) out.title = l.title.trim();
|
||||
}
|
||||
if (l.dot !== undefined) out.dot = l.dot === true;
|
||||
if (!out.text && !out.dot) throw new Error(`labels[${i}] needs text or dot`);
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
const byPlugin = new Map<string, RegisteredDayLabel[]>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: Map<string, RegisteredDayLabel[]> | null = null;
|
||||
|
||||
function changed(): void {
|
||||
snapshot = null;
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribeDayLabels(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
export function setDayLabels(pluginId: string, pluginName: string, labels: DayLabel[]): void {
|
||||
if (labels.length === 0) byPlugin.delete(pluginId);
|
||||
else byPlugin.set(pluginId, labels.map((l) => ({ ...l, pluginId, pluginName })));
|
||||
changed();
|
||||
}
|
||||
|
||||
export function clearDayLabels(pluginId: string): void {
|
||||
if (byPlugin.delete(pluginId)) changed();
|
||||
}
|
||||
|
||||
/** Labels by date. Stable between changes (for useSyncExternalStore). */
|
||||
export function dayLabelsByDate(): Map<string, RegisteredDayLabel[]> {
|
||||
if (snapshot) return snapshot;
|
||||
const out = new Map<string, RegisteredDayLabel[]>();
|
||||
for (const labels of byPlugin.values()) {
|
||||
for (const l of labels) {
|
||||
const list = out.get(l.date);
|
||||
if (list) list.push(l);
|
||||
else out.set(l.date, [l]);
|
||||
}
|
||||
}
|
||||
snapshot = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetDayLabels(): void {
|
||||
byPlugin.clear();
|
||||
changed();
|
||||
}
|
||||
@@ -3,7 +3,11 @@
|
||||
// structured-cloneable data back to the iframe.
|
||||
|
||||
import type { InstalledPlugin, Permission } from '../plugin-types';
|
||||
import { pluginHasPermission } from './permissions';
|
||||
import { mayOfferSlot, pluginHasPermission } from './permissions';
|
||||
import { proxyAccounts, proxyCancel, proxyConnect, proxyDisconnect, proxyFetch, type StreamSink } from './proxy-client';
|
||||
import { checkActions, isActionSurface, setPluginActions } from './actions';
|
||||
import { checkDayLabels, setDayLabels } from './day-labels';
|
||||
import { checkPages, setPluginPages } from './pages';
|
||||
import { toast as appToast } from '@/stores/toast-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
@@ -169,6 +173,17 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
|
||||
// redirect URI the provider sends the user back to.
|
||||
'oauth.createState': 'http:fetch',
|
||||
'oauth.getRedirectUri': 'http:fetch',
|
||||
// Requests with a credential, through the server (lib/plugin-secrets).
|
||||
'proxy.fetch': 'http:proxy',
|
||||
'proxy.cancel': 'http:proxy',
|
||||
'proxy.connect': 'http:proxy',
|
||||
'proxy.accounts': 'http:proxy',
|
||||
'proxy.disconnect': 'http:proxy',
|
||||
// Buttons and menu entries drawn by the host; the surface's own
|
||||
// permission is checked per call (see the slot catalogue).
|
||||
'ui.setActions': null,
|
||||
'ui.setDayLabels': 'ui:calendar-action',
|
||||
'ui.setPages': 'ui:app-page',
|
||||
// sieve (delivery-time classification)
|
||||
'sieve.isSupported': 'filters:read',
|
||||
'sieve.getActiveScript': 'filters:read',
|
||||
@@ -1576,7 +1591,7 @@ export async function dispatchApiCall(
|
||||
plugin: InstalledPlugin,
|
||||
method: string,
|
||||
args: unknown[],
|
||||
opts?: { privileged?: boolean },
|
||||
opts?: { privileged?: boolean; sink?: StreamSink },
|
||||
): Promise<unknown> {
|
||||
// Tier gate: privileged-only methods are refused for untrusted (null-origin)
|
||||
// instances even if the permission is somehow present. Defence-in-depth on
|
||||
@@ -1596,6 +1611,22 @@ export async function dispatchApiCall(
|
||||
}
|
||||
|
||||
switch (method) {
|
||||
case 'proxy.fetch': return proxyFetch(plugin, args, opts?.sink ?? null);
|
||||
case 'proxy.cancel': proxyCancel(plugin, args[0]); return undefined;
|
||||
case 'proxy.connect': return proxyConnect(plugin, args);
|
||||
case 'proxy.accounts': return proxyAccounts(plugin);
|
||||
case 'proxy.disconnect': return proxyDisconnect(plugin, args);
|
||||
|
||||
case 'ui.setActions': {
|
||||
const surface = args[0];
|
||||
if (typeof surface !== 'string' || !isActionSurface(surface)) throw new Error(`"${String(surface)}" is not a surface that takes actions`);
|
||||
if (!mayOfferSlot(plugin, surface)) throw new Error(`Plugin "${plugin.id}" may not add actions to "${surface}"`);
|
||||
setPluginActions(plugin.id, plugin.name, surface, checkActions(args[1]));
|
||||
return undefined;
|
||||
}
|
||||
case 'ui.setDayLabels': setDayLabels(plugin.id, plugin.name, checkDayLabels(args[0])); return undefined;
|
||||
case 'ui.setPages': setPluginPages(plugin.id, plugin.name, checkPages(args[0])); return undefined;
|
||||
|
||||
case 'storage.get': return storageGet(plugin.id, args[0] as string);
|
||||
case 'storage.set': storageSet(plugin.id, args[0] as string, args[1]); return undefined;
|
||||
case 'storage.remove': storageRemove(plugin.id, args[0] as string); return undefined;
|
||||
|
||||
@@ -15,7 +15,7 @@ import { snapshotHostTheme, type ThemeSnapshot } from './host-theme';
|
||||
import { effectiveSettings } from '../plugin-settings';
|
||||
import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout';
|
||||
import type {
|
||||
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
|
||||
SandboxToHost, HostToSandbox, InitMsg, InitPayload, StreamChunkMsg, StreamEndMsg, StreamProgressMsg,
|
||||
} from './protocol';
|
||||
|
||||
// ─── Callback marshalling ────────────────────────────────────
|
||||
@@ -249,7 +249,7 @@ export class SandboxInstance {
|
||||
const { id, method, args } = msg;
|
||||
void (async () => {
|
||||
try {
|
||||
const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged });
|
||||
const result = await dispatchApiCall(this.plugin, method, args ?? [], { privileged: this.privileged, sink: this });
|
||||
this.send({ type: 'api-response', id, ok: true, result });
|
||||
} catch (err) {
|
||||
this.send({ type: 'api-response', id, ok: false, error: (err as Error).message ?? String(err) });
|
||||
@@ -353,6 +353,17 @@ export class SandboxInstance {
|
||||
return p;
|
||||
}
|
||||
|
||||
/** Streamed proxy responses and upload progress (see proxy-client.ts). */
|
||||
sendStream(msg: StreamChunkMsg | StreamEndMsg | StreamProgressMsg): void {
|
||||
if (this.destroyed) return;
|
||||
if (msg.type === 'stream-chunk') {
|
||||
// Hand the buffer over instead of copying it.
|
||||
this.iframe.contentWindow?.postMessage(msg, '*', [msg.chunk]);
|
||||
return;
|
||||
}
|
||||
this.send(msg);
|
||||
}
|
||||
|
||||
setLocale(locale: string): void {
|
||||
if (this.destroyed) return;
|
||||
this.send({ type: 'locale-change', locale });
|
||||
|
||||
@@ -21,7 +21,13 @@ import { resolvePluginTier } from './tier';
|
||||
import { register as registerActive, deregister as deregisterActive, all as allActiveEntries } from './registry';
|
||||
import { cancelPluginDialogs } from './host-api';
|
||||
import { registerShortcuts } from './shortcuts';
|
||||
import { mayOfferSlot, mayRegisterHook, pluginHasPermission } from './permissions';
|
||||
import { mayOfferSlot, mayRegisterHook, pluginHasPermission, slotPropsFor } from './permissions';
|
||||
import { clearPluginActions, setActionDispatcher } from './actions';
|
||||
import { clearDayLabels } from './day-labels';
|
||||
import { clearPluginPages } from './pages';
|
||||
import { cancelPluginProxyRequests } from './proxy-client';
|
||||
import { cancelConnectFor } from './connect-dialog';
|
||||
import type { SlotName } from '../plugin-types';
|
||||
|
||||
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
|
||||
|
||||
@@ -182,6 +188,9 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
const hookDisposables: Disposable[] = [];
|
||||
for (const hookName of info.hooks) {
|
||||
if (hookName.startsWith('shortcut:')) continue;
|
||||
// Not a bus: the host calls it directly for clicks on this plugin's own
|
||||
// actions (see setActionDispatcher below).
|
||||
if (hookName === 'onAction') continue;
|
||||
const bus = HOOK_BUSES[hookName];
|
||||
if (!bus) {
|
||||
console.warn(`[plugin-sandbox] Plugin "${plugin.id}" registered unknown hook "${hookName}"`);
|
||||
@@ -237,6 +246,22 @@ export async function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Action clicks ────────────────────────────────────────────
|
||||
|
||||
// A click on a button a plugin declared (api.ui.setActions) goes to that
|
||||
// plugin's background instance as its `onAction` hook, with the surface's
|
||||
// context cut down to what the plugin may see.
|
||||
setActionDispatcher(async (pluginId, surface, actionId, context) => {
|
||||
const entry = allActiveEntries().find((e) => e.plugin.id === pluginId);
|
||||
if (!entry) return;
|
||||
const visible = slotPropsFor(entry.plugin, surface as SlotName, context);
|
||||
try {
|
||||
await entry.background.invokeHook('onAction', [{ surface, actionId, context: visible }]);
|
||||
} catch (err) {
|
||||
pluginErrorTracker.record(pluginId, err);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Unload ───────────────────────────────────────────────────
|
||||
|
||||
export function unloadSandboxedPlugin(pluginId: string): void {
|
||||
@@ -249,6 +274,13 @@ export function unloadSandboxedPlugin(pluginId: string): void {
|
||||
// Drop any message-list category tabs the plugin registered so the strip
|
||||
// disappears (and the inbox unfilters) the moment the plugin is disabled.
|
||||
try { useMessageListTabsStore.getState().clearTabs(pluginId); } catch { /* ignore */ }
|
||||
// Same for the buttons, day labels and pages it declared, and anything
|
||||
// still running through the proxy for it.
|
||||
clearPluginActions(pluginId);
|
||||
clearDayLabels(pluginId);
|
||||
clearPluginPages(pluginId);
|
||||
cancelPluginProxyRequests(pluginId);
|
||||
cancelConnectFor(pluginId);
|
||||
try { entry.background.destroy(); } catch { /* ignore */ }
|
||||
cancelPluginDialogs(pluginId);
|
||||
pluginErrorTracker.reset(pluginId);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Full pages plugins add to the app (`api.ui.setPages`): each gets an entry
|
||||
// in the navigation rail and its own route, /apps/<pluginId>/<pageId>, where
|
||||
// the plugin's `app-page` slot fills the content area.
|
||||
|
||||
export interface PluginPage {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Name of a host icon (see components/icons). */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface RegisteredPage extends PluginPage {
|
||||
pluginId: string;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
export const MAX_PAGES_PER_PLUGIN = 3;
|
||||
const ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
|
||||
const ICON_RE = /^[A-Za-z0-9-]{1,40}$/;
|
||||
|
||||
export function checkPages(value: unknown): PluginPage[] {
|
||||
if (!Array.isArray(value)) throw new Error('pages must be a list');
|
||||
if (value.length > MAX_PAGES_PER_PLUGIN) throw new Error(`At most ${MAX_PAGES_PER_PLUGIN} pages`);
|
||||
const seen = new Set<string>();
|
||||
return value.map((raw, i) => {
|
||||
if (!raw || typeof raw !== 'object') throw new Error(`pages[${i}] must be an object`);
|
||||
const p = raw as Record<string, unknown>;
|
||||
if (typeof p.id !== 'string' || !ID_RE.test(p.id)) throw new Error(`pages[${i}].id must be lowercase letters, digits and hyphens`);
|
||||
if (seen.has(p.id)) throw new Error(`pages[${i}].id is used twice`);
|
||||
seen.add(p.id);
|
||||
if (typeof p.title !== 'string' || !p.title.trim() || p.title.length > 40) throw new Error(`pages[${i}].title is required (max 40 characters)`);
|
||||
const out: PluginPage = { id: p.id, title: p.title.trim() };
|
||||
if (p.icon !== undefined) {
|
||||
if (typeof p.icon !== 'string' || !ICON_RE.test(p.icon)) throw new Error(`pages[${i}].icon is invalid`);
|
||||
out.icon = p.icon;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
const byPlugin = new Map<string, RegisteredPage[]>();
|
||||
const listeners = new Set<() => void>();
|
||||
let snapshot: RegisteredPage[] | null = null;
|
||||
|
||||
function changed(): void {
|
||||
snapshot = null;
|
||||
for (const l of listeners) {
|
||||
try { l(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
export function subscribePages(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
export function setPluginPages(pluginId: string, pluginName: string, pages: PluginPage[]): void {
|
||||
if (pages.length === 0) byPlugin.delete(pluginId);
|
||||
else byPlugin.set(pluginId, pages.map((p) => ({ ...p, pluginId, pluginName })));
|
||||
changed();
|
||||
}
|
||||
|
||||
export function clearPluginPages(pluginId: string): void {
|
||||
if (byPlugin.delete(pluginId)) changed();
|
||||
}
|
||||
|
||||
/** Every page, plugins in install order. Stable between changes. */
|
||||
export function allPages(): RegisteredPage[] {
|
||||
if (snapshot) return snapshot;
|
||||
snapshot = [...byPlugin.values()].flat();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function findPage(pluginId: string, pageId: string): RegisteredPage | null {
|
||||
return byPlugin.get(pluginId)?.find((p) => p.id === pageId) ?? null;
|
||||
}
|
||||
|
||||
/** Test helper. */
|
||||
export function resetPages(): void {
|
||||
byPlugin.clear();
|
||||
changed();
|
||||
}
|
||||
@@ -191,8 +191,18 @@ export interface SlotShouldShowMsg {
|
||||
context: unknown;
|
||||
}
|
||||
|
||||
/** Host → sandbox: a piece of a streamed api.proxy.fetch response. */
|
||||
export interface StreamChunkMsg { type: 'stream-chunk'; streamId: string; chunk: ArrayBuffer; }
|
||||
/** Host → sandbox: a streamed response ended (error set when it broke off). */
|
||||
export interface StreamEndMsg { type: 'stream-end'; streamId: string; error?: string; }
|
||||
/** Host → sandbox: upload progress of an api.proxy.fetch request. */
|
||||
export interface StreamProgressMsg { type: 'stream-progress'; streamId: string; loaded: number; total: number; }
|
||||
|
||||
export type HostToSandbox =
|
||||
| InitMsg
|
||||
| StreamChunkMsg
|
||||
| StreamEndMsg
|
||||
| StreamProgressMsg
|
||||
| ApiResponseMsg
|
||||
| CallbackResponseMsg
|
||||
| HookInvokeMsg
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// Host side of `api.proxy`: a plugin's requests that need one of its
|
||||
// credentials go to /api/plugin-proxy, which adds the secret on the server.
|
||||
// Responses come back whole (text, JSON, bytes) or as a stream of chunks for
|
||||
// large downloads and AI answers; uploads report progress.
|
||||
|
||||
import type { InstalledPlugin } from '../plugin-types';
|
||||
import { apiFetch, withBasePath } from '../browser-navigation';
|
||||
import { requestConnect, forgetCredentials, listConnectedAccounts } from './connect-dialog';
|
||||
|
||||
/** Where streamed chunks and upload progress go: the iframe that asked. */
|
||||
export interface StreamSink {
|
||||
sendStream(msg:
|
||||
| { type: 'stream-chunk'; streamId: string; chunk: ArrayBuffer }
|
||||
| { type: 'stream-end'; streamId: string; error?: string }
|
||||
| { type: 'stream-progress'; streamId: string; loaded: number; total: number }): void;
|
||||
}
|
||||
|
||||
/** Largest response returned in one piece; bigger ones need responseType "stream". */
|
||||
export const MAX_PROXY_BUFFERED_BYTES = 25 * 1024 * 1024;
|
||||
/** Largest request body; matches MAX_PROXY_REQUEST_BYTES on the server. */
|
||||
export const MAX_PROXY_UPLOAD_BYTES = 100 * 1024 * 1024;
|
||||
export const MAX_STREAMS_PER_PLUGIN = 8;
|
||||
|
||||
type ResponseType = 'text' | 'json' | 'arraybuffer' | 'stream';
|
||||
|
||||
export interface ProxyInit {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string | ArrayBuffer | Uint8Array | Blob | null;
|
||||
responseType?: ResponseType;
|
||||
/** Set by the runtime: ties chunks and progress to this request. */
|
||||
streamId?: string;
|
||||
/** Store these JSON fields of the response as the credential (see the proxy route). */
|
||||
capture?: { secret: string; username?: string };
|
||||
}
|
||||
|
||||
export interface ProxyResult {
|
||||
status: number;
|
||||
ok: boolean;
|
||||
headers: Record<string, string>;
|
||||
body?: unknown;
|
||||
/** Set when the proxy itself refused, e.g. "rate_limited", "wrong_origin". */
|
||||
proxyError?: string;
|
||||
}
|
||||
|
||||
const HEADER_NAME_RE = /^[A-Za-z0-9!#$%&'*+.^_`|~-]{1,64}$/;
|
||||
const STREAM_ID_RE = /^[a-z0-9]{6,40}$/;
|
||||
const running = new Map<string, Map<string, AbortController>>();
|
||||
|
||||
function credentialSpec(plugin: InstalledPlugin, credentialId: unknown) {
|
||||
if (typeof credentialId !== 'string') throw new Error('credentialId must be a string');
|
||||
const spec = plugin.credentials?.[credentialId];
|
||||
if (!spec) throw new Error(`The manifest declares no credential "${credentialId}"`);
|
||||
return { id: credentialId, spec };
|
||||
}
|
||||
|
||||
function checkInit(raw: unknown): ProxyInit {
|
||||
if (raw === undefined || raw === null) return {};
|
||||
if (typeof raw !== 'object') throw new Error('init must be an object');
|
||||
const init = raw as Record<string, unknown>;
|
||||
const out: ProxyInit = {};
|
||||
if (init.method !== undefined) {
|
||||
if (typeof init.method !== 'string' || !/^[A-Z]{3,10}$/i.test(init.method)) throw new Error('Invalid method');
|
||||
out.method = init.method.toUpperCase();
|
||||
}
|
||||
if (init.headers !== undefined) {
|
||||
if (!init.headers || typeof init.headers !== 'object') throw new Error('headers must be an object');
|
||||
const entries = Object.entries(init.headers as Record<string, unknown>);
|
||||
if (entries.length > 50) throw new Error('Too many headers');
|
||||
out.headers = {};
|
||||
for (const [name, value] of entries) {
|
||||
if (!HEADER_NAME_RE.test(name) || typeof value !== 'string' || value.length > 8192 || /[\r\n]/.test(value)) {
|
||||
throw new Error(`Invalid header "${name}"`);
|
||||
}
|
||||
out.headers[name] = value;
|
||||
}
|
||||
}
|
||||
if (init.body !== undefined && init.body !== null) {
|
||||
const b = init.body;
|
||||
const size = typeof b === 'string' ? b.length : b instanceof Blob ? b.size : (b instanceof ArrayBuffer || ArrayBuffer.isView(b)) ? (b as ArrayBuffer).byteLength : -1;
|
||||
if (size < 0) throw new Error('body must be a string, ArrayBuffer, Uint8Array or Blob');
|
||||
if (size > MAX_PROXY_UPLOAD_BYTES) throw new Error(`body is larger than ${MAX_PROXY_UPLOAD_BYTES} bytes; upload it in chunks`);
|
||||
out.body = b as ProxyInit['body'];
|
||||
}
|
||||
if (init.responseType !== undefined) {
|
||||
if (!['text', 'json', 'arraybuffer', 'stream'].includes(init.responseType as string)) throw new Error('Invalid responseType');
|
||||
out.responseType = init.responseType as ResponseType;
|
||||
}
|
||||
if (init.streamId !== undefined) {
|
||||
if (typeof init.streamId !== 'string' || !STREAM_ID_RE.test(init.streamId)) throw new Error('Invalid streamId');
|
||||
out.streamId = init.streamId;
|
||||
}
|
||||
if (init.capture !== undefined) {
|
||||
const c = init.capture as Record<string, unknown>;
|
||||
if (!c || typeof c.secret !== 'string' || (c.username !== undefined && typeof c.username !== 'string')) throw new Error('Invalid capture');
|
||||
out.capture = { secret: c.secret, ...(typeof c.username === 'string' ? { username: c.username } : {}) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function headersOf(res: { headers: Headers }): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
res.headers.forEach((value, name) => { out[name] = value; });
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readCapped(res: Response, limit: number): Promise<ArrayBuffer> {
|
||||
const length = Number(res.headers.get('content-length') ?? '0');
|
||||
if (length > limit) {
|
||||
await res.body?.cancel().catch(() => {});
|
||||
throw new Error(`Response is larger than ${limit} bytes; use responseType "stream"`);
|
||||
}
|
||||
if (!res.body) return new ArrayBuffer(0);
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > limit) {
|
||||
await reader.cancel().catch(() => {});
|
||||
throw new Error(`Response is larger than ${limit} bytes; use responseType "stream"`);
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const c of chunks) { out.set(c, offset); offset += c.byteLength; }
|
||||
return out.buffer;
|
||||
}
|
||||
|
||||
function track(pluginId: string, streamId: string, controller: AbortController): () => void {
|
||||
let streams = running.get(pluginId);
|
||||
if (!streams) {
|
||||
streams = new Map();
|
||||
running.set(pluginId, streams);
|
||||
}
|
||||
if (streams.size >= MAX_STREAMS_PER_PLUGIN) throw new Error(`At most ${MAX_STREAMS_PER_PLUGIN} proxy requests at once`);
|
||||
streams.set(streamId, controller);
|
||||
return () => {
|
||||
streams!.delete(streamId);
|
||||
if (streams!.size === 0) running.delete(pluginId);
|
||||
};
|
||||
}
|
||||
|
||||
/** Upload with progress reports; resolves with a fetch-like Response. */
|
||||
function sendWithProgress(url: string, method: string, headers: Record<string, string>, body: XMLHttpRequestBodyInit, signal: AbortSignal, onProgress: (loaded: number, total: number) => void): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url);
|
||||
xhr.responseType = 'arraybuffer';
|
||||
for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value);
|
||||
xhr.setRequestHeader('x-bulwark-method', method);
|
||||
xhr.upload.onprogress = (e) => onProgress(e.loaded, e.lengthComputable ? e.total : 0);
|
||||
xhr.onload = () => {
|
||||
const h = new Headers();
|
||||
for (const line of xhr.getAllResponseHeaders().trim().split(/[\r\n]+/)) {
|
||||
const i = line.indexOf(':');
|
||||
if (i > 0) h.append(line.slice(0, i).trim(), line.slice(i + 1).trim());
|
||||
}
|
||||
resolve(new Response(xhr.status === 204 || xhr.status === 304 ? null : xhr.response as ArrayBuffer, { status: xhr.status, headers: h }));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Network error'));
|
||||
xhr.onabort = () => reject(new DOMException('Aborted', 'AbortError'));
|
||||
signal.addEventListener('abort', () => xhr.abort(), { once: true });
|
||||
xhr.send(body);
|
||||
});
|
||||
}
|
||||
|
||||
/** `api.proxy.fetch(credentialId, url, init)`. */
|
||||
export async function proxyFetch(plugin: InstalledPlugin, args: unknown[], sink: StreamSink | null): Promise<ProxyResult> {
|
||||
const { id: credentialId } = credentialSpec(plugin, args[0]);
|
||||
if (typeof args[1] !== 'string' || args[1].length > 8192) throw new Error('url must be a string');
|
||||
const target = args[1];
|
||||
const init = checkInit(args[2]);
|
||||
const method = init.method ?? (init.body ? 'POST' : 'GET');
|
||||
const responseType = init.responseType ?? 'text';
|
||||
const streamId = init.streamId ?? Math.random().toString(36).slice(2, 12);
|
||||
if (init.capture && plugin.credentials?.[credentialId]?.scope !== 'user') throw new Error('capture needs a user credential');
|
||||
|
||||
const query = new URLSearchParams({ plugin: plugin.id, credential: credentialId, url: target });
|
||||
const url = withBasePath(`/api/plugin-proxy?${query}`);
|
||||
const headers: Record<string, string> = { ...(init.headers ?? {}) };
|
||||
if (init.capture) headers['x-bulwark-capture'] = JSON.stringify(init.capture);
|
||||
|
||||
const controller = new AbortController();
|
||||
const untrack = track(plugin.id, streamId, controller);
|
||||
let streaming = false;
|
||||
try {
|
||||
let res: Response;
|
||||
if (init.body && sink && typeof XMLHttpRequest !== 'undefined') {
|
||||
res = await sendWithProgress(url, method, headers, init.body as XMLHttpRequestBodyInit, controller.signal,
|
||||
(loaded, total) => sink.sendStream({ type: 'stream-progress', streamId, loaded, total }));
|
||||
} else {
|
||||
res = await apiFetch(`/api/plugin-proxy?${query}`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'x-bulwark-method': method },
|
||||
body: (init.body ?? undefined) as BodyInit | undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
}
|
||||
|
||||
const result: ProxyResult = { status: res.status, ok: res.ok, headers: headersOf(res) };
|
||||
const proxyRefused = !!res.headers.get('x-bulwark-proxy-error');
|
||||
|
||||
if (responseType === 'stream' && sink && res.body && res.ok) {
|
||||
streaming = true;
|
||||
const reader = res.body.getReader();
|
||||
void (async () => {
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const copy = value.slice().buffer;
|
||||
sink.sendStream({ type: 'stream-chunk', streamId, chunk: copy });
|
||||
}
|
||||
sink.sendStream({ type: 'stream-end', streamId });
|
||||
} catch (err) {
|
||||
sink.sendStream({ type: 'stream-end', streamId, error: controller.signal.aborted ? 'cancelled' : (err as Error).message });
|
||||
} finally {
|
||||
untrack();
|
||||
}
|
||||
})();
|
||||
return result;
|
||||
}
|
||||
|
||||
const bytes = await readCapped(res, MAX_PROXY_BUFFERED_BYTES);
|
||||
if (proxyRefused) {
|
||||
result.proxyError = res.headers.get('x-bulwark-proxy-error') ?? 'refused';
|
||||
try {
|
||||
result.body = (JSON.parse(new TextDecoder().decode(bytes)) as { error?: string }).error ?? '';
|
||||
} catch {
|
||||
result.body = '';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (responseType === 'arraybuffer') result.body = bytes;
|
||||
else {
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
if (responseType === 'json') {
|
||||
try { result.body = text ? JSON.parse(text) : null; } catch { throw new Error(`Response is not JSON (HTTP ${res.status})`); }
|
||||
} else {
|
||||
result.body = text;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
if (!streaming) untrack();
|
||||
}
|
||||
}
|
||||
|
||||
/** `api.proxy.cancel(streamId)`: stop a running request or stream. */
|
||||
export function proxyCancel(plugin: InstalledPlugin, streamId: unknown): void {
|
||||
if (typeof streamId !== 'string') return;
|
||||
running.get(plugin.id)?.get(streamId)?.abort();
|
||||
}
|
||||
|
||||
/** Stop everything a plugin has running (it was turned off). */
|
||||
export function cancelPluginProxyRequests(pluginId: string): void {
|
||||
for (const controller of running.get(pluginId)?.values() ?? []) controller.abort();
|
||||
running.delete(pluginId);
|
||||
}
|
||||
|
||||
/** `api.proxy.connect(credentialId, { mode, server, username })`. */
|
||||
export async function proxyConnect(plugin: InstalledPlugin, args: unknown[]): Promise<{ connected: boolean; origin?: string }> {
|
||||
const { id, spec } = credentialSpec(plugin, args[0]);
|
||||
if (spec.scope !== 'user') throw new Error(`"${id}" is set up by the administrator`);
|
||||
const opts = (args[1] && typeof args[1] === 'object' ? args[1] : {}) as Record<string, unknown>;
|
||||
const mode = opts.mode === 'signin' ? 'signin' : 'password';
|
||||
return requestConnect({
|
||||
pluginId: plugin.id,
|
||||
pluginName: plugin.name,
|
||||
credentialId: id,
|
||||
spec,
|
||||
mode,
|
||||
...(typeof opts.server === 'string' && opts.server.length < 2048 ? { suggestedOrigin: opts.server } : {}),
|
||||
...(typeof opts.username === 'string' && opts.username.length < 256 ? { suggestedUsername: opts.username } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** `api.proxy.accounts()`: which of its credentials are connected, without secrets. */
|
||||
export async function proxyAccounts(plugin: InstalledPlugin): Promise<{ credentialId: string; origin: string; username?: string; connected: boolean }[]> {
|
||||
const list = await listConnectedAccounts(plugin.id);
|
||||
return list.map(({ credentialId, origin, username, connected }) => ({ credentialId, origin, ...(username ? { username } : {}), connected }));
|
||||
}
|
||||
|
||||
/** `api.proxy.disconnect(credentialId)`. */
|
||||
export async function proxyDisconnect(plugin: InstalledPlugin, args: unknown[]): Promise<boolean> {
|
||||
const { id } = credentialSpec(plugin, args[0]);
|
||||
return forgetCredentials(plugin.id, id);
|
||||
}
|
||||
@@ -190,6 +190,61 @@ function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_AP
|
||||
});
|
||||
}
|
||||
|
||||
// ─── api.proxy streams ───────────────────────────────────────
|
||||
|
||||
interface ProxyStream {
|
||||
controller?: ReadableStreamDefaultController<Uint8Array>;
|
||||
onProgress?: (loaded: number, total: number) => void;
|
||||
}
|
||||
const proxyStreams = new Map<string, ProxyStream>();
|
||||
|
||||
interface ProxyFetchInit {
|
||||
method?: string;
|
||||
headers?: Record<string, string>;
|
||||
body?: string | ArrayBuffer | Uint8Array | Blob | null;
|
||||
/** "text" (default), "json", "arraybuffer", or "stream" for a ReadableStream body. */
|
||||
responseType?: 'text' | 'json' | 'arraybuffer' | 'stream';
|
||||
/** Upload progress, in bytes. */
|
||||
onProgress?: (loaded: number, total: number) => void;
|
||||
/** Store these JSON fields of the response as the credential instead of returning them. */
|
||||
capture?: { secret: string; username?: string };
|
||||
/** Stops the request (and a streamed body). */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
async function proxyFetch(credentialId: string, url: string, init: ProxyFetchInit = {}): Promise<unknown> {
|
||||
const streamId = uid().replace(/[^a-z0-9]/g, '').slice(0, 24);
|
||||
const entry: ProxyStream = { onProgress: init.onProgress };
|
||||
proxyStreams.set(streamId, entry);
|
||||
const stream = init.responseType === 'stream'
|
||||
? new ReadableStream<Uint8Array>({
|
||||
start(controller) { entry.controller = controller; },
|
||||
cancel() {
|
||||
proxyStreams.delete(streamId);
|
||||
void callApi('proxy.cancel', [streamId]).catch(() => {});
|
||||
},
|
||||
})
|
||||
: null;
|
||||
const abort = () => {
|
||||
void callApi('proxy.cancel', [streamId]).catch(() => {});
|
||||
try { entry.controller?.error(new Error('cancelled')); } catch { /* closed */ }
|
||||
proxyStreams.delete(streamId);
|
||||
};
|
||||
init.signal?.addEventListener('abort', abort, { once: true });
|
||||
const { onProgress: _p, signal: _s, ...plain } = init;
|
||||
void _p; void _s;
|
||||
try {
|
||||
// No overall timeout: a big upload or a long AI answer takes as long as
|
||||
// it takes, and api.proxy.cancel / the signal stop it.
|
||||
const res = await callApi('proxy.fetch', [credentialId, url, { ...plain, streamId }], 0) as Record<string, unknown>;
|
||||
if (!stream || !res.ok) proxyStreams.delete(streamId);
|
||||
return stream && res.ok ? { ...res, body: stream } : res;
|
||||
} catch (err) {
|
||||
proxyStreams.delete(streamId);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function invokeHostCallback(callbackId: string, args: unknown[]): Promise<unknown> {
|
||||
const id = uid();
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -421,6 +476,31 @@ function buildPluginApi(manifest: PluginManifest) {
|
||||
/** Downloads a file generated by the plugin. Not a user's file or attachment. */
|
||||
downloadFile: (opts: { content: string; filename: string; contentType?: string }) =>
|
||||
callApi('ui.downloadFile', [opts]) as Promise<void>,
|
||||
/**
|
||||
* Buttons or menu entries on an action surface (see the slot catalogue:
|
||||
* message rows, the selection toolbar, attachment menus, the command
|
||||
* palette, ...). Replaces what this plugin set there before; [] removes
|
||||
* them. Clicks arrive in the `onAction({ surface, actionId, context })` hook.
|
||||
*/
|
||||
setActions: (surface: string, actions: Array<{ id: string; label: string; icon?: string; order?: number; mailboxRoles?: string[]; mimeTypes?: string[]; danger?: boolean }>) =>
|
||||
callApi('ui.setActions', [surface, actions]) as Promise<void>,
|
||||
/** Short labels and dots on calendar days. Replaces this plugin's earlier labels. */
|
||||
setDayLabels: (labels: Array<{ date: string; text?: string; title?: string; dot?: boolean }>) =>
|
||||
callApi('ui.setDayLabels', [labels]) as Promise<void>,
|
||||
/** Full pages with a button in the navigation bar; the `app-page` slot renders them. */
|
||||
setPages: (pages: Array<{ id: string; title: string; icon?: string }>) =>
|
||||
callApi('ui.setPages', [pages]) as Promise<void>,
|
||||
},
|
||||
// Requests with an account or key the manifest declares under
|
||||
// `credentials`; the server adds the secret (permission http:proxy).
|
||||
proxy: {
|
||||
fetch: proxyFetch,
|
||||
/** Show the host's dialog to connect a user credential. */
|
||||
connect: (credentialId: string, opts?: { mode?: 'password' | 'signin'; server?: string; username?: string }) =>
|
||||
callApi('proxy.connect', [credentialId, opts ?? {}], 0) as Promise<{ connected: boolean; origin?: string }>,
|
||||
/** Which credentials are connected (no secrets). */
|
||||
accounts: () => callApi('proxy.accounts', []) as Promise<Array<{ credentialId: string; origin: string; username?: string; connected: boolean }>>,
|
||||
disconnect: (credentialId: string) => callApi('proxy.disconnect', [credentialId]) as Promise<boolean>,
|
||||
},
|
||||
// Email keyword mutations (permission: email:write). Keywords follow JMAP
|
||||
// syntax, e.g. '$category-promotions' or '$label:<tagId>'.
|
||||
@@ -840,6 +920,26 @@ function handleHostMessage(ev: MessageEvent): void {
|
||||
case 'props-update':
|
||||
slotPropsUpdater?.(msg.props ?? {});
|
||||
break;
|
||||
|
||||
case 'stream-chunk': {
|
||||
const entry = proxyStreams.get(msg.streamId);
|
||||
try { entry?.controller?.enqueue(new Uint8Array(msg.chunk)); } catch { /* cancelled */ }
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream-end': {
|
||||
const entry = proxyStreams.get(msg.streamId);
|
||||
proxyStreams.delete(msg.streamId);
|
||||
try {
|
||||
if (msg.error) entry?.controller?.error(new Error(msg.error));
|
||||
else entry?.controller?.close();
|
||||
} catch { /* already closed */ }
|
||||
break;
|
||||
}
|
||||
|
||||
case 'stream-progress':
|
||||
try { proxyStreams.get(msg.streamId)?.onProgress?.(msg.loaded, msg.total); } catch { /* plugin's own error */ }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user