diff --git a/app/(main)/[locale]/apps/[[...segments]]/page.tsx b/app/(main)/[locale]/apps/[[...segments]]/page.tsx
new file mode 100644
index 000000000..bd91cb2f0
--- /dev/null
+++ b/app/(main)/[locale]/apps/[[...segments]]/page.tsx
@@ -0,0 +1,26 @@
+import { PluginPageApp } from "@/components/plugins/plugin-page-app";
+import { generateLiteSegmentParams } from "@/lib/lite-static-params";
+
+// Pages plugins add with api.ui.setPages: /apps//[/].
+// Anything after the page id reaches the plugin's `app-page` slot as `path`.
+// A catch-all rather than [pluginId]/[pageId] so the static Lite export can
+// prerender the bare route; plugins never run in Lite, so it only ever shows
+// the "not available" note there (lib/lite-static-params.ts).
+export const generateStaticParams = generateLiteSegmentParams;
+
+function decodeOnce(segment: string): string {
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
+}
+
+export default async function PluginPageRoute({
+ params,
+}: {
+ params: Promise<{ segments?: string[] }>;
+}) {
+ const { segments } = await params;
+ return ;
+}
diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index 5e6f64cac..b4e444621 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -9,8 +9,11 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
+import { CommandPaletteHost } from "@/components/command-palette/command-palette";
+import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
import { QuickRuleHost } from "@/components/filters/quick-rule-host";
import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog";
+import { PluginAppEvents } from "@/components/providers/plugin-app-events";
import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
@@ -56,9 +59,12 @@ export default async function LocaleLayout({
{children}
+
+
+
diff --git a/app/(main)/admin/_tabs/plugin-approvals-panel.tsx b/app/(main)/admin/_tabs/plugin-approvals-panel.tsx
new file mode 100644
index 000000000..51fe96758
--- /dev/null
+++ b/app/(main)/admin/_tabs/plugin-approvals-panel.tsx
@@ -0,0 +1,192 @@
+'use client';
+
+import { useCallback, useEffect, useState } from 'react';
+import { AlertTriangle, Check, Code, Loader2, ShieldCheck, X } from '@/components/icons';
+import { apiFetch } from '@/lib/browser-navigation';
+import { HIGH_RISK_PERMISSIONS } from '@/lib/plugin-sandbox/permission-text';
+
+interface ApprovalEntry {
+ pluginId: string;
+ bundleHash: string;
+ status: 'pending' | 'approved' | 'denied';
+ manifest: {
+ name?: string;
+ version?: string;
+ author?: string;
+ description?: string;
+ permissions?: string[];
+ httpOrigins?: string[];
+ apiPostPaths?: string[];
+ };
+ requestedBy: string;
+ requestedAt: string;
+ decidedAt?: string;
+}
+
+interface SourceView {
+ entry: ApprovalEntry;
+ code?: string;
+ warnings?: string[];
+ error?: string;
+}
+
+const STATUS_STYLE: Record = {
+ pending: 'bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400',
+ approved: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/30 dark:text-emerald-400',
+ denied: 'bg-muted text-muted-foreground',
+};
+
+/**
+ * Plugins users installed themselves and asked to have approved. The admin
+ * decides on the package the user uploaded: its manifest and code are read
+ * on the server, and the approval is pinned to that exact package.
+ */
+export function PluginApprovalsPanel() {
+ const [entries, setEntries] = useState(null);
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(null);
+ const [source, setSource] = useState(null);
+
+ const load = useCallback(async () => {
+ try {
+ const res = await apiFetch('/api/admin/plugin-approvals');
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json() as { entries: ApprovalEntry[] };
+ setEntries([...data.entries].sort((a, b) =>
+ Number(b.status === 'pending') - Number(a.status === 'pending') || b.requestedAt.localeCompare(a.requestedAt)));
+ setError(null);
+ } catch (e) {
+ setError(`Could not load approval requests (${(e as Error).message})`);
+ }
+ }, []);
+
+ useEffect(() => { void load(); }, [load]);
+
+ async function decide(entry: ApprovalEntry, decision: 'approved' | 'denied' | 'revoke') {
+ const key = `${entry.pluginId}:${entry.bundleHash}`;
+ setBusy(key);
+ try {
+ const res = decision === 'revoke'
+ ? await apiFetch(`/api/admin/plugin-approvals?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`, { method: 'DELETE' })
+ : await apiFetch('/api/admin/plugin-approvals', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision }),
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ await load();
+ } catch (e) {
+ setError(`Could not save the decision (${(e as Error).message})`);
+ } finally {
+ setBusy(null);
+ }
+ }
+
+ async function viewSource(entry: ApprovalEntry) {
+ setSource({ entry });
+ try {
+ const res = await apiFetch(`/api/admin/plugin-approvals/source?pluginId=${encodeURIComponent(entry.pluginId)}&bundleHash=${encodeURIComponent(entry.bundleHash)}`);
+ const data = await res.json();
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
+ setSource({ entry, code: data.code, warnings: data.warnings });
+ } catch (e) {
+ setSource({ entry, error: (e as Error).message });
+ }
+ }
+
+ const pending = entries?.filter((e) => e.status === 'pending').length ?? 0;
+
+ return (
+
+
+
+
+
Approval Requests
+ {pending > 0 && (
+ {pending} waiting
+ )}
+
+
Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user's mail.
+
+
+ {error &&
{error}
}
+
+ {entries === null ? (
+
+ ) : entries.length === 0 ? (
+
No requests yet
+ ) : (
+
+ {entries.map((entry) => {
+ const key = `${entry.pluginId}:${entry.bundleHash}`;
+ const perms = entry.manifest.permissions ?? [];
+ return (
+
+
+ {entry.manifest.name ?? entry.pluginId}
+ v{entry.manifest.version}
+ {entry.status}
+
+ {entry.manifest.description &&
{entry.manifest.description}
}
+
+ {entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()}
+
+ {perms.length > 0 && (
+
+ {perms.map((p) => (
+ {p}
+ ))}
+
+ )}
+ {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].length > 0 && (
+
+ Can send data to: {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].join(', ')}
+
+ )}
+
+ viewSource(entry)} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md border border-border text-xs hover:bg-accent">
+ View code
+
+ {entry.status !== 'approved' && (
+ decide(entry, 'approved')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-emerald-600 text-white text-xs font-medium hover:bg-emerald-700 disabled:opacity-50">
+ Approve
+
+ )}
+ {entry.status !== 'denied' && (
+ decide(entry, 'denied')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md bg-muted text-foreground text-xs font-medium hover:bg-accent disabled:opacity-50">
+ Deny
+
+ )}
+ {entry.status !== 'pending' && (
+ decide(entry, 'revoke')} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md text-xs text-muted-foreground hover:text-destructive disabled:opacity-50">
+ Forget decision
+
+ )}
+
+
+ );
+ })}
+
+ )}
+
+ {source && (
+
{ if (e.target === e.currentTarget) setSource(null); }}>
+
+
+ {source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version}
+ setSource(null)} aria-label="Close" className="rounded p-1 hover:bg-accent">
+
+ {source.warnings && source.warnings.length > 0 && (
+
+
{source.warnings.join('; ')}
+
+ )}
+
+ {source.error ?? source.code ?? 'Loading…'}
+
+
+
+ )}
+
+ );
+}
diff --git a/app/(main)/admin/_tabs/plugins.tsx b/app/(main)/admin/_tabs/plugins.tsx
index 55b25b642..908820b7e 100644
--- a/app/(main)/admin/_tabs/plugins.tsx
+++ b/app/(main)/admin/_tabs/plugins.tsx
@@ -6,6 +6,7 @@ import type { SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
import { PluginConfigPanel } from './plugin-config-panel';
+import { PluginApprovalsPanel } from './plugin-approvals-panel';
interface PluginEntry {
id: string;
@@ -374,6 +375,8 @@ export function PluginsTab() {
+ {pluginsUploadEnabled && }
+
diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx
index 45dfe9386..032544b81 100644
--- a/app/(sandbox)/layout.tsx
+++ b/app/(sandbox)/layout.tsx
@@ -1,14 +1,15 @@
import type { Metadata } from 'next';
import type { ReactNode } from 'react';
+import './plugin-kit.css';
// The plugin sandbox iframe runs with an opaque origin (the `sandbox`
// attribute in production excludes `allow-same-origin` for isolation). Any
-// asset request from this layout - bundled fonts, globals.css, etc. - is then
-// cross-origin from the "null" origin to the host origin and gets blocked
-// (fonts in particular require CORS). So this layout is intentionally minimal:
-// no font imports, no CSS imports. Plugins ship their own styles, and both the
-// plugin bundle and all host API calls travel over the postMessage RPC bridge,
-// so the sandbox never fetches same-origin assets itself.
+// asset request from this layout is then cross-origin from the "null" origin
+// to the host origin. Fonts need CORS, so there are no font imports. The one
+// stylesheet is plugin-kit.css, the styles of the plugin UI kit: Next serves
+// it from /_next/static, which answers with Access-Control-Allow-Origin: *
+// (next.config.ts), and the sandbox CSP allows styles from 'self'. Plugin
+// bundles and host API calls travel over the postMessage RPC bridge.
export const metadata: Metadata = {
title: 'Plugin sandbox',
@@ -19,6 +20,11 @@ export default function PluginSandboxLayout({ children }: { children: ReactNode
return (
+ {/* next dev puts its indicator into every document, so each plugin
+ slot showed its own badge. Plugins have no use for it. */}
+ {process.env.NODE_ENV === 'development' && (
+
+ )}
{children}
diff --git a/app/(sandbox)/plugin-kit.css b/app/(sandbox)/plugin-kit.css
new file mode 100644
index 000000000..6265d9eae
--- /dev/null
+++ b/app/(sandbox)/plugin-kit.css
@@ -0,0 +1,76 @@
+/* Styles for the plugin UI kit (lib/plugin-sandbox/ui-kit), loaded into
+ every plugin iframe. Built from the same Tailwind classes as the app's own
+ components, so a kit Button is the app's Button. Only the kit's sources
+ are scanned, which keeps the file small.
+
+ No Tailwind preflight: plugins written before the kit style plain HTML
+ elements and rely on the browser's defaults. The kit's elements get the
+ same reset through the .bk class each kit component puts on its root. */
+
+@layer theme, base, components, utilities;
+@import "tailwindcss/theme.css" layer(theme);
+@import "tailwindcss/utilities.css" layer(utilities) source(none);
+@import "../theme-inline.css";
+
+@source "../../lib/plugin-sandbox/ui-kit";
+@source "../../components/ui/button.tsx";
+@source "../../components/ui/input.tsx";
+@source "../../components/files/file-icons.tsx";
+
+
+@layer base {
+ /* Tailwind's preflight, limited to the kit's own elements. :where() keeps
+ it at zero specificity, so every utility still wins. */
+ :where(.bk, .bk *, .bk ::before, .bk ::after) {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ border: 0 solid var(--color-border);
+ }
+ :where(button.bk, input.bk, select.bk, textarea.bk, .bk button, .bk input, .bk select, .bk textarea) {
+ font: inherit;
+ font-feature-settings: inherit;
+ letter-spacing: inherit;
+ color: inherit;
+ background-color: transparent;
+ border-radius: 0;
+ opacity: 1;
+ }
+ :where(button.bk, select.bk, [role="button"].bk, [role="switch"].bk, [role="checkbox"].bk, .bk button, .bk select, .bk [role="button"], .bk [role="tab"], .bk [role="switch"], .bk [role="checkbox"], .bk [role="option"]):not(:disabled, [aria-disabled="true"]) {
+ cursor: pointer;
+ }
+ :where(button.bk:disabled, select.bk:disabled, .bk[aria-disabled="true"], .bk button:disabled, .bk select:disabled, .bk [aria-disabled="true"]) {
+ cursor: not-allowed;
+ }
+ :where(.bk h1, .bk h2, .bk h3, .bk h4) {
+ font-size: inherit;
+ font-weight: inherit;
+ }
+ :where(a.bk, .bk a) {
+ color: inherit;
+ text-decoration: inherit;
+ }
+ :where(.bk ol, .bk ul) {
+ list-style: none;
+ }
+ :where(.bk img, .bk svg) {
+ display: block;
+ vertical-align: middle;
+ }
+ :where(.bk input::placeholder, .bk textarea::placeholder) {
+ color: var(--color-muted-foreground);
+ opacity: 1;
+ }
+ :where(.bk table) {
+ border-collapse: collapse;
+ text-indent: 0;
+ }
+ /* Same as globals.css: native option lists only follow these. */
+ :where(.bk option, .bk optgroup) {
+ background-color: var(--color-popover);
+ color: var(--color-popover-foreground);
+ }
+ :where(.dark .bk select) {
+ color-scheme: dark;
+ }
+}
diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts
index d62bacf60..3896f05fd 100644
--- a/app/api/admin/marketplace/[slug]/route.ts
+++ b/app/api/admin/marketplace/[slug]/route.ts
@@ -5,8 +5,7 @@ import {
getPluginRegistry,
getThemeRegistry,
} from '@/lib/admin/plugin-registry';
-import JSZip from 'jszip';
-import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
+import { openPackage, readManifest, readText } from '@/lib/extension-package';
import { configManager } from '@/lib/admin/config-manager';
async function getDirectoryUrl(): Promise
{
@@ -79,61 +78,28 @@ export async function GET(
} else {
const buffer = await bundleRes.arrayBuffer();
bundleSize = buffer.byteLength;
- const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
- if (buffer.byteLength > maxSize) {
- bundleError = `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit`;
- } else {
- const zip = await JSZip.loadAsync(buffer);
+ // Opening the preview must not inflate an arbitrary bundle in
+ // full: every read goes through the capped package reader.
+ const pkg = await openPackage(buffer, type === 'theme' ? 'theme' : 'plugin');
+ try {
+ manifest = await readManifest(pkg);
+ } catch (err) {
+ bundleError = err instanceof Error ? err.message : 'Invalid manifest.json in bundle';
+ }
- // Detect optional root directory inside the ZIP.
- const entries = Object.keys(zip.files);
- const topDirs = new Set(entries.map((e) => e.split('/')[0]));
- let root = '';
- if (topDirs.size === 1) {
- const dir = [...topDirs][0];
- if (zip.files[dir + '/'] || entries.some((e) => e.startsWith(dir + '/'))) {
- root = dir + '/';
- }
- }
-
- const manifestFile = zip.file(root + 'manifest.json');
- if (!manifestFile) {
- bundleError = 'Bundle missing manifest.json';
- } else {
- try {
- manifest = JSON.parse(await manifestFile.async('string'));
- } catch {
- bundleError = 'Invalid manifest.json in bundle';
- }
- }
-
- if (manifest) {
- if (type === 'theme') {
- const cssFile = zip.file(root + 'theme.css');
- if (cssFile) {
- const css = await cssFile.async('string');
- sourcePreview = {
- name: 'theme.css',
- content: css.length > MAX_PREVIEW_SOURCE_LEN
- ? css.slice(0, MAX_PREVIEW_SOURCE_LEN)
- : css,
- truncated: css.length > MAX_PREVIEW_SOURCE_LEN,
- };
- }
- } else {
- const entrypoint = (manifest.entrypoint as string) || 'index.js';
- const jsFile = zip.file(root + entrypoint);
- if (jsFile) {
- const code = await jsFile.async('string');
- sourcePreview = {
- name: entrypoint,
- content: code.length > MAX_PREVIEW_SOURCE_LEN
- ? code.slice(0, MAX_PREVIEW_SOURCE_LEN)
- : code,
- truncated: code.length > MAX_PREVIEW_SOURCE_LEN,
- };
- }
- }
+ if (manifest) {
+ const name = type === 'theme'
+ ? 'theme.css'
+ : (typeof manifest.entrypoint === 'string' && manifest.entrypoint) || 'index.js';
+ const content = await readText(pkg, name);
+ if (content !== null) {
+ sourcePreview = {
+ name,
+ content: content.length > MAX_PREVIEW_SOURCE_LEN
+ ? content.slice(0, MAX_PREVIEW_SOURCE_LEN)
+ : content,
+ truncated: content.length > MAX_PREVIEW_SOURCE_LEN,
+ };
}
}
}
diff --git a/app/api/admin/marketplace/__tests__/route.test.ts b/app/api/admin/marketplace/__tests__/route.test.ts
new file mode 100644
index 000000000..747e39e3c
--- /dev/null
+++ b/app/api/admin/marketplace/__tests__/route.test.ts
@@ -0,0 +1,100 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { NextRequest } from 'next/server';
+import JSZip from 'jszip';
+
+vi.mock('@/lib/admin/session', () => ({
+ requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
+ getClientIP: () => '127.0.0.1',
+}));
+vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: {
+ ensureLoaded: vi.fn(async () => {}),
+ get: vi.fn(() => 'https://extensions.test'),
+ },
+}));
+
+const registry = new Map>();
+vi.mock('@/lib/admin/plugin-registry', () => ({
+ savePlugin: vi.fn(async (p: Record) => { registry.set(p.id as string, p); }),
+ saveTheme: vi.fn(async () => {}),
+ getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null),
+ getTheme: vi.fn(async () => null),
+ getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })),
+ getThemeRegistry: vi.fn(async () => ({ themes: [] })),
+}));
+vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({
+ ...(await orig()),
+ invalidateFrameOriginsCache: vi.fn(),
+}));
+
+import { POST } from '../route';
+
+async function bundle(manifest: Record, code = 'module.exports = {};'): Promise {
+ const zip = new JSZip();
+ zip.file('manifest.json', JSON.stringify(manifest));
+ zip.file('index.js', code);
+ const buf = await zip.generateAsync({ type: 'uint8array' });
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer;
+}
+
+function install(body: Record) {
+ return POST(new NextRequest('http://localhost/api/admin/marketplace', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ headers: { 'content-type': 'application/json' },
+ }));
+}
+
+const MANIFEST = {
+ name: 'Pretty Dates', version: '1.0.0', author: 'x', description: 'd',
+ type: 'hook', permissions: ['email:read'], entrypoint: 'index.js',
+};
+
+beforeEach(() => {
+ registry.clear();
+});
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe('POST /api/admin/marketplace', () => {
+ it('installs a plugin under its directory slug', async () => {
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pretty-dates' }))));
+
+ const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
+
+ expect(res.status).toBe(200);
+ expect(registry.has('pretty-dates')).toBe(true);
+ });
+
+ it('refuses a ZIP bomb from the directory', async () => {
+ const zip = new JSZip();
+ zip.file('manifest.json', JSON.stringify({ ...MANIFEST, id: 'pretty-dates' }));
+ zip.file('index.js', new Uint8Array(30 * 1024 * 1024));
+ const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(bytes as BodyInit)));
+
+ const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
+
+ expect(res.status).toBe(400);
+ expect(registry.size).toBe(0);
+ });
+
+ it('refuses a bundle whose manifest id differs from the slug', async () => {
+ // Otherwise a directory entry can overwrite another installed plugin,
+ // inheriting its enabled and force-enabled state, while the admin UI
+ // (which keys install status by slug) still shows "not installed".
+ registry.set('pgp-plugin', { id: 'pgp-plugin', forceEnabled: true, version: '2.0.0' });
+ vi.stubGlobal('fetch', vi.fn(async () => new Response(await bundle({ ...MANIFEST, id: 'pgp-plugin' }))));
+
+ const res = await install({ slug: 'pretty-dates', version: '1.0.0', type: 'plugin' });
+
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toMatch(/does not match/);
+ expect(registry.get('pgp-plugin')?.version).toBe('2.0.0');
+ });
+});
diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts
index b218c4ea3..61abf34f6 100644
--- a/app/api/admin/marketplace/route.ts
+++ b/app/api/admin/marketplace/route.ts
@@ -9,18 +9,18 @@ import {
getTheme,
getPluginRegistry,
getThemeRegistry,
+ themeCSSResolver,
type ServerPlugin,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
+import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
import {
- sanitizeFrameOrigins,
- sanitizeHttpOrigins,
- sanitizeApiPostPaths,
- invalidateFrameOriginsCache,
-} from '@/lib/admin/csp-frame-origins';
-import JSZip from 'jszip';
-import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE, ALL_PERMISSIONS } from '@/lib/plugin-types';
-import { sanitizeThemeCSS } from '@/lib/theme-loader';
+ EXTENSION_ID_RE,
+ ExtensionPackageError,
+ readPluginPackage,
+ readThemePackage,
+ suspiciousCode,
+} from '@/lib/extension-package';
import { configManager } from '@/lib/admin/config-manager';
async function getDirectoryUrl(): Promise {
@@ -123,6 +123,14 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid type' }, { status: 400 });
}
+ // The slug becomes a file name and the registry key; it must be a plain id.
+ if (typeof slug !== 'string' || !EXTENSION_ID_RE.test(slug)) {
+ return NextResponse.json(
+ { error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
+ { status: 400 },
+ );
+ }
+
// Download the bundle from the directory
const directoryUrl = await getDirectoryUrl();
const bundleUrl = new URL(`/api/v1/bundle/${encodeURIComponent(slug)}/${encodeURIComponent(version)}`, directoryUrl);
@@ -138,92 +146,41 @@ export async function POST(request: NextRequest) {
}
const buffer = await bundleRes.arrayBuffer();
- const maxSize = type === 'theme' ? MAX_THEME_SIZE : MAX_PLUGIN_SIZE;
-
- if (buffer.byteLength > maxSize) {
- return NextResponse.json(
- { error: `Bundle exceeds ${type === 'theme' ? '1 MB' : '5 MB'} size limit` },
- { status: 400 }
- );
- }
-
- // Parse the ZIP
- let zip: JSZip;
- try {
- zip = await JSZip.loadAsync(buffer);
- } catch {
- return NextResponse.json({ error: 'Invalid ZIP file from directory' }, { status: 400 });
- }
-
- // Find root directory
- const entries = Object.keys(zip.files);
- const topDirs = new Set(entries.map(e => e.split('/')[0]));
- let root = '';
- if (topDirs.size === 1) {
- const dir = [...topDirs][0];
- if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
- root = dir + '/';
- }
- }
-
- // Read manifest
- const manifestFile = zip.file(root + 'manifest.json');
- if (!manifestFile) {
- return NextResponse.json({ error: 'Bundle missing manifest.json' }, { status: 400 });
- }
-
- let manifest: Record;
- try {
- manifest = JSON.parse(await manifestFile.async('string'));
- } catch {
- return NextResponse.json({ error: 'Invalid manifest.json in bundle' }, { status: 400 });
- }
-
const now = new Date().toISOString();
- // Resolve and strictly validate the id used as a filename. Marketplace
- // bundles are authored by a third-party publisher; without this an id
- // like "../../foo" causes savePlugin/saveTheme to write outside the
- // plugins/themes dir via path.join.
- const resolvedId = typeof manifest.id === 'string' && manifest.id ? manifest.id : slug;
- if (typeof resolvedId !== 'string' || !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(resolvedId)) {
- return NextResponse.json(
- { error: 'Invalid id: must be lowercase alphanumeric with hyphens, min 2 chars' },
- { status: 400 },
- );
- }
-
if (type === 'theme') {
- // Read theme.css
- const cssFile = zip.file(root + 'theme.css');
- if (!cssFile) {
- return NextResponse.json({ error: 'Theme bundle missing theme.css' }, { status: 400 });
+ const read = await readThemePackage(buffer, { resolveExtends: await themeCSSResolver() });
+ if (!read.valid || !read.manifest) {
+ return NextResponse.json({ error: read.errors.join('; ') }, { status: 400 });
}
-
- let css = await cssFile.async('string');
-
- // Sanitize unconditionally: the safety check is advisory, and the
- // sanitizer is also what drops rules outside :root and .dark.
- const warnings: string[] = [];
- const sanitized = sanitizeThemeCSS(css);
- css = sanitized.css;
- warnings.push(...sanitized.warnings);
+ const manifest = read.manifest;
+ // The slug is what the admin picked and what install status is keyed
+ // on. A bundle that names another id would install over whatever
+ // already uses that id, keeping its enabled and force-enabled state.
+ if (manifest.id !== slug) {
+ return NextResponse.json(
+ { error: `Bundle id "${String(manifest.id)}" does not match the directory entry "${slug}"` },
+ { status: 400 },
+ );
+ }
+ const resolvedId = slug;
+ const { css, skin, warnings } = read;
const existingTheme = await getTheme(resolvedId);
const isUpdate = existingTheme !== null;
const theme: ServerTheme = {
id: resolvedId,
- name: (manifest.name as string) || slug,
+ name: manifest.name || slug,
// Prefer the directory-published version (what we requested) over
// manifest.version. Publishers sometimes forget to bump the version
// inside the bundle's manifest.json; trusting it would make the
// update never appear to "stick" — the registry would keep showing
// the older version even after a successful update.
- version: version || (manifest.version as string),
- author: (manifest.author as string) || 'Unknown',
- description: (manifest.description as string) || '',
- variants: (manifest.variants as string[]) || ['light', 'dark'],
+ version: version || manifest.version,
+ author: manifest.author || 'Unknown',
+ description: manifest.description || '',
+ variants: manifest.variants,
enabled: existingTheme?.enabled ?? true,
...(existingTheme?.forceEnabled !== undefined
? { forceEnabled: existingTheme.forceEnabled }
@@ -232,7 +189,7 @@ export async function POST(request: NextRequest) {
updatedAt: now,
};
- await saveTheme(theme, css);
+ await saveTheme(theme, css, skin);
await auditLog(
isUpdate ? 'marketplace.update_theme' : 'marketplace.install_theme',
{
@@ -246,148 +203,82 @@ export async function POST(request: NextRequest) {
);
return NextResponse.json({ success: true, theme, warnings, updated: isUpdate });
- } else {
- // Plugin installation
- // Read entrypoint JS
- const entrypoint = (manifest.entrypoint as string) || 'index.js';
- const jsFile = zip.file(root + entrypoint);
- if (!jsFile) {
- return NextResponse.json({ error: `Bundle missing entrypoint: ${entrypoint}` }, { status: 400 });
- }
-
- const code = await jsFile.async('string');
-
- // Block plugins with dangerous JS patterns
- const DANGEROUS_JS_PATTERNS = [
- { pattern: /\beval\s*\(/g, label: 'eval()' },
- { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
- { pattern: /document\.cookie/g, label: 'document.cookie' },
- { pattern: /document\.write/g, label: 'document.write' },
- { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
- ];
- const dangerousFindings: string[] = [];
- for (const { pattern, label } of DANGEROUS_JS_PATTERNS) {
- if (pattern.test(code)) dangerousFindings.push(label);
- pattern.lastIndex = 0;
- }
- if (dangerousFindings.length > 0) {
- return NextResponse.json(
- { error: `Plugin rejected: contains ${dangerousFindings.join(', ')}. These patterns are not allowed for security reasons.` },
- { status: 400 },
- );
- }
-
- // Validate permissions
- const permissions = Array.isArray(manifest.permissions) ? manifest.permissions as string[] : [];
- const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
- const unknownPerms = permissions.filter(p => !validPerms.has(p));
-
- const warnings: string[] = [];
- if (unknownPerms.length > 0) {
- warnings.push(`Unknown permissions: ${unknownPerms.join(', ')}`);
- }
-
- // Plugins may declare iframe origins they need for embedded content.
- // Anything that doesn't pass strict origin validation is silently
- // dropped - the plugin still installs, but those origins are not
- // added to the host CSP.
- const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
- const droppedFrameOrigins = Array.isArray(manifest.frameOrigins)
- ? (manifest.frameOrigins as unknown[]).filter(
- (v) => typeof v !== 'string' || !declaredFrameOrigins.includes(v),
- )
- : [];
- if (droppedFrameOrigins.length > 0) {
- warnings.push(
- `Ignored invalid frameOrigins: ${droppedFrameOrigins.join(', ')}`,
- );
- }
-
- const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
- const droppedHttpOrigins = Array.isArray(manifest.httpOrigins)
- ? (manifest.httpOrigins as unknown[]).filter(
- (v) => typeof v !== 'string' || !declaredHttpOrigins.includes(v),
- )
- : [];
- if (droppedHttpOrigins.length > 0) {
- warnings.push(
- `Ignored invalid httpOrigins: ${droppedHttpOrigins.join(', ')}`,
- );
- }
-
- const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
- const droppedApiPostPaths = Array.isArray(manifest.apiPostPaths)
- ? (manifest.apiPostPaths as unknown[]).filter(
- (v) => typeof v !== 'string' || !declaredApiPostPaths.includes(v),
- )
- : [];
- if (droppedApiPostPaths.length > 0) {
- warnings.push(
- `Ignored invalid apiPostPaths: ${droppedApiPostPaths.join(', ')}`,
- );
- }
-
- const existingPlugin = await getPlugin(resolvedId);
- const isUpdate = existingPlugin !== null;
-
- const plugin: ServerPlugin = {
- id: resolvedId,
- name: (manifest.name as string) || slug,
- // See theme branch: trust the directory-published version, not
- // manifest.version, so updates actually stick in the registry.
- version: version || (manifest.version as string),
- author: (manifest.author as string) || 'Unknown',
- description: (manifest.description as string) || '',
- type: (manifest.type as string) || 'hook',
- ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
- permissions,
- entrypoint,
- enabled: existingPlugin?.enabled ?? true,
- ...(existingPlugin?.forceEnabled !== undefined
- ? { forceEnabled: existingPlugin.forceEnabled }
- : {}),
- installedAt: existingPlugin?.installedAt ?? now,
- updatedAt: now,
- ...(manifest.configSchema && typeof manifest.configSchema === 'object'
- ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
- : {}),
- ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
- ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
- : {}),
- ...(declaredFrameOrigins.length > 0
- ? { frameOrigins: declaredFrameOrigins }
- : {}),
- ...(declaredHttpOrigins.length > 0
- ? { httpOrigins: declaredHttpOrigins }
- : {}),
- ...(declaredApiPostPaths.length > 0
- ? { apiPostPaths: declaredApiPostPaths }
- : {}),
- ...(manifest.locales && typeof manifest.locales === 'object'
- ? { locales: manifest.locales as ServerPlugin['locales'] }
- : {}),
-
- };
-
- await savePlugin(plugin, code);
- invalidateFrameOriginsCache();
- await auditLog(
- isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
- {
- id: plugin.id,
- name: plugin.name,
- version: plugin.version,
- slug,
- frameOrigins: declaredFrameOrigins,
- httpOrigins: declaredHttpOrigins,
- apiPostPaths: declaredApiPostPaths,
- ...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
- },
- ip,
- );
-
- return NextResponse.json({ success: true, plugin, warnings, updated: isUpdate });
}
+
+ // Plugin installation
+ let pkg;
+ try {
+ pkg = await readPluginPackage(buffer, {
+ appVersion: process.env.NEXT_PUBLIC_APP_VERSION,
+ expectedId: slug,
+ });
+ } catch (err) {
+ if (err instanceof ExtensionPackageError) {
+ // Same wording the admin UI already shows for an id mismatch.
+ const message = err.message.replace(/Package id "([^"]*)" does not match "([^"]*)"/, 'Bundle id "$1" does not match the directory entry "$2"');
+ return NextResponse.json({ error: message }, { status: 400 });
+ }
+ throw err;
+ }
+ const { manifest, code } = pkg;
+
+ // Block plugins with dangerous JS patterns
+ const suspicious = suspiciousCode(code);
+ if (suspicious.length > 0) {
+ return NextResponse.json(
+ { error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` },
+ { status: 400 },
+ );
+ }
+
+ const existingPlugin = await getPlugin(slug);
+ const isUpdate = existingPlugin !== null;
+
+ const plugin: ServerPlugin = {
+ id: slug,
+ name: manifest.name,
+ // See theme branch: trust the directory-published version, not
+ // manifest.version, so updates actually stick in the registry.
+ version: version || manifest.version,
+ author: manifest.author,
+ description: manifest.description,
+ type: manifest.type,
+ ...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
+ permissions: manifest.permissions,
+ entrypoint: manifest.entrypoint,
+ enabled: existingPlugin?.enabled ?? true,
+ ...(existingPlugin?.forceEnabled !== undefined
+ ? { forceEnabled: existingPlugin.forceEnabled }
+ : {}),
+ installedAt: existingPlugin?.installedAt ?? now,
+ updatedAt: now,
+ ...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}),
+ ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}),
+ ...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}),
+ ...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}),
+ ...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}),
+ ...(manifest.locales ? { locales: manifest.locales } : {}),
+ ...(manifest.credentials ? { credentials: manifest.credentials } : {}),
+ };
+
+ await savePlugin(plugin, code);
+ invalidateFrameOriginsCache();
+ await auditLog(
+ isUpdate ? 'marketplace.update_plugin' : 'marketplace.install_plugin',
+ {
+ id: plugin.id,
+ name: plugin.name,
+ version: plugin.version,
+ slug,
+ frameOrigins: plugin.frameOrigins ?? [],
+ httpOrigins: plugin.httpOrigins ?? [],
+ apiPostPaths: plugin.apiPostPaths ?? [],
+ ...(isUpdate ? { previousVersion: existingPlugin.version } : {}),
+ },
+ ip,
+ );
+
+ return NextResponse.json({ success: true, plugin, warnings: pkg.warnings, updated: isUpdate });
} catch (error) {
logger.error('Marketplace install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Installation failed' }, { status: 500 });
diff --git a/app/api/admin/plugin-approvals/source/route.ts b/app/api/admin/plugin-approvals/source/route.ts
new file mode 100644
index 000000000..c966124e1
--- /dev/null
+++ b/app/api/admin/plugin-approvals/source/route.ts
@@ -0,0 +1,34 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/admin/session';
+import { logger } from '@/lib/logger';
+import { readApprovalBundle } from '@/lib/admin/plugin-approvals';
+import { readPluginPackage } from '@/lib/extension-package';
+
+/**
+ * GET /api/admin/plugin-approvals/source?pluginId=…&bundleHash=…
+ *
+ * The manifest and code of a plugin a user asked to have approved, read
+ * from the ZIP they uploaded, so the admin decides on what will actually run.
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const result = await requireAdminAuth(request);
+ if ('error' in result) return result.error;
+
+ const pluginId = request.nextUrl.searchParams.get('pluginId') ?? '';
+ const bundleHash = request.nextUrl.searchParams.get('bundleHash') ?? '';
+ if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(pluginId) || !/^[a-f0-9]{64}$/.test(bundleHash)) {
+ return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
+ }
+ const zip = await readApprovalBundle(pluginId, bundleHash);
+ if (!zip) return NextResponse.json({ error: 'No package stored for this request' }, { status: 404 });
+ const pkg = await readPluginPackage(zip);
+ return NextResponse.json(
+ { manifest: pkg.manifest, code: pkg.code, warnings: pkg.warnings },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+ } catch (err) {
+ logger.error('plugin-approvals source GET', { error: err instanceof Error ? err.message : String(err) });
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts b/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts
new file mode 100644
index 000000000..7c73cd2a3
--- /dev/null
+++ b/app/api/admin/plugins/[id]/bundle/__tests__/route.test.ts
@@ -0,0 +1,36 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+
+let signedIn = false;
+vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: vi.fn(async () => signedIn) }));
+vi.mock('@/lib/admin/plugin-dev', () => ({ getDevPlugin: vi.fn(async () => null), readDevBundle: vi.fn() }));
+vi.mock('@/lib/admin/plugin-registry', () => ({
+ getPlugin: vi.fn(async () => ({ id: 'demo', enabled: true, bundleHash: 'h' })),
+ getPluginBundle: vi.fn(async () => 'module.exports = {}'),
+ getPluginSignature: vi.fn(async () => 'sig'),
+}));
+vi.mock('@/lib/admin/plugin-signing', () => ({ signBytes: vi.fn(async () => 'sig') }));
+
+import { GET } from '../route';
+
+function get() {
+ return GET(new NextRequest('http://localhost/api/admin/plugins/demo/bundle'), { params: Promise.resolve({ id: 'demo' }) });
+}
+
+beforeEach(() => { signedIn = false; });
+
+describe('GET plugin bundle', () => {
+ it('does not hand plugin code to someone who is not signed in', async () => {
+ expect((await get()).status).toBe(401);
+ });
+
+ it('serves it to a signed-in user with its signature', async () => {
+ signedIn = true;
+ const res = await get();
+ expect(res.status).toBe(200);
+ expect(res.headers.get('x-bundle-signature')).toBe('sig');
+ });
+});
diff --git a/app/api/admin/plugins/[id]/bundle/route.ts b/app/api/admin/plugins/[id]/bundle/route.ts
index f2a4fc687..06b398ae5 100644
--- a/app/api/admin/plugins/[id]/bundle/route.ts
+++ b/app/api/admin/plugins/[id]/bundle/route.ts
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
-import { getPluginBundle, getPlugin } from '@/lib/admin/plugin-registry';
+import { getPluginBundle, getPlugin, getPluginSignature } from '@/lib/admin/plugin-registry';
import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
import { signBytes } from '@/lib/admin/plugin-signing';
+import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
async function safeSign(code: string): Promise {
try { return await signBytes(code); } catch { return null; }
@@ -10,13 +11,18 @@ async function safeSign(code: string): Promise {
/**
* GET /api/admin/plugins/[id]/bundle - Serve plugin JS bundle
*
- * Public endpoint so the client-side plugin loader can fetch bundles.
+ * Serves bundles to signed-in users so the client-side plugin loader can fetch them.
* Only serves plugins that exist in the registry and are enabled.
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
+ // Bundles are delivered to signed-in users only, like the plugin list
+ // that names them (/api/plugins).
+ if (!(await canSeeInstanceDetails(request))) {
+ return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
+ }
try {
const { id } = await params;
@@ -65,7 +71,7 @@ export async function GET(
};
if (etag) headers['ETag'] = etag;
- const signature = await safeSign(code);
+ const signature = await getPluginSignature(id, code);
if (signature) headers['X-Bundle-Signature'] = signature;
if (etag && request.headers.get('if-none-match') === etag) {
diff --git a/app/api/admin/plugins/[id]/config/__tests__/route.test.ts b/app/api/admin/plugins/[id]/config/__tests__/route.test.ts
new file mode 100644
index 000000000..24e6c94b3
--- /dev/null
+++ b/app/api/admin/plugins/[id]/config/__tests__/route.test.ts
@@ -0,0 +1,43 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+
+let admin = true;
+vi.mock('@/lib/admin/session', async () => {
+ const { NextResponse } = await import('next/server');
+ return { requireAdminAuth: async () => (admin ? { ok: true } : { error: NextResponse.json({}, { status: 401 }) }) };
+});
+vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => ({ username: 'alice@example.org' }) }));
+vi.mock('@/lib/admin/plugin-registry', () => ({
+ getPlugin: async () => ({ id: 'paperless', configSchema: { serverUrl: { type: 'string' }, apiToken: { type: 'secret' } } }),
+}));
+vi.mock('@/lib/admin/plugin-dev', () => ({ getDevPlugin: async () => null }));
+vi.mock('@/lib/admin/plugin-config', () => ({
+ getPluginConfig: async () => ({ serverUrl: 'https://paperless.example.org', apiToken: 'secret-token' }),
+ setPluginConfig: vi.fn(), deletePluginConfigKey: vi.fn(),
+}));
+
+import { GET } from '../route';
+
+function get(query = '') {
+ return GET(new NextRequest(`http://localhost/api/admin/plugins/paperless/config${query}`), { params: Promise.resolve({ id: 'paperless' }) });
+}
+
+beforeEach(() => { admin = true; });
+
+describe('GET plugin config', () => {
+ it('gives an admin every field', async () => {
+ expect(await (await get()).json()).toEqual({ serverUrl: 'https://paperless.example.org', apiToken: 'secret-token' });
+ });
+
+ it('never gives secrets with ?public=1, even to an admin (what plugins use)', async () => {
+ expect(await (await get('?public=1')).json()).toEqual({ serverUrl: 'https://paperless.example.org' });
+ });
+
+ it('gives a user only the fields that aren\'t secrets', async () => {
+ admin = false;
+ expect(await (await get()).json()).toEqual({ serverUrl: 'https://paperless.example.org' });
+ });
+});
diff --git a/app/api/admin/plugins/[id]/config/route.ts b/app/api/admin/plugins/[id]/config/route.ts
index e8c5e9a00..7e75f1e61 100644
--- a/app/api/admin/plugins/[id]/config/route.ts
+++ b/app/api/admin/plugins/[id]/config/route.ts
@@ -34,8 +34,12 @@ export async function GET(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
+ // ?public=1: only the fields any signed-in user may see, even for an
+ // admin. The plugin API uses it so a plugin never gets secrets just
+ // because the user is also signed in to the admin dashboard.
+ const publicOnly = request.nextUrl.searchParams.get('public') === '1';
const adminAuth = await requireAdminAuth(request);
- const isAdmin = !('error' in adminAuth);
+ const isAdmin = !publicOnly && !('error' in adminAuth);
if (!isAdmin) {
const creds = await getStalwartCredentials(request);
diff --git a/app/api/admin/plugins/__tests__/route.test.ts b/app/api/admin/plugins/__tests__/route.test.ts
new file mode 100644
index 000000000..9d2ce079b
--- /dev/null
+++ b/app/api/admin/plugins/__tests__/route.test.ts
@@ -0,0 +1,127 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+import JSZip from 'jszip';
+
+vi.mock('@/lib/admin/session', () => ({
+ requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
+ getClientIP: () => '127.0.0.1',
+}));
+vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
+
+let policy: Record;
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: {
+ ensureLoaded: vi.fn(async () => {}),
+ get: vi.fn(),
+ getPolicy: vi.fn(() => policy),
+ setPolicy: vi.fn(async (p: Record) => { policy = p; }),
+ },
+}));
+
+const registry = new Map>();
+const removedConfig: string[] = [];
+vi.mock('@/lib/admin/plugin-registry', () => ({
+ savePlugin: vi.fn(async (p: Record) => { registry.set(p.id as string, p); }),
+ getPlugin: vi.fn(async (id: string) => registry.get(id) ?? null),
+ getPluginRegistry: vi.fn(async () => ({ plugins: [...registry.values()] })),
+ deletePlugin: vi.fn(async (id: string) => registry.delete(id)),
+}));
+vi.mock('@/lib/admin/plugin-config', () => ({
+ deletePluginConfig: vi.fn(async (id: string) => { removedConfig.push(id); }),
+}));
+vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) }));
+vi.mock('@/lib/admin/csp-frame-origins', async (orig) => ({
+ ...(await orig()),
+ invalidateFrameOriginsCache: vi.fn(),
+}));
+
+import { DELETE, POST } from '../route';
+
+const MANIFEST = {
+ id: 'demo-plugin', name: 'Demo', version: '1.0.0', author: 'me', description: 'd',
+ type: 'hook', permissions: ['email:read'], entrypoint: 'index.js',
+};
+
+async function upload(files: Record) {
+ const zip = new JSZip();
+ for (const [n, d] of Object.entries(files)) zip.file(n, d);
+ const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
+ const form = new FormData();
+ form.append('file', new File([bytes as BlobPart], 'p.zip', { type: 'application/zip' }));
+ return POST(new NextRequest('http://localhost/api/admin/plugins', { method: 'POST', body: form }));
+}
+
+beforeEach(() => {
+ registry.clear();
+ removedConfig.length = 0;
+ policy = { forceEnabledPlugins: [] };
+});
+
+describe('POST /api/admin/plugins', () => {
+ it('installs a valid plugin with its locales', async () => {
+ const res = await upload({
+ 'manifest.json': JSON.stringify({ ...MANIFEST, locales: { de: { hi: 'Hallo' } } }),
+ 'index.js': 'module.exports = {}',
+ });
+
+ expect(res.status).toBe(200);
+ expect(registry.get('demo-plugin')?.locales).toEqual({ de: { hi: 'Hallo' } });
+ });
+
+ it('refuses permissions given as a string', async () => {
+ const res = await upload({
+ 'manifest.json': JSON.stringify({ ...MANIFEST, permissions: 'crypto:full email:send', tier: 'privileged' }),
+ 'index.js': '',
+ });
+
+ expect(res.status).toBe(400);
+ expect(registry.size).toBe(0);
+ });
+
+ it('refuses a ZIP bomb instead of inflating it', async () => {
+ const res = await upload({
+ 'manifest.json': JSON.stringify(MANIFEST),
+ 'index.js': new Uint8Array(30 * 1024 * 1024),
+ });
+
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toMatch(/once unpacked/);
+ });
+
+ it('refuses an invalid settings schema', async () => {
+ const res = await upload({
+ 'manifest.json': JSON.stringify({ ...MANIFEST, settingsSchema: { m: { type: 'select', label: 'M', default: 'a', options: 'a,b' } } }),
+ 'index.js': '',
+ });
+
+ expect(res.status).toBe(400);
+ });
+});
+
+describe('re-uploading and deleting', () => {
+ it('keeps a plugin the admin disabled disabled when a new version is uploaded', async () => {
+ registry.set('demo-plugin', { id: 'demo-plugin', enabled: false, forceEnabled: true });
+
+ await upload({ 'manifest.json': JSON.stringify({ ...MANIFEST, version: '1.1.0' }), 'index.js': '' });
+
+ expect(registry.get('demo-plugin')).toMatchObject({ version: '1.1.0', enabled: false, forceEnabled: true });
+ });
+
+ it('drops the config and the forced flag when a plugin is deleted', async () => {
+ registry.set('demo-plugin', { id: 'demo-plugin' });
+ policy = { forceEnabledPlugins: ['demo-plugin', 'other'] };
+
+ const res = await DELETE(new NextRequest('http://localhost/api/admin/plugins', {
+ method: 'DELETE',
+ body: JSON.stringify({ id: 'demo-plugin' }),
+ headers: { 'content-type': 'application/json' },
+ }));
+
+ expect(res.status).toBe(200);
+ expect(removedConfig).toEqual(['demo-plugin']);
+ expect(policy.forceEnabledPlugins).toEqual(['other']);
+ });
+});
diff --git a/app/api/admin/plugins/route.ts b/app/api/admin/plugins/route.ts
index a62c4bf81..7e0306515 100644
--- a/app/api/admin/plugins/route.ts
+++ b/app/api/admin/plugins/route.ts
@@ -3,31 +3,17 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
+ getPlugin,
getPluginRegistry,
savePlugin,
deletePlugin as removePlugin,
type ServerPlugin,
} from '@/lib/admin/plugin-registry';
import { listDevPlugins } from '@/lib/admin/plugin-dev';
-import {
- sanitizeFrameOrigins,
- sanitizeHttpOrigins,
- sanitizeApiPostPaths,
- invalidateFrameOriginsCache,
-} from '@/lib/admin/csp-frame-origins';
-
-// Server-side extraction using the same validation logic
-// ZIP parsing needs to happen on the server for admin-uploaded plugins
-import JSZip from 'jszip';
-import { MAX_PLUGIN_SIZE, ALL_PERMISSIONS, ALLOWED_PLUGIN_FILES } from '@/lib/plugin-types';
-
-const SUSPICIOUS_JS_PATTERNS = [
- { pattern: /\beval\s*\(/g, label: 'eval()' },
- { pattern: /\bnew\s+Function\s*\(/g, label: 'new Function()' },
- { pattern: /document\.cookie/g, label: 'document.cookie' },
- { pattern: /document\.write/g, label: 'document.write' },
- { pattern: /innerHTML\s*=/g, label: 'innerHTML assignment' },
-];
+import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
+import { cleanUpDeletedPlugin } from '@/lib/admin/extension-cleanup';
+import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package';
+import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types';
/**
* GET /api/admin/plugins - List all admin-managed plugins
@@ -79,141 +65,64 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Plugin ZIP exceeds 5 MB size limit' }, { status: 400 });
}
- // Extract and validate ZIP
- let zip: JSZip;
+ let pkg;
try {
- const buffer = await file.arrayBuffer();
- zip = await JSZip.loadAsync(buffer);
- } catch {
- return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
- }
-
- // Find root
- const entries = Object.keys(zip.files);
- const topDirs = new Set(entries.map(e => e.split('/')[0]));
- let root = '';
- if (topDirs.size === 1) {
- const dir = [...topDirs][0];
- if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
- root = dir + '/';
+ pkg = await readPluginPackage(await file.arrayBuffer(), {
+ appVersion: process.env.NEXT_PUBLIC_APP_VERSION,
+ });
+ } catch (err) {
+ if (err instanceof ExtensionPackageError) {
+ return NextResponse.json({ error: err.message }, { status: 400 });
}
+ throw err;
}
+ const { manifest, code } = pkg;
- // Read manifest
- const manifestFile = zip.file(root + 'manifest.json');
- if (!manifestFile) {
- return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
- }
-
- let manifest: Record;
- try {
- manifest = JSON.parse(await manifestFile.async('string'));
- } catch {
- return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
- }
-
- // Validate manifest
- const errors: string[] = [];
- if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
- if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
- if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
- if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
- if (!manifest.entrypoint || typeof manifest.entrypoint !== 'string') errors.push('Missing or invalid "entrypoint"');
-
- const validTypes = ['ui-extension', 'sidebar-app', 'hook'];
- if (!validTypes.includes(manifest.type as string)) {
- errors.push(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
- }
-
- if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
- errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
- }
-
- if (manifest.permissions && Array.isArray(manifest.permissions)) {
- const validPerms = new Set(ALL_PERMISSIONS as readonly string[]);
- const unknown = (manifest.permissions as string[]).filter(p => !validPerms.has(p));
- if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')}`);
- }
-
- if (errors.length > 0) {
- return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
- }
-
- // Check file extensions
- for (const [filePath, entry] of Object.entries(zip.files)) {
- if (entry.dir) continue;
- const ext = filePath.lastIndexOf('.') >= 0 ? filePath.slice(filePath.lastIndexOf('.')).toLowerCase() : '';
- if (ext && !ALLOWED_PLUGIN_FILES.has(ext)) {
- errors.push(`Disallowed file type: ${filePath}`);
- }
- }
- if (errors.length > 0) {
- return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
- }
-
- // Read entrypoint code
- const entryFile = zip.file(root + (manifest.entrypoint as string));
- if (!entryFile) {
- return NextResponse.json({ error: `Missing entrypoint: ${manifest.entrypoint}` }, { status: 400 });
- }
- const code = await entryFile.async('string');
-
- // Security: block plugins containing dangerous JS patterns
- const warnings: string[] = [];
- for (const { pattern, label } of SUSPICIOUS_JS_PATTERNS) {
- if (pattern.test(code)) warnings.push(`Contains ${label}`);
- pattern.lastIndex = 0;
- }
- if (warnings.length > 0) {
+ // Admin uploads reach every user, so the patterns a user upload only
+ // warns about are refused here.
+ const suspicious = suspiciousCode(code);
+ if (suspicious.length > 0) {
return NextResponse.json(
- { error: `Plugin rejected: ${warnings.join(', ')}. These patterns are not allowed for security reasons.` },
+ { error: `Plugin rejected: contains ${suspicious.join(', ')}. These patterns are not allowed for security reasons.` },
{ status: 400 },
);
}
- const declaredFrameOrigins = sanitizeFrameOrigins(manifest.frameOrigins);
- const declaredHttpOrigins = sanitizeHttpOrigins(manifest.httpOrigins);
- const declaredApiPostPaths = sanitizeApiPostPaths(manifest.apiPostPaths);
-
+ // A new upload of an installed plugin is an update: keep whether the
+ // admin had it enabled or forced instead of switching it on for everyone.
+ const existing = await getPlugin(manifest.id);
const now = new Date().toISOString();
const plugin: ServerPlugin = {
- id: manifest.id as string,
- name: manifest.name as string,
- version: manifest.version as string,
- author: manifest.author as string,
- description: (manifest.description as string) || '',
- type: manifest.type as string,
+ id: manifest.id,
+ name: manifest.name,
+ version: manifest.version,
+ author: manifest.author,
+ description: manifest.description,
+ type: manifest.type,
...(manifest.tier === 'privileged' ? { tier: 'privileged' } : {}),
- permissions: (manifest.permissions as string[]) || [],
- entrypoint: manifest.entrypoint as string,
- enabled: true,
- ...(manifest.configSchema && typeof manifest.configSchema === 'object'
- ? { configSchema: manifest.configSchema as ServerPlugin['configSchema'] }
- : {}),
- ...(manifest.settingsSchema && typeof manifest.settingsSchema === 'object'
- ? { settingsSchema: manifest.settingsSchema as ServerPlugin['settingsSchema'] }
- : {}),
- ...(manifest.locales && typeof manifest.locales === 'object'
- ? { locales: manifest.locales as ServerPlugin['locales'] }
- : {}),
- ...(declaredFrameOrigins.length > 0
- ? { frameOrigins: declaredFrameOrigins }
- : {}),
- ...(declaredHttpOrigins.length > 0
- ? { httpOrigins: declaredHttpOrigins }
- : {}),
- ...(declaredApiPostPaths.length > 0
- ? { apiPostPaths: declaredApiPostPaths }
- : {}),
+ permissions: manifest.permissions,
+ entrypoint: manifest.entrypoint,
+ enabled: existing?.enabled ?? true,
+ ...(existing?.forceEnabled !== undefined ? { forceEnabled: existing.forceEnabled } : {}),
+ ...(manifest.configSchema ? { configSchema: manifest.configSchema } : {}),
+ ...(manifest.settingsSchema ? { settingsSchema: manifest.settingsSchema } : {}),
+ ...(manifest.locales ? { locales: manifest.locales } : {}),
+ ...(manifest.frameOrigins ? { frameOrigins: manifest.frameOrigins } : {}),
+ ...(manifest.httpOrigins ? { httpOrigins: manifest.httpOrigins } : {}),
+ ...(manifest.apiPostPaths ? { apiPostPaths: manifest.apiPostPaths } : {}),
+ ...(manifest.credentials ? { credentials: manifest.credentials } : {}),
installedAt: now,
updatedAt: now,
};
+ const declaredFrameOrigins = plugin.frameOrigins ?? [];
+ const declaredHttpOrigins = plugin.httpOrigins ?? [];
+ const declaredApiPostPaths = plugin.apiPostPaths ?? [];
await savePlugin(plugin, code);
invalidateFrameOriginsCache();
await auditLog('plugin.install', { id: plugin.id, name: plugin.name, version: plugin.version, frameOrigins: declaredFrameOrigins, httpOrigins: declaredHttpOrigins, apiPostPaths: declaredApiPostPaths }, ip);
- return NextResponse.json({ plugin });
+ return NextResponse.json({ plugin, warnings: pkg.warnings });
} catch (error) {
logger.error('Plugin install error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
@@ -291,6 +200,7 @@ export async function DELETE(request: NextRequest) {
if (!removed) {
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
}
+ await cleanUpDeletedPlugin(id);
invalidateFrameOriginsCache();
await auditLog('plugin.delete', { id }, ip);
diff --git a/app/api/admin/themes/[id]/css/route.ts b/app/api/admin/themes/[id]/css/route.ts
index 347ccfaad..ad154a470 100644
--- a/app/api/admin/themes/[id]/css/route.ts
+++ b/app/api/admin/themes/[id]/css/route.ts
@@ -1,46 +1,13 @@
-import { NextRequest, NextResponse } from 'next/server';
-import { getThemeCSS, getThemeRegistry } from '@/lib/admin/plugin-registry';
-import { logger } from '@/lib/logger';
+import { NextRequest } from 'next/server';
+import { serveThemeAsset } from '@/lib/admin/theme-asset';
/**
- * GET /api/admin/themes/[id]/css - Serve theme CSS to clients
+ * GET /api/admin/themes/[id]/css - Serve a theme's CSS to clients
*/
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
- try {
- const { id } = await params;
-
- // Validate ID format
- if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id)) {
- return NextResponse.json({ error: 'Invalid theme ID' }, { status: 400 });
- }
-
- // Verify theme exists and is enabled
- const registry = await getThemeRegistry();
- const theme = registry.themes.find(t => t.id === id);
- if (!theme) {
- return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
- }
- if (!theme.enabled) {
- return NextResponse.json({ error: 'Theme is disabled' }, { status: 403 });
- }
-
- const css = await getThemeCSS(id);
- if (!css) {
- return NextResponse.json({ error: 'Theme CSS not found' }, { status: 404 });
- }
-
- return new NextResponse(css, {
- headers: {
- 'Content-Type': 'text/css; charset=utf-8',
- 'Cache-Control': 'public, max-age=3600',
- 'X-Content-Type-Options': 'nosniff',
- },
- });
- } catch (error) {
- logger.error('Theme CSS serve error', { error: error instanceof Error ? error.message : 'Unknown error' });
- return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
- }
+ const { id } = await params;
+ return serveThemeAsset(id, 'css');
}
diff --git a/app/api/admin/themes/[id]/skin/route.ts b/app/api/admin/themes/[id]/skin/route.ts
new file mode 100644
index 000000000..c292e44e5
--- /dev/null
+++ b/app/api/admin/themes/[id]/skin/route.ts
@@ -0,0 +1,13 @@
+import { NextRequest } from 'next/server';
+import { serveThemeAsset } from '@/lib/admin/theme-asset';
+
+/**
+ * GET /api/admin/themes/[id]/skin - Serve a theme's skin (Theme API v2 component CSS) to clients
+ */
+export async function GET(
+ _request: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ const { id } = await params;
+ return serveThemeAsset(id, 'skin');
+}
diff --git a/app/api/admin/themes/__tests__/route.test.ts b/app/api/admin/themes/__tests__/route.test.ts
new file mode 100644
index 000000000..89a60618a
--- /dev/null
+++ b/app/api/admin/themes/__tests__/route.test.ts
@@ -0,0 +1,90 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+
+vi.mock('@/lib/admin/session', () => ({
+ requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
+ getClientIP: () => '127.0.0.1',
+}));
+vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
+
+let policy: Record;
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: {
+ ensureLoaded: vi.fn(async () => {}),
+ get: vi.fn(),
+ getPolicy: vi.fn(() => policy),
+ setPolicy: vi.fn(async (p: Record) => { policy = p; }),
+ },
+}));
+const saved: Array<{ theme: Record; css: string; skin: string | null }> = [];
+vi.mock('@/lib/admin/plugin-registry', () => ({
+ deleteTheme: vi.fn(async () => true),
+ themeCSSResolver: vi.fn(async () => () => null),
+ saveTheme: vi.fn(async (theme: Record, css: string, skin: string | null) => { saved.push({ theme, css, skin }); }),
+ getTheme: vi.fn(async () => null),
+ getThemeRegistry: vi.fn(async () => ({ themes: [] })),
+}));
+
+import JSZip from 'jszip';
+import { DELETE, POST } from '../route';
+
+function del(id: string) {
+ return DELETE(new NextRequest('http://localhost/api/admin/themes', {
+ method: 'DELETE',
+ body: JSON.stringify({ id }),
+ headers: { 'content-type': 'application/json' },
+ }));
+}
+
+beforeEach(() => {
+ saved.length = 0;
+ policy = { forceEnabledThemes: ['corp', 'other'], forceEnabledPlugins: [] };
+});
+
+describe('DELETE /api/admin/themes', () => {
+ it('stops forcing a theme once it is deleted', async () => {
+ const res = await del('corp');
+
+ expect(res.status).toBe(200);
+ expect(policy.forceEnabledThemes).toEqual(['other']);
+ });
+});
+
+describe('POST /api/admin/themes', () => {
+ it('refuses a theme.css that unpacks past the limit', async () => {
+ const zip = new JSZip();
+ zip.file('manifest.json', JSON.stringify({ id: 'big', name: 'Big', version: '1.0.0', author: 'a', type: 'theme', variants: ['light'] }));
+ zip.file('theme.css', new Uint8Array(30 * 1024 * 1024));
+ const bytes = await zip.generateAsync({ type: 'uint8array', compression: 'DEFLATE' });
+ const form = new FormData();
+ form.append('file', new File([bytes as BlobPart], 't.zip'));
+
+ const res = await POST(new NextRequest('http://localhost/api/admin/themes', { method: 'POST', body: form }));
+
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toMatch(/once unpacked/);
+ });
+});
+
+describe('POST /api/admin/themes with Theme API v2', () => {
+ it('installs a tokens-only theme with a skin', async () => {
+ const zip = new JSZip();
+ zip.file('manifest.json', JSON.stringify({
+ id: 'ocean', name: 'Ocean', version: '1.0.0', author: 'a', type: 'theme', variants: ['light', 'dark'],
+ apiVersion: 2, tokens: { light: { '--color-primary': '#0066cc' }, dark: { '--color-primary': '#66aaff' } },
+ }));
+ zip.file('skin.css', '.message-list { border-radius: 8px; }');
+ const bytes = await zip.generateAsync({ type: 'uint8array' });
+ const form = new FormData();
+ form.append('file', new File([bytes as BlobPart], 'ocean.zip'));
+
+ const res = await POST(new NextRequest('http://localhost/api/admin/themes', { method: 'POST', body: form }));
+
+ expect(res.status).toBe(200);
+ expect(saved[0].css).toContain('#0066cc');
+ expect(saved[0].skin).toContain('border-radius');
+ });
+});
diff --git a/app/api/admin/themes/route.ts b/app/api/admin/themes/route.ts
index f600d09b6..3950021aa 100644
--- a/app/api/admin/themes/route.ts
+++ b/app/api/admin/themes/route.ts
@@ -3,15 +3,16 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
import { auditLog } from '@/lib/admin/audit';
import { logger } from '@/lib/logger';
import {
+ getTheme,
getThemeRegistry,
saveTheme,
deleteTheme as removeTheme,
+ themeCSSResolver,
type ServerTheme,
} from '@/lib/admin/plugin-registry';
+import { forgetExtensionInPolicy } from '@/lib/admin/extension-cleanup';
-import JSZip from 'jszip';
-import { MAX_THEME_SIZE } from '@/lib/plugin-types';
-import { sanitizeThemeCSS } from '@/lib/theme-loader';
+import { readThemePackage } from '@/lib/extension-package';
/**
* GET /api/admin/themes - List all admin-managed themes
@@ -47,98 +48,30 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing file' }, { status: 400 });
}
- if (file.size > MAX_THEME_SIZE) {
- return NextResponse.json({ error: 'Theme ZIP exceeds 1 MB size limit' }, { status: 400 });
+ const read = await readThemePackage(await file.arrayBuffer(), {
+ resolveExtends: await themeCSSResolver(),
+ });
+ if (!read.valid || !read.manifest) {
+ return NextResponse.json({ error: read.errors.join('; ') }, { status: 400 });
}
+ const { manifest, css, skin, warnings } = read;
- // Extract and validate ZIP
- let zip: JSZip;
- try {
- const buffer = await file.arrayBuffer();
- zip = await JSZip.loadAsync(buffer);
- } catch {
- return NextResponse.json({ error: 'Invalid ZIP file' }, { status: 400 });
- }
-
- // Find root
- const entries = Object.keys(zip.files);
- const topDirs = new Set(entries.map(e => e.split('/')[0]));
- let root = '';
- if (topDirs.size === 1) {
- const dir = [...topDirs][0];
- if (zip.files[dir + '/'] || entries.some(e => e.startsWith(dir + '/'))) {
- root = dir + '/';
- }
- }
-
- // Read manifest
- const manifestFile = zip.file(root + 'manifest.json');
- if (!manifestFile) {
- return NextResponse.json({ error: 'Missing manifest.json' }, { status: 400 });
- }
-
- let manifest: Record;
- try {
- manifest = JSON.parse(await manifestFile.async('string'));
- } catch {
- return NextResponse.json({ error: 'Invalid manifest.json' }, { status: 400 });
- }
-
- // Validate manifest
- const errors: string[] = [];
- if (!manifest.id || typeof manifest.id !== 'string') errors.push('Missing or invalid "id"');
- if (!manifest.name || typeof manifest.name !== 'string') errors.push('Missing or invalid "name"');
- if (!manifest.version || typeof manifest.version !== 'string') errors.push('Missing or invalid "version"');
- if (!manifest.author || typeof manifest.author !== 'string') errors.push('Missing or invalid "author"');
-
- if (manifest.type !== 'theme') {
- errors.push(`Expected type "theme", got "${manifest.type}"`);
- }
-
- if (manifest.id && typeof manifest.id === 'string' && !/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(manifest.id)) {
- errors.push('ID must be lowercase alphanumeric with hyphens, min 2 chars');
- }
-
- if (!manifest.variants || !Array.isArray(manifest.variants) || manifest.variants.length === 0) {
- errors.push('Missing or empty "variants" array');
- } else {
- const valid = manifest.variants.every((v: unknown) => v === 'light' || v === 'dark');
- if (!valid) errors.push('Variants must be "light" or "dark"');
- }
-
- if (errors.length > 0) {
- return NextResponse.json({ error: errors.join('; ') }, { status: 400 });
- }
-
- // Read theme.css
- const cssFile = zip.file(root + 'theme.css');
- if (!cssFile) {
- return NextResponse.json({ error: 'Missing theme.css' }, { status: 400 });
- }
-
- let css = await cssFile.async('string');
-
- // Sanitize unconditionally: the safety check is advisory, and the
- // sanitizer is also what drops rules outside :root and .dark.
- const warnings: string[] = [];
- const sanitized = sanitizeThemeCSS(css);
- css = sanitized.css;
- warnings.push(...sanitized.warnings);
-
+ const existing = await getTheme(manifest.id);
const now = new Date().toISOString();
const theme: ServerTheme = {
- id: manifest.id as string,
- name: manifest.name as string,
- version: manifest.version as string,
- author: manifest.author as string,
- description: (manifest.description as string) || '',
- variants: manifest.variants as string[],
- enabled: true,
- installedAt: now,
+ id: manifest.id,
+ name: manifest.name,
+ version: manifest.version,
+ author: manifest.author,
+ description: manifest.description || '',
+ variants: manifest.variants,
+ enabled: existing?.enabled ?? true,
+ ...(existing?.forceEnabled !== undefined ? { forceEnabled: existing.forceEnabled } : {}),
+ installedAt: existing?.installedAt ?? now,
updatedAt: now,
};
- await saveTheme(theme, css);
+ await saveTheme(theme, css, skin);
await auditLog('theme.install', { id: theme.id, name: theme.name, version: theme.version, warnings }, ip);
return NextResponse.json({ theme, warnings });
@@ -205,6 +138,7 @@ export async function DELETE(request: NextRequest) {
if (!removed) {
return NextResponse.json({ error: 'Theme not found' }, { status: 404 });
}
+ await forgetExtensionInPolicy('theme', id);
await auditLog('theme.delete', { id }, ip);
return NextResponse.json({ success: true });
diff --git a/app/api/plugin-approval-status/__tests__/route.test.ts b/app/api/plugin-approval-status/__tests__/route.test.ts
new file mode 100644
index 000000000..7d0ba7970
--- /dev/null
+++ b/app/api/plugin-approval-status/__tests__/route.test.ts
@@ -0,0 +1,98 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+import JSZip from 'jszip';
+
+vi.mock('next/headers', () => ({ cookies: async () => ({ get: () => undefined }) }));
+vi.mock('@/lib/stalwart/auth-context', () => ({
+ readStalwartAuthContextFromStore: (_store: unknown, slot: number) => (slot === 0 ? { username: currentUser } : null),
+}));
+vi.mock('@/lib/security/same-origin', () => ({ rejectCrossOriginRequest: () => null }));
+
+let currentUser = 'alice@example.org';
+const entries: Array> = [];
+const bundles = new Map();
+vi.mock('@/lib/admin/plugin-approvals', async (orig) => {
+ const real = await orig();
+ return {
+ ...real,
+ getApprovalStatus: vi.fn(async () => ({ status: 'not-requested' })),
+ requestApproval: vi.fn(async (pluginId: string, bundleHash: string, manifest: unknown, requestedBy: string) => {
+ const pending = entries.filter((e) => e.status === 'pending' && e.requestedBy === requestedBy).length;
+ if (pending >= real.MAX_PENDING_PER_USER) throw new real.ApprovalLimitError('too many');
+ const entry = { pluginId, bundleHash, manifest, requestedBy, status: 'pending', requestedAt: 'now' };
+ entries.push(entry);
+ return entry;
+ }),
+ saveApprovalBundle: vi.fn(async (id: string, hash: string, zip: Uint8Array) => { bundles.set(`${id}:${hash}`, zip); }),
+ };
+});
+
+import { POST } from '../route';
+import { packageHash, checkPluginManifest } from '@/lib/extension-package';
+
+const MANIFEST = {
+ id: 'my-plugin', name: 'Mine', version: '1.0.0', author: 'me', description: 'd',
+ type: 'hook', permissions: ['email:read'], entrypoint: 'index.js',
+};
+
+async function zipOf(manifest: Record, code = 'module.exports = {}') {
+ const zip = new JSZip();
+ zip.file('manifest.json', JSON.stringify(manifest));
+ zip.file('index.js', code);
+ return zip.generateAsync({ type: 'uint8array' });
+}
+
+async function submit(bytes: Uint8Array) {
+ const form = new FormData();
+ form.append('file', new File([bytes as BlobPart], 'p.zip'));
+ return POST(new NextRequest('http://localhost/api/plugin-approval-status', { method: 'POST', body: form }));
+}
+
+beforeEach(() => {
+ entries.length = 0;
+ bundles.clear();
+ currentUser = 'alice@example.org';
+});
+
+describe('POST /api/plugin-approval-status', () => {
+ it('pins the request to the hash of the code and manifest it read itself', async () => {
+ const res = await submit(await zipOf(MANIFEST));
+ const body = await res.json();
+
+ const expected = await packageHash(checkPluginManifest(MANIFEST).manifest!, 'module.exports = {}');
+ expect(res.status).toBe(200);
+ expect(body.bundleHash).toBe(expected);
+ expect(entries[0].manifest).toMatchObject({ name: 'Mine', permissions: ['email:read'] });
+ expect(bundles.has(`my-plugin:${expected}`)).toBe(true);
+ });
+
+ it('gives the same code with wider permissions a different hash', async () => {
+ const narrow = await (await submit(await zipOf(MANIFEST))).json();
+ const wide = await (await submit(await zipOf({ ...MANIFEST, permissions: ['email:read', 'http:fetch'], httpOrigins: ['https://evil.example'] }))).json();
+
+ expect(wide.bundleHash).not.toBe(narrow.bundleHash);
+ });
+
+ it('refuses the old form where the client named the hash and manifest', async () => {
+ const res = await POST(new NextRequest('http://localhost/api/plugin-approval-status', {
+ method: 'POST',
+ body: JSON.stringify({ pluginId: 'my-plugin', bundleHash: 'a'.repeat(64), manifest: { permissions: [] } }),
+ headers: { 'content-type': 'application/json' },
+ }));
+
+ expect(res.status).toBe(400);
+ expect(entries).toHaveLength(0);
+ });
+
+ it('limits how many requests one user can have open', async () => {
+ for (let i = 0; i < 10; i++) {
+ expect((await submit(await zipOf({ ...MANIFEST, id: `p-${i}x` }))).status).toBe(200);
+ }
+ const res = await submit(await zipOf({ ...MANIFEST, id: 'one-more' }));
+
+ expect(res.status).toBe(429);
+ });
+});
diff --git a/app/api/plugin-approval-status/route.ts b/app/api/plugin-approval-status/route.ts
index 08f99867e..d33f659c6 100644
--- a/app/api/plugin-approval-status/route.ts
+++ b/app/api/plugin-approval-status/route.ts
@@ -6,7 +6,14 @@ import { sessionCookieName } from '@/lib/auth/session-cookie';
import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { logger } from '@/lib/logger';
-import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/admin/plugin-approvals';
+import {
+ ApprovalLimitError,
+ getApprovalStatus,
+ requestApproval,
+ saveApprovalBundle,
+ type ApprovalEntry,
+} from '@/lib/admin/plugin-approvals';
+import { ExtensionPackageError, packageHash, readPluginPackage } from '@/lib/extension-package';
/**
* GET /api/plugin-approval-status?pluginId=X&bundleHash=Y
@@ -15,8 +22,9 @@ import { getApprovalStatus, requestApproval, type ApprovalEntry } from '@/lib/ad
* they want to enable. The client uses this BEFORE running `enablePlugin`
* when the `requirePluginApproval` policy is set.
*
- * POST same path with body `{ pluginId, bundleHash, manifest }` creates a
- * pending approval entry (or returns the existing one).
+ * POST same path with the plugin ZIP as multipart `file` creates a pending
+ * approval entry (or returns the existing one) for the package hash the
+ * server computes, and keeps the ZIP for the admin to review.
*/
async function resolveUsername(): Promise {
@@ -67,26 +75,55 @@ export async function POST(request: NextRequest) {
const username = await resolveUsername();
if (!username) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
- let body: unknown;
- try { body = await request.json(); } catch { body = null; }
- const b = (body ?? {}) as { pluginId?: unknown; bundleHash?: unknown; manifest?: unknown };
- if (!isValidId(b.pluginId) || !isValidHash(b.bundleHash)) {
- return NextResponse.json({ error: 'invalid pluginId or bundleHash' }, { status: 400 });
+ // The request carries the package itself. The admin reviews the code
+ // and manifest the server read from it, and the approval is pinned to
+ // the hash the server computed, not to anything the client claims.
+ let file: File | null = null;
+ try {
+ const form = await request.formData();
+ const value = form.get('file');
+ file = value instanceof File ? value : null;
+ } catch { /* not multipart */ }
+ if (!file) {
+ return NextResponse.json({ error: 'Send the plugin ZIP as "file"' }, { status: 400 });
}
+ const bytes = new Uint8Array(await file.arrayBuffer());
- const m = (b.manifest ?? {}) as Record;
- const manifest: ApprovalEntry['manifest'] = {
- name: typeof m.name === 'string' ? m.name.slice(0, 200) : undefined,
- version: typeof m.version === 'string' ? m.version.slice(0, 64) : undefined,
- author: typeof m.author === 'string' ? m.author.slice(0, 200) : undefined,
- description: typeof m.description === 'string' ? m.description.slice(0, 500) : undefined,
- permissions: Array.isArray(m.permissions) ? (m.permissions as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 50) : undefined,
- httpOrigins: Array.isArray(m.httpOrigins) ? (m.httpOrigins as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
- apiPostPaths: Array.isArray(m.apiPostPaths) ? (m.apiPostPaths as unknown[]).filter((x): x is string => typeof x === 'string').slice(0, 20) : undefined,
+ let pkg;
+ try {
+ pkg = await readPluginPackage(bytes, { appVersion: process.env.NEXT_PUBLIC_APP_VERSION });
+ } catch (err) {
+ if (err instanceof ExtensionPackageError) return NextResponse.json({ error: err.message }, { status: 400 });
+ throw err;
+ }
+ const { manifest, code } = pkg;
+ const hash = await packageHash(manifest, code);
+
+ const snapshot: ApprovalEntry['manifest'] = {
+ name: manifest.name,
+ version: manifest.version,
+ author: manifest.author,
+ description: manifest.description,
+ permissions: manifest.permissions,
+ httpOrigins: manifest.httpOrigins,
+ apiPostPaths: manifest.apiPostPaths,
};
- const entry = await requestApproval(b.pluginId as string, b.bundleHash as string, manifest, username);
- return NextResponse.json({ status: entry.status, requestedAt: entry.requestedAt, decidedAt: entry.decidedAt });
+ let entry: ApprovalEntry;
+ try {
+ entry = await requestApproval(manifest.id, hash, snapshot, username);
+ } catch (err) {
+ if (err instanceof ApprovalLimitError) return NextResponse.json({ error: err.message }, { status: 429 });
+ throw err;
+ }
+ if (entry.status === 'pending') await saveApprovalBundle(manifest.id, hash, bytes);
+ return NextResponse.json({
+ pluginId: manifest.id,
+ bundleHash: hash,
+ status: entry.status,
+ requestedAt: entry.requestedAt,
+ decidedAt: entry.decidedAt,
+ });
} catch (err) {
logger.error('plugin-approval-status POST', { error: err instanceof Error ? err.message : String(err) });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts
new file mode 100644
index 000000000..a7d941c4c
--- /dev/null
+++ b/app/api/plugin-proxy/route.ts
@@ -0,0 +1,83 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { configManager } from '@/lib/admin/config-manager';
+import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
+import { getStalwartCredentials } from '@/lib/stalwart/credentials';
+import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials';
+import { ProxyError, proxyRequest, type CaptureSpec } from '@/lib/plugin-secrets/proxy';
+
+const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
+const FIELD_RE = /^[A-Za-z_][A-Za-z0-9_]{0,63}$/;
+
+function parseCapture(raw: string | null): CaptureSpec | null | undefined {
+ if (!raw) return undefined;
+ try {
+ const v = JSON.parse(raw) as Record;
+ if (typeof v.secret !== 'string' || !FIELD_RE.test(v.secret)) return null;
+ if (v.username !== undefined && (typeof v.username !== 'string' || !FIELD_RE.test(v.username))) return null;
+ return { secret: v.secret, ...(typeof v.username === 'string' ? { username: v.username } : {}) };
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * POST /api/plugin-proxy?plugin=&credential=&url=
+ *
+ * Forwards a plugin's request to `url` with the named credential added. The
+ * target method goes in `X-Bulwark-Method` (default GET); the request body
+ * and headers are passed through, minus cookies, auth and transport headers.
+ * `X-Bulwark-Capture: {"secret":"appPassword","username":"loginName"}` stores
+ * those JSON fields of the response as the credential instead of returning
+ * them (Nextcloud Login Flow v2).
+ *
+ * Called by the host page on a plugin's behalf, never by the sandboxed
+ * plugin itself (its iframe has an opaque origin and is refused here).
+ */
+export async function POST(request: NextRequest) {
+ const crossOrigin = rejectCrossOriginRequest(request);
+ if (crossOrigin) return crossOrigin;
+
+ await configManager.ensureLoaded();
+ if (!configManager.getPolicy().features.pluginsEnabled) {
+ return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 });
+ }
+
+ const creds = await getStalwartCredentials(request);
+ if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 });
+
+ const params = request.nextUrl.searchParams;
+ const pluginId = params.get('plugin') ?? '';
+ const credentialId = params.get('credential') ?? '';
+ const url = params.get('url') ?? '';
+ if (!PLUGIN_ID_RE.test(pluginId) || !CREDENTIAL_ID_RE.test(credentialId) || !url) {
+ return NextResponse.json({ error: 'plugin, credential and url are required', code: 'bad_request' }, { status: 400 });
+ }
+ const capture = parseCapture(request.headers.get('x-bulwark-capture'));
+ if (capture === null) {
+ return NextResponse.json({ error: 'Invalid X-Bulwark-Capture header', code: 'bad_capture' }, { status: 400 });
+ }
+
+ try {
+ return await proxyRequest({
+ user: { username: creds.username, serverUrl: creds.serverUrl },
+ pluginId,
+ credentialId,
+ url,
+ method: request.headers.get('x-bulwark-method') ?? 'GET',
+ headers: request.headers,
+ body: request.body,
+ capture,
+ signal: request.signal,
+ });
+ } catch (error) {
+ if (error instanceof ProxyError) {
+ // Marks the proxy's own refusals, so the plugin can tell them from the
+ // upstream server's errors.
+ const headers: Record = { '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 });
+ }
+ return NextResponse.json({ error: 'Internal server error', code: 'internal' }, { status: 500 });
+ }
+}
diff --git a/app/api/plugin-secrets/__tests__/route.test.ts b/app/api/plugin-secrets/__tests__/route.test.ts
new file mode 100644
index 000000000..c99c310c9
--- /dev/null
+++ b/app/api/plugin-secrets/__tests__/route.test.ts
@@ -0,0 +1,131 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { NextRequest } from 'next/server';
+
+let signedIn = true;
+let pluginsEnabled = true;
+let sameOrigin = true;
+vi.mock('@/lib/stalwart/credentials', () => ({
+ getStalwartCredentials: async () => (signedIn ? { username: 'alice@example.org', serverUrl: 'https://mail.example.org', authHeader: 'x' } : null),
+}));
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features: { pluginsEnabled } }) },
+}));
+vi.mock('@/lib/security/same-origin', async () => {
+ const { NextResponse } = await import('next/server');
+ return { rejectCrossOriginRequest: () => (sameOrigin ? null : NextResponse.json({}, { status: 403 })) };
+});
+const store = new Map>();
+vi.mock('@/lib/plugin-secrets/vault', () => ({
+ VaultFullError: class extends Error {},
+ putUserCredential: vi.fn(async (_u: unknown, pluginId: string, credentialId: string, input: Record) => {
+ store.set(`${pluginId}/${credentialId}`, input);
+ }),
+ listUserCredentials: vi.fn(async () => []),
+ deleteUserCredentials: vi.fn(async () => {}),
+}));
+const proxied: unknown[] = [];
+vi.mock('@/lib/plugin-secrets/proxy', async (orig) => {
+ const real = await orig();
+ return {
+ ...real,
+ proxyRequest: vi.fn(async (input: unknown) => { proxied.push(input); return new Response('ok'); }),
+ adminCredentialStatus: vi.fn(async () => [{ credentialId: 'shared', configured: true }]),
+ adminCredentialOrigins: vi.fn(async () => ['https://paperless.example.com']),
+ };
+});
+
+import { GET as listCredentials, POST as saveCredential } from '../route';
+import { POST as proxy } from '../../plugin-proxy/route';
+
+const VALID = {
+ pluginId: 'nextcloud', credentialId: 'cloud', label: 'Nextcloud',
+ origin: 'https://cloud.example.org/index.php', inject: { type: 'basic' }, username: 'alice', secret: 'app-pw',
+};
+
+function save(body: unknown) {
+ return saveCredential(new NextRequest('http://localhost/api/plugin-secrets', { method: 'POST', body: JSON.stringify(body) }));
+}
+
+function proxyCall(query: string, headers: Record = {}) {
+ return proxy(new NextRequest(`http://localhost/api/plugin-proxy?${query}`, { method: 'POST', headers }));
+}
+
+beforeEach(() => {
+ signedIn = true;
+ pluginsEnabled = true;
+ sameOrigin = true;
+ store.clear();
+ proxied.length = 0;
+});
+
+describe('GET /api/plugin-secrets', () => {
+ it("names the servers of set-up admin credentials for one plugin, for the host page's link check", async () => {
+ const res = await listCredentials(new NextRequest('http://localhost/api/plugin-secrets?plugin=paperless'));
+ expect(await res.json()).toMatchObject({ admin: [{ credentialId: 'shared', configured: true }], adminOrigins: ['https://paperless.example.com'] });
+ const all = await listCredentials(new NextRequest('http://localhost/api/plugin-secrets'));
+ expect((await all.json()).adminOrigins).toEqual([]);
+ });
+});
+
+describe('POST /api/plugin-secrets', () => {
+ it('stores a credential for the server origin only', async () => {
+ const res = await save(VALID);
+
+ expect(res.status).toBe(200);
+ expect(store.get('nextcloud/cloud')).toMatchObject({ origin: 'https://cloud.example.org', secret: 'app-pw', username: 'alice' });
+ });
+
+ it.each([
+ ['a plain http server', { origin: 'http://cloud.example.org' }],
+ ['a header the proxy owns', { inject: { type: 'header', name: 'Cookie' } }],
+ ['a secret with a line break', { secret: 'a\r\nX-Evil: 1' }],
+ ['a bad plugin id', { pluginId: '../x' }],
+ ])('refuses %s', async (_name, change) => {
+ const res = await save({ ...VALID, ...change });
+
+ expect(res.status).toBe(400);
+ expect(store.size).toBe(0);
+ });
+
+ it('refuses without a session, with plugins off, or from another site', async () => {
+ signedIn = false;
+ expect((await save(VALID)).status).toBe(401);
+ signedIn = true;
+ pluginsEnabled = false;
+ expect((await save(VALID)).status).toBe(403);
+ pluginsEnabled = true;
+ sameOrigin = false;
+ expect((await save(VALID)).status).toBe(403);
+ expect(store.size).toBe(0);
+ });
+});
+
+describe('POST /api/plugin-proxy', () => {
+ it('passes the request on with the method and capture header', async () => {
+ const res = await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fcloud.example.org%2Flogin%2Fv2%2Fpoll', {
+ 'x-bulwark-method': 'POST', 'x-bulwark-capture': '{"secret":"appPassword","username":"loginName"}',
+ });
+
+ expect(res.status).toBe(200);
+ expect(proxied[0]).toMatchObject({
+ pluginId: 'nextcloud', credentialId: 'cloud', method: 'POST',
+ url: 'https://cloud.example.org/login/v2/poll',
+ capture: { secret: 'appPassword', username: 'loginName' },
+ user: { username: 'alice@example.org' },
+ });
+ });
+
+ it('refuses without a session, from another site, or with a broken capture header', async () => {
+ signedIn = false;
+ expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example')).status).toBe(401);
+ signedIn = true;
+ sameOrigin = false;
+ expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example')).status).toBe(403);
+ sameOrigin = true;
+ expect((await proxyCall('plugin=nextcloud&credential=cloud&url=https%3A%2F%2Fa.example', { 'x-bulwark-capture': '{"secret":"a.b"}' })).status).toBe(400);
+ expect(proxied).toEqual([]);
+ });
+});
diff --git a/app/api/plugin-secrets/route.ts b/app/api/plugin-secrets/route.ts
new file mode 100644
index 000000000..0e67b59ff
--- /dev/null
+++ b/app/api/plugin-secrets/route.ts
@@ -0,0 +1,108 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { configManager } from '@/lib/admin/config-manager';
+import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
+import { getStalwartCredentials } from '@/lib/stalwart/credentials';
+import { CREDENTIAL_ID_RE, checkInject, credentialOrigin } from '@/lib/plugin-secrets/credentials';
+import { adminCredentialOrigins, adminCredentialStatus } from '@/lib/plugin-secrets/proxy';
+import {
+ VaultFullError,
+ deleteUserCredentials,
+ listUserCredentials,
+ putUserCredential,
+ type VaultUser,
+} from '@/lib/plugin-secrets/vault';
+
+const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
+const MAX_SECRET_LENGTH = 4096;
+const NO_STORE = { 'cache-control': 'no-store' };
+
+async function currentUser(request: NextRequest): Promise {
+ await configManager.ensureLoaded();
+ if (!configManager.getPolicy().features.pluginsEnabled) {
+ return NextResponse.json({ error: 'Plugins are turned off' }, { status: 403 });
+ }
+ const creds = await getStalwartCredentials(request);
+ if (!creds) return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
+ return { username: creds.username, serverUrl: creds.serverUrl };
+}
+
+/** GET /api/plugin-secrets[?plugin=] - the user's connected accounts, without secrets. */
+export async function GET(request: NextRequest) {
+ const user = await currentUser(request);
+ if (user instanceof NextResponse) return user;
+ const pluginId = request.nextUrl.searchParams.get('plugin') ?? undefined;
+ if (pluginId !== undefined && !PLUGIN_ID_RE.test(pluginId)) {
+ return NextResponse.json({ error: 'Invalid plugin id' }, { status: 400 });
+ }
+ const credentials = await listUserCredentials(user, pluginId);
+ // For one plugin, also say which of its admin credentials are set up, and
+ // on which servers: the host page checks the links a plugin shows against
+ // them (the plugin itself only gets yes/no, see proxyAccounts).
+ const [admin, adminOrigins] = pluginId
+ ? await Promise.all([adminCredentialStatus(pluginId), adminCredentialOrigins(pluginId)])
+ : [[], []];
+ return NextResponse.json({ credentials, admin, adminOrigins }, { headers: NO_STORE });
+}
+
+/**
+ * POST /api/plugin-secrets - connect an account for a plugin.
+ * Body: { pluginId, credentialId, label, origin, inject, username?, secret? }
+ *
+ * Sent by the host's own "Connect" dialog, where the user typed the secret
+ * and saw which plugin and which server it is for. Without `secret` it only
+ * records the approved server, for a sign-in flow that fills the secret in
+ * later through the proxy.
+ */
+export async function POST(request: NextRequest) {
+ const crossOrigin = rejectCrossOriginRequest(request);
+ if (crossOrigin) return crossOrigin;
+ const user = await currentUser(request);
+ if (user instanceof NextResponse) return user;
+
+ let body: Record;
+ try {
+ body = await request.json();
+ } catch {
+ return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
+ }
+ const { pluginId, credentialId, label, username, secret } = body;
+ const origin = credentialOrigin(body.origin);
+ const inject = checkInject(body.inject);
+ if (typeof pluginId !== 'string' || !PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id');
+ if (typeof credentialId !== 'string' || !CREDENTIAL_ID_RE.test(credentialId)) return bad('Invalid credential id');
+ if (typeof label !== 'string' || !label.trim() || label.length > 80) return bad('Invalid label');
+ if (!origin) return bad('The server address must start with https://');
+ if (!inject) return bad('Invalid inject');
+ if (username !== undefined && (typeof username !== 'string' || username.length > 256 || /[\r\n]/.test(username))) return bad('Invalid username');
+ if (secret !== undefined && (typeof secret !== 'string' || !secret || secret.length > MAX_SECRET_LENGTH || /[\r\n]/.test(secret))) return bad('Invalid secret');
+
+ try {
+ await putUserCredential(user, pluginId, credentialId, {
+ origin, inject, label: label.trim(),
+ ...(typeof username === 'string' && username ? { username } : {}),
+ ...(typeof secret === 'string' ? { secret } : {}),
+ });
+ } catch (error) {
+ if (error instanceof VaultFullError) return NextResponse.json({ error: error.message }, { status: 409 });
+ throw error;
+ }
+ return NextResponse.json({ ok: true }, { headers: NO_STORE });
+}
+
+/** DELETE /api/plugin-secrets?plugin=[&credential=] - disconnect. */
+export async function DELETE(request: NextRequest) {
+ const crossOrigin = rejectCrossOriginRequest(request);
+ if (crossOrigin) return crossOrigin;
+ const user = await currentUser(request);
+ if (user instanceof NextResponse) return user;
+ const pluginId = request.nextUrl.searchParams.get('plugin') ?? '';
+ const credentialId = request.nextUrl.searchParams.get('credential') ?? undefined;
+ if (!PLUGIN_ID_RE.test(pluginId)) return bad('Invalid plugin id');
+ if (credentialId !== undefined && !CREDENTIAL_ID_RE.test(credentialId)) return bad('Invalid credential id');
+ await deleteUserCredentials(user, pluginId, credentialId);
+ return NextResponse.json({ ok: true }, { headers: NO_STORE });
+}
+
+function bad(error: string) {
+ return NextResponse.json({ error }, { status: 400 });
+}
diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts
index 8332d1da9..238ecc0e7 100644
--- a/app/api/plugins/route.ts
+++ b/app/api/plugins/route.ts
@@ -63,6 +63,8 @@ export async function GET(request: NextRequest) {
httpOrigins: p.httpOrigins,
// Surface so clients can enforce api.http.post path allowlists.
apiPostPaths: p.apiPostPaths,
+ // Which credentials the plugin may use through the proxy; no secrets.
+ credentials: p.credentials,
// Per-user settings schema, captured from the manifest at upload/load
// time so the client can render the settings UI without re-parsing.
settingsSchema: p.settingsSchema,
@@ -82,6 +84,8 @@ export async function GET(request: NextRequest) {
description: t.description,
variants: t.variants,
forceEnabled: t.forceEnabled || false,
+ hasSkin: t.hasSkin || false,
+ contentHash: t.contentHash,
}));
return NextResponse.json(
diff --git a/app/globals.css b/app/globals.css
index 5d0db0a5f..2c1008564 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -1,7 +1,7 @@
@import "tailwindcss";
@import "tw-animate-css";
-@custom-variant dark (&:where(.dark, .dark *));
+@import "./theme-inline.css";
:root {
font-size: var(--font-size-base);
@@ -123,46 +123,6 @@
--color-chart-5: #a78bfa;
}
-@theme inline {
- --color-border: var(--color-border);
- --color-input: var(--color-input);
- --color-ring: var(--color-ring);
- --color-background: var(--color-background);
- --color-foreground: var(--color-foreground);
- --color-primary: var(--color-primary);
- --color-primary-foreground: var(--color-primary-foreground);
- --color-secondary: var(--color-secondary);
- --color-secondary-foreground: var(--color-secondary-foreground);
- --color-muted: var(--color-muted);
- --color-muted-foreground: var(--color-muted-foreground);
- --color-accent: var(--color-accent);
- --color-accent-foreground: var(--color-accent-foreground);
- --color-destructive: var(--color-destructive);
- --color-destructive-foreground: var(--color-destructive-foreground);
- --color-popover: var(--color-popover);
- --color-popover-foreground: var(--color-popover-foreground);
- --color-sidebar: var(--color-sidebar);
- --color-sidebar-foreground: var(--color-sidebar-foreground);
- --color-sidebar-border: var(--color-sidebar-border);
- --color-sidebar-accent: var(--color-sidebar-accent);
- --color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground);
- --color-card: var(--color-card);
- --color-card-foreground: var(--color-card-foreground);
- --color-success: var(--color-success);
- --color-success-foreground: var(--color-success-foreground);
- --color-warning: var(--color-warning);
- --color-warning-foreground: var(--color-warning-foreground);
- --color-info: var(--color-info);
- --color-info-foreground: var(--color-info-foreground);
- --color-selection: var(--color-selection);
- --color-selection-foreground: var(--color-selection-foreground);
- --color-unread: var(--color-unread);
- --color-chart-1: var(--color-chart-1);
- --color-chart-2: var(--color-chart-2);
- --color-chart-3: var(--color-chart-3);
- --color-chart-4: var(--color-chart-4);
- --color-chart-5: var(--color-chart-5);
-}
* {
border-color: var(--color-border);
diff --git a/app/theme-inline.css b/app/theme-inline.css
new file mode 100644
index 000000000..32ab34fc7
--- /dev/null
+++ b/app/theme-inline.css
@@ -0,0 +1,46 @@
+/* Shared by the app (globals.css) and the plugin UI kit (sandbox
+ plugin-kit.css): the dark variant and the mapping from the theme tokens to
+ Tailwind colours, so both generate the same utilities. */
+
+@custom-variant dark (&:where(.dark, .dark *));
+
+@theme inline {
+ --color-border: var(--color-border);
+ --color-input: var(--color-input);
+ --color-ring: var(--color-ring);
+ --color-background: var(--color-background);
+ --color-foreground: var(--color-foreground);
+ --color-primary: var(--color-primary);
+ --color-primary-foreground: var(--color-primary-foreground);
+ --color-secondary: var(--color-secondary);
+ --color-secondary-foreground: var(--color-secondary-foreground);
+ --color-muted: var(--color-muted);
+ --color-muted-foreground: var(--color-muted-foreground);
+ --color-accent: var(--color-accent);
+ --color-accent-foreground: var(--color-accent-foreground);
+ --color-destructive: var(--color-destructive);
+ --color-destructive-foreground: var(--color-destructive-foreground);
+ --color-popover: var(--color-popover);
+ --color-popover-foreground: var(--color-popover-foreground);
+ --color-sidebar: var(--color-sidebar);
+ --color-sidebar-foreground: var(--color-sidebar-foreground);
+ --color-sidebar-border: var(--color-sidebar-border);
+ --color-sidebar-accent: var(--color-sidebar-accent);
+ --color-sidebar-accent-foreground: var(--color-sidebar-accent-foreground);
+ --color-card: var(--color-card);
+ --color-card-foreground: var(--color-card-foreground);
+ --color-success: var(--color-success);
+ --color-success-foreground: var(--color-success-foreground);
+ --color-warning: var(--color-warning);
+ --color-warning-foreground: var(--color-warning-foreground);
+ --color-info: var(--color-info);
+ --color-info-foreground: var(--color-info-foreground);
+ --color-selection: var(--color-selection);
+ --color-selection-foreground: var(--color-selection-foreground);
+ --color-unread: var(--color-unread);
+ --color-chart-1: var(--color-chart-1);
+ --color-chart-2: var(--color-chart-2);
+ --color-chart-3: var(--color-chart-3);
+ --color-chart-4: var(--color-chart-4);
+ --color-chart-5: var(--color-chart-5);
+}
diff --git a/components/__tests__/keyboard-shortcuts-palette.test.tsx b/components/__tests__/keyboard-shortcuts-palette.test.tsx
new file mode 100644
index 000000000..b4e6c02ed
--- /dev/null
+++ b/components/__tests__/keyboard-shortcuts-palette.test.tsx
@@ -0,0 +1,37 @@
+import { act, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+const pro = vi.hoisted(() => ({ active: false }));
+vi.mock('@/components/pro/pro-interface-redirect', () => ({ useProInterfaceActive: () => pro.active }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
+
+const { KeyboardShortcutsModal } = await import('../keyboard-shortcuts-modal');
+const { resetActions, setPluginActions } = await import('@/lib/plugin-sandbox/actions');
+
+describe('keyboard shortcuts help', () => {
+ it('lists Ctrl+K as the command palette outside the Pro shell', () => {
+ pro.active = false;
+ render( {}} />);
+ expect(screen.getByText('command_palette.open')).toBeInTheDocument();
+ expect(screen.queryByText('shortcuts.global.global_search')).toBeNull();
+ });
+
+ it('lists Ctrl+K as global search in the Pro shell', () => {
+ pro.active = true;
+ render( {}} />);
+ expect(screen.getByText('shortcuts.global.global_search')).toBeInTheDocument();
+ expect(screen.queryByText('command_palette.open')).toBeNull();
+ });
+
+ it("lists a plugin's composer button shortcut under the composer, with the plugin's name", () => {
+ act(() => setPluginActions('assistant', 'Writing assistant', 'composer-toolbar-actions', [
+ { id: 'ask', label: 'Assistant', shortcut: 'Ctrl+Shift+Y' },
+ { id: 'quiet', label: 'No key' },
+ ]));
+ render( {}} />);
+ expect(screen.getByText('Assistant (Writing assistant)')).toBeInTheDocument();
+ expect(screen.getByText('Ctrl+Shift+Y')).toBeInTheDocument();
+ expect(screen.queryByText(/No key/)).toBeNull();
+ act(() => resetActions());
+ });
+});
diff --git a/components/__tests__/plugin-app-events.test.tsx b/components/__tests__/plugin-app-events.test.tsx
new file mode 100644
index 000000000..75790e086
--- /dev/null
+++ b/components/__tests__/plugin-app-events.test.tsx
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, render } from '@testing-library/react';
+import { appLifecycleHooks, clearAllHooks, routerHooks } from '@/lib/plugin-hooks';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useAuthStore } from '@/stores/auth-store';
+
+let pathname = '/';
+vi.mock('next/navigation', () => ({ usePathname: () => pathname }));
+
+import { PluginAppEvents, PLUGIN_INTERVAL_MS } from '../providers/plugin-app-events';
+
+function listen unknown) => unknown }>(bus: T) {
+ const fn = vi.fn();
+ bus.register('test', fn);
+ return fn;
+}
+
+beforeEach(() => {
+ clearAllHooks();
+ pathname = '/';
+ usePluginStore.setState({ initialized: false, initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useAuthStore.setState({ isAuthenticated: false });
+});
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe('app events for plugins', () => {
+ // These used to live in the mail view and stopped on every other page.
+ it('reports the network and tab state on any page', () => {
+ const online = listen(appLifecycleHooks.onOnline);
+ const visibility = listen(appLifecycleHooks.onVisibilityChange);
+ render( );
+
+ act(() => { window.dispatchEvent(new Event('online')); });
+ act(() => { document.dispatchEvent(new Event('visibilitychange')); });
+
+ expect(online).toHaveBeenCalledTimes(1);
+ expect(visibility).toHaveBeenCalledWith({ visible: true });
+ });
+
+ it('reports unhandled errors', () => {
+ const errors = listen(appLifecycleHooks.onAppError);
+ render( );
+
+ act(() => { window.dispatchEvent(new ErrorEvent('error', { message: 'boom' })); });
+
+ expect(errors).toHaveBeenCalledWith({ message: 'boom' });
+ });
+
+ it('ticks once a minute', () => {
+ vi.useFakeTimers();
+ const tick = listen(appLifecycleHooks.onInterval);
+ render( );
+
+ act(() => { vi.advanceTimersByTime(PLUGIN_INTERVAL_MS * 2 + 10); });
+
+ expect(tick).toHaveBeenCalledTimes(2);
+ });
+
+ it('says the app is ready once, after sign-in and plugin start', () => {
+ const ready = listen(appLifecycleHooks.onAppReady);
+ const { rerender } = render( );
+ expect(ready).not.toHaveBeenCalled();
+
+ act(() => {
+ useAuthStore.setState({ isAuthenticated: true });
+ usePluginStore.setState({ initialized: true });
+ });
+ rerender( );
+ act(() => { usePluginStore.setState({ initialized: false }); });
+ act(() => { usePluginStore.setState({ initialized: true }); });
+
+ expect(ready).toHaveBeenCalledTimes(1);
+ });
+
+ it('starts the plugins once the user is signed in, on any page', () => {
+ render( );
+ expect(usePluginStore.getState().initializePlugins).not.toHaveBeenCalled();
+
+ act(() => { useAuthStore.setState({ isAuthenticated: true }); });
+
+ expect(usePluginStore.getState().initializePlugins).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports navigation between pages', () => {
+ const navigate = listen(routerHooks.onNavigate);
+ const { rerender } = render( );
+
+ pathname = '/calendar';
+ rerender( );
+
+ expect(navigate).toHaveBeenCalledWith({ path: '/calendar', from: '/' });
+ });
+});
diff --git a/components/__tests__/plugin-oauth-callback-listener.test.tsx b/components/__tests__/plugin-oauth-callback-listener.test.tsx
index 6b738810c..2c84da6d9 100644
--- a/components/__tests__/plugin-oauth-callback-listener.test.tsx
+++ b/components/__tests__/plugin-oauth-callback-listener.test.tsx
@@ -2,6 +2,7 @@ import { authHooks } from "@/lib/plugin-hooks";
import {
PLUGIN_OAUTH_EVENT,
PLUGIN_OAUTH_STORAGE_KEY,
+ createOAuthState,
} from "@/lib/plugin-oauth";
import { act, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -25,6 +26,8 @@ function emitStorageEvent(newValue: string | null) {
});
}
+let STATE = "";
+
describe("PluginOAuthCallbackListener", () => {
let handler: (payload: unknown) => Promise;
let disposable: { dispose: () => void };
@@ -35,6 +38,7 @@ describe("PluginOAuthCallbackListener", () => {
mockUsePathname.mockReturnValue("/en/mail");
handler = vi.fn<(payload: unknown) => Promise>().mockResolvedValue(undefined);
disposable = authHooks.onOAuthCallback.register("test-plugin", handler);
+ STATE = createOAuthState("test-plugin");
});
afterEach(() => {
@@ -43,14 +47,14 @@ describe("PluginOAuthCallbackListener", () => {
it("delivers a success payload via the storage event", () => {
const { unmount } = render( );
- const payload = { code: "abc", state: "xyz", receivedAt: 1 };
+ const payload = { code: "abc", state: STATE, receivedAt: 1 };
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(
- expect.objectContaining({ code: "abc", state: "xyz" }),
+ expect.objectContaining({ code: "abc", state: STATE }),
);
// Best-effort cleanup after delivery
expect(localStorage.getItem(PLUGIN_OAUTH_STORAGE_KEY)).toBeNull();
@@ -59,14 +63,14 @@ describe("PluginOAuthCallbackListener", () => {
it("delivers an error payload (denial) with state", () => {
const { unmount } = render( );
- const payload = { state: "xyz", error: "access_denied", receivedAt: 1 };
+ const payload = { state: STATE, error: "access_denied", receivedAt: 1 };
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(
- expect.objectContaining({ state: "xyz", error: "access_denied" }),
+ expect.objectContaining({ state: STATE, error: "access_denied" }),
);
unmount();
});
@@ -74,7 +78,7 @@ describe("PluginOAuthCallbackListener", () => {
it("does nothing on the callback route", () => {
mockUsePathname.mockReturnValue("/en/plugins/oauth/callback");
const { unmount } = render( );
- const payload = { code: "abc", state: "xyz", receivedAt: 1 };
+ const payload = { code: "abc", state: STATE, receivedAt: 1 };
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
@@ -95,7 +99,7 @@ describe("PluginOAuthCallbackListener", () => {
emitStorageEvent("not json");
emitStorageEvent(JSON.stringify({ foo: "bar" }));
- emitStorageEvent(JSON.stringify({ code: 123, state: "xyz" }));
+ emitStorageEvent(JSON.stringify({ code: 123, state: STATE }));
emitStorageEvent(null);
expect(handler).not.toHaveBeenCalled();
@@ -119,7 +123,7 @@ describe("PluginOAuthCallbackListener", () => {
try {
vi.setSystemTime(0);
const { unmount } = render( );
- const payload = { code: "abc", state: "xyz", receivedAt: 1 };
+ const payload = { code: "abc", state: STATE, receivedAt: 1 };
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
@@ -127,10 +131,10 @@ describe("PluginOAuthCallbackListener", () => {
expect(handler).toHaveBeenCalledOnce();
- // Outside the dedup window the same payload is delivered again
+ // A state is used once: replaying it later delivers nothing either.
vi.setSystemTime(61_000);
emitStorageEvent(JSON.stringify(payload));
- expect(handler).toHaveBeenCalledTimes(2);
+ expect(handler).toHaveBeenCalledOnce();
unmount();
} finally {
@@ -140,7 +144,7 @@ describe("PluginOAuthCallbackListener", () => {
it("delivers same-tab custom events", () => {
const { unmount } = render( );
- const payload = { code: "abc", state: "xyz", receivedAt: 1 };
+ const payload = { code: "abc", state: STATE, receivedAt: 1 };
act(() => {
window.dispatchEvent(
@@ -150,7 +154,7 @@ describe("PluginOAuthCallbackListener", () => {
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(
- expect.objectContaining({ code: "abc", state: "xyz" }),
+ expect.objectContaining({ code: "abc", state: STATE }),
);
unmount();
});
@@ -159,7 +163,7 @@ describe("PluginOAuthCallbackListener", () => {
const { unmount } = render( );
unmount();
- const payload = { code: "abc", state: "xyz", receivedAt: 1 };
+ const payload = { code: "abc", state: STATE, receivedAt: 1 };
localStorage.setItem(PLUGIN_OAUTH_STORAGE_KEY, JSON.stringify(payload));
emitStorageEvent(JSON.stringify(payload));
act(() => {
@@ -170,4 +174,29 @@ describe("PluginOAuthCallbackListener", () => {
expect(handler).not.toHaveBeenCalled();
});
+
+ it("delivers the code only to the plugin that started the flow", () => {
+ // Every plugin with http:fetch used to receive every code.
+ const other = vi.fn<(payload: unknown) => Promise>().mockResolvedValue(undefined);
+ const otherDisposable = authHooks.onOAuthCallback.register("other-plugin", other);
+ const { unmount } = render( );
+
+ emitStorageEvent(JSON.stringify({ code: "abc", state: STATE, receivedAt: 1 }));
+
+ expect(handler).toHaveBeenCalledOnce();
+ expect(other).not.toHaveBeenCalled();
+ otherDisposable.dispose();
+ unmount();
+ });
+
+ it("drops a callback whose state no plugin created", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const { unmount } = render( );
+
+ emitStorageEvent(JSON.stringify({ code: "abc", state: "made-up", receivedAt: 1 }));
+
+ expect(handler).not.toHaveBeenCalled();
+ warn.mockRestore();
+ unmount();
+ });
});
diff --git a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..b1d0063c1
--- /dev/null
+++ b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
@@ -0,0 +1,254 @@
+import { render, screen, fireEvent, waitFor, act, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { EventModal } from '../event-modal';
+import { EventDetailPopover } from '../event-detail-popover';
+import { CalendarWeekView } from '../calendar-week-view';
+import { CalendarDayView } from '../calendar-day-view';
+import { CalendarMonthView } from '../calendar-month-view';
+import { MiniCalendar } from '../mini-calendar';
+import { resetDayLabels, setDayLabels } from '@/lib/plugin-sandbox/day-labels';
+import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
+import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
+
+// Plugin surfaces in the calendar: a section in an event's details and in
+// its form, and the labels and dots plugins put on days.
+
+vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+
+const calendars = [
+ { id: 'cal-1', name: 'Work', color: '#123456', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
+] as unknown as Calendar[];
+
+const existing = {
+ id: 'e1',
+ uid: 'uid-1',
+ title: 'Planning',
+ start: '2026-10-12T10:00:00',
+ duration: 'PT1H',
+ timeZone: null,
+ showWithoutTime: false,
+ calendarIds: { 'cal-1': true },
+} as unknown as CalendarEvent;
+
+afterEach(() => {
+ cleanup();
+ clearSlotOffers();
+ resetDayLabels();
+});
+
+describe('calendar-event-detail', () => {
+ it('shows the event in the detail popover', () => {
+ offerSlots('p1', ['calendar-event-detail']);
+ render(
+ ,
+ );
+
+ const frame = screen.getByTestId('plugin-frame-calendar-event-detail');
+ expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' });
+ });
+
+ it('moves the popover up when the plugin section makes it taller, so it stays in the window', () => {
+ let resize: (() => void) | null = null;
+ vi.stubGlobal('ResizeObserver', class {
+ constructor(cb: () => void) { resize = cb; }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ });
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
+ Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
+ let height = 200;
+ const spy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
+ return this.getAttribute('role') === 'dialog' ? height : 0;
+ });
+ try {
+ offerSlots('p1', ['calendar-event-detail']);
+ // An event at 14:00 in the week view of a 1280x800 window.
+ render(
+ ,
+ );
+ const popover = screen.getByRole('dialog');
+ expect(popover.style.top).toBe('520px');
+
+ height = 420;
+ act(() => resize?.());
+ expect(popover.style.top).toBe(`${800 - 420 - 12}px`);
+ } finally {
+ spy.mockRestore();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('shows the event in the modal view, and not in the form', () => {
+ offerSlots('p1', ['calendar-event-detail']);
+ render( );
+
+ expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-detail')).event).toMatchObject({ id: 'e1', title: 'Planning' });
+
+ fireEvent.click(screen.getByText('events.edit'));
+ expect(screen.queryByTestId('plugin-frame-calendar-event-detail')).toBeNull();
+ });
+
+ it('renders nothing when no plugin offers it', () => {
+ render( );
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
+
+describe('calendar-event-form', () => {
+ it('hands the plugin the form state of an edited event and follows the fields', async () => {
+ offerSlots('p1', ['calendar-event-form']);
+ render( );
+ expect(screen.queryByTestId('plugin-frame-calendar-event-form')).toBeNull();
+
+ fireEvent.click(screen.getByText('events.edit'));
+ const frame = screen.getByTestId('plugin-frame-calendar-event-form');
+ expect(framePropsOf(frame).event).toMatchObject({
+ id: 'e1', uid: 'uid-1', calendarId: 'cal-1', title: 'Planning', start: '2026-10-12T10:00:00', duration: 'PT1H', isAllDay: false,
+ });
+
+ fireEvent.change(screen.getByPlaceholderText('form.title'), { target: { value: 'Planning v2' } });
+ await waitFor(() => {
+ expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({ title: 'Planning v2' });
+ });
+ });
+
+ it('gives a new event an empty id', () => {
+ offerSlots('p1', ['calendar-event-form']);
+ render(
+ ,
+ );
+
+ expect(framePropsOf(screen.getByTestId('plugin-frame-calendar-event-form')).event).toMatchObject({
+ id: '', uid: '', calendarId: 'cal-1', duration: 'PT1H30M', participants: [],
+ });
+ });
+
+ it('renders nothing when no plugin offers it', () => {
+ render( );
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
+
+describe('calendar-day-header and mini-calendar-marks', () => {
+ const week = {
+ focus: { date: new Date(2026, 8, 9), nonce: 0 },
+ windowKey: 'week:2026-09-07',
+ rangeStart: new Date(2026, 8, 7),
+ rangeEnd: new Date(2026, 8, 13),
+ selectedDate: new Date(2026, 8, 9),
+ events: [],
+ calendars,
+ onSelectDate: vi.fn(),
+ onSelectEvent: vi.fn(),
+ onCreateAtTime: vi.fn(),
+ };
+
+ function headerOf(dayKey: string): HTMLElement {
+ const header = document.querySelector(`[role="columnheader"][data-day="${dayKey}"], [data-day="${dayKey}"] [role="columnheader"]`);
+ if (!header) throw new Error(`no header for ${dayKey}`);
+ return header;
+ }
+
+ it('puts a label under the day name in the week view', () => {
+ render( );
+ expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
+
+ act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day', title: 'A feast' }]));
+
+ const label = headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]');
+ expect(label).toHaveTextContent('Feast day');
+ expect(label?.title).toBe('A feast (Holidays)');
+ expect(headerOf('2026-09-10').querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
+ });
+
+ it('puts a label under the day name in the day view', () => {
+ act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
+ render( );
+
+ expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day');
+ });
+
+ it('shows the label in the month cell header, as a dot on phones', () => {
+ act(() => setDayLabels('hol', 'Holidays', [
+ { date: '2026-09-09', text: 'Feast day' },
+ { date: '2026-09-10', dot: true, title: 'Dot only' },
+ ]));
+ const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) };
+ const { unmount } = render( );
+
+ const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]');
+ expect(labels).toHaveLength(1);
+ expect(labels[0]).toHaveTextContent('Feast day');
+ expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
+ unmount();
+
+ render( );
+ const dots = [...document.querySelectorAll('[data-plugin-surface="calendar-day-header"]')];
+ expect(dots).toHaveLength(2);
+ for (const dot of dots) expect(dot).toHaveAttribute('data-compact');
+ expect(dots[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
+ expect(dots[0].title).toBe('Feast day (Holidays)');
+ expect(dots[1].title).toBe('Dot only (Holidays)');
+ });
+
+ it('marks days with a dot in the small month calendar', () => {
+ render(
+ ,
+ );
+ expect(document.querySelector('[data-plugin-surface="mini-calendar-marks"]')).toBeNull();
+
+ act(() => setDayLabels('hol', 'Holidays', [
+ { date: '2026-09-09', dot: true, title: 'A feast' },
+ { date: '2026-09-10', text: 'Text only' },
+ ]));
+
+ const dots = document.querySelectorAll('[data-plugin-surface="mini-calendar-marks"]');
+ expect(dots).toHaveLength(1);
+ expect(dots[0].closest('button')).toHaveTextContent('9');
+ expect(dots[0].closest('button')).toHaveTextContent('A feast');
+ });
+
+ it('keeps the dot visible on today, whose filled circle has white text', () => {
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(2026, 8, 9, 12));
+ try {
+ act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', dot: true, title: 'A feast' }]));
+ render(
+ ,
+ );
+ const dot = document.querySelector('[data-plugin-surface="mini-calendar-marks"]')!;
+ expect(dot.closest('button')).toHaveAttribute('aria-current', 'date');
+ // Its own colour, not the button's text colour, which is white on today.
+ expect(dot.className).not.toMatch(/bg-current/);
+ expect(dot.className).toMatch(/ring-background/);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/components/calendar/__tests__/event-modal-open-hooks.test.tsx b/components/calendar/__tests__/event-modal-open-hooks.test.tsx
new file mode 100644
index 000000000..00c4e44ca
--- /dev/null
+++ b/components/calendar/__tests__/event-modal-open-hooks.test.tsx
@@ -0,0 +1,66 @@
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { EventModal } from '../event-modal';
+import { calendarFormHooks, calendarHooks, clearAllHooks } from '@/lib/plugin-hooks';
+import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
+
+// Opening an event for reading fires onCalendarEventOpen once; opening the
+// form (a new event, or Edit on an open one) fires onCalendarEventFormOpen.
+
+const calendars = [
+ { id: 'cal-1', name: 'Work', isDefault: true, myRights: { mayWriteAll: true, mayWriteOwn: true } },
+] as unknown as Calendar[];
+
+const existing = {
+ id: 'e1',
+ uid: 'uid-1',
+ title: 'Planning',
+ start: '2026-10-12T10:00:00',
+ duration: 'PT1H',
+ timeZone: null,
+ showWithoutTime: false,
+ calendarIds: { 'cal-1': true },
+} as unknown as CalendarEvent;
+
+afterEach(() => clearAllHooks());
+
+describe('EventModal open hooks', () => {
+ it('reports an existing event opened for reading, and the form when Edit is pressed', async () => {
+ const opened = vi.fn();
+ const formOpened = vi.fn();
+ calendarHooks.onCalendarEventOpen.register('t', opened);
+ calendarFormHooks.onCalendarEventFormOpen.register('t', formOpened);
+
+ render( );
+
+ await waitFor(() => expect(opened).toHaveBeenCalledTimes(1));
+ expect(opened.mock.calls[0][0]).toMatchObject({ id: 'e1', title: 'Planning', calendarId: 'cal-1' });
+ expect(formOpened).not.toHaveBeenCalled();
+
+ fireEvent.click(screen.getByText('events.edit'));
+ await waitFor(() => expect(formOpened).toHaveBeenCalledTimes(1));
+ expect(formOpened.mock.calls[0][0]).toMatchObject({ title: 'Planning', calendarId: 'cal-1', uid: 'uid-1', isAllDay: false });
+ expect(opened).toHaveBeenCalledTimes(1);
+ });
+
+ it('reports the form for a new event, and no event open', async () => {
+ const opened = vi.fn();
+ const formOpened = vi.fn();
+ calendarHooks.onCalendarEventOpen.register('t', opened);
+ calendarFormHooks.onCalendarEventFormOpen.register('t', formOpened);
+
+ render(
+ ,
+ );
+
+ await waitFor(() => expect(formOpened).toHaveBeenCalledTimes(1));
+ expect(formOpened.mock.calls[0][0]).toMatchObject({ calendarId: 'cal-1', uid: undefined, attendees: [] });
+ expect(opened).not.toHaveBeenCalled();
+ });
+});
diff --git a/components/calendar/__tests__/task-plugin-surfaces.test.tsx b/components/calendar/__tests__/task-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..473f460e5
--- /dev/null
+++ b/components/calendar/__tests__/task-plugin-surfaces.test.tsx
@@ -0,0 +1,110 @@
+import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { TaskModal } from '../task-modal';
+import { TaskToolbar } from '../task-toolbar';
+import { filterTasksForList } from '../task-list-view';
+import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
+import type { Calendar, CalendarTask } from '@/lib/jmap/types';
+
+// Plugin surfaces for tasks: a section in a task's details and buttons in
+// the task list toolbar acting on the tasks the list shows.
+
+vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+
+const calendars = [{ id: 'cal-1', name: 'Tasks', isShared: false }] as unknown as Calendar[];
+
+function makeTask(id: string, overrides: Partial = {}): CalendarTask {
+ return {
+ id,
+ '@type': 'Task',
+ uid: `uid-${id}`,
+ title: `Task ${id}`,
+ description: '',
+ due: null,
+ showWithoutTime: true,
+ priority: 0,
+ progress: 'needs-action',
+ calendarIds: { 'cal-1': true },
+ alerts: null,
+ ...overrides,
+ } as unknown as CalendarTask;
+}
+
+const dispatch = vi.fn().mockResolvedValue(undefined);
+
+beforeEach(() => {
+ dispatch.mockClear();
+ setActionDispatcher(dispatch);
+});
+
+afterEach(() => {
+ cleanup();
+ clearSlotOffers();
+ resetActions();
+ setActionDispatcher(null);
+});
+
+describe('task-detail-section', () => {
+ it('shows an open task to the plugin', () => {
+ offerSlots('p1', ['task-detail-section']);
+ render( );
+
+ const frame = screen.getByTestId('plugin-frame-task-detail-section');
+ expect(framePropsOf(frame).task).toMatchObject({ id: 't1', uid: 'uid-t1', title: 'Task t1', priority: 5, calendarId: 'cal-1', isComplete: false });
+ });
+
+ it('is not shown for a new task or without a plugin', () => {
+ offerSlots('p1', ['task-detail-section']);
+ const { unmount } = render( );
+ expect(screen.queryByTestId('plugin-frame-task-detail-section')).toBeNull();
+ unmount();
+
+ clearSlotOffers();
+ render( );
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
+
+describe('task-list-actions', () => {
+ const toolbar = {
+ filter: 'all' as const,
+ showCompleted: false,
+ onFilterChange: vi.fn(),
+ onShowCompletedChange: vi.fn(),
+ onCreateTask: vi.fn(),
+ };
+
+ it('puts plugin buttons after the create button and sends the listed task ids', async () => {
+ act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }]));
+ render( );
+
+ const button = screen.getByRole('button', { name: 'Plan my day' });
+ expect(screen.getByText('tasks.create').compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+
+ await act(async () => { fireEvent.click(button); });
+ expect(dispatch).toHaveBeenCalledWith('pm', 'task-list-actions', 'plan', { taskIds: ['t2', 't1'] });
+ });
+
+ it('draws icon-only buttons when compact', () => {
+ act(() => setPluginActions('pm', 'Planner', 'task-list-actions', [{ id: 'plan', label: 'Plan my day' }]));
+ render( );
+ expect(screen.getByRole('button', { name: 'Plan my day' })).not.toHaveTextContent('Plan my day');
+ });
+
+ it('renders nothing without a plugin', () => {
+ render( );
+ expect(document.querySelector('[data-plugin-actions]')).toBeNull();
+ });
+
+ it('lists the ids the task list shows, in its order', () => {
+ const tasks = [
+ makeTask('done', { progress: 'completed' }),
+ makeTask('later', { due: '2030-01-02' }),
+ makeTask('other-cal', { calendarIds: { 'cal-2': true } }),
+ makeTask('soon', { due: '2030-01-01' }),
+ ];
+ expect(filterTasksForList(tasks, ['cal-1'], 'all', false).map((t) => t.id)).toEqual(['soon', 'later']);
+ expect(filterTasksForList(tasks, ['cal-1'], 'all', true).map((t) => t.id)).toEqual(['soon', 'later', 'done']);
+ });
+});
diff --git a/components/calendar/calendar-app.tsx b/components/calendar/calendar-app.tsx
index 9d5b6e4e6..d3b17fe01 100644
--- a/components/calendar/calendar-app.tsx
+++ b/components/calendar/calendar-app.tsx
@@ -25,7 +25,7 @@ import { CalendarMonthView } from "@/components/calendar/calendar-month-view";
import { CalendarWeekView } from "@/components/calendar/calendar-week-view";
import { CalendarDayView } from "@/components/calendar/calendar-day-view";
import { CalendarAgendaView } from "@/components/calendar/calendar-agenda-view";
-import { TaskListView } from "@/components/calendar/task-list-view";
+import { TaskListView, filterTasksForList } from "@/components/calendar/task-list-view";
import { TaskToolbar } from "@/components/calendar/task-toolbar";
import { TaskModal } from "@/components/calendar/task-modal";
import { MiniCalendar } from "@/components/calendar/mini-calendar";
@@ -97,6 +97,8 @@ import { findMeetingLink } from "@/lib/event-links";
import { useDocumentTitle } from "@/hooks/use-document-title";
import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
+import { calendarHooks } from "@/lib/plugin-hooks";
+import { eventToView } from "@/lib/plugin-projection";
type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean }
@@ -707,6 +709,9 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
setDetailPinned(true);
setDetailEvent(event);
setDetailAnchorRect(anchorRect);
+ // A click opens the event's card; the hover preview does not count as
+ // opening it. The editor reports its own opens (event-modal).
+ void calendarHooks.onCalendarEventOpen.emit(eventToView(event));
}, [isMobile, closeDetail, openEditModal]);
const {
@@ -1206,7 +1211,10 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
delete (newEventData as Record).uid;
delete (newEventData as Record).recurrenceId;
try {
- await createEvent(client, newEventData, sendScheduling);
+ // null when the save failed or a plugin stopped it: either way
+ // the truncated master has to be restored.
+ const created = await createEvent(client, newEventData, sendScheduling);
+ if (!created) throw new Error("The new series was not created");
} catch (createError) {
debug.error("Failed to create new series, rolling back master truncation:", createError);
try {
@@ -1480,6 +1488,12 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
return filtered;
}, [events, selectedCalendarIds, showBirthdayCalendar, birthdayEvents]);
+ // The tasks the task list shows; plugin buttons in its toolbar act on them.
+ const listedTaskIds = useMemo(
+ () => filterTasksForList(taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted).map((task) => task.id),
+ [taskStore.tasks, selectedCalendarIds, taskStore.filter, taskStore.showCompleted],
+ );
+
// Tasks on the month, week and day grids follow the calendar filter like
// the events do (#1107).
const calendarTasks = useMemo(() => {
@@ -1671,6 +1685,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
onFilterChange={taskStore.setFilter}
onShowCompletedChange={taskStore.setShowCompleted}
onCreateTask={openCreateTaskModal}
+ taskIds={listedTaskIds}
+ compact={isMobile}
/>
{format(day, "d")}
+
{hasAllDayArea && (
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx
index 31ba69a59..cd413898a 100644
--- a/components/calendar/calendar-month-view.tsx
+++ b/components/calendar/calendar-month-view.tsx
@@ -18,6 +18,7 @@ import { toast } from "@/stores/toast-store";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
import { useScrollWindow } from "@/hooks/use-scroll-window";
import { dayKey, parseDayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
+import { PluginDayLabel } from "./plugin-day-label";
interface CalendarMonthViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -379,6 +380,8 @@ export function CalendarMonthView({
>
{formatDayNumber(day)}
+ {/* Phones have no room for the text: a dot stands in for it. */}
+
{isMobile && !showChips ? (
diff --git a/components/calendar/calendar-week-view.tsx b/components/calendar/calendar-week-view.tsx
index 2a342a034..d7f63c9d4 100644
--- a/components/calendar/calendar-week-view.tsx
+++ b/components/calendar/calendar-week-view.tsx
@@ -23,6 +23,7 @@ import { useScrollWindow, getScrollStart, setScrollStart, scrollToStart } from "
import { dayKey, type ScrollWindowViewProps } from "@/lib/calendar-scroll-window";
import type { PendingEventPreview } from "./event-modal";
import { readableTextOn } from "@/lib/color-transform";
+import { PluginDayLabel } from "./plugin-day-label";
interface CalendarWeekViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -436,6 +437,7 @@ export function CalendarWeekView({
>
{format(day, "d")}
+
);
})}
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx
index e7c573d4c..4ab0eba77 100644
--- a/components/calendar/event-detail-popover.tsx
+++ b/components/calendar/event-detail-popover.tsx
@@ -25,6 +25,8 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { eventToView } from "@/lib/plugin-projection";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -52,13 +54,18 @@ const POPOVER_GAP = 8;
const VIEWPORT_MARGIN = 12;
const MAX_HEIGHT = 480;
+/** As tall as MAX_HEIGHT, but never taller than the window. */
+function maxPopoverHeight(): number {
+ return Math.max(0, Math.min(MAX_HEIGHT, window.innerHeight - 2 * VIEWPORT_MARGIN));
+}
+
function computePosition(
anchorRect: DOMRect,
popoverHeight: number
): { top: number; left: number } {
const vw = window.innerWidth;
const vh = window.innerHeight;
- const clampedHeight = Math.min(popoverHeight, MAX_HEIGHT);
+ const clampedHeight = Math.min(popoverHeight, maxPopoverHeight());
const clampTop = (top: number) =>
Math.min(Math.max(VIEWPORT_MARGIN, top), vh - clampedHeight - VIEWPORT_MARGIN);
@@ -145,6 +152,7 @@ export function EventDetailPopover({
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
const meeting = useMemo(() => findMeetingLink(event), [event]);
+ const pluginProps = useMemo(() => ({ event: eventToView(event) }), [event]);
const virtualLocation = meeting?.uri ?? null;
// A URL opens as a link, a location that only names the service joins the
@@ -223,6 +231,19 @@ export function EventDetailPopover({
if (!ready) requestAnimationFrame(() => setReady(true));
}, [anchorRect, noteExpanded, showDeleteConfirm, ready]);
+ // Content that grows after the popover was placed (a plugin section reports
+ // its height a moment later) moves it again, so it stays in the window.
+ useEffect(() => {
+ const node = popoverRef.current;
+ if (!node || isMobile || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(() => {
+ const next = computePosition(anchorRect, node.offsetHeight);
+ setPosition((prev) => (prev && prev.top === next.top && prev.left === next.left ? prev : next));
+ });
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [anchorRect, isMobile]);
+
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -299,14 +320,14 @@ export function EventDetailPopover({
"fixed z-[60] bg-background border border-border shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out",
isMobile
? "inset-0 rounded-none flex flex-col"
- : "rounded-lg"
+ : "rounded-lg flex flex-col"
)}
style={isMobile ? {
opacity: 1,
transform: "none",
} : {
width: POPOVER_WIDTH,
- maxHeight: MAX_HEIGHT,
+ maxHeight: maxPopoverHeight(),
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: ready ? 1 : 0,
@@ -410,7 +431,7 @@ export function EventDetailPopover({
{/* Content */}
{/* Date & Time */}
@@ -580,6 +601,8 @@ export function EventDetailPopover({
)}
+
+
{/* Quick Note */}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 340ba2e52..6a0b5b4f3 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -39,7 +39,8 @@ import {
calendarHooks,
type CalendarEventFormSavePatch,
} from "@/lib/plugin-hooks";
-import type { ConflictWarning } from "@/lib/plugin-types";
+import type { CalendarEventView, ConflictWarning } from "@/lib/plugin-types";
+import { eventToView } from "@/lib/plugin-projection";
export interface PendingEventPreview {
start: Date;
@@ -418,6 +419,82 @@ export function EventModal({
return () => { cancelled = true; clearTimeout(t); };
}, [title, description, startDate, startTime, endDate, endTime, allDay, location, virtualLocation, calendarId, event?.uid, attendees]);
+ // Plugins hear about an existing event opened for reading once per event,
+ // and about the form each time it opens (new event, or view -> edit).
+ const reportedOpenRef = useRef(null);
+ useEffect(() => {
+ if (!event || mode !== "view" || reportedOpenRef.current === event.id) return;
+ reportedOpenRef.current = event.id;
+ void calendarHooks.onCalendarEventOpen.emit(eventToView(event));
+ }, [event, mode]);
+
+ useEffect(() => {
+ if (mode !== "edit" || calendarFormHooks.onCalendarEventFormOpen.size === 0) return;
+ void calendarFormHooks.onCalendarEventFormOpen.emit({
+ title,
+ description,
+ start: allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime}:00`,
+ end: allDay ? `${endDate}T23:59:59` : `${endDate}T${endTime}:00`,
+ isAllDay: allDay,
+ location,
+ virtualLocation,
+ calendarId,
+ uid: event?.uid,
+ attendees: attendees.map((a) => a.email),
+ });
+ // Only the opening counts; the fields are read as they are at that moment.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [mode]);
+
+ // calendar-event-detail: the event as plugins see it, in the read views.
+ const eventDetailProps = useMemo(() => (event ? { event: eventToView(event) } : null), [event]);
+
+ // calendar-event-form: the form's current state in the same shape. Typing
+ // would hand the plugin iframe new props on every key, so the view settles
+ // for half a second first.
+ const formEventView = useMemo(() => {
+ const startStr = allDay ? `${startDate}T00:00:00` : `${startDate}T${startTime || "00:00"}:00`;
+ const start = new Date(startStr);
+ let duration = event?.duration ?? "PT1H";
+ if (!isNaN(start.getTime())) {
+ if (allDay) {
+ const inclusiveEnd = new Date(`${endDate}T00:00:00`);
+ if (!isNaN(inclusiveEnd.getTime())) duration = buildAllDayDuration(start, inclusiveEnd < start ? start : inclusiveEnd);
+ } else {
+ const end = new Date(`${endDate}T${endTime || "00:00"}:00`);
+ if (!isNaN(end.getTime())) duration = buildDuration(start, end);
+ }
+ }
+ const base = event ? eventToView(event) : null;
+ return {
+ id: event?.id ?? "",
+ uid: event?.uid ?? "",
+ calendarId,
+ title,
+ description,
+ start: startStr,
+ duration,
+ timeZone: getEffectiveTimeZone(),
+ utcStart: null,
+ utcEnd: null,
+ isAllDay: allDay,
+ location,
+ status: base?.status ?? "confirmed",
+ participants: attendees.map((a) => {
+ const known = base?.participants.find((p) => p.email.toLowerCase() === a.email.toLowerCase());
+ return known ?? { name: a.name, email: a.email, role: "attendee", status: "needs-action" };
+ }),
+ isRecurring: recurrence !== "none",
+ keywords: base?.keywords ?? [],
+ };
+ }, [event, calendarId, title, description, startDate, startTime, endDate, endTime, allDay, location, attendees, recurrence]);
+ const [settledFormEvent, setSettledFormEvent] = useState(formEventView);
+ useEffect(() => {
+ const timer = setTimeout(() => setSettledFormEvent(formEventView), 500);
+ return () => clearTimeout(timer);
+ }, [formEventView]);
+ const eventFormProps = useMemo(() => ({ event: settledFormEvent }), [settledFormEvent]);
+
// Report live preview to parent for grid outline
useEffect(() => {
if (!onPreviewChange || isEdit) return;
@@ -795,6 +872,8 @@ export function EventModal({
)}
+
+ {eventDetailProps && }
@@ -972,6 +1051,8 @@ export function EventModal({
)}
+
+ {eventDetailProps && }
@@ -1319,6 +1400,8 @@ export function EventModal({
)}
+
+
diff --git a/components/calendar/mini-calendar.tsx b/components/calendar/mini-calendar.tsx
index 03bc55dc8..cd7a9e8bc 100644
--- a/components/calendar/mini-calendar.tsx
+++ b/components/calendar/mini-calendar.tsx
@@ -12,6 +12,7 @@ import { getEventDayBounds } from "@/lib/calendar-utils";
import { displayNow } from "@/lib/timezone";
import type { CalendarEvent } from "@/lib/jmap/types";
import { useCalendarLocale } from "@/hooks/use-calendar-locale";
+import { PluginDayLabel } from "./plugin-day-label";
type PickerView = "days" | "months" | "years";
@@ -199,6 +200,7 @@ export function MiniCalendar({
{hasEvent && !selected && !today && (
)}
+
);
diff --git a/components/calendar/plugin-day-label.tsx b/components/calendar/plugin-day-label.tsx
new file mode 100644
index 000000000..5fc22e768
--- /dev/null
+++ b/components/calendar/plugin-day-label.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { useSyncExternalStore } from "react";
+import { cn } from "@/lib/utils";
+import { dayLabelsByDate, subscribeDayLabels, type RegisteredDayLabel } from "@/lib/plugin-sandbox/day-labels";
+
+const NONE: RegisteredDayLabel[] = [];
+const EMPTY_MAP = new Map();
+
+/** The labels plugins put on one day (api.ui.setDayLabels). */
+export function useDayLabels(date: string): RegisteredDayLabel[] {
+ const byDate = useSyncExternalStore(subscribeDayLabels, dayLabelsByDate, () => EMPTY_MAP);
+ return byDate.get(date) ?? NONE;
+}
+
+function tooltip(labels: RegisteredDayLabel[]): string {
+ return labels.map((l) => `${l.title ?? l.text ?? ""} (${l.pluginName})`.trim()).join("\n");
+}
+
+interface PluginDayLabelProps {
+ /**
+ * calendar-day-header: the label text under a day's name (day, week and
+ * month views). mini-calendar-marks: a dot on the day in the small month
+ * calendar; the parent must be positioned.
+ */
+ surface: "calendar-day-header" | "mini-calendar-marks";
+ /** YYYY-MM-DD */
+ date: string;
+ className?: string;
+ /**
+ * calendar-day-header only: no room for text (the phone month view), so a
+ * dot stands for the day's labels; their text is its tooltip and is read
+ * out by screen readers.
+ */
+ compact?: boolean;
+}
+
+/** What plugins put on a calendar day. Renders nothing without labels. */
+export function PluginDayLabel({ surface, date, className, compact }: PluginDayLabelProps) {
+ const labels = useDayLabels(date);
+
+ if (surface === "mini-calendar-marks") {
+ const marks = labels.filter((l) => l.dot);
+ if (marks.length === 0) return null;
+ return (
+ <>
+
+ {marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}
+ >
+ );
+ }
+
+ if (compact) {
+ const shown = labels.filter((l) => l.text || l.dot);
+ if (shown.length === 0) return null;
+ return (
+
+
+ {shown.map((l) => l.text ?? l.title ?? l.pluginName).join(", ")}
+
+ );
+ }
+
+ const withText = labels.filter((l) => l.text);
+ if (withText.length === 0) return null;
+ return (
+
+ {withText.map((l) => l.text).join(" · ")}
+
+ );
+}
diff --git a/components/calendar/task-list-view.tsx b/components/calendar/task-list-view.tsx
index 8d896f181..6a2bf81b9 100644
--- a/components/calendar/task-list-view.tsx
+++ b/components/calendar/task-list-view.tsx
@@ -62,6 +62,60 @@ function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType {
+ const calIds = Object.keys(task.calendarIds);
+ return calIds.some(id => selectedCalendarIds.includes(id));
+ });
+
+ if (!showCompleted) {
+ result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
+ }
+
+ switch (filter) {
+ case "pending":
+ result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
+ break;
+ case "completed":
+ result = result.filter(task => task.progress === "completed");
+ break;
+ case "overdue":
+ result = result.filter(task => {
+ if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
+ return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
+ });
+ break;
+ }
+
+ // Sort: overdue first, then by due date (no due date last), then by priority
+ result.sort((a, b) => {
+ // Completed tasks at the bottom
+ if (a.progress === "completed" && b.progress !== "completed") return 1;
+ if (a.progress !== "completed" && b.progress === "completed") return -1;
+
+ // Tasks with due dates before those without
+ if (a.due && !b.due) return -1;
+ if (!a.due && b.due) return 1;
+ if (a.due && b.due) {
+ const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
+ if (dateCompare !== 0) return dateCompare;
+ }
+
+ // Higher priority first (lower number = higher priority, but 0 = no priority goes last)
+ const aPri = a.priority || 10;
+ const bPri = b.priority || 10;
+ return aPri - bPri;
+ });
+
+ return result;
+}
+
export function TaskListView({
tasks,
calendars,
@@ -77,53 +131,10 @@ export function TaskListView({
const timeFormat = useSettingsStore((s) => s.timeFormat);
const [quickAddTitle, setQuickAddTitle] = useState("");
- const filteredTasks = useMemo(() => {
- let result = tasks.filter(task => {
- const calIds = Object.keys(task.calendarIds);
- return calIds.some(id => selectedCalendarIds.includes(id));
- });
-
- if (!showCompleted) {
- result = result.filter(task => task.progress !== "completed" && task.progress !== "cancelled");
- }
-
- switch (filter) {
- case "pending":
- result = result.filter(task => task.progress === "needs-action" || task.progress === "in-process");
- break;
- case "completed":
- result = result.filter(task => task.progress === "completed");
- break;
- case "overdue":
- result = result.filter(task => {
- if (!task.due || task.progress === "completed" || task.progress === "cancelled") return false;
- return isBefore(parseISO(task.due), displayNow()) && !isDisplayToday(parseISO(task.due));
- });
- break;
- }
-
- // Sort: overdue first, then by due date (no due date last), then by priority
- result.sort((a, b) => {
- // Completed tasks at the bottom
- if (a.progress === "completed" && b.progress !== "completed") return 1;
- if (a.progress !== "completed" && b.progress === "completed") return -1;
-
- // Tasks with due dates before those without
- if (a.due && !b.due) return -1;
- if (!a.due && b.due) return 1;
- if (a.due && b.due) {
- const dateCompare = new Date(a.due).getTime() - new Date(b.due).getTime();
- if (dateCompare !== 0) return dateCompare;
- }
-
- // Higher priority first (lower number = higher priority, but 0 = no priority goes last)
- const aPri = a.priority || 10;
- const bPri = b.priority || 10;
- return aPri - bPri;
- });
-
- return result;
- }, [tasks, selectedCalendarIds, filter, showCompleted]);
+ const filteredTasks = useMemo(
+ () => filterTasksForList(tasks, selectedCalendarIds, filter, showCompleted),
+ [tasks, selectedCalendarIds, filter, showCompleted],
+ );
const handleToggle = useCallback((e: React.MouseEvent, task: CalendarTask) => {
e.stopPropagation();
diff --git a/components/calendar/task-modal.tsx b/components/calendar/task-modal.tsx
index 8e648c490..ad2d32052 100644
--- a/components/calendar/task-modal.tsx
+++ b/components/calendar/task-modal.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useCallback, useRef } from "react";
+import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -8,6 +8,8 @@ import { X, Trash2, CalendarDays, Bell, Flag } from "@/components/icons";
import { format, parseISO } from "date-fns";
import type { CalendarTask, Calendar, CalendarEventAlert } from "@/lib/jmap/types";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { taskToView } from "@/lib/plugin-projection";
interface TaskModalProps {
task?: CalendarTask | null;
@@ -48,6 +50,8 @@ export function TaskModal({
const t = useTranslations("calendar");
const isEdit = !!task;
const titleRef = useRef(null);
+ // task-detail-section: the saved task, for an existing task only.
+ const pluginTaskProps = useMemo(() => (task ? { task: taskToView(task) } : null), [task]);
const writableCalendars = calendars.filter(c => !c.isShared || c.myRights?.mayWriteAll || c.myRights?.mayWriteOwn);
const defaultCalendarId = writableCalendars[0]?.id ?? calendars[0]?.id ?? "";
@@ -302,6 +306,8 @@ export function TaskModal({
{t("tasks.alert_1day")}
+
+ {pluginTaskProps && }
{/* Footer */}
diff --git a/components/calendar/task-toolbar.tsx b/components/calendar/task-toolbar.tsx
index 0619d464f..7accbe05d 100644
--- a/components/calendar/task-toolbar.tsx
+++ b/components/calendar/task-toolbar.tsx
@@ -1,9 +1,11 @@
"use client";
+import { useMemo } from "react";
import { useTranslations } from "next-intl";
import { Plus } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
+import { PluginActions } from "@/components/plugins/plugin-actions";
import type { TaskViewFilter } from "@/stores/task-store";
interface TaskToolbarProps {
@@ -12,8 +14,14 @@ interface TaskToolbarProps {
onFilterChange: (filter: TaskViewFilter) => void;
onShowCompletedChange: (show: boolean) => void;
onCreateTask: () => void;
+ /** The tasks the list shows, in order: what plugin buttons here act on. */
+ taskIds?: string[];
+ /** Icon-only plugin buttons (phones). */
+ compact?: boolean;
}
+const NO_IDS: string[] = [];
+
const FILTERS: TaskViewFilter[] = ["all", "pending", "completed", "overdue"];
export function TaskToolbar({
@@ -22,8 +30,11 @@ export function TaskToolbar({
onFilterChange,
onShowCompletedChange,
onCreateTask,
+ taskIds = NO_IDS,
+ compact,
}: TaskToolbarProps) {
const t = useTranslations("calendar");
+ const pluginContext = useMemo(() => ({ taskIds }), [taskIds]);
return (
@@ -60,6 +71,8 @@ export function TaskToolbar({
{t("tasks.create")}
+
+
);
}
diff --git a/components/command-palette/__tests__/command-palette.test.tsx b/components/command-palette/__tests__/command-palette.test.tsx
new file mode 100644
index 000000000..b7869b0bb
--- /dev/null
+++ b/components/command-palette/__tests__/command-palette.test.tsx
@@ -0,0 +1,174 @@
+import React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+
+const nav = vi.hoisted(() => ({ pathname: '/', push: vi.fn() }));
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string, params?: Record) =>
+ params ? `${key}(${Object.values(params).join(',')})` : key,
+ useLocale: () => 'en',
+}));
+vi.mock('@/i18n/navigation', () => ({
+ useRouter: () => ({ push: nav.push, back: vi.fn(), replace: vi.fn() }),
+ usePathname: () => nav.pathname,
+ Link: ({ children }: { children: React.ReactNode }) => children,
+}));
+vi.mock('@/components/keyboard-shortcuts-modal', () => ({
+ KeyboardShortcutsModal: ({ isOpen }: { isOpen: boolean }) => (isOpen ? shortcuts help
: null),
+}));
+
+const { CommandPaletteHost } = await import('../command-palette');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { useThemeStore } = await import('@/stores/theme-store');
+const appCommands = await import('@/lib/app-commands');
+
+const dispatch = vi.fn().mockResolvedValue(undefined);
+const pressCtrlK = (target: Element = document.body) =>
+ act(() => { fireEvent.keyDown(target, { key: 'k', code: 'KeyK', ctrlKey: true }); });
+
+beforeEach(() => {
+ nav.pathname = '/';
+ nav.push.mockClear();
+ resetActions();
+ dispatch.mockClear();
+ setActionDispatcher(dispatch);
+ useAuthStore.setState({ isAuthenticated: true, client: null } as never);
+});
+afterEach(() => {
+ appCommands.parkAppCommand(null as never);
+});
+
+describe('command palette', () => {
+ it('opens on Ctrl+K with the search field focused, and closes on Escape', () => {
+ render( );
+ expect(screen.queryByRole('dialog')).toBeNull();
+
+ pressCtrlK();
+ expect(screen.getByRole('dialog', { name: 'title' })).toBeInTheDocument();
+ const input = screen.getByRole('combobox');
+ expect(input).toHaveFocus();
+ expect(input).toHaveAttribute('aria-controls', screen.getByRole('listbox').id);
+
+ act(() => { fireEvent.keyDown(input, { key: 'Escape' }); });
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('opens from a menu entry (phones)', () => {
+ render( );
+ act(() => appCommands.openCommandPalette());
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('leaves Ctrl+K alone while typing in a field', () => {
+ render(<> >);
+ pressCtrlK(screen.getByLabelText('other field'));
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('leaves Ctrl+K to global search in the Pro shell, and does nothing signed out', () => {
+ nav.pathname = '/pro';
+ const { unmount } = render( );
+ pressCtrlK();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ unmount();
+
+ nav.pathname = '/';
+ useAuthStore.setState({ isAuthenticated: false } as never);
+ render( );
+ pressCtrlK();
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('lists the app commands and no plugin group without plugin entries', () => {
+ render( );
+ pressCtrlK();
+
+ const labels = screen.getAllByRole('option').map((o) => o.textContent);
+ expect(labels).toContain('go_to(mail)');
+ expect(labels).toContain('go_to(settings)');
+ expect(labels).toContain('compose');
+ expect(labels).toContain('shortcuts');
+ expect(screen.queryByText('group_plugins')).toBeNull();
+ });
+
+ it('shows plugin entries with the plugin name and runs them with Enter', async () => {
+ act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
+ render( );
+ pressCtrlK();
+
+ expect(screen.getByText('group_plugins')).toBeInTheDocument();
+ const option = screen.getByRole('option', { name: /Create issue/ });
+ expect(option).toHaveTextContent('Jira');
+
+ const input = screen.getByRole('combobox');
+ act(() => { fireEvent.change(input, { target: { value: 'issue' } }); });
+ expect(screen.getAllByRole('option')).toHaveLength(1);
+ expect(screen.getByRole('option')).toHaveAttribute('aria-selected', 'true');
+ expect(input).toHaveAttribute('aria-activedescendant', screen.getByRole('option').id);
+
+ await act(async () => { fireEvent.keyDown(input, { key: 'Enter' }); });
+ expect(dispatch).toHaveBeenCalledWith('jira', 'command-palette', 'new-issue', {});
+ expect(screen.queryByRole('dialog')).toBeNull();
+ });
+
+ it('finds plugin entries by plugin name too', () => {
+ act(() => setPluginActions('jira', 'Jira', 'command-palette', [{ id: 'new-issue', label: 'Create issue' }]));
+ render( );
+ pressCtrlK();
+ act(() => { fireEvent.change(screen.getByRole('combobox'), { target: { value: 'jir' } }); });
+ expect(screen.getAllByRole('option').map((o) => o.getAttribute('data-command-id'))).toEqual(['plugin:jira:new-issue']);
+ });
+
+ it('moves with the arrow keys and wraps around', () => {
+ render( );
+ pressCtrlK();
+ const input = screen.getByRole('combobox');
+ const options = () => screen.getAllByRole('option');
+
+ expect(options()[0]).toHaveAttribute('aria-selected', 'true');
+ act(() => { fireEvent.keyDown(input, { key: 'ArrowDown' }); });
+ expect(options()[1]).toHaveAttribute('aria-selected', 'true');
+ act(() => { fireEvent.keyDown(input, { key: 'ArrowUp' }); });
+ act(() => { fireEvent.keyDown(input, { key: 'ArrowUp' }); });
+ expect(options()[options().length - 1]).toHaveAttribute('aria-selected', 'true');
+ });
+
+ it('navigates with a go-to command', () => {
+ render( );
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: 'go_to(settings)' })); });
+ expect(nav.push).toHaveBeenCalledWith('/settings');
+ });
+
+ it('hands compose to the mail page, or opens mail first and parks it', () => {
+ const taken = vi.fn((e: Event) => e.preventDefault());
+ window.addEventListener('bulwark:app-command', taken);
+ render( );
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: 'compose' })); });
+ expect(taken).toHaveBeenCalled();
+ expect(nav.push).not.toHaveBeenCalled();
+ window.removeEventListener('bulwark:app-command', taken);
+
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: 'compose' })); });
+ expect(nav.push).toHaveBeenCalledWith('/');
+ expect(appCommands.peekParkedAppCommand()).toBe('compose');
+ });
+
+ it('switches the theme and shows the keyboard shortcuts', () => {
+ useThemeStore.setState({ resolvedTheme: 'light' } as never);
+ const setTheme = vi.fn();
+ useThemeStore.setState({ setTheme } as never);
+ render( );
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: 'theme_dark' })); });
+ expect(setTheme).toHaveBeenCalledWith('dark');
+
+ pressCtrlK();
+ act(() => { fireEvent.click(screen.getByRole('option', { name: 'shortcuts' })); });
+ expect(screen.getByText('shortcuts help')).toBeInTheDocument();
+ });
+});
diff --git a/components/command-palette/command-palette.tsx b/components/command-palette/command-palette.tsx
new file mode 100644
index 000000000..d3c4b8d4d
--- /dev/null
+++ b/components/command-palette/command-palette.tsx
@@ -0,0 +1,288 @@
+"use client";
+
+import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
+import { useTranslations } from "next-intl";
+import {
+ BookUser,
+ Calendar,
+ HardDrive,
+ Keyboard,
+ Mail,
+ Moon,
+ PenLine,
+ Puzzle,
+ Search,
+ Settings,
+ Sun,
+ iconForName,
+} from "@/components/icons";
+import type { AppIcon } from "@/components/icons";
+import { usePathname, useRouter } from "@/i18n/navigation";
+import { useAuthStore } from "@/stores/auth-store";
+import { useCalendarStore } from "@/stores/calendar-store";
+import { usePolicyStore } from "@/stores/policy-store";
+import { useThemeStore } from "@/stores/theme-store";
+import { usePluginActions } from "@/components/plugins/plugin-actions";
+import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
+import { runAction } from "@/lib/plugin-sandbox/actions";
+import { COMMAND_PALETTE_OPEN_EVENT, parkAppCommand, requestAppCommand, type AppCommand } from "@/lib/app-commands";
+import { isEditableEventTarget } from "@/lib/keyboard";
+import { useFocusTrap } from "@/hooks/use-focus-trap";
+import { cn } from "@/lib/utils";
+
+interface PaletteCommand {
+ id: string;
+ label: string;
+ icon: AppIcon;
+ /** Shown after the label: the plugin that added the entry. */
+ source?: string;
+ group: "navigation" | "actions" | "plugins";
+ run: () => void;
+}
+
+/** The action surface plugins declare palette entries on (api.ui.setActions). */
+const surface = "command-palette";
+
+/** Surfaces where the palette does not take Ctrl+K: the Pro shell uses it for global search. */
+const isProShell = (pathname: string) => pathname === "/pro" || pathname.startsWith("/pro/");
+
+/**
+ * The command palette (Ctrl+K / ⌘K): a searchable list of the app's own
+ * commands and of the entries plugins declare for `command-palette`.
+ * Mounted once in the locale layout; it only listens while signed in.
+ */
+export function CommandPaletteHost() {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const pathname = usePathname();
+ const [open, setOpen] = useState(false);
+ const [showShortcuts, setShowShortcuts] = useState(false);
+ const enabled = isAuthenticated && !isProShell(pathname);
+
+ useEffect(() => {
+ if (!enabled) return;
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (!(event.ctrlKey || event.metaKey) || event.altKey || event.shiftKey) return;
+ // event.code: the K key on any keyboard layout, Latin or not.
+ if (event.code !== "KeyK" && event.key.toLowerCase() !== "k") return;
+ if (isEditableEventTarget(event)) return;
+ event.preventDefault();
+ setOpen((v) => !v);
+ };
+ const onOpen = () => setOpen(true);
+ window.addEventListener("keydown", onKeyDown);
+ window.addEventListener(COMMAND_PALETTE_OPEN_EVENT, onOpen);
+ return () => {
+ window.removeEventListener("keydown", onKeyDown);
+ window.removeEventListener(COMMAND_PALETTE_OPEN_EVENT, onOpen);
+ };
+ }, [enabled]);
+
+ useEffect(() => {
+ if (!enabled) setOpen(false);
+ }, [enabled]);
+
+ return (
+ <>
+ {open && setOpen(false)} onShowShortcuts={() => setShowShortcuts(true)} />}
+ setShowShortcuts(false)} />
+ >
+ );
+}
+
+function CommandPalette({ onClose, onShowShortcuts }: { onClose: () => void; onShowShortcuts: () => void }) {
+ const t = useTranslations("command_palette");
+ const tSidebar = useTranslations("sidebar");
+ const router = useRouter();
+ const pathname = usePathname();
+ const listId = useId();
+ const optionId = (index: number) => `${listId}-option-${index}`;
+ const [query, setQuery] = useState("");
+ const [active, setActive] = useState(0);
+ const listRef = useRef(null);
+
+ const client = useAuthStore((s) => s.client);
+ const supportsCalendar = useCalendarStore((s) => s.supportsCalendar);
+ const isFeatureEnabled = usePolicyStore((s) => s.isFeatureEnabled);
+ const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
+ const pluginActions = usePluginActions(surface);
+
+ const dialogRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
+
+ const commands = useMemo(() => {
+ const go = (href: string) => () => router.push(href);
+ // Compose and search belong to the mail page: run there, or open mail first.
+ const onMail = (command: AppCommand) => () => {
+ if (requestAppCommand(command)) return;
+ parkAppCommand(command);
+ router.push("/");
+ };
+ const list: PaletteCommand[] = [
+ { id: "go-mail", group: "navigation", icon: Mail, label: t("go_to", { name: tSidebar("mail") }), run: go("/") },
+ ];
+ if (supportsCalendar && isFeatureEnabled("calendarEnabled")) {
+ list.push({ id: "go-calendar", group: "navigation", icon: Calendar, label: t("go_to", { name: tSidebar("calendar") }), run: go("/calendar") });
+ }
+ if ((client?.supportsContacts?.() ?? false) && isFeatureEnabled("contactsEnabled")) {
+ list.push({ id: "go-contacts", group: "navigation", icon: BookUser, label: t("go_to", { name: tSidebar("contacts") }), run: go("/contacts") });
+ }
+ if ((client?.supportsFiles?.() ?? false) && isFeatureEnabled("filesEnabled")) {
+ list.push({ id: "go-files", group: "navigation", icon: HardDrive, label: t("go_to", { name: tSidebar("files") }), run: go("/files") });
+ }
+ list.push(
+ { id: "go-settings", group: "navigation", icon: Settings, label: t("go_to", { name: tSidebar("settings") }), run: go("/settings") },
+ { id: "compose", group: "actions", icon: PenLine, label: t("compose"), run: onMail("compose") },
+ { id: "search", group: "actions", icon: Search, label: t("search"), run: onMail("search") },
+ {
+ id: "theme",
+ group: "actions",
+ icon: resolvedTheme === "dark" ? Sun : Moon,
+ label: resolvedTheme === "dark" ? t("theme_light") : t("theme_dark"),
+ run: () => useThemeStore.getState().setTheme(resolvedTheme === "dark" ? "light" : "dark"),
+ },
+ { id: "shortcuts", group: "actions", icon: Keyboard, label: t("shortcuts"), run: onShowShortcuts },
+ );
+ for (const action of pluginActions) {
+ list.push({
+ id: `plugin:${action.pluginId}:${action.id}`,
+ group: "plugins",
+ icon: iconForName(action.icon) ?? Puzzle,
+ label: action.label,
+ source: action.pluginName,
+ run: () => { void runAction(action, surface, {}); },
+ });
+ }
+ return list;
+ }, [client, supportsCalendar, isFeatureEnabled, resolvedTheme, pluginActions, router, t, tSidebar, onShowShortcuts]);
+
+ const needle = query.trim().toLowerCase();
+ const visible = needle
+ ? commands.filter((c) => c.label.toLowerCase().includes(needle) || (c.source?.toLowerCase().includes(needle) ?? false))
+ : commands;
+ const activeIndex = visible.length === 0 ? -1 : Math.min(active, visible.length - 1);
+
+ // Close when the page changes underneath (a "go to" command ran).
+ const startPath = useRef(pathname);
+ useEffect(() => {
+ if (pathname !== startPath.current) onClose();
+ }, [pathname, onClose]);
+
+ useEffect(() => {
+ if (activeIndex < 0) return;
+ const node = listRef.current?.querySelector(`[data-index="${activeIndex}"]`);
+ node?.scrollIntoView?.({ block: "nearest" });
+ }, [activeIndex]);
+
+ const runCommand = useCallback((command: PaletteCommand) => {
+ onClose();
+ command.run();
+ }, [onClose]);
+
+ const onKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ if (visible.length) setActive((activeIndex + 1) % visible.length);
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ if (visible.length) setActive((activeIndex - 1 + visible.length) % visible.length);
+ } else if (event.key === "Home") {
+ event.preventDefault();
+ setActive(0);
+ } else if (event.key === "End") {
+ event.preventDefault();
+ setActive(Math.max(0, visible.length - 1));
+ } else if (event.key === "Enter") {
+ event.preventDefault();
+ if (activeIndex >= 0) runCommand(visible[activeIndex]);
+ } else if ((event.ctrlKey || event.metaKey) && (event.code === "KeyK" || event.key.toLowerCase() === "k")) {
+ event.preventDefault();
+ onClose();
+ }
+ };
+
+ const groupLabel = (group: PaletteCommand["group"]) =>
+ group === "navigation" ? t("group_navigation") : group === "actions" ? t("group_actions") : t("group_plugins");
+
+ return (
+ { if (e.target === e.currentTarget) onClose(); }}
+ >
+
+
+
+ = 0 ? optionId(activeIndex) : undefined}
+ aria-label={t("title")}
+ value={query}
+ onChange={(e) => { setQuery(e.target.value); setActive(0); }}
+ onKeyDown={onKeyDown}
+ placeholder={t("placeholder")}
+ autoComplete="off"
+ spellCheck={false}
+ className="h-12 flex-1 min-w-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
+ />
+ Esc
+
+
+ {visible.length === 0 && (
+ {t("no_results")}
+ )}
+ {visible.map((command, index) => {
+ const Icon = command.icon;
+ const showGroup = index === 0 || visible[index - 1].group !== command.group;
+ return (
+
+ {showGroup && (
+
+ {groupLabel(command.group)}
+
+ )}
+ { if (index !== activeIndex) setActive(index); }}
+ onMouseDown={(e) => e.preventDefault()}
+ onClick={() => runCommand(command)}
+ className={cn(
+ "mx-1 flex cursor-pointer items-center gap-2.5 rounded-md px-2.5 py-2 text-sm max-sm:min-h-[44px]",
+ index === activeIndex ? "bg-muted text-foreground" : "text-foreground/90",
+ )}
+ >
+
+ {command.label}
+ {command.source && (
+ {command.source}
+ )}
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/components/contacts/__tests__/contact-detail.test.tsx b/components/contacts/__tests__/contact-detail.test.tsx
index 27f5ce962..a908f69f9 100644
--- a/components/contacts/__tests__/contact-detail.test.tsx
+++ b/components/contacts/__tests__/contact-detail.test.tsx
@@ -2,6 +2,7 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ContactDetail } from '../contact-detail';
import type { ContactCard } from '@/lib/jmap/types';
+import { contactHooks, clearAllHooks } from '@/lib/plugin-hooks';
beforeEach(() => {
Object.assign(navigator, {
@@ -65,3 +66,23 @@ describe('ContactDetail', () => {
expect(onDelete).toHaveBeenCalledOnce();
});
});
+
+describe('ContactDetail plugin hooks', () => {
+ beforeEach(() => clearAllHooks());
+
+ it('tells plugins once when a contact is opened, again for another one', async () => {
+ const opened = vi.fn();
+ contactHooks.onContactOpen.register('test', opened);
+
+ const { rerender } = render( );
+ // Same contact with changed data: still the same open contact.
+ rerender( );
+ rerender( );
+ rerender( );
+ await Promise.resolve();
+
+ expect(opened).toHaveBeenCalledTimes(2);
+ expect(opened.mock.calls[0][0]).toMatchObject({ id: '1', fullName: 'Alice Smith', emails: ['alice@example.com'] });
+ expect(opened.mock.calls[1][0]).toMatchObject({ id: '2' });
+ });
+});
diff --git a/components/contacts/__tests__/contacts-plugin-surfaces.test.tsx b/components/contacts/__tests__/contacts-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..2f48b430f
--- /dev/null
+++ b/components/contacts/__tests__/contacts-plugin-surfaces.test.tsx
@@ -0,0 +1,108 @@
+import { render, screen, fireEvent, act, cleanup } from '@testing-library/react';
+import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
+import { ContactDetail } from '../contact-detail';
+import { ContactList } from '../contact-list';
+import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
+import type { ContactCard } from '@/lib/jmap/types';
+
+// Plugin surfaces in contacts: a section in a contact's details and buttons
+// in the list's selection toolbar.
+
+vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+
+const alice: ContactCard = {
+ id: '1',
+ uid: 'uid-alice',
+ addressBookIds: { ab1: true },
+ name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
+ emails: { e0: { address: 'alice@example.com' } },
+ notes: { n0: { note: 'VIP customer' } },
+};
+const bob: ContactCard = {
+ id: '2',
+ addressBookIds: { ab1: true },
+ name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
+};
+
+const dispatch = vi.fn().mockResolvedValue(undefined);
+
+beforeEach(() => {
+ dispatch.mockClear();
+ setActionDispatcher(dispatch);
+});
+
+afterEach(() => {
+ cleanup();
+ clearSlotOffers();
+ resetActions();
+ setActionDispatcher(null);
+});
+
+describe('contact-detail-section', () => {
+ it('shows the contact to the plugin in its details', () => {
+ offerSlots('p1', ['contact-detail-section']);
+ render( );
+
+ const frame = screen.getByTestId('plugin-frame-contact-detail-section');
+ expect(framePropsOf(frame).contact).toMatchObject({
+ id: '1', uid: 'uid-alice', addressBookId: 'ab1', firstName: 'Alice', lastName: 'Smith', emails: ['alice@example.com'],
+ });
+ // Below the contact's own fields.
+ expect(screen.getByText('VIP customer').compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it('renders nothing without a plugin or without a contact', () => {
+ const { unmount } = render( );
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ unmount();
+
+ offerSlots('p1', ['contact-detail-section']);
+ render( );
+ expect(document.querySelector('[data-plugin-slot]')).toBeNull();
+ });
+});
+
+describe('contact-list-actions', () => {
+ const props = {
+ contacts: [alice, bob],
+ selectedContactId: null,
+ searchQuery: '',
+ onSearchChange: vi.fn(),
+ onSelectContact: vi.fn(),
+ onCreateNew: vi.fn(),
+ onToggleSelection: vi.fn(),
+ onSelectRangeContacts: vi.fn(),
+ onSelectAll: vi.fn(),
+ onClearSelection: vi.fn(),
+ onBulkDelete: vi.fn(),
+ onBulkAddToGroup: vi.fn(),
+ onBulkExport: vi.fn(),
+ onEditContact: vi.fn(),
+ onDeleteContact: vi.fn(),
+ onAddContactToGroup: vi.fn(),
+ };
+
+ it('puts plugin buttons in the selection toolbar and sends the selected ids', async () => {
+ act(() => setPluginActions('crm', 'CRM', 'contact-list-actions', [{ id: 'sync', label: 'Sync to CRM' }]));
+ render( );
+
+ const button = screen.getByRole('button', { name: 'Sync to CRM' });
+ // Next to the app's own bulk buttons.
+ expect(button.closest('div.flex-wrap')?.parentElement).toContainElement(screen.getByText('bulk.delete'));
+
+ await act(async () => { fireEvent.click(button); });
+ expect(dispatch).toHaveBeenCalledWith('crm', 'contact-list-actions', 'sync', { contactIds: ['1', '2'] });
+ });
+
+ it('shows no plugin buttons without a selection or without a plugin', () => {
+ act(() => setPluginActions('crm', 'CRM', 'contact-list-actions', [{ id: 'sync', label: 'Sync to CRM' }]));
+ const { unmount } = render( );
+ expect(screen.queryByRole('button', { name: 'Sync to CRM' })).toBeNull();
+ unmount();
+
+ resetActions();
+ render( );
+ expect(document.querySelector('[data-plugin-actions]')).toBeNull();
+ });
+});
diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx
index babdfc65b..14c66479c 100644
--- a/components/contacts/contact-detail.tsx
+++ b/components/contacts/contact-detail.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useRef } from "react";
+import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "@/components/icons";
import { Avatar } from "@/components/ui/avatar";
@@ -16,6 +16,8 @@ import { exportContact } from "./contact-export";
import { printContact } from "./contact-print";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { contactHooks } from "@/lib/plugin-hooks";
+import { contactToView } from "@/lib/plugin-projection";
type MoreItem =
| {
@@ -123,6 +125,17 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
+ const pluginContactProps = useMemo(() => (contact ? { contact: contactToView(contact) } : null), [contact]);
+
+ // Tell plugins once per contact shown here, not on every edit of that card.
+ const latestContact = useRef(contact);
+ useEffect(() => { latestContact.current = contact; });
+ const openedId = contact?.id;
+ useEffect(() => {
+ const opened = latestContact.current;
+ if (!openedId || !opened) return;
+ void contactHooks.onContactOpen.emit(contactToView(opened));
+ }, [openedId]);
if (!contact) {
return (
@@ -497,6 +510,10 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
)}
+ {pluginContactProps && (
+
+ )}
+
{(contact.created || contact.updated) && (
diff --git a/components/contacts/contact-list.tsx b/components/contacts/contact-list.tsx
index 2875279b1..8c0bb12bc 100644
--- a/components/contacts/contact-list.tsx
+++ b/components/contacts/contact-list.tsx
@@ -7,6 +7,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { ContactListItem } from "./contact-list-item";
import { ContactContextMenu } from "./contact-context-menu";
+import { PluginActions } from "@/components/plugins/plugin-actions";
import { useContextMenu } from "@/hooks/use-context-menu";
import { cn } from "@/lib/utils";
import type { AnniversaryDate, ContactCard } from "@/lib/jmap/types";
@@ -268,6 +269,7 @@ export function ContactList({
}, [sorted, locale, sortByLastName]);
const hasSelection = selectedContactIds.size > 0;
+ const pluginContext = useMemo(() => ({ contactIds: [...selectedContactIds] }), [selectedContactIds]);
const allSelected = sorted.length > 0 && sorted.every(c => selectedContactIds.has(c.id));
return (
@@ -521,6 +523,7 @@ export function ContactList({
{t("bulk.delete")}
+
diff --git a/components/contacts/contacts-app.tsx b/components/contacts/contacts-app.tsx
index b72efc7c1..07899ff28 100644
--- a/components/contacts/contacts-app.tsx
+++ b/components/contacts/contacts-app.tsx
@@ -347,7 +347,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
if (supportsSync && client) {
await updateContact(client, contactId, { keywords: updatedKeywords });
} else {
- updateLocalContact(contactId, { keywords: updatedKeywords });
+ await updateLocalContact(contactId, { keywords: updatedKeywords });
}
}
const msg = contactIds.length === 1
@@ -427,11 +427,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
if (!confirmed) return;
try {
- if (supportsSync && client) {
- await deleteContact(client, contactId);
- } else {
- deleteLocalContact(contactId);
- }
+ const deleted = supportsSync && client
+ ? await deleteContact(client, contactId)
+ : await deleteLocalContact(contactId);
+ // A plugin stopped it and has told the user why.
+ if (!deleted) return;
toast.success(t("toast.deleted"));
if (selectedContactId === contactId) setView("list");
} catch (error) {
@@ -468,33 +468,27 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const { id: _id, uid: _uid, created: _created, updated: _updated, ...rest } = source;
void _id; void _uid; void _created; void _updated;
const data: Partial = JSON.parse(JSON.stringify(rest));
- if (supportsSync && client) {
- await createContact(client, data);
- toast.success(t("toast.created"));
- } else {
- const localContact: ContactCard = {
+ const created = supportsSync && client
+ ? await createContact(client, data)
+ : await addLocalContact({
id: `local-${generateUUID()}`,
addressBookIds: data.addressBookIds || {},
...data,
- };
- addLocalContact(localContact);
- toast.success(t("toast.created"));
- }
+ });
+ if (created) toast.success(t("toast.created"));
}, [supportsSync, client, createContact, addLocalContact, t]);
const handleSaveNew = useCallback(async (data: Partial) => {
- if (supportsSync && client) {
- await createContact(client, data);
- toast.success(t("toast.created"));
- } else {
- const localContact: ContactCard = {
+ const created = supportsSync && client
+ ? await createContact(client, data)
+ : await addLocalContact({
id: `local-${generateUUID()}`,
addressBookIds: {},
...data,
- };
- addLocalContact(localContact);
- toast.success(t("toast.created"));
- }
+ });
+ // A plugin stopped it: keep the form open with what was typed.
+ if (!created) return;
+ toast.success(t("toast.created"));
setDefaultBookIdForCreate(undefined);
setCreatePrefill(undefined);
if (returnToEmail) {
@@ -508,13 +502,11 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const handleSaveEdit = useCallback(async (data: Partial) => {
if (!selectedContact) return;
- if (supportsSync && client) {
- await updateContact(client, selectedContact.id, data);
- toast.success(t("toast.updated"));
- } else {
- updateLocalContact(selectedContact.id, data);
- toast.success(t("toast.updated"));
- }
+ const updated = supportsSync && client
+ ? await updateContact(client, selectedContact.id, data)
+ : await updateLocalContact(selectedContact.id, data);
+ if (!updated) return;
+ toast.success(t("toast.updated"));
setView("detail");
}, [supportsSync, client, selectedContact, updateContact, updateLocalContact, t]);
diff --git a/components/email/__tests__/composer-account-switch.test.tsx b/components/email/__tests__/composer-account-switch.test.tsx
index 5da9297d0..a5dfc270e 100644
--- a/components/email/__tests__/composer-account-switch.test.tsx
+++ b/components/email/__tests__/composer-account-switch.test.tsx
@@ -140,6 +140,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -147,7 +155,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-attach-from-files.test.tsx b/components/email/__tests__/composer-attach-from-files.test.tsx
index 938a612f9..475634773 100644
--- a/components/email/__tests__/composer-attach-from-files.test.tsx
+++ b/components/email/__tests__/composer-attach-from-files.test.tsx
@@ -151,6 +151,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -158,7 +166,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onBeforeAttachmentUpload: { intercept: hoisted.beforeAttachmentUpload },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
diff --git a/components/email/__tests__/composer-attachment-list-scroll.test.tsx b/components/email/__tests__/composer-attachment-list-scroll.test.tsx
index 9efbceeb6..af1d5673f 100644
--- a/components/email/__tests__/composer-attachment-list-scroll.test.tsx
+++ b/components/email/__tests__/composer-attachment-list-scroll.test.tsx
@@ -133,13 +133,21 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { intercept: async () => true },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-close-guard.test.tsx b/components/email/__tests__/composer-close-guard.test.tsx
index 85cec8f86..e934e97ee 100644
--- a/components/email/__tests__/composer-close-guard.test.tsx
+++ b/components/email/__tests__/composer-close-guard.test.tsx
@@ -134,6 +134,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -141,7 +149,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-discard.test.tsx b/components/email/__tests__/composer-discard.test.tsx
index f391bd06c..ae7e06c9d 100644
--- a/components/email/__tests__/composer-discard.test.tsx
+++ b/components/email/__tests__/composer-discard.test.tsx
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-draft-attachments.test.tsx b/components/email/__tests__/composer-draft-attachments.test.tsx
index 259e74132..460acd1de 100644
--- a/components/email/__tests__/composer-draft-attachments.test.tsx
+++ b/components/email/__tests__/composer-draft-attachments.test.tsx
@@ -132,6 +132,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -139,7 +147,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-format-toggle.test.tsx b/components/email/__tests__/composer-format-toggle.test.tsx
index 8b64cbf66..28f3f029c 100644
--- a/components/email/__tests__/composer-format-toggle.test.tsx
+++ b/components/email/__tests__/composer-format-toggle.test.tsx
@@ -137,6 +137,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -144,7 +152,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-forward-other-account.test.tsx b/components/email/__tests__/composer-forward-other-account.test.tsx
index 4c310c81a..087f5cf34 100644
--- a/components/email/__tests__/composer-forward-other-account.test.tsx
+++ b/components/email/__tests__/composer-forward-other-account.test.tsx
@@ -143,6 +143,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -150,7 +158,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-from-override-envelope.test.tsx b/components/email/__tests__/composer-from-override-envelope.test.tsx
index 93b1a94a7..778a2c1c3 100644
--- a/components/email/__tests__/composer-from-override-envelope.test.tsx
+++ b/components/email/__tests__/composer-from-override-envelope.test.tsx
@@ -136,6 +136,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -143,7 +151,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-identity-template-hooks.test.tsx b/components/email/__tests__/composer-identity-template-hooks.test.tsx
new file mode 100644
index 000000000..7ca0ae9d8
--- /dev/null
+++ b/components/email/__tests__/composer-identity-template-hooks.test.tsx
@@ -0,0 +1,224 @@
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { EmailComposer } from '../email-composer';
+import { useIdentityStore } from '@/stores/identity-store';
+import { clearAllHooks, identityHooks, templateHooks } from '@/lib/plugin-hooks';
+
+const TEMPLATE = vi.hoisted(() => ({
+ id: 'tpl-1',
+ name: 'Greeting',
+ category: '',
+ subject: 'Hello',
+ body: 'Dear reader',
+ isHTML: false,
+ isFavorite: false,
+ createdAt: '2026-01-01T00:00:00.000Z',
+ updatedAt: '2026-01-01T00:00:00.000Z',
+}));
+
+// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
+
+const editorKeyDown = vi.hoisted(() => vi.fn());
+const toastError = vi.hoisted(() => vi.fn());
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', {
+ 'data-testid': 'rich-text-editor',
+ onKeyDown: (event: React.KeyboardEvent) => {
+ if (!event.defaultPrevented) editorKeyDown();
+ },
+ }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+// The picker hands the composer a template, as choosing one does.
+vi.mock('@/components/templates/template-picker', () => ({
+ TemplatePicker: ({ onSelect }: { onSelect: (t: unknown, v: Record) => void }) =>
+ React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
+}));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+const updateSetting = vi.fn();
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ signaturePosition: 'above_quote',
+ signatureSeparatorEnabled: false,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: (...args: unknown[]) => updateSetting(...args),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook, registerTemplateSyncBridge: () => {} };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: toastError, success: () => {} },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ sanitizeSignatureHtmlForDisplay: (v: string) => v,
+ sanitizePluginBodyHtml: (v: string) => v,
+ escapeHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/signature-utils', () => ({
+ appendPlainTextSignature: (body: string) => body,
+ getPlainTextSignature: () => '',
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: () => '',
+ serializeEditorContent: () => '',
+}));
+vi.mock('@/lib/template-utils', async (importOriginal) => ({
+ ...(await importOriginal()),
+ substitutePlaceholders: (s: string) => s,
+}));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+const DATA = {
+ to: '',
+ cc: '',
+ bcc: '',
+ subject: '',
+ body: '',
+ showCc: false,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ draftId: null,
+};
+
+const ME = { id: 'id-me', email: 'me@example.com', name: 'Me', mayDelete: false };
+const WORK = { id: 'id-work', email: 'work@example.com', name: 'Work', mayDelete: true, htmlSignature: 'W ' };
+
+describe('composer plugin hooks for identities and templates', () => {
+ beforeEach(() => {
+ useIdentityStore.setState({ identities: [ME, WORK] });
+ });
+ afterEach(() => clearAllHooks());
+
+ it('tells plugins which From identity the user picked', () => {
+ const selected = vi.fn();
+ identityHooks.onIdentitySelect.register('test', selected);
+ render( );
+
+ fireEvent.change(screen.getByTestId('composer-from'), { target: { value: 'id-work' } });
+
+ expect(selected).toHaveBeenCalledTimes(1);
+ expect(selected).toHaveBeenCalledWith(expect.objectContaining({ id: 'id-work', email: 'work@example.com', htmlSignature: 'W ' }));
+ });
+
+ it('inserts the template as a plugin rendered it and reports it applied', async () => {
+ const render_ = vi.fn((value: { subject: string; html: string; text: string }, ctx: { templateId: string }) => {
+ expect(ctx).toEqual({ templateId: 'tpl-1' });
+ return { ...value, subject: `[Plugin] ${value.subject}` };
+ });
+ const applied = vi.fn();
+ templateHooks.onTemplateRender.register('test', render_);
+ templateHooks.onTemplateApply.register('test', applied);
+ render( );
+
+ act(() => { fireEvent.keyDown(window, { key: 't' }); });
+ fireEvent.click(await screen.findByTestId('pick-template'));
+
+ await waitFor(() => expect(applied).toHaveBeenCalledTimes(1));
+ expect(render_).toHaveBeenCalledWith(
+ { subject: 'Hello', html: 'Dear reader
', text: 'Dear reader' },
+ { templateId: 'tpl-1' },
+ );
+ expect(applied).toHaveBeenCalledWith(expect.objectContaining({ id: 'tpl-1', name: 'Greeting' }));
+ expect(screen.getByDisplayValue('[Plugin] Hello')).toBeInTheDocument();
+ });
+});
diff --git a/components/email/__tests__/composer-mention.test.tsx b/components/email/__tests__/composer-mention.test.tsx
index 28cceeb20..c79d871a0 100644
--- a/components/email/__tests__/composer-mention.test.tsx
+++ b/components/email/__tests__/composer-mention.test.tsx
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -145,7 +153,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-mobile-formatting.test.tsx b/components/email/__tests__/composer-mobile-formatting.test.tsx
index ca1ffeb69..241a18bfb 100644
--- a/components/email/__tests__/composer-mobile-formatting.test.tsx
+++ b/components/email/__tests__/composer-mobile-formatting.test.tsx
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -145,7 +153,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-mv-window.test.tsx b/components/email/__tests__/composer-mv-window.test.tsx
index 5b0407095..739d76da7 100644
--- a/components/email/__tests__/composer-mv-window.test.tsx
+++ b/components/email/__tests__/composer-mv-window.test.tsx
@@ -139,6 +139,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -146,7 +154,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-plugin-attachment-source.test.tsx b/components/email/__tests__/composer-plugin-attachment-source.test.tsx
index f07a6f80a..7128bf0b9 100644
--- a/components/email/__tests__/composer-plugin-attachment-source.test.tsx
+++ b/components/email/__tests__/composer-plugin-attachment-source.test.tsx
@@ -15,6 +15,14 @@ vi.mock('@/components/email/rich-text-editor', () => ({
// callback the host passed through extraProps - exactly what a real plugin
// does after it has uploaded a picked file to JMAP itself (api.jmap.uploadBlob).
// Every other slot renders nothing, matching the rest of the test suite.
+// The composer only renders its plugin row when a plugin offers a slot.
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({
+ usePluginSlotOffers: (slot: string) => (slot === 'composer-attachment-source' ? [{ pluginId: 'p', order: 100 }] : []),
+}));
+const attachState = vi.hoisted(() => ({
+ next: { blobId: 'cloud-blob-1', name: 'vacation.jpg', type: 'image/jpeg', size: 42_000 } as Record,
+ allow: true,
+}));
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: ({ name, extraProps }: { name: string; extraProps?: Record }) => {
if (name !== 'composer-attachment-source') return null;
@@ -26,8 +34,7 @@ vi.mock('@/components/plugins/plugin-slot', () => ({
{
type: 'button',
'data-testid': 'fake-cloud-storage-plugin',
- onClick: () =>
- onAttach?.({ blobId: 'cloud-blob-1', name: 'vacation.jpg', type: 'image/jpeg', size: 42_000 }),
+ onClick: () => onAttach?.(attachState.next as never),
},
'Attach from cloud storage',
);
@@ -155,6 +162,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -162,9 +177,10 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ onBeforeAttachmentUpload: { intercept: async () => attachState.allow },
},
contactHooks: {
search: { call: async () => [] },
@@ -208,6 +224,29 @@ describe('composer-attachment-source plugin slot', () => {
afterEach(() => {
useAuthStore.setState({ client: null });
vi.clearAllMocks();
+ attachState.next = { blobId: 'cloud-blob-1', name: 'vacation.jpg', type: 'image/jpeg', size: 42_000 };
+ attachState.allow = true;
+ });
+
+ it('ignores something from a plugin that is not an attachment', async () => {
+ attachState.next = { blobId: 'not a blob id', name: 'evil.jpg', type: 'image/jpeg', size: 1 };
+ render( );
+
+ fireEvent.click(screen.getByTestId('fake-cloud-storage-plugin'));
+
+ await new Promise((r) => setTimeout(r, 50));
+ expect(screen.queryByText('evil.jpg')).not.toBeInTheDocument();
+ });
+
+ it('applies the same attachment checks as a local file', async () => {
+ // A plugin's onBeforeAttachmentUpload guard used to be skipped here.
+ attachState.allow = false;
+ render( );
+
+ fireEvent.click(screen.getByTestId('fake-cloud-storage-plugin'));
+
+ await new Promise((r) => setTimeout(r, 50));
+ expect(screen.queryByText('vacation.jpg')).not.toBeInTheDocument();
});
it('adds a plugin-supplied blob as an attachment chip when onAttach is called', async () => {
@@ -231,7 +270,7 @@ describe('composer-attachment-source plugin slot', () => {
render( );
fireEvent.click(screen.getByTestId('fake-cloud-storage-plugin'));
- expect(screen.getByText('vacation.jpg')).toBeInTheDocument();
+ expect(await screen.findByText('vacation.jpg')).toBeInTheDocument();
// Fake timers only from here on - the autosave debounce below needs
// vi.advanceTimersByTimeAsync, but findBy*'s own internal polling (not
diff --git a/components/email/__tests__/composer-plugin-edit.test.tsx b/components/email/__tests__/composer-plugin-edit.test.tsx
new file mode 100644
index 000000000..1d4c5dfa6
--- /dev/null
+++ b/components/email/__tests__/composer-plugin-edit.test.tsx
@@ -0,0 +1,366 @@
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import type { Editor } from '@tiptap/core';
+import { undoDepth } from '@tiptap/pm/history';
+import { EmailComposer } from '../email-composer';
+import { buildSignatureBlock } from '../signature-block';
+import { buildQuotedHtmlBlock, serializeEditorContent } from '../quoted-html';
+import { composerHandle } from '@/lib/plugin-sandbox/composer-registry';
+import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+
+// The real composer around the real editor, worked on by a plugin through
+// the handle it registers for api.composer: what the plugin reads as the
+// message, and that each change is one step in the editor's history that
+// leaves the signature, the quoted reply and inline images alone.
+
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+
+// ─── Composer dependency mocks (as in composer-close-guard.test.tsx) ──────────
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+const updateSetting = vi.fn();
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ recipientMentionsEnabled: true,
+ sendDelaySeconds: 0,
+ signaturePosition: 'above_quote',
+ signatureSeparatorEnabled: false,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: (...args: unknown[]) => updateSetting(...args),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onBeforeAttachmentUpload: { intercept: async () => true },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', async (orig) => ({
+ ...(await orig()),
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizePluginBodyHtml: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/signature-utils', () => ({
+ appendPlainTextSignature: (body: string) => body,
+ getPlainTextSignature: () => '',
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+const SIGNATURE = buildSignatureBlock('Alice Example ACME Ltd.
');
+const QUOTE = buildQuotedHtmlBlock('Can you send the photo?
');
+const BODY =
+ 'Hello Bob,
here it is
'
+ + SIGNATURE
+ + 'On Monday, Bob wrote:
'
+ + QUOTE;
+
+const DRAFT = {
+ to: 'Bob Builder ',
+ cc: '',
+ bcc: '',
+ subject: 'Re: Photo',
+ body: BODY,
+ showCc: false,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose' as const,
+ draftId: null,
+};
+
+const PLUGIN = 'assistant';
+
+/** The real composer; its id comes from a click on a plugin's toolbar button, as for a plugin. */
+async function renderComposer(body = BODY) {
+ const clicks: Record[] = [];
+ setActionDispatcher(async (_plugin, _surface, _id, context) => { clicks.push(context); });
+ setPluginActions(PLUGIN, 'Assistant', 'composer-toolbar-actions', [{ id: 'ask', label: 'Assistant', icon: 'sparkles', shortcut: 'Ctrl+Shift+Y' }]);
+ render( );
+ const dom = await waitFor(() => {
+ const el = document.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null;
+ expect(el?.editor).toBeTruthy();
+ return el!;
+ });
+ const editor = dom.editor!;
+ await waitFor(() => expect(editor.getHTML()).toContain(body.slice(3, 12)));
+ fireEvent.click(screen.getByRole('button', { name: 'Assistant' }));
+ await waitFor(() => expect(clicks).toHaveLength(1));
+ const id = clicks[0].composerId as string;
+ expect(typeof id).toBe('string');
+ return { editor, id, handle: composerHandle(id)!, clicks };
+}
+
+const html = (editor: Editor) => serializeEditorContent(editor);
+
+describe('plugins editing the message being written', () => {
+ const range = Range.prototype as unknown as Record;
+ const doc = document as unknown as Record;
+ beforeAll(() => {
+ range.getClientRects = () => [];
+ range.getBoundingClientRect = () => ({ x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 });
+ doc.elementFromPoint = () => null;
+ });
+ afterAll(() => {
+ delete range.getClientRects;
+ delete range.getBoundingClientRect;
+ delete doc.elementFromPoint;
+ });
+ afterEach(() => {
+ resetActions();
+ setActionDispatcher(null);
+ });
+
+ it('reads only the user\'s own text: no signature, no quote header, no quote', async () => {
+ const { handle } = await renderComposer();
+ const body = handle.readBody();
+ expect(body.text).toBe('Hello Bob,\nhere it is');
+ expect(body.html).toContain('cid:photo1');
+ expect(body.html).not.toContain('ACME');
+ expect(body.html).not.toContain('Bob wrote');
+ expect(body.html).not.toContain('Can you send');
+ });
+
+ it('replaces the own text in place, at once, and leaves signature, quote and inline image', async () => {
+ const { editor, handle } = await renderComposer();
+ const before = html(editor);
+ let ok = false;
+ act(() => { ok = handle.replaceBody('Hi Bob, attached.
', 'Hi Bob, attached.'); });
+ expect(ok).toBe(true);
+ // Applied to the editor at once, so a plugin reads its own change back.
+ expect(handle.readBody().text).toBe('Hi Bob, attached.');
+
+ const after = html(editor);
+ expect(after).not.toContain('Hello Bob');
+ expect(after.indexOf('Hi Bob, attached.')).toBeLessThan(after.indexOf('data-signature-block-node'));
+ expect(after).toContain('ACME Ltd.');
+ expect(after).toContain('On Monday, Bob wrote:');
+ expect(after).toContain('Can you send the photo?');
+ expect(after).toContain('data-cid="photo1"');
+ // The blank line between the text and the signature stays.
+ expect(after.indexOf('data-cid="photo1"')).toBeLessThan(after.indexOf('data-signature-block-node'));
+ expect(before).not.toBe(after);
+ });
+
+ it("leaves a reply's quote header (On ... wrote:) with the quote when there is no signature above it", async () => {
+ const { editor, handle } = await renderComposer('Thanks, that works.
On Monday, Bob wrote:
' + QUOTE);
+ expect(handle.readBody().text).toBe('Thanks, that works.');
+ act(() => { handle.replaceBody('Thank you, that works for me.
', 'Thank you, that works for me.'); });
+ const after = html(editor);
+ expect(after).toContain('Thank you, that works for me.');
+ expect(after).toContain('On Monday, Bob wrote:');
+ expect(after.indexOf('Thank you')).toBeLessThan(after.indexOf('On Monday'));
+ });
+
+ it('is one step in the editor history: one Ctrl+Z brings the old text back', async () => {
+ const { editor, handle } = await renderComposer();
+ const before = html(editor);
+ const depth = undoDepth(editor.state);
+ act(() => { handle.replaceBody('Hi Bob, attached.
', 'Hi Bob, attached.'); });
+ expect(undoDepth(editor.state)).toBe(depth + 1);
+ act(() => { editor.commands.undo(); });
+ expect(html(editor)).toBe(before);
+ });
+
+ it('replaces a selection in one step and keeps an image inside it', async () => {
+ const { editor, handle } = await renderComposer();
+ const before = html(editor);
+ // "Hello Bob," up to and including the image.
+ let imageEnd = 0;
+ editor.state.doc.descendants((node, pos) => { if (node.type.name === 'image') imageEnd = pos + node.nodeSize; });
+ act(() => { editor.commands.setTextSelection({ from: 1, to: imageEnd }); });
+ expect(handle.readSelection().text).toContain('Hello Bob,');
+
+ const depth = undoDepth(editor.state);
+ act(() => { handle.replaceSelection('Dear Bob,
', 'Dear Bob,', { below: false }); });
+ expect(undoDepth(editor.state)).toBe(depth + 1);
+ const after = html(editor);
+ expect(after).toContain('Dear Bob,');
+ expect(after).not.toContain('Hello Bob');
+ expect(after).toContain('data-cid="photo1"');
+ expect(after).toContain('ACME Ltd.');
+
+ act(() => { editor.commands.undo(); });
+ expect(html(editor)).toBe(before);
+ });
+
+ it('refuses to replace a selection that holds the signature', async () => {
+ const { editor, handle } = await renderComposer();
+ const before = html(editor);
+ act(() => { editor.commands.setTextSelection({ from: 1, to: editor.state.doc.content.size }); });
+ let ok = true;
+ act(() => { ok = handle.replaceSelection('x
', 'x', { below: false }); });
+ expect(ok).toBe(false);
+ expect(html(editor)).toBe(before);
+ });
+
+ it('adds below the selection without touching it', async () => {
+ const { editor, handle } = await renderComposer();
+ act(() => { editor.commands.setTextSelection({ from: 1, to: 6 }); });
+ act(() => { handle.replaceSelection('Suggested: Hi Bob
', 'Suggested: Hi Bob', { below: true }); });
+ const after = html(editor);
+ expect(after).toContain('Hello Bob,');
+ expect(after.indexOf('Hello Bob,')).toBeLessThan(after.indexOf('Suggested: Hi Bob'));
+ expect(after.indexOf('Suggested: Hi Bob')).toBeLessThan(after.indexOf('data-signature-block-node'));
+ });
+
+ it('runs a plugin button\'s shortcut while typing in the composer', async () => {
+ const { editor, clicks } = await renderComposer();
+ fireEvent.keyDown(editor.view.dom, { key: 'Y', code: 'KeyY', ctrlKey: true, shiftKey: true });
+ await waitFor(() => expect(clicks).toHaveLength(2));
+ expect(clicks[1].composerId).toBe(clicks[0].composerId);
+ });
+
+ it('attaches a file the plugin uploaded, and forgets the composer when it closes', async () => {
+ const { handle, id } = await renderComposer();
+ let ok = false;
+ await act(async () => { ok = await handle.attach({ blobId: 'B1', name: 'report.pdf', type: 'application/pdf', size: 2048 }); });
+ expect(ok).toBe(true);
+ expect(await screen.findByText('report.pdf')).toBeTruthy();
+ cleanup();
+ expect(composerHandle(id)).toBeNull();
+ });
+});
diff --git a/components/email/__tests__/composer-plugin-lifecycle.test.tsx b/components/email/__tests__/composer-plugin-lifecycle.test.tsx
new file mode 100644
index 000000000..ff7c7a0c0
--- /dev/null
+++ b/components/email/__tests__/composer-plugin-lifecycle.test.tsx
@@ -0,0 +1,354 @@
+import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import React, { useState } from 'react';
+import { EmailComposer } from '../email-composer';
+import { useAuthStore } from '@/stores/auth-store';
+import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks';
+import { resetComposerSessions } from '@/lib/plugin-composer-hooks';
+
+// mail-compose hooks fired by the composer itself: onComposerOpen and
+// onComposerClose once per session (not per remount), onDraftAutoSave after a
+// save the server took, and the onBeforeCompose prefill the host hands in.
+// The hook module is the real one here.
+
+// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
+
+const editorKeyDown = vi.hoisted(() => vi.fn());
+const toastError = vi.hoisted(() => vi.fn());
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', {
+ 'data-testid': 'rich-text-editor',
+ onKeyDown: (event: React.KeyboardEvent) => {
+ if (!event.defaultPrevented) editorKeyDown();
+ },
+ }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+const updateSetting = vi.fn();
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ signaturePosition: 'above_quote',
+ signatureSeparatorEnabled: false,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: (...args: unknown[]) => updateSetting(...args),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: toastError, success: () => {} },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/signature-utils', () => ({
+ appendPlainTextSignature: (body: string) => body,
+ getPlainTextSignature: () => '',
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: () => '',
+ serializeEditorContent: () => '',
+}));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+const DRAFT = {
+ to: 'bob@example.com',
+ cc: '',
+ bcc: '',
+ subject: 'Lunch',
+ body: 'Hello there
',
+ showCc: false,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose' as const,
+ draftId: null,
+};
+
+const flushCloseTimer = () => act(() => new Promise((r) => setTimeout(r, 10)));
+
+const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
+
+function Host({ onSend, sessionKey = 's1' }: { onSend?: () => Promise; sessionKey?: string }) {
+ const [open, setOpen] = useState(true);
+ if (!open) return closed
;
+ return (
+ setOpen(false)}
+ onSend={async () => {
+ setOpen(false);
+ await onSend?.();
+ }}
+ />
+ );
+}
+
+beforeEach(() => {
+ clearAllHooks();
+ resetComposerSessions();
+ useAuthStore.setState({ client: null });
+});
+
+describe('onComposerOpen / onComposerClose', () => {
+ it('reports the open with the mode and the original message', () => {
+ const opened = vi.fn();
+ emailHooks.onComposerOpen.register('test', opened);
+ render(
+ ,
+ );
+ expect(opened).toHaveBeenCalledTimes(1);
+ expect(opened).toHaveBeenCalledWith({ mode: 'reply', inReplyToId: 'orig-1', originalSubject: 'Plans' });
+ });
+
+ it('treats a remount with the same session as the same composer', async () => {
+ const opened = vi.fn();
+ const closed = vi.fn();
+ emailHooks.onComposerOpen.register('test', opened);
+ emailHooks.onComposerClose.register('test', closed);
+
+ const { rerender } = render(
);
+ // Moved to another parent: React mounts a new composer.
+ rerender();
+ await flushCloseTimer();
+
+ expect(opened).toHaveBeenCalledTimes(1);
+ expect(closed).not.toHaveBeenCalled();
+ });
+
+ it('reports a close without a send', async () => {
+ const closed = vi.fn();
+ emailHooks.onComposerClose.register('test', closed);
+ render( );
+
+ // Nothing typed: closes without asking.
+ act(() => requestCloseRef.current!());
+ await screen.findByText('closed');
+ await flushCloseTimer();
+
+ expect(closed).toHaveBeenCalledTimes(1);
+ expect(closed).toHaveBeenCalledWith({ draftId: null, sent: false });
+ });
+
+ it('reports a close after a send as sent, even though the host closes mid-send', async () => {
+ const closed = vi.fn();
+ emailHooks.onComposerClose.register('test', closed);
+ render( new Promise((r) => setTimeout(r, 20))} />);
+
+ fireEvent.click(screen.getAllByTestId('composer-send')[0]);
+ await screen.findByText('closed');
+ await act(() => new Promise((r) => setTimeout(r, 50)));
+
+ expect(closed).toHaveBeenCalledTimes(1);
+ expect(closed).toHaveBeenCalledWith(expect.objectContaining({ sent: true }));
+ });
+});
+
+describe('onDraftAutoSave', () => {
+ it('fires after the server took the draft, not when the save fails', async () => {
+ const saved = vi.fn();
+ emailHooks.onDraftAutoSave.register('test', saved);
+ const createDraft = vi.fn().mockRejectedValueOnce(new Error('refused')).mockResolvedValue('draft-7');
+ useAuthStore.setState({
+ client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
+ });
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render( );
+ fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
+ await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
+ await act(async () => {});
+ expect(saved).not.toHaveBeenCalled();
+
+ fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch on Friday?' } });
+ await waitFor(() => expect(saved).toHaveBeenCalledTimes(1), { timeout: 4000 });
+ expect(saved).toHaveBeenCalledWith({ draftId: 'draft-7', subject: 'Lunch on Friday?' });
+ } finally {
+ consoleError.mockRestore();
+ useAuthStore.setState({ client: null });
+ }
+ }, 15_000);
+});
+
+describe('onBeforeCompose prefill', () => {
+ it('fills the fields of a new message', () => {
+ render( );
+ expect(screen.getByTestId('composer-subject')).toHaveValue('From a plugin');
+ expect(screen.getByText('carol@example.com')).toBeInTheDocument();
+ });
+
+ it('goes over what the host handed in', () => {
+ render( );
+ expect(screen.getByTestId('composer-subject')).toHaveValue('Changed');
+ // Fields the plugin left alone stay.
+ expect(screen.getByText('bob@example.com')).toBeInTheDocument();
+ });
+});
+
+describe('onBeforeDraftAutoSave', () => {
+ function withClient(createDraft: ReturnType) {
+ useAuthStore.setState({
+ client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
+ });
+ }
+
+ it('lets a plugin skip a save while typing', async () => {
+ const before = vi.fn().mockReturnValue(false);
+ emailHooks.onBeforeDraftAutoSave.register('test', before);
+ const createDraft = vi.fn().mockResolvedValue('draft-1');
+ withClient(createDraft);
+ try {
+ render( );
+ fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
+ await waitFor(() => expect(before).toHaveBeenCalled(), { timeout: 4000 });
+ await act(async () => {});
+
+ expect(createDraft).not.toHaveBeenCalled();
+ } finally {
+ useAuthStore.setState({ client: null });
+ }
+ }, 15_000);
+
+ it('saves the draft a plugin returns', async () => {
+ emailHooks.onBeforeDraftAutoSave.register('test', (draft: { subject: string }) => ({ ...draft, subject: `[x] ${draft.subject}` }));
+ const createDraft = vi.fn().mockResolvedValue('draft-1');
+ withClient(createDraft);
+ try {
+ render( );
+ fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
+ await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
+
+ expect(createDraft.mock.calls[0][1]).toBe('[x] Lunch?');
+ } finally {
+ useAuthStore.setState({ client: null });
+ }
+ }, 15_000);
+});
diff --git a/components/email/__tests__/composer-plugin-send.test.tsx b/components/email/__tests__/composer-plugin-send.test.tsx
index 0d5b95449..308d766b7 100644
--- a/components/email/__tests__/composer-plugin-send.test.tsx
+++ b/components/email/__tests__/composer-plugin-send.test.tsx
@@ -141,6 +141,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -148,7 +156,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: hoisted.takeOver },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..05772d4f5
--- /dev/null
+++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx
@@ -0,0 +1,481 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { EmailComposer } from '../email-composer';
+import { useAuthStore } from '@/stores/auth-store';
+import { checkActions, resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+import { clearOffers, lastSlotProps, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
+import { useSettingsStore } from '@/stores/settings-store';
+import { SLOT_CATALOG } from '@/lib/plugin-slot-catalog';
+
+// Plugin surfaces of the composer: frames above and below the message text
+// (fed the draft, at most every 2 seconds), and entries in the Send and
+// Attach menus.
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
+}));
+vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({
+ PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot,
+}));
+
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ signaturePosition: 'above_quote',
+ signatureSeparatorEnabled: false,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ onBeforeAttachmentUpload: { intercept: async () => true },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/signature-utils', () => ({
+ appendPlainTextSignature: (body: string) => body,
+ getPlainTextSignature: () => '',
+ plainTextBodyHasSignature: () => false,
+ plainTextBodyWithoutSignature: (body: string) => body,
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: () => '',
+ serializeEditorContent: () => '',
+}));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+const DRAFT = {
+ to: 'bob@example.com',
+ cc: '',
+ bcc: '',
+ subject: 'Lunch',
+ body: 'Hello there
',
+ showCc: false,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose' as const,
+ draftId: null,
+};
+
+const follows = (a: Node, b: Node) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
+const draftOf = (el: HTMLElement) => slotProps(el).draft as { subject: string; to: string[]; htmlBody: string; identityId: string };
+
+const dispatch = vi.fn().mockResolvedValue(undefined);
+
+beforeEach(() => {
+ resetActions();
+ dispatch.mockClear();
+ setActionDispatcher(dispatch);
+ useAuthStore.setState({ client: null });
+});
+
+afterEach(() => {
+ clearOffers();
+ setActionDispatcher(null);
+ vi.useRealTimers();
+});
+
+describe('composer-above-body and composer-below-body', () => {
+ it('put the frames around the message text with the draft', () => {
+ offerSlots('p1', ['composer-above-body', 'composer-below-body']);
+ render( );
+
+ const above = screen.getByTestId('slot-composer-above-body');
+ const below = screen.getByTestId('slot-composer-below-body');
+ const editor = screen.getByTestId('rich-text-editor');
+ expect(follows(screen.getByTestId('composer-subject'), above)).toBe(true);
+ expect(follows(above, editor)).toBe(true);
+ expect(follows(editor, below)).toBe(true);
+ expect(draftOf(above)).toMatchObject({ subject: 'Lunch', to: ['bob@example.com'], htmlBody: 'Hello there
', identityId: 'id-me' });
+ expect(draftOf(below).subject).toBe('Lunch');
+ });
+
+ it('send a changed draft only after 2 seconds without changes', () => {
+ offerSlots('p1', ['composer-above-body']);
+ render( );
+ vi.useFakeTimers();
+
+ fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch?' } });
+ act(() => { vi.advanceTimersByTime(1000); });
+ fireEvent.change(screen.getByTestId('composer-subject'), { target: { value: 'Lunch at 12?' } });
+ act(() => { vi.advanceTimersByTime(1900); });
+ expect(draftOf(screen.getByTestId('slot-composer-above-body')).subject).toBe('Lunch');
+
+ act(() => { vi.advanceTimersByTime(200); });
+ expect(draftOf(screen.getByTestId('slot-composer-above-body')).subject).toBe('Lunch at 12?');
+ });
+
+ it('let a plugin read the selection and write over it', () => {
+ useSettingsStore.setState({ plainTextMode: true });
+ try {
+ offerSlots('p1', ['composer-above-body']);
+ const { container } = render( );
+ const textarea = container.querySelector('textarea') as HTMLTextAreaElement;
+ const props = lastSlotProps('composer-above-body') as { insertText: (t: string) => boolean; getSelectedText: () => string };
+
+ textarea.setSelectionRange(0, 5);
+ expect(props.getSelectedText()).toBe('Hello');
+ act(() => { expect(props.insertText('Hi')).toBe(true); });
+
+ expect(textarea.value).toBe('Hi there');
+ expect(props.insertText('x'.repeat(100_001))).toBe(false);
+ } finally {
+ useSettingsStore.setState({ plainTextMode: false });
+ }
+ });
+
+ it('let a cloud picker put share links into the text', () => {
+ offerSlots('p1', ['composer-attachment-source']);
+ render( );
+
+ const props = lastSlotProps('composer-attachment-source');
+ expect(typeof props.onAttach).toBe('function');
+ expect(typeof props.insertText).toBe('function');
+ });
+
+ it('put the attachment source in the plugin button row, where the catalogue says it is', () => {
+ offerSlots('p1', ['composer-attachment-source']);
+ render( );
+
+ const frame = screen.getByTestId('slot-composer-attachment-source');
+ expect(frame.closest('[data-composer-plugin-row]')).not.toBeNull();
+ expect(SLOT_CATALOG['composer-attachment-source'].where).toMatch(/plugin row/);
+ });
+
+ it('draw nothing without a plugin', () => {
+ const { container } = render( );
+ expect(container.querySelector('[data-plugin-slot="composer-above-body"]')).toBeNull();
+ expect(container.querySelector('[data-plugin-slot="composer-below-body"]')).toBeNull();
+ });
+});
+
+describe('composer-send-menu', () => {
+ it('turns Send into a split button for plugin entries and sends the draft on click', async () => {
+ act(() => setPluginActions('p', 'Later', 'composer-send-menu', [{ id: 'later', label: 'Send with Later' }]));
+ render( );
+
+ // The phone and the desktop Send button are both in the DOM.
+ fireEvent.click(screen.getAllByRole('button', { name: 'more_actions' })[0]);
+ expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull();
+ await act(async () => { fireEvent.click(screen.getAllByRole('menuitem', { name: 'Send with Later' })[0]); });
+
+ expect(dispatch).toHaveBeenCalledTimes(1);
+ const [pluginId, surface, actionId, context] = dispatch.mock.calls[0];
+ expect([pluginId, surface, actionId]).toEqual(['p', 'composer-send-menu', 'later']);
+ expect((context as { draft: { subject: string } }).draft).toMatchObject({ subject: 'Lunch', to: ['bob@example.com'] });
+ expect(screen.queryByRole('menuitem', { name: 'Send with Later' })).toBeNull();
+ });
+
+ it('lists the entries under Schedule send when the account has it', () => {
+ useAuthStore.setState({
+ client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
+ });
+ act(() => setPluginActions('p', 'Later', 'composer-send-menu', [{ id: 'later', label: 'Send with Later' }]));
+ render( );
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'schedule_send' })[0]);
+ const schedule = screen.getAllByRole('menuitem', { name: /schedule_send/ })[0];
+ const plugin = screen.getAllByRole('menuitem', { name: 'Send with Later' })[0];
+ expect(follows(schedule, plugin)).toBe(true);
+ });
+
+ it('keeps a plain Send button without plugin entries or scheduled send', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
+ expect(screen.queryByRole('button', { name: 'schedule_send' })).toBeNull();
+ });
+});
+
+describe('composer-options-menu and composer-toolbar-actions', () => {
+ it('lists plugin options in Options, ticked ones as check items counted in the badge', async () => {
+ act(() => setPluginActions('smime', 'S/MIME', 'composer-options-menu', [
+ { id: 'sign', label: 'Sign', checked: true, description: 'With your certificate' },
+ { id: 'encrypt', label: 'Encrypt', checked: false },
+ { id: 'zones', label: 'Insert time in other zones' },
+ ]));
+ render( );
+
+ const options = screen.getByTestId('composer-options-menu');
+ expect(options).toHaveTextContent('1');
+ fireEvent.click(options);
+ const sign = screen.getByRole('menuitemcheckbox', { name: /Sign/ });
+ expect(sign).toHaveAttribute('aria-checked', 'true');
+ expect(sign).toHaveTextContent('With your certificate');
+ expect(screen.getByRole('menuitemcheckbox', { name: 'Encrypt' })).toHaveAttribute('aria-checked', 'false');
+
+ await act(async () => { fireEvent.click(screen.getByRole('menuitemcheckbox', { name: 'Encrypt' })); });
+ expect(dispatch).toHaveBeenCalledWith('smime', 'composer-options-menu', 'encrypt', { composerId: expect.any(String) });
+ // A check item leaves the menu open, so several options can be set.
+ expect(screen.getByRole('menuitemcheckbox', { name: /Sign/ })).toBeInTheDocument();
+
+ await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Insert time in other zones' })); });
+ expect(dispatch).toHaveBeenCalledWith('smime', 'composer-options-menu', 'zones', { composerId: expect.any(String) });
+ });
+
+ it('draws plugin buttons in the bottom bar like Attach, with the shortcut in the tooltip', async () => {
+ act(() => setPluginActions('ai', 'Assistant', 'composer-toolbar-actions', [{ id: 'ask', label: 'Assistant', icon: 'sparkles', shortcut: 'Alt+A' }]));
+ render( );
+ const button = screen.getByRole('button', { name: 'Assistant' });
+ expect(button).toHaveAttribute('title', 'Assistant (Alt+A)');
+ expect(button.className).toContain('h-9');
+ // In the bar only when it is wide enough next to the app's own buttons...
+ expect(button.className).toMatch(/(^|\s)hidden(\s|$)/);
+ expect(button.className).toContain('@[44rem]/composer-bar:inline-flex');
+ let bar: HTMLElement | null = button;
+ while (bar && !bar.classList.contains('@container/composer-bar')) bar = bar.parentElement;
+ expect(bar).not.toBeNull();
+ // Not in the plugin frame row above the bar.
+ expect(button.closest('[data-composer-plugin-row]')).toBeNull();
+ await act(async () => { fireEvent.click(button); });
+ expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) });
+
+ // ...and otherwise in the Options menu, which is shown only while narrow.
+ dispatch.mockClear();
+ fireEvent.click(screen.getByTestId('composer-options-menu'));
+ const entry = screen.getByRole('menuitem', { name: 'Assistant' });
+ expect(entry.closest('[data-narrow-only]')!.className).toContain('@[44rem]/composer-bar:hidden');
+ await act(async () => { fireEvent.click(entry); });
+ expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) });
+ });
+
+ it('refuses odd values for checked and description', () => {
+ expect(() => setPluginActions('p', 'P', 'composer-options-menu', checkActions([{ id: 'a', label: 'A', checked: 'yes' }]))).toThrow(/checked must be true or false/);
+ expect(() => checkActions([{ id: 'a', label: 'A', description: 'x'.repeat(121) }])).toThrow(/description/);
+ });
+});
+
+describe('composer-attach-menu', () => {
+ it('turns Attach into a menu for plugin entries', async () => {
+ act(() => setPluginActions('p', 'Cloud', 'composer-attach-menu', [{ id: 'cloud', label: 'From the cloud' }]));
+ render( );
+
+ fireEvent.click(screen.getByTestId('composer-attach-menu'));
+ expect(screen.getByRole('menuitem', { name: 'attach_from_device' })).toBeInTheDocument();
+ expect(screen.queryByTestId('composer-attach-from-files')).toBeNull();
+ await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'From the cloud' })); });
+
+ expect(dispatch).toHaveBeenCalledWith('p', 'composer-attach-menu', 'cloud', { composerId: expect.any(String) });
+ expect(screen.queryByRole('menuitem', { name: 'From the cloud' })).toBeNull();
+ });
+
+ it('keeps the plain Attach button without plugin entries', () => {
+ render( );
+ expect(screen.queryByTestId('composer-attach-menu')).toBeNull();
+ expect(screen.getByRole('button', { name: 'attach' })).toBeInTheDocument();
+ });
+});
+
+describe('composer-sidebar and composer-sidebar-right', () => {
+ function stubWidth(width: number) {
+ class FixedWidthObserver {
+ constructor(private cb: ResizeObserverCallback) {}
+ observe(el: Element) {
+ if ((el as HTMLElement).dataset.testid !== 'email-composer') return;
+ this.cb([{ target: el, contentRect: { width, height: 700 } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
+ }
+ unobserve() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', FixedWidthObserver);
+ }
+ const panel = (side: 'left' | 'right') => document.querySelector(`[data-composer-sidebar="${side}"]`)!;
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ try {
+ localStorage.removeItem('composerPluginSidebarCollapsed:left');
+ localStorage.removeItem('composerPluginSidebarCollapsed:right');
+ } catch { /* ignore */ }
+ });
+
+ it('give both panels a fixed width beside a wide composer', () => {
+ stubWidth(1400);
+ offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']);
+ render( );
+
+ for (const side of ['left', 'right'] as const) {
+ expect(panel(side).dataset.composerSidebarMode).toBe('docked');
+ expect(panel(side).style.width).toBe('260px');
+ }
+ expect(panel('left')).toContainElement(screen.getByTestId('slot-composer-sidebar'));
+ expect(panel('right')).toContainElement(screen.getByTestId('slot-composer-sidebar-right'));
+ });
+
+ it('fold the right panel, then both, when the composer would get too narrow', () => {
+ stubWidth(900);
+ offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']);
+ const { unmount } = render( );
+ expect(panel('left').dataset.composerSidebarMode).toBe('docked');
+ expect(panel('right').dataset.composerSidebarMode).toBe('folded');
+ expect(screen.queryByTestId('slot-composer-sidebar-right')).toBeNull();
+ unmount();
+
+ // The reading pane of a 1280 px window: an open panel would leave the
+ // composer too narrow for its bottom bar.
+ stubWidth(792);
+ render( );
+ expect(panel('left').dataset.composerSidebarMode).toBe('folded');
+ expect(panel('right').dataset.composerSidebarMode).toBe('folded');
+ });
+
+ it('open a folded panel over the composer when there is no room beside it', () => {
+ stubWidth(600);
+ offerSlots('p1', ['composer-sidebar-right']);
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
+ expect(panel('right').dataset.composerSidebarMode).toBe('overlay');
+ expect(panel('right').className).toContain('absolute');
+
+ fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_hide' }));
+ expect(panel('right').dataset.composerSidebarMode).toBe('folded');
+ });
+});
diff --git a/components/email/__tests__/composer-recipient-validation.test.tsx b/components/email/__tests__/composer-recipient-validation.test.tsx
index f7815d517..de2414ee6 100644
--- a/components/email/__tests__/composer-recipient-validation.test.tsx
+++ b/components/email/__tests__/composer-recipient-validation.test.tsx
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-reopened-draft-signature.test.tsx b/components/email/__tests__/composer-reopened-draft-signature.test.tsx
index ea61f85dc..3ddd45a2f 100644
--- a/components/email/__tests__/composer-reopened-draft-signature.test.tsx
+++ b/components/email/__tests__/composer-reopened-draft-signature.test.tsx
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -145,7 +153,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-reply-draft-threading.test.tsx b/components/email/__tests__/composer-reply-draft-threading.test.tsx
index a820821b5..2e34434c0 100644
--- a/components/email/__tests__/composer-reply-draft-threading.test.tsx
+++ b/components/email/__tests__/composer-reply-draft-threading.test.tsx
@@ -138,6 +138,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -145,7 +153,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-reply-focus.test.tsx b/components/email/__tests__/composer-reply-focus.test.tsx
index 545440f3d..62bb0752e 100644
--- a/components/email/__tests__/composer-reply-focus.test.tsx
+++ b/components/email/__tests__/composer-reply-focus.test.tsx
@@ -145,6 +145,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -152,7 +160,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-schedule-presets.test.tsx b/components/email/__tests__/composer-schedule-presets.test.tsx
index 32c9697ea..954f7507c 100644
--- a/components/email/__tests__/composer-schedule-presets.test.tsx
+++ b/components/email/__tests__/composer-schedule-presets.test.tsx
@@ -143,6 +143,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -150,7 +158,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-send-errors.test.tsx b/components/email/__tests__/composer-send-errors.test.tsx
index 1f2a4fba0..5e791f620 100644
--- a/components/email/__tests__/composer-send-errors.test.tsx
+++ b/components/email/__tests__/composer-send-errors.test.tsx
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-send-unmount.test.tsx b/components/email/__tests__/composer-send-unmount.test.tsx
index d2143cf78..2bd02d5e7 100644
--- a/components/email/__tests__/composer-send-unmount.test.tsx
+++ b/components/email/__tests__/composer-send-unmount.test.tsx
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-short-pane.test.tsx b/components/email/__tests__/composer-short-pane.test.tsx
index e37e11aa9..db39437d4 100644
--- a/components/email/__tests__/composer-short-pane.test.tsx
+++ b/components/email/__tests__/composer-short-pane.test.tsx
@@ -133,13 +133,21 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-title.test.tsx b/components/email/__tests__/composer-title.test.tsx
index 729a5eace..8f5e80ee9 100644
--- a/components/email/__tests__/composer-title.test.tsx
+++ b/components/email/__tests__/composer-title.test.tsx
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/composer-upload-account.test.tsx b/components/email/__tests__/composer-upload-account.test.tsx
index 3b8baf88a..d5a9ab085 100644
--- a/components/email/__tests__/composer-upload-account.test.tsx
+++ b/components/email/__tests__/composer-upload-account.test.tsx
@@ -144,6 +144,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -151,7 +159,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
onBeforeAttachmentUpload: { intercept: async () => true },
diff --git a/components/email/__tests__/empty-subject-warning.test.tsx b/components/email/__tests__/empty-subject-warning.test.tsx
index c8a020628..4908bc2b9 100644
--- a/components/email/__tests__/empty-subject-warning.test.tsx
+++ b/components/email/__tests__/empty-subject-warning.test.tsx
@@ -142,6 +142,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
@@ -149,7 +157,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
- onBeforeEmailSend: { intercept: async () => true },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..cba16b2d7
--- /dev/null
+++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
@@ -0,0 +1,290 @@
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
+
+// Plugin surfaces of the message list: frames above the list and in the empty
+// list, buttons on each row (hover, and the row menu), and buttons in the
+// toolbar shown while messages are selected.
+
+vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({
+ PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot,
+}));
+vi.mock('@/hooks/use-email-drag', () => ({
+ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
+}));
+vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
+vi.mock('@/components/email/rules-menu', () => ({ RulesContextSubMenu: () => null }));
+vi.mock('@/hooks/use-copy-link', () => ({ useCopyLink: () => vi.fn() }));
+vi.mock('@/stores/auth-store', async () => {
+ const { create } = await import('zustand');
+ return {
+ useAuthStore: create(() => ({
+ client: {}, identities: [], username: null, activeAccountId: 'acct-1',
+ getAllConnectedClients: () => new Map(), getClientForAccount: () => null,
+ })),
+ };
+});
+vi.mock('@/stores/account-store', () => {
+ const state = { getAccountById: () => undefined, accounts: [] };
+ return {
+ useAccountStore: Object.assign(
+ (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
+ { getState: () => state },
+ ),
+ };
+});
+
+const { EmailList } = await import('../email-list');
+const { MvListToolbar } = await import('../mv-list-toolbar');
+const { ThreadListItem } = await import('../thread-list-item');
+const { EmailContextMenu } = await import('../email-context-menu');
+const { useEmailStore } = await import('@/stores/email-store');
+const { useUIStore } = await import('@/stores/ui-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+const { groupEmailsByThread } = await import('@/lib/thread-utils');
+
+const mail = (id: string): Email => ({
+ id,
+ threadId: `thread-${id}`,
+ mailboxIds: { inbox: true },
+ keywords: { $seen: true },
+ size: 1000,
+ receivedAt: '2026-09-30T10:00:00Z',
+ from: [{ name: 'Alice', email: 'alice@example.com' }],
+ subject: `Mail ${id}`,
+ hasAttachment: false,
+});
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+const dispatch = vi.fn().mockResolvedValue(undefined);
+const initialEmailState = useEmailStore.getState();
+
+beforeEach(() => {
+ resetActions();
+ dispatch.mockClear();
+ setActionDispatcher(dispatch);
+ useEmailStore.setState(initialEmailState, true);
+ useEmailStore.setState({ selectedMailbox: 'inbox', mailboxes, emails: [], selectedEmailKeys: new Set() });
+ useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useSettingsStore.setState({ interfaceLayout: 'default', hoverActions: ['delete', 'star'], hoverActionsMode: 'inline' } as never);
+});
+
+afterEach(() => {
+ clearOffers();
+ setActionDispatcher(null);
+});
+
+describe('message-list-header', () => {
+ it('shows the plugin frame above the list with the folder', () => {
+ offerSlots('p1', ['message-list-header']);
+ render( );
+
+ const frame = screen.getByTestId('slot-message-list-header');
+ expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox' });
+ // Above the scrolling list, not inside it.
+ expect(frame.closest('[data-tour="email-list"]')).toBeNull();
+ });
+
+ it('draws nothing without a plugin', () => {
+ const { container } = render( );
+ expect(container.querySelector('[data-plugin-slot="message-list-header"]')).toBeNull();
+ });
+});
+
+describe('message-list-empty', () => {
+ it('shows the frame under the "no messages" text of an empty list', () => {
+ offerSlots('p1', ['message-list-empty']);
+ render( );
+
+ const frame = screen.getByTestId('slot-message-list-empty');
+ expect(slotProps(frame)).toEqual({ mailboxId: 'inbox', mailboxRole: 'inbox', searching: false });
+ const text = screen.getByText('no_emails_description');
+ expect(text.compareDocumentPosition(frame) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it('says when the empty list is a search', () => {
+ offerSlots('p1', ['message-list-empty']);
+ useEmailStore.setState({ searchQuery: 'invoice' });
+ render( );
+
+ expect(slotProps(screen.getByTestId('slot-message-list-empty')).searching).toBe(true);
+ });
+
+ it('is not there when the list has messages, or without a plugin', () => {
+ offerSlots('p1', ['message-list-empty']);
+ const { unmount } = render( );
+ expect(screen.queryByTestId('slot-message-list-empty')).toBeNull();
+ unmount();
+ clearOffers();
+ render( );
+ expect(screen.queryByTestId('slot-message-list-empty')).toBeNull();
+ });
+});
+
+describe('message-bulk-actions', () => {
+ it('adds the plugin buttons to the selection toolbar and sends the selected ids', async () => {
+ const emails = [mail('a'), mail('b'), mail('c')];
+ useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'c']) });
+ act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
+ render( );
+
+ const button = screen.getByRole('button', { name: 'Make tasks' });
+ // In the toolbar that holds the app's own batch buttons.
+ const toolbar = screen.getByText('batch_actions.clear_selection', { selector: 'button' }).parentElement!;
+ expect(toolbar).toContainElement(button);
+ await act(async () => { fireEvent.click(button); });
+ expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'c'] });
+ });
+
+ it('has no plugin buttons without a selection or without a plugin', () => {
+ const emails = [mail('a')];
+ useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a']) });
+ const { unmount } = render( );
+ expect(document.querySelector('[data-plugin-actions="message-bulk-actions"]')).toBeNull();
+ unmount();
+
+ act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
+ useEmailStore.setState({ selectedEmailKeys: new Set() });
+ render( );
+ expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
+ });
+
+ it('puts them behind one "more" button when the list is narrow', async () => {
+ // The list next to an open folder sidebar, or on a phone.
+ class NarrowObserver {
+ constructor(private cb: ResizeObserverCallback) {}
+ observe(el: Element) {
+ this.cb([{ target: el, contentRect: { width: 384, height: 48 } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
+ }
+ unobserve() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', NarrowObserver);
+ try {
+ const emails = [mail('a'), mail('b')];
+ useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'b']) });
+ act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [
+ { id: 'make', label: 'Make tasks' },
+ { id: 'more', label: 'Remind me' },
+ ]));
+ render( );
+
+ expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
+ // "Clear selection" keeps its name for screen readers but drops the text.
+ const clear = screen.getByRole('button', { name: 'batch_actions.clear_selection' });
+ expect(clear).not.toHaveTextContent('batch_actions.clear_selection');
+
+ fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
+ const item = await screen.findByRole('menuitem', { name: 'Make tasks' });
+ expect(screen.getByRole('menuitem', { name: 'Remind me' })).toBeInTheDocument();
+ await act(async () => { fireEvent.click(item); });
+ expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'b'] });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('adds them to the Mountain View toolbar too', async () => {
+ const emails = [mail('a'), mail('b')];
+ useEmailStore.setState({ emails, selectedEmailKeys: new Set(['b']) });
+ act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
+ render( {}} />);
+
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name: 'Make tasks' })); });
+ expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['b'] });
+ });
+
+ it('leaves the Mountain View toolbar alone without a selection', () => {
+ useEmailStore.setState({ emails: [mail('a')] });
+ act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }]));
+ render( {}} />);
+ expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
+ });
+});
+
+describe('message-row-actions', () => {
+ const row = (email: Email) => {
+ const [thread] = groupEmailsByThread([email]);
+ return {}} onEmailSelect={() => {}} />;
+ };
+
+ it('adds the plugin buttons after the row\'s own hover buttons', async () => {
+ act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later', icon: 'clock' }]));
+ const onSelect = vi.fn();
+ const [thread] = groupEmailsByThread([mail('a')]);
+ render( {}} onEmailSelect={onSelect} />);
+
+ const button = screen.getByRole('button', { name: 'Later' });
+ const star = screen.getByTestId('hover-action-star');
+ expect(star.parentElement).toContainElement(button);
+ expect(star.compareDocumentPosition(button) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ await act(async () => { fireEvent.click(button); });
+ expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' });
+ // The click is the plugin's, not a row click that opens the message.
+ expect(onSelect).not.toHaveBeenCalled();
+ });
+
+ it('shows them even when the user turned off every quick action', () => {
+ useSettingsStore.setState({ hoverActions: [] } as never);
+ act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
+ render(row(mail('a')));
+ expect(screen.getByRole('button', { name: 'Later' })).toBeInTheDocument();
+ });
+
+ it('honours the folder roles the plugin asked for', () => {
+ act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['sent'] }]));
+ render(row(mail('a')));
+ expect(screen.queryByRole('button', { name: 'Later' })).toBeNull();
+ });
+
+ it('draws nothing without a plugin', () => {
+ render(row(mail('a')));
+ expect(document.querySelector('[data-plugin-actions="message-row-actions"]')).toBeNull();
+ });
+
+ const menu = (props: Partial> = {}) => render(
+ {})}
+ menuRef={{ current: null }}
+ mailboxes={mailboxes}
+ selectedMailbox="inbox"
+ currentMailboxRole="inbox"
+ {...props}
+ />,
+ );
+
+ it('puts them in the row menu, which is what a long press opens on phones', async () => {
+ act(() => setPluginActions('p', 'Snooze+', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
+ const onClose = vi.fn();
+ menu({ onClose });
+
+ const item = screen.getByRole('menuitem', { name: 'Later' });
+ await act(async () => { fireEvent.click(item); });
+ expect(dispatch).toHaveBeenCalledWith('p', 'message-row-actions', 'later', { emailId: 'a' });
+ expect(onClose).toHaveBeenCalled();
+ });
+
+ it('keeps them out of the menu for a multi-selection, and out of an empty menu', () => {
+ const { unmount } = menu();
+ expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull();
+ unmount();
+ act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later' }]));
+ menu({ isMultiSelect: true, selectedCount: 3 });
+ expect(screen.queryByRole('menuitem', { name: 'Later' })).toBeNull();
+ });
+
+ it('keeps the menu entries to the folder roles the plugin asked for', () => {
+ act(() => setPluginActions('p', 'P', 'message-row-actions', [{ id: 'later', label: 'Later', mailboxRoles: ['junk'] }]));
+ menu();
+ expect(within(document.body).queryByRole('menuitem', { name: 'Later' })).toBeNull();
+ });
+});
diff --git a/components/email/__tests__/plugin-row-badges.test.tsx b/components/email/__tests__/plugin-row-badges.test.tsx
new file mode 100644
index 000000000..149d9a559
--- /dev/null
+++ b/components/email/__tests__/plugin-row-badges.test.tsx
@@ -0,0 +1,175 @@
+import { render, screen, waitFor, act } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { ThreadListItem } from '../thread-list-item';
+import { clearRowBadgeCache, normalizeBadges, MAX_ROW_BADGES } from '../plugin-row-badges';
+import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useUIStore } from '@/stores/ui-store';
+import { groupEmailsByThread } from '@/lib/thread-utils';
+import { renderHooks, clearAllHooks } from '@/lib/plugin-hooks';
+import { refreshRowBadges, resetRowBadgeRefresh } from '@/lib/plugin-sandbox/row-badges';
+import type { Email } from '@/lib/jmap/types';
+
+// onEmailListItemRender: plugins add small badges next to a row's subject.
+
+vi.mock('@/hooks/use-email-drag', () => ({
+ useEmailDrag: () => ({ dragHandlers: {}, isDragging: false }),
+}));
+
+vi.mock('@/stores/auth-store', () => {
+ const state = { identities: [], username: null };
+ return {
+ useAuthStore: (selector?: (s: typeof state) => unknown) => (selector ? selector(state) : state),
+ };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { getAccountById: () => undefined };
+ return {
+ useAccountStore: Object.assign(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (selector: any) => (selector ? selector(state) : state),
+ { getState: () => state },
+ ),
+ };
+});
+
+const makeEmail = (overrides: Partial = {}): Email => ({
+ id: 'email-1',
+ threadId: 'thread-1',
+ mailboxIds: { inbox: true },
+ keywords: { $seen: true },
+ size: 1000,
+ receivedAt: '2024-01-15T10:00:00Z',
+ from: [{ name: 'Alice', email: 'alice@example.com' }],
+ subject: 'Test Subject',
+ hasAttachment: false,
+ ...overrides,
+});
+
+function row(emails: Email[]) {
+ const [thread] = groupEmailsByThread(emails);
+ return (
+ {}} onEmailSelect={() => {}} />
+ );
+}
+
+beforeEach(() => {
+ clearAllHooks();
+ clearRowBadgeCache();
+ resetRowBadgeRefresh();
+ useUIStore.setState({ isMobile: false, isTablet: false, isDesktop: true });
+ useSettingsStore.setState({
+ emailKeywords: [...DEFAULT_KEYWORDS],
+ showPreview: false,
+ mailLayout: 'split',
+ interfaceLayout: 'default',
+ });
+ useEmailStore.setState({ selectedEmailKeys: new Set(), selectedMailbox: 'inbox' });
+});
+
+describe('plugin badges in message-list rows', () => {
+ it('shows the badges a plugin returns, with the email as a plain summary', async () => {
+ const handler = vi.fn(async (badges: unknown[]) => [
+ ...badges,
+ { key: 'enc', label: 'Encrypted', color: '#2563eb', title: 'Signed and encrypted' },
+ ]);
+ renderHooks.onEmailListItemRender.register('test', handler);
+
+ render(row([makeEmail()]));
+
+ const badge = await screen.findByText('Encrypted');
+ expect(badge).toHaveAttribute('title', 'Signed and encrypted');
+ expect(badge.style.borderColor).not.toBe('');
+ expect(handler).toHaveBeenCalledTimes(1);
+ const [initial, ctx] = handler.mock.calls[0] as unknown as [unknown, { email: Record }];
+ expect(initial).toEqual([]);
+ expect(ctx.email).toMatchObject({ id: 'email-1', subject: 'Test Subject', keywords: ['$seen'] });
+ // A projection, not the store object.
+ expect(ctx.email).not.toHaveProperty('mailboxIds.inbox');
+ });
+
+ it('asks the plugin again when it requests a refresh, at most once a second', async () => {
+ let label = 'Open';
+ const handler = vi.fn(async () => [{ key: 'task', label }]);
+ renderHooks.onEmailListItemRender.register('test', handler);
+ render(row([makeEmail()]));
+ await screen.findByText('Open');
+
+ label = 'Done';
+ act(() => { expect(refreshRowBadges(10_000)).toBe(true); });
+ await screen.findByText('Done');
+ expect(refreshRowBadges(10_500)).toBe(false);
+ expect(handler).toHaveBeenCalledTimes(2);
+ });
+
+ it('shows badges on a conversation row and on the phone layout', async () => {
+ useUIStore.setState({ isMobile: true, isTablet: false, isDesktop: false });
+ renderHooks.onEmailListItemRender.register('test', async () => [{ key: 'k', label: 'Phone badge' }]);
+
+ render(row([
+ makeEmail({ id: 'a', receivedAt: '2024-01-15T10:00:00Z' }),
+ makeEmail({ id: 'b', receivedAt: '2024-01-16T10:00:00Z' }),
+ ]));
+
+ expect(await screen.findByText('Phone badge')).toBeInTheDocument();
+ });
+
+ it('renders the label as text, never as HTML', async () => {
+ renderHooks.onEmailListItemRender.register('test', async () => [
+ { key: 'x', label: ' ' },
+ ]);
+
+ const { container } = render(row([makeEmail()]));
+
+ expect(await screen.findByText(' ')).toBeInTheDocument();
+ expect(container.querySelector('img[src="x"]')).toBeNull();
+ });
+
+ it('asks plugins once per message and keyword state, not on every render', async () => {
+ const handler = vi.fn(async () => [{ key: 'k', label: 'Badge' }]);
+ renderHooks.onEmailListItemRender.register('test', handler);
+ const email = makeEmail();
+
+ const view = render(row([email]));
+ await screen.findByText('Badge');
+ view.rerender(row([{ ...email }]));
+ view.unmount();
+ render(row([{ ...email }]));
+ await screen.findByText('Badge');
+ expect(handler).toHaveBeenCalledTimes(1);
+
+ // Marking it unread is a new keyword state: ask again.
+ render(row([{ ...email, keywords: {} }]));
+ await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
+ });
+
+ it('does nothing without a handler', async () => {
+ const transform = vi.spyOn(renderHooks.onEmailListItemRender, 'transform');
+ render(row([makeEmail()]));
+ await act(async () => {});
+ expect(transform).not.toHaveBeenCalled();
+ expect(screen.queryByTestId('plugin-row-badges')).toBeNull();
+ transform.mockRestore();
+ });
+});
+
+describe('normalizeBadges', () => {
+ it(`keeps at most ${MAX_ROW_BADGES} well-formed badges and drops unsafe colours`, () => {
+ const out = normalizeBadges([
+ { key: 'a', label: 'A', color: 'url(https://evil.example/x.png)' },
+ { key: 'b', label: '' },
+ 'nonsense',
+ { key: 'c', label: 'C', color: 'var(--color-warning)' },
+ { key: 'd', label: 'D', color: 'red; background: url(x)' },
+ { key: 'e', label: 'E' },
+ { key: 'f', label: 'F' },
+ ]);
+ expect(out).toEqual([
+ { key: 'a', label: 'A' },
+ { key: 'c', label: 'C', color: 'var(--color-warning)' },
+ { key: 'd', label: 'D' },
+ ]);
+ expect(normalizeBadges(undefined)).toEqual([]);
+ });
+});
diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx
new file mode 100644
index 000000000..36c7f96f3
--- /dev/null
+++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx
@@ -0,0 +1,427 @@
+import { act, fireEvent, render, screen, within } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import type { Email, Mailbox, ThreadGroup } from '@/lib/jmap/types';
+import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+import { clearOffers, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
+
+// Plugin surfaces of the open message: frames above the subject, under the
+// sender, between new text and quote, above a conversation; buttons in the
+// reader toolbar (More panel on phones); entries in an attachment's menu.
+
+const device = vi.hoisted(() => ({ isMobile: false }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: device.isMobile, isTablet: false, isDesktop: !device.isMobile }),
+ useMediaQuery: () => false,
+ useIsMobile: () => device.isMobile,
+}));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/components/plugins/plugin-iframe-slot', async () => ({
+ PluginIframeSlot: (await import('@/components/plugins/__tests__/fake-plugin-offer')).FakeIframeSlot,
+}));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+const { ThreadConversationView } = await import('../thread-conversation-view');
+const { ReaderAboveQuoteSlot, QUOTE_ANCHOR_ATTR } = await import('../reader-plugin-surfaces');
+const { useSettingsStore } = await import('@/stores/settings-store');
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+function mail(overrides: Partial = {}): Email {
+ return {
+ id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
+ subject: 'Hello there', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
+ textBody: [], htmlBody: [], attachments: [], bodyValues: {},
+ ...overrides,
+ } as unknown as Email;
+}
+
+function textMail(text: string, overrides: Partial = {}): Email {
+ return mail({
+ textBody: [{ partId: '1', type: 'text/plain' }],
+ bodyValues: { '1': { value: text, isTruncated: false } },
+ ...overrides,
+ } as Partial);
+}
+
+const REPLY = 'Sounds good, see you then.\n\nOn Monday Bob wrote:\n> Shall we meet at ten?\n> Bob';
+
+function viewer(email: Email, props: Partial> = {}) {
+ return render(
+ ,
+ );
+}
+
+const follows = (a: Node, b: Node) => !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING);
+
+const dispatch = vi.fn().mockResolvedValue(undefined);
+const initialSettings = useSettingsStore.getState();
+
+beforeEach(() => {
+ device.isMobile = false;
+ resetActions();
+ dispatch.mockClear();
+ setActionDispatcher(dispatch);
+ useSettingsStore.setState(initialSettings, true);
+});
+
+afterEach(() => {
+ clearOffers();
+ setActionDispatcher(null);
+});
+
+describe('email-detail-sidebar', () => {
+ // Reports a fixed width for whatever it observes, like a 584 px reading pane
+ // (three panes on a laptop).
+ function stubWidth(width: number) {
+ class FixedWidthObserver {
+ constructor(private cb: ResizeObserverCallback) {}
+ observe(el: Element) {
+ this.cb([{ target: el, contentRect: { width } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
+ }
+ unobserve() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', FixedWidthObserver);
+ }
+
+ afterEach(() => {
+ vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+ try { localStorage.removeItem('emailDetailSidebarCollapsed'); } catch { /* ignore */ }
+ });
+
+ it('starts collapsed when the message opens into a pane too narrow for it', () => {
+ stubWidth(584);
+ offerSlots('p1', ['email-detail-sidebar']);
+ // The reader mounts empty first, then a message opens.
+ const { rerender } = render(
+ ,
+ );
+ rerender( );
+
+ expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
+ expect(screen.getByRole('button', { name: 'plugin_panel_show' })).toBeInTheDocument();
+ });
+
+ it('lays the panel over the message when opened anyway, instead of squeezing it', () => {
+ stubWidth(584);
+ offerSlots('p1', ['email-detail-sidebar']);
+ viewer(textMail('Hi'));
+
+ fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
+ const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
+ expect(panel.getAttribute('data-plugin-detail-panel')).toBe('overlay');
+ expect(panel.className).toContain('absolute');
+ });
+
+ it('docks next to the message when there is room', () => {
+ stubWidth(1000);
+ offerSlots('p1', ['email-detail-sidebar']);
+ viewer(textMail('Hi'));
+
+ const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
+ expect(panel.getAttribute('data-plugin-detail-panel')).toBe('docked');
+ });
+});
+
+describe('reader-header and reader-below-subject', () => {
+ it('put the frames above the subject and under the sender, with the message', () => {
+ offerSlots('p1', ['reader-header', 'reader-below-subject']);
+ const { container } = viewer(textMail('Hi'));
+
+ const header = screen.getByTestId('slot-reader-header');
+ const below = screen.getByTestId('slot-reader-below-subject');
+ const subject = screen.getByRole('heading', { name: 'Hello there' });
+ const body = container.querySelector('.email-content-wrapper')!;
+ expect(follows(header, subject)).toBe(true);
+ expect(follows(subject, below)).toBe(true);
+ expect(follows(below, body)).toBe(true);
+ expect((slotProps(header).email as { id: string }).id).toBe('e1');
+ expect((slotProps(below).email as { subject: string }).subject).toBe('Hello there');
+ });
+
+ it('draw nothing without a plugin', () => {
+ const { container } = viewer(textMail('Hi'));
+ expect(container.querySelector('[data-plugin-slot="reader-header"]')).toBeNull();
+ expect(container.querySelector('[data-plugin-slot="reader-below-subject"]')).toBeNull();
+ });
+});
+
+describe('email-footer', () => {
+ it('hands the frame the id of the open message', () => {
+ offerSlots('p1', ['email-footer']);
+ viewer(textMail('Hi'));
+ expect(slotProps(screen.getByTestId('slot-email-footer'))).toEqual({ emailId: 'e1' });
+ });
+});
+
+describe('reader-above-quote', () => {
+ it('sits between the new text of a plain-text reply and its folded quote', () => {
+ offerSlots('p1', ['reader-above-quote']);
+ const { container } = viewer(textMail(REPLY));
+
+ const frame = screen.getByTestId('slot-reader-above-quote');
+ const quote = container.querySelector('.email-content-text details')!;
+ expect(quote).not.toBeNull();
+ expect(follows(frame, quote)).toBe(true);
+ expect(follows(screen.getByText(/Sounds good/), frame)).toBe(true);
+ expect((slotProps(frame).email as { id: string }).id).toBe('e1');
+ // The frame renders inside the anchor, so the anchor must grow with it
+ // instead of letting the frame spill over the quote toggle and the footer.
+ const anchor = container.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)!;
+ expect(anchor.contains(frame)).toBe(true);
+ expect(anchor.style.height).toBe('');
+ });
+
+ it('is not mounted for a message without a quote, or without a plugin', () => {
+ offerSlots('p1', ['reader-above-quote']);
+ const { unmount } = viewer(textMail('Just some text.'));
+ expect(screen.queryByTestId('slot-reader-above-quote')).toBeNull();
+ unmount();
+ clearOffers();
+ const { container } = viewer(textMail(REPLY));
+ expect(container.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)).toBeNull();
+ });
+
+ it('lays the frame over an HTML body and makes room for it before the quote toggle', () => {
+ offerSlots('p1', ['reader-above-quote']);
+ const host = document.createElement('div');
+ document.body.appendChild(host);
+ const iframe = document.createElement('iframe');
+ host.appendChild(iframe);
+ const doc = iframe.contentDocument!;
+ doc.body.innerHTML = 'New text
... Old ';
+
+ const { unmount } = render(
+ ,
+ { container: host.appendChild(document.createElement('div')) },
+ );
+
+ const anchor = doc.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)!;
+ expect(anchor.nextElementSibling).toBe(doc.querySelector('[data-quote-toggle]'));
+ const overlay = document.querySelector('[data-plugin-quote-overlay]')!;
+ expect(within(overlay as HTMLElement).getByTestId('slot-reader-above-quote')).toBeInTheDocument();
+ unmount();
+ expect(doc.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)).toBeNull();
+ host.remove();
+ });
+
+ it('shows in a conversation message on phones too', () => {
+ offerSlots('p1', ['reader-above-quote']);
+ const email = textMail(REPLY, { keywords: {} } as Partial);
+ const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup;
+ render( {}} />);
+
+ expect(screen.getByTestId('slot-reader-above-quote')).toBeInTheDocument();
+ });
+});
+
+describe('thread-summary', () => {
+ const conversation = [
+ textMail('First', { id: 'm1', receivedAt: '2026-01-01T00:00:00Z' } as Partial),
+ textMail('Second', { id: 'm2', receivedAt: '2026-01-02T00:00:00Z' } as Partial),
+ ];
+
+ it('sits above the messages of the phone conversation view', () => {
+ offerSlots('p1', ['thread-summary']);
+ const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
+ render( {}} />);
+
+ const frame = screen.getByTestId('slot-thread-summary');
+ expect(slotProps(frame)).toEqual({ threadId: 't1', emailIds: ['m2', 'm1'] });
+ expect(follows(frame, screen.getAllByText('Alice')[0])).toBe(true);
+ });
+
+ it('sits above a Mountain View conversation', () => {
+ offerSlots('p1', ['thread-summary']);
+ useSettingsStore.setState({ interfaceLayout: 'mountain-view', markAsReadDelay: 0 });
+ viewer(conversation[1], { mvConversation: conversation });
+
+ expect(slotProps(screen.getByTestId('slot-thread-summary'))).toEqual({ threadId: 't1', emailIds: ['m1', 'm2'] });
+ });
+
+ it('draws nothing without a plugin, and nothing for a single message', () => {
+ const thread = { threadKey: 't1', latestEmail: conversation[1], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
+ const { unmount } = render( {}} />);
+ expect(screen.queryByTestId('slot-thread-summary')).toBeNull();
+ unmount();
+ offerSlots('p1', ['thread-summary']);
+ viewer(conversation[1]);
+ expect(screen.queryByTestId('slot-thread-summary')).toBeNull();
+ });
+});
+
+describe('reader surfaces in the phone conversation view', () => {
+ const conversation = [
+ textMail('Second', { id: 'm2', subject: 'Re: Plan', receivedAt: '2026-01-02T00:00:00Z' } as Partial),
+ textMail('First', { id: 'm1', subject: 'Plan', receivedAt: '2026-01-01T00:00:00Z' } as Partial),
+ ];
+ const thread = { threadKey: 't1', latestEmail: conversation[0], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
+
+ it('mounts the single reader\'s frames on the unfolded message only', () => {
+ offerSlots('p1', ['reader-header', 'reader-below-subject', 'email-banner', 'email-footer']);
+ render( {}} />);
+
+ // The latest message is unfolded, the older one (read) is folded.
+ for (const slot of ['reader-header', 'reader-below-subject', 'email-banner'] as const) {
+ const frames = screen.getAllByTestId(`slot-${slot}`);
+ expect(frames).toHaveLength(1);
+ expect((slotProps(frames[0]).email as { id: string }).id).toBe('m2');
+ }
+ const footer = screen.getAllByTestId('slot-email-footer');
+ expect(footer).toHaveLength(1);
+ expect(slotProps(footer[0])).toEqual({ emailId: 'm2' });
+ // Header on top, the body between banner and footer.
+ expect(follows(screen.getByTestId('slot-reader-header'), screen.getByTestId('slot-reader-below-subject'))).toBe(true);
+ expect(follows(screen.getByTestId('slot-email-banner'), screen.getByText('Second'))).toBe(true);
+ expect(follows(screen.getByText('Second'), footer[0])).toBe(true);
+ });
+
+ it('puts plugin reader actions behind a more button on the unfolded message', async () => {
+ act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate', mailboxRoles: ['inbox'] }]));
+ const { unmount } = render( {}} mailboxRole="inbox" />);
+
+ fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
+ const item = await screen.findByRole('menuitem', { name: 'Translate' });
+ await act(async () => { fireEvent.click(item); });
+ expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'm2' });
+ unmount();
+
+ // Kept to the folder roles the plugin asked for.
+ render( {}} mailboxRole="junk" />);
+ expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
+ });
+});
+
+describe('reader-actions', () => {
+ it('adds the plugin buttons to the reader toolbar', async () => {
+ act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }]));
+ viewer(textMail('Hola'));
+
+ const button = screen.getByRole('button', { name: 'Translate' });
+ expect(button.closest('[data-plugin-actions="reader-actions"]')).not.toBeNull();
+ await act(async () => { fireEvent.click(button); });
+ expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' });
+ });
+
+ it('keeps to the folder roles the plugin asked for', () => {
+ act(() => setPluginActions('p', 'P', 'reader-actions', [{ id: 'translate', label: 'Translate', mailboxRoles: ['junk'] }]));
+ viewer(textMail('Hola'));
+ expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull();
+ });
+
+ it('puts them in the More panel on phones', async () => {
+ device.isMobile = true;
+ act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }]));
+ viewer(textMail('Hola'));
+
+ expect(screen.queryByRole('button', { name: 'Translate' })).toBeNull();
+ const item = screen.getByRole('menuitem', { name: 'Translate', hidden: true });
+ await act(async () => { fireEvent.click(item); });
+ expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'e1' });
+ });
+
+ it('draws nothing without a plugin', () => {
+ const { container } = viewer(textMail('Hola'));
+ expect(container.querySelector('[data-plugin-actions="reader-actions"]')).toBeNull();
+ });
+});
+
+describe('attachment-actions', () => {
+ const withAttachment = () => textMail('See attached', {
+ hasAttachment: true,
+ attachments: [{ partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' }],
+ } as Partial);
+
+ it('puts the frames next to the chip, not in its hover overlay', () => {
+ offerSlots('p1', ['attachment-actions']);
+ viewer(withAttachment());
+
+ // Desktop chip and phone chip are both in the DOM.
+ const frames = screen.getAllByTestId('slot-attachment-actions');
+ expect(frames.length).toBeGreaterThan(0);
+ for (const frame of frames) {
+ // Nothing between the frame and the page may hide it until hover.
+ for (let el: HTMLElement | null = frame; el; el = el.parentElement) {
+ expect(el.className).not.toMatch(/\bopacity-0\b/);
+ }
+ expect(frame.closest('[data-attachment-name]')).toBeNull();
+ expect(slotProps(frame)).toEqual({
+ attachment: { name: 'invoice.pdf', type: 'application/pdf', size: 1234, blobId: 'blob-pdf', emailId: 'e1' },
+ });
+ }
+ });
+
+ it('keeps the invisible hover overlay from taking taps', () => {
+ viewer(withAttachment());
+ const overlays = [...document.querySelectorAll('[data-attachment-name="invoice.pdf"] div')]
+ .filter((d) => /\bopacity-0\b/.test(d.className));
+ expect(overlays.length).toBeGreaterThan(0);
+ for (const overlay of overlays) expect(overlay.className).toMatch(/\bpointer-events-none\b/);
+ });
+
+ it("shows the overlay's buttons to keyboard users who tab into them, not only on hover", () => {
+ viewer(withAttachment());
+ const overlays = [...document.querySelectorAll('[data-attachment-name="invoice.pdf"] div')]
+ .filter((d) => /\bopacity-0\b/.test(d.className));
+ expect(overlays.length).toBeGreaterThan(0);
+ for (const overlay of overlays) {
+ expect(overlay.className).toMatch(/\bgroup-focus-within:opacity-100\b/);
+ expect(overlay.className).toMatch(/\bgroup-focus-within:pointer-events-auto\b/);
+ }
+ });
+});
+
+describe('attachment-menu', () => {
+ const withAttachments = () => textMail('See attached', {
+ hasAttachment: true,
+ attachments: [
+ { partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' },
+ { partId: '3', blobId: 'blob-png', name: 'photo.png', type: 'image/png', size: 99, disposition: 'attachment' },
+ ],
+ } as Partial);
+
+ it('opens the plugin entries from a menu button on the attachment and sends the attachment', async () => {
+ act(() => setPluginActions('p', 'Cloud', 'attachment-menu', [{ id: 'save', label: 'Save to cloud', mimeTypes: ['application/pdf'] }]));
+ viewer(withAttachments());
+
+ // Only the PDF gets the menu (desktop chip and phone chip are both in the DOM).
+ const buttons = document.querySelectorAll('[data-attachment-plugin-menu]');
+ expect(buttons.length).toBeGreaterThan(0);
+ for (const b of buttons) {
+ expect(b.closest('[data-attachment-name]')?.getAttribute('data-attachment-name')).toBe('invoice.pdf');
+ }
+ fireEvent.click(buttons[0]);
+ const item = await screen.findByRole('menuitem', { name: 'Save to cloud' });
+ await act(async () => { fireEvent.click(item); });
+ expect(dispatch).toHaveBeenCalledWith('p', 'attachment-menu', 'save', {
+ emailId: 'e1', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234,
+ });
+ });
+
+ it('adds the menu to attachments in a conversation message', () => {
+ act(() => setPluginActions('p', 'Cloud', 'attachment-menu', [{ id: 'save', label: 'Save to cloud' }]));
+ const email = withAttachments();
+ const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup;
+ render( {}} />);
+
+ expect(document.querySelectorAll('[data-attachment-plugin-menu]')).toHaveLength(2);
+ });
+
+ it('draws no menu button without a plugin entry', () => {
+ viewer(withAttachments());
+ expect(document.querySelector('[data-attachment-plugin-menu]')).toBeNull();
+ });
+});
diff --git a/components/email/__tests__/recipient-autocomplete.test.tsx b/components/email/__tests__/recipient-autocomplete.test.tsx
index 97c1eb937..af1feb66d 100644
--- a/components/email/__tests__/recipient-autocomplete.test.tsx
+++ b/components/email/__tests__/recipient-autocomplete.test.tsx
@@ -137,6 +137,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx
index bfdac74c7..058916079 100644
--- a/components/email/__tests__/recipient-chip-drag.test.tsx
+++ b/components/email/__tests__/recipient-chip-drag.test.tsx
@@ -130,6 +130,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
diff --git a/components/email/__tests__/recipient-paste.test.tsx b/components/email/__tests__/recipient-paste.test.tsx
index 5863db954..3d8a80ca9 100644
--- a/components/email/__tests__/recipient-paste.test.tsx
+++ b/components/email/__tests__/recipient-paste.test.tsx
@@ -129,6 +129,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
diff --git a/components/email/__tests__/reply-addressing.test.tsx b/components/email/__tests__/reply-addressing.test.tsx
index bec2f195b..86330916b 100644
--- a/components/email/__tests__/reply-addressing.test.tsx
+++ b/components/email/__tests__/reply-addressing.test.tsx
@@ -158,6 +158,14 @@ vi.mock('@/stores/toast-store', () => ({
}));
vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
diff --git a/components/email/__tests__/viewer-plugin-panel-width.test.tsx b/components/email/__tests__/viewer-plugin-panel-width.test.tsx
new file mode 100644
index 000000000..a16703c47
--- /dev/null
+++ b/components/email/__tests__/viewer-plugin-panel-width.test.tsx
@@ -0,0 +1,78 @@
+import { render, screen, act, fireEvent } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import React from 'react';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+// With a plugin panel open at 280 px next to a 300 px message (three panes
+// on a laptop), the subject broke after every few letters. The panel now
+// starts collapsed when it would leave the message under 480 px.
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+}));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/components/plugins/plugin-slot', () => ({
+ PluginSlot: ({ name }: { name: string }) => React.createElement('div', { 'data-testid': `slot-${name}` }),
+}));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({
+ usePluginSlotOffers: (slot: string) => (slot === 'email-detail-sidebar' ? [{ pluginId: 'notes', order: 100 }] : []),
+}));
+
+let reportWidth = 0;
+const observers: Array<(entries: Array<{ contentRect: { width: number } }>) => void> = [];
+class FakeResizeObserver {
+ constructor(private cb: (entries: Array<{ contentRect: { width: number } }>) => void) { observers.push(cb); }
+ observe() { this.cb([{ contentRect: { width: reportWidth } }]); }
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', FakeResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+const email = {
+ id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Alice', email: 'alice@example.com' }], to: [], subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z',
+ preview: '', hasAttachment: false, size: 1, textBody: [], htmlBody: [], attachments: [], bodyValues: {},
+} as unknown as Email;
+
+function renderViewer() {
+ return render( );
+}
+
+beforeEach(() => {
+ localStorage.clear();
+ observers.length = 0;
+});
+
+describe('plugin detail panel and narrow messages', () => {
+ it('starts collapsed when it would leave the message too narrow', () => {
+ reportWidth = 640;
+ renderViewer();
+
+ expect(screen.queryByTestId('slot-email-detail-sidebar')).not.toBeInTheDocument();
+ expect(screen.getByLabelText('plugin_panel_show')).toBeInTheDocument();
+ });
+
+ it('opens anyway when the user asks', () => {
+ reportWidth = 640;
+ renderViewer();
+
+ act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); });
+
+ expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
+ });
+
+ it('is open when there is room', () => {
+ reportWidth = 1100;
+ renderViewer();
+
+ expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
+ });
+});
diff --git a/components/email/__tests__/viewer-plugin-slot-remount.test.tsx b/components/email/__tests__/viewer-plugin-slot-remount.test.tsx
new file mode 100644
index 000000000..fbd5c96d9
--- /dev/null
+++ b/components/email/__tests__/viewer-plugin-slot-remount.test.tsx
@@ -0,0 +1,73 @@
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { describe, it, expect, vi } from 'vitest';
+import React, { useEffect } from 'react';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+// The "more details" panel defined its plugin slot component inside render,
+// so every re-render of the viewer was a new component type: React unmounted
+// and remounted the plugin's iframe each time (and re-ran its shouldShow).
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+}));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+
+const mounts: Record = {};
+const propsSeen: Record = {};
+vi.mock('@/components/plugins/plugin-slot', () => ({
+ PluginSlot: ({ name, extraProps }: { name: string; extraProps?: unknown }) => {
+ (propsSeen[name] ??= []).push(extraProps);
+ useEffect(() => {
+ mounts[name] = (mounts[name] ?? 0) + 1;
+ // Runs once per mount on purpose: that is what the test counts.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return React.createElement('div', { 'data-slot': name });
+ },
+}));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({
+ usePluginSlotOffers: (slot: string) =>
+ slot === 'email-details-section' || slot === 'email-banner' ? [{ pluginId: 'p', order: 100 }] : [],
+}));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+const email = {
+ id: 'e1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
+ subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
+ textBody: [], htmlBody: [], attachments: [], bodyValues: {},
+} as unknown as Email;
+
+describe('plugin slots in the message viewer', () => {
+ it('keep their iframes and props across re-renders', () => {
+ const view = (extra: Record) => (
+
+ );
+ const { rerender } = render(view({ isLoading: false }));
+ act(() => { fireEvent.click(screen.getAllByText('show_details')[0]); });
+ const detailMounts = mounts['email-details-section'];
+ const bannerProps = propsSeen['email-banner'].at(-1);
+ expect(detailMounts).toBeGreaterThan(0);
+
+ rerender(view({ isLoading: false, onReply: () => {} }));
+ rerender(view({ isLoading: false, onReply: () => {}, onForward: () => {} }));
+
+ expect(mounts['email-details-section']).toBe(detailMounts);
+ expect(propsSeen['email-banner'].at(-1)).toBe(bannerProps);
+ });
+});
diff --git a/components/email/__tests__/viewer-read-hooks.test.tsx b/components/email/__tests__/viewer-read-hooks.test.tsx
new file mode 100644
index 000000000..6437c4f4e
--- /dev/null
+++ b/components/email/__tests__/viewer-read-hooks.test.tsx
@@ -0,0 +1,94 @@
+import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { emailHooks, clearAllHooks } from '@/lib/plugin-hooks';
+
+// mail-read hooks fired by the reading pane: onEmailContentRender once the
+// body is on screen and onThreadExpand for the conversation cards. (The link
+// hook runs inside the body iframe, which jsdom doesn't load; its decision is
+// covered in lib/__tests__/plugin-hook-result.test.ts.)
+
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+}));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+
+class NoopResizeObserver {
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+}
+vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+
+const { EmailViewer } = await import('../email-viewer');
+const { ThreadConversationView } = await import('../thread-conversation-view');
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
+] as unknown as Mailbox[];
+
+const textEmail = (id = 'e1') => ({
+ id, blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Alice', email: 'alice@example.com' }], to: [{ name: 'Bob', email: 'bob@example.com' }],
+ subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
+ textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
+ bodyValues: { '1': { value: 'Hello there' } },
+}) as unknown as Email;
+
+const viewer = (email: Email, extra: Record = {}) => (
+
+);
+
+beforeEach(() => clearAllHooks());
+
+describe('onEmailContentRender', () => {
+ it('fires once the body is on screen, once per message', async () => {
+ const handler = vi.fn();
+ emailHooks.onEmailContentRender.register('test', handler);
+
+ const { rerender } = render(viewer(textEmail(), { isLoading: true }));
+ await act(async () => {});
+ expect(handler).not.toHaveBeenCalled();
+
+ rerender(viewer(textEmail(), { isLoading: false }));
+ await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
+ expect(handler).toHaveBeenCalledWith({ emailId: 'e1', hasHtml: false, hasExternalContent: false });
+
+ rerender(viewer(textEmail(), { isLoading: false, onReply: () => {} }));
+ await act(async () => {});
+ expect(handler).toHaveBeenCalledTimes(1);
+
+ rerender(viewer(textEmail('e2'), { isLoading: false }));
+ await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
+ expect(handler).toHaveBeenLastCalledWith({ emailId: 'e2', hasHtml: false, hasExternalContent: false });
+ });
+});
+
+describe('onThreadExpand in the conversation view', () => {
+ it('reports a card being unfolded and folded', () => {
+ const handler = vi.fn();
+ emailHooks.onThreadExpand.register('test', handler);
+ const older = { ...textEmail('old'), keywords: { $seen: true }, receivedAt: '2025-12-01T00:00:00Z' } as Email;
+ const newer = textEmail('new');
+
+ render(
+ {}}
+ />,
+ );
+ // The newest card starts unfolded, the older read one folded.
+ const olderHeader = screen.getAllByText('Alice').at(-1)!;
+ fireEvent.click(olderHeader);
+ expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: true });
+ fireEvent.click(screen.getAllByText('Alice').at(-1)!);
+ expect(handler).toHaveBeenLastCalledWith({ threadId: 't1', emailIds: ['old'], expanded: false });
+ expect(handler).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx
new file mode 100644
index 000000000..c2fa7860a
--- /dev/null
+++ b/components/email/composer-plugin-sidebars.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+// The composer-sidebar and composer-sidebar-right plugin panels. Each gets a
+// fixed width and a toggle to fold it to a thin strip. When the composer is
+// too narrow for an open panel next to it, the panel folds by itself (right
+// first); opening it anyway lays it over the composer instead of squeezing
+// the message into a column nobody can write in.
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
+import { cn } from "@/lib/utils";
+
+/** Width of an open panel. */
+export const COMPOSER_SIDEBAR_WIDTH = 260;
+/** Width of a folded panel (the strip with its toggle). */
+export const COMPOSER_SIDEBAR_STRIP = 37;
+/**
+ * Narrowest the composer itself may get next to open panels: below this its
+ * bottom bar (Attach, Templates, Options, Discard, Send) runs into itself.
+ */
+export const MIN_COMPOSER_WIDTH = 560;
+
+export type ComposerSidebarSide = "left" | "right";
+/** docked: open beside the composer; folded: a strip; overlay: open over the composer. */
+export type ComposerSidebarMode = "docked" | "folded" | "overlay";
+
+interface SideState {
+ present: boolean;
+ /** Folded by the user. */
+ collapsed: boolean;
+ /** Opened by the user while there was no room for it. */
+ overlayOpen?: boolean;
+}
+
+/**
+ * How each panel is shown in a composer `width` px wide (0 = not measured
+ * yet: everything the user left open is shown open). The left panel gets
+ * room first, since the right one is folded first.
+ */
+export function composerSidebarModes(
+ width: number,
+ left: SideState,
+ right: SideState,
+): Record {
+ const present = (left.present ? 1 : 0) + (right.present ? 1 : 0);
+ let room = width > 0 ? width - MIN_COMPOSER_WIDTH - present * COMPOSER_SIDEBAR_STRIP : Infinity;
+ const place = (side: SideState): ComposerSidebarMode | null => {
+ if (!side.present) return null;
+ if (side.collapsed) return "folded";
+ const extra = COMPOSER_SIDEBAR_WIDTH - COMPOSER_SIDEBAR_STRIP;
+ if (extra <= room) {
+ room -= extra;
+ return "docked";
+ }
+ return side.overlayOpen ? "overlay" : "folded";
+ };
+ const l = place(left);
+ return { left: l, right: place(right) };
+}
+
+const storageKey = (side: ComposerSidebarSide) => `composerPluginSidebarCollapsed:${side}`;
+
+function readCollapsed(side: ComposerSidebarSide): boolean {
+ if (typeof window === "undefined") return false;
+ try { return localStorage.getItem(storageKey(side)) === "1"; } catch { return false; }
+}
+
+/** Layout state of both panels for a composer whose root element is `root`. */
+export function useComposerSidebars(root: HTMLElement | null) {
+ const hasLeft = usePluginSlotOffers("composer-sidebar").length > 0;
+ const hasRight = usePluginSlotOffers("composer-sidebar-right").length > 0;
+ const [width, setWidth] = useState(0);
+ const [collapsed, setCollapsed] = useState>(() => ({
+ left: readCollapsed("left"),
+ right: readCollapsed("right"),
+ }));
+ const [overlayOpen, setOverlayOpen] = useState>({ left: false, right: false });
+
+ useEffect(() => {
+ if (!root || (!hasLeft && !hasRight) || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver((entries) => {
+ setWidth(Math.round(entries[0]?.contentRect.width ?? root.getBoundingClientRect().width));
+ });
+ observer.observe(root);
+ return () => observer.disconnect();
+ }, [root, hasLeft, hasRight]);
+
+ const modes = composerSidebarModes(
+ width,
+ { present: hasLeft, collapsed: collapsed.left, overlayOpen: overlayOpen.left },
+ { present: hasRight, collapsed: collapsed.right, overlayOpen: overlayOpen.right },
+ );
+
+ const setSideCollapsed = (side: ComposerSidebarSide, value: boolean) => {
+ setCollapsed((prev) => ({ ...prev, [side]: value }));
+ try { localStorage.setItem(storageKey(side), value ? "1" : "0"); } catch { /* storage blocked */ }
+ };
+
+ /** Open a folded panel: beside the composer when it fits, over it otherwise. */
+ const open = (side: ComposerSidebarSide) => {
+ const next = { ...collapsed, [side]: false };
+ const fits = composerSidebarModes(
+ width,
+ { present: hasLeft, collapsed: next.left },
+ { present: hasRight, collapsed: next.right },
+ )[side] === "docked";
+ setSideCollapsed(side, false);
+ setOverlayOpen((prev) => ({ ...prev, [side]: !fits }));
+ };
+ const close = (side: ComposerSidebarSide) => {
+ setSideCollapsed(side, true);
+ setOverlayOpen((prev) => ({ ...prev, [side]: false }));
+ };
+
+ return { modes, open, close };
+}
+
+/** One plugin panel of the composer, in the mode useComposerSidebars picked. */
+export function ComposerPluginSidebar({
+ side,
+ mode,
+ onOpen,
+ onClose,
+}: {
+ side: ComposerSidebarSide;
+ mode: ComposerSidebarMode | null;
+ onOpen: () => void;
+ onClose: () => void;
+}) {
+ const t = useTranslations("email_viewer");
+ if (!mode) return null;
+ const border = side === "left" ? "border-e" : "border-s";
+ const OpenIcon = side === "left" ? PanelLeftOpen : PanelRightOpen;
+ const CloseIcon = side === "left" ? PanelLeftClose : PanelRightClose;
+
+ if (mode === "folded") {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ {side === "left"
+ ?
+ :
}
+
+
+ );
+}
diff --git a/components/email/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx
index bba4b502c..eff707bc4 100644
--- a/components/email/composer-toolbar-menu.tsx
+++ b/components/email/composer-toolbar-menu.tsx
@@ -2,7 +2,9 @@
import { useEffect, useId, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
-import { Check, ChevronDown, type AppIcon } from "@/components/icons";
+import { Check, ChevronDown, iconForName, Puzzle, type AppIcon } from "@/components/icons";
+import { usePluginActions } from "@/components/plugins/plugin-actions";
+import { runAction } from "@/lib/plugin-sandbox/actions";
import { cn } from "@/lib/utils";
interface ComposerToolbarMenuProps {
@@ -138,3 +140,111 @@ export function ComposerToolbarMenuCheckItem({ checked, onToggle, description, t
);
}
+
+interface PluginItemsProps {
+ /** A surface of kind 'action' in lib/plugin-slot-catalog.ts. */
+ surface: string;
+ /** What the click is about; a function is read at click time. */
+ context: Record | (() => Record);
+ /** Called before the plugin gets the click, e.g. to close the menu. */
+ onSelect?: () => void;
+ /** Draw a separator before the entries (when the menu has its own above). */
+ separator?: boolean;
+ /** Only while the composer bar is narrower than 44rem (see ComposerPluginToolbarButtons). */
+ onlyWhenNarrow?: boolean;
+}
+
+/**
+ * Buttons plugins declared for the composer's bottom bar
+ * (composer-toolbar-actions), drawn like Attach and Templates. An action's
+ * shortcut is shown in its tooltip; the composer binds it.
+ *
+ * The bar is a container (`@container/composer-bar`). Below 44rem there is
+ * no room next to the app's own buttons, Discard and Send, so the buttons
+ * are hidden and the same actions are listed in the Options menu instead
+ * (ComposerPluginMenuItems with `onlyWhenNarrow`).
+ */
+export function ComposerPluginToolbarButtons({ surface, context }: { surface: "composer-toolbar-actions"; context: () => Record }) {
+ const actions = usePluginActions(surface);
+ if (actions.length === 0) return null;
+ return (
+ <>
+ {actions.map((action) => {
+ const Icon = iconForName(action.icon) ?? Puzzle;
+ const tip = action.shortcut ? `${action.label} (${action.shortcut})` : action.label;
+ return (
+ { void runAction(action, surface, context()); }}
+ >
+
+ {action.label}
+
+ );
+ })}
+ >
+ );
+}
+
+/** Shows its children only while the composer bar is narrower than 44rem. */
+function NarrowOnly({ active, children }: { active: boolean; children: React.ReactNode }) {
+ if (!active) return <>{children}>;
+ return {children}
;
+}
+
+/** Entries plugins declared for a composer menu, drawn like the menu's own. */
+export function ComposerPluginMenuItems({ surface, context, onSelect, separator, onlyWhenNarrow }: PluginItemsProps) {
+ const actions = usePluginActions(surface);
+ if (actions.length === 0) return null;
+ return (
+
+ {separator &&
}
+ {actions.map((action) => {
+ const Icon = iconForName(action.icon) ?? Puzzle;
+ if (action.checked !== undefined) {
+ // An option like the menu's own (plain text, read receipt): stays
+ // open, so several can be set in one go.
+ return (
+ { void runAction(action, surface, typeof context === "function" ? context() : context); }}
+ >
+ {action.label}
+
+ );
+ }
+ return (
+ {
+ onSelect?.();
+ void runAction(action, surface, typeof context === "function" ? context() : context);
+ }}
+ className={cn(
+ "flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground",
+ action.danger && "text-destructive",
+ )}
+ >
+
+
+ {action.label}
+ {action.description && {action.description} }
+
+
+ );
+ })}
+
+ );
+}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index fe3f3160d..5cff52511 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -34,7 +34,11 @@ import {
Reply,
Forward,
} from "@/components/icons";
-import { ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
+import { ComposerPluginMenuItems, ComposerPluginToolbarButtons, ComposerToolbarMenu, ComposerToolbarMenuItem, ComposerToolbarMenuCheckItem } from "@/components/email/composer-toolbar-menu";
+import { registerComposer } from "@/lib/plugin-sandbox/composer-registry";
+import { plainOwnRange, readOwnContent, readSelection as readEditorSelection, replaceOwnContent, replaceSelection as replaceEditorSelection, replaceTextareaRange, textToEditorHtml, type OwnTextOptions } from "@/lib/plugin-sandbox/composer-edit";
+import { actionsFor, runAction } from "@/lib/plugin-sandbox/actions";
+import { matchesCombo } from "@/lib/plugin-sandbox/shortcuts";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { schedulePresets } from "@/lib/schedule-presets";
import { debug } from "@/lib/debug";
@@ -61,14 +65,19 @@ import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store";
import { useSettingsStore } from "@/stores/settings-store";
+import { ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars";
import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
+import { usePluginActions } from "@/components/plugins/plugin-actions";
import { Avatar } from "@/components/ui/avatar";
import { FilePreviewModal } from "@/components/files/file-preview-modal";
import { FilePickerDialog } from "@/components/files/file-picker-dialog";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNode } from "@/lib/jmap/types";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
-import { useTemplateStore } from "@/stores/template-store";
+import { useTemplateStore, renderTemplateWithPlugins, notifyTemplateApplied } from "@/stores/template-store";
+import { notifyIdentitySelected } from "@/stores/identity-store";
+import { usePluginSignature } from "@/hooks/use-plugin-signature";
import { SubAddressHelper } from "@/components/identity/sub-address-helper";
import { generateSubAddress } from "@/lib/sub-addressing";
import { substitutePlaceholders, spliceTemplateAboveSignature, composeBodyHasUserContent } from "@/lib/template-utils";
@@ -98,7 +107,10 @@ import {
type Recipient,
enrichChipsWithColorsAndIcons,
ICON_MAP,
+ plainTextToComposerBody,
} from "@/lib/email-composer-utils";
+import type { ComposePrefill } from "@/lib/plugin-compose-gate";
+import { composerContextMode, composerMounted, composerUnmounted, draftSaved } from "@/lib/plugin-composer-hooks";
import { isValidEmail } from "@/lib/validation";
import { buildMentionCandidates } from "@/lib/recipient-mentions";
import { RichTextEditor } from "@/components/email/rich-text-editor";
@@ -235,6 +247,20 @@ interface EmailComposerProps {
className?: string;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
+ /**
+ * Fields a plugin filled in through onBeforeCompose, over what the composer
+ * would start with. The host passes it for the first mount of a session
+ * only: a remount restores the user's own text from `initialData`.
+ */
+ prefill?: ComposePrefill | null;
+ /**
+ * Identifies the compose session across remounts (a pop-out, a layout
+ * switch), so plugins hear onComposerOpen / onComposerClose once per
+ * message rather than once per mount.
+ */
+ sessionKey?: string;
+ /** Id of the message being replied to or forwarded, for onComposerOpen. */
+ sourceEmailId?: string;
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
/**
* Email of the mailbox/account the user is viewing when they start a new
@@ -323,6 +349,28 @@ function getDefaultScheduleValue(): string {
// the body instead (#1114).
const PINNED_FIELDS_MIN_BODY_HEIGHT = 200;
+/**
+ * An attachment a plugin uploaded itself, as the composer stores it, or null
+ * when the plugin sent something that isn't one.
+ */
+export function normalizePluginAttachment(raw: unknown): { name: string; type: string; size: number; blobId: string } | null {
+ if (!raw || typeof raw !== 'object') return null;
+ const r = raw as Record;
+ if (typeof r.blobId !== 'string' || !/^[A-Za-z0-9_-]{1,255}$/.test(r.blobId)) return null;
+ if (typeof r.size !== 'number' || !Number.isFinite(r.size) || r.size < 0) return null;
+ // eslint-disable-next-line no-control-regex -- removing control characters is the point
+ const name = typeof r.name === 'string' ? r.name.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255) : '';
+ if (!name) return null;
+ const type = typeof r.type === 'string' && /^[\w.+-]+\/[\w.+-]+$/.test(r.type) ? r.type : 'application/octet-stream';
+ return { name, type, size: Math.floor(r.size), blobId: r.blobId };
+}
+
+/** How long a plugin may take over onBeforeDraftAutoSave before the save goes on without it. */
+const DRAFT_HOOK_TIMEOUT_MS = 5000;
+
+/** Longest text a plugin may insert into the message at once. */
+const MAX_PLUGIN_INSERT_LENGTH = 100_000;
+
export function EmailComposer({
onSend,
onScheduledSendCreated,
@@ -331,8 +379,11 @@ export function EmailComposer({
onDiscardDraft,
onSaveState,
className,
- initialDraftText,
- initialData,
+ initialDraftText: initialDraftTextProp,
+ initialData: initialDataProp,
+ prefill,
+ sessionKey,
+ sourceEmailId,
mode = 'compose',
composeFromAccountEmail,
replyTo
@@ -340,14 +391,34 @@ export function EmailComposer({
const t = useTranslations('email_composer');
const tCommon = useTranslations('common');
const tQuote = useTranslations('quote_header');
+ const tViewer = useTranslations('email_viewer');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode);
// Per-message format (#1022). Seeded from the "plain text only" setting -
// or the format a re-opened / stashed draft was written in - and switchable
// from the toolbar for just this message.
const [plainTextMode, setPlainTextMode] = useState(
- () => initialData?.plainTextMode ?? plainTextModeDefault
+ () => initialDataProp?.plainTextMode ?? plainTextModeDefault
);
+ // A plugin's onBeforeCompose prefill (plain text body) folded into what the
+ // composer starts from: over `initialData` when the host hands one in (a
+ // mailto:, a re-opened draft), otherwise over the fields built below.
+ const [initialPlainTextMode] = useState(plainTextMode);
+ const initialData = useMemo(() => {
+ if (!initialDataProp || !prefill) return initialDataProp;
+ const next = { ...initialDataProp };
+ if (prefill.to) next.to = prefill.to.join(', ');
+ if (prefill.cc) {
+ next.cc = prefill.cc.join(', ');
+ if (prefill.cc.length > 0) next.showCc = true;
+ }
+ if (prefill.subject !== undefined) next.subject = prefill.subject;
+ if (prefill.body !== undefined) {
+ next.body = initialPlainTextMode ? prefill.body : plainTextToComposerBody(prefill.body);
+ }
+ return next;
+ }, [initialDataProp, prefill, initialPlainTextMode]);
+ const initialDraftText = !initialDataProp && prefill?.body !== undefined ? prefill.body : initialDraftTextProp;
const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter);
const autoSelectReplyIdentity = useSettingsStore((state) => state.autoSelectReplyIdentity);
const replyIdentityMatch = useSettingsStore((state) => state.replyIdentityMatch);
@@ -626,17 +697,22 @@ export function EmailComposer({
// is typing lives in a separate `*Input` string per field. This keeps a
// display name containing a comma (e.g. "Doo, John") intact instead of
// tearing it apart on a delimiter.
- const [to, setTo] = useState(initialData ? parseRecipientList(initialData.to) : getInitialTo());
- const [cc, setCc] = useState(initialData ? parseRecipientList(initialData.cc) : getInitialCc());
+ const [to, setTo] = useState(() => initialData
+ ? parseRecipientList(initialData.to)
+ : prefill?.to ? parseRecipientList(prefill.to.join(', ')) : getInitialTo());
+ const [cc, setCc] = useState(() => initialData
+ ? parseRecipientList(initialData.cc)
+ : prefill?.cc ? parseRecipientList(prefill.cc.join(', ')) : getInitialCc());
const [bcc, setBcc] = useState(initialData ? parseRecipientList(initialData.bcc) : []);
const [toInput, setToInput] = useState('');
const [ccInput, setCcInput] = useState('');
const [bccInput, setBccInput] = useState('');
- const [subject, setSubject] = useState(initialData?.subject ?? getInitialSubject());
+ const [subject, setSubject] = useState(() => initialData?.subject ?? prefill?.subject ?? getInitialSubject());
const [body, setBody] = useState(() =>
initialData?.body != null ? withSignatureEmbedded(initialData.body) : getInitialBody()
);
- const [showCc, setShowCc] = useState(initialData?.showCc ?? getInitialCc().length > 0);
+ const [showCc, setShowCc] = useState(() => initialData?.showCc
+ ?? (prefill?.cc ? prefill.cc.length > 0 : getInitialCc().length > 0));
const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false);
// Committed recipients plus any not-yet-committed text the user has typed.
// Send/validation/draft paths treat a typed-but-uncommitted address as a
@@ -869,9 +945,11 @@ export function EmailComposer({
// Alias identities often lack a configured signature - fall back to the primary
// identity's signature so replies (which auto-select a matching alias) still
// populate the user's signature.
- const signatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
+ const ownSignatureIdentity = (currentIdentity?.htmlSignature || currentIdentity?.textSignature)
? currentIdentity
: primaryIdentity;
+ // Plugins may rewrite the signature (onSignatureRender).
+ const { identity: signatureIdentity, revision: pluginSignatureRevision } = usePluginSignature(ownSignatureIdentity);
// Hold the TipTap editor instance so we can swap the embedded signature
// when the user switches identity in "above quote" mode without rebuilding
@@ -882,15 +960,18 @@ export function EmailComposer({
const replyFocusedRef = useRef(false);
const prevSignatureIdentityIdRef = useRef(signatureIdentity?.id);
const prevSignatureSeparatorRef = useRef(signatureSeparatorEnabled);
+ const prevPluginSignatureRevisionRef = useRef(pluginSignatureRevision);
useEffect(() => {
const editor = editorRef.current;
const identityChanged = prevSignatureIdentityIdRef.current !== signatureIdentity?.id;
const separatorChanged = prevSignatureSeparatorRef.current !== signatureSeparatorEnabled;
+ const pluginSignatureChanged = prevPluginSignatureRevisionRef.current !== pluginSignatureRevision;
prevSignatureIdentityIdRef.current = signatureIdentity?.id;
prevSignatureSeparatorRef.current = signatureSeparatorEnabled;
+ prevPluginSignatureRevisionRef.current = pluginSignatureRevision;
if (!editor) return;
- if (!identityChanged && !separatorChanged) return;
+ if (!identityChanged && !separatorChanged && !pluginSignatureChanged) return;
if (plainTextMode) return;
// Replies/forwards only embed when configured for "above quote". Compose
// always embeds (see getInitialBody), so swap on identity change there too.
@@ -928,7 +1009,7 @@ export function EmailComposer({
// would re-run on unrelated identity-field changes and re-splice the
// signature into the live editor.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode]);
+ }, [signatureIdentity?.id, signatureIdentity?.htmlSignature, signatureIdentity?.textSignature, signatureSeparatorEnabled, signaturePosition, mode, plainTextMode, pluginSignatureRevision]);
useEffect(() => {
const handleClickOutsideSendMenu = (event: MouseEvent) => {
@@ -1282,6 +1363,31 @@ export function EmailComposer({
// open the next composer with the discarded reply's mode/replyTo.
const explicitCloseRef = useRef(false);
+ // onComposerOpen / onComposerClose, once per session (see
+ // lib/plugin-composer-hooks.ts). Without a key from the host every mount is
+ // its own session.
+ const fallbackSessionKeyRef = useRef(null);
+ if (!sessionKey && fallbackSessionKeyRef.current === null) {
+ fallbackSessionKeyRef.current = `composer-${Math.random().toString(36).slice(2)}`;
+ }
+ const effectiveSessionKey = sessionKey ?? fallbackSessionKeyRef.current ?? 'composer';
+ // Whether this session ended in a send, and the draft it ended with.
+ const sentRef = useRef(false);
+ const lastDraftIdRef = useRef(initialData?.draftId ?? null);
+ useEffect(() => {
+ composerMounted(effectiveSessionKey, {
+ mode: composerContextMode(mode),
+ ...(sourceEmailId && mode !== 'compose' ? { inReplyToId: sourceEmailId } : {}),
+ ...(replyTo?.subject && mode !== 'compose' ? { originalSubject: replyTo.subject } : {}),
+ });
+ return () => composerUnmounted(effectiveSessionKey, () => ({
+ draftId: draftIdRef.current ?? lastDraftIdRef.current,
+ sent: sentRef.current,
+ }));
+ // The session is fixed for the life of this mount.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [effectiveSessionKey]);
+
// Auto-save state on unmount (when user navigates away without explicitly closing)
useEffect(() => {
return () => {
@@ -1563,8 +1669,8 @@ export function EmailComposer({
}
};
- const handleTemplateSelect = useCallback((template: EmailTemplate, filledValues: Record) => {
- const filledSubject = Object.keys(filledValues).length > 0
+ const handleTemplateSelect = useCallback(async (template: EmailTemplate, filledValues: Record) => {
+ let filledSubject = Object.keys(filledValues).length > 0
? substitutePlaceholders(template.subject, filledValues)
: template.subject;
const filledBody = Object.keys(filledValues).length > 0
@@ -1572,9 +1678,19 @@ export function EmailComposer({
: template.body;
// In plain text mode, use template body as-is; otherwise convert to HTML
- const bodyContent = plainTextMode || template.isHTML
+ let bodyContent = plainTextMode || template.isHTML
? filledBody
: `${filledBody.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, ' ')}
`;
+ // Plugins may rewrite the template before it goes in (onTemplateRender).
+ const rendered = await renderTemplateWithPlugins(template.id, {
+ subject: filledSubject,
+ html: plainTextMode && !template.isHTML ? `${escapeHtml(filledBody).replace(/\n/g, ' ')}
` : bodyContent,
+ text: template.isHTML ? htmlToPlainText(filledBody) : filledBody,
+ });
+ if (rendered) {
+ filledSubject = rendered.subject;
+ bodyContent = plainTextMode ? rendered.text : sanitizePluginBodyHtml(rendered.html);
+ }
if (mode === 'compose') {
// An empty template subject must not wipe one the user typed (#540).
@@ -1659,6 +1775,7 @@ export function EmailComposer({
}
setShowTemplatePicker(false);
+ notifyTemplateApplied(template);
}, [mode, plainTextMode, signatureIdentity, signatureSeparatorEnabled]);
useEffect(() => {
@@ -1935,17 +2052,42 @@ export function EmailComposer({
// offers "attach from there". No `file` is set, matching the existing
// draft-part hydration path above (a blobId-only attachment is already a
// supported shape, just never previously reachable from a plugin).
- const handlePluginAttachmentAdd = useCallback((upload: PluginAttachmentUpload) => {
- setAttachments(prev => [
- ...prev,
- {
- name: upload.name,
- type: upload.type,
- size: upload.size,
- blobId: upload.blobId,
- },
- ]);
- }, []);
+ const handlePluginAttachmentAdd = useCallback(async (raw: PluginAttachmentUpload): Promise => {
+ // The values come from a plugin: check them, and run the same intercept
+ // and size limit every other way of attaching a file goes through.
+ const upload = normalizePluginAttachment(raw);
+ if (!upload) {
+ debug.warn('email', 'Refused a malformed attachment from a plugin', raw);
+ return false;
+ }
+ const ok = await emailHooks.onBeforeAttachmentUpload.intercept({
+ name: upload.name, type: upload.type, size: upload.size,
+ });
+ if (!ok) return false;
+ const maxTotal = composerClient?.getMaxSizeAttachmentsPerEmail?.() ?? 0;
+ const total = attachmentsRef.current.reduce((sum, att) => sum + (att.size || 0), 0);
+ if (maxTotal > 0 && total + upload.size > maxTotal) {
+ toast.error(t('attachments_total_too_large', { max: formatFileSize(maxTotal) }));
+ return false;
+ }
+ setAttachments(prev => [...prev, upload]);
+ return true;
+ }, [composerClient, t]);
+ // Stable props, so a re-render of the composer doesn't post them to the
+ // plugin's iframe again.
+ const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
+ // The plugin side panels fold when the composer gets too narrow for them.
+ const [composerRootEl, setComposerRootEl] = useState(null);
+ const pluginSidebars = useComposerSidebars(composerRootEl);
+ const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source');
+ const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0;
+ const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0;
+ // Plugin entries make the Send and Attach buttons menus even where the app
+ // itself has nothing else to put there.
+ const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
+ const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0;
+ // Ticked plugin options count in the Options button's badge like the app's own.
+ const pluginOptionsOn = usePluginActions('composer-options-menu').filter((a) => a.checked).length;
// Files from the Files app are already blobs on the server, so they are
// attached by blobId with no download or upload (#1179). Stalwart resolves a
@@ -2030,7 +2172,10 @@ export function EmailComposer({
}, [previewAttachment, composerClient]);
// Auto-save draft functionality
- const saveDraftOnce = async (): Promise => {
+ // `auto`: the debounced save while typing. Only that one may be skipped
+ // by a plugin; saves on close and before send always run, or the text
+ // would be lost.
+ const saveDraftOnce = async (auto = false): Promise => {
if (!client || !composerClient) return null;
const versionAtStart = changeVersionRef.current;
@@ -2133,7 +2278,19 @@ export function EmailComposer({
fromName,
htmlBody: draftHtmlBody
}
- savedDraft = await emailHooks.onBeforeDraftAutoSave.transform(savedDraft);
+ if (emailHooks.onBeforeDraftAutoSave.size > 0) {
+ const outcome = await emailHooks.onBeforeDraftAutoSave.interceptWithValue(
+ savedDraft,
+ (result) => (result && typeof result === 'object' ? result as typeof savedDraft : undefined),
+ { timeoutMs: DRAFT_HOOK_TIMEOUT_MS },
+ );
+ if (!outcome.allowed && auto) {
+ // Skipped: leave lastSavedDataRef alone so the next save retries.
+ setSaveStatus('idle');
+ return draftIdRef.current;
+ }
+ if (outcome.allowed) savedDraft = outcome.value;
+ }
// Use the JMAP client and raw identity id for the *owning* account
// - falls back to active client for single-account / same-account
@@ -2196,6 +2353,8 @@ export function EmailComposer({
setSaveStatus('saved');
saveFailedRef.current = false;
+ lastDraftIdRef.current = savedDraftId;
+ draftSaved(savedDraftId, savedDraft.subject);
// Reset status after 2 seconds
setTimeout(() => setSaveStatus('idle'), 2000);
@@ -2218,13 +2377,13 @@ export function EmailComposer({
// racing two `Email/set { destroy, create }` requests against the same
// draftId, which left orphan drafts and (when EmailSubmission failed)
// looked like "send didn't happen" (#303).
- const saveDraft = (): Promise => {
+ const saveDraft = (opts: { auto?: boolean } = {}): Promise => {
const previous = inflightSaveRef.current;
const promise = (async (): Promise => {
if (previous) {
try { await previous; } catch { /* prior failure already reported */ }
}
- return saveDraftOnce();
+ return saveDraftOnce(!!opts.auto);
})();
inflightSaveRef.current = promise;
promise.finally(() => {
@@ -2238,6 +2397,162 @@ export function EmailComposer({
// Keep saveDraftRef pointing to latest saveDraft
saveDraftRef.current = saveDraft;
+ // The draft as plugins see it: onDraftChange, the frames above and below
+ // the body, and clicks on plugin entries in the Send menu.
+ const draftSnapshot = () => ({
+ to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
+ cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
+ bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
+ subject,
+ htmlBody: plainTextMode ? '' : body,
+ textBody: plainTextMode ? body : htmlToPlainText(body),
+ identityId: selectedIdentityId || '',
+ attachments: attachments
+ .filter(a => a.blobId && !a.uploading && !a.error)
+ .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
+ });
+ const draftSnapshotRef = useRef(draftSnapshot);
+ draftSnapshotRef.current = draftSnapshot;
+
+ // composer-above-body / composer-below-body get the draft when they appear
+ // and then 2 seconds after the last change, like onDraftChange: a new prop
+ // object on every keystroke would post the whole body into their iframes.
+ const wantsPluginDraft = hasAboveBodyOffers || hasBelowBodyOffers;
+ const [pluginDraft, setPluginDraft] = useState | null>(null);
+ const pluginDraftShownRef = useRef(false);
+ useEffect(() => {
+ if (!wantsPluginDraft) {
+ pluginDraftShownRef.current = false;
+ return;
+ }
+ if (!pluginDraftShownRef.current) {
+ pluginDraftShownRef.current = true;
+ setPluginDraft(draftSnapshotRef.current());
+ return;
+ }
+ const timer = setTimeout(() => setPluginDraft(draftSnapshotRef.current()), 2000);
+ return () => clearTimeout(timer);
+ }, [wantsPluginDraft, toStr, ccStr, bccStr, subject, body, plainTextMode, selectedIdentityId, attachments]);
+ // Plugins write into the message through these (slot props, gated by the
+ // catalogue): plain text at the caret, replacing the selection, so a
+ // rewrite of a selected paragraph is one call and Ctrl+Z takes it back in
+ // the rich editor. Stable, so the slot iframes don't get new props.
+ const plainTextModeRef = useRef(plainTextMode);
+ plainTextModeRef.current = plainTextMode;
+ const pluginInsertText = useCallback((value: unknown) => {
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PLUGIN_INSERT_LENGTH) return false;
+ if (plainTextModeRef.current) {
+ const textarea = bodyRef.current;
+ if (!textarea) return false;
+ const start = textarea.selectionStart ?? textarea.value.length;
+ const end = textarea.selectionEnd ?? start;
+ setBody((prev) => prev.slice(0, start) + value + prev.slice(end));
+ requestAnimationFrame(() => {
+ textarea.focus();
+ textarea.setSelectionRange(start + value.length, start + value.length);
+ });
+ return true;
+ }
+ const editor = editorRef.current;
+ if (!editor) return false;
+ const html = value.split(/\r?\n\r?\n/).map((para) => `${escapeHtml(para).replace(/\r?\n/g, ' ')}
`).join('');
+ editor.chain().focus().insertContent(html).run();
+ return true;
+ }, []);
+ const pluginSelectedText = useCallback(() => {
+ if (plainTextModeRef.current) {
+ const textarea = bodyRef.current;
+ if (!textarea) return '';
+ return textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0);
+ }
+ const editor = editorRef.current;
+ if (!editor) return '';
+ const { from, to } = editor.state.selection;
+ return editor.state.doc.textBetween(from, to, '\n\n');
+ }, []);
+ // Plugins read and change this message through api.composer, with the id
+ // this composer hands out in its action clicks and slot props (see
+ // composer-registry.ts). The rich editor is changed only through its own
+ // transactions, so one Ctrl+Z takes an edit back; the user's own text ends
+ // at the signature and the quote (composer-edit.ts).
+ const pluginEditOptsRef = useRef({ signature: '' });
+ pluginEditOptsRef.current = {
+ replyLike: mode === 'reply' || mode === 'replyAll' || mode === 'forward',
+ forwardedSeparator: tQuote('forwarded_separator'),
+ signature: getPlainTextSignature(signatureIdentity),
+ };
+ const pluginAttachRef = useRef(handlePluginAttachmentAdd);
+ pluginAttachRef.current = handlePluginAttachmentAdd;
+ const [pluginComposerId, setPluginComposerId] = useState(null);
+ useEffect(() => {
+ const plainRange = (value: string) => plainOwnRange(value, pluginEditOptsRef.current);
+ const { id, unregister } = registerComposer({
+ readBody: () => {
+ if (plainTextModeRef.current) {
+ const value = bodyRef.current?.value ?? '';
+ const { from, to } = plainRange(value);
+ const text = value.slice(from, to);
+ return { text, html: textToEditorHtml(text) };
+ }
+ const editor = editorRef.current;
+ return editor ? readOwnContent(editor, pluginEditOptsRef.current) : { html: '', text: '' };
+ },
+ readSelection: () => {
+ if (plainTextModeRef.current) {
+ const textarea = bodyRef.current;
+ const text = textarea ? textarea.value.slice(textarea.selectionStart ?? 0, textarea.selectionEnd ?? 0) : '';
+ return { text, html: textToEditorHtml(text), empty: text.length === 0 };
+ }
+ const editor = editorRef.current;
+ return editor ? readEditorSelection(editor) : { html: '', text: '', empty: true };
+ },
+ replaceBody: (html, text) => {
+ if (plainTextModeRef.current) {
+ const textarea = bodyRef.current;
+ if (!textarea) return false;
+ const { from, to } = plainRange(textarea.value);
+ return replaceTextareaRange(textarea, from, to, text);
+ }
+ const editor = editorRef.current;
+ return !!editor && replaceOwnContent(editor, html, pluginEditOptsRef.current);
+ },
+ replaceSelection: (html, text, { below }) => {
+ if (plainTextModeRef.current) {
+ const textarea = bodyRef.current;
+ if (!textarea) return false;
+ const start = textarea.selectionStart ?? textarea.value.length;
+ const end = textarea.selectionEnd ?? start;
+ return below
+ ? replaceTextareaRange(textarea, end, end, `\n\n${text}`)
+ : replaceTextareaRange(textarea, start, end, text);
+ }
+ const editor = editorRef.current;
+ return !!editor && replaceEditorSelection(editor, html, { below });
+ },
+ attach: (file) => pluginAttachRef.current(file as PluginAttachmentUpload),
+ });
+ setPluginComposerId(id);
+ return unregister;
+ }, []);
+
+ const pluginDraftProps = useMemo(
+ () => ({ draft: pluginDraft, insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
+ [pluginDraft, pluginInsertText, pluginSelectedText, pluginComposerId],
+ );
+ const pluginToolbarProps = useMemo(
+ () => ({ insertText: pluginInsertText, getSelectedText: pluginSelectedText, composerId: pluginComposerId }),
+ [pluginInsertText, pluginSelectedText, pluginComposerId],
+ );
+ // A cloud picker attaches files, or puts share links in the text.
+ const pluginAttachmentSourceProps = useMemo(
+ () => ({ onAttach: handlePluginAttachmentAdd, insertText: pluginInsertText, composerId: pluginComposerId }),
+ [handlePluginAttachmentAdd, pluginInsertText, pluginComposerId],
+ );
+ const pluginComposerIdRef = useRef(pluginComposerId);
+ pluginComposerIdRef.current = pluginComposerId;
+ const sendMenuContext = useCallback(() => ({ draft: draftSnapshotRef.current(), composerId: pluginComposerIdRef.current }), []);
+ const composerActionContext = useCallback(() => ({ composerId: pluginComposerIdRef.current }), []);
+
// Trigger auto-save when content changes (only if user modified something)
useEffect(() => {
// Clear existing timeout
@@ -2257,19 +2572,8 @@ export function EmailComposer({
// tracked via inflightSaveRef.
saveTimeoutRef.current = null;
// Plugin observers (AI assist, grammar, …) get a debounced snapshot here.
- emailHooks.onDraftChange.emit({
- to: withInput(to, toInput).map(r => formatRecipient(r.name, r.email)),
- cc: withInput(cc, ccInput).map(r => formatRecipient(r.name, r.email)),
- bcc: withInput(bcc, bccInput).map(r => formatRecipient(r.name, r.email)),
- subject,
- htmlBody: plainTextMode ? '' : body,
- textBody: plainTextMode ? body : htmlToPlainText(body),
- identityId: selectedIdentityId || '',
- attachments: attachments
- .filter(a => a.blobId && !a.uploading && !a.error)
- .map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
- });
- saveDraft();
+ emailHooks.onDraftChange.emit(draftSnapshot());
+ saveDraft({ auto: true });
}, 2000);
// Cleanup on unmount
@@ -2623,11 +2927,14 @@ export function EmailComposer({
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
};
- const sendAllowed = await emailHooks.onBeforeEmailSend.intercept(sendablePreview);
- if (!sendAllowed) {
+ const sendCheck = await emailHooks.onBeforeEmailSend.interceptOutcome(sendablePreview);
+ if (sendCheck !== 'allowed') {
// A plugin vetoed the send (it is expected to show its own UI).
// Leave a trace so a silent no-op send is diagnosable (#592).
- debug.log('email', 'Send aborted by an onBeforeEmailSend plugin handler');
+ debug.log('email', `Send stopped by an onBeforeEmailSend plugin handler (${sendCheck})`);
+ // A guard that crashed or never answered showed nothing; say why
+ // the message is still here.
+ if (sendCheck === 'failed') toast.error(t('send_guard_failed'));
return;
}
@@ -2670,6 +2977,7 @@ export function EmailComposer({
}
const sendHandledByPlugin = pluginOutcome === 'handled';
if (sendHandledByPlugin) {
+ sentRef.current = true;
if (finalDraftId) {
// The draft was created through the identity's owning account
// (see saveDraft), so clean it up there too - not on the active
@@ -2726,6 +3034,9 @@ export function EmailComposer({
// draft ("Continue draft" flashed after every send). Reset below if
// the send fails and the composer stays open.
explicitCloseRef.current = true;
+ // Read when the close is reported: the host tears the composer down
+ // while onSend is still running. Reset below if the send fails.
+ sentRef.current = true;
await onSend?.({
to: outgoing.to,
cc: outgoing.cc,
@@ -2784,6 +3095,7 @@ export function EmailComposer({
} catch (err) {
// Not sent, so the composer stays open and is an unsent draft again.
explicitCloseRef.current = false;
+ sentRef.current = false;
debug.error('Failed to send email:', err);
if (err instanceof RecipientsRejectedError) {
// Nothing went out; the message stays open here so the addresses
@@ -2947,7 +3259,16 @@ export function EmailComposer({
const hasPrimaryModifier = e.ctrlKey || e.metaKey;
const isSendShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && !e.shiftKey;
const isScheduleShortcut = e.key === 'Enter' && hasPrimaryModifier && !e.altKey && e.shiftKey;
- if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) return;
+ if (!isPlainEscape && !isSendShortcut && !isScheduleShortcut) {
+ // A plugin's composer button with a shortcut (composer-toolbar-actions)
+ // works while the composer has focus, also while typing.
+ if (!e.altKey && !hasPrimaryModifier) return;
+ const action = actionsFor('composer-toolbar-actions').find((a) => a.shortcut && matchesCombo(a.shortcut, e.nativeEvent));
+ if (!action) return;
+ e.preventDefault();
+ void runAction(action, 'composer-toolbar-actions', composerActionContext());
+ return;
+ }
if (
showTemplatePicker ||
@@ -2988,14 +3309,12 @@ export function EmailComposer({
return (
-
+
pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} />
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
{/* Mobile: keep scheduled send reachable without consuming footer space. */}
- {composerClient?.hasDelayedSend() ? (
+ {composerClient?.hasDelayedSend() || hasSendMenuActions ? (
handleSend()}
@@ -3139,7 +3458,8 @@ export function EmailComposer({
type="button"
onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend || isSending}
- title={t('schedule_send')}
+ title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
+ aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
size="sm"
className="h-9 rounded-s-none px-2"
aria-haspopup="menu"
@@ -3149,15 +3469,23 @@ export function EmailComposer({
{showSendMenu && (
-
-
- {t('schedule_send')}
-
+ {composerClient?.hasDelayedSend() && (
+
+
+ {t('schedule_send')}
+
+ )}
+ setShowSendMenu(false)}
+ separator={!!composerClient?.hasDelayedSend()}
+ />
)}
@@ -3213,7 +3541,10 @@ export function EmailComposer({
setSelectedIdentityId(e.target.value)}
+ onChange={(e) => {
+ setSelectedIdentityId(e.target.value);
+ notifyIdentitySelected(identities, e.target.value);
+ }}
className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate"
>
{identityGroups.length > 0
@@ -3476,6 +3807,9 @@ export function EmailComposer({
+ {wantsPluginDraft && (
+
+ )}
{/* Body */}
{plainTextMode ? (
' : ''}${composerSignatureHtml}` }}
/>
) : null}
+ {wantsPluginDraft && (
+
+ )}
@@ -3634,18 +3971,36 @@ export function EmailComposer({
)}
+ {/* Plugin buttons get their own row: the composer is often too narrow
+ for them next to Attach, Templates, Options, Discard and Send, and
+ squeezing them in pushed Send out of the window. */}
+ {(pluginToolbarOffers.length > 0 || pluginAttachmentSourceOffers.length > 0) && (
+
+
+ {/* Lets a plugin offer an alternative attachment source (an
+ external file browser, cloud storage picker, etc). The plugin
+ calls `onAttach` once it has uploaded the chosen file to JMAP
+ itself (api.jmap.uploadBlob) and just wants it added to this
+ draft. */}
+
+
+ )}
+
{/* Bottom toolbar. In Mountain View's window both halves fold into
one row (see [data-mv-compose-footer] in globals.css): Send first,
the tools after it, and Discard as a bin at the far end. */}
{/* Left side actions */}
-
+
- {filesEnabled && composerClient?.supportsFiles?.() ? (
+ {(filesEnabled && composerClient?.supportsFiles?.()) || hasAttachMenuActions ? (
{(close) => <>
{t('attach_from_device')}
- { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
- testId="composer-attach-from-files"
- >
- {t('attach_from_files')}
-
+ {filesEnabled && composerClient?.supportsFiles?.() && (
+ { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
+ testId="composer-attach-from-files"
+ >
+ {t('attach_from_files')}
+
+ )}
+
>}
) : (
@@ -3719,10 +4077,10 @@ export function EmailComposer({
- {() => <>
+ {(close) => <>
)}
+
+
>}
-
- {/* Lets a plugin offer an alternative attachment source (an
- external file browser, cloud storage picker, etc). The plugin
- calls `onAttach` once it has uploaded the chosen file to JMAP
- itself (api.jmap.uploadBlob) and just wants it added to this
- draft. */}
-
+
{/* Right side - Discard + Send (desktop) */}
-
+
{mvWindow ? : t('discard')}
- {composerClient?.hasDelayedSend() ? (
+ {composerClient?.hasDelayedSend() || hasSendMenuActions ? (
handleSend()}
@@ -3804,7 +4155,8 @@ export function EmailComposer({
type="button"
onClick={() => setShowSendMenu((open) => !open)}
disabled={!canSend || isSending}
- title={t('schedule_send')}
+ title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
+ aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
className="rounded-s-none px-2"
aria-haspopup="menu"
aria-expanded={showSendMenu}
@@ -3816,15 +4168,23 @@ export function EmailComposer({
role="menu"
className="absolute end-0 bottom-full z-50 mb-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg"
>
-
-
- {t('schedule_send')}
-
+ {composerClient?.hasDelayedSend() && (
+
+
+ {t('schedule_send')}
+
+ )}
+ setShowSendMenu(false)}
+ separator={!!composerClient?.hasDelayedSend()}
+ />
)}
@@ -4063,10 +4423,7 @@ export function EmailComposer({
/>
)}
-
+
pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} />
);
}
diff --git a/components/email/email-context-menu.tsx b/components/email/email-context-menu.tsx
index 3c732be1a..26ee9d45a 100644
--- a/components/email/email-context-menu.tsx
+++ b/components/email/email-context-menu.tsx
@@ -12,6 +12,7 @@ import {
ContextMenuHeader,
} from "@/components/ui/context-menu";
import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
import {
Reply,
ReplyAll,
@@ -266,6 +267,23 @@ export function EmailContextMenu({
// Stable, so the Rules entry does not re-resolve its account every render.
const ruleSenderEmails = useMemo(() => ruleEmails ?? [email], [ruleEmails, email]);
+ // The row's plugin buttons (message-row-actions). On phones the long press
+ // opens this menu, so it is where those buttons live there.
+ const hasRowPluginActions = usePluginActions("message-row-actions", { mailboxRole: currentMailboxRole ?? null }).length > 0;
+ const rowPluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
+ const rowPluginBlock = !showBatchActions && hasRowPluginActions ? (
+ <>
+
+
+ >
+ ) : null;
+
// The menu's entries, composed below in the default layout's order or,
// under Mountain View, in Gmail's.
const replyBlock = (
@@ -564,6 +582,7 @@ export function EmailContextMenu({
{findBlock}
{permalinkBlock}
+ {rowPluginBlock}
>
) : (
<>
@@ -581,6 +600,7 @@ export function EmailContextMenu({
{spamBlock}
{readItem}
+ {rowPluginBlock}
>
)}
>
diff --git a/components/email/email-hover-actions.tsx b/components/email/email-hover-actions.tsx
index 6b1e835fe..82fd8c29c 100644
--- a/components/email/email-hover-actions.tsx
+++ b/components/email/email-hover-actions.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Email } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
@@ -11,6 +11,7 @@ import { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query";
import { TagPicker } from "@/components/email/tag-picker";
import { getEmailTagIds } from "@/lib/thread-utils";
+import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
interface EmailHoverActionsProps {
email: Email;
@@ -27,6 +28,8 @@ interface EmailHoverActionsProps {
onUndoSpam?: () => void;
// Hidden where marking spam is meaningless for self-authored mail (Drafts, Sent).
spamApplicable?: boolean;
+ /** Role of the folder the row is listed in; plugin buttons can be limited to some. */
+ mailboxRole?: string | null;
}
const ACTION_CONFIG: Record
state.hoverActions);
const mountainView = useSettingsStore((state) => state.interfaceLayout === 'mountain-view');
@@ -113,6 +117,9 @@ export function EmailHoverActions({
const hoverActionsCorner = useSettingsStore((state) => state.hoverActionsCorner);
const t = useTranslations("settings.email_behavior.hover_actions");
const isMobile = useIsMobile();
+ // Buttons plugins add to the row (message-row-actions), after the app's own.
+ const pluginActionCount = usePluginActions("message-row-actions", { mailboxRole }).length;
+ const pluginContext = useMemo(() => ({ emailId: email.id }), [email.id]);
// The tag quick-action opens the same picker the context menu and the reading
// pane use. It is portalled to the body because every row sits inside a
@@ -159,7 +166,7 @@ export function EmailHoverActions({
const hoverBackgroundClassName = backgroundClassName;
if (isMobile) return null;
- if (hoverActions.length === 0) return null;
+ if (hoverActions.length === 0 && pluginActionCount === 0) return null;
const handleAction = (e: React.MouseEvent, action: HoverAction) => {
e.stopPropagation();
@@ -251,6 +258,18 @@ export function EmailHoverActions({
);
});
+ if (pluginActionCount > 0) {
+ actionButtons.push(
+ ,
+ );
+ }
const tagPicker =
tagPickerPos && onSetTag
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 5e33fd79b..a52fe0b68 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -9,7 +9,7 @@ import { listVerificationCode } from "@/lib/verification-code";
import { EmailContextMenu, type CopyTargetAccount } from "./email-context-menu";
import { BatchTagButton } from "./batch-tag-button";
import { cn, cleanPreview } from "@/lib/utils";
-import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck } from "@/components/icons";
+import { Trash2, Mail, MailX, MailOpen, Loader2, SearchX, AlertTriangle, CalendarClock, ShieldCheck, X } from "@/components/icons";
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
@@ -31,7 +31,10 @@ import { TagDisplayContext, useMeasuredTagDisplay } from "@/hooks/use-tag-displa
import { SearchChips } from "@/components/search/search-chips";
import { isFilterEmpty, DEFAULT_SEARCH_FILTERS } from "@/lib/jmap/search-utils";
import { getOwnAddresses } from "@/lib/filters/quick-rule-target";
+import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { normalizeAddress } from "@/lib/filters/quick-rules";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { PluginActions } from "@/components/plugins/plugin-actions";
interface EmailListProps {
emails: Email[];
@@ -72,6 +75,9 @@ interface EmailListProps {
hideBatchToolbar?: boolean;
}
+/** Below this width the batch toolbar drops its labels (see batchNarrow). */
+const BATCH_TOOLBAR_WIDE = 480;
+
export function EmailList({
emails,
selectedEmailId,
@@ -264,6 +270,20 @@ export function EmailList({
observer.observe(toolbar);
return () => observer.disconnect();
}, []);
+ // A narrow list (folder sidebar open, phones) has no room for the batch
+ // toolbar's labels: plugin buttons go behind one "more" button and "Clear
+ // selection" becomes an icon, instead of wrapping into each other.
+ const [batchNarrow, setBatchNarrow] = useState(false);
+ useEffect(() => {
+ const toolbar = batchToolbarRef.current;
+ if (!toolbar || typeof ResizeObserver === 'undefined') return;
+ const observer = new ResizeObserver((entries) => {
+ const width = entries[0]?.contentRect.width ?? toolbar.getBoundingClientRect().width;
+ if (width > 0) setBatchNarrow(width < BATCH_TOOLBAR_WIDE);
+ });
+ observer.observe(toolbar);
+ return () => observer.disconnect();
+ }, []);
// One tag treatment for the whole list, measured from the scroll container.
const tagDisplay = useMeasuredTagDisplay(parentRef);
const density = useSettingsStore((state) => state.density);
@@ -380,6 +400,19 @@ export function EmailList({
);
const hasSelection = selectedEmailKeys.size > 0;
+ // What plugin buttons in the selection toolbar act on: the JMAP ids of the
+ // selected messages, thread members included.
+ const selectedEmailIds = useMemo(
+ () => resolveSelectedEmails({ emails, threadEmailsCache, selectedEmailKeys }).map((email) => email.id),
+ [emails, threadEmailsCache, selectedEmailKeys],
+ );
+ const bulkActionContext = useMemo(() => ({ emailIds: selectedEmailIds }), [selectedEmailIds]);
+ const listSlotProps = useMemo(
+ () => ({ mailboxId: selectedMailbox || null, mailboxRole: effectiveMailboxRole ?? null }),
+ [selectedMailbox, effectiveMailboxRole],
+ );
+ const isSearching = !!searchQuery || !isFilterEmpty(searchFilters);
+ const emptySlotProps = useMemo(() => ({ ...listSlotProps, searching: isSearching }), [listSlotProps, isSearching]);
const handleBatchMarkAsRead = async (read: boolean) => {
if (!client || isProcessing) return;
@@ -497,18 +530,34 @@ export function EmailList({
}
}, [client, hasMoreEmails, isLoadingMore, isLoading, isScheduledView, loadMoreEmails, onLoadMoreScheduled]);
+ // The messages of a thread as the list knows them, for onThreadExpand.
+ const threadMembers = useCallback((threadKey: string): Email[] => (
+ useEmailStore.getState().threadEmailsCache.get(threadKey)
+ ?? threadGroups.find((g) => g.threadKey === threadKey)?.emails
+ ?? []
+ ), [threadGroups]);
+
const handleToggleThreadExpansion = useCallback(async (threadKey: string) => {
const isExpanded = expandedThreadIds.has(threadKey);
if (!isExpanded && client) {
toggleThreadExpansion(threadKey);
await fetchThreadEmails(client, threadKey);
+ emitThreadExpand(threadKey, threadMembers(threadKey), true);
// Mark all unread emails in this thread as read
void markThreadAsRead(client, threadKey);
} else {
toggleThreadExpansion(threadKey);
+ emitThreadExpand(threadKey, threadMembers(threadKey), !isExpanded);
}
- }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead]);
+ }, [client, expandedThreadIds, toggleThreadExpansion, fetchThreadEmails, markThreadAsRead, threadMembers]);
+
+ const collapseAllThreadsAndNotify = useCallback(() => {
+ const wereExpanded = [...useEmailStore.getState().expandedThreadIds];
+ const members = wereExpanded.map((key) => [key, threadMembers(key)] as const);
+ collapseAllThreads();
+ for (const [key, emails] of members) emitThreadExpand(key, emails, false);
+ }, [collapseAllThreads, threadMembers]);
// Range-based load more: trigger when last visible item is near the end.
// Debounce to prevent rapid cascade when thread grouping reduces item
@@ -562,13 +611,13 @@ export function EmailList({
hasSelection && !isScheduledView && !hideBatchToolbar ? "max-h-16 opacity-100" : "max-h-0 opacity-0"
)}
>
-
-
-
+
+
+
{t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
-
+
)}
-
+ {hasSelection && (
+
+ )}
+
- {t('batch_actions.clear_selection')}
+ {batchNarrow ? : t('batch_actions.clear_selection')}
@@ -691,6 +751,8 @@ export function EmailList({
)}
+
+
{/* Email List */}
{/* Loading overlay */}
@@ -722,6 +784,7 @@ export function EmailList({
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
+
) : (
<>
@@ -756,14 +819,14 @@ export function EmailList({
isLoading={isLoadingThread === thread.threadKey}
expandedEmails={threadEmailsCache.get(thread.threadKey)}
onToggleExpand={() => handleToggleThreadExpansion(thread.threadKey)}
- onCollapseAllThreads={collapseAllThreads}
+ onCollapseAllThreads={collapseAllThreadsAndNotify}
onEmailSelect={(email) => {
// Collapse expanded threads when selecting an email outside the expanded thread
const currentExpanded = useEmailStore.getState().expandedThreadIds;
if (currentExpanded.size > 0) {
// Check if the selected email belongs to any expanded thread via its threadId
if (!email.threadId || !currentExpanded.has(threadKeyFor(email))) {
- collapseAllThreads();
+ collapseAllThreadsAndNotify();
}
}
onEmailSelect?.(email);
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 07463f525..df59d15dd 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -4,7 +4,7 @@ import { Fragment, useState, useEffect, useLayoutEffect, useMemo, useRef, useCal
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
-import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
+import { applyNewTabToAnchor, emailIframeCsp, escapeHtml, isExternalResourceUrl, isOpenableLinkHref, plainTextToSafeHtml, sanitizeEmailBodyForIframe, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { collectReferencedCids, isEmbeddedInBody } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
@@ -26,6 +26,7 @@ import { useKeywordFormat } from "@/hooks/use-keyword-format";
import { getEmailTagIds } from "@/lib/thread-utils";
import { getSecurityStatus, extractListHeaders, getSenderVerification } from "@/lib/email-headers";
import { emailToReadView } from "@/lib/plugin-projection";
+import type { EmailReadView, ExternalLinkContext } from "@/lib/plugin-types";
import { generateEmailSource } from "@/lib/email-source";
import {
Reply,
@@ -111,6 +112,7 @@ import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover";
import { MailtoLink } from "@/components/ui/mailto-link";
+import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
import { imageBlobUrl, inertBlobType, isFilePreviewable, isMimeTypeSafeForInlinePreview, toInertBlob } from "@/lib/file-preview";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
@@ -120,10 +122,14 @@ import { VerificationCodeChip } from "./verification-code-chip";
import { ConversationEmailBody, useConversationExternalContent, useMarkReadWhenShown } from "./thread-conversation-view";
import type { TnefAttachment } from "@/lib/tnef";
import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { PluginActions } from "@/components/plugins/plugin-actions";
+import { AttachmentPluginFrames, AttachmentPluginMenu, PluginPanelEntries, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
import { RulesPanel, useRulesAvailability } from "./rules-menu";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { ResizeHandle } from "@/components/layout/resize-handle";
-import { emailHooks, uiHooks, renderHooks } from "@/lib/plugin-hooks";
+import { emailHooks, renderHooks } from "@/lib/plugin-hooks";
+import { emitThreadExpand } from "@/lib/plugin-thread-expand";
+import { resolveExternalLink } from "@/lib/plugin-hook-result";
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
@@ -951,6 +957,18 @@ function MvPager({
);
}
+/**
+ * The plugin slot under one "more details" category. Props are memoised so
+ * re-renders of the viewer don't post them to the plugin again.
+ */
+function DetailsCategorySlot({ email, category }: { email: EmailReadView | null; category: string | null }) {
+ const props = useMemo(() => ({ email, category }), [email, category]);
+ return ;
+}
+
+/** Narrowest the message may get next to a plugin panel before the panel collapses. */
+const MIN_READER_WIDTH = 480;
+
function SidebarSection({ icon: Icon, title, children }: { icon: React.ComponentType<{ className?: string }>; title: string; children: React.ReactNode }) {
return (
@@ -1106,10 +1124,17 @@ export function EmailViewer({
const mvOpenIndex = (mvViewer || mvMobileViewer) && mvConversation && email ? mvConversation.findIndex((e) => e.id === email.id) : -1;
const mvBefore = mvOpenIndex > 0 ? mvConversation!.slice(0, mvOpenIndex) : [];
const mvAfter = mvOpenIndex >= 0 ? mvConversation!.slice(mvOpenIndex + 1) : [];
+ const mvConversationIds = mvOpenIndex >= 0 ? mvConversation!.map((e) => e.id).join(',') : '';
+ const threadSummaryProps = useMemo(
+ () => ({ threadId: email?.threadId ?? null, emailIds: mvConversationIds ? mvConversationIds.split(',') : [] }),
+ [email?.threadId, mvConversationIds],
+ );
// Mountain View: which other messages of the conversation are unfolded. A
// conversation opens with its unread messages unfolded, as Gmail does, so
// reading the thread reads all of them.
const [mvExpandedIds, setMvExpandedIds] = useState
>(() => new Set());
+ const mvExpandedIdsRef = useRef(mvExpandedIds);
+ mvExpandedIdsRef.current = mvExpandedIds;
const mvExpandedThreadRef = useRef(null);
useEffect(() => {
if (!mvConversation || mvConversation.length === 0 || !email) {
@@ -1122,6 +1147,7 @@ export function EmailViewer({
setMvExpandedIds(new Set(mvConversation.filter((e) => e.id !== email.id && !e.keywords?.$seen).map((e) => e.id)));
}, [mvConversation, email]);
const toggleMvExpanded = useCallback((target: Email) => {
+ emitThreadExpand(target.threadId, [target], !mvExpandedIdsRef.current.has(target.id));
setMvExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(target.id)) next.delete(target.id);
@@ -1133,6 +1159,12 @@ export function EmailViewer({
const [mvOpenCollapsed, setMvOpenCollapsed] = useState(false);
useEffect(() => setMvOpenCollapsed(false), [email?.id]);
const mvOpenFolded = mvOpenIndex >= 0 && mvOpenCollapsed;
+ // Folding or unfolding the open message of a conversation, told to plugins
+ // like any other conversation message (onThreadExpand).
+ const setMvOpenFolded = (folded: boolean) => {
+ if (email) emitThreadExpand(email.threadId, [email], !folded);
+ setMvOpenCollapsed(folded);
+ };
// The conversation is not here yet (the one on hand is another thread's, or
// none). Hold the message back instead of drawing it alone and then pushing
// it down under the earlier messages.
@@ -1390,6 +1422,22 @@ export function EmailViewer({
// Whether any plugin offers a "more details" section, so we only render the
// bottom plugin category wrapper when something will fill it.
const hasDetailsSlotOffers = usePluginSlotOffers('email-details-section').length > 0;
+ const hasBannerOffers = usePluginSlotOffers('email-banner').length > 0;
+ const hasHeaderOffers = usePluginSlotOffers('reader-header').length > 0;
+ const hasBelowSubjectOffers = usePluginSlotOffers('reader-below-subject').length > 0;
+ const hasAboveQuoteOffers = usePluginSlotOffers('reader-above-quote').length > 0;
+ // One projected view per message for all plugin slots on it, built only
+ // when a plugin is there to see it. A fresh object on every render made
+ // each slot post its props to the plugin again and re-run shouldShow.
+ const wantsPluginEmailView = hasDetailSidebar || hasDetailsSlotOffers || hasBannerOffers
+ || hasHeaderOffers || hasBelowSubjectOffers || hasAboveQuoteOffers;
+ const pluginEmailView = useMemo(
+ () => (email && wantsPluginEmailView ? emailToReadView(email) : null),
+ [email, wantsPluginEmailView],
+ );
+ const pluginEmailProps = useMemo(() => ({ email: pluginEmailView }), [pluginEmailView]);
+ const readerActionContext = useMemo(() => ({ emailId: email?.id }), [email?.id]);
+ const pluginFooterProps = useMemo(() => ({ emailId: email?.id }), [email?.id]);
const [detailSidebarCollapsed, setDetailSidebarCollapsed] = useState(() => {
if (typeof window === 'undefined') return false;
try { return localStorage.getItem('emailDetailSidebarCollapsed') === '1'; } catch { return false; }
@@ -1402,6 +1450,29 @@ export function EmailViewer({
} catch { return 280; }
});
const detailSidebarWidthRef = useRef(detailSidebarWidth);
+ // The panel takes its width from the message. When that would leave the
+ // message too narrow to read (three panes on a laptop), the panel starts
+ // collapsed; opening it anyway is still the user's call.
+ // A callback ref: the root only exists once a message is open (the empty
+ // and loading states render other markup), so the observer has to attach
+ // when the element appears, not when the plugin offer does.
+ const [viewerRootEl, setViewerRootEl] = useState(null);
+ const [viewerWidth, setViewerWidth] = useState(0);
+ const [detailSidebarForcedOpen, setDetailSidebarForcedOpen] = useState(false);
+ useEffect(() => {
+ if (!hasDetailSidebar || !viewerRootEl || typeof ResizeObserver === 'undefined') return;
+ const ro = new ResizeObserver((entries) => {
+ const width = Math.round(entries[0]?.contentRect.width ?? 0);
+ setViewerWidth(width);
+ });
+ ro.observe(viewerRootEl);
+ return () => ro.disconnect();
+ }, [hasDetailSidebar, viewerRootEl]);
+ const readerTooNarrow = viewerWidth > 0 && viewerWidth - detailSidebarWidth < MIN_READER_WIDTH;
+ const sidebarCollapsed = detailSidebarCollapsed || (readerTooNarrow && !detailSidebarForcedOpen);
+ // Opened by hand while the message is too narrow for it: the panel lies over
+ // the message instead of squeezing it.
+ const sidebarOverlay = !sidebarCollapsed && readerTooNarrow;
useEffect(() => {
try { localStorage.setItem('emailDetailSidebarCollapsed', detailSidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
@@ -2725,6 +2796,15 @@ export function EmailViewer({
// Gates the quick reply on the iframe having loaded the current srcDoc, so
// it doesn't flash in below a still-resizing iframe.
const [iframeReady, setIframeReady] = useState(false);
+ // The plain-text body, where reader-above-quote mounts between text and quote.
+ const [plainBodyEl, setPlainBodyEl] = useState(null);
+ // One object per body: React writes innerHTML again whenever this object
+ // changes, which would drop what was mounted in the body and fold the quote
+ // back up on every render.
+ const plainBodyHtml = useMemo(
+ () => ({ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }),
+ [effectiveEmailContent.html],
+ );
// Tracks which parsed document we've already wired up, so setup runs exactly
// once per srcDoc even though both the readiness poll below and the iframe
// 'load' event can trigger it.
@@ -2747,6 +2827,30 @@ export function EmailViewer({
staleDocRef.current = iframeRef.current?.contentDocument ?? null;
}, [emailIframeSrcDoc]);
+ // onEmailContentRender: once per opened message, after its body is on
+ // screen (the iframe has loaded an HTML body; a text body renders at once).
+ const contentRenderedForRef = useRef(null);
+ useEffect(() => {
+ if (!email || isBodyLoading) return;
+ if (effectiveEmailContent.isHtml && !iframeReady) return;
+ if (contentRenderedForRef.current === email.id) return;
+ contentRenderedForRef.current = email.id;
+ if (emailHooks.onEmailContentRender.size === 0) return;
+ let hasExternalContent = hasBlockedContent;
+ if (!hasExternalContent && effectiveEmailContent.isHtml) {
+ // Loaded rather than blocked (allowed by the user or the policy): look
+ // at what the rendered body actually references.
+ const doc = iframeRef.current?.contentDocument;
+ hasExternalContent = !!doc && Array.from(doc.querySelectorAll('[src], [srcset], [background], [poster]')).some((el) =>
+ ['src', 'srcset', 'background', 'poster'].some((attr) => isExternalResourceUrl(el.getAttribute(attr))));
+ }
+ void emailHooks.onEmailContentRender.emit({
+ emailId: email.id,
+ hasHtml: effectiveEmailContent.isHtml,
+ hasExternalContent,
+ });
+ }, [email, isBodyLoading, effectiveEmailContent.isHtml, iframeReady, hasBlockedContent]);
+
// Open a blob: link the rendered body points at a cid: part. Mirrors the
// attachment-chip gate: inert previewable types open in a tab, everything
// else (and any blob: URL we did not mint) is downloaded under its part name.
@@ -2872,7 +2976,17 @@ export function EmailViewer({
const targetEl = (ev.target as Element | null)?.closest?.('a[href], area[href]') as HTMLAnchorElement | HTMLAreaElement | null;
if (!targetEl) return;
const href = targetEl.getAttribute('href') || '';
- if (!href || href.startsWith('#') || href.startsWith('mailto:')) return;
+ if (!href || href.startsWith('#')) return;
+ if (/^mailto:/i.test(href)) {
+ // Write it here, like any other address in the app; the mail page
+ // asks onMailtoIntercept first. Not taken (no recipient, nobody
+ // listening): the browser's own handling stays.
+ if (requestInternalMailto(href)) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ }
+ return;
+ }
ev.preventDefault();
ev.stopPropagation();
// A link at a cid: part now carries the blob: URL we minted for it.
@@ -2887,14 +3001,16 @@ export function EmailViewer({
// data:, unresolved cid: and relative paths would land inside our
// own origin.
if (!isOpenableLinkHref(href)) return;
- const ctx = {
+ const ctx: ExternalLinkContext = {
href,
target: targetEl.getAttribute('target') ?? undefined,
emailId: email?.id,
};
- const ok = await uiHooks.onBeforeExternalLink.intercept(ctx);
- if (!ok || !isOpenableLinkHref(ctx.href)) return;
- window.open(ctx.href, '_blank', 'noopener,noreferrer');
+ // A plugin returns false to stop the link or { href } to send it
+ // somewhere else (a web address only).
+ const target = await resolveExternalLink(ctx);
+ if (!target) return;
+ window.open(target, '_blank', 'noopener,noreferrer');
};
doc.addEventListener('click', onLinkClick, true);
@@ -3430,6 +3546,15 @@ export function EmailViewer({
>)}
+ {/* Phones get these in the More panel. */}
+ {!isMobile && email && (
+
+ )}
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
@@ -3973,6 +4098,7 @@ export function EmailViewer({
return (
@@ -4174,6 +4300,14 @@ export function EmailViewer({
{t('keyboard_shortcuts')}
)}
+ {email && (
+
setMoreMenuOpen(false)}
+ />
+ )}
>
)}
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
@@ -4246,6 +4380,8 @@ export function EmailViewer({
)}
+
+
{/* === SUBJECT BLOCK === */}
+ {/* Above the messages of the open conversation. */}
+ {mvOpenIndex >= 0 && (
+
+ )}
+
{/* Mountain View: the conversation's earlier messages, folded. */}
{mvBefore.length > 0 && (
@@ -4387,7 +4528,7 @@ export function EmailViewer({
{/* Mountain View: the open message folded like the rest of the conversation. */}
{mvOpenFolded && email ? (
- setMvOpenCollapsed(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
+ setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
) : mvAwaitingConversation ? (
@@ -4409,7 +4550,7 @@ export function EmailViewer({
// message, as Gmail does; its buttons and links keep their own job.
if (isInteractiveClick(e.target, e.currentTarget)) return;
if (window.getSelection()?.toString()) return;
- setMvOpenCollapsed(true);
+ setMvOpenFolded(true);
} : undefined}
>
@@ -4561,7 +4702,7 @@ export function EmailViewer({
const thumbUrl = imageThumbUrls[attachment.id];
return (
- {(dragProps) => (
+ {(dragProps) => (<>
- )}
+
+ >)}
);
})}
@@ -4657,7 +4787,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
- {(dragProps) => (
+ {(dragProps) => (<>
{formatFileSize(attachment.size)}
-
- )}
+
+ >)}
);
})}
@@ -4727,7 +4846,7 @@ export function EmailViewer({
onClick={mvOpenIndex >= 0 ? (e) => {
if (isInteractiveClick(e.target, e.currentTarget)) return;
if (window.getSelection()?.toString()) return;
- setMvOpenCollapsed(true);
+ setMvOpenFolded(true);
} : undefined}
>
@@ -4827,6 +4946,8 @@ export function EmailViewer({
+
+
{/* Expandable Details (shared across mobile/tablet/desktop) */}
{showFullHeaders && (() => {
const translateAuthResult = (result?: string) => {
@@ -4917,20 +5038,13 @@ export function EmailViewer({
const hasListInfo = !!(listHeaders?.listId || listHeaders?.listUnsubscribe || listHeaders?.listHelp || listHeaders?.listPost);
const hasAuthSection = !!(auth?.spf || auth?.dkim || auth?.dmarc || auth?.iprev || email.spamScore !== undefined || email.spamLLM);
- // Projected, read-only view handed to plugins that render in the
- // "more details" panel. Includes the parsed `headers` map and full
- // `source` so plugins can inspect raw headers / message source.
- // Built lazily here - only when the details panel is expanded.
- const detailsView = emailToReadView(email);
// Lets a plugin add rows under an existing category. The plugin's
// own `shouldShow({ email, category })` decides which category it
// appears under (or `category === null` for the new bottom section).
- const CategorySlot = ({ category }: { category: string }) => (
-
+ // A component defined here would be a new type on every render and
+ // remount the plugin's iframe each time, so it lives outside.
+ const categorySlot = (category: string) => (
+
);
return (
@@ -5004,7 +5118,7 @@ export function EmailViewer({
)}
-
+ {categorySlot("recipients_routing")}
{hasAuthSection && (
@@ -5090,7 +5204,7 @@ export function EmailViewer({
)}
-
+ {categorySlot("authentication_security")}
)}
@@ -5125,7 +5239,7 @@ export function EmailViewer({
{email.threadId}
)}
-
+ {categorySlot("identifiers_threading")}
)}
@@ -5153,7 +5267,7 @@ export function EmailViewer({
{email.accountLabel}
)}
-
+ {categorySlot("message_properties")}
{hasListInfo && (
@@ -5179,7 +5293,7 @@ export function EmailViewer({
{listHeaders.listPost} |
)}
-
+ {categorySlot("mailing_list")}
)}
@@ -5187,10 +5301,7 @@ export function EmailViewer({
`category === null` render their own titled section here. */}
{hasDetailsSlotOffers && (
)}
@@ -5373,7 +5484,7 @@ export function EmailViewer({
)}
-
+
{/* === ATTACHMENTS below header (below-header mode, desktop only) === */}
{attachmentPosition === 'below-header' && effectiveAttachments.length > 0 && (
@@ -5425,7 +5536,7 @@ export function EmailViewer({
const thumbUrl = imageThumbUrls[attachment.id];
return (
- {(dragProps) => (
+ {(dragProps) => (<>
- )}
+
+ >)}
);
})}
@@ -5526,7 +5626,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
- {(dragProps) => (
+ {(dragProps) => (<>
{formatFileSize(attachment.size)}
-
- )}
+
+ >)}
);
})}
@@ -5597,7 +5686,7 @@ export function EmailViewer({
const thumbUrl = imageThumbUrls[attachment.id];
return (
- {(dragProps) => (
+ {(dragProps) => (<>
{formatFileSize(attachment.size)}
+ {/* Phones have no hover: the plugin menu stays visible. */}
+
- )}
+
+ >)}
);
})}
@@ -5692,7 +5771,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
- {(dragProps) => (
+ {(dragProps) => (<>
{formatFileSize(attachment.size)}
-
- )}
+
+ >)}
);
})}
@@ -5758,7 +5826,7 @@ export function EmailViewer({
{/* Email Body */}
) : (
)}
+ {!isBodyLoading && (
+
+ )}
-
+
{/* Mountain View: the conversation's later messages, folded. */}
{mvAfter.length > 0 && (
@@ -6063,47 +6140,61 @@ export function EmailViewer({
{hasDetailSidebar && !isMobile && (
<>
{/* Collapse toggle when sidebar is collapsed */}
- {detailSidebarCollapsed && (
+ {sidebarCollapsed && (
setDetailSidebarCollapsed(false)}
+ onClick={() => {
+ setDetailSidebarCollapsed(false);
+ if (readerTooNarrow) setDetailSidebarForcedOpen(true);
+ }}
className="h-8 w-8 m-1"
- aria-label="Expand panel"
+ aria-label={t('plugin_panel_show')}
>
)}
- {!detailSidebarCollapsed && (
+ {!sidebarCollapsed && (
<>
- { detailSidebarWidthRef.current = detailSidebarWidth; }}
- onResize={(delta) => {
- const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
- setDetailSidebarWidth(newWidth);
- }}
- onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
- onDoubleClick={() => setDetailSidebarWidth(280)}
- />
+ {!sidebarOverlay && (
+ { detailSidebarWidthRef.current = detailSidebarWidth; }}
+ onResize={(delta) => {
+ const newWidth = Math.max(200, Math.min(500, detailSidebarWidthRef.current - delta));
+ setDetailSidebarWidth(newWidth);
+ }}
+ onResizeEnd={() => { detailSidebarWidthRef.current = detailSidebarWidth; }}
+ onDoubleClick={() => setDetailSidebarWidth(280)}
+ />
+ )}
setDetailSidebarCollapsed(true)}
+ onClick={() => {
+ setDetailSidebarCollapsed(true);
+ setDetailSidebarForcedOpen(false);
+ }}
className="h-7 w-7"
- aria-label="Collapse panel"
+ aria-label={t('plugin_panel_hide')}
>
>
@@ -6139,10 +6230,10 @@ export function EmailViewer({
}} : {}),
};
if (client && supportsSync) {
- createContact(client, contactData).then(() => toast.success('Contact added'));
+ createContact(client, contactData).then((created) => { if (created) toast.success('Contact added'); });
} else {
- addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard);
- toast.success('Contact added');
+ void addLocalContact({ id: `local-${generateUUID()}`, addressBookIds: {}, ...contactData } as ContactCard)
+ .then((created) => { if (created) toast.success('Contact added'); });
}
}}
/>
diff --git a/components/email/mv-list-toolbar.tsx b/components/email/mv-list-toolbar.tsx
index 98aad7e34..5fbce7d6a 100644
--- a/components/email/mv-list-toolbar.tsx
+++ b/components/email/mv-list-toolbar.tsx
@@ -31,6 +31,7 @@ import { localizeMailboxName } from "@/lib/mailbox-label";
import { buildMoveTargets, resolveMoveOwnerAccountId } from "@/lib/move-targets";
import { getEmailTagIds } from "@/lib/thread-utils";
import { cn, type MailboxNode } from "@/lib/utils";
+import { PluginActions } from "@/components/plugins/plugin-actions";
interface MvListToolbarProps {
/** Loaded conversation count, for the right-hand range readout. */
@@ -606,6 +607,13 @@ export function MvListToolbar({
)}
+ email.id) }}
+ mailboxRole={currentRole ?? null}
+ compact
+ className="shrink-0"
+ />
{tBatch("selected_messages", { count: selectionCount })}
diff --git a/components/email/plugin-row-badges.tsx b/components/email/plugin-row-badges.tsx
new file mode 100644
index 000000000..de6b0e787
--- /dev/null
+++ b/components/email/plugin-row-badges.tsx
@@ -0,0 +1,164 @@
+"use client";
+
+import { useEffect, useState, useSyncExternalStore } from "react";
+import type { Email } from "@/lib/jmap/types";
+import type { EmailListBadge } from "@/lib/plugin-types";
+import { renderHooks } from "@/lib/plugin-hooks";
+import { emailSummary } from "@/lib/plugin-projection";
+import { rowBadgeGeneration, subscribeRowBadges } from "@/lib/plugin-sandbox/row-badges";
+import { cn } from "@/lib/utils";
+import { TAG_GROUP_CLASS, TAG_LOZENGE_CLASS } from "./tag-badge";
+
+// Badges plugins add to a message-list row through onEmailListItemRender.
+//
+// The hook is async and would otherwise run for every row on every render, so
+// the answer is kept per message and keyword state: scrolling a row back into
+// view, or re-rendering the list, reuses it. A keyword change (read, flagged,
+// a tag) asks again, since that is what most badges are about.
+
+/** At most this many plugin badges per row; the row has little room. */
+export const MAX_ROW_BADGES = 3;
+const MAX_LABEL_LENGTH = 32;
+const MAX_TITLE_LENGTH = 200;
+const CACHE_LIMIT = 2000;
+
+const cache = new Map();
+const inflight = new Map>();
+
+function cacheKey(email: Email, listeners: number, generation: number): string {
+ const keywords = Object.keys(email.keywords ?? {})
+ .filter((k) => email.keywords?.[k])
+ .sort()
+ .join(",");
+ // The listener count is part of the key so a plugin being switched on or
+ // off doesn't leave the old answer behind.
+ // The generation moves on when a plugin asks for a refresh.
+ return `${generation}|${listeners}|${email.id}|${keywords}`;
+}
+
+// A colour the badge may use: hex, rgb()/hsl() with plain numbers, a CSS
+// variable or a colour name. Anything else (url(), expressions, several
+// declarations) is dropped and the badge keeps the theme colours.
+const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(rgb|rgba|hsl|hsla)\([\d\s.,%/+-]+\)|var\(--[a-z0-9-]+\)|[a-z]{3,30})$/i;
+
+// eslint-disable-next-line no-control-regex -- removing control characters is the point
+const CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
+
+function cleanText(value: unknown, max: number): string {
+ return typeof value === "string" ? value.replace(CONTROL_CHARS, "").trim().slice(0, max) : "";
+}
+
+/** Keep only well-formed badges, as plain text, capped at MAX_ROW_BADGES. */
+export function normalizeBadges(raw: unknown): EmailListBadge[] {
+ if (!Array.isArray(raw)) return [];
+ const out: EmailListBadge[] = [];
+ const seen = new Set();
+ for (const item of raw) {
+ if (out.length >= MAX_ROW_BADGES) break;
+ if (!item || typeof item !== "object") continue;
+ const candidate = item as Record;
+ const label = cleanText(candidate.label, MAX_LABEL_LENGTH);
+ if (!label) continue;
+ const key = cleanText(candidate.key, 100) || label;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ const badge: EmailListBadge = { key, label };
+ const color = cleanText(candidate.color, 60);
+ if (color && SAFE_COLOR.test(color)) badge.color = color;
+ const title = cleanText(candidate.title, MAX_TITLE_LENGTH);
+ if (title) badge.title = title;
+ out.push(badge);
+ }
+ return out;
+}
+
+function remember(key: string, badges: EmailListBadge[]): void {
+ if (cache.size >= CACHE_LIMIT) {
+ const oldest = cache.keys().next().value;
+ if (oldest !== undefined) cache.delete(oldest);
+ }
+ cache.set(key, badges);
+}
+
+function resolveBadges(email: Email, key: string): Promise {
+ const pending = inflight.get(key);
+ if (pending) return pending;
+ const promise = renderHooks.onEmailListItemRender.transform([], { email: emailSummary(email) })
+ .then(normalizeBadges, () => [])
+ .then((badges) => {
+ remember(key, badges);
+ return badges;
+ })
+ .finally(() => inflight.delete(key));
+ inflight.set(key, promise);
+ return promise;
+}
+
+/** Forget every cached answer (tests). */
+export function clearRowBadgeCache(): void {
+ cache.clear();
+ inflight.clear();
+}
+
+/**
+ * The plugin badges for a row: none at first, then whatever the plugins
+ * returned. Without any onEmailListItemRender handler this does nothing.
+ */
+export function usePluginRowBadges(email: Email): EmailListBadge[] {
+ const listeners = renderHooks.onEmailListItemRender.size;
+ const generation = useSyncExternalStore(subscribeRowBadges, rowBadgeGeneration, rowBadgeGeneration);
+ const key = listeners > 0 ? cacheKey(email, listeners, generation) : null;
+ const [state, setState] = useState<{ key: string | null; badges: EmailListBadge[] }>(() => ({
+ key,
+ badges: key ? cache.get(key) ?? [] : [],
+ }));
+
+ useEffect(() => {
+ if (!key) return;
+ const cached = cache.get(key);
+ if (cached) {
+ setState((prev) => (prev.key === key && prev.badges === cached ? prev : { key, badges: cached }));
+ return;
+ }
+ let cancelled = false;
+ void resolveBadges(email, key).then((badges) => {
+ if (!cancelled) setState({ key, badges });
+ });
+ return () => { cancelled = true; };
+ // `key` covers the parts of the email the answer depends on.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key]);
+
+ if (!key) return [];
+ // While a new answer is on its way, show the cached one for this key, if
+ // any, rather than the previous key's badges.
+ return state.key === key ? state.badges : cache.get(key) ?? [];
+}
+
+/** Plugin badges next to a row's subject. Renders nothing when there are none. */
+export function PluginRowBadges({ email, className }: { email: Email; className?: string }) {
+ const badges = usePluginRowBadges(email);
+ if (badges.length === 0) return null;
+ return (
+
+ {badges.map((badge) => (
+
+ {badge.label}
+
+ ))}
+
+ );
+}
diff --git a/components/email/reader-plugin-surfaces.tsx b/components/email/reader-plugin-surfaces.tsx
new file mode 100644
index 000000000..f6f3cde0f
--- /dev/null
+++ b/components/email/reader-plugin-surfaces.tsx
@@ -0,0 +1,290 @@
+"use client";
+
+// Plugin surfaces of the open message that need more than a one-line mount:
+// the attachment menu, plugin entries in the phone "More" panel, and the
+// frame between a reply's own text and the quote under it.
+
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { createPortal } from "react-dom";
+import { useTranslations } from "next-intl";
+import { iconForName, MoreHorizontal, Puzzle } from "@/components/icons";
+import { ContextMenu } from "@/components/ui/context-menu";
+import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
+import { useContextMenu } from "@/hooks/use-context-menu";
+import { runAction } from "@/lib/plugin-sandbox/actions";
+import { QUOTE_TOGGLE_ATTR } from "@/lib/quote-collapse";
+import { cn } from "@/lib/utils";
+import type { AttachmentInfo } from "@/lib/plugin-types";
+
+// ─── attachment-menu ─────────────────────────────────────────
+
+export interface AttachmentMenuTarget {
+ /** Parts without a blob (decrypted, unpacked from TNEF) get no menu: a plugin couldn't fetch them. */
+ blobId?: string;
+ name?: string | null;
+ type: string;
+ size: number;
+}
+
+/**
+ * A "more" button on an attachment that opens the entries plugins declared
+ * for `attachment-menu`, filtered by the attachment's MIME type. Nothing is
+ * drawn when no plugin has an entry for this type, or the part has no blob.
+ */
+export function AttachmentPluginMenu({
+ emailId,
+ attachment,
+ className,
+ iconClassName = "w-4 h-4",
+}: {
+ emailId: string | undefined;
+ attachment: AttachmentMenuTarget;
+ className?: string;
+ iconClassName?: string;
+}) {
+ const t = useTranslations("email_viewer");
+ const actions = usePluginActions("attachment-menu", { mimeType: attachment.type });
+ const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu();
+ const context = useMemo(() => ({
+ emailId,
+ blobId: attachment.blobId,
+ name: attachment.name || "",
+ type: attachment.type,
+ size: attachment.size,
+ }), [emailId, attachment.blobId, attachment.name, attachment.type, attachment.size]);
+
+ if (actions.length === 0 || !emailId || !attachment.blobId) return null;
+ return (
+ <>
+ openContextMenu(e, true)}
+ >
+
+
+
+
+
+ >
+ );
+}
+
+// ─── attachment-actions ──────────────────────────────────────
+
+/**
+ * The `attachment-actions` frames of one attachment, drawn next to its chip.
+ * They used to sit in the chip's hover overlay, where a wide frame was cut
+ * off and covered the name, and on phones (no hover) lay invisible over the
+ * chip and swallowed taps meant for its menu button.
+ */
+export function AttachmentPluginFrames({
+ emailId,
+ attachment,
+ className,
+}: {
+ emailId: string | undefined;
+ attachment: AttachmentMenuTarget;
+ className?: string;
+}) {
+ const extraProps = useMemo(() => ({
+ attachment: {
+ name: attachment.name || "",
+ type: attachment.type,
+ size: attachment.size,
+ blobId: attachment.blobId,
+ emailId,
+ } satisfies AttachmentInfo,
+ }), [emailId, attachment.name, attachment.type, attachment.size, attachment.blobId]);
+ return (
+
+ );
+}
+
+// ─── Entries in a phone slide-in panel ───────────────────────
+
+/**
+ * Plugin actions as rows of a full-height phone menu panel (the reader's
+ * "More" panel), drawn like the panel's own rows. A separator leads them.
+ */
+export function PluginPanelEntries({
+ surface,
+ context,
+ mailboxRole,
+ onRan,
+}: {
+ surface: string;
+ context: Record;
+ mailboxRole?: string | null;
+ onRan?: () => void;
+}) {
+ const actions = usePluginActions(surface, { mailboxRole });
+ if (actions.length === 0) return null;
+ return (
+ <>
+
+ {actions.map((action) => {
+ const Icon = iconForName(action.icon) ?? Puzzle;
+ return (
+ { onRan?.(); void runAction(action, surface, context); }}
+ className={cn(
+ "w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3",
+ action.danger ? "text-destructive" : "text-foreground",
+ )}
+ >
+
+ {action.label}
+
+ );
+ })}
+ >
+ );
+}
+
+// ─── reader-above-quote ──────────────────────────────────────
+
+export const QUOTE_ANCHOR_ATTR = "data-plugin-quote-anchor";
+
+/**
+ * Puts an empty block right before the collapsed quote of a rendered body
+ * and returns it: before the "•••" toggle setupQuoteCollapse added (HTML
+ * body in its iframe), or before the collapsePlainTextQuotes
+ * wrapped the quote in (plain-text body). Null when the body has no quote
+ * the viewer recognised.
+ */
+export function insertQuoteAnchor(root: HTMLElement): HTMLElement | null {
+ const existing = root.querySelector(`[${QUOTE_ANCHOR_ATTR}]`);
+ if (existing) return existing;
+ const toggle = root.querySelector(`[${QUOTE_TOGGLE_ATTR}]`)
+ ?? root.querySelector(":scope > details");
+ if (!toggle?.parentNode) return null;
+ const anchor = root.ownerDocument.createElement("div");
+ anchor.setAttribute(QUOTE_ANCHOR_ATTR, "");
+ // No fixed height: in a plain-text body the frame renders inside the anchor
+ // and must push the quote toggle down, not lie over it. QuoteOverlay sets
+ // the height itself for an HTML body.
+ anchor.style.cssText = "display:block;margin:0;padding:0;border:0;white-space:normal;";
+ toggle.parentNode.insertBefore(anchor, toggle);
+ return anchor;
+}
+
+/**
+ * The `reader-above-quote` frame. In a plain-text body (same document) it
+ * renders into the anchor. In an HTML body the anchor lives in the mail's
+ * sandboxed iframe, which can't host a plugin frame, so the frame is laid
+ * over the iframe at the anchor's place and the anchor is grown to the
+ * frame's height to make room for it.
+ *
+ * `root` is the body element the quote was collapsed in (null while the body
+ * isn't ready); `version` changes whenever that body is rendered anew.
+ */
+export function ReaderAboveQuoteSlot({
+ root,
+ frame,
+ extraProps,
+ version,
+}: {
+ root: HTMLElement | null;
+ /** The iframe holding `root`, for an HTML body. */
+ frame?: HTMLIFrameElement | null;
+ extraProps: Record;
+ version?: unknown;
+}) {
+ const offered = usePluginSlotOffers("reader-above-quote").length > 0;
+ const [anchor, setAnchor] = useState(null);
+
+ useLayoutEffect(() => {
+ if (!offered || !root) {
+ setAnchor(null);
+ return;
+ }
+ const next = insertQuoteAnchor(root);
+ setAnchor(next);
+ return () => {
+ next?.remove();
+ };
+ }, [offered, root, version]);
+
+ if (!anchor) return null;
+ const slot = ;
+ if (!frame || anchor.ownerDocument === document) {
+ return createPortal(slot, anchor);
+ }
+ return {slot} ;
+}
+
+function QuoteOverlay({ anchor, frame, children }: { anchor: HTMLElement; frame: HTMLIFrameElement; children: React.ReactNode }) {
+ const overlayRef = useRef(null);
+ const [box, setBox] = useState<{ top: number; left: number; width: number } | null>(null);
+
+ useEffect(() => {
+ const overlay = overlayRef.current;
+ const host = overlay?.offsetParent as HTMLElement | null;
+ if (!overlay) return;
+ const place = () => {
+ // The anchor's box is in the iframe's viewport; the iframe doesn't
+ // scroll (it is as tall as the mail), so frame top + anchor top is
+ // where the anchor sits on the page.
+ const frameRect = frame.getBoundingClientRect();
+ const hostRect = host?.getBoundingClientRect() ?? { top: 0, left: 0 };
+ const anchorRect = anchor.getBoundingClientRect();
+ const next = {
+ top: frameRect.top - hostRect.top + frame.clientTop + anchorRect.top,
+ left: frameRect.left - hostRect.left + frame.clientLeft,
+ width: frame.clientWidth,
+ };
+ setBox((prev) => (prev && prev.top === next.top && prev.left === next.left && prev.width === next.width ? prev : next));
+ };
+ const fit = () => {
+ // Make room in the mail for the frame. A mail scaled down to fit a
+ // phone is drawn smaller than its layout, so undo that scale.
+ const scale = anchor.offsetWidth > 0 ? anchor.getBoundingClientRect().width / anchor.offsetWidth : 1;
+ const height = overlay.offsetHeight / (scale || 1);
+ anchor.style.height = `${Math.ceil(height)}px`;
+ place();
+ };
+ fit();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(fit);
+ observer.observe(overlay);
+ observer.observe(frame);
+ window.addEventListener("resize", place);
+ return () => {
+ observer.disconnect();
+ window.removeEventListener("resize", place);
+ anchor.style.height = "0px";
+ };
+ }, [anchor, frame]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx
index 4a4f70d43..3acf3b128 100644
--- a/components/email/thread-conversation-view.tsx
+++ b/components/email/thread-conversation-view.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState, useEffect, useMemo, useRef, useCallback } from "react";
+import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
@@ -41,6 +41,12 @@ import { useAuthStore } from "@/stores/auth-store";
import { isFilePreviewable, toInertBlob } from "@/lib/file-preview";
import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status";
import { useOwnDomainAddress } from "@/hooks/use-own-domain-address";
+import { emitThreadExpand } from "@/lib/plugin-thread-expand";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { PluginActions } from "@/components/plugins/plugin-actions";
+import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
+import { emailToReadView } from "@/lib/plugin-projection";
+import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
interface ThreadConversationViewProps {
thread: ThreadGroup;
@@ -52,6 +58,8 @@ interface ThreadConversationViewProps {
onForward?: (email: Email) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
+ /** Role of the folder the conversation was opened from, for plugin actions limited to some folders. */
+ mailboxRole?: string | null;
}
// Helper function to get file icon based on mime type or extension
@@ -166,9 +174,15 @@ export function ThreadConversationView({
onForward,
onDownloadAttachment,
onMarkAsRead,
+ mailboxRole,
}: ThreadConversationViewProps) {
const t = useTranslations();
const externalContent = useConversationExternalContent();
+ const emailIdList = emails.map((email) => email.id).join(',');
+ const threadSummaryProps = useMemo(
+ () => ({ threadId: thread.latestEmail.threadId ?? null, emailIds: emailIdList ? emailIdList.split(',') : [] }),
+ [thread.latestEmail.threadId, emailIdList],
+ );
// Track which emails are expanded (most recent by default)
const [expandedIds, setExpandedIds] = useState>(new Set());
@@ -193,6 +207,8 @@ export function ThreadConversationView({
}, [emails]);
const toggleExpanded = (emailId: string) => {
+ const target = emails.find((e) => e.id === emailId);
+ if (target) emitThreadExpand(target.threadId, [target], !expandedIds.has(emailId));
setExpandedIds(prev => {
const next = new Set(prev);
if (next.has(emailId)) {
@@ -239,6 +255,7 @@ export function ThreadConversationView({
{/* Email Cards */}
+
{emails.map((email, index) => (
onForward(email) : undefined}
onDownloadAttachment={onDownloadAttachment}
onMarkAsRead={onMarkAsRead}
+ mailboxRole={mailboxRole}
/>
))}
@@ -277,6 +295,24 @@ interface EmailCardProps {
onForward?: () => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string) => void;
onMarkAsRead?: (emailId: string, read: boolean) => void;
+ mailboxRole?: string | null;
+}
+
+/**
+ * The single reader's plugin surfaces for one message of the phone
+ * conversation, mounted only while the message is unfolded: reader-header
+ * on top, reader-below-subject and email-banner under the sender,
+ * email-footer under the body. The props are the same as in the single
+ * reader and stay stable across re-renders.
+ */
+function useConversationPluginProps(email: Email) {
+ const headerOffers = usePluginSlotOffers('reader-header').length;
+ const belowSubjectOffers = usePluginSlotOffers('reader-below-subject').length;
+ const bannerOffers = usePluginSlotOffers('email-banner').length;
+ const wantsView = headerOffers + belowSubjectOffers + bannerOffers > 0;
+ const emailProps = useMemo(() => ({ email: wantsView ? emailToReadView(email) : null }), [email, wantsView]);
+ const idProps = useMemo(() => ({ emailId: email.id }), [email.id]);
+ return { emailProps, idProps };
}
function EmailCard({
@@ -292,9 +328,11 @@ function EmailCard({
onForward,
onDownloadAttachment,
onMarkAsRead,
+ mailboxRole,
}: EmailCardProps) {
const t = useTranslations();
const density = useSettingsStore((state) => state.density);
+ const plugin = useConversationPluginProps(email);
const sender = email.from?.[0];
const formatAddress = useOwnDomainAddress();
const isUnread = !email.keywords?.$seen;
@@ -309,6 +347,7 @@ function EmailCard({
isExpanded ? "bg-background shadow-sm" : "bg-muted/30",
isUnread && !isExpanded && "border-s-2 border-l-primary"
)}>
+ {isExpanded &&
}
{/* Card Header - Always visible */}
+
+
+
- {/* Action Buttons */}
+ {/* Action Buttons; plugin reader actions behind a "more" button, as
+ in the single reader's More panel on phones. */}
{onReply && (
{t("email_viewer.forward")}
)}
+
)}
@@ -464,6 +514,14 @@ export function ConversationEmailBody({
const [hasBlockedContent, setHasBlockedContent] = useState(false);
const [cidBlobUrls, setCidBlobUrls] = useState>({});
const { client } = useAuthStore();
+ // reader-above-quote: the body the quote was collapsed in, once it is on screen.
+ const [htmlBodyEl, setHtmlBodyEl] = useState(null);
+ const [plainBodyEl, setPlainBodyEl] = useState(null);
+ const wantsQuoteSlot = usePluginSlotOffers('reader-above-quote').length > 0;
+ const quoteSlotProps = useMemo(
+ () => ({ email: wantsQuoteSlot ? emailToReadView(email) : null }),
+ [email, wantsQuoteSlot],
+ );
// Fetch inline CID images with authentication to prevent browser auth dialogs
useEffect(() => {
@@ -648,6 +706,13 @@ export function ConversationEmailBody({
${isDark ? emailDarkModeCss() : ''}
${emailContent.html}