Merge branch 'feat/plugin-system-audit'

This commit is contained in:
Linus Rath
2026-10-14 18:09:19 +02:00
352 changed files with 41083 additions and 3224 deletions
@@ -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/<pluginId>/<pageId>[/<path>].
// 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 <PluginPageApp linkSegments={(segments ?? []).map(decodeOnce)} />;
}
+6
View File
@@ -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({
<ImpersonationReconciler />
{children}
<PluginDialogHost />
<CommandPaletteHost />
<PluginConnectDialog />
<QuickRuleHost />
<PluginConsentDialog />
<PluginOAuthCallbackListener />
<PluginAppEvents />
<PWAInstallPrompt />
<PushNotificationPrompt />
</ProtocolLaunchHandlerProvider>
@@ -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<ApprovalEntry['status'], string> = {
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<ApprovalEntry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [source, setSource] = useState<SourceView | null>(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 (
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-muted-foreground" />
<h2 className="text-sm font-medium text-foreground">Approval Requests</h2>
{pending > 0 && (
<span className="text-xs px-1.5 py-0.5 rounded bg-orange-100 text-orange-700 dark:bg-orange-950/30 dark:text-orange-400">{pending} waiting</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5">Plugins users installed themselves. Read the code before you approve: an approved plugin runs in that user&apos;s mail.</p>
</div>
{error && <div className="px-4 py-2 text-sm text-destructive">{error}</div>}
{entries === null ? (
<div className="p-6 text-center text-sm text-muted-foreground"><Loader2 className="inline w-4 h-4 animate-spin" /></div>
) : entries.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">No requests yet</div>
) : (
<div className="divide-y divide-border">
{entries.map((entry) => {
const key = `${entry.pluginId}:${entry.bundleHash}`;
const perms = entry.manifest.permissions ?? [];
return (
<div key={key} className="px-4 py-3 space-y-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span className="text-sm font-medium text-foreground">{entry.manifest.name ?? entry.pluginId}</span>
<span className="text-xs text-muted-foreground">v{entry.manifest.version}</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${STATUS_STYLE[entry.status]}`}>{entry.status}</span>
</div>
{entry.manifest.description && <p className="text-xs text-muted-foreground">{entry.manifest.description}</p>}
<div className="text-xs text-muted-foreground">
{entry.pluginId} · by {entry.manifest.author ?? 'unknown'} · requested by {entry.requestedBy} on {new Date(entry.requestedAt).toLocaleString()}
</div>
{perms.length > 0 && (
<div className="flex flex-wrap gap-1">
{perms.map((p) => (
<span key={p} className={`text-[11px] px-1.5 py-0.5 rounded font-mono ${HIGH_RISK_PERMISSIONS.has(p) ? 'bg-warning/15 text-foreground' : 'bg-muted text-muted-foreground'}`}>{p}</span>
))}
</div>
)}
{[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].length > 0 && (
<div className="text-xs text-muted-foreground break-all">
Can send data to: {[...(entry.manifest.httpOrigins ?? []), ...(entry.manifest.apiPostPaths ?? [])].join(', ')}
</div>
)}
<div className="flex flex-wrap gap-2 pt-1">
<button type="button" onClick={() => viewSource(entry)} className="inline-flex items-center gap-1.5 h-7 px-3 rounded-md border border-border text-xs hover:bg-accent">
<Code className="w-3.5 h-3.5" /> View code
</button>
{entry.status !== 'approved' && (
<button type="button" disabled={busy === key} onClick={() => 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">
<Check className="w-3.5 h-3.5" /> Approve
</button>
)}
{entry.status !== 'denied' && (
<button type="button" disabled={busy === key} onClick={() => 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">
<X className="w-3.5 h-3.5" /> Deny
</button>
)}
{entry.status !== 'pending' && (
<button type="button" disabled={busy === key} onClick={() => 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
</button>
)}
</div>
</div>
);
})}
</div>
)}
{source && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" onMouseDown={(e) => { if (e.target === e.currentTarget) setSource(null); }}>
<div role="dialog" aria-modal="true" aria-label="Plugin code" className="flex max-h-[90vh] w-full max-w-4xl flex-col rounded-lg border border-border bg-background shadow-xl">
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<span className="text-sm font-medium">{source.entry.manifest.name ?? source.entry.pluginId} v{source.entry.manifest.version}</span>
<button type="button" onClick={() => setSource(null)} aria-label="Close" className="rounded p-1 hover:bg-accent"><X className="h-4 w-4" /></button>
</div>
{source.warnings && source.warnings.length > 0 && (
<div className="flex items-start gap-2 border-b border-border px-4 py-2 text-xs text-foreground">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 text-warning" /> {source.warnings.join('; ')}
</div>
)}
<pre className="min-h-0 flex-1 overflow-auto p-4 text-[11px] leading-relaxed font-mono whitespace-pre-wrap break-all">
{source.error ?? source.code ?? 'Loading…'}
</pre>
</div>
</div>
)}
</div>
);
}
+3
View File
@@ -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() {
</div>
</div>
{pluginsUploadEnabled && <PluginApprovalsPanel />}
<div className="border border-border rounded-lg">
<div className="px-4 py-3 border-b border-border bg-muted/30">
<div className="flex items-center gap-2">
+12 -6
View File
@@ -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 (
<html lang="en">
<body style={{ margin: 0, padding: 0, background: 'transparent' }}>
{/* 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' && (
<style>{'nextjs-portal{display:none!important}'}</style>
)}
{children}
</body>
</html>
+76
View File
@@ -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;
}
}
+22 -56
View File
@@ -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<string> {
@@ -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,
};
}
}
}
@@ -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<string, Record<string, unknown>>();
vi.mock('@/lib/admin/plugin-registry', () => ({
savePlugin: vi.fn(async (p: Record<string, unknown>) => { 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<typeof import('@/lib/admin/csp-frame-origins')>()),
invalidateFrameOriginsCache: vi.fn(),
}));
import { POST } from '../route';
async function bundle(manifest: Record<string, unknown>, code = 'module.exports = {};'): Promise<ArrayBuffer> {
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<string, unknown>) {
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');
});
});
+112 -221
View File
@@ -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<string> {
@@ -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<string, unknown>;
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 });
@@ -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 });
}
}
@@ -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');
});
});
+9 -3
View File
@@ -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<string | null> {
try { return await signBytes(code); } catch { return null; }
@@ -10,13 +11,18 @@ async function safeSign(code: string): Promise<string | null> {
/**
* 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) {
@@ -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' });
});
});
+5 -1
View File
@@ -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);
@@ -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<string, unknown>;
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
get: vi.fn(),
getPolicy: vi.fn(() => policy),
setPolicy: vi.fn(async (p: Record<string, unknown>) => { policy = p; }),
},
}));
const registry = new Map<string, Record<string, unknown>>();
const removedConfig: string[] = [];
vi.mock('@/lib/admin/plugin-registry', () => ({
savePlugin: vi.fn(async (p: Record<string, unknown>) => { 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<typeof import('@/lib/admin/csp-frame-origins')>()),
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<string, string | Uint8Array>) {
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']);
});
});
+44 -134
View File
@@ -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<string, unknown>;
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);
+5 -38
View File
@@ -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');
}
+13
View File
@@ -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');
}
@@ -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<string, unknown>;
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
get: vi.fn(),
getPolicy: vi.fn(() => policy),
setPolicy: vi.fn(async (p: Record<string, unknown>) => { policy = p; }),
},
}));
const saved: Array<{ theme: Record<string, unknown>; 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<string, unknown>, 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');
});
});
+22 -88
View File
@@ -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<string, unknown>;
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 });
@@ -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<Record<string, unknown>> = [];
const bundles = new Map<string, Uint8Array>();
vi.mock('@/lib/admin/plugin-approvals', async (orig) => {
const real = await orig<typeof import('@/lib/admin/plugin-approvals')>();
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<string, unknown>, 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);
});
});
+56 -19
View File
@@ -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<string | null> {
@@ -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<string, unknown>;
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 });
+83
View File
@@ -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<string, unknown>;
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=<id>&credential=<id>&url=<target>
*
* 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<string, string> = { 'cache-control': 'no-store', 'x-bulwark-proxy-error': error.code };
const retryAfter = (error as ProxyError & { retryAfter?: number }).retryAfter;
if (retryAfter) headers['retry-after'] = String(retryAfter);
return NextResponse.json({ error: error.message, code: error.code }, { status: error.status, headers });
}
return NextResponse.json({ error: 'Internal server error', code: 'internal' }, { status: 500 });
}
}
@@ -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<string, Record<string, unknown>>();
vi.mock('@/lib/plugin-secrets/vault', () => ({
VaultFullError: class extends Error {},
putUserCredential: vi.fn(async (_u: unknown, pluginId: string, credentialId: string, input: Record<string, unknown>) => {
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<typeof import('@/lib/plugin-secrets/proxy')>();
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<string, string> = {}) {
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([]);
});
});
+108
View File
@@ -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<VaultUser | NextResponse> {
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=<id>] - 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<string, unknown>;
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=<id>[&credential=<id>] - 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 });
}
+4
View File
@@ -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(
+1 -41
View File
@@ -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);
+46
View File
@@ -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);
}
@@ -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(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
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(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
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(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
expect(screen.getByText('Assistant (Writing assistant)')).toBeInTheDocument();
expect(screen.getByText('Ctrl+Shift+Y')).toBeInTheDocument();
expect(screen.queryByText(/No key/)).toBeNull();
act(() => resetActions());
});
});
@@ -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<T extends { register: (id: string, fn: (...a: unknown[]) => 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(<PluginAppEvents />);
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(<PluginAppEvents />);
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(<PluginAppEvents />);
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(<PluginAppEvents />);
expect(ready).not.toHaveBeenCalled();
act(() => {
useAuthStore.setState({ isAuthenticated: true });
usePluginStore.setState({ initialized: true });
});
rerender(<PluginAppEvents />);
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(<PluginAppEvents />);
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(<PluginAppEvents />);
pathname = '/calendar';
rerender(<PluginAppEvents />);
expect(navigate).toHaveBeenCalledWith({ path: '/calendar', from: '/' });
});
});
@@ -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<void>;
let disposable: { dispose: () => void };
@@ -35,6 +38,7 @@ describe("PluginOAuthCallbackListener", () => {
mockUsePathname.mockReturnValue("/en/mail");
handler = vi.fn<(payload: unknown) => Promise<void>>().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(<PluginOAuthCallbackListener />);
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(<PluginOAuthCallbackListener />);
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(<PluginOAuthCallbackListener />);
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(<PluginOAuthCallbackListener />);
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(<PluginOAuthCallbackListener />);
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(<PluginOAuthCallbackListener />);
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<void>>().mockResolvedValue(undefined);
const otherDisposable = authHooks.onOAuthCallback.register("other-plugin", other);
const { unmount } = render(<PluginOAuthCallbackListener />);
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(<PluginOAuthCallbackListener />);
emitStorageEvent(JSON.stringify({ code: "abc", state: "made-up", receivedAt: 1 }));
expect(handler).not.toHaveBeenCalled();
warn.mockRestore();
unmount();
});
});
@@ -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(
<EventDetailPopover
event={existing}
calendar={calendars[0]}
anchorRect={new DOMRect(100, 100, 50, 20)}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
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(
<EventDetailPopover
event={existing}
calendar={calendars[0]}
anchorRect={new DOMRect(600, 520, 120, 60)}
onEdit={vi.fn()}
onDelete={vi.fn()}
onDuplicate={vi.fn()}
onClose={vi.fn()}
onSaveNote={vi.fn()}
/>,
);
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(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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(
<EventModal
calendars={calendars}
defaultDate={new Date('2026-10-12T10:00:00')}
defaultEndDate={new Date('2026-10-12T11:30:00')}
defaultCalendarId="cal-1"
onSave={vi.fn()}
onClose={vi.fn()}
/>,
);
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(<EventModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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<HTMLElement>(`[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(<CalendarWeekView {...week} />);
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<HTMLElement>('[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(<CalendarDayView {...week} windowKey="day:2026-09-09" rangeStart={new Date(2026, 8, 9)} rangeEnd={new Date(2026, 8, 9)} />);
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(<CalendarMonthView {...month} />);
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(<CalendarMonthView {...month} isMobile />);
const dots = [...document.querySelectorAll<HTMLElement>('[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(
<MiniCalendar selectedDate={new Date(2026, 8, 1)} displayMonth={new Date(2026, 8, 1)} onSelectDate={vi.fn()} onChangeMonth={vi.fn()} />,
);
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(
<MiniCalendar selectedDate={new Date(2026, 8, 1)} displayMonth={new Date(2026, 8, 1)} onSelectDate={vi.fn()} onChangeMonth={vi.fn()} />,
);
const dot = document.querySelector<HTMLElement>('[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();
}
});
});
@@ -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(<EventModal event={existing} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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(
<EventModal
calendars={calendars}
defaultDate={new Date('2026-10-12T10:00:00')}
defaultCalendarId="cal-1"
onSave={vi.fn()}
onClose={vi.fn()}
/>,
);
await waitFor(() => expect(formOpened).toHaveBeenCalledTimes(1));
expect(formOpened.mock.calls[0][0]).toMatchObject({ calendarId: 'cal-1', uid: undefined, attendees: [] });
expect(opened).not.toHaveBeenCalled();
});
});
@@ -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> = {}): 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(<TaskModal task={makeTask('t1', { priority: 5 })} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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(<TaskModal calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
expect(screen.queryByTestId('plugin-frame-task-detail-section')).toBeNull();
unmount();
clearSlotOffers();
render(<TaskModal task={makeTask('t1')} calendars={calendars} onSave={vi.fn()} onClose={vi.fn()} />);
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(<TaskToolbar {...toolbar} taskIds={['t2', 't1']} />);
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(<TaskToolbar {...toolbar} taskIds={[]} compact />);
expect(screen.getByRole('button', { name: 'Plan my day' })).not.toHaveTextContent('Plan my day');
});
it('renders nothing without a plugin', () => {
render(<TaskToolbar {...toolbar} taskIds={['t1']} />);
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']);
});
});
+18 -2
View File
@@ -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<CalendarEvent>; 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<string, unknown>).uid;
delete (newEventData as Record<string, unknown>).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}
/>
<TaskListView
tasks={taskStore.tasks}
@@ -20,6 +20,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 CalendarDayViewProps extends ScrollWindowViewProps {
selectedDate: Date;
@@ -334,6 +335,7 @@ export function CalendarDayView({
>
{format(day, "d")}
</div>
<PluginDayLabel surface="calendar-day-header" date={key} className="w-full" />
</div>
{hasAllDayArea && (
@@ -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)}
</button>
{/* Phones have no room for the text: a dot stands in for it. */}
<PluginDayLabel surface="calendar-day-header" date={key} compact={isMobile} className={isMobile ? undefined : "min-w-0 flex-1"} />
</div>
{isMobile && !showChips ? (
<div className="flex items-center justify-center gap-0.5 flex-wrap">
@@ -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")}
</button>
<PluginDayLabel surface="calendar-day-header" date={dayKey(day)} className="w-full px-1 text-center" />
</div>
);
})}
+27 -4
View File
@@ -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 */}
<div className={cn(
"px-4 py-2 space-y-2.5 overflow-y-auto",
isMobile ? "flex-1" : ""
isMobile ? "flex-1" : "min-h-0"
)} style={isMobile ? undefined : { maxHeight: MAX_HEIGHT - 140 }}>
{/* Date & Time */}
<div className="flex items-start gap-2.5">
@@ -580,6 +601,8 @@ export function EventDetailPopover({
</p>
</div>
)}
<PluginSlot name="calendar-event-detail" className="w-full" extraProps={pluginProps} />
</div>
{/* Quick Note */}
+84 -1
View File
@@ -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<string | null>(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<CalendarEventView>(() => {
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({
</div>
</div>
)}
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
</div>
</div>
@@ -972,6 +1051,8 @@ export function EventModal({
</p>
</div>
)}
{eventDetailProps && <PluginSlot name="calendar-event-detail" className="w-full" extraProps={eventDetailProps} />}
</div>
</div>
@@ -1319,6 +1400,8 @@ export function EventModal({
</label>
</div>
)}
<PluginSlot name="calendar-event-form" className="w-full" extraProps={eventFormProps} />
</div>
</div>
+2
View File
@@ -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 && (
<span className="absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-primary" />
)}
<PluginDayLabel surface="mini-calendar-marks" date={format(day, "yyyy-MM-dd")} />
</button>
</Fragment>
);
+85
View File
@@ -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<string, RegisteredDayLabel[]>();
/** 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 (
<>
<span
aria-hidden="true"
data-plugin-surface="mini-calendar-marks"
title={tooltip(marks)}
className={cn("pointer-events-none absolute top-0 end-0 h-1.5 w-1.5 rounded-full bg-muted-foreground ring-1 ring-background", className)}
/>
<span className="sr-only">{marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}</span>
</>
);
}
if (compact) {
const shown = labels.filter((l) => l.text || l.dot);
if (shown.length === 0) return null;
return (
<span
data-plugin-surface="calendar-day-header"
data-compact=""
title={tooltip(shown)}
className={cn("inline-flex flex-shrink-0", className)}
>
<span aria-hidden="true" className="block h-1.5 w-1.5 rounded-full bg-muted-foreground" />
<span className="sr-only">{shown.map((l) => l.text ?? l.title ?? l.pluginName).join(", ")}</span>
</span>
);
}
const withText = labels.filter((l) => l.text);
if (withText.length === 0) return null;
return (
<div
data-plugin-surface="calendar-day-header"
title={tooltip(withText)}
className={cn("max-w-full truncate text-[10px] leading-tight text-muted-foreground", className)}
>
{withText.map((l) => l.text).join(" · ")}
</div>
);
}
+58 -47
View File
@@ -62,6 +62,60 @@ function getDueDateLabel(due: string, showWithoutTime: boolean, t: ReturnType<ty
};
}
/** The tasks the list shows, in its order: filtered by calendar and status. */
export function filterTasksForList(
tasks: CalendarTask[],
selectedCalendarIds: string[],
filter: TaskViewFilter,
showCompleted: boolean,
): CalendarTask[] {
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;
}
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();
+7 -1
View File
@@ -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<HTMLInputElement>(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({
<option value="1440">{t("tasks.alert_1day")}</option>
</select>
</div>
{pluginTaskProps && <PluginSlot name="task-detail-section" className="w-full" extraProps={pluginTaskProps} />}
</div>
{/* Footer */}
+13
View File
@@ -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 (
<div className="flex items-center gap-2 px-4 py-2 border-b border-border flex-wrap">
@@ -60,6 +71,8 @@ export function TaskToolbar({
<Plus className="w-4 h-4 me-1" />
{t("tasks.create")}
</Button>
<PluginActions surface="task-list-actions" context={pluginContext} compact={compact} />
</div>
);
}
@@ -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<string, string>) =>
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 ? <div>shortcuts help</div> : 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(<CommandPaletteHost />);
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(<CommandPaletteHost />);
act(() => appCommands.openCommandPalette());
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
it('leaves Ctrl+K alone while typing in a field', () => {
render(<><input aria-label="other field" /><CommandPaletteHost /></>);
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(<CommandPaletteHost />);
pressCtrlK();
expect(screen.queryByRole('dialog')).toBeNull();
unmount();
nav.pathname = '/';
useAuthStore.setState({ isAuthenticated: false } as never);
render(<CommandPaletteHost />);
pressCtrlK();
expect(screen.queryByRole('dialog')).toBeNull();
});
it('lists the app commands and no plugin group without plugin entries', () => {
render(<CommandPaletteHost />);
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(<CommandPaletteHost />);
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(<CommandPaletteHost />);
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(<CommandPaletteHost />);
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(<CommandPaletteHost />);
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(<CommandPaletteHost />);
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(<CommandPaletteHost />);
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();
});
});
@@ -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 && <CommandPalette onClose={() => setOpen(false)} onShowShortcuts={() => setShowShortcuts(true)} />}
<KeyboardShortcutsModal isOpen={showShortcuts} onClose={() => 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<HTMLUListElement>(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<PaletteCommand[]>(() => {
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<HTMLElement>(`[data-index="${activeIndex}"]`);
node?.scrollIntoView?.({ block: "nearest" });
}, [activeIndex]);
const runCommand = useCallback((command: PaletteCommand) => {
onClose();
command.run();
}, [onClose]);
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
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 (
<div
className="fixed inset-0 z-[60] flex items-start justify-center bg-black/50 p-4 pt-[12vh] max-sm:p-0 max-sm:pt-0"
onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={t("title")}
className={cn(
"w-full max-w-xl overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl",
"max-sm:h-full max-sm:max-w-none max-sm:rounded-none max-sm:border-0 max-sm:pt-[env(safe-area-inset-top)]",
"flex flex-col",
)}
>
<div className="flex items-center gap-2 border-b border-border px-3">
<Search className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<input
type="text"
role="combobox"
aria-expanded="true"
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={activeIndex >= 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"
/>
<kbd className="hidden sm:inline-flex items-center rounded border border-border bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">Esc</kbd>
</div>
<ul
ref={listRef}
id={listId}
role="listbox"
aria-label={t("title")}
className="max-h-[60vh] max-sm:max-h-none max-sm:flex-1 overflow-y-auto py-1"
>
{visible.length === 0 && (
<li role="presentation" className="px-4 py-6 text-center text-sm text-muted-foreground">{t("no_results")}</li>
)}
{visible.map((command, index) => {
const Icon = command.icon;
const showGroup = index === 0 || visible[index - 1].group !== command.group;
return (
<li key={command.id} role="presentation">
{showGroup && (
<div aria-hidden="true" className="px-3 pt-2 pb-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{groupLabel(command.group)}
</div>
)}
<div
id={optionId(index)}
role="option"
aria-selected={index === activeIndex}
data-index={index}
data-command-id={command.id}
onMouseMove={() => { 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",
)}
>
<Icon className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
<span className="flex-1 min-w-0 truncate">{command.label}</span>
{command.source && (
<span className="flex-shrink-0 truncate max-w-[40%] text-xs text-muted-foreground">{command.source}</span>
)}
</div>
</li>
);
})}
</ul>
</div>
</div>
);
}
@@ -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(<ContactDetail contact={contact} onEdit={vi.fn()} onDelete={vi.fn()} />);
// Same contact with changed data: still the same open contact.
rerender(<ContactDetail contact={{ ...contact, notes: {} }} onEdit={vi.fn()} onDelete={vi.fn()} />);
rerender(<ContactDetail contact={{ ...contact, id: '2' }} onEdit={vi.fn()} onDelete={vi.fn()} />);
rerender(<ContactDetail contact={null} onEdit={vi.fn()} onDelete={vi.fn()} />);
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' });
});
});
@@ -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(<ContactDetail contact={alice} onEdit={vi.fn()} onDelete={vi.fn()} />);
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(<ContactDetail contact={alice} onEdit={vi.fn()} onDelete={vi.fn()} />);
expect(document.querySelector('[data-plugin-slot]')).toBeNull();
unmount();
offerSlots('p1', ['contact-detail-section']);
render(<ContactDetail contact={null} onEdit={vi.fn()} onDelete={vi.fn()} />);
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(<ContactList {...props} selectedContactIds={new Set(['1', '2'])} />);
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(<ContactList {...props} selectedContactIds={new Set()} />);
expect(screen.queryByRole('button', { name: 'Sync to CRM' })).toBeNull();
unmount();
resetActions();
render(<ContactList {...props} selectedContactIds={new Set(['1'])} />);
expect(document.querySelector('[data-plugin-actions]')).toBeNull();
});
});
+18 -1
View File
@@ -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
</Section>
)}
{pluginContactProps && (
<PluginSlot name="contact-detail-section" className="w-full py-6" extraProps={pluginContactProps} />
)}
<ContactActivity contact={contact} />
{(contact.created || contact.updated) && (
+3
View File
@@ -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({
<Trash2 className="w-3.5 h-3.5 me-1" />
{t("bulk.delete")}
</Button>
<PluginActions surface="contact-list-actions" context={pluginContext} />
<Button variant="ghost" size="icon" onClick={onClearSelection} className="h-7 w-7">
<X className="w-3.5 h-3.5" />
</Button>
+23 -31
View File
@@ -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<ContactCard> = 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<ContactCard>) => {
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<ContactCard>) => {
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]);
@@ -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 },
},
@@ -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 },
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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<string, string>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof import('@/lib/template-utils')>()),
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: '<b>W</b>' };
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(<EmailComposer initialData={{ ...DATA, mode: 'compose' }} />);
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: '<b>W</b>' }));
});
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(<EmailComposer initialData={{ ...DATA, mode: 'compose' }} />);
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: '<p>Dear reader</p>', text: 'Dear reader' },
{ templateId: 'tpl-1' },
);
expect(applied).toHaveBeenCalledWith(expect.objectContaining({ id: 'tpl-1', name: 'Greeting' }));
expect(screen.getByDisplayValue('[Plugin] Hello')).toBeInTheDocument();
});
});
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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<string, unknown>,
allow: true,
}));
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: ({ name, extraProps }: { name: string; extraProps?: Record<string, unknown> }) => {
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(<EmailComposer onClose={vi.fn()} />);
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(<EmailComposer onClose={vi.fn()} />);
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(<EmailComposer onClose={vi.fn()} />);
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
@@ -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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof import('@/lib/email-sanitization')>()),
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('<p>Alice Example<br>ACME Ltd.</p>');
const QUOTE = buildQuotedHtmlBlock('<p>Can you send the photo?</p>');
const BODY =
'<p>Hello Bob,</p><p>here it is <img src="cid:photo1" data-cid="photo1" alt="photo"></p><p></p>'
+ SIGNATURE
+ '<p>On Monday, Bob wrote:</p>'
+ QUOTE;
const DRAFT = {
to: 'Bob Builder <bob@example.com>',
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<string, unknown>[] = [];
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(<EmailComposer initialData={{ ...DRAFT, body }} mode="reply" onClose={vi.fn()} />);
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<string, unknown>;
const doc = document as unknown as Record<string, unknown>;
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('<p>Hi Bob, attached.</p>', '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('<p>Thanks, that works.</p><p>On Monday, Bob wrote:</p>' + QUOTE);
expect(handle.readBody().text).toBe('Thanks, that works.');
act(() => { handle.replaceBody('<p>Thank you, that works for me.</p>', '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('<p>Hi Bob, attached.</p>', '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('<p>Dear Bob,</p>', '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('<p>x</p>', '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('<p>Suggested: Hi Bob</p>', '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();
});
});
@@ -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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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: '<p>Hello there</p>',
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<void>; sessionKey?: string }) {
const [open, setOpen] = useState(true);
if (!open) return <p>closed</p>;
return (
<EmailComposer
sessionKey={sessionKey}
requestCloseRef={requestCloseRef}
initialData={DRAFT}
onClose={() => 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(
<EmailComposer
mode="reply"
sessionKey="r1"
sourceEmailId="orig-1"
replyTo={{ from: [{ email: 'alice@example.com' }], subject: 'Plans' }}
/>,
);
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(<div key="a"><EmailComposer sessionKey="same" initialData={DRAFT} /></div>);
// Moved to another parent: React mounts a new composer.
rerender(<section key="b"><EmailComposer sessionKey="same" initialData={DRAFT} /></section>);
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(<Host />);
// 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(<Host onSend={() => 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(<EmailComposer initialData={DRAFT} />);
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(<EmailComposer prefill={{ to: ['carol@example.com'], subject: 'From a plugin' }} />);
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(<EmailComposer initialData={DRAFT} prefill={{ subject: 'Changed' }} />);
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<typeof vi.fn>) {
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(<EmailComposer initialData={DRAFT} />);
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(<EmailComposer initialData={DRAFT} />);
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);
});
@@ -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 },
},
@@ -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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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<typeof state>) => 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: '<p>Hello there</p>',
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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: '<p>Hello there</p>', 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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={{ ...DRAFT, body: 'Hello there' }} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
// 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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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<HTMLElement>(`[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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
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');
});
});
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
},
@@ -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 },
@@ -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 },
},
@@ -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<string>() });
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(<EmailList emails={[mail('a')]} />);
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(<EmailList emails={[mail('a')]} />);
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(<EmailList emails={[]} />);
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(<EmailList emails={[]} />);
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(<EmailList emails={[mail('a')]} />);
expect(screen.queryByTestId('slot-message-list-empty')).toBeNull();
unmount();
clearOffers();
render(<EmailList emails={[]} />);
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(<EmailList emails={emails} />);
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(<EmailList emails={emails} />);
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<string>() });
render(<EmailList emails={emails} />);
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(<EmailList emails={emails} />);
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(<MvListToolbar loadedCount={2} onRefresh={() => {}} />);
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(<MvListToolbar loadedCount={1} onRefresh={() => {}} />);
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
});
});
describe('message-row-actions', () => {
const row = (email: Email) => {
const [thread] = groupEmailsByThread([email]);
return <ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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(<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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<React.ComponentProps<typeof EmailContextMenu>> = {}) => render(
<EmailContextMenu
email={mail('a')}
position={{ x: 0, y: 0 }}
isOpen
onClose={props.onClose ?? (() => {})}
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();
});
});
@@ -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> = {}): 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 (
<ThreadListItem thread={thread} isExpanded={false} onToggleExpand={() => {}} 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<string>(), 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<string, unknown> }];
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: '<img src=x onerror=alert(1)>' },
]);
const { container } = render(row([makeEmail()]));
expect(await screen.findByText('<img src=x onerror=alert(1)>')).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([]);
});
});
@@ -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> = {}): 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> = {}): Email {
return mail({
textBody: [{ partId: '1', type: 'text/plain' }],
bodyValues: { '1': { value: text, isTruncated: false } },
...overrides,
} as Partial<Email>);
}
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<React.ComponentProps<typeof EmailViewer>> = {}) {
return render(
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...props} />,
);
}
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(
<EmailViewer email={null} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />,
);
rerender(<EmailViewer email={textMail('Hi')} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
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<HTMLElement>(`[${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 = '<p>New text</p><button data-quote-toggle>...</button><blockquote type="cite">Old</blockquote>';
const { unmount } = render(
<ReaderAboveQuoteSlot root={doc.body} frame={iframe} extraProps={{ email: { id: 'e1' } }} />,
{ 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<Email>);
const thread = { threadKey: 't1', latestEmail: email, emails: [email], emailCount: 1 } as unknown as ThreadGroup;
render(<ThreadConversationView thread={thread} emails={[email]} onBack={() => {}} />);
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<Email>),
textMail('Second', { id: 'm2', receivedAt: '2026-01-02T00:00:00Z' } as Partial<Email>),
];
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(<ThreadConversationView thread={thread} emails={[conversation[1], conversation[0]]} onBack={() => {}} />);
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(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} />);
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<Email>),
textMail('First', { id: 'm1', subject: 'Plan', receivedAt: '2026-01-01T00:00:00Z' } as Partial<Email>),
];
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(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} />);
// 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(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} 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(<ThreadConversationView thread={thread} emails={conversation} onBack={() => {}} 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<Email>);
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<HTMLElement>('[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<HTMLElement>('[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<Email>);
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(<ThreadConversationView thread={thread} emails={[email]} onBack={() => {}} />);
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();
});
});
@@ -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 () => [] },
@@ -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 () => [] },
@@ -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 () => [] },
@@ -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 () => [] },
@@ -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(<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
}
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();
});
});
@@ -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<string, number> = {};
const propsSeen: Record<string, unknown[]> = {};
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<string, unknown>) => (
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...extra} />
);
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);
});
});
@@ -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<string, unknown> = {}) => (
<EmailViewer email={email} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" {...extra} />
);
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(
<ThreadConversationView
thread={{ threadId: 't1', emails: [newer, older], latestEmail: newer } as never}
emails={[newer, older]}
onBack={() => {}}
/>,
);
// 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);
});
});
@@ -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<ComposerSidebarSide, ComposerSidebarMode | null> {
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<Record<ComposerSidebarSide, boolean>>(() => ({
left: readCollapsed("left"),
right: readCollapsed("right"),
}));
const [overlayOpen, setOverlayOpen] = useState<Record<ComposerSidebarSide, boolean>>({ 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 (
<div
className={cn("hidden md:flex flex-col items-center shrink-0 h-full border-border bg-background", border)}
style={{ width: COMPOSER_SIDEBAR_STRIP }}
data-composer-sidebar={side}
data-composer-sidebar-mode="folded"
>
<button
type="button"
onClick={onOpen}
className="m-1 grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
title={t("plugin_panel_show")}
aria-label={t("plugin_panel_show")}
>
<OpenIcon className="h-4 w-4" />
</button>
</div>
);
}
return (
<div
className={cn(
"hidden md:flex flex-col shrink-0 h-full border-border bg-background overflow-hidden",
border,
mode === "overlay" && cn("absolute inset-y-0 z-30 shadow-xl", side === "left" ? "start-0" : "end-0"),
)}
style={{ width: COMPOSER_SIDEBAR_WIDTH }}
data-composer-sidebar={side}
data-composer-sidebar-mode={mode}
>
<div className={cn("flex items-center px-1 py-1 border-b border-border shrink-0", side === "left" ? "justify-end" : "justify-start")}>
<button
type="button"
onClick={onClose}
className="grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
title={t("plugin_panel_hide")}
aria-label={t("plugin_panel_hide")}
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{side === "left"
? <PluginSlot name="composer-sidebar" className="flex flex-col" />
: <PluginSlot name="composer-sidebar-right" className="flex flex-col" />}
</div>
</div>
);
}
+111 -1
View File
@@ -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
</button>
);
}
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<string, unknown> | (() => Record<string, unknown>);
/** 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<string, unknown> }) {
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 (
<Button
key={`${action.pluginId}:${action.id}`}
type="button"
variant="ghost"
className={cn("hidden @[44rem]/composer-bar:inline-flex h-9 gap-1.5 px-2 sm:px-2.5", action.danger && "text-destructive")}
title={tip}
aria-label={action.label}
aria-keyshortcuts={action.shortcut}
data-plugin-action={`${action.pluginId}:${action.id}`}
onClick={() => { void runAction(action, surface, context()); }}
>
<Icon className="w-4 h-4" />
<span className="text-sm">{action.label}</span>
</Button>
);
})}
</>
);
}
/** 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 <div className="@[44rem]/composer-bar:hidden" data-narrow-only="">{children}</div>;
}
/** 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 (
<NarrowOnly active={!!onlyWhenNarrow}>
{separator && <div role="separator" className="my-1 h-px bg-border" />}
{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 (
<ComposerToolbarMenuCheckItem
key={`${action.pluginId}:${action.id}`}
checked={action.checked}
description={action.description}
onToggle={() => { void runAction(action, surface, typeof context === "function" ? context() : context); }}
>
{action.label}
</ComposerToolbarMenuCheckItem>
);
}
return (
<button
key={`${action.pluginId}:${action.id}`}
type="button"
role="menuitem"
title={action.pluginName}
onClick={() => {
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",
)}
>
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
<span className="flex-1">
<span className="block">{action.label}</span>
{action.description && <span className="block text-xs text-muted-foreground">{action.description}</span>}
</span>
</button>
);
})}
</NarrowOnly>
);
}
+459 -102
View File
@@ -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<string, unknown>;
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<boolean>(
() => 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<ComposerDraftData | null | undefined>(() => {
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<Recipient[]>(initialData ? parseRecipientList(initialData.to) : getInitialTo());
const [cc, setCc] = useState<Recipient[]>(initialData ? parseRecipientList(initialData.cc) : getInitialCc());
const [to, setTo] = useState<Recipient[]>(() => initialData
? parseRecipientList(initialData.to)
: prefill?.to ? parseRecipientList(prefill.to.join(', ')) : getInitialTo());
const [cc, setCc] = useState<Recipient[]>(() => initialData
? parseRecipientList(initialData.cc)
: prefill?.cc ? parseRecipientList(prefill.cc.join(', ')) : getInitialCc());
const [bcc, setBcc] = useState<Recipient[]>(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<string | null | undefined>(signatureIdentity?.id);
const prevSignatureSeparatorRef = useRef<boolean>(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<string | null>(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<string | null>(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<string, string>) => {
const filledSubject = Object.keys(filledValues).length > 0
const handleTemplateSelect = useCallback(async (template: EmailTemplate, filledValues: Record<string, string>) => {
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
: `<p>${filledBody.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>')}</p>`;
// Plugins may rewrite the template before it goes in (onTemplateRender).
const rendered = await renderTemplateWithPlugins(template.id, {
subject: filledSubject,
html: plainTextMode && !template.isHTML ? `<p>${escapeHtml(filledBody).replace(/\n/g, '<br>')}</p>` : 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<boolean> => {
// 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<HTMLDivElement | null>(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<string | null> => {
// `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<string | null> => {
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<string | null> => {
const saveDraft = (opts: { auto?: boolean } = {}): Promise<string | null> => {
const previous = inflightSaveRef.current;
const promise = (async (): Promise<string | null> => {
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<ReturnType<typeof draftSnapshot> | 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) => `<p>${escapeHtml(para).replace(/\r?\n/g, '<br>')}</p>`).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<OwnTextOptions & { signature: string }>({ 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<string | null>(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 (
<div
ref={setComposerRootEl}
data-testid="email-composer"
className={cn("flex h-full bg-background", className)}
className={cn("flex h-full bg-background relative", className)}
onKeyDownCapture={handleComposerKeyDown}
>
<PluginSlot
name="composer-sidebar"
className="hidden md:flex shrink-0 h-full overflow-hidden border-e border-border"
/>
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} />
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
@@ -3122,7 +3441,7 @@ export function EmailComposer({
</div>
</div>
{/* Mobile: keep scheduled send reachable without consuming footer space. */}
{composerClient?.hasDelayedSend() ? (
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
<div ref={mobileSendMenuRef} className="relative inline-flex md:hidden">
<Button
onClick={() => 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({
</Button>
{showSendMenu && (
<div role="menu" className="absolute end-0 top-full z-50 mt-2 min-w-44 rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-lg">
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
{composerClient?.hasDelayedSend() && (
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
)}
<ComposerPluginMenuItems
surface="composer-send-menu"
context={sendMenuContext}
onSelect={() => setShowSendMenu(false)}
separator={!!composerClient?.hasDelayedSend()}
/>
</div>
)}
</div>
@@ -3213,7 +3541,10 @@ export function EmailComposer({
<select
data-testid="composer-from"
value={selectedIdentityId || primaryIdentity?.id || ''}
onChange={(e) => 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({
</div>
<div className={cn("flex-1", pinFields && "min-h-0 overflow-auto")}>
{wantsPluginDraft && (
<PluginSlot name="composer-above-body" className="w-full" extraProps={pluginDraftProps} />
)}
{/* Body */}
{plainTextMode ? (
<textarea
@@ -3534,6 +3868,9 @@ export function EmailComposer({
dangerouslySetInnerHTML={{ __html: `${wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? '<div>-- </div>' : ''}${composerSignatureHtml}` }}
/>
) : null}
{wantsPluginDraft && (
<PluginSlot name="composer-below-body" className="w-full" extraProps={pluginDraftProps} />
)}
</div>
</div>
@@ -3634,18 +3971,36 @@ export function EmailComposer({
</div>
)}
{/* 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) && (
<div className="flex flex-wrap items-center gap-1 border-t border-border px-4 py-1.5 bg-background shrink-0" data-composer-plugin-row="">
<PluginSlot name="composer-toolbar" extraProps={pluginToolbarProps} />
{/* 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. */}
<PluginSlot
name="composer-attachment-source"
extraProps={pluginAttachmentSourceProps}
/>
</div>
)}
{/* 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. */}
<div
data-mv-compose-footer={mvWindow ? "" : undefined}
className={cn(
"flex items-center justify-between px-4 py-2.5 bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]",
"@container/composer-bar flex items-center justify-between px-4 py-2.5 bg-background shrink-0 pb-[calc(0.625rem+env(safe-area-inset-bottom)/2)]",
mvWindow ? "gap-1" : "border-t",
)}
>
{/* Left side actions */}
<div className="flex items-center gap-1">
<div className="flex min-w-0 flex-1 items-center gap-1">
<input
ref={fileInputRef}
type="file"
@@ -3654,7 +4009,7 @@ export function EmailComposer({
className="hidden"
accept="*/*"
/>
{filesEnabled && composerClient?.supportsFiles?.() ? (
{(filesEnabled && composerClient?.supportsFiles?.()) || hasAttachMenuActions ? (
<ComposerToolbarMenu icon={Paperclip} label={t('attach')} testId="composer-attach-menu">
{(close) => <>
<ComposerToolbarMenuItem
@@ -3663,13 +4018,16 @@ export function EmailComposer({
>
{t('attach_from_device')}
</ComposerToolbarMenuItem>
<ComposerToolbarMenuItem
icon={FolderOpen}
onSelect={() => { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
testId="composer-attach-from-files"
>
{t('attach_from_files')}
</ComposerToolbarMenuItem>
{filesEnabled && composerClient?.supportsFiles?.() && (
<ComposerToolbarMenuItem
icon={FolderOpen}
onSelect={() => { close(); setFilesPicker({ client: composerClient, accountId: composerAccountId }); }}
testId="composer-attach-from-files"
>
{t('attach_from_files')}
</ComposerToolbarMenuItem>
)}
<ComposerPluginMenuItems surface="composer-attach-menu" context={composerActionContext} onSelect={close} separator />
</>}
</ComposerToolbarMenu>
) : (
@@ -3719,10 +4077,10 @@ export function EmailComposer({
<ComposerToolbarMenu
icon={SlidersHorizontal}
label={t('options')}
activeCount={[plainTextMode, requestReadReceipt, requestDsn, requireTls].filter(Boolean).length}
activeCount={[plainTextMode, requestReadReceipt, requestDsn, requireTls].filter(Boolean).length + pluginOptionsOn}
testId="composer-options-menu"
>
{() => <>
{(close) => <>
<ComposerToolbarMenuCheckItem
checked={plainTextMode}
onToggle={togglePlainTextMode}
@@ -3759,22 +4117,15 @@ export function EmailComposer({
{t('option_require_tls')}
</ComposerToolbarMenuCheckItem>
)}
<ComposerPluginMenuItems surface="composer-options-menu" context={composerActionContext} onSelect={close} separator />
<ComposerPluginMenuItems surface="composer-toolbar-actions" context={composerActionContext} onSelect={close} separator onlyWhenNarrow />
</>}
</ComposerToolbarMenu>
<PluginSlot name="composer-toolbar" />
{/* 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. */}
<PluginSlot
name="composer-attachment-source"
extraProps={{ onAttach: handlePluginAttachmentAdd }}
/>
<ComposerPluginToolbarButtons surface="composer-toolbar-actions" context={composerActionContext} />
</div>
{/* Right side - Discard + Send (desktop) */}
<div className="flex items-center gap-2">
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={handleDiscardClick}
@@ -3788,7 +4139,7 @@ export function EmailComposer({
>
{mvWindow ? <Trash2 className="w-5 h-5" /> : t('discard')}
</button>
{composerClient?.hasDelayedSend() ? (
{composerClient?.hasDelayedSend() || hasSendMenuActions ? (
<div ref={sendMenuRef} data-mv-send={mvWindow ? "" : undefined} className="relative hidden md:inline-flex">
<Button
onClick={() => 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"
>
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
{composerClient?.hasDelayedSend() && (
<button
type="button"
role="menuitem"
onClick={openScheduleDialog}
className="flex w-full items-center gap-2 rounded-sm px-3 py-2 text-start text-sm hover:bg-accent hover:text-accent-foreground"
>
<CalendarClock className="w-4 h-4" />
{t('schedule_send')}
</button>
)}
<ComposerPluginMenuItems
surface="composer-send-menu"
context={sendMenuContext}
onSelect={() => setShowSendMenu(false)}
separator={!!composerClient?.hasDelayedSend()}
/>
</div>
)}
</div>
@@ -4063,10 +4423,7 @@ export function EmailComposer({
/>
)}
</div>
<PluginSlot
name="composer-sidebar-right"
className="hidden md:flex shrink-0 h-full overflow-hidden border-s border-border"
/>
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} />
</div>
);
}
+20
View File
@@ -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 ? (
<>
<ContextMenuSeparator />
<PluginActions
surface="message-row-actions"
variant="menu"
context={rowPluginContext}
mailboxRole={currentMailboxRole ?? null}
onRan={onClose}
/>
</>
) : 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}
<ContextMenuSeparator />
{permalinkBlock}
{rowPluginBlock}
</>
) : (
<>
@@ -581,6 +600,7 @@ export function EmailContextMenu({
{spamBlock}
<ContextMenuSeparator />
{readItem}
{rowPluginBlock}
</>
)}
</>
+21 -2
View File
@@ -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<HoverAction, {
@@ -105,6 +108,7 @@ export function EmailHoverActions({
isInJunk = false,
onUndoSpam,
spamApplicable = true,
mailboxRole = null,
}: EmailHoverActionsProps) {
const configuredHoverActions = useSettingsStore((state) => 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({
</button>
);
});
if (pluginActionCount > 0) {
actionButtons.push(
<PluginActions
key="plugin-actions"
surface="message-row-actions"
context={pluginContext}
mailboxRole={mailboxRole}
compact
className="flex-nowrap gap-0.5"
/>,
);
}
const tagPicker =
tagPickerPos && onSetTag
+74 -11
View File
@@ -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"
)}
>
<div className="px-4 py-2 border-b bg-accent/30 border-border flex items-center justify-between">
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground">
<div className={cn("py-2 border-b bg-accent/30 border-border flex items-center justify-between gap-2", batchNarrow ? "px-2" : "px-4")} data-batch-narrow={batchNarrow ? "" : undefined}>
<div className="flex items-center gap-2 min-w-0 animate-in fade-in slide-in-from-left-3 duration-300">
<span className="text-sm font-medium text-foreground whitespace-nowrap truncate">
{t('batch_actions.selected_messages', { count: selectedEmailKeys.size })}
</span>
</div>
<div className="flex items-center gap-1 animate-in fade-in slide-in-from-right-3 duration-300">
<div className={cn("flex items-center flex-shrink-0 animate-in fade-in slide-in-from-right-3 duration-300", batchNarrow ? "gap-0.5 [&_button]:px-2" : "gap-1")}>
<Button
variant="ghost"
size="sm"
@@ -636,16 +685,27 @@ export function EmailList({
<Trash2 className="w-4 h-4" />
)}
</Button>
<div className="w-px h-6 bg-border mx-1" />
{hasSelection && (
<PluginActions
surface="message-bulk-actions"
context={bulkActionContext}
mailboxRole={effectiveMailboxRole ?? null}
compact
overflow={batchNarrow}
className="flex-nowrap"
/>
)}
<div className={cn("w-px h-6 bg-border", batchNarrow ? "mx-0.5" : "mx-1")} />
<Button
variant="ghost"
size="sm"
onClick={clearSelection}
title={t('batch_actions.clear_selection')}
aria-label={t('batch_actions.clear_selection')}
disabled={isProcessing}
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
className="text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50 whitespace-nowrap"
>
{t('batch_actions.clear_selection')}
{batchNarrow ? <X className="w-4 h-4" /> : t('batch_actions.clear_selection')}
</Button>
</div>
</div>
@@ -691,6 +751,8 @@ export function EmailList({
</div>
)}
<PluginSlot name="message-list-header" className="w-full" extraProps={listSlotProps} />
{/* Email List */}
<div ref={parentRef} className="flex-1 overflow-y-auto bg-background relative" data-tour="email-list">
{/* Loading overlay */}
@@ -722,6 +784,7 @@ export function EmailList({
<p className="text-sm mt-1 text-muted-foreground">
{isScheduledView ? t('no_scheduled_emails_description') : searchQuery || !isFilterEmpty(searchFilters) ? t('no_search_results_description') : t('no_emails_description')}
</p>
<PluginSlot name="message-list-empty" className="mt-4 w-full max-w-md px-4" extraProps={emptySlotProps} />
</div>
) : (
<>
@@ -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);
+242 -151
View File
@@ -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 <PluginSlot name="email-details-section" className="mt-2 empty:mt-0" extraProps={props} />;
}
/** 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 (
<div>
@@ -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<ReadonlySet<string>>(() => new Set());
const mvExpandedIdsRef = useRef(mvExpandedIds);
mvExpandedIdsRef.current = mvExpandedIds;
const mvExpandedThreadRef = useRef<string | null>(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<boolean>(() => {
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<HTMLDivElement | null>(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<HTMLDivElement | null>(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<string | null>(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({
</Button>
</>)}
<PluginSlot name="toolbar-actions" />
{/* Phones get these in the More panel. */}
{!isMobile && email && (
<PluginActions
surface="reader-actions"
context={readerActionContext}
mailboxRole={currentMailboxRole ?? null}
compact={!showToolbarLabels}
/>
)}
</div>
{/* Right: Organize actions - order: archive, delete, move, tag, spam, read state, print, view source */}
@@ -3973,6 +4098,7 @@ export function EmailViewer({
return (
<div
ref={setViewerRootEl}
data-tour="email-viewer"
className={cn("flex-1 flex flex-row h-full bg-background overflow-hidden relative", className)}
>
@@ -4174,6 +4300,14 @@ export function EmailViewer({
{t('keyboard_shortcuts')}
</button>
)}
{email && (
<PluginPanelEntries
surface="reader-actions"
context={readerActionContext}
mailboxRole={currentMailboxRole ?? null}
onRan={() => setMoreMenuOpen(false)}
/>
)}
</>
)}
{moreMenuSub === 'move' && moveTree.length > 0 && onMoveToMailbox && (() => {
@@ -4246,6 +4380,8 @@ export function EmailViewer({
</div>
)}
<PluginSlot name="reader-header" className="w-full bg-background" extraProps={pluginEmailProps} />
{/* === SUBJECT BLOCK === */}
<div className={cn(
"bg-background",
@@ -4377,6 +4513,11 @@ export function EmailViewer({
<div className={cn("flex-1 overflow-auto overscroll-contain", mvViewer || mvMobileViewer ? "bg-background" : "bg-muted/30", isMobile && !mvMobileViewer && "pb-[calc(3.25rem+env(safe-area-inset-bottom)/2)] sm:pb-0")}>
<div className="min-h-full flex flex-col">
{/* Above the messages of the open conversation. */}
{mvOpenIndex >= 0 && (
<PluginSlot name="thread-summary" className="w-full" extraProps={threadSummaryProps} />
)}
{/* Mountain View: the conversation's earlier messages, folded. */}
{mvBefore.length > 0 && (
<div data-mv-conversation-before="">
@@ -4387,7 +4528,7 @@ export function EmailViewer({
{/* Mountain View: the open message folded like the rest of the conversation. */}
{mvOpenFolded && email ? (
<div data-mv-open-folded="">
<MvConversationCard email={email} onOpen={() => setMvOpenCollapsed(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
<MvConversationCard email={email} onOpen={() => setMvOpenFolded(false)} timeFormat={timeFormat} compact={mvMobileViewer} />
</div>
) : mvAwaitingConversation ? (
<div className={cn("flex items-start py-3 animate-pulse", mvMobileViewer ? "gap-3 px-4" : "gap-4 px-6")} data-mv-conversation-pending="">
@@ -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}
>
<div className="flex items-start" style={{ gap: mvViewer ? '16px' : 'var(--density-item-gap)' }}>
@@ -4561,7 +4702,7 @@ export function EmailViewer({
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
@@ -4599,7 +4740,7 @@ export function EmailViewer({
</span>
</div>
<div className={cn(
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
"absolute bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
)}>
<button
@@ -4618,22 +4759,11 @@ export function EmailViewer({
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -4657,7 +4787,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
@@ -4674,7 +4804,7 @@ export function EmailViewer({
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
@@ -4691,22 +4821,11 @@ export function EmailViewer({
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} iconClassName="w-3.5 h-3.5" />
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -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}
>
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
@@ -4827,6 +4946,8 @@ export function EmailViewer({
</div>
</div>
<PluginSlot name="reader-below-subject" className="w-full bg-background" extraProps={pluginEmailProps} />
{/* 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 }) => (
<PluginSlot
name="email-details-section"
className="mt-2 empty:mt-0"
extraProps={{ email: detailsView, category }}
/>
// 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) => (
<DetailsCategorySlot email={pluginEmailView} category={category} />
);
return (
@@ -5004,7 +5118,7 @@ export function EmailViewer({
)}
</Row>
</dl>
<CategorySlot category="recipients_routing" />
{categorySlot("recipients_routing")}
</section>
{hasAuthSection && (
@@ -5090,7 +5204,7 @@ export function EmailViewer({
</div>
</div>
)}
<CategorySlot category="authentication_security" />
{categorySlot("authentication_security")}
</section>
)}
@@ -5125,7 +5239,7 @@ export function EmailViewer({
<Row label={t('details.thread_id')} mono>{email.threadId}</Row>
)}
</dl>
<CategorySlot category="identifiers_threading" />
{categorySlot("identifiers_threading")}
</section>
)}
@@ -5153,7 +5267,7 @@ export function EmailViewer({
<Row label={t('details.account')}>{email.accountLabel}</Row>
)}
</dl>
<CategorySlot category="message_properties" />
{categorySlot("message_properties")}
</section>
{hasListInfo && (
@@ -5179,7 +5293,7 @@ export function EmailViewer({
<Row label={t('details.list_post')}><span className="break-all">{listHeaders.listPost}</span></Row>
)}
</dl>
<CategorySlot category="mailing_list" />
{categorySlot("mailing_list")}
</section>
)}
@@ -5187,10 +5301,7 @@ export function EmailViewer({
`category === null` render their own titled section here. */}
{hasDetailsSlotOffers && (
<section className="lg:col-span-2 min-w-0">
<PluginSlot
name="email-details-section"
extraProps={{ email: detailsView, category: null }}
/>
<DetailsCategorySlot email={pluginEmailView} category={null} />
</section>
)}
</div>
@@ -5373,7 +5484,7 @@ export function EmailViewer({
</div>
)}
<PluginSlot name="email-banner" extraProps={{ email: emailToReadView(email) }} />
<PluginSlot name="email-banner" extraProps={pluginEmailProps} />
{/* === 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 (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer flex-shrink-0 overflow-hidden",
@@ -5468,7 +5579,7 @@ export function EmailViewer({
</span>
</div>
<div className={cn(
"absolute rounded-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5",
"absolute rounded-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-e-md rounded-s-none",
)}>
<button
@@ -5487,22 +5598,11 @@ export function EmailViewer({
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -5526,7 +5626,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
@@ -5543,7 +5643,7 @@ export function EmailViewer({
<span className="text-xs text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
@@ -5560,22 +5660,11 @@ export function EmailViewer({
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} />
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -5597,7 +5686,7 @@ export function EmailViewer({
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className={cn(
"bg-muted/60 hover:bg-muted rounded-md border border-border/50 group relative cursor-pointer overflow-hidden",
@@ -5633,9 +5722,11 @@ export function EmailViewer({
<span className="text-xs text-muted-foreground flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
{/* Phones have no hover: the plugin menu stays visible. */}
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="-me-1 flex-shrink-0" iconClassName="w-3.5 h-3.5" />
</div>
<div className={cn(
"absolute bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
"absolute bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5 rounded-md",
thumbUrl ? "top-1 end-1" : "inset-y-0 end-0 rounded-s-none rounded-e-md",
)}>
<button
@@ -5654,22 +5745,10 @@ export function EmailViewer({
<Eye className="w-4 h-4 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -5692,7 +5771,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
{(dragProps) => (<>
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
@@ -5709,7 +5788,8 @@ export function EmailViewer({
<span className="text-[10px] text-muted-foreground ms-auto flex-shrink-0">
{formatFileSize(attachment.size)}
</span>
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1 px-1.5">
<AttachmentPluginMenu emailId={email?.id} attachment={attachment} className="flex-shrink-0" iconClassName="w-3.5 h-3.5" />
<div className="absolute inset-y-0 end-0 rounded-e-md bg-background/95 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto group-focus-within:opacity-100 group-focus-within:pointer-events-auto transition-opacity flex items-center justify-center gap-1 px-1.5">
<button
className="p-1 hover:bg-accent rounded transition-colors"
title={t('download')}
@@ -5726,22 +5806,10 @@ export function EmailViewer({
<Eye className="w-3.5 h-3.5 text-foreground" />
</button>
)}
<PluginSlot
name="attachment-actions"
className="contents"
extraProps={{
attachment: {
name: attachment.name || '',
type: attachment.type,
size: attachment.size,
blobId: attachment.blobId,
emailId: email?.id,
} satisfies AttachmentInfo,
}}
/>
</div>
</div>
)}
<AttachmentPluginFrames emailId={email?.id} attachment={attachment} />
</>)}
</DraggableAttachmentChip>
);
})}
@@ -5758,7 +5826,7 @@ export function EmailViewer({
{/* Email Body */}
<div className={cn(
"email-content-wrapper overflow-x-auto",
"email-content-wrapper overflow-x-auto relative",
// Folded, the body stays mounted so unfolding does not reload it.
(mvOpenFolded || mvAwaitingConversation) && "hidden",
// Mountain View indents the body under the sender's name, as Gmail does.
@@ -5790,9 +5858,10 @@ export function EmailViewer({
/>
) : (
<div
ref={setPlainBodyEl}
className="email-content-text text-foreground"
dir="auto"
dangerouslySetInnerHTML={{ __html: sanitizePlainTextRenderedHtml(effectiveEmailContent.html) }}
dangerouslySetInnerHTML={plainBodyHtml}
style={{
...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }),
fontSize: '14px',
@@ -5802,9 +5871,17 @@ export function EmailViewer({
}}
/>
)}
{!isBodyLoading && (
<ReaderAboveQuoteSlot
root={effectiveEmailContent.isHtml ? (iframeReady ? iframeRef.current?.contentDocument?.body ?? null : null) : plainBodyEl}
frame={effectiveEmailContent.isHtml ? iframeRef.current : null}
extraProps={pluginEmailProps}
version={effectiveEmailContent.html}
/>
)}
</div>
<PluginSlot name="email-footer" />
<PluginSlot name="email-footer" extraProps={pluginFooterProps} />
{/* 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 && (
<div className="flex flex-col items-center border-s border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => 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')}
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!detailSidebarCollapsed && (
{!sidebarCollapsed && (
<>
<ResizeHandle
onResizeStart={() => { 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 && (
<ResizeHandle
onResizeStart={() => { 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)}
/>
)}
<div
className="flex flex-col h-full border-s border-border bg-background overflow-hidden"
style={{ width: detailSidebarWidth, minWidth: detailSidebarWidth }}
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
className={cn(
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
sidebarOverlay && "absolute inset-y-0 end-0 z-30 shadow-xl",
)}
style={sidebarOverlay
? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) }
: { width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => setDetailSidebarCollapsed(true)}
onClick={() => {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}}
className="h-7 w-7"
aria-label="Collapse panel"
aria-label={t('plugin_panel_hide')}
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
<div className="flex-1 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={{ email }} />
<PluginSlot name="email-detail-sidebar" extraProps={pluginEmailProps} />
</div>
</div>
</>
@@ -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'); });
}
}}
/>
+8
View File
@@ -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({
</div>
)}
</div>
<PluginActions
surface="message-bulk-actions"
context={{ emailIds: selectedEmails.map((email) => email.id) }}
mailboxRole={currentRole ?? null}
compact
className="shrink-0"
/>
<span className="ms-2 text-sm text-muted-foreground truncate">
{tBatch("selected_messages", { count: selectionCount })}
</span>
+164
View File
@@ -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<string, EmailListBadge[]>();
const inflight = new Map<string, Promise<EmailListBadge[]>>();
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<string>();
for (const item of raw) {
if (out.length >= MAX_ROW_BADGES) break;
if (!item || typeof item !== "object") continue;
const candidate = item as Record<string, unknown>;
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<EmailListBadge[]> {
const pending = inflight.get(key);
if (pending) return pending;
const promise = renderHooks.onEmailListItemRender.transform<EmailListBadge[]>([], { 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 (
<span className={cn(TAG_GROUP_CLASS, className)} data-testid="plugin-row-badges">
{badges.map((badge) => (
<span
key={badge.key}
title={badge.title}
className={cn(
TAG_LOZENGE_CLASS,
"max-w-[8rem] truncate border",
!badge.color && "border-border bg-muted text-muted-foreground",
)}
style={badge.color ? {
color: badge.color,
borderColor: badge.color,
backgroundColor: `color-mix(in srgb, ${badge.color} 12%, transparent)`,
} : undefined}
>
{badge.label}
</span>
))}
</span>
);
}

Some files were not shown because too many files have changed in this diff Show More