+ {/* The left panel's button at the start, the right one's at the end: where each panel is. */}
+ {leftPanelToggle &&
);
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 899a02c6a..212d8f3ed 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -40,6 +40,7 @@ import { useBottomControls } from "@/hooks/use-bottom-controls";
import { useComposingSurface } from "@/hooks/use-composing-surface";
import { ReaderSelectionMenu, useReaderSelectionMenu, watchBodySelection, watchElementSelection } from "./reader-selection-menu";
import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
+import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/plugins/app-side-panel";
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
@@ -98,7 +99,6 @@ import {
MapPin,
StickyNote,
PanelRightClose,
- PanelRightOpen,
Send,
FolderInput,
Sun,
@@ -136,6 +136,8 @@ import { useIsPaneScoped } from "@/hooks/use-pane-context";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore, resolveEmailBlobContext, emailWriteSource, type EmailBlobContext } from "@/stores/email-store";
+import { useListSelection } from "@/lib/plugin-sandbox/assistant/b.list-selection";
+import { readability } from "@/lib/plugin-sandbox/assistant/b.projection";
import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { ExternalSenderChip } from "./external-sender-chip";
@@ -288,6 +290,8 @@ interface EmailViewerProps {
mailboxes?: Mailbox[];
selectedMailbox?: string;
className?: string;
+ /** The side panel hears of the messages ticked in the main list (false: a Pro tab, whose message isn't from that list). */
+ withListSelection?: boolean;
}
// Helper function to format recipients with contextual display
@@ -1313,6 +1317,7 @@ export function EmailViewer({
mailboxes = [],
selectedMailbox = "",
className,
+ withListSelection = true,
}: EmailViewerProps) {
const t = useTranslations('email_viewer');
const archiveActionEnabled = useArchiveActionEnabled();
@@ -1801,9 +1806,11 @@ export function EmailViewer({
// each slot post its props to the plugin again and re-run shouldShow.
const wantsPluginEmailView = hasDetailSidebar || hasDetailsSlotOffers || hasBannerOffers
|| hasHeaderOffers || hasBelowSubjectOffers || hasAboveQuoteOffers;
+ // A message of another connected login (unified view, Pro tab): the host
+ // reads parts only through the signed-in login, so plugins hear they can't.
const pluginEmailView = useMemo(
- () => (email && wantsPluginEmailView ? emailToReadView(email) : null),
- [email, wantsPluginEmailView],
+ () => (email && wantsPluginEmailView ? { ...emailToReadView(email), ...readability(blobClient === client) } : null),
+ [email, wantsPluginEmailView, blobClient, client],
);
// Reader frames learn the layout: the bar a plugin shows on phones has no
// place next to a side panel on a desktop.
@@ -1818,9 +1825,11 @@ export function EmailViewer({
setBodySelection((prev) => (prev.text === text ? prev : (text ? { text } : NO_BODY_SELECTION)));
}, []);
const [detailFocusRequest, setDetailFocusRequest] = useState(0);
+ // The messages ticked in the list, for the AI assistant (lib/plugin-sandbox/assistant).
+ const listSelection = useListSelection(withListSelection);
const pluginDetailProps = useMemo(
- () => ({ email: pluginEmailView, selection: bodySelection, focusRequest: detailFocusRequest }),
- [pluginEmailView, bodySelection, detailFocusRequest],
+ () => ({ email: pluginEmailView, selection: bodySelection, listSelection, focusRequest: detailFocusRequest }),
+ [pluginEmailView, bodySelection, listSelection, detailFocusRequest],
);
const selectionMenu = useReaderSelectionMenu();
const emailIdRef = useRef(email?.id);
@@ -1884,6 +1893,12 @@ export function EmailViewer({
setDetailSidebarForcedOpen(false);
}
};
+ // The one button that shows and hides the plugin side panel, at the end of
+ // the toolbar row in every state of the reader (message, loading, none, scheduled).
+ // The same button as in the other views' side panels (app-side-panel.tsx).
+ const panelToggle = hasDetailSidebar && !isMobile ? (
+
o.pluginId))} />
+ ) : null;
useEffect(() => onPanelRequest((request) => {
if (request.slot !== 'email-detail-sidebar') return null;
const state = detailPanelRef.current;
@@ -3858,31 +3873,29 @@ ${printWatermark ? printWatermark.html : ''}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]);
+ // The toolbar row with only the panel button: over the empty or loading
+ // reader, and on top when the user put the toolbar below the subject. Same
+ // height and place as the full toolbar, so the button never moves.
+ const toggleOnlyToolbar = panelToggle && liftToolbar ? (
+
+ ) : null;
+
// The plugin side panel. One element in the same place whatever the viewer
// shows (a message, the next one loading, none), so its frame lives on while
- // the user reads on instead of starting over with every message.
- const panelToggleInToolbar = !!email;
+ // the user reads on instead of starting over with every message. Its button
+ // is always at the end of the toolbar row above it.
const sidePanel = (
<>
{hasDetailSidebar && !isMobile && (
- {/* With a message open the toggle sits in its toolbar; without one, here. */}
- {sidebarCollapsed && !panelToggleInToolbar && (
-
-
{
- setDetailSidebarCollapsed(false);
- if (readerTooNarrow) setDetailSidebarForcedOpen(true);
- }}
- className="h-8 w-8 m-1"
- aria-label={t('plugin_panel_show')}
- >
-
-
-
- )}
{!sidebarCollapsed && (
<>
{!sidebarOverlay && (
@@ -3901,6 +3914,8 @@ ${printWatermark ? printWatermark.html : ''}
/>
)}
o.pluginId), t('plugin_panel'))}
data-plugin-detail-panel={sidebarOverlay ? 'overlay' : 'docked'}
className={cn(
"flex flex-col h-full border-s border-border bg-background overflow-hidden",
@@ -3910,22 +3925,6 @@ ${printWatermark ? printWatermark.html : ''}
? { width: Math.min(detailSidebarWidth, Math.max(200, viewerWidth - 48)) }
: { width: detailSidebarWidth, minWidth: detailSidebarWidth }}
>
- {!panelToggleInToolbar && (
-
-
{
- setDetailSidebarCollapsed(true);
- setDetailSidebarForcedOpen(false);
- }}
- className="h-7 w-7"
- aria-label={t('plugin_panel_hide')}
- >
-
-
-
- )}
@@ -3936,7 +3935,9 @@ ${printWatermark ? printWatermark.html : ''}
)}
>
);
- const shell = (content: React.ReactNode, top: React.ReactNode = null) => (
+ // `top` is the row above the message row; by default the one holding only
+ // the panel button (none without a panel or on a phone).
+ const shell = (content: React.ReactNode, top: React.ReactNode = toggleOnlyToolbar) => (
, lifted ?
: null);
+ return shell(
+
,
+ lifted ? (
+
+
+
+ ) : toggleOnlyToolbar,
+ );
}
if (!email) {
@@ -4494,22 +4503,11 @@ ${printWatermark ? printWatermark.html : ''}
)}
{/* The plugin side panel: shown or hidden for every message alike. */}
- {hasDetailSidebar && !isMobile && (
-
- {sidebarCollapsed ? : }
-
- )}
+ {toolbarPosition === 'top' && panelToggle}
)}
+ {/* A scheduled message has no organize actions; the panel button still ends the row. */}
+ {isScheduled && toolbarPosition === 'top' && panelToggle}
>
);
@@ -4546,7 +4544,7 @@ ${printWatermark ? printWatermark.html : ''}
data-tour="email-viewer"
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
>
- {toolbarPosition === 'top' && liftToolbar && topToolbar}
+ {toolbarPosition === 'top' && liftToolbar ? topToolbar : toggleOnlyToolbar}
{/* The message and the side panels, below the toolbar. Overlays anchor here. */}
<>
diff --git a/components/email/save-to-files-dialog.tsx b/components/email/save-to-files-dialog.tsx
index 6c3517698..641eb77b7 100644
--- a/components/email/save-to-files-dialog.tsx
+++ b/components/email/save-to-files-dialog.tsx
@@ -6,6 +6,7 @@ import { FolderPickerDialog } from "@/components/files/folder-picker-dialog";
import { toast } from "@/stores/toast-store";
import { fileNodeKind } from "@/lib/jmap/filenode-kind";
import { TRASH_FOLDER_NAME } from "@/lib/files-trash";
+import { saveBlobsToFiles } from "@/lib/files-save";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { FileNode } from "@/lib/jmap/types";
@@ -34,6 +35,18 @@ interface SaveToFilesDialogProps {
// Nothing is "where the items already are": every folder, My files too, can be picked.
const NO_CURRENT_FOLDER = "#none";
+/** The path of a folder from the picker's list ('/' for My files), for plugins. */
+function folderPathOf(folders: { id: string; name: string; parentId: string | null }[], id: string | null): string {
+ const byId = new Map(folders.map((f) => [f.id, f]));
+ const names: string[] = [];
+ const seen = new Set();
+ for (let at = id ? byId.get(id) : undefined; at && !seen.has(at.id); at = at.parentId ? byId.get(at.parentId) : undefined) {
+ seen.add(at.id);
+ names.unshift(at.name);
+ }
+ return `/${names.join("/")}`;
+}
+
/**
* "Save to Files" for an attachment: pick a folder with the Files app's
* folder picker. The attachment's blob is already on the server, so it is
@@ -75,17 +88,12 @@ export function SaveToFilesDialog({ attachment, client, blobAccountId, onClose }
if (!attachment?.blobId) return;
const folderName = parentId ? (folders.find((f) => f.id === parentId)?.name ?? "") : tFiles("my_files");
try {
- const filesAccountId = blobAccountId ? client.getFilesAccountId() : undefined;
- const blobId = blobAccountId && filesAccountId && blobAccountId !== filesAccountId
- ? (await client.copyBlobs(blobAccountId, [attachment.blobId], filesAccountId))[attachment.blobId]
- : attachment.blobId;
- await client.createFileNode(
- attachment.name || "attachment",
- blobId,
- attachment.type || "application/octet-stream",
- attachment.size,
- parentId,
+ const { saved } = await saveBlobsToFiles(
+ [{ blobId: attachment.blobId, name: attachment.name, type: attachment.type, size: attachment.size }],
+ { client, parentId, path: folderPathOf(folders, parentId), blobAccountId },
);
+ // A plugin stopped it (onBeforeFileUpload); it says why itself.
+ if (saved.length === 0) return;
toast.success(t("saved"), t("saved_to", { folder: folderName }));
} catch (error) {
console.error("Failed to save the attachment to Files:", error);
diff --git a/components/files/__tests__/files-plugin-surfaces.test.tsx b/components/files/__tests__/files-plugin-surfaces.test.tsx
index 2890e8b92..1044bb952 100644
--- a/components/files/__tests__/files-plugin-surfaces.test.tsx
+++ b/components/files/__tests__/files-plugin-surfaces.test.tsx
@@ -223,6 +223,14 @@ describe('file-preview-section', () => {
onToggleStar: vi.fn(),
} as unknown as React.ComponentProps;
+ it('gives the details panel the same header height as the other side panels', () => {
+ render( );
+ const header = document.querySelector('[data-details-header]') as HTMLElement;
+ expect(header.className).toContain('h-14');
+ expect(header.className).toContain('box-content');
+ expect(header.className).not.toContain('h-11');
+ });
+
it('shows the selected file in the details panel', () => {
offerSlots('p1', ['file-preview-section']);
render( );
diff --git a/components/files/__tests__/files-side-panel.test.tsx b/components/files/__tests__/files-side-panel.test.tsx
new file mode 100644
index 000000000..2b9d4a234
--- /dev/null
+++ b/components/files/__tests__/files-side-panel.test.tsx
@@ -0,0 +1,173 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { FileBrowser } from '../file-browser';
+import { useFileStore, type FileResource } from '@/stores/file-store';
+import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+
+// The plugin side panel (app-side-panel) in Files: its button right after
+// the details button, at the end of the header in both of its shapes, the
+// panel last in the row under it with the selected files and the folder,
+// and the details panel and it taking turns.
+
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/settings/share-collection-dialog', () => ({ ShareCollectionDialog: () => null }));
+vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+const device = vi.hoisted(() => ({ isMobile: false }));
+vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useIsMobile: () => device.isMobile,
+}));
+
+const folder = {
+ id: 'f-docs', name: 'Documents', serverName: 'Documents', isDirectory: true, contentType: '', contentLength: 0,
+ lastModified: '2026-10-01T00:00:00Z', blobId: null, parentId: null,
+} as FileResource;
+const notes = {
+ id: 'f-notes', name: 'notes.txt', serverName: 'notes.txt', isDirectory: false, contentType: 'text/plain', contentLength: 10,
+ lastModified: '2026-10-01T00:00:00Z', blobId: 'b-notes', parentId: null,
+} as FileResource;
+const photo = {
+ id: 'f-photo', name: 'photo.png', serverName: 'photo.png', isDirectory: false, contentType: 'image/png', contentLength: 99,
+ lastModified: '2026-10-02T00:00:00Z', blobId: 'b-photo', parentId: null,
+} as FileResource;
+
+function renderBrowser(selected: string[] = []) {
+ const client = { listAllFileNodesAcrossAccounts: vi.fn(async () => []) } as unknown as IJMAPClient;
+ act(() => {
+ useFileStore.setState({
+ client, sharedRoots: [], allFolders: [folder], currentParentId: null, pathStack: [{ id: null, name: '' }], hasListed: true,
+ });
+ });
+ const noop = vi.fn(async () => {});
+ render(
+ ''}
+ favorites={[]}
+ recentFiles={[]}
+ onToggleFavorite={vi.fn()}
+ onOpenRecent={vi.fn()}
+ onRenameNode={noop}
+ onDeleteNode={noop}
+ onDownloadNode={noop}
+ onRevealNode={noop}
+ onRemoveRecent={vi.fn()}
+ client={client}
+ ownAccountId="a"
+ />,
+ );
+}
+
+beforeEach(() => {
+ device.isMobile = false;
+ localStorage.clear();
+});
+afterEach(() => {
+ cleanup();
+ clearSlotOffers();
+});
+
+const frame = () => screen.queryByTestId('plugin-frame-app-side-panel');
+const toggle = () => document.querySelector('[data-plugin-panel-toggle]')!;
+const details = () => screen.getByRole('button', { name: 'details' });
+
+describe('Files side panel', () => {
+ it('has no button without a plugin', () => {
+ renderBrowser();
+ expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
+ });
+
+ it('puts the button right after the details button, last in the header, and the folder in the context', () => {
+ offerSlots('p1', ['app-side-panel']);
+ renderBrowser();
+ const header = details().closest('.h-14')!;
+ const buttons: HTMLElement[] = Array.from(header.querySelectorAll('button'));
+ expect(buttons.indexOf(toggle())).toBe(buttons.indexOf(details()) + 1);
+ expect(buttons[buttons.length - 1]).toBe(toggle());
+ const panel = document.querySelector('[data-app-side-panel]')!;
+ expect(panel.parentElement!.lastElementChild).toBe(panel);
+ expect(framePropsOf(frame()!)).toEqual({ view: 'files', context: { folder: { id: null, path: '/' } }, focusRequest: 0 });
+ });
+
+ it('names the selected files, and keeps the button last in the selection bar', () => {
+ offerSlots('p1', ['app-side-panel']);
+ renderBrowser(['notes.txt', 'photo.png']);
+ const bar = screen.getByRole('toolbar', { name: 'selection_actions' });
+ const buttons = Array.from(bar.querySelectorAll('button'));
+ expect(buttons[buttons.length - 1]).toBe(toggle());
+ expect(buttons[buttons.length - 2]).toBe(details());
+ const context = framePropsOf(frame()!).context as { files: { name: string }[]; fileCount: number };
+ expect(context.files.map((f) => f.name)).toEqual(['notes.txt', 'photo.png']);
+ expect(context.fileCount).toBe(2);
+ });
+
+ it('folds the details when the panel opens, and the panel when the details open', () => {
+ offerSlots('p1', ['app-side-panel']);
+ localStorage.setItem('appSidePanelCollapsed', '1');
+ renderBrowser();
+ expect(frame()).toBeNull();
+ fireEvent.click(details());
+ expect(details()).toHaveAttribute('aria-pressed', 'true');
+
+ fireEvent.click(toggle());
+ expect(frame()).not.toBeNull();
+ expect(details()).toHaveAttribute('aria-pressed', 'false');
+
+ fireEvent.click(details());
+ expect(details()).toHaveAttribute('aria-pressed', 'true');
+ expect(frame()).toBeNull();
+ expect(toggle()).toHaveAttribute('aria-pressed', 'false');
+ // Only here: the other views keep the panel open as the user left it.
+ expect(localStorage.getItem('appSidePanelCollapsed')).toBe('0');
+ // The details go, the panel is back.
+ fireEvent.click(details());
+ expect(frame()).not.toBeNull();
+ });
+
+ it('never shows both: with both stored open the details fold when the page loads', () => {
+ offerSlots('p1', ['app-side-panel']);
+ localStorage.setItem('appSidePanelCollapsed', '0');
+ localStorage.setItem('files-show-details', '1');
+ renderBrowser();
+ expect(frame()).not.toBeNull();
+ expect(details()).toHaveAttribute('aria-pressed', 'false');
+ expect(document.querySelector('[data-app-side-panel]')).toBeTruthy();
+ });
+
+ it('has no button in the header on a phone', () => {
+ device.isMobile = true;
+ offerSlots('p1', ['app-side-panel']);
+ act(() => renderBrowser());
+ expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
+ expect(frame()).toBeNull();
+ });
+});
diff --git a/components/files/file-browser.tsx b/components/files/file-browser.tsx
index e1f272ac1..37d375816 100644
--- a/components/files/file-browser.tsx
+++ b/components/files/file-browser.tsx
@@ -32,6 +32,8 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { AppMain } from "@/components/layout/app-landmarks";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
+import { useAppSidePanel } from "@/components/plugins/app-side-panel";
+import { MAX_SIDE_PANEL_FILES } from "@/lib/plugin-sandbox/assistant/f.types";
import { resourceToView, useFileStore, isSharedFilesPage, sharedFilePages, type FileResource } from "@/stores/file-store";
import { TRASH_LOCATION, TRASH_PATH, isTrashLocation } from "@/lib/files-trash";
import { PluginActions, usePluginActions } from "@/components/plugins/plugin-actions";
@@ -439,6 +441,26 @@ export function FileBrowser({
// What plugins see of the selection (file-actions, file-preview-section).
const selectedViews = useMemo(() => selectedList.map((r) => resourceToView(r, currentPath)), [selectedList, currentPath]);
+ // The plugin side panel (app-side-panel), with the selected files and the
+ // open folder. It shares its place with the details panel: opening it
+ // folds the details, and while the details are open it steps aside here
+ // only (the other views keep it as the user left it).
+ const sidePanelContext = useMemo(() => ({
+ ...(selectedViews.length > 0 ? { files: selectedViews.slice(0, MAX_SIDE_PANEL_FILES), fileCount: selectedViews.length } : {}),
+ folder: { id: currentParentId ?? null, path: currentPath },
+ }), [selectedViews, currentParentId, currentPath]);
+ const sidePanel = useAppSidePanel({ view: "files", context: sidePanelContext, hidden: showDetails, onOpen: () => toggleDetails(false) });
+ // Both stored open (the details here, the panel in any view): once the
+ // panel shows here the details fold, so the two never sit side by side.
+ const sidePanelDocks = sidePanel.docks;
+ const sidePanelWanted = sidePanel.wantsOpen;
+ const sidePanelSettled = useRef(false);
+ useEffect(() => {
+ if (sidePanelSettled.current || !sidePanelDocks) return;
+ sidePanelSettled.current = true;
+ if (showDetails && sidePanelWanted) toggleDetails(false);
+ }, [sidePanelDocks, sidePanelWanted, showDetails, toggleDetails]);
+
// ---- Breadcrumbs -----------------------------------------------------------
const crumbs: Crumb[] = useMemo(() => {
const segments = currentPath.split("/").filter(Boolean);
@@ -1283,6 +1305,7 @@ export function FileBrowser({
toolsHidden={accountPickerMode}
selection={selectionActions}
emptyTrash={inTrash && onEmptyTrash ? { onEmpty: onEmptyTrash, disabled: trashCount === 0 } : undefined}
+ panelToggle={sidePanel.toggle}
/>
{error && (
@@ -1296,7 +1319,7 @@ export function FileBrowser({
)}
-
+
)}
+ {sidePanel.panel}
diff --git a/components/files/file-details-panel.tsx b/components/files/file-details-panel.tsx
index f3217de20..34facf37d 100644
--- a/components/files/file-details-panel.tsx
+++ b/components/files/file-details-panel.tsx
@@ -59,7 +59,8 @@ export function FileDetailsPanel({
const pluginProps = useMemo(() => (pluginFile ? { file: pluginFile } : null), [pluginFile]);
const header = (
-
+ // h-14 like the other side panels' headers (the contact panel, the plugin panels).
+
{t("details")}
diff --git a/components/files/files-header.tsx b/components/files/files-header.tsx
index a4e90e3d6..005bcc4ba 100644
--- a/components/files/files-header.tsx
+++ b/components/files/files-header.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo, useState } from "react";
+import { useMemo, useState, type ReactNode } from "react";
import { useTranslations } from "next-intl";
import {
Menu, ChevronRight, Search, X, ArrowUpDown, Check, LayoutGrid, LayoutList,
@@ -84,6 +84,8 @@ interface FilesHeaderProps {
selection: SelectionActions | null;
/** In the trash: the Empty trash button. */
emptyTrash?: { onEmpty: () => void; disabled: boolean };
+ /** The plugin side panel's button, after the details button (desktop and tablet). */
+ panelToggle?: ReactNode;
}
const MAX_VISIBLE_CRUMBS = 3;
@@ -114,6 +116,7 @@ export function FilesHeader({
toolsHidden,
selection,
emptyTrash,
+ panelToggle,
}: FilesHeaderProps) {
const t = useTranslations("files");
const sortMenu = useContextMenu();
@@ -122,6 +125,8 @@ export function FilesHeader({
const [dropCrumb, setDropCrumb] = useState(null);
const headerClass = "flex items-center gap-1 px-3 h-14 box-content border-b border-border min-w-0 flex-shrink-0";
+ // The row is px-3; the margin puts the button where the reader has it (px-4, lg:px-6).
+ const toggleSlot = panelToggle ? {panelToggle}
: null;
if (selection) {
return (
@@ -169,6 +174,7 @@ export function FilesHeader({
{compact && !selection.trash && }
+ {toggleSlot}
);
}
@@ -360,6 +366,7 @@ export function FilesHeader({
)}
+ {toggleSlot}
(
diff --git a/components/layout/phone-header-actions.tsx b/components/layout/phone-header-actions.tsx
index fee8315dc..e33815115 100644
--- a/components/layout/phone-header-actions.tsx
+++ b/components/layout/phone-header-actions.tsx
@@ -7,6 +7,7 @@ import { IconButton } from "@/components/ui/icon-button";
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
import { AccountSwitcher } from "@/components/layout/account-switcher";
import { PluginActions } from "@/components/plugins/plugin-actions";
+import { AppSidePanelPhoneButton } from "@/components/plugins/app-side-panel";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { openCommandPalette } from "@/lib/app-commands";
@@ -140,6 +141,8 @@ export function PhoneHeaderActions({
return (
+ {/* A plugin's side panel of this view, which opens as a dialog on a phone. */}
+
{/* Which account you are in is otherwise only visible in Settings.
Last, so it sits on the far edge, mirrored under RTL by the flex row. */}
diff --git a/components/plugins/__tests__/app-side-panel.test.tsx b/components/plugins/__tests__/app-side-panel.test.tsx
new file mode 100644
index 000000000..21711e94d
--- /dev/null
+++ b/components/plugins/__tests__/app-side-panel.test.tsx
@@ -0,0 +1,287 @@
+import React from 'react';
+import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { clearOffers, FakeIframeSlot, offerSlots, slotProps } from './fake-plugin-offer';
+import { requestPanel, resetPanelRequests } from '@/lib/plugin-sandbox/panel-requests';
+import { setActionDispatcher } from '@/lib/plugin-sandbox/actions';
+import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
+import { registerShortcuts } from '@/lib/plugin-sandbox/shortcuts';
+
+// The plugin side panel of Calendar, Contacts, Files and Settings
+// (app-side-panel): its button, folding, width, the overlay on a narrow
+// view, focus requests, the plugin's own requests, and the dialog where it
+// can't dock.
+
+vi.mock('@/components/plugins/plugin-iframe-slot', () => ({ PluginIframeSlot: FakeIframeSlot }));
+const device = vi.hoisted(() => ({ isMobile: false }));
+vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
+ ...(await importOriginal
()),
+ useIsMobile: () => device.isMobile,
+}));
+
+const { useAppSidePanel, AppSidePanelPhoneButton, APP_SIDE_PANEL_COLLAPSED_KEY, APP_SIDE_PANEL_WIDTH_KEY } = await import('../app-side-panel');
+const { EmbeddedContext } = await import('@/hooks/use-is-embedded');
+import type { AppSidePanelContext, AppSidePanelViewName } from '@/lib/plugin-sandbox/assistant/f.types';
+
+// One ResizeObserver per row; tests set the row's width with resizeRow().
+let observed: ((width: number) => void) | null = null;
+class FakeResizeObserver {
+ constructor(private cb: ResizeObserverCallback) {}
+ observe() { observed = (width) => this.cb([{ contentRect: { width } } as ResizeObserverEntry], this as unknown as ResizeObserver); }
+ disconnect() { observed = null; }
+ unobserve() {}
+}
+const resizeRow = (width: number) => act(() => observed?.(width));
+
+const EVENT_CONTEXT: AppSidePanelContext = { page: { id: 'reading', label: 'Reading' } };
+
+function Harness({ view = 'settings', context = EVENT_CONTEXT, dock = true, hidden = false, onOpen }: { view?: AppSidePanelViewName; context?: AppSidePanelContext; dock?: boolean; hidden?: boolean; onOpen?: () => void }) {
+ const panel = useAppSidePanel({ view, context, dock, hidden, onOpen });
+ return (
+
+
{panel.toggle}
+
{panel.panel}
+
+
+ );
+}
+
+const toggle = () => screen.getByRole('button', { name: 'plugin_panel' });
+const frame = () => screen.queryByTestId('slot-app-side-panel');
+
+beforeEach(() => {
+ device.isMobile = false;
+ localStorage.clear();
+ vi.stubGlobal('ResizeObserver', FakeResizeObserver);
+});
+afterEach(() => {
+ cleanup();
+ clearOffers();
+ resetPanelRequests();
+ setActionDispatcher(null);
+ vi.unstubAllGlobals();
+});
+
+describe('app-side-panel', () => {
+ it('shows nothing without a plugin offering it', () => {
+ render( );
+ expect(screen.queryByRole('button')).toBeNull();
+ expect(document.querySelector('[data-app-side-panel]')).toBeNull();
+ });
+
+ it('opens and folds from its button, and the frame gets the view, its context and a focus request', () => {
+ offerSlots('p1', ['app-side-panel'], { fill: ['app-side-panel'] });
+ render( );
+ // Open until the user folds it (the plugin didn't ask to start folded).
+ expect(frame()).not.toBeNull();
+ expect(toggle()).toHaveAttribute('aria-pressed', 'true');
+ // One name; aria-pressed says whether it is open.
+ expect(toggle()).toHaveAttribute('title', 'plugin_panel');
+ expect(screen.getByRole('complementary', { name: 'p1' })).toContainElement(frame());
+ expect(slotProps(frame()!)).toEqual({ view: 'settings', context: EVENT_CONTEXT, focusRequest: 0 });
+ expect(frame()!.getAttribute('data-fill')).toBe('true');
+
+ fireEvent.click(toggle());
+ expect(frame()).toBeNull();
+ expect(toggle()).toHaveAttribute('aria-pressed', 'false');
+ expect(toggle()).toHaveAccessibleName('plugin_panel');
+ expect(localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY)).toBe('1');
+
+ // Opening asks the plugin to take the focus.
+ fireEvent.click(toggle());
+ expect(slotProps(frame()!).focusRequest).toBe(1);
+ expect(localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY)).toBe('0');
+ });
+
+ it('starts folded when the plugin asks, until the user opens it', () => {
+ offerSlots('p1', ['app-side-panel'], { startFolded: ['app-side-panel'] });
+ const { unmount } = render( );
+ expect(frame()).toBeNull();
+ fireEvent.click(toggle());
+ expect(frame()).not.toBeNull();
+ unmount();
+ // The choice holds in the next view.
+ render( );
+ expect(frame()).not.toBeNull();
+ });
+
+ it('keeps its width between 200 and 500 px, and remembers it', () => {
+ offerSlots('p1', ['app-side-panel']);
+ localStorage.setItem(APP_SIDE_PANEL_WIDTH_KEY, '900');
+ render( );
+ const panel = document.querySelector('[data-app-side-panel]')!;
+ expect(panel.style.width).toBe('500px');
+ const handle = screen.getByRole('separator');
+ expect(handle).toHaveAttribute('aria-valuemin', '200');
+ expect(handle).toHaveAttribute('aria-valuemax', '500');
+ fireEvent.doubleClick(handle);
+ expect(panel.style.width).toBe('280px');
+ expect(localStorage.getItem(APP_SIDE_PANEL_WIDTH_KEY)).toBe('280');
+ });
+
+ it('folds when the view gets too narrow, and lies over it when opened anyway', () => {
+ offerSlots('p1', ['app-side-panel']);
+ render( );
+ resizeRow(1200);
+ expect(document.querySelector('[data-app-side-panel]')).toHaveAttribute('data-app-side-panel', 'docked');
+ resizeRow(600);
+ expect(frame()).toBeNull();
+ fireEvent.click(toggle());
+ const panel = document.querySelector('[data-app-side-panel]')!;
+ expect(panel).toHaveAttribute('data-app-side-panel', 'overlay');
+ expect(panel.className).toContain('absolute');
+ // No drag handle over the view.
+ expect(screen.queryByRole('separator')).toBeNull();
+ });
+
+ it('answers the plugin\'s open, close and toggle requests', () => {
+ offerSlots('p1', ['app-side-panel'], { startFolded: ['app-side-panel'] });
+ const onOpen = vi.fn();
+ render( );
+ expect(frame()).toBeNull();
+
+ let answer: boolean | null = null;
+ act(() => { answer = requestPanel('p1', 'app-side-panel', 'open', undefined); });
+ expect(answer).toBe(true);
+ expect(frame()).not.toBeNull();
+ expect(slotProps(frame()!).focusRequest).toBe(1);
+ expect(onOpen).toHaveBeenCalledTimes(1);
+
+ act(() => { answer = requestPanel('p1', 'app-side-panel', 'toggle', undefined); });
+ expect(answer).toBe(false);
+ expect(frame()).toBeNull();
+
+ act(() => { answer = requestPanel('p1', 'app-side-panel', 'close', undefined); });
+ expect(answer).toBe(false);
+ // The reader's panel is not this one.
+ act(() => { answer = requestPanel('p1', 'email-detail-sidebar', 'open', undefined); });
+ expect(answer).toBeNull();
+ });
+
+ it('is not there in the Pro shell\'s panes', () => {
+ offerSlots('p1', ['app-side-panel']);
+ render( );
+ expect(screen.queryByRole('button')).toBeNull();
+ expect(requestPanel('p1', 'app-side-panel', 'open', undefined)).toBeNull();
+ });
+
+ it('asks the plugin for its dialog on a phone, with the view and what it shows', async () => {
+ device.isMobile = true;
+ const dispatch = vi.fn().mockResolvedValue(undefined);
+ setActionDispatcher(dispatch);
+ offerSlots('p1', ['app-side-panel']);
+ render( );
+ // No panel and no toolbar button; the phone header has the button.
+ expect(screen.queryByTestId('toolbar')!.childElementCount).toBe(0);
+ expect(document.querySelector('[data-app-side-panel]')).toBeNull();
+ const phone = screen.getByRole('button', { name: 'plugin_panel_show' });
+ expect(screen.getByTestId('phone-header')).toContainElement(phone);
+
+ fireEvent.click(phone);
+ expect(dispatch).toHaveBeenCalledWith('p1', 'app-side-panel', 'open', { view: 'calendar', context: { selectedCount: 2 } });
+
+ // The plugin's own open request goes the same way; the panel stays closed.
+ let answer: boolean | null = null;
+ act(() => { answer = requestPanel('p1', 'app-side-panel', 'toggle', undefined); });
+ expect(answer).toBe(false);
+ expect(dispatch).toHaveBeenCalledTimes(2);
+ act(() => { answer = requestPanel('p1', 'app-side-panel', 'close', undefined); });
+ expect(dispatch).toHaveBeenCalledTimes(2);
+ });
+
+ it('uses the dialog where the view says it has no room (Settings below 1024 px)', () => {
+ const dispatch = vi.fn().mockResolvedValue(undefined);
+ setActionDispatcher(dispatch);
+ offerSlots('p1', ['app-side-panel']);
+ const { unmount } = render( );
+ expect(screen.getByTestId('toolbar').childElementCount).toBe(0);
+ fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
+ expect(dispatch).toHaveBeenCalledWith('p1', 'app-side-panel', 'open', { view: 'settings', context: EVENT_CONTEXT });
+ // Leaving the view takes the phone header's button away.
+ unmount();
+ render();
+ expect(screen.queryByRole('button')).toBeNull();
+ });
+
+ it("shows no button and no panel when the plugin's shouldShow says no", async () => {
+ const evaluateShouldShow = vi.fn(async () => false);
+ register({
+ plugin: { id: 'p2', name: 'P2', permissions: ['ui:side-panel'], managed: true },
+ code: '', tier: 'untrusted', background: { evaluateShouldShow },
+ slotOffers: [{ name: 'app-side-panel', order: 100, hasShouldShow: true }], hookDisposables: [],
+ } as unknown as ActivePlugin);
+ try {
+ render( );
+ await waitFor(() => expect(evaluateShouldShow).toHaveBeenCalledWith('app-side-panel', { view: 'calendar', context: {} }));
+ expect(screen.queryByRole('button')).toBeNull();
+ expect(document.querySelector('[data-app-side-panel]')).toBeNull();
+ expect(requestPanel('p2', 'app-side-panel', 'open', undefined)).toBeNull();
+ // It says yes: the button comes.
+ evaluateShouldShow.mockResolvedValue(true);
+ cleanup();
+ render( );
+ expect(await screen.findByRole('button', { name: 'plugin_panel' })).toBeTruthy();
+ } finally {
+ deregister('p2');
+ }
+ });
+
+ it('steps aside while the view hides it, without changing the choice the other views keep', () => {
+ offerSlots('p1', ['app-side-panel']);
+ const onOpen = vi.fn();
+ const { rerender } = render( );
+ expect(frame()).toBeNull();
+ expect(toggle()).toHaveAttribute('aria-pressed', 'false');
+ expect(localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY)).toBeNull();
+ rerender( );
+ expect(frame()).not.toBeNull();
+ // Opening it while hidden asks the view to make room.
+ rerender( );
+ fireEvent.click(toggle());
+ expect(onOpen).toHaveBeenCalledTimes(1);
+ });
+
+ it('grows only as far as the view keeps its room, so resizing never folds it', () => {
+ offerSlots('p1', ['app-side-panel']);
+ render( );
+ resizeRow(900);
+ const handle = screen.getByRole('separator');
+ expect(handle).toHaveAttribute('aria-valuemax', '420');
+ handle.focus();
+ for (let i = 0; i < 30; i++) fireEvent.keyDown(handle, { key: 'ArrowLeft' });
+ const panel = document.querySelector('[data-app-side-panel]')!;
+ expect(panel).toHaveAttribute('data-app-side-panel', 'docked');
+ expect(panel.style.width).toBe('420px');
+ // The handle is still there and keeps the focus.
+ expect(document.activeElement).toBe(handle);
+ // A drag stops at the same place.
+ fireEvent.pointerDown(handle, { clientX: 500, pointerId: 1, button: 0 });
+ fireEvent.pointerMove(document, { clientX: 100, pointerId: 1 });
+ fireEvent.pointerUp(document, { clientX: 100, pointerId: 1 });
+ expect(document.querySelector('[data-app-side-panel]')).toHaveAttribute('data-app-side-panel', 'docked');
+ expect(document.querySelector('[data-app-side-panel]')!.style.width).toBe('420px');
+ });
+
+ it('gives the focus back to its button when it closes with the focus inside', async () => {
+ offerSlots('p1', ['app-side-panel']);
+ render( );
+ const panel = screen.getByRole('complementary');
+ panel.tabIndex = -1;
+ panel.focus();
+ act(() => { requestPanel('p1', 'app-side-panel', 'close', undefined); });
+ await waitFor(() => expect(document.activeElement).toBe(toggle()));
+ });
+
+ it("names the plugin's panel shortcut in the button's title", () => {
+ offerSlots('p1', ['app-side-panel']);
+ const dispose = registerShortcuts({ pluginId: 'p1', invokeHook: vi.fn() } as never, [{ id: 'toggle-panel', keys: 'Alt+A', label: 'Toggle' }]);
+ try {
+ render( );
+ expect(toggle()).toHaveAttribute('title', 'plugin_panel (Alt+A)');
+ expect(toggle()).toHaveAttribute('aria-keyshortcuts', 'Alt+A');
+ expect(toggle()).toHaveAccessibleName('plugin_panel');
+ } finally {
+ dispose();
+ }
+ });
+});
diff --git a/components/plugins/__tests__/plugin-dialog-fill.test.tsx b/components/plugins/__tests__/plugin-dialog-fill.test.tsx
new file mode 100644
index 000000000..add97a70d
--- /dev/null
+++ b/components/plugins/__tests__/plugin-dialog-fill.test.tsx
@@ -0,0 +1,75 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+
+// A plugin dialog that asks to be filled (api.ui.openDialog({ fill: true })):
+// its frame takes the dialog's height, which follows what the window shows,
+// so a chat's input stays in view on a phone with the keyboard up.
+
+vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
+vi.mock('../plugin-iframe-slot', () => ({
+ PluginIframeSlot: ({ fill }: { fill?: boolean }) =>
,
+}));
+
+import { PluginDialogHost } from '../plugin-dialog-host';
+import { awaitCustomDialog, head, resetDialogLimits, resolveHead } from '@/lib/plugin-sandbox/host-dialog';
+import { isFillableSlot } from '@/lib/plugin-sandbox/slot-layout';
+
+let listeners: (() => void)[] = [];
+const viewport = { height: 667, addEventListener: (_: string, fn: () => void) => { listeners.push(fn); }, removeEventListener: () => {} };
+
+beforeEach(() => {
+ while (head()) resolveHead(null);
+ resetDialogLimits();
+ listeners = [];
+ vi.stubGlobal('visualViewport', viewport);
+ viewport.height = 667;
+});
+afterEach(() => vi.unstubAllGlobals());
+
+const box = () => document.querySelector('[data-plugin-dialog]')!;
+
+describe('a plugin dialog that fills', () => {
+ it('fills the dialog, which fits the visible window and follows the keyboard', () => {
+ expect(isFillableSlot('plugin-dialog')).toBe(true);
+ render( );
+ act(() => { void awaitCustomDialog({ pluginId: 'ai', title: 'AI assistant', message: '', slot: 'plugin-dialog', fill: true }); });
+ expect(screen.getByTestId('custom-frame').dataset.fill).toBe('true');
+ // 667 px window: 90 % less the margin, at most 640.
+ expect(box().style.height).toBe('584px');
+ // The keyboard comes up: 360 px left.
+ act(() => { viewport.height = 360; for (const fn of listeners) fn(); });
+ expect(box().style.height).toBe('308px');
+ act(() => resolveHead(null));
+ });
+
+ it('fills a popover beside its trigger the same way', () => {
+ render( );
+ const anchor = { left: 100, top: 50, right: 124, bottom: 74 };
+ act(() => { void awaitCustomDialog({ pluginId: 'ai', title: 'AI assistant', message: '', slot: 'plugin-dialog', fill: true, anchor }); });
+ expect(document.querySelector('[data-plugin-dialog-popover]')).toBeTruthy();
+ expect(screen.getByTestId('custom-frame').dataset.fill).toBe('true');
+ expect(box().style.height).toBe('584px');
+ act(() => resolveHead(null));
+ });
+
+ it('grows with its content as before without fill', () => {
+ render( );
+ act(() => { void awaitCustomDialog({ pluginId: 'ai', title: 'AI assistant', message: '', slot: 'plugin-dialog' }); });
+ expect(screen.getByTestId('custom-frame').dataset.fill).toBe('false');
+ expect(box().style.height).toBe('');
+ expect(box().style.maxHeight).toBe('90vh');
+ act(() => resolveHead(null));
+ });
+
+ it('is what api.ui.openDialog asks for with fill: true', async () => {
+ const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
+ const plugin = { id: 'ai', name: 'AI', permissions: [], managed: true } as never;
+ void dispatchApiCall(plugin, 'ui.openDialog', [{ slot: 'plugin-dialog', title: 'AI assistant', fill: true }]);
+ await vi.waitFor(() => expect(head()?.fill).toBe(true));
+ resolveHead(null);
+ void dispatchApiCall(plugin, 'ui.openDialog', [{ slot: 'plugin-dialog', title: 'AI assistant' }]);
+ await vi.waitFor(() => expect(head()).toBeTruthy());
+ expect(head()?.fill).toBeUndefined();
+ resolveHead(null);
+ }, 30_000);
+});
diff --git a/components/plugins/app-side-panel.tsx b/components/plugins/app-side-panel.tsx
new file mode 100644
index 000000000..2db5e2829
--- /dev/null
+++ b/components/plugins/app-side-panel.tsx
@@ -0,0 +1,367 @@
+'use client';
+
+// The plugin side panel of Calendar, Contacts, Files and Settings (slot
+// app-side-panel), built like the reader's (email-viewer.tsx): one button at
+// the end of the view's h-14 toolbar row shows and hides it, it can be
+// dragged between 200 and 500 px, and it lies over the view instead of
+// squeezing it when the view would get too narrow. Its width and whether it
+// is folded are kept in localStorage for all four views. Until the user
+// folds or opens it, a plugin that asked to start folded starts folded.
+// Only a plugin whose shouldShow says yes gets the button and the panel.
+//
+// Each view mounts its own panel, so the frame starts over when the user
+// switches views; the plugin keeps what it needs in its storage. In the Pro
+// shell's panes there is none (only a Mail pane's reader panel).
+//
+// Phones, and Settings below 1024 px (phone-shaped there), have no room for
+// a panel: a button in the phone header (PhoneHeaderActions, and Contacts'
+// row over an open contact) asks the plugin for its dialog instead, through
+// its onAction hook (surface `app-side-panel`, actionId `open`, context
+// { view, context }), and so does api.ui.openPanel there.
+
+import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } from 'react';
+import { useTranslations } from 'next-intl';
+import { PanelRightClose, PanelRightOpen } from '@/components/icons';
+import { Button } from '@/components/ui/button';
+import { IconButton } from '@/components/ui/icon-button';
+import { ResizeHandle } from '@/components/layout/resize-handle';
+import { PluginSlot } from '@/components/plugins/plugin-slot';
+import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
+import { useIsEmbedded } from '@/hooks/use-is-embedded';
+import { useIsMobile } from '@/hooks/use-media-query';
+import { onPanelRequest } from '@/lib/plugin-sandbox/panel-requests';
+import { runPluginAction } from '@/lib/plugin-sandbox/actions';
+import { get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
+import { slotPropsFor } from '@/lib/plugin-sandbox/permissions';
+import { shortcutKeysFor } from '@/lib/plugin-sandbox/shortcuts';
+import type { AppSidePanelContext, AppSidePanelViewName } from '@/lib/plugin-sandbox/assistant/f.types';
+import { cn } from '@/lib/utils';
+
+export const APP_SIDE_PANEL_SLOT = 'app-side-panel';
+export const APP_SIDE_PANEL_MIN_WIDTH = 200;
+export const APP_SIDE_PANEL_MAX_WIDTH = 500;
+export const APP_SIDE_PANEL_DEFAULT_WIDTH = 280;
+/** The view keeps at least this much room next to a docked panel; with less the panel lies over it. */
+export const APP_SIDE_PANEL_MIN_CONTENT = 480;
+export const APP_SIDE_PANEL_COLLAPSED_KEY = 'appSidePanelCollapsed';
+export const APP_SIDE_PANEL_WIDTH_KEY = 'appSidePanelWidth';
+/** The id of a plugin shortcut that opens and closes its panel; its keys go into the button's title. */
+export const PANEL_SHORTCUT_ID = 'toggle-panel';
+
+const clampWidth = (n: number) => Math.max(APP_SIDE_PANEL_MIN_WIDTH, Math.min(APP_SIDE_PANEL_MAX_WIDTH, n));
+
+/**
+ * The button that shows and hides a plugin side panel, the same in the
+ * reader and in every other view: at the end of the toolbar row above the
+ * panel. One name ("Plugin panel"); aria-pressed says whether it is open.
+ * `shortcut`: the keys of the plugin's own shortcut for its panel.
+ */
+export function PluginPanelToggle({ open, onClick, className, buttonRef, shortcut }: {
+ open: boolean;
+ onClick: () => void;
+ className?: string;
+ buttonRef?: React.Ref;
+ shortcut?: string | null;
+}) {
+ const t = useTranslations('email_viewer');
+ const label = t('plugin_panel');
+ return (
+
+ {open ? : }
+
+ );
+}
+
+/** The keys of the panel shortcut of the first of these plugins that has one ("Alt+A"), or null. */
+export function panelShortcutOf(pluginIds: string[]): string | null {
+ for (const id of pluginIds) {
+ const keys = shortcutKeysFor(id, PANEL_SHORTCUT_ID);
+ if (keys) return keys;
+ }
+ return null;
+}
+
+/** The panel's name for screen readers: its plugins' names, else "Plugin panel". */
+export function panelLabelOf(pluginIds: string[], fallback: string): string {
+ const names = pluginIds.map((id) => getActivePlugin(id)?.plugin.name).filter((n): n is string => !!n);
+ return names.length ? names.join(', ') : fallback;
+}
+
+// ─── Where the panel can't dock: the plugin's dialog ───────────────
+
+export interface AppSidePanelDialogEntry {
+ pluginId: string;
+ view: AppSidePanelViewName;
+ context: AppSidePanelContext;
+}
+
+let dialogEntry: AppSidePanelDialogEntry | null = null;
+const dialogListeners = new Set<() => void>();
+
+function setDialogEntry(entry: AppSidePanelDialogEntry | null) {
+ dialogEntry = entry;
+ for (const listener of dialogListeners) listener();
+}
+
+function subscribeDialogEntry(listener: () => void) {
+ dialogListeners.add(listener);
+ return () => { dialogListeners.delete(listener); };
+}
+
+/** The view whose plugin panel opens as a dialog right now (a phone), or null. */
+export function useAppSidePanelDialogEntry(): AppSidePanelDialogEntry | null {
+ return useSyncExternalStore(subscribeDialogEntry, () => dialogEntry, () => null);
+}
+
+/** Asks the plugin for its dialog with what the view shows (the context is cut to its permissions on the way). */
+export function openAppSidePanelDialog(entry: AppSidePanelDialogEntry, pluginId = entry.pluginId): void {
+ void runPluginAction(pluginId, APP_SIDE_PANEL_SLOT, 'open', { view: entry.view, context: entry.context }).catch(() => {});
+}
+
+/** The phone's button for the view's plugin panel, which opens as a dialog there. */
+export function AppSidePanelPhoneButton({ className }: { className?: string } = {}) {
+ const t = useTranslations('email_viewer');
+ const entry = useAppSidePanelDialogEntry();
+ if (!entry) return null;
+ return (
+ openAppSidePanelDialog(entry)}
+ className={className}
+ data-app-side-panel-phone=""
+ >
+
+
+ );
+}
+
+// ─── Which plugins show it ─────────────────────────────────────────
+
+/**
+ * The plugins whose offer of the panel shows here: each one's shouldShow
+ * (asked in its background frame with the props it may see) said yes, or it
+ * has none. Until a plugin answers its offer doesn't count, so a plugin that
+ * is switched off for these views never gets a button that opens nothing.
+ */
+function useShownPluginIds(pluginOffers: readonly { pluginId: string; hasShouldShow: boolean }[], view: AppSidePanelViewName, context: AppSidePanelContext): string[] {
+ const [answers, setAnswers] = useState>({});
+ const key = pluginOffers.map((o) => `${o.pluginId}:${o.hasShouldShow ? 1 : 0}`).join(',');
+ useEffect(() => {
+ let live = true;
+ for (const offer of pluginOffers) {
+ const active = getActivePlugin(offer.pluginId);
+ if (!offer.hasShouldShow || !active?.background?.evaluateShouldShow) {
+ setAnswers((prev) => (prev[offer.pluginId] === true ? prev : { ...prev, [offer.pluginId]: true }));
+ continue;
+ }
+ const props = slotPropsFor(active.plugin, APP_SIDE_PANEL_SLOT, { view, context });
+ active.background.evaluateShouldShow(APP_SIDE_PANEL_SLOT, props)
+ .then((show) => { if (live) setAnswers((prev) => (prev[offer.pluginId] === show ? prev : { ...prev, [offer.pluginId]: show })); })
+ .catch(() => { if (live) setAnswers((prev) => ({ ...prev, [offer.pluginId]: false })); });
+ }
+ return () => { live = false; };
+ // The offers by their key; a new context asks again (the plugin may decide by it).
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [key, view, context]);
+ return useMemo(() => pluginOffers.filter((o) => answers[o.pluginId] === true).map((o) => o.pluginId), [pluginOffers, answers]);
+}
+
+// ─── The panel ─────────────────────────────────────────────────────
+
+export interface UseAppSidePanelOptions {
+ view: AppSidePanelViewName;
+ /** What the view shows; keep it memoised, a new object is sent to the plugin. */
+ context: AppSidePanelContext;
+ /** False where the view has no room for a panel at any width it is in now (Settings below 1024 px): the dialog then. */
+ dock?: boolean;
+ /**
+ * Folded here for now without changing the stored choice (Files while its
+ * details panel is open), so the other views keep the panel as the user left it.
+ */
+ hidden?: boolean;
+ /** Called when the panel opens, so the view can fold a panel it shares the place with (Files' details). */
+ onOpen?: () => void;
+}
+
+export interface AppSidePanel {
+ /** A plugin shows the panel here (outside the Pro shell's panes). */
+ offered: boolean;
+ /** The panel docks in this view now (else it is a dialog, or nothing). */
+ docks: boolean;
+ /** Whether the docked panel is open. */
+ open: boolean;
+ /** Whether the user's choice is to have it open (also while `hidden` folds it here). */
+ wantsOpen: boolean;
+ /** The button for the end of the toolbar row, or null. */
+ toggle: ReactNode;
+ /** The resize handle and the panel, as the last children of the row it sits in; null while folded. */
+ panel: ReactNode;
+ /** Put on the row the panel sits in: its width decides docked or over the view. */
+ rowRef: (el: HTMLElement | null) => void;
+ /** Folds the panel and keeps it folded. */
+ close: () => void;
+}
+
+function readChoice(): boolean | null {
+ if (typeof window === 'undefined') return null;
+ try {
+ const stored = localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY);
+ return stored === '1' ? true : stored === '0' ? false : null;
+ } catch { return null; }
+}
+
+function readWidth(): number {
+ if (typeof window === 'undefined') return APP_SIDE_PANEL_DEFAULT_WIDTH;
+ try {
+ const n = parseInt(localStorage.getItem(APP_SIDE_PANEL_WIDTH_KEY) ?? '', 10);
+ return Number.isFinite(n) ? clampWidth(n) : APP_SIDE_PANEL_DEFAULT_WIDTH;
+ } catch { return APP_SIDE_PANEL_DEFAULT_WIDTH; }
+}
+
+export function useAppSidePanel({ view, context, dock = true, hidden = false, onOpen }: UseAppSidePanelOptions): AppSidePanel {
+ const t = useTranslations('email_viewer');
+ const isEmbedded = useIsEmbedded();
+ const isMobile = useIsMobile();
+ const offers = usePluginSlotOffers(APP_SIDE_PANEL_SLOT);
+ const shownIds = useShownPluginIds(isEmbedded ? [] : offers, view, context);
+ const offered = shownIds.length > 0 && !isEmbedded;
+ const docks = offered && dock && !isMobile;
+ const asDialog = offered && !docks;
+
+ const [choice, setChoice] = useState(readChoice);
+ const [width, setWidth] = useState(readWidth);
+ const widthAtStart = useRef(width);
+ const [forcedOpen, setForcedOpen] = useState(false);
+ const [focusRequest, setFocusRequest] = useState(0);
+ const [rowEl, setRowEl] = useState(null);
+ const [rowWidth, setRowWidth] = useState(0);
+ const toggleRef = useRef(null);
+ const panelRef = useRef(null);
+
+ useEffect(() => {
+ if (!docks || !rowEl || typeof ResizeObserver === 'undefined') return;
+ const ro = new ResizeObserver((entries) => setRowWidth(Math.round(entries[0]?.contentRect.width ?? 0)));
+ ro.observe(rowEl);
+ return () => ro.disconnect();
+ }, [docks, rowEl]);
+
+ const collapsed = choice ?? offers.some((o) => o.startFolded && shownIds.includes(o.pluginId));
+ const tooNarrow = rowWidth > 0 && rowWidth - width < APP_SIDE_PANEL_MIN_CONTENT;
+ const folded = collapsed || hidden || (tooNarrow && !forcedOpen);
+ const overlay = !folded && tooNarrow;
+ // Docked, the panel grows only as far as the view keeps its room: a drag
+ // or the arrow keys stop there instead of folding the panel away.
+ const dockedMax = rowWidth > 0 ? Math.max(APP_SIDE_PANEL_MIN_WIDTH, rowWidth - APP_SIDE_PANEL_MIN_CONTENT) : APP_SIDE_PANEL_MAX_WIDTH;
+
+ useEffect(() => {
+ if (choice === null) return;
+ try { localStorage.setItem(APP_SIDE_PANEL_COLLAPSED_KEY, choice ? '1' : '0'); } catch { /* ignore */ }
+ }, [choice]);
+ useEffect(() => {
+ try { localStorage.setItem(APP_SIDE_PANEL_WIDTH_KEY, String(width)); } catch { /* ignore */ }
+ }, [width]);
+
+ const onOpenRef = useRef(onOpen);
+ onOpenRef.current = onOpen;
+ const state = useRef({ offered, docks, folded, tooNarrow, view, context, pluginId: shownIds[0] ?? null });
+ state.current = { offered, docks, folded, tooNarrow, view, context, pluginId: shownIds[0] ?? null };
+
+ const setOpen = useCallback((want: boolean) => {
+ const now = state.current;
+ if (want) {
+ setChoice(false);
+ if (now.tooNarrow) setForcedOpen(true);
+ // Opening on request puts the focus in the panel (the plugin's input).
+ setFocusRequest((n) => n + 1);
+ onOpenRef.current?.();
+ } else {
+ // Focus in the panel goes back to the button, not to the page's start.
+ const hadFocus = !!panelRef.current && panelRef.current.contains(document.activeElement);
+ setChoice(true);
+ setForcedOpen(false);
+ if (hadFocus) requestAnimationFrame(() => toggleRef.current?.focus());
+ }
+ }, []);
+
+ const close = useCallback(() => {
+ if (!state.current.folded) setOpen(false);
+ }, [setOpen]);
+
+ // api.ui.openPanel / closePanel / togglePanel for this slot.
+ useEffect(() => onPanelRequest((request) => {
+ if (request.slot !== APP_SIDE_PANEL_SLOT) return null;
+ const now = state.current;
+ if (!now.offered) return null;
+ if (!now.docks) {
+ // No room for a panel: the plugin that asked gets the open request
+ // for its dialog, and the panel itself stays closed.
+ if (request.mode !== 'close') openAppSidePanelDialog({ pluginId: request.pluginId, view: now.view, context: now.context });
+ return false;
+ }
+ const want = request.mode === 'open' ? true : request.mode === 'close' ? false : now.folded;
+ setOpen(want);
+ return want;
+ }), [setOpen]);
+
+ // The phone button, while this view's panel is a dialog.
+ const firstPluginId = shownIds[0] ?? null;
+ useEffect(() => {
+ if (!asDialog || !firstPluginId) return;
+ setDialogEntry({ pluginId: firstPluginId, view, context });
+ return () => setDialogEntry(null);
+ }, [asDialog, firstPluginId, view, context]);
+
+ const toggle = docks ? (
+ setOpen(folded)} buttonRef={toggleRef} shortcut={panelShortcutOf(shownIds)} />
+ ) : null;
+
+ const panel = docks && !folded ? (
+ <>
+ {!overlay && (
+ { widthAtStart.current = width; }}
+ // The panel is on the right: dragging left makes it wider.
+ onResize={(delta) => setWidth(clampWidth(Math.min(dockedMax, widthAtStart.current - delta)))}
+ onResizeEnd={() => { widthAtStart.current = width; }}
+ onDoubleClick={() => setWidth(Math.min(dockedMax, APP_SIDE_PANEL_DEFAULT_WIDTH))}
+ />
+ )}
+
+ >
+ ) : null;
+
+ return { offered, docks, open: docks && !folded, wantsOpen: docks && !collapsed, toggle, panel, rowRef: setRowEl, close };
+}
diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx
index 13f4cee80..bac10468b 100644
--- a/components/plugins/plugin-dialog-host.tsx
+++ b/components/plugins/plugin-dialog-host.tsx
@@ -11,7 +11,9 @@
// DialogButtons) drawn by the host in its footer; confirm, alert and prompt
// are the app's ConfirmDialog and PromptDialog. A custom dialog opened with
// `anchor: 'trigger'` is a popover beside the control the user clicked
-// (dialog-anchor.ts), with a one-line header naming the plugin.
+// (dialog-anchor.ts), with a one-line header naming the plugin. `fill: true`
+// gives either form a fixed height the plugin's frame fills (a chat), so an
+// anchored, filled dialog is a tall popover beside the control.
import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
import { useTranslations } from 'next-intl';
@@ -124,6 +126,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
// Footer buttons of a custom dialog: the plugin declares them, the host
// draws them and passes each click back as `dialogButtonClick` ({ id, n }).
const [buttons, setButtons] = useState([]);
+ const viewportHeight = useVisibleHeight(current?.kind === 'custom' && current.fill === true);
const [buttonClick, setButtonClick] = useState<{ id: string; n: number }>({ id: '', n: 0 });
const setDialogButtons = useCallback((value: unknown) => { setButtons(readDialogButtons(value)); }, []);
const pressButton = (id: string) => setButtonClick((c) => ({ id, n: c.n + 1 }));
@@ -198,6 +201,10 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
const confirmLabel = current.confirmLabel ?? (current.kind === 'alert' ? t('ok') : current.kind === 'prompt' ? t('submit') : t('confirm'));
const cancelLabel = current.cancelLabel ?? t('cancel');
const custom = current.kind === 'custom';
+ const fill = custom && current.fill === true;
+ // A filled dialog is as tall as the visible window lets it be (a phone's
+ // keyboard takes its share), centred or as a popover alike.
+ const fillHeight = Math.min(640, Math.round(viewportHeight * 0.9) - 16);
// Who is asking, under the title: plugins choose the title and message, so
// the host names the plugin in a place the plugin can't change.
@@ -237,6 +244,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
pluginId={current.pluginId}
slot={(current.slot ?? 'plugin-dialog') as SlotName}
extraProps={customProps}
+ fill={fill}
/>
);
@@ -265,6 +273,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
visibility: place ? undefined : 'hidden',
width: `min(${current.width ?? 560}px, calc(100vw - ${2 * POPOVER_MARGIN}px))`,
maxHeight: `calc(100vh - ${2 * POPOVER_MARGIN}px)`,
+ ...(fill ? { height: fillHeight } : {}),
}}
data-plugin-dialog={current.pluginId}
>
@@ -276,7 +285,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
- {frame}
+ {frame}
{footer}
@@ -299,7 +308,9 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
{custom ? (
@@ -319,7 +330,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
-
+
{message}
{frame}
@@ -418,6 +429,21 @@ function usePopoverPlace(anchor: AnchorRect | undefined, panelRef: React.RefObje
}
/** A footer button's icon by name; unknown names show none. */
+/** The height of what the window shows now (the visual viewport, without a phone's keyboard), followed while `on`. */
+function useVisibleHeight(on: boolean): number {
+ const read = () => (typeof window === 'undefined' ? 800 : Math.round(window.visualViewport?.height ?? window.innerHeight));
+ const [height, setHeight] = useState(read);
+ useEffect(() => {
+ if (!on || typeof window === 'undefined') return;
+ const update = () => setHeight(read());
+ update();
+ const target = window.visualViewport ?? window;
+ target.addEventListener('resize', update);
+ return () => target.removeEventListener('resize', update);
+ }, [on]);
+ return height;
+}
+
function FooterIcon({ name }: { name?: string }) {
const Glyph = name ? iconForName(name) : null;
return Glyph ?
: null;
diff --git a/components/pro/__tests__/pro-email-edit-draft.test.tsx b/components/pro/__tests__/pro-email-edit-draft.test.tsx
index fac8fc3a7..5a77f16e5 100644
--- a/components/pro/__tests__/pro-email-edit-draft.test.tsx
+++ b/components/pro/__tests__/pro-email-edit-draft.test.tsx
@@ -143,6 +143,12 @@ describe('Pro email view - editing a draft', () => {
expect(initialData.draftAccountId).toBeUndefined();
});
+ // The ticks in the main list are another view's: the tab's side panel doesn't get them.
+ it('gives the side panel of an email tab no list selection', async () => {
+ await openAndEdit(undefined);
+ expect(viewerProps.current?.withListSelection).toBe(false);
+ });
+
// Pro shell with the 0.16.23 login active and the 1.0 login's Drafts
// browsed: the store reads a row of that list through the 1.0 login. A
// view that names no login can't tell, so it names none - the active one
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index c92d66519..6040ba126 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -534,6 +534,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
currentMailboxRole={currentMailboxRole}
mailboxes={mailboxes}
className="flex-1"
+ withListSelection={false}
/>
diff --git a/components/settings/__tests__/composing-settings-locks.test.tsx b/components/settings/__tests__/composing-settings-locks.test.tsx
new file mode 100644
index 000000000..9370e46f8
--- /dev/null
+++ b/components/settings/__tests__/composing-settings-locks.test.tsx
@@ -0,0 +1,77 @@
+import { render, screen, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { ComposingSettings } from '../composing-settings';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useAuthStore } from '@/stores/auth-store';
+import { usePolicyStore } from '@/stores/policy-store';
+
+// The admin panel offers locks for these three rows (restriction-keys.ts);
+// the page has to show them, or the user changes a value the next policy
+// load puts back.
+
+vi.mock('next-intl', () => ({
+ useTranslations: () => (key: string) => key,
+}));
+
+vi.mock('../settings-section', async (importOriginal) => ({
+ ...(await importOriginal
()),
+ SettingsSection: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+const ROWS = {
+ plainTextMode: 'plain_text_mode.label',
+ autoSelectReplyIdentity: 'auto_select_reply_identity.label',
+ replyIdentityMatch: 'reply_identity_match.label',
+} as const;
+
+function setPolicy(locked: string[], hidden: string[] = []) {
+ usePolicyStore.setState({
+ ...usePolicyStore.getState(),
+ policy: {},
+ isSettingLocked: (key: string) => locked.includes(key),
+ isSettingHidden: (key: string) => hidden.includes(key),
+ isFeatureEnabled: (gate: string) => gate === 'customFromEnabled',
+ } as never);
+}
+
+/** The SettingItem row of a label: the element that holds the control. */
+function row(labelKey: string): HTMLElement {
+ return screen.getByText(labelKey).closest('[data-search-label]') as HTMLElement;
+}
+
+describe('ComposingSettings admin locks', () => {
+ beforeEach(() => {
+ useSettingsStore.getState().resetToDefaults();
+ // The identity match row shows only with auto-select on.
+ useSettingsStore.setState({ autoSelectReplyIdentity: true });
+ useAuthStore.setState({ client: null });
+ });
+
+ it('shows the lock and makes the control inert for each locked row', () => {
+ setPolicy(Object.keys(ROWS));
+ render( );
+ for (const label of Object.values(ROWS)) {
+ const item = row(label);
+ expect(within(item).getByText('org_locked'), label).toBeTruthy();
+ const control = item.querySelector('[role="switch"], select');
+ expect(control, label).not.toBeNull();
+ expect(control!.closest('[inert]'), label).not.toBeNull();
+ }
+ });
+
+ it('leaves the rows editable without a lock', () => {
+ setPolicy([]);
+ render( );
+ for (const label of Object.values(ROWS)) {
+ const item = row(label);
+ expect(within(item).queryByText('org_locked'), label).toBeNull();
+ expect(item.querySelector('[inert]'), label).toBeNull();
+ }
+ });
+
+ it('hides the rows the organisation hides', () => {
+ setPolicy([], Object.keys(ROWS));
+ render( );
+ for (const label of Object.values(ROWS)) expect(screen.queryByText(label), label).toBeNull();
+ });
+});
diff --git a/components/settings/__tests__/settings-side-panel.test.tsx b/components/settings/__tests__/settings-side-panel.test.tsx
new file mode 100644
index 000000000..a01c2ccc6
--- /dev/null
+++ b/components/settings/__tests__/settings-side-panel.test.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '@/lib/plugin-types';
+import { framePropsOf } from '@/components/plugins/__tests__/fake-slot-offer';
+
+// The plugin side panel (app-side-panel) in Settings: on desktop an h-14 row
+// over the page and the panel with the page's name and the button at its
+// end; below 1024 px Settings is phone-shaped and the panel is a dialog.
+
+const createSlotInstance = vi.fn((..._args: unknown[]) => ({ destroy: vi.fn(), updateProps: vi.fn(), setTheme: vi.fn() }));
+vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
+vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
+ createSlotInstance: (...args: unknown[]) => createSlotInstance(...args),
+}));
+vi.mock('@/lib/plugin-sandbox/host-theme', () => ({ snapshotHostTheme: () => ({}) }));
+vi.mock('next-intl', () => ({
+ useTranslations: (ns?: string) => (key: string) => (ns ? `${ns}.${key}` : key),
+ useMessages: () => ({}),
+ useLocale: () => 'en',
+ useFormatter: () => ({ dateTime: String, relativeTime: String, number: String }),
+}));
+const media = vi.hoisted(() => ({ desktop: true }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useIsDesktop: () => media.desktop,
+ useIsMobile: () => false,
+ useMediaQuery: () => false,
+ useDeviceDetection: () => ({ isMobile: false, isTablet: !media.desktop, isDesktop: media.desktop }),
+}));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail', stalwartFeaturesEnabled: false }) }));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
+vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
+vi.mock('@/components/settings/appearance-settings', () => ({ AppearanceSettings: () => appearance pane
}));
+vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () => accessibility pane
}));
+
+const { SettingsApp } = await import('../settings-app');
+const registry = await import('@/lib/plugin-sandbox/registry');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { usePluginStore } = await import('@/stores/plugin-store');
+const { useThemeStore } = await import('@/stores/theme-store');
+
+function activate(id: string, name: string, slots: string[]) {
+ const plugin: InstalledPlugin = {
+ id, name, version: '1.0.0', author: '', description: '', type: 'ui-extension',
+ permissions: ['ui:settings-section', 'ui:side-panel'], grantedPermissions: ['ui:settings-section', 'ui:side-panel'], entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+ registry.register({
+ plugin, code: '', tier: 'untrusted',
+ background: { evaluateShouldShow: vi.fn(async () => true) } as never,
+ slotOffers: slots.map((slot) => ({ name: slot as never, order: 100, hasShouldShow: false })),
+ hookDisposables: [],
+ });
+}
+
+beforeEach(() => {
+ media.desktop = true;
+ createSlotInstance.mockClear();
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ try { localStorage.setItem('settings-active-tab', 'appearance'); } catch { /* ignore */ }
+ // Every capability check answers "no"; ids are plain strings.
+ const client = new Proxy({}, {
+ get: (_t, prop) => (prop === 'then' ? undefined : (prop === 'getAccountId' ? () => 'acc' : () => null)),
+ });
+ useAuthStore.setState({ isAuthenticated: true, isLoading: false, client: client as never });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) } as never);
+});
+afterEach(() => {
+ for (const p of registry.all()) registry.deregister(p.plugin.id);
+ vi.unstubAllGlobals();
+});
+
+const { setActionDispatcher } = await import('@/lib/plugin-sandbox/actions');
+
+describe('Settings side panel', () => {
+ it('adds no row without a plugin', () => {
+ render( );
+ expect(document.querySelector('[data-settings-panel-row]')).toBeNull();
+ });
+
+ it('puts the page name and the button in an h-14 row over the page and the panel', () => {
+ activate('assistant', 'Assistant', ['app-side-panel']);
+ render( );
+ const row = document.querySelector('[data-settings-panel-row]')!;
+ expect(row.className).toContain('h-14');
+ const toggle = row.querySelector('[data-plugin-panel-toggle]')!;
+ expect(row.lastElementChild).toBe(toggle);
+ const frame = screen.getByTestId('plugin-frame-app-side-panel');
+ const page = (framePropsOf(frame).context as { page: { id: string; label: string } }).page;
+ expect(page.id).toBe('appearance');
+ expect(row.firstElementChild!.textContent).toBe(page.label);
+ expect(page.label).not.toBe('');
+ // The page itself (main) and then the panel, in the row under it.
+ const panel = document.querySelector('[data-app-side-panel]')!;
+ expect(panel.parentElement!.lastElementChild).toBe(panel);
+ expect(panel.parentElement!.querySelector('main, [role=main]')).not.toBeNull();
+
+ fireEvent.click(screen.getByTestId('settings-tab-accessibility'));
+ expect((framePropsOf(screen.getByTestId('plugin-frame-app-side-panel')).context as { page: { id: string } }).page.id).toBe('accessibility');
+ });
+
+ it('asks for the dialog below 1024 px, from the phone header', () => {
+ media.desktop = false;
+ const dispatch = vi.fn().mockResolvedValue(undefined);
+ setActionDispatcher(dispatch);
+ activate('assistant', 'Assistant', ['app-side-panel']);
+ render( );
+ expect(document.querySelector('[data-settings-panel-row]')).toBeNull();
+ expect(document.querySelector('[data-app-side-panel]')).toBeNull();
+ const buttons = document.querySelectorAll('[data-app-side-panel-phone]');
+ expect(buttons.length).toBeGreaterThan(0);
+ fireEvent.click(buttons[0]);
+ expect(dispatch).toHaveBeenCalledWith('assistant', 'app-side-panel', 'open', expect.objectContaining({ view: 'settings' }));
+ setActionDispatcher(null);
+ });
+
+ it('keeps the page (and what was typed on it) when the panel starts docking', () => {
+ render( );
+ const pane = screen.getByText('appearance pane');
+ act(() => activate('assistant', 'Assistant', ['app-side-panel']));
+ expect(document.querySelector('[data-settings-panel-row]')).not.toBeNull();
+ expect(pane.isConnected).toBe(true);
+ });
+});
diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx
index 0062e9776..aea6b724b 100644
--- a/components/settings/composing-settings.tsx
+++ b/components/settings/composing-settings.tsx
@@ -21,6 +21,8 @@ export function ComposingSettings() {
const t = useTranslations('settings.email_behavior');
const tPage = useTranslations('settings.composing_page');
const templatesEnabled = usePolicyStore((s) => s.isFeatureEnabled('templatesEnabled'));
+ // The admin panel can lock or hide these three (restriction-keys.ts).
+ const { isSettingLocked, isSettingHidden } = usePolicyStore();
const [newKeyword, setNewKeyword] = useState('');
const {
@@ -52,12 +54,14 @@ export function ComposingSettings() {
return (
-
+ {!isSettingHidden('plainTextMode') && (
+
updateSetting('plainTextMode', checked)}
/>
+ )}
-
+ {!isSettingHidden('autoSelectReplyIdentity') && (
+
updateSetting('autoSelectReplyIdentity', checked)}
/>
+ )}
- {autoSelectReplyIdentity && customFromEnabled && (
-
+ {autoSelectReplyIdentity && customFromEnabled && !isSettingHidden('replyIdentityMatch') && (
+
updateSetting('replyIdentityMatch', value as ReplyIdentityMatch)}
diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx
index 22104668f..93a658443 100644
--- a/components/settings/settings-app.tsx
+++ b/components/settings/settings-app.tsx
@@ -63,6 +63,7 @@ import { useManagedAccountStore } from '@/stores/managed-account-store';
import { useIsDesktop } from '@/hooks/use-media-query';
import { NavigationRail } from '@/components/layout/navigation-rail';
import { PhoneHeaderActions } from '@/components/layout/phone-header-actions';
+import { useAppSidePanel } from '@/components/plugins/app-side-panel';
import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
import { InlineAppView } from '@/components/layout/inline-app-view';
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
@@ -759,6 +760,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
// " - Settings - - " (WCAG 2.4.2). On a phone the
// page list alone, with no page open, is just Settings.
const activeTabLabel = visibleTabs.find((tab) => tab.id === effectiveActiveTab)?.label ?? '';
+
+ // The plugin side panel (app-side-panel), with the open page. Docked on
+ // desktop under a row with the page's name and its button; below 1024 px
+ // Settings is phone-shaped and the panel opens as a dialog.
+ const sidePanelContext = useMemo(
+ () => ({ page: { id: String(effectiveActiveTab), label: activeTabLabel } }),
+ [effectiveActiveTab, activeTabLabel],
+ );
+ const sidePanel = useAppSidePanel({ view: 'settings', context: sidePanelContext, dock: isDesktop });
useDocumentTitle(
activeTabLabel && (isDesktop || mobileShowContent)
? `${activeTabLabel} - ${tSidebar('settings')}`
@@ -1036,6 +1046,16 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
}
// Desktop layout
+ const settingsMain = (
+ void} embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto">
+
+ {/* The page's heading for screen readers; the sidebar shows which
+ page is open (the phone header has a visible one). */}
+
{activeTabLabel}
+ {renderTabContent()}
+
+
+ );
return (
{!isEmbedded &&
{tShell("skip_to_content")} }
@@ -1183,14 +1203,23 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
-
void} embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto">
-
- {/* The page's heading for screen readers; the sidebar shows which
- page is open (the phone header has a visible one). */}
-
{activeTabLabel}
- {renderTabContent()}
+ {/* The page under a row with its name and the plugin side panel's
+ button, like the reader's toolbar, lined up with the sidebar's h-14
+ row. The same element with or without the panel, so a page keeps
+ what was typed when the panel starts or stops docking. */}
+
+ {sidePanel.docks && (
+
+ {/* The page's h1 below names it for screen readers. */}
+
{activeTabLabel}
+ {sidePanel.toggle}
+
+ )}
+
+ {settingsMain}
+ {sidePanel.panel}
-
+
>
)}
diff --git a/lib/__tests__/calendar-rsvp.test.ts b/lib/__tests__/calendar-rsvp.test.ts
new file mode 100644
index 000000000..d650d7e2a
--- /dev/null
+++ b/lib/__tests__/calendar-rsvp.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { IJMAPClient } from '../jmap/client-interface';
+import type { CalendarEvent } from '../jmap/types';
+import { answerInvitation, attendeeOf, type RsvpRequest } from '../calendar-rsvp';
+
+// The banner's three ways of answering an invitation, now in one place that
+// calendar.respond uses too.
+
+const me = { email: 'bob@example.org', roles: { attendee: true }, participationStatus: 'needs-action' };
+const organiser = { email: 'alice@example.org', roles: { owner: true } };
+const parsed = {
+ uid: 'u1', title: 'Team offsite', organizerCalendarAddress: 'mailto:alice@example.org',
+ participants: { p1: organiser, p2: me },
+} as unknown as Partial
;
+
+function request(over: Partial = {}): RsvpRequest & { store: { rsvpEvent: ReturnType; updateEvent: ReturnType; importEvents: ReturnType } } {
+ const imported = { id: 'new', uid: 'u1', participants: { p1: organiser, p2: me } } as unknown as CalendarEvent;
+ return {
+ client: {} as IJMAPClient,
+ store: {
+ rsvpEvent: vi.fn(async () => true),
+ updateEvent: vi.fn(async () => true),
+ importEvents: vi.fn(async () => 1),
+ events: () => [imported],
+ },
+ parsedEvent: parsed,
+ existingEvent: null,
+ myParticipant: { id: 'p2', participant: me as never },
+ actingEmails: ['bob@example.org'],
+ calendarId: 'cal1',
+ sharedMailAccount: null,
+ resolveExisting: async () => null,
+ ...over,
+ } as never;
+}
+
+describe('answerInvitation', () => {
+ it('answers on the stored copy when the user is among its participants', async () => {
+ const stored = { id: 'e1', uid: 'u1', participants: { a: organiser, b: me } } as unknown as CalendarEvent;
+ const req = request({ existingEvent: stored, resolveExisting: async () => stored });
+ expect(await answerInvitation(req, 'accepted')).toBe('answered');
+ expect(req.store.rsvpEvent).toHaveBeenCalledWith(req.client, 'e1', 'b', 'accepted', { imip: 'mailto:alice@example.org' });
+ expect(req.store.importEvents).not.toHaveBeenCalled();
+ });
+
+ it('repairs a stored copy that lost its participants', async () => {
+ const stored = { id: 'e1', uid: 'u1', participants: null } as unknown as CalendarEvent;
+ const req = request({ existingEvent: stored, resolveExisting: async () => stored });
+ expect(await answerInvitation(req, 'tentative')).toBe('answered');
+ expect(req.store.updateEvent).toHaveBeenCalledWith(req.client, 'e1', expect.objectContaining({
+ participants: expect.objectContaining({ p2: expect.objectContaining({ participationStatus: 'tentative' }) }),
+ organizerCalendarAddress: 'mailto:alice@example.org',
+ }), true);
+ });
+
+ it('adds the invitation to the calendar first when it is not there, then answers', async () => {
+ const req = request();
+ expect(await answerInvitation(req, 'declined')).toBe('answered');
+ expect(req.store.importEvents).toHaveBeenCalledWith(req.client, [parsed], 'cal1', { throwIfNoneImported: true });
+ expect(req.store.rsvpEvent).toHaveBeenCalledWith(req.client, 'new', 'p2', 'declined', { imip: 'mailto:alice@example.org' });
+ });
+
+ it('finds the new copy on the server when the store doesn’t hold it', async () => {
+ const base = request();
+ const req = request({ store: { ...base.store, events: () => [] }, findImported: async () => ({ id: 'srv', uid: 'u1', participants: { p2: me } } as never) } as never);
+ expect(await answerInvitation(req, 'accepted')).toBe('answered');
+ expect(req.store.rsvpEvent).toHaveBeenCalledWith(req.client, 'srv', 'p2', 'accepted', { imip: 'mailto:alice@example.org' });
+ });
+
+ it('says when a plugin stopped it, when it only added the event, and when it can\'t', async () => {
+ expect(await answerInvitation(request({ store: { ...request().store, importEvents: vi.fn(async () => null) } } as never), 'accepted')).toBe('stopped');
+ expect(await answerInvitation(request({ myParticipant: null, store: { ...request().store, events: () => [{ id: 'new', uid: 'u1', participants: {} } as never] } } as never), 'accepted')).toBe('added');
+ expect(await answerInvitation(request({ calendarId: undefined }), 'accepted')).toBe('failed');
+ });
+
+ it('treats an owner who isn\'t an attendee as the organiser', () => {
+ expect(attendeeOf({ id: 'p1', participant: organiser as never })).toEqual({ participant: null, isOrganizer: true });
+ expect(attendeeOf({ id: 'p2', participant: me as never }).isOrganizer).toBe(false);
+ });
+});
diff --git a/lib/__tests__/exif.test.ts b/lib/__tests__/exif.test.ts
new file mode 100644
index 000000000..2e18542e2
--- /dev/null
+++ b/lib/__tests__/exif.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from 'vitest';
+import { readPhotoExif } from '../exif';
+
+// A JPEG with an APP1 Exif block, built by hand: a TIFF header, IFD0 with
+// pointers to the Exif and GPS IFDs, and their values after them.
+
+type Value = { tag: number; type: 2 | 4 | 5; ascii?: string; long?: number; rationals?: [number, number][] };
+
+function tiff(little: boolean, ifds: { ifd0: Value[]; exif: Value[]; gps: Value[] }): Uint8Array {
+ const buf = new DataView(new ArrayBuffer(1024));
+ const u16 = (at: number, v: number) => buf.setUint16(at, v, little);
+ const u32 = (at: number, v: number) => buf.setUint32(at, v, little);
+ buf.setUint16(0, little ? 0x4949 : 0x4d4d);
+ u16(2, 42);
+ u32(4, 8);
+ const sizeOf = (n: number) => 2 + n * 12 + 4;
+ const at0 = 8;
+ const atExif = at0 + sizeOf(ifds.ifd0.length + 2);
+ const atGps = atExif + sizeOf(ifds.exif.length);
+ let data = atGps + sizeOf(ifds.gps.length);
+ const write = (at: number, values: Value[]) => {
+ u16(at, values.length);
+ values.forEach((v, i) => {
+ const e = at + 2 + i * 12;
+ u16(e, v.tag);
+ u16(e + 2, v.type);
+ if (v.type === 2) {
+ const bytes = [...new TextEncoder().encode(v.ascii ?? ''), 0];
+ u32(e + 4, bytes.length);
+ const target = bytes.length <= 4 ? e + 8 : data;
+ if (bytes.length > 4) { u32(e + 8, data); data += bytes.length + (bytes.length % 2); }
+ bytes.forEach((b, j) => buf.setUint8(target + j, b));
+ } else if (v.type === 4) {
+ u32(e + 4, 1);
+ u32(e + 8, v.long ?? 0);
+ } else {
+ const r = v.rationals ?? [];
+ u32(e + 4, r.length);
+ u32(e + 8, data);
+ r.forEach(([num, den]) => { u32(data, num); u32(data + 4, den); data += 8; });
+ }
+ });
+ u32(at + 2 + values.length * 12, 0);
+ };
+ write(at0, [...ifds.ifd0, { tag: 0x8769, type: 4, long: atExif }, { tag: 0x8825, type: 4, long: atGps }]);
+ write(atExif, ifds.exif);
+ write(atGps, ifds.gps);
+ return new Uint8Array(buf.buffer.slice(0, data));
+}
+
+function jpeg(block: Uint8Array): Uint8Array {
+ const header = new TextEncoder().encode('Exif\0\0');
+ const length = 2 + header.length + block.length;
+ // SOI, an APP0 first (as cameras write JFIF before Exif), then APP1, then the scan.
+ const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
+ return new Uint8Array([0xff, 0xd8, ...app0, 0xff, 0xe1, length >> 8, length & 0xff, ...header, ...block, 0xff, 0xda, 0, 2, 0xff, 0xd9]);
+}
+
+const photo = (little: boolean) => jpeg(tiff(little, {
+ ifd0: [{ tag: 0x0132, type: 2, ascii: '2026:11:01 09:00:00' }],
+ exif: [{ tag: 0x9003, type: 2, ascii: '2026:10:31 14:05:30' }, { tag: 0x9011, type: 2, ascii: '+01:00' }],
+ gps: [
+ { tag: 1, type: 2, ascii: 'N' }, { tag: 2, type: 5, rationals: [[52, 1], [31, 1], [1234, 100]] },
+ { tag: 3, type: 2, ascii: 'W' }, { tag: 4, type: 5, rationals: [[13, 1], [24, 1], [0, 1]] },
+ ],
+}));
+
+describe('readPhotoExif', () => {
+ it('reads the date taken and the GPS position, in both byte orders', () => {
+ for (const little of [false, true]) {
+ expect(readPhotoExif(photo(little))).toEqual({ taken: '2026-10-31T14:05:30+01:00', lat: 52.52009, lon: -13.4 });
+ }
+ });
+
+ it('gives no date when only the date the file changed is there', () => {
+ const bytes = jpeg(tiff(false, { ifd0: [{ tag: 0x0132, type: 2, ascii: '2026:11:01 09:00:00' }], exif: [], gps: [] }));
+ expect(readPhotoExif(bytes)).toEqual({});
+ });
+
+ it('gives nothing for a JPEG without EXIF, a PNG or broken bytes', () => {
+ expect(readPhotoExif(new Uint8Array([0xff, 0xd8, 0xff, 0xda, 0, 2, 0xff, 0xd9]))).toEqual({});
+ expect(readPhotoExif(new Uint8Array([0x89, 0x50, 0x4e, 0x47]))).toEqual({});
+ const cut = photo(false).slice(0, 40);
+ expect(() => readPhotoExif(cut)).not.toThrow();
+ });
+});
diff --git a/lib/__tests__/extension-package.test.ts b/lib/__tests__/extension-package.test.ts
index bf1f0f81c..920525e95 100644
--- a/lib/__tests__/extension-package.test.ts
+++ b/lib/__tests__/extension-package.test.ts
@@ -42,6 +42,19 @@ describe('checkPluginManifest', () => {
.toMatch(/Unknown permissions: root:all/);
});
+ it('grants the optional permissions it knows and leaves out the others, with a warning', () => {
+ const { manifest, errors, warnings } = checkPluginManifest({ ...BASE, optionalPermissions: ['files:read', 'settings:fly', 'email:read'] });
+
+ expect(errors).toEqual([]);
+ expect(manifest!.permissions).toEqual(['email:read', 'files:read']);
+ expect(warnings.join()).toMatch(/Left out optional permissions .*settings:fly/);
+ expect(manifest).not.toHaveProperty('optionalPermissions');
+ });
+
+ it('refuses optional permissions given as a string', () => {
+ expect(checkPluginManifest({ ...BASE, optionalPermissions: 'files:read' }).errors.join()).toMatch(/"optionalPermissions" must be a list of strings/);
+ });
+
it.each(['../index.js', '/index.js', 'a/../../x.js', 'index.css', 'a\\b.js'])('refuses entrypoint %s', (entrypoint) => {
expect(checkPluginManifest({ ...BASE, entrypoint }).manifest).toBeNull();
});
diff --git a/lib/__tests__/files-save.test.ts b/lib/__tests__/files-save.test.ts
new file mode 100644
index 000000000..b9b801563
--- /dev/null
+++ b/lib/__tests__/files-save.test.ts
@@ -0,0 +1,66 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { IJMAPClient } from '../jmap/client-interface';
+import { saveBlobsToFiles } from '../files-save';
+import { fileHooks } from '../plugin-hooks';
+
+// Saving attachments to Files (the reader's Save to Files and
+// api.files.saveAttachments): filed by blob, copied over from another
+// account first, and announced to plugins like an upload in Files.
+
+function makeClient() {
+ let n = 0;
+ return {
+ getFilesAccountId: vi.fn(() => 'me'),
+ copyBlobs: vi.fn(async (_from: string, ids: string[]) => Object.fromEntries(ids.map((id) => [id, `${id}-mine`]))),
+ createFileNode: vi.fn(async (name: string, blobId: string, type: string, _size: number, parentId: string | null) => ({ id: `n${++n}`, name, blobId, type, parentId, size: 0, created: '', modified: '' })),
+ };
+}
+const blobs = [
+ { blobId: 'b1', name: 'invoice.pdf', type: 'application/pdf', size: 1234 },
+ { blobId: 'b2', name: null, type: null, size: 9 },
+];
+
+afterEach(() => {
+ fileHooks.onBeforeFileUpload.clear();
+ fileHooks.onAfterFileUpload.clear();
+});
+
+describe('saveBlobsToFiles', () => {
+ it('files each blob in the folder and tells plugins as for an upload', async () => {
+ const client = makeClient();
+ const before = vi.fn(() => true);
+ const after = vi.fn();
+ fileHooks.onBeforeFileUpload.register('p', before);
+ fileHooks.onAfterFileUpload.register('p', after);
+ const { saved, stopped } = await saveBlobsToFiles(blobs, { client: client as unknown as IJMAPClient, parentId: 'docs', path: '/Documents' });
+ expect(client.createFileNode.mock.calls).toEqual([
+ ['invoice.pdf', 'b1', 'application/pdf', 1234, 'docs'],
+ ['attachment', 'b2', 'application/octet-stream', 9, 'docs'],
+ ]);
+ expect(client.copyBlobs).not.toHaveBeenCalled();
+ expect(before.mock.calls).toEqual([
+ [{ name: 'invoice.pdf', size: 1234, type: 'application/pdf', path: '/Documents' }],
+ [{ name: 'attachment', size: 9, type: 'application/octet-stream', path: '/Documents' }],
+ ]);
+ await vi.waitFor(() => expect(after).toHaveBeenCalledTimes(2));
+ expect(after.mock.calls[0][0]).toMatchObject({ name: 'invoice.pdf', type: 'file', path: '/Documents' });
+ expect(saved.map((n) => [n.name, n.size])).toEqual([['invoice.pdf', 1234], ['attachment', 9]]);
+ expect(stopped).toEqual([]);
+ });
+
+ it('copies blobs of another account of the login into the Files account first', async () => {
+ const client = makeClient();
+ await saveBlobsToFiles([blobs[0]], { client: client as unknown as IJMAPClient, parentId: null, path: '/', blobAccountId: 'team' });
+ expect(client.copyBlobs).toHaveBeenCalledWith('team', ['b1'], 'me');
+ expect(client.createFileNode).toHaveBeenCalledWith('invoice.pdf', 'b1-mine', 'application/pdf', 1234, null);
+ });
+
+ it('skips a file another plugin stops, and saves the rest', async () => {
+ const client = makeClient();
+ fileHooks.onBeforeFileUpload.register('dlp', (info: { name: string }) => info.name !== 'invoice.pdf');
+ const { saved, stopped } = await saveBlobsToFiles(blobs, { client: client as unknown as IJMAPClient, parentId: null, path: '/' });
+ expect(stopped).toEqual(['invoice.pdf']);
+ expect(saved.map((n) => n.name)).toEqual(['attachment']);
+ expect(client.createFileNode).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/__tests__/image-shrink.test.ts b/lib/__tests__/image-shrink.test.ts
new file mode 100644
index 000000000..d9f58dc92
--- /dev/null
+++ b/lib/__tests__/image-shrink.test.ts
@@ -0,0 +1,153 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { shrinkImage, toBase64, MAX_SHRUNK_IMAGE_BYTES } from '../image-shrink';
+
+// jsdom has no image decoder and no canvas: the browser's createImageBitmap
+// and OffscreenCanvas are stood in for. A "picture" is a PNG header (the
+// size the file declares; colour type 6 for see-through pixels). Encoded
+// sizes follow a formula the test sets, so the byte cap can be hit on purpose.
+
+interface Fake { w: number; h: number; alpha?: boolean; broken?: boolean }
+let bytesPer: (type: string, quality: number | undefined, w: number, h: number) => number;
+const encoded: { type: string; quality?: number; w: number; h: number }[] = [];
+const fills: string[] = [];
+const decodes: unknown[] = [];
+let oldBrowser = false;
+
+class FakeBitmap {
+ constructor(readonly width: number, readonly height: number, readonly alpha: boolean) {}
+ close() {}
+}
+
+class FakeCanvas {
+ private drawn: FakeBitmap | null = null;
+ constructor(public width: number, public height: number) {}
+ getContext() {
+ const draw = (bitmap: FakeBitmap) => { this.drawn = bitmap; };
+ const drawn = () => this.drawn;
+ return {
+ globalCompositeOperation: 'source-over',
+ fillStyle: '',
+ drawImage(bitmap: FakeBitmap) { draw(bitmap); },
+ fillRect() { fills.push(this.fillStyle); },
+ getImageData(_x: number, _y: number, w: number, h: number) {
+ const data = new Uint8ClampedArray(w * h * 4).fill(255);
+ if (drawn()?.alpha) data[3] = 0;
+ return { data };
+ },
+ };
+ }
+ async convertToBlob({ type, quality }: { type: string; quality?: number }) {
+ encoded.push({ type, quality, w: this.width, h: this.height });
+ return new Blob([new Uint8Array(bytesPer(type, quality, this.width, this.height))], { type });
+ }
+}
+
+/** A PNG header declaring `w` × `h`; colour type 6 (alpha), 2, or 99 for a file that won't decode. */
+function header({ w, h, alpha, broken }: Fake): Uint8Array {
+ const b = new Uint8Array(33);
+ b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
+ const view = new DataView(b.buffer);
+ view.setUint32(16, w);
+ view.setUint32(20, h);
+ b[24] = 8;
+ b[25] = broken ? 99 : alpha ? 6 : 2;
+ return b;
+}
+const picture = (fake: Fake, type = 'image/jpeg') => new Blob([header(fake) as BlobPart], { type });
+
+beforeEach(() => {
+ encoded.length = 0;
+ fills.length = 0;
+ decodes.length = 0;
+ oldBrowser = false;
+ bytesPer = () => 1000;
+ vi.stubGlobal('OffscreenCanvas', FakeCanvas);
+ vi.stubGlobal('createImageBitmap', vi.fn(async (blob: Blob, options?: { resizeWidth?: number }) => {
+ decodes.push(options);
+ if (oldBrowser && options) throw new TypeError("Failed to execute 'createImageBitmap': The provided value 'from-image' is not a valid enum value");
+ const b = new Uint8Array(await blob.arrayBuffer());
+ const view = new DataView(b.buffer);
+ const w = view.getUint32(16);
+ const h = view.getUint32(20);
+ if (b[25] === 99) throw new DOMException('cannot decode', 'InvalidStateError');
+ const width = options?.resizeWidth ?? w;
+ return new FakeBitmap(width, Math.round((h * width) / w), b[25] === 6);
+ }));
+});
+
+afterEach(() => vi.unstubAllGlobals());
+
+describe('shrinkImage', () => {
+ it('shrinks the long edge to 1568 px as a JPEG at 0.85 on white, decoding at that size', async () => {
+ const out = await shrinkImage(picture({ w: 4000, h: 3000 }));
+ expect(out).toMatchObject({ status: 'ok', type: 'image/jpeg', width: 1568, height: 1176 });
+ expect(out.bytes?.length).toBe(1000);
+ expect(decodes).toEqual([{ imageOrientation: 'from-image', resizeWidth: 1568, resizeQuality: 'high' }]);
+ expect(encoded).toEqual([{ type: 'image/jpeg', quality: 0.85, w: 1568, h: 1176 }]);
+ expect(fills).toEqual(['#ffffff']);
+ });
+
+ it('takes a smaller long edge, and never makes an image larger', async () => {
+ expect(await shrinkImage(picture({ w: 3000, h: 4000 }), { maxSide: 1024 })).toMatchObject({ width: 768, height: 1024 });
+ expect(await shrinkImage(picture({ w: 800, h: 600 }))).toMatchObject({ status: 'ok', width: 800, height: 600 });
+ // Small enough already: decoded as it is.
+ expect(decodes.at(-1)).toEqual({ imageOrientation: 'from-image' });
+ });
+
+ it('skips tracking pixels and tiny images without decoding them', async () => {
+ expect(await shrinkImage(picture({ w: 1, h: 1 }, 'image/gif'))).toEqual({ status: 'tracker', width: 1, height: 1 });
+ expect(await shrinkImage(picture({ w: 2, h: 2 }))).toMatchObject({ status: 'tracker' });
+ expect(await shrinkImage(picture({ w: 40, h: 600 }))).toMatchObject({ status: 'tiny' });
+ expect(await shrinkImage(picture({ w: 100, h: 100 }))).toMatchObject({ status: 'tiny' });
+ expect(decodes).toEqual([]);
+ expect(await shrinkImage(picture({ w: 130, h: 130 }))).toMatchObject({ status: 'ok' });
+ // Asked for on purpose: small ones too, but never a tracker.
+ expect(await shrinkImage(picture({ w: 40, h: 40 }), { small: true })).toMatchObject({ status: 'ok', width: 40 });
+ expect(await shrinkImage(picture({ w: 1, h: 2 }), { small: true })).toMatchObject({ status: 'tracker' });
+ });
+
+ it('refuses an image that declares more than 50 megapixels without decoding it', async () => {
+ expect(await shrinkImage(picture({ w: 20_000, h: 20_000 }, 'image/png'))).toEqual({ status: 'too_big', width: 20_000, height: 20_000 });
+ expect(decodes).toEqual([]);
+ expect(await shrinkImage(picture({ w: 8_000, h: 6_000 }))).toMatchObject({ status: 'ok' });
+ });
+
+ it('answers unsupported for a header it can not read, and for what the browser can not decode', async () => {
+ expect(await shrinkImage(new Blob(['not an image at all, just text'], { type: 'image/heic' }))).toEqual({ status: 'unsupported' });
+ expect(decodes).toEqual([]);
+ expect(await shrinkImage(picture({ w: 300, h: 200, broken: true }))).toMatchObject({ status: 'unsupported' });
+ });
+
+ it('decodes without options on a browser that does not know them', async () => {
+ oldBrowser = true;
+ expect(await shrinkImage(picture({ w: 600, h: 400 }))).toMatchObject({ status: 'ok', width: 600, height: 400 });
+ expect(decodes).toEqual([{ imageOrientation: 'from-image' }, undefined]);
+ });
+
+ it('keeps a PNG with see-through parts as PNG, and turns an opaque one into JPEG', async () => {
+ expect(await shrinkImage(picture({ w: 600, h: 400, alpha: true }, 'image/png'))).toMatchObject({ status: 'ok', type: 'image/png' });
+ expect(fills).toEqual([]);
+ expect(await shrinkImage(picture({ w: 600, h: 400 }, 'image/png'))).toMatchObject({ status: 'ok', type: 'image/jpeg' });
+ });
+
+ it('stays under 1.5 MB: quality 0.7 first, then a quarter smaller per step', async () => {
+ bytesPer = (_t, q) => (q === 0.85 ? MAX_SHRUNK_IMAGE_BYTES + 1 : 1000);
+ expect(await shrinkImage(picture({ w: 2000, h: 1000 }))).toMatchObject({ status: 'ok', width: 1568, height: 784 });
+ expect(encoded.map((e) => e.quality)).toEqual([0.85, 0.7]);
+
+ encoded.length = 0;
+ bytesPer = (_t, _q, w) => (w > 1200 ? MAX_SHRUNK_IMAGE_BYTES + 1 : 1000);
+ expect(await shrinkImage(picture({ w: 2000, h: 1000 }))).toMatchObject({ status: 'ok', width: 1176, height: 588 });
+
+ bytesPer = () => MAX_SHRUNK_IMAGE_BYTES + 1;
+ expect(await shrinkImage(picture({ w: 2000, h: 1000 }))).toEqual({ status: 'too_big', width: 2000, height: 1000 });
+ });
+});
+
+describe('toBase64', () => {
+ it('encodes large byte arrays', () => {
+ const bytes = new Uint8Array(100_000).map((_, i) => i % 256);
+ expect(atob(toBase64(bytes)).length).toBe(100_000);
+ expect(toBase64(new Uint8Array([104, 105]))).toBe('aGk=');
+ });
+});
diff --git a/lib/__tests__/image-size.test.ts b/lib/__tests__/image-size.test.ts
new file mode 100644
index 000000000..f2a8f1839
--- /dev/null
+++ b/lib/__tests__/image-size.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from 'vitest';
+import { imageDimensions } from '../image-size';
+
+// The size an image declares, read from the header of each format.
+
+const bytes = (...parts: (number[] | string)[]) => new Uint8Array(parts.flatMap((p) => (typeof p === 'string' ? [...p].map((c) => c.charCodeAt(0)) : p)));
+const be32 = (n: number) => [(n >>> 24) & 255, (n >>> 16) & 255, (n >>> 8) & 255, n & 255];
+const le16 = (n: number) => [n & 255, (n >>> 8) & 255];
+const le24 = (n: number) => [n & 255, (n >>> 8) & 255, (n >>> 16) & 255];
+const le32 = (n: number) => [...le16(n & 0xffff), ...le16(n >>> 16)];
+
+describe('imageDimensions', () => {
+ it('reads PNG and GIF', () => {
+ expect(imageDimensions(bytes([0x89], 'PNG', [13, 10, 26, 10, 0, 0, 0, 13], 'IHDR', be32(40_000), be32(30_000), [8, 2, 0, 0, 0]))).toEqual({ width: 40_000, height: 30_000 });
+ expect(imageDimensions(bytes('GIF89a', le16(320), le16(200), [0, 0, 0]))).toEqual({ width: 320, height: 200 });
+ });
+
+ it('reads a JPEG frame header after an EXIF block', () => {
+ const app1 = [0xff, 0xe1, 0x00, 0x10, ...new Array(14).fill(0)];
+ const sof = [0xff, 0xc2, 0x00, 0x11, 8, 0x0b, 0xb8, 0x0f, 0xa0, 3, ...new Array(9).fill(0)];
+ expect(imageDimensions(bytes([0xff, 0xd8], app1, sof))).toEqual({ width: 4000, height: 3000 });
+ // No frame header before the image data: unknown.
+ expect(imageDimensions(bytes([0xff, 0xd8], app1, [0xff, 0xda, 0, 2, 0, 0, 0, 0, 0, 0]))).toBeNull();
+ });
+
+ it('reads the three kinds of WebP', () => {
+ const riff = (chunk: string, body: number[]) => bytes('RIFF', [0, 0, 0, 0], 'WEBP', chunk, [0, 0, 0, 0], body);
+ expect(imageDimensions(riff('VP8 ', [0, 0, 0, 0x9d, 0x01, 0x2a, ...le16(640), ...le16(480)]))).toEqual({ width: 640, height: 480 });
+ // VP8L: 14 bits each of width - 1 and height - 1 after the 0x2f signature.
+ const w = 1023;
+ const h = 767;
+ const packed = (w - 1) | ((h - 1) << 14);
+ expect(imageDimensions(riff('VP8L', [0x2f, ...le32(packed), 0]))).toEqual({ width: w, height: h });
+ expect(imageDimensions(riff('VP8X', [0, 0, 0, 0, ...le24(16_383), ...le24(9_999)]))).toEqual({ width: 16_384, height: 10_000 });
+ });
+
+ it('reads BMP, and nothing from what it does not know', () => {
+ expect(imageDimensions(bytes('BM', new Array(16).fill(0), le32(800), le32(-600 >>> 0)))).toEqual({ width: 800, height: 600 });
+ expect(imageDimensions(bytes(''))).toBeNull();
+ expect(imageDimensions(new Uint8Array(0))).toBeNull();
+ });
+});
diff --git a/lib/__tests__/jmap-filenode-writes.test.ts b/lib/__tests__/jmap-filenode-writes.test.ts
index 9a0fc25b5..c7e58e415 100644
--- a/lib/__tests__/jmap-filenode-writes.test.ts
+++ b/lib/__tests__/jmap-filenode-writes.test.ts
@@ -466,3 +466,23 @@ describe('FileNode write errors, names and types', () => {
expect(sets(calls)).toHaveLength(4);
});
});
+
+// A file's content is replaced by pointing the node at a new blob: the
+// assistant's overwrite (api.files.create with replace). Same request on
+// 0.16 and 1.0; `modified: null` has 1.0 stamp the change.
+describe('replaceFileNodeBlob', () => {
+ it('sets the new blob and lets the server stamp the change, in the node\'s own account', async () => {
+ const { client, calls } = makeClient({});
+ await client.replaceFileNodeBlob('n1', 'blob-new');
+ await client.replaceFileNodeBlob('grp:n2', 'blob-team');
+ expect(sets(calls)).toEqual([
+ { accountId: 'me', update: { n1: { blobId: 'blob-new', modified: null } } },
+ { accountId: 'grp', update: { n2: { blobId: 'blob-team', modified: null } } },
+ ]);
+ });
+
+ it('throws when the server refuses it', async () => {
+ const { client } = makeClient({}, { refuse: (id) => (id === 'n1' ? { type: 'forbidden', description: 'Not allowed.' } : undefined) });
+ await expect(client.replaceFileNodeBlob('n1', 'blob-new')).rejects.toThrow('Not allowed.');
+ });
+});
diff --git a/lib/__tests__/jmap-sieve-deactivate.test.ts b/lib/__tests__/jmap-sieve-deactivate.test.ts
new file mode 100644
index 000000000..a48f059a7
--- /dev/null
+++ b/lib/__tests__/jmap-sieve-deactivate.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it, vi } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+
+// Stalwart answers SieveScript/set with onSuccessActivateScript: null without
+// an error but leaves the active script active (checked on 0.16 and 1.0), so
+// undoing the first filter rule, which deletes the script it created, failed
+// with "scriptIsActive". RFC 9661 deactivates with onSuccessDeactivateScript.
+
+describe('JMAPClient.deactivateSieveScript', () => {
+ it('asks the server to deactivate with onSuccessDeactivateScript', async () => {
+ const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pw');
+ const request = vi.fn(async () => ({ methodResponses: [['SieveScript/set', { accountId: 'a1' }, '0']] }));
+ Object.assign(client as unknown as Record, { request, sieveUsing: () => ['urn:ietf:params:jmap:sieve'] });
+ await client.deactivateSieveScript('a1');
+ const [[calls]] = request.mock.calls as unknown as [[[string, Record, string][]]];
+ expect(calls[0][1]).toEqual({ accountId: 'a1', onSuccessDeactivateScript: true });
+ });
+});
diff --git a/lib/__tests__/jmap-thread-newest.test.ts b/lib/__tests__/jmap-thread-newest.test.ts
new file mode 100644
index 000000000..bc4df7be5
--- /dev/null
+++ b/lib/__tests__/jmap-thread-newest.test.ts
@@ -0,0 +1,50 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { JMAPClient } from '../jmap/client';
+
+// getThreadEmails with `newest`: Thread/get first, then Email/get only for
+// the newest messages, so a thread of thousands never loads all its bodies.
+
+function mockFetchResponse(status: number, body?: unknown): Response {
+ return new Response(body ? JSON.stringify(body) : null, { status, headers: { 'Content-Type': 'application/json' } });
+}
+
+const session = {
+ capabilities: { 'urn:ietf:params:jmap:core': {} },
+ accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: {} } },
+ primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
+ apiUrl: 'https://mail.example.com/jmap/api',
+ downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
+ uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
+ eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
+};
+
+describe('JMAPClient.getThreadEmails newest', () => {
+ let fetchSpy: ReturnType;
+ beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); });
+ afterEach(() => { fetchSpy.mockRestore(); });
+
+ async function threadOf(ids: string[], options?: { newest?: number }): Promise {
+ fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, session));
+ const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
+ await client.connect();
+ fetchSpy.mockReset();
+ const asked: string[][] = [];
+ fetchSpy.mockImplementation(async (_input: unknown, init?: RequestInit) => {
+ const [name, args] = JSON.parse((init as RequestInit).body as string).methodCalls[0];
+ if (name === 'Thread/get') return mockFetchResponse(200, { methodResponses: [['Thread/get', { list: [{ id: 't', emailIds: ids }] }, '0']] });
+ asked.push(args.ids);
+ return mockFetchResponse(200, { methodResponses: [['Email/get', { list: [] }, '0']] });
+ });
+ await client.getThreadEmails('t', undefined, options);
+ return asked;
+ }
+
+ it('fetches only the newest messages of the thread', async () => {
+ const ids = Array.from({ length: 80 }, (_, i) => `e${i}`);
+ expect((await threadOf(ids, { newest: 50 })).flat()).toEqual(ids.slice(30));
+ });
+
+ it('fetches every message without it, as before', async () => {
+ expect((await threadOf(['a', 'b', 'c'])).flat()).toEqual(['a', 'b', 'c']);
+ });
+});
diff --git a/lib/__tests__/mailbox-shared-out.test.ts b/lib/__tests__/mailbox-shared-out.test.ts
new file mode 100644
index 000000000..fc97cacc6
--- /dev/null
+++ b/lib/__tests__/mailbox-shared-out.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { IJMAPClient } from '../jmap/client-interface';
+import { isSharedOut } from '../mailbox-shared-out';
+
+const boxes = [
+ { id: 'arch' },
+ { id: '2026', parentId: 'arch' },
+ { id: 'inbox' },
+];
+const client = (shares: Record, sharing = true) => ({
+ supportsMailboxSharing: () => sharing,
+ getMailboxShareWith: vi.fn(async (id: string) => shares[id] ?? null),
+}) as unknown as IJMAPClient;
+
+describe('isSharedOut', () => {
+ it('asks the server for the folder and, when asked to, the folders below it', async () => {
+ const c = client({ '2026': { u2: { mayReadItems: true } } });
+ expect(await isSharedOut(c, boxes, 'arch')).toBe(false);
+ expect(await isSharedOut(c, boxes, 'arch', undefined, { descendants: true })).toBe(true);
+ });
+
+ it('is false only where nothing is shared or the server has no sharing', async () => {
+ expect(await isSharedOut(client({}), boxes, 'inbox')).toBe(false);
+ expect(await isSharedOut(client({ inbox: { u2: {} } }, false), boxes, 'inbox')).toBe(false);
+ expect(await isSharedOut({ supportsMailboxSharing: () => true } as unknown as IJMAPClient, boxes, 'inbox')).toBe(true);
+ });
+});
diff --git a/lib/__tests__/plugin-app-side-panel.test.ts b/lib/__tests__/plugin-app-side-panel.test.ts
new file mode 100644
index 000000000..9f53bd284
--- /dev/null
+++ b/lib/__tests__/plugin-app-side-panel.test.ts
@@ -0,0 +1,99 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { slotPropsFor, mayOfferSlot } from '@/lib/plugin-sandbox/permissions';
+import { PANEL_SLOTS, onPanelRequest, requestPanel, resetPanelRequests } from '@/lib/plugin-sandbox/panel-requests';
+import { isFillableSlot } from '@/lib/plugin-sandbox/slot-layout';
+import { ALL_PERMISSIONS, type InstalledPlugin, type Permission } from '@/lib/plugin-types';
+import { slotSpec } from '@/lib/plugin-slot-catalog';
+import { assistantFeatures } from '@/lib/plugin-sandbox/assistant';
+import { runPluginAction } from '@/lib/plugin-sandbox/actions';
+import { deregister, register, type ActivePlugin } from '@/lib/plugin-sandbox/registry';
+import { APP_SIDE_PANEL_CONTEXT_FIELDS } from '@/lib/plugin-sandbox/assistant/f.types';
+
+// The app-side-panel slot (area f of the assistant's host APIs): a side
+// panel in Calendar, Contacts, Files and Settings. Its context carries the
+// open event, contact, files or settings page, each behind the permission
+// that guards that data elsewhere.
+
+const plugin = (permissions: Permission[]): InstalledPlugin => ({
+ id: 'p', name: 'P', version: '1.0.0', author: '', description: '', type: 'ui-extension',
+ permissions, grantedPermissions: permissions, entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
+});
+
+const context = {
+ event: { id: 'e1', title: 'Team offsite' },
+ contact: { id: 'c1', fullName: 'Aa Bob' },
+ selectedCount: 3,
+ files: [{ id: 'f1', name: 'agenda.pdf' }],
+ fileCount: 1,
+ folder: { id: 'd1', path: '/Projects' },
+ page: { id: 'reading', label: 'Reading' },
+ secret: 'not in the catalogue',
+};
+
+afterEach(() => resetPanelRequests());
+
+describe('app-side-panel', () => {
+ it('is a slot of its own permission, fillable, and a panel plugins can open', () => {
+ expect(ALL_PERMISSIONS).toContain('ui:side-panel');
+ expect(slotSpec('app-side-panel')?.permissions).toEqual(['ui:side-panel']);
+ expect(mayOfferSlot(plugin(['ui:side-panel']), 'app-side-panel')).toBe(true);
+ expect(mayOfferSlot(plugin(['ui:email-details']), 'app-side-panel')).toBe(false);
+ expect(isFillableSlot('app-side-panel')).toBe(true);
+ expect(PANEL_SLOTS).toContain('app-side-panel');
+ expect(assistantFeatures().sidePanel).toBe(true);
+ });
+
+ it('gives the plugin only the parts of the context it has permissions for', () => {
+ const props = { view: 'calendar', context, focusRequest: 2 };
+ // ui:side-panel alone: the view and the settings page, nothing of the user's data.
+ expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', props)).toEqual({
+ view: 'calendar', context: { page: context.page }, focusRequest: 2,
+ });
+ expect(slotPropsFor(plugin(['ui:side-panel', 'calendar:read']), 'app-side-panel', props).context).toEqual({
+ event: context.event, page: context.page,
+ });
+ expect(slotPropsFor(plugin(['ui:side-panel', 'contacts:read']), 'app-side-panel', props).context).toEqual({
+ contact: context.contact, selectedCount: 3, page: context.page,
+ });
+ expect(slotPropsFor(plugin(['ui:side-panel', 'files:read']), 'app-side-panel', props).context).toEqual({
+ files: context.files, fileCount: 1, folder: context.folder, page: context.page,
+ });
+ // A part the catalogue doesn't name never goes out, whatever the plugin may do.
+ const all = slotPropsFor(plugin(['ui:side-panel', 'calendar:read', 'contacts:read', 'files:read']), 'app-side-panel', props);
+ expect(all.context).not.toHaveProperty('secret');
+ expect(Object.keys(all.context as object).sort()).toEqual(Object.keys(APP_SIDE_PANEL_CONTEXT_FIELDS).sort());
+ });
+
+ it("cuts the context the same way in the click that asks the plugin for its dialog (the loader's dispatcher)", async () => {
+ // Importing the loader installs its action dispatcher, as in the app.
+ await import('@/lib/plugin-sandbox/loader');
+ const invokeHook = vi.fn(async () => undefined);
+ register({ plugin: plugin(['ui:side-panel', 'calendar:read']), code: '', tier: 'untrusted', background: { invokeHook }, slotOffers: [], hookDisposables: [] } as unknown as ActivePlugin);
+ try {
+ await runPluginAction('p', 'app-side-panel', 'open', { view: 'contacts', context: { contact: context.contact, page: context.page, secret: 'x' } });
+ expect(invokeHook).toHaveBeenCalledWith('onAction', [{ surface: 'app-side-panel', actionId: 'open', context: { view: 'contacts', context: { page: context.page } } }]);
+ } finally {
+ deregister('p');
+ }
+ // Garbage in the context is dropped, not passed on.
+ expect(slotPropsFor(plugin(['ui:side-panel']), 'app-side-panel', { context: 'x' }).context).toEqual({});
+ // The loader's import is slow under load.
+ }, 30_000);
+
+ it('tells each plugin whether it may show the panel (sidePanel follows its grant)', () => {
+ expect(assistantFeatures(plugin(['ui:side-panel'])).sidePanel).toBe(true);
+ // Enabled before an update added the permission: not granted yet.
+ const notGranted = { ...plugin(['ui:side-panel']), grantedPermissions: [] };
+ expect(assistantFeatures(notGranted).sidePanel).toBe(false);
+ expect(assistantFeatures(plugin([])).sidePanel).toBe(false);
+ });
+
+ it('routes api.ui.openPanel for it to the view that shows it, and nowhere else', () => {
+ expect(requestPanel('p', 'app-side-panel', 'open', undefined)).toBeNull();
+ const handler = vi.fn((request: { slot: string }) => (request.slot === 'app-side-panel' ? true : null));
+ onPanelRequest(handler);
+ expect(requestPanel('p', 'app-side-panel', 'open', undefined)).toBe(true);
+ expect(handler).toHaveBeenCalledWith({ pluginId: 'p', slot: 'app-side-panel', mode: 'open' });
+ expect(requestPanel('p', 'email-detail-sidebar', 'open', undefined)).toBeNull();
+ });
+});
diff --git a/lib/__tests__/plugin-assistant-areas.test.ts b/lib/__tests__/plugin-assistant-areas.test.ts
new file mode 100644
index 000000000..2574cd39c
--- /dev/null
+++ b/lib/__tests__/plugin-assistant-areas.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, it, vi } from 'vitest';
+
+// The AI assistant's host APIs live in one set of files per area
+// (lib/plugin-sandbox/assistant). These tests check that whatever an area
+// adds reaches the permission table, the dispatcher, api.host.features and
+// the plugin's api, without the shared files naming it.
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+const area = vi.hoisted(() => ({
+ calls: [] as unknown[][],
+}));
+
+vi.mock('@/lib/plugin-sandbox/assistant/b', () => ({
+ PERMS: { 'email.probeArea': 'email:read' },
+ dispatch: async (_plugin: unknown, method: string, args: unknown[]) => { area.calls.push([method, ...args]); return 'from area b'; },
+ features: () => ({ probeArea: true }),
+}));
+
+vi.mock('@/lib/plugin-sandbox/assistant/b.runtime', () => ({
+ runtime: (callApi: (m: string, a: unknown[]) => Promise) => ({ email: { probeArea: (x: string) => callApi('email.probeArea', [x]) } }),
+}));
+
+const { PERM_PER_METHOD, dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
+const { snapshotHostFeatures } = await import('@/lib/plugin-sandbox/host-features');
+const { withAssistantRuntime } = await import('@/lib/plugin-sandbox/assistant/runtime');
+const { NOT_MINE, dispatchAssistant } = await import('@/lib/plugin-sandbox/assistant');
+
+const plugin = { id: 'p', name: 'P', permissions: ['email:read'], managed: true } as never;
+
+describe('the assistant areas', () => {
+ it('put their methods behind their permissions and run them', async () => {
+ expect(PERM_PER_METHOD['email.probeArea']).toBe('email:read');
+ expect(await dispatchApiCall(plugin, 'email.probeArea', ['x'])).toBe('from area b');
+ expect(area.calls).toEqual([['email.probeArea', 'x']]);
+ // Without the permission the area never sees the call.
+ const without = { ...(plugin as object), permissions: [] } as never;
+ await expect(dispatchApiCall(without, 'email.probeArea', [])).rejects.toThrow(/lacks permission "email:read"/);
+ expect(area.calls).toHaveLength(1);
+ });
+
+ it('leave other methods alone', async () => {
+ expect(await dispatchAssistant(plugin, 'email.get', [])).toBe(NOT_MINE);
+ });
+
+ it('add their flags to api.host.features', () => {
+ expect(snapshotHostFeatures()).toMatchObject({ probeArea: true, externalSenderLabel: false });
+ });
+
+ it('add their methods to the plugin api next to the existing ones', () => {
+ const calls: unknown[][] = [];
+ const api = withAssistantRuntime({ email: { get: () => 'core' } }, async (method, args) => { calls.push([method, args]); return null; });
+ expect(api.email.get()).toBe('core');
+ (api.email as unknown as { probeArea: (x: string) => unknown }).probeArea('y');
+ expect(calls).toEqual([['email.probeArea', ['y']]]);
+ });
+});
diff --git a/lib/__tests__/plugin-assistant-b-list-selection.test.ts b/lib/__tests__/plugin-assistant-b-list-selection.test.ts
new file mode 100644
index 000000000..110c4b2bd
--- /dev/null
+++ b/lib/__tests__/plugin-assistant-b-list-selection.test.ts
@@ -0,0 +1,59 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import type { Email } from '../jmap/types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { listSelectionOf, MAX_LIST_SELECTION } from '../plugin-sandbox/assistant/b.list-selection';
+import { snapshotHostFeatures } from '../plugin-sandbox/host-features';
+import { SLOT_CATALOG } from '../plugin-slot-catalog';
+
+const client = { getAccountId: () => 'acc1' } as unknown as IJMAPClient;
+const other = { getAccountId: () => 'other' } as unknown as IJMAPClient;
+
+const row = (id: string, stamp: Partial = {}) => ({ id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, ...stamp } as unknown as Email);
+const state = (emails: Email[], keys: string[], threads: [string, Email[]][] = []) => ({
+ emails, selectedEmailKeys: new Set(keys), threadEmailsCache: new Map(threads),
+});
+
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+ useEmailStore.setState({ emails: [], selectedEmailKeys: new Set(), viewingAccountId: null });
+});
+
+describe('listSelectionOf', () => {
+ it('is null with fewer than two messages ticked', () => {
+ expect(listSelectionOf(state([row('a'), row('b')], []), client)).toBeNull();
+ expect(listSelectionOf(state([row('a'), row('b')], ['a']), client)).toBeNull();
+ });
+
+ it('names each message with its account, members of open conversations too', () => {
+ const shared = row('s', { sourceAccountId: 'team' });
+ const member = row('m');
+ expect(listSelectionOf(state([row('a'), shared], ['a', 'team:s', 'm'], [['t', [member]]]), client)).toEqual({
+ emails: [{ id: 'a', accountId: 'acc1' }, { id: 's', accountId: 'team' }, { id: 'm', accountId: 'acc1' }],
+ total: 3,
+ });
+ });
+
+ it('names at most 50 and counts them all', () => {
+ const rows = Array.from({ length: 70 }, (_, i) => row(`e${i}`));
+ const out = listSelectionOf(state(rows, rows.map((r) => r.id)), client)!;
+ expect(out.emails).toHaveLength(MAX_LIST_SELECTION);
+ expect(out.total).toBe(70);
+ });
+
+ it('leaves out rows of another connected login, which this login can not read', () => {
+ useAuthStore.setState({ client, getClientForAccount: (id: string) => (id === 'login2' ? other : null) } as never);
+ const foreign = row('x', { sourceClientAccountId: 'login2', sourceAccountId: 'acc-2' });
+ expect(listSelectionOf(state([row('a'), foreign], ['a', 'login2/acc-2:x']), client)).toEqual({ emails: [{ id: 'a', accountId: 'acc1' }], total: 2 });
+ });
+
+ it('is announced and reaches only plugins that may read mail', () => {
+ expect(snapshotHostFeatures()).toMatchObject({ listSelection: true });
+ const spec = SLOT_CATALOG['email-detail-sidebar'];
+ expect(spec.props === '*' ? null : spec.props.listSelection).toBe('email:read');
+ // The panel outside Mail (area f) has no list to tick in.
+ const app = SLOT_CATALOG['app-side-panel' as keyof typeof SLOT_CATALOG];
+ if (app && app.props !== '*') expect(app.props).not.toHaveProperty('listSelection');
+ });
+});
diff --git a/lib/__tests__/plugin-assistant-b-projection.test.ts b/lib/__tests__/plugin-assistant-b-projection.test.ts
new file mode 100644
index 000000000..1fe7c5843
--- /dev/null
+++ b/lib/__tests__/plugin-assistant-b-projection.test.ts
@@ -0,0 +1,49 @@
+import { describe, expect, it } from 'vitest';
+import type { Email } from '../jmap/types';
+import { emailToReadView } from '../plugin-projection';
+import { cidAltTexts, normaliseCid } from '../plugin-sandbox/assistant/b.projection';
+
+// An inline image of a message names its Content-ID and the alt text the
+// sender wrote for it, so the assistant can name the picture.
+
+const html = [
+ 'Hi Alice, here is the logo idea:
',
+ '
',
+ " ",
+ ' ',
+ ' ',
+].join('');
+
+const email = {
+ id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: {}, subject: 'Logo', receivedAt: '2026-05-01T10:00:00Z',
+ htmlBody: [{ partId: '1', blobId: 'b-html', size: 1, type: 'text/html' }],
+ bodyValues: { 1: { value: html } },
+ attachments: [
+ { partId: '2', blobId: 'blob-logo', name: 'logo.png', type: 'image/png', size: 10, disposition: 'inline', cid: '' },
+ { partId: '3', blobId: 'blob-chart', name: 'chart.png', type: 'image/png', size: 10, cid: 'chart@aa' },
+ { partId: '4', blobId: 'blob-plain', name: 'plain.png', type: 'image/png', size: 10, cid: 'plain@aa' },
+ { partId: '5', blobId: 'blob-pdf', name: 'agenda.pdf', type: 'application/pdf', size: 10, disposition: 'attachment' },
+ ],
+} as unknown as Email;
+
+describe('cid and alt on EmailReadView.attachments', () => {
+ it('gives inline images their Content-ID and alt text, and leaves plain attachments alone', () => {
+ const view = emailToReadView(email);
+ expect(view.attachments).toEqual([
+ { blobId: 'blob-logo', name: 'logo.png', type: 'image/png', size: 10, inline: true, cid: 'logo1@aa', alt: 'Orange logo with a stripe' },
+ { blobId: 'blob-chart', name: 'chart.png', type: 'image/png', size: 10, inline: true, cid: 'chart@aa', alt: 'Sales & costs' },
+ { blobId: 'blob-plain', name: 'plain.png', type: 'image/png', size: 10, inline: true, cid: 'plain@aa' },
+ { blobId: 'blob-pdf', name: 'agenda.pdf', type: 'application/pdf', size: 10, inline: false },
+ ]);
+ });
+
+ it('reads alt texts only of cid images, never of remote ones', () => {
+ expect([...cidAltTexts(email).entries()]).toEqual([['logo1@aa', 'Orange logo with a stripe'], ['chart@aa', 'Sales & costs']]);
+ expect(normaliseCid('')).toBe('logo1@aa');
+ });
+
+ it('copes with a message without HTML', () => {
+ const plain = { ...email, htmlBody: [], bodyValues: {} } as unknown as Email;
+ expect(emailToReadView(plain).attachments[0]).toEqual({ blobId: 'blob-logo', name: 'logo.png', type: 'image/png', size: 10, inline: true, cid: 'logo1@aa' });
+ });
+});
diff --git a/lib/__tests__/plugin-assistant-b-runtime.test.ts b/lib/__tests__/plugin-assistant-b-runtime.test.ts
new file mode 100644
index 000000000..7433da72e
--- /dev/null
+++ b/lib/__tests__/plugin-assistant-b-runtime.test.ts
@@ -0,0 +1,64 @@
+/**
+ * The AI assistant's host-call flags (lib/plugin-sandbox/assistant) reach
+ * the plugin: api.host.features in the sandbox carries them from init and
+ * follows host-change, next to the core flags.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import React from 'react';
+import { cleanup, render, waitFor } from '@testing-library/react';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+const HOST_ORIGIN = window.location.origin;
+
+function post(source: unknown, data: unknown): void {
+ const ev = new MessageEvent('message', { data, origin: HOST_ORIGIN });
+ Object.defineProperty(ev, 'source', { value: source });
+ window.dispatchEvent(ev);
+}
+
+describe('the assistant flags in the sandbox runtime', () => {
+ let hostWindow: { postMessage: ReturnType };
+ const realParent = Object.getOwnPropertyDescriptor(window, 'parent');
+ const g = globalThis as { __pluginApi?: { host: { features: Record } }; __seenAtActivate?: boolean };
+
+ beforeEach(() => {
+ vi.resetModules();
+ hostWindow = { postMessage: vi.fn() };
+ Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window });
+ vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
+ });
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ delete g.__pluginApi;
+ delete g.__seenAtActivate;
+ if (realParent) Object.defineProperty(window, 'parent', realParent);
+ });
+
+ it('come with init and follow host-change', async () => {
+ const bundle = `module.exports = { activate: function (api) {
+ globalThis.__seenAtActivate = api.host.features.attachmentText;
+ globalThis.__pluginApi = api;
+ } };`;
+ const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime');
+ render(React.createElement(SandboxRuntime));
+ post(hostWindow, {
+ type: 'init',
+ payload: {
+ mode: 'background', pluginId: 'x', tier: 'privileged', locale: 'en',
+ manifest: { id: 'x', version: '1.0.0', permissions: [], settings: {} },
+ code: bundle,
+ hostFeatures: { externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false, attachmentText: true },
+ },
+ });
+ await waitFor(() => expect(g.__pluginApi).toBeDefined());
+ expect(g.__seenAtActivate).toBe(true);
+ const kept = g.__pluginApi!.host.features;
+ post(hostWindow, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false, attachmentText: 'yes' } });
+ // Booleans only: anything but true is false.
+ expect(kept).toMatchObject({ externalSenderLabel: true, attachmentText: false });
+ }, 30_000);
+});
diff --git a/lib/__tests__/plugin-assistant-c-features.test.ts b/lib/__tests__/plugin-assistant-c-features.test.ts
new file mode 100644
index 000000000..61d637de6
--- /dev/null
+++ b/lib/__tests__/plugin-assistant-c-features.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+const { snapshotHostFeatures } = await import('../plugin-sandbox/host-features');
+
+// A plugin sees answerInvitation and changeSettings only once it was granted
+// the optional permission behind them: one enabled before the update that
+// added them doesn't have it until the user turns it off and on.
+
+function plugin(granted: string[]): InstalledPlugin {
+ return {
+ id: 'ai-assistant', name: 'AI', version: '2.2.0', author: 'test', description: '', type: 'hook',
+ permissions: ['calendar:respond', 'settings:change'], grantedPermissions: granted,
+ entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
+ } as InstalledPlugin;
+}
+
+describe('the area c flags for one plugin', () => {
+ it('follow what the plugin was granted', () => {
+ expect(snapshotHostFeatures(plugin([]))).toMatchObject({ answerInvitation: false, changeSettings: false, batchMail: true });
+ expect(snapshotHostFeatures(plugin(['calendar:respond']))).toMatchObject({ answerInvitation: true, changeSettings: false });
+ expect(snapshotHostFeatures(plugin(['calendar:respond', 'settings:change']))).toMatchObject({ answerInvitation: true, changeSettings: true });
+ });
+
+ it('say what the webmail offers when no plugin is named', () => {
+ expect(snapshotHostFeatures()).toMatchObject({ answerInvitation: true, changeSettings: true });
+ });
+});
diff --git a/lib/__tests__/plugin-assistant-undo-tokens.test.ts b/lib/__tests__/plugin-assistant-undo-tokens.test.ts
new file mode 100644
index 000000000..946983774
--- /dev/null
+++ b/lib/__tests__/plugin-assistant-undo-tokens.test.ts
@@ -0,0 +1,49 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { UNDO_TOKEN_TTL_MS, clearUndoTokens, issueUndoToken, withUndoToken } from '../plugin-sandbox/assistant/c.undo';
+
+// The host keeps what an undo needs and hands the plugin only a token: bound
+// to that plugin and account, good for 30 minutes, spent once the undo went
+// through, kept when it failed.
+
+const use = (token: unknown, { plugin = 'p1', account = 'acc1', kind = 'batch' } = {}, run = async (data: unknown) => data) =>
+ withUndoToken('test.undo', plugin, account, kind, token, run);
+
+beforeEach(() => { clearUndoTokens(); });
+afterEach(() => { vi.useRealTimers(); });
+
+describe('undo tokens', () => {
+ it('work for 30 minutes and not after', async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-11-03T10:00:00Z'));
+ const early = issueUndoToken('p1', 'acc1', 'batch', 'a');
+ const late = issueUndoToken('p1', 'acc1', 'batch', 'b');
+ vi.setSystemTime(Date.now() + UNDO_TOKEN_TTL_MS - 1000);
+ expect(await use(early)).toBe('a');
+ vi.setSystemTime(Date.now() + 2000);
+ await expect(use(late)).rejects.toThrow(/can't be undone any more/);
+ });
+
+ it('belong to the plugin, the account and the kind they were made for', async () => {
+ const token = issueUndoToken('p1', 'acc1', 'batch', 'x');
+ await expect(use(token, { account: 'acc2' })).rejects.toThrow(/can't be undone any more/);
+ await expect(use(token, { plugin: 'p2' })).rejects.toThrow(/can't be undone any more/);
+ await expect(use(token, { kind: 'filters' })).rejects.toThrow(/can't be undone any more/);
+ expect(await use(token)).toBe('x');
+ });
+
+ it('are spent when the undo went through, and kept when it failed', async () => {
+ const token = issueUndoToken('p1', 'acc1', 'batch', 'x');
+ await expect(use(token, {}, async () => { throw new Error('server down'); })).rejects.toThrow('server down');
+ expect(await use(token)).toBe('x');
+ await expect(use(token)).rejects.toThrow(/can't be undone any more/);
+ });
+
+ it('run one undo at a time', async () => {
+ const token = issueUndoToken('p1', 'acc1', 'batch', 'x');
+ let finish!: () => void;
+ const first = use(token, {}, () => new Promise((resolve) => { finish = () => resolve('done'); }));
+ await expect(use(token)).rejects.toThrow(/can't be undone any more/);
+ finish();
+ expect(await first).toBe('done');
+ });
+});
diff --git a/lib/__tests__/plugin-host-api-app-info.test.ts b/lib/__tests__/plugin-host-api-app-info.test.ts
index 946126e77..94c79dad8 100644
--- a/lib/__tests__/plugin-host-api-app-info.test.ts
+++ b/lib/__tests__/plugin-host-api-app-info.test.ts
@@ -102,4 +102,22 @@ describe('api.app.getSettings', () => {
expect(settings).not.toHaveProperty('pushRelayUrl');
expect(JSON.stringify(settings)).not.toContain('boss@example.org');
});
+
+ it('shares every setting a plugin may change, so it knows the value it would replace', async () => {
+ const { SETTINGS_SCHEMA } = await import('@/lib/settings-schema');
+ const { SHARED_SETTINGS } = await import('../plugin-sandbox/host-api-app');
+ const { useSettingsStore } = await import('@/stores/settings-store');
+ const { useCalendarStore } = await import('@/stores/calendar-store');
+ const shared = new Set(SHARED_SETTINGS);
+ const missing = Object.values(SETTINGS_SCHEMA).filter((e) => e.store === 'settings' && !shared.has(e.key)).map((e) => e.key);
+ expect(missing).toEqual([]);
+
+ useSettingsStore.getState().resetToDefaults();
+ useCalendarStore.setState({ viewMode: 'week' });
+ const settings = await dispatchApiCall(plugin(['settings:read']), 'app.getSettings', []) as Record;
+ expect(settings.calendarView).toBe('week');
+ // The reminder words come through whole (29 by default), or a proposal
+ // built on them would drop the rest.
+ expect(settings.attachmentReminderKeywords).toEqual(useSettingsStore.getState().attachmentReminderKeywords);
+ });
});
diff --git a/lib/__tests__/plugin-host-attachment-image.test.ts b/lib/__tests__/plugin-host-attachment-image.test.ts
new file mode 100644
index 000000000..d9450d96a
--- /dev/null
+++ b/lib/__tests__/plugin-host-attachment-image.test.ts
@@ -0,0 +1,146 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import type { Email } from '../jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+const exif = vi.hoisted(() => ({ read: vi.fn(() => ({ taken: '2026-10-31T14:05:30', lat: 52.52, lon: 13.4 })) }));
+vi.mock('@/lib/exif', () => ({ readPhotoExif: exif.read }));
+
+import { dispatchApiCall, PERM_PER_METHOD } from '../plugin-sandbox/host-api';
+import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
+import { snapshotHostFeatures } from '../plugin-sandbox/host-features';
+import { withAssistantRuntime } from '../plugin-sandbox/assistant/runtime';
+
+// jsdom can't decode or draw images: createImageBitmap and OffscreenCanvas
+// are stood in for. A "picture" is a PNG header declaring its size.
+
+function plugin(perms: string[], id = 'ai-assistant'): InstalledPlugin {
+ return {
+ id, name: 'AI Assistant', version: '2.2.0', author: 'test', description: '',
+ type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+}
+
+const ASSISTANT = plugin(['email:read', 'email:blob-read']);
+const call = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'email.getAttachmentImage', args);
+
+const parts = {
+ photo: { partId: '2', blobId: 'blob-photo', name: 'whiteboard.jpg', type: 'image/jpeg', size: 30 },
+ logo: { partId: '3', blobId: 'blob-logo', name: 'logo.png', type: 'image/png', size: 30, disposition: 'inline', cid: '' },
+ pixel: { partId: '4', blobId: 'blob-pixel', name: 'p.gif', type: 'image/gif', size: 30, disposition: 'inline', cid: 'px@aa' },
+ pdf: { partId: '5', blobId: 'blob-pdf', name: 'agenda.pdf', type: 'application/pdf', size: 30 },
+ svg: { partId: '6', blobId: 'blob-svg', name: 'chart.svg', type: 'image/svg+xml', size: 30 },
+ huge: { partId: '7', blobId: 'blob-huge', name: 'raw.tif', type: 'image/tiff', size: 21 * 1024 * 1024 },
+};
+const SIZES: Record = {
+ 'blob-photo': { w: 3000, h: 2000 }, 'blob-logo': { w: 240, h: 160 }, 'blob-pixel': { w: 1, h: 1 },
+};
+
+function pngHeader({ w, h }: { w: number; h: number }): Uint8Array {
+ const b = new Uint8Array(33);
+ b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
+ new DataView(b.buffer).setUint32(16, w);
+ new DataView(b.buffer).setUint32(20, h);
+ b[24] = 8;
+ b[25] = 2;
+ return b;
+}
+
+const message = {
+ id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: {}, subject: 'Logo', receivedAt: '2026-05-01T10:00:00Z',
+ htmlBody: [{ partId: '1', blobId: 'b-html', size: 1, type: 'text/html' }],
+ bodyValues: { 1: { value: '
' } },
+ attachments: Object.values(parts),
+} as unknown as Email;
+
+class FakeCanvas {
+ constructor(public width: number, public height: number) {}
+ getContext() {
+ return { drawImage() {}, fillRect() {}, getImageData: (_x: number, _y: number, w: number, h: number) => ({ data: new Uint8ClampedArray(w * h * 4).fill(255) }) };
+ }
+ async convertToBlob({ type }: { type: string }) { return new Blob([new Uint8Array([104, 105])], { type }); }
+}
+
+let client: Record>;
+
+beforeEach(() => {
+ resetPluginRateLimits();
+ exif.read.mockClear();
+ vi.stubGlobal('OffscreenCanvas', FakeCanvas);
+ vi.stubGlobal('createImageBitmap', vi.fn(async (blob: Blob, options?: { resizeWidth?: number }) => {
+ const view = new DataView(await blob.arrayBuffer());
+ const w = view.getUint32(16);
+ const h = view.getUint32(20);
+ const width = options?.resizeWidth ?? w;
+ return { width, height: Math.round((h * width) / w), close() {} };
+ }));
+ client = {
+ getEmail: vi.fn(async (id: string) => (id === 'm1' ? message : null)),
+ fetchBlobArrayBuffer: vi.fn(async (blobId: string) => pngHeader(SIZES[blobId] ?? { w: 0, h: 0 }).buffer),
+ getAccountId: vi.fn(() => 'acc1'),
+ getMailboxes: vi.fn(async () => []),
+ };
+ useAuthStore.setState({ client: client as unknown as IJMAPClient });
+ useEmailStore.setState({ selectedEmail: null, emails: [], mailboxes: [], selectedMailbox: '' });
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ useAuthStore.setState({ client: null });
+});
+
+describe('api.email.getAttachmentImage', () => {
+ it('is behind email:blob-read, offered in the runtime and announced in the host features', async () => {
+ expect(PERM_PER_METHOD['email.getAttachmentImage']).toBe('email:blob-read');
+ await expect(call(plugin(['email:read']), 'm1', 'blob-photo')).rejects.toThrow(/lacks permission "email:blob-read"/);
+ expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
+ expect(snapshotHostFeatures()).toMatchObject({ attachmentImages: true });
+ const calls: unknown[][] = [];
+ const api = withAssistantRuntime({} as { email?: { getAttachmentImage?: (...a: unknown[]) => unknown } }, async (method, args) => { calls.push([method, args]); return null; });
+ await api.email?.getAttachmentImage?.('m1', 'blob-photo', { maxSide: 512 });
+ expect(calls).toEqual([['email.getAttachmentImage', ['m1', 'blob-photo', { maxSide: 512 }]]]);
+ });
+
+ it('returns the photo shrunk to 1568 px as base64 JPEG, without EXIF unless asked', async () => {
+ expect(await call(ASSISTANT, 'm1', 'blob-photo')).toEqual({
+ name: 'whiteboard.jpg', type: 'image/jpeg', width: 1568, height: 1045, data: 'aGk=', status: 'ok',
+ });
+ expect(exif.read).not.toHaveBeenCalled();
+ expect(await call(ASSISTANT, 'm1', 'blob-photo', { maxSide: 600, exif: true })).toMatchObject({
+ width: 600, height: 400, exif: { taken: '2026-10-31T14:05:30', lat: 52.52, lon: 13.4 },
+ });
+ expect(exif.read).toHaveBeenCalledTimes(1);
+ });
+
+ it('gives an inline image its alt text and skips a tracking pixel', async () => {
+ expect(await call(ASSISTANT, 'm1', 'blob-logo')).toMatchObject({ status: 'ok', alt: 'Orange logo with a stripe', width: 240, height: 160 });
+ expect(await call(ASSISTANT, 'm1', 'blob-pixel')).toEqual({ name: 'p.gif', type: 'image/gif', width: 1, height: 1, data: '', status: 'tracker' });
+ });
+
+ it('answers unsupported for what is not a picture, SVG included, and too_big without downloading', async () => {
+ expect(await call(ASSISTANT, 'm1', 'blob-pdf')).toMatchObject({ status: 'unsupported', data: '' });
+ expect(await call(ASSISTANT, 'm1', 'blob-svg')).toMatchObject({ status: 'unsupported' });
+ expect(await call(ASSISTANT, 'm1', 'blob-huge')).toMatchObject({ status: 'too_big' });
+ expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
+ });
+
+ it('refuses a blob that is not an attachment of that message', async () => {
+ await expect(call(ASSISTANT, 'm1', 'b-html')).rejects.toThrow(/not an attachment of this message/);
+ await expect(call(ASSISTANT, 'missing', 'blob-photo')).rejects.toThrow(/no message with id "missing"/);
+ expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
+ });
+
+ it('checks its options and allows 30 reads a minute', async () => {
+ await expect(call(ASSISTANT, 'm1', 'blob-photo', { maxSide: 8000 })).rejects.toThrow(/maxSide must be a whole number from 64 to 2576/);
+ await expect(call(ASSISTANT, 'm1', 'blob-photo', { exif: 'yes' })).rejects.toThrow(/exif must be true or false/);
+ await expect(call(ASSISTANT, 'm1', 'blob-photo', { url: 'https://x' })).rejects.toThrow(/unknown property "url"/);
+ for (let i = 0; i < 30; i++) await call(ASSISTANT, 'm1', 'blob-pdf');
+ await expect(call(ASSISTANT, 'm1', 'blob-pdf')).rejects.toThrow(/too many reads/);
+ });
+});
diff --git a/lib/__tests__/plugin-host-attachment-text.test.ts b/lib/__tests__/plugin-host-attachment-text.test.ts
new file mode 100644
index 000000000..bb5f0e9e5
--- /dev/null
+++ b/lib/__tests__/plugin-host-attachment-text.test.ts
@@ -0,0 +1,137 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { strToU8, zipSync } from 'fflate';
+import type { InstalledPlugin } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import type { Email } from '../jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+import { dispatchApiCall, PERM_PER_METHOD } from '../plugin-sandbox/host-api';
+import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
+import { snapshotHostFeatures } from '../plugin-sandbox/host-features';
+import { withAssistantRuntime } from '../plugin-sandbox/assistant/runtime';
+
+function plugin(perms: string[], id = 'ai-assistant'): InstalledPlugin {
+ return {
+ id, name: 'AI Assistant', version: '2.2.0', author: 'test', description: '',
+ type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+}
+
+const ASSISTANT = plugin(['email:read', 'email:blob-read']);
+const call = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'email.getAttachmentText', args);
+
+const notes = { partId: '2', blobId: 'blob-notes', name: 'workshop-notes.txt', type: 'text/plain', size: 40 };
+const minutes = { partId: '3', blobId: 'blob-docx', name: 'minutes.docx', type: 'application/octet-stream', size: 300 };
+const photo = { partId: '4', blobId: 'blob-jpg', name: 'photo.jpg', type: 'image/jpeg', size: 3 };
+const old = { partId: '5', blobId: 'blob-doc', name: 'old.doc', type: 'application/msword', size: 8 };
+const huge = { partId: '6', blobId: 'blob-big', name: 'scan.pdf', type: 'application/pdf', size: 20 * 1024 * 1024 + 1 };
+
+const message = {
+ id: 'm1', threadId: 't1', blobId: 'blob-raw', size: 4, mailboxIds: { inbox: true }, keywords: {},
+ subject: 'Workshop', receivedAt: '2026-05-01T10:00:00Z', attachments: [notes, minutes, photo, old, huge],
+} as unknown as Email;
+
+const DOCX = zipSync({
+ 'word/document.xml': strToU8('Launch moves to 12 December. '),
+});
+const BLOBS: Record = {
+ 'blob-notes': strToU8('Workshop notes\n\n1. Launch date moves to 12 December.\n'),
+ 'blob-docx': DOCX,
+ 'blob-jpg': new Uint8Array([0xff, 0xd8, 0xff]),
+ 'blob-doc': new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]),
+};
+
+let client: Record>;
+
+beforeEach(() => {
+ resetPluginRateLimits();
+ client = {
+ // Every account has an m1: only the host's routing keeps a plugin out of the ones it may not read.
+ getEmail: vi.fn(async (id: string) => (id === 'm1' ? message : null)),
+ fetchBlobArrayBuffer: vi.fn(async (blobId: string) => {
+ const bytes = BLOBS[blobId];
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
+ }),
+ getAccountId: vi.fn(() => 'acc1'),
+ getMailboxes: vi.fn(async () => []),
+ };
+ useAuthStore.setState({ client: client as unknown as IJMAPClient });
+ useEmailStore.setState({ selectedEmail: null, emails: [], mailboxes: [], selectedMailbox: '' });
+});
+
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+});
+
+describe('api.email.getAttachmentText', () => {
+ it('is behind email:blob-read, offered in the runtime and announced in the host features', async () => {
+ expect(PERM_PER_METHOD['email.getAttachmentText']).toBe('email:blob-read');
+ await expect(call(plugin(['email:read']), 'm1', 'blob-notes')).rejects.toThrow(/lacks permission "email:blob-read"/);
+ expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
+ expect(snapshotHostFeatures()).toMatchObject({ attachmentText: true });
+ const calls: unknown[][] = [];
+ const api = withAssistantRuntime({} as { email?: { getAttachmentText?: (...a: unknown[]) => unknown } }, async (method, args) => { calls.push([method, args]); return null; });
+ await api.email?.getAttachmentText?.('m1', 'blob-notes', { maxChars: 10 });
+ expect(calls).toEqual([['email.getAttachmentText', ['m1', 'blob-notes', { maxChars: 10 }]]]);
+ });
+
+ it('returns the text of a text attachment', async () => {
+ const out = await call(ASSISTANT, 'm1', 'blob-notes');
+ expect(out).toEqual({
+ name: 'workshop-notes.txt', type: 'text/plain', size: BLOBS['blob-notes'].byteLength,
+ format: 'text', status: 'ok', text: 'Workshop notes\n\n1. Launch date moves to 12 December.', truncated: false,
+ });
+ expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-notes', 'workshop-notes.txt', 'text/plain');
+ });
+
+ it('reads an Office file by its name and cuts at maxChars', async () => {
+ expect(await call(ASSISTANT, 'm1', 'blob-docx')).toMatchObject({ format: 'docx', status: 'ok', text: 'Launch moves to 12 December.' });
+ expect(await call(ASSISTANT, 'm1', 'blob-docx', { maxChars: 12 })).toMatchObject({ text: 'Launch moves', truncated: true });
+ });
+
+ it('answers with a status for what it can not read', async () => {
+ expect(await call(ASSISTANT, 'm1', 'blob-jpg')).toMatchObject({ status: 'unsupported', text: '' });
+ expect(await call(ASSISTANT, 'm1', 'blob-doc')).toMatchObject({ status: 'unsupported', format: 'other' });
+ });
+
+ it('answers too_big for an attachment over 20 MB without downloading it', async () => {
+ expect(await call(ASSISTANT, 'm1', 'blob-big')).toMatchObject({ name: 'scan.pdf', format: 'pdf', status: 'too_big', text: '', truncated: false });
+ expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
+ });
+
+ it('refuses a blob that is not an attachment of that message, and a message it can not find', async () => {
+ await expect(call(ASSISTANT, 'm1', 'blob-raw')).rejects.toThrow(/not an attachment of this message/);
+ await expect(call(ASSISTANT, 'm1', 'someone-elses-blob')).rejects.toThrow(/not an attachment/);
+ await expect(call(ASSISTANT, 'missing', 'blob-notes')).rejects.toThrow(/no message with id "missing"/);
+ // An account this login doesn't show is not asked.
+ await expect(call(ASSISTANT, 'm1', 'blob-notes', { accountId: 'stranger' })).rejects.toThrow(/no message with id/);
+ expect(client.getEmail).not.toHaveBeenCalledWith('m1', 'stranger');
+ expect(client.fetchBlobArrayBuffer).not.toHaveBeenCalled();
+ });
+
+ it('reads an attachment of a shared folder from that account', async () => {
+ useEmailStore.setState({ mailboxes: [{ id: 'shared-acc:inbox', originalId: 'inbox', name: 'Team', isShared: true, accountId: 'shared-acc' } as never] });
+ expect(await call(ASSISTANT, 'm1', 'blob-notes', { accountId: 'shared-acc' })).toMatchObject({ status: 'ok' });
+ expect(client.getEmail).toHaveBeenCalledWith('m1', 'shared-acc');
+ expect(client.fetchBlobArrayBuffer).toHaveBeenCalledWith('blob-notes', 'workshop-notes.txt', 'text/plain', 'shared-acc');
+ });
+
+ it('checks its options', async () => {
+ await expect(call(ASSISTANT, 'm1', 'blob-notes', { maxChars: 100_001 })).rejects.toThrow(/maxChars must be a whole number from 1 to 100000/);
+ await expect(call(ASSISTANT, 'm1', 'blob-notes', { pages: 3 })).rejects.toThrow(/unknown property "pages"/);
+ await expect(call(ASSISTANT, 'm1', 7)).rejects.toThrow(/blobId must be a string/);
+ });
+
+ it('allows 30 reads a minute per plugin', async () => {
+ for (let i = 0; i < 30; i++) await call(ASSISTANT, 'm1', 'blob-notes');
+ await expect(call(ASSISTANT, 'm1', 'blob-notes')).rejects.toThrow(/too many reads/);
+ // Another plugin has its own count.
+ await expect(call(plugin(['email:blob-read'], 'other'), 'm1', 'blob-notes')).resolves.toMatchObject({ status: 'ok' });
+ });
+});
diff --git a/lib/__tests__/plugin-host-batch.test.ts b/lib/__tests__/plugin-host-batch.test.ts
new file mode 100644
index 000000000..a30f45fa9
--- /dev/null
+++ b/lib/__tests__/plugin-host-batch.test.ts
@@ -0,0 +1,147 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import type { Mailbox } from '../jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
+import { clearUndoHistory, undoHistory, undoLatest } from '../undo-history';
+import { clearUndoTokens } from '../plugin-sandbox/assistant/c.undo';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+import { dispatchApiCall } from '../plugin-sandbox/host-api';
+
+function plugin(id: string, perms: string[]): InstalledPlugin {
+ return {
+ id, name: id, version: '1.0.0', author: 'test', description: '',
+ type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+}
+const WRITER = plugin('assistant', ['email:write']);
+const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
+
+const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
+const news = { id: 'news', name: 'Newsletters', role: null } as unknown as Mailbox;
+const trash = { id: 'trash', name: 'Trash', role: 'trash' } as Mailbox;
+// As the client lists folders: without their share maps (only Mailbox/get
+// with shareWith, getMailboxShareWith, says who else reads a folder).
+const sharedOut = { id: 'team', name: 'Team', role: null } as unknown as Mailbox;
+const underShared = { id: 'team-sub', name: 'Projects', role: null, parentId: 'team' } as unknown as Mailbox;
+const unknownShare = { id: 'flaky', name: 'Flaky', role: null } as unknown as Mailbox;
+const SHARES: Record = { team: { u2: { mayReadItems: true } } };
+const otherAccount = { id: 'acc2:box', name: 'Theirs', isShared: true, accountId: 'acc2' } as unknown as Mailbox;
+
+const rows = [
+ { id: 'm1', mailboxIds: { inbox: true }, keywords: { $seen: true }, receivedAt: '2026-10-01T10:00:00Z' },
+ { id: 'm2', mailboxIds: { inbox: true, news: true }, keywords: {}, receivedAt: '2026-10-02T10:00:00Z' },
+];
+let getEmailFields: ReturnType;
+let client: IJMAPClient;
+const store = {
+ batchArchive: vi.fn(async () => true),
+ batchMoveToMailbox: vi.fn(async () => true),
+ batchMarkAsRead: vi.fn(async () => true),
+ batchSetTag: vi.fn(async () => true),
+ undoMove: vi.fn(async () => {}),
+};
+
+beforeEach(() => {
+ resetPluginRateLimits();
+ clearUndoHistory();
+ clearUndoTokens();
+ for (const fn of Object.values(store)) fn.mockClear();
+ getEmailFields = vi.fn(async (ids: string[]) => rows.filter((r) => ids.includes(r.id)));
+ client = {
+ getAccountId: () => 'acc1', getEmailFields,
+ supportsMailboxSharing: () => true,
+ getMailboxShareWith: vi.fn(async (id: string) => {
+ if (id === 'flaky') throw new Error('Mailbox/get failed');
+ return SHARES[id] ?? null;
+ }),
+ } as unknown as IJMAPClient;
+ useAuthStore.setState({ client });
+ useEmailStore.setState({ mailboxes: [inbox, news, trash, sharedOut, underShared, unknownShare, otherAccount], ...store } as never);
+ useSettingsStore.setState({ emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }] } as never);
+});
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+});
+
+describe('api.email.batch', () => {
+ it('archives the messages of the account, as the store archives them, with one Undo', async () => {
+ const result = await call(WRITER, 'email.batch', { emailIds: ['m1', 'm2', 'gone'], action: 'archive' }) as { done: number; failed: number; undoToken: string };
+ expect(result).toMatchObject({ done: 2, failed: 1 });
+ // From the signed-in account only, and through the store with an explicit target.
+ expect(getEmailFields).toHaveBeenCalledWith(['m1', 'm2', 'gone'], expect.any(Array), undefined);
+ expect(store.batchArchive).toHaveBeenCalledWith(client, expect.objectContaining({ emailIds: ['m1', 'm2'], client }));
+ // One entry in the app's undo history for the whole batch.
+ expect(undoHistory()).toHaveLength(1);
+
+ await call(WRITER, 'email.undoBatch', result.undoToken);
+ // The restore points were taken before: every folder and the read state.
+ expect(store.undoMove).toHaveBeenCalledTimes(1);
+ expect(store.undoMove).toHaveBeenCalledWith(client, [{
+ client, accountId: undefined,
+ points: [{ id: 'm1', mailboxIds: { inbox: true }, seen: true }, { id: 'm2', mailboxIds: { inbox: true, news: true }, seen: false }],
+ }], []);
+ // Off the history too, and it works once.
+ expect(undoHistory()).toHaveLength(0);
+ await expect(call(WRITER, 'email.undoBatch', result.undoToken)).rejects.toThrow(/can't be undone/);
+ });
+
+ it('runs the undo once whether Ctrl+Z or the plugin comes first', async () => {
+ const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'news' }) as { undoToken: string };
+ expect(store.batchMoveToMailbox).toHaveBeenCalledWith(client, 'news', expect.objectContaining({ emailIds: ['m1'] }));
+ undoLatest();
+ await call(WRITER, 'email.undoBatch', undoToken);
+ expect(store.undoMove).toHaveBeenCalledTimes(1);
+ });
+
+ it('marks read only what changes and puts exactly that back', async () => {
+ const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1', 'm2'], action: 'read' }) as { undoToken: string };
+ expect(store.batchMarkAsRead).toHaveBeenLastCalledWith(client, true, { emailIds: ['m2'], client });
+ await call(WRITER, 'email.undoBatch', undoToken);
+ expect(store.batchMarkAsRead).toHaveBeenLastCalledWith(client, false, { emailIds: ['m2'], client });
+ });
+
+ it('tags through the store and takes the tag off again on undo', async () => {
+ const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1', 'm2'], action: 'tag', tagId: 'work' }) as { undoToken: string };
+ expect(store.batchSetTag).toHaveBeenLastCalledWith(client, 'work', true, expect.objectContaining({ emailIds: ['m1', 'm2'] }));
+ await call(WRITER, 'email.undoBatch', undoToken);
+ expect(store.batchSetTag).toHaveBeenLastCalledWith(client, 'work', false, expect.objectContaining({ emailIds: ['m1', 'm2'] }));
+ await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'tag', tagId: 'nope' })).rejects.toThrow(/not one of the user's tags/);
+ });
+
+ it('refuses shared folders, other accounts, the trash and more than 200 messages', async () => {
+ await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'team' })).rejects.toThrow(/only the user reads/);
+ // A folder inside a shared one, and one whose share map the server didn't give.
+ await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'team-sub' })).rejects.toThrow(/only the user reads/);
+ await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'flaky' })).rejects.toThrow(/only the user reads/);
+ await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'acc2:box' })).rejects.toThrow(/only the user reads/);
+ await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'move', mailboxId: 'trash' })).rejects.toThrow(/trash/);
+ await expect(call(WRITER, 'email.batch', { emailIds: Array.from({ length: 201 }, (_, i) => `m${i}`), action: 'read' })).rejects.toThrow(/at most 200/);
+ await expect(call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'delete' })).rejects.toThrow(/action must be/);
+ expect(store.batchMoveToMailbox).not.toHaveBeenCalled();
+ });
+
+ it('lets the plugin try a failed undo again', async () => {
+ const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'archive' }) as { undoToken: string };
+ store.undoMove.mockRejectedValueOnce(new Error('server down'));
+ await expect(call(WRITER, 'email.undoBatch', undoToken)).rejects.toThrow('server down');
+ await call(WRITER, 'email.undoBatch', undoToken);
+ expect(store.undoMove).toHaveBeenCalledTimes(2);
+ await expect(call(WRITER, 'email.undoBatch', undoToken)).rejects.toThrow(/can't be undone/);
+ });
+
+ it('keeps the undo to the plugin that made it, and needs email:write', async () => {
+ const { undoToken } = await call(WRITER, 'email.batch', { emailIds: ['m1'], action: 'archive' }) as { undoToken: string };
+ await expect(call(plugin('other', ['email:write']), 'email.undoBatch', undoToken)).rejects.toThrow(/can't be undone/);
+ expect(store.undoMove).not.toHaveBeenCalled();
+ await expect(call(plugin('reader', ['email:read']), 'email.batch', { emailIds: ['m1'], action: 'read' })).rejects.toThrow(/lacks permission "email:write"/);
+ });
+});
diff --git a/lib/__tests__/plugin-host-calendar-respond.test.ts b/lib/__tests__/plugin-host-calendar-respond.test.ts
new file mode 100644
index 000000000..b87e3263c
--- /dev/null
+++ b/lib/__tests__/plugin-host-calendar-respond.test.ts
@@ -0,0 +1,142 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import type { CalendarEvent } from '../jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useVacationStore } from '@/stores/vacation-store';
+import { clearUndoTokens } from '../plugin-sandbox/assistant/c.undo';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+import { dispatchApiCall } from '../plugin-sandbox/host-api';
+
+function plugin(id: string, perms: string[]): InstalledPlugin {
+ return {
+ id, name: id, version: '1.0.0', author: 'test', description: '',
+ type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+}
+const ASSISTANT = plugin('assistant', ['calendar:respond', 'calendar:write', 'vacation:read']);
+const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
+
+const alice = { email: 'alice@example.org', name: 'Alice', roles: { owner: true } };
+const bob = { email: 'bob@example.org', name: 'Bob', roles: { attendee: true }, participationStatus: 'needs-action' };
+const invitation = {
+ id: 'inv', subject: 'Invitation: Team offsite', from: [{ email: 'alice@example.org', name: 'Alice' }],
+ attachments: [{ blobId: 'ics1', name: 'invite.ics', type: 'text/calendar', size: 100 }],
+ textBody: [], htmlBody: [],
+};
+let parsed: Partial;
+let client: IJMAPClient;
+const store = { rsvpEvent: vi.fn(async () => true), importEvents: vi.fn(async () => 1), deleteEvent: vi.fn(async () => true), createEvent: vi.fn(async () => ({ id: 'again' })) };
+
+beforeEach(() => {
+ clearUndoTokens();
+ for (const fn of Object.values(store)) fn.mockClear();
+ parsed = {
+ uid: 'u1', title: 'Team offsite', start: '2026-11-20T09:00:00',
+ organizerCalendarAddress: 'mailto:alice@example.org', participants: { p1: alice, p2: bob } as never,
+ };
+ client = {
+ getAccountId: () => 'acc1',
+ getEmail: vi.fn(async (id: string) => (id === 'inv' ? invitation : null)),
+ parseCalendarEvents: vi.fn(async () => [parsed]),
+ fetchBlob: vi.fn(async () => new Blob(['BEGIN:VCALENDAR\r\nMETHOD:REQUEST\r\nEND:VCALENDAR'])),
+ queryCalendarEvents: vi.fn(async () => []),
+ getCalendarEvent: vi.fn(async () => null),
+ supportsVacationResponse: () => true,
+ } as unknown as IJMAPClient;
+ useAuthStore.setState({ client, primaryIdentity: { email: 'bob@example.org' }, identities: [] } as never);
+ useEmailStore.setState({ emails: [], selectedEmail: null } as never);
+ useSettingsStore.setState({ calendarInvitationParsingEnabled: true } as never);
+ const imported = { id: 'e-new', uid: 'u1', participants: { p1: alice, p2: bob } };
+ useCalendarStore.setState({
+ supportsCalendar: true, calendars: [{ id: 'cal1', isDefault: true }], events: [],
+ ...store,
+ importEvents: vi.fn(async () => { useCalendarStore.setState({ events: [imported] } as never); return 1; }),
+ } as never);
+});
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+});
+
+describe('api.calendar.respond', () => {
+ it('answers the invitation the way the banner does, so the server replies to the organiser', async () => {
+ const out = await call(ASSISTANT, 'calendar.respond', 'inv', 'accepted');
+ expect(out).toEqual({ status: 'accepted', title: 'Team offsite', start: '2026-11-20T09:00:00', organizer: 'alice@example.org' });
+ expect(useCalendarStore.getState().importEvents).toHaveBeenCalledWith(client, [parsed], 'cal1', { throwIfNoneImported: true });
+ expect(store.rsvpEvent).toHaveBeenCalledWith(client, 'e-new', 'p2', 'accepted', { imip: 'mailto:alice@example.org' });
+ });
+
+ it('refuses an event the user organises, a message that asks no answer, and while invitations are off', async () => {
+ // The user's own invitation, sent from their own address.
+ vi.mocked(client.getEmail).mockResolvedValueOnce({ ...invitation, from: [{ email: 'bob@example.org', name: 'Bob' }] } as never);
+ parsed.participants = { p1: { ...alice, email: 'bob@example.org' } } as never;
+ await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/organises/);
+ // From forged as the user's own address, for someone else's event, not verified.
+ vi.mocked(client.getEmail).mockResolvedValueOnce({ ...invitation, from: [{ email: 'bob@example.org', name: 'Bob' }] } as never);
+ parsed.participants = { p1: alice, p2: bob } as never;
+ await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/may not come from/);
+ // Someone else's event whose sender doesn't match its organiser.
+ vi.mocked(client.getEmail).mockResolvedValueOnce({ ...invitation, from: [{ email: 'mallory@evil.example', name: 'Alice' }] } as never);
+ parsed.participants = { p1: alice, p2: bob } as never;
+ await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/may not come from/);
+ parsed.participants = { p1: alice, p2: bob } as never;
+ parsed.status = 'cancelled';
+ vi.mocked(client.fetchBlob).mockResolvedValue(new Blob(['METHOD:CANCEL']));
+ await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/not an invitation that asks for an answer/);
+ useSettingsStore.setState({ calendarInvitationParsingEnabled: false } as never);
+ await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/switched off/);
+ expect(store.rsvpEvent).not.toHaveBeenCalled();
+ });
+
+ it('answers a message the list names with the account of the user', async () => {
+ // Search results carry their account, the user's own one included.
+ useEmailStore.setState({ emails: [{ id: 'inv', sourceClientAccountId: 'login1', sourceAccountId: 'acc1' }] } as never);
+ useAuthStore.setState({ getClientForAccount: () => client } as never);
+ await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).resolves.toMatchObject({ status: 'accepted' });
+ });
+
+ it('refuses mail of another account, a bad status and plugins without calendar:respond', async () => {
+ useEmailStore.setState({ emails: [{ id: 'inv', sourceClientAccountId: 'login1', sourceAccountId: 'acc2' }] } as never);
+ useAuthStore.setState({ getClientForAccount: () => client } as never);
+ await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/other accounts/);
+ await expect(call(ASSISTANT, 'calendar.respond', 'inv', 'delegated')).rejects.toThrow(/status must be/);
+ await expect(call(plugin('cal', ['calendar:write']), 'calendar.respond', 'inv', 'accepted')).rejects.toThrow(/lacks permission "calendar:respond"/);
+ expect(store.rsvpEvent).not.toHaveBeenCalled();
+ });
+});
+
+describe('api.calendar.deleteEvent and undoDelete', () => {
+ it('deletes a single event and puts the host\'s copy back with the same uid', async () => {
+ vi.mocked(client.getCalendarEvent).mockResolvedValue({ id: 'e1', uid: 'u9', title: 'Dentist', start: '2026-11-03T10:00:00', calendarIds: { cal1: true }, utcStart: 'x' } as never);
+ const { undoToken } = await call(ASSISTANT, 'calendar.deleteEvent', 'e1') as { undoToken: string };
+ expect(store.deleteEvent).toHaveBeenCalledWith(client, 'e1', false);
+ await expect(call(plugin('other', ['calendar:write']), 'calendar.undoDelete', undoToken)).rejects.toThrow(/can't be undone/);
+ expect(await call(ASSISTANT, 'calendar.undoDelete', undoToken)).toEqual({ id: 'again' });
+ expect(store.createEvent).toHaveBeenCalledWith(client, { uid: 'u9', title: 'Dentist', start: '2026-11-03T10:00:00', calendarIds: { cal1: true } }, false);
+ });
+
+ it('gives no token for a repeating event, and takes no copy from the plugin', async () => {
+ vi.mocked(client.getCalendarEvent).mockResolvedValue({ id: 'e2', uid: 'u2', recurrenceRules: [{ frequency: 'weekly' }] } as never);
+ expect(await call(ASSISTANT, 'calendar.deleteEvent', 'e2')).toEqual({ undoToken: null });
+ await expect(call(ASSISTANT, 'calendar.undoDelete', { uid: 'u2', title: 'Fake' })).rejects.toThrow(/undoToken must be a string/);
+ expect(store.createEvent).not.toHaveBeenCalled();
+ });
+});
+
+describe('api.vacation.get', () => {
+ it('says whether the out-of-office forwards mail', async () => {
+ const fetch = vi.fn(async () => { useVacationStore.setState({ isEnabled: false, forward: { enabled: true, to: 'x@example.com' }, error: null } as never); });
+ useVacationStore.setState({ fetchVacationResponse: fetch } as never);
+ expect(await call(ASSISTANT, 'vacation.get')).toMatchObject({ isEnabled: false, forwardActive: true });
+ useVacationStore.setState({ fetchVacationResponse: vi.fn(async () => { useVacationStore.setState({ forward: null } as never); }) } as never);
+ expect(await call(ASSISTANT, 'vacation.get')).toMatchObject({ forwardActive: false });
+ });
+});
diff --git a/lib/__tests__/plugin-host-features.test.ts b/lib/__tests__/plugin-host-features.test.ts
index 5c76a65d5..d85e4443c 100644
--- a/lib/__tests__/plugin-host-features.test.ts
+++ b/lib/__tests__/plugin-host-features.test.ts
@@ -17,6 +17,10 @@ import {
isSlotHandedOver,
snapshotHostFeatures,
} from '@/lib/plugin-sandbox/host-features';
+import { assistantFeatures } from '@/lib/plugin-sandbox/assistant';
+
+/** The AI assistant's flags (lib/plugin-sandbox/assistant) come on top, as `plugin` sees them; their own tests check them. */
+const withAssistant = (core: Record, plugin?: never) => ({ ...core, ...assistantFeatures(plugin) });
function setExternalMail(patch: Partial) {
usePolicyStore.setState({
@@ -31,11 +35,11 @@ beforeEach(() => setExternalMail({}));
describe('snapshotHostFeatures', () => {
it('has one flag per switch', () => {
- expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false });
+ expect(snapshotHostFeatures()).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }));
setExternalMail({ senderLabel: true });
- expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false });
+ expect(snapshotHostFeatures()).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }));
setExternalMail({ recipientWarning: true });
- expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false });
+ expect(snapshotHostFeatures()).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }));
});
// A link check switches Google Safe Browsing off in the mode instead of
@@ -81,21 +85,21 @@ describe('host bridge', () => {
const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never;
const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' });
const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit;
- expect(pending?.hostFeatures).toEqual({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false });
+ expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false }, plugin));
// Before init has gone out a change lands in the payload ...
setExternalMail({});
- expect(pending?.hostFeatures).toEqual({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false });
+ expect(pending?.hostFeatures).toEqual(withAssistant({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false }, plugin));
// ... after it, as a host-change message.
const sent: unknown[] = [];
(instance as unknown as { pendingInit: unknown }).pendingInit = null;
(instance as unknown as { send: (m: unknown) => void }).send = (m) => sent.push(m);
setExternalMail({ recipientWarning: true });
- expect(sent).toEqual([{ type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false } }]);
+ expect(sent).toEqual([{ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false }, plugin) }]);
// The admin turns third-party traffic off: running plugins hear it.
setNoThirdPartyTraffic(true);
- expect(sent[1]).toEqual({ type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true } });
+ expect(sent[1]).toEqual({ type: 'host-change', features: withAssistant({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true }, plugin) });
setNoThirdPartyTraffic(false);
instance.destroy();
}, 30_000);
@@ -161,5 +165,17 @@ describe('api.host in the sandbox runtime', () => {
expect(kept.externalSenderLabel).toBe(true);
expect((kept as { noThirdPartyTraffic?: boolean }).noThirdPartyTraffic).toBe(true);
expect(changes).toEqual([{ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true }]);
+
+ // The assistant's flags (lib/plugin-sandbox/assistant) reach the plugin too.
+ expect((kept as { sidePanel?: boolean }).sidePanel).toBeUndefined();
+ post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true, sidePanel: true } });
+ expect((kept as { sidePanel?: boolean }).sidePanel).toBe(true);
+ post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true, sidePanel: 'yes' } });
+ expect((kept as { sidePanel?: boolean }).sidePanel).toBe(false);
+ // A flag the host no longer names turns off instead of keeping its old value.
+ post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true, sidePanel: true } });
+ expect((kept as { sidePanel?: boolean }).sidePanel).toBe(true);
+ post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true } });
+ expect((kept as { sidePanel?: boolean }).sidePanel).toBe(false);
}, 30_000);
});
diff --git a/lib/__tests__/plugin-host-files.test.ts b/lib/__tests__/plugin-host-files.test.ts
new file mode 100644
index 000000000..c9a6cb9ca
--- /dev/null
+++ b/lib/__tests__/plugin-host-files.test.ts
@@ -0,0 +1,457 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import type { Email, FileNode } from '../jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useFileStore } from '@/stores/file-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { emailHooks, fileHooks } from '@/lib/plugin-hooks';
+import { MAX_TEXT_SOURCE_BYTES } from '@/lib/attachment-text/types';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+const composeOpen = vi.hoisted(() => vi.fn(() => 'opened'));
+const shrink = vi.hoisted(() => vi.fn(async () => ({ status: 'ok', type: 'image/jpeg', width: 800, height: 600, bytes: new Uint8Array([1, 2, 3]) })));
+const exif = vi.hoisted(() => vi.fn(() => ({ taken: '2026-10-01T10:00:00', lat: 52.5, lon: 13.4 })));
+vi.mock('@/lib/image-shrink', async (importOriginal) => ({ ...(await importOriginal()), shrinkImage: shrink }));
+vi.mock('@/lib/exif', () => ({ readPhotoExif: exif }));
+vi.mock('../plugin-sandbox/host-api-compose', async (importOriginal) => ({
+ ...(await importOriginal()),
+ doComposeOpen: composeOpen,
+}));
+
+import { dispatchApiCall, PERM_PER_METHOD } from '../plugin-sandbox/host-api';
+import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
+import { snapshotHostFeatures } from '../plugin-sandbox/host-features';
+import { withAssistantRuntime } from '../plugin-sandbox/assistant/runtime';
+import { forgetFileListing } from '../plugin-sandbox/assistant/d';
+import { registerComposer, resetComposers } from '../plugin-sandbox/composer-registry';
+
+// api.files (area d of the assistant's host APIs): find, read and attach
+// the user's files, save attachments, create, move and rename, only in
+// the user's own drive and never in a shared folder, with the Files hooks.
+
+function plugin(perms: string[], id = 'ai-assistant'): InstalledPlugin {
+ return {
+ id, name: 'AI Assistant', version: '2.2.0', author: 'test', description: '',
+ type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+}
+const ASSISTANT = plugin(['email:read', 'files:read', 'files:write', 'compose:write', 'compose:open']);
+const call = (method: string, ...args: unknown[]) => dispatchApiCall(ASSISTANT, method, args);
+
+const node = (n: Partial & { id: string; name: string }): FileNode => ({
+ parentId: null, type: 'd', blobId: null, size: 0, created: '2026-01-01T00:00:00Z', modified: '2026-01-01T00:00:00Z', ...n,
+} as FileNode);
+const folder = (id: string, name: string, parentId: string | null = null, extra: Partial = {}) => node({ id, name, parentId, ...extra });
+const file = (id: string, name: string, parentId: string | null, type: string, modified: string, extra: Partial = {}) =>
+ node({ id, name, parentId, type, blobId: `blob-${id}`, size: 100, modified, ...extra });
+
+const BOB = { 'bob-principal': { mayRead: true, mayWrite: false } } as unknown as FileNode['shareWith'];
+
+/** The user's own drive. */
+function ownNodes(): FileNode[] {
+ return [
+ folder('docs', 'Documents'),
+ file('notes', 'notes.md', 'docs', 'text/markdown', '2026-10-30T10:00:00Z'),
+ file('budget', 'budget 2026.xlsx', 'docs', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '2026-10-20T10:00:00Z'),
+ file('photo', 'whiteboard.jpg', 'docs', 'image/jpeg', '2026-09-01T10:00:00Z'),
+ file('report', 'report.pdf', null, 'application/pdf', '2026-10-31T10:00:00Z'),
+ folder('team', 'Team', null, { shareWith: BOB }),
+ folder('team-sub', 'Plans', 'team'),
+ file('team-notes', 'team notes.md', 'team-sub', 'text/markdown', '2026-10-01T10:00:00Z'),
+ node({ id: 'link', name: 'notes link', nodeType: 'symlink', blobId: null, type: '' }),
+ folder('trash', '.Trash'),
+ folder('batch', '1730112000000~docs', 'trash'),
+ file('gone', 'old notes.md', 'batch', 'text/markdown', '2026-10-31T12:00:00Z'),
+ ];
+}
+/** Bob's drive, as the user sees what he shared (listAllFileNodesAcrossAccounts). */
+const bobNodes = (): FileNode[] => [
+ folder('bob:root', 'Bob home', null, { isShared: true, accountId: 'bob', accountName: 'Bob', discoverableOnly: true }),
+ file('bob:plan', 'Bob plan.txt', 'bob:root', 'text/plain', '2026-10-15T10:00:00Z', { isShared: true, accountId: 'bob', accountName: 'Bob' }),
+];
+
+const RULES = { forbiddenChars: '/<>:"\\|?*', forbiddenNames: ['.', '..', 'CON', 'NUL'], maxNameBytes: 255 };
+const message = {
+ id: 'm1', threadId: 't1', blobId: 'raw', size: 4, mailboxIds: { inbox: true }, keywords: {}, subject: 'Invoice',
+ receivedAt: '2026-05-01T10:00:00Z', attachments: [{ partId: '2', blobId: 'blob-inv', name: 'invoice.pdf', type: 'application/pdf', size: 1234 }],
+} as unknown as Email;
+
+let own: FileNode[];
+let client: Record>;
+let seq = 0;
+
+beforeEach(() => {
+ seq = 0;
+ resetPluginRateLimits();
+ resetComposers();
+ forgetFileListing();
+ composeOpen.mockClear();
+ shrink.mockClear();
+ exif.mockClear();
+ own = ownNodes();
+ client = {
+ supportsFiles: vi.fn(() => true),
+ listAllFileNodesAcrossAccounts: vi.fn(async () => [...own, ...bobNodes()]),
+ listAllFileNodes: vi.fn(async () => own),
+ fetchBlobArrayBuffer: vi.fn(async () => new TextEncoder().encode('# Notes\n\nLaunch on 12 December.').buffer),
+ uploadBlob: vi.fn(async (f: File) => ({ blobId: `up-${++seq}`, size: f.size, type: f.type })),
+ createFileNode: vi.fn(async (name: string, blobId: string, type: string, size: number, parentId: string | null) => ({ id: `new-${++seq}`, name, blobId, type, parentId, size: 0, created: '', modified: '' })),
+ createFileDirectory: vi.fn(async (name: string, parentId: string | null) => ({ id: `dir-${++seq}`, name, parentId, blobId: null, type: 'd', size: 0, created: '', modified: '' })),
+ updateFileNode: vi.fn(async () => undefined),
+ replaceFileNodeBlob: vi.fn(async () => undefined),
+ destroyFileNodes: vi.fn(async (ids: string[]) => ({ destroyed: ids, notDestroyed: [] })),
+ getFileNameRules: vi.fn(() => RULES),
+ getMaxFileNodeDepth: vi.fn(() => 64),
+ getAccountId: vi.fn(() => 'me'),
+ getFilesAccountId: vi.fn(() => 'me'),
+ copyBlobs: vi.fn(),
+ getEmail: vi.fn(async (id: string) => (id === 'm1' ? message : null)),
+ getMailboxes: vi.fn(async () => []),
+ };
+ useAuthStore.setState({ client: client as unknown as IJMAPClient, activeAccountId: 'login-a' });
+ useEmailStore.setState({ selectedEmail: null, emails: [], mailboxes: [], selectedMailbox: '' });
+ usePolicyStore.setState({ policy: DEFAULT_POLICY });
+});
+
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+ for (const bus of Object.values(fileHooks)) bus.clear();
+ emailHooks.onBeforeAttachmentUpload.clear();
+ useFileStore.setState({ client: null, hasListed: false });
+});
+
+describe('api.files: offered, gated, and nothing that deletes or shares', () => {
+ it('has exactly these methods, each behind files:read or files:write', () => {
+ const methods = Object.entries(PERM_PER_METHOD).filter(([m]) => m.startsWith('files.'));
+ expect(Object.fromEntries(methods)).toEqual({
+ 'files.find': 'files:read', 'files.list': 'files:read', 'files.getText': 'files:read', 'files.getImage': 'files:read',
+ 'files.attach': 'files:read',
+ 'files.saveAttachments': 'files:write', 'files.create': 'files:write', 'files.createFolder': 'files:write',
+ 'files.move': 'files:write', 'files.rename': 'files:write',
+ });
+ const api = withAssistantRuntime({} as { files?: Record }, async () => null);
+ expect(Object.keys(api.files ?? {}).sort()).toEqual(['attach', 'create', 'createFolder', 'find', 'getImage', 'getText', 'list', 'move', 'rename', 'saveAttachments']);
+ });
+
+ it('refuses without the permissions', async () => {
+ await expect(dispatchApiCall(plugin(['email:read']), 'files.find', [{}])).rejects.toThrow(/lacks permission "files:read"/);
+ await expect(dispatchApiCall(plugin(['files:read']), 'files.create', [{ name: 'a.md', content: 'x', type: 'text/markdown' }])).rejects.toThrow(/lacks permission "files:write"/);
+ await expect(dispatchApiCall(plugin(['files:read']), 'files.attach', [['notes'], { composerId: 'c1' }])).rejects.toThrow(/lacks permission "compose:write"/);
+ await expect(dispatchApiCall(plugin(['files:read']), 'files.attach', [['notes'], { newDraft: {} }])).rejects.toThrow(/lacks permission "compose:open"/);
+ await expect(dispatchApiCall(plugin(['files:write']), 'files.saveAttachments', ['m1', ['blob-inv']])).rejects.toThrow(/lacks permission "email:read"/);
+ expect(client.uploadBlob).not.toHaveBeenCalled();
+ expect(client.createFileNode).not.toHaveBeenCalled();
+ });
+
+ it('says files only where the server has Files and the administrator left it on', async () => {
+ expect(snapshotHostFeatures()).toMatchObject({ files: true });
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, features: { ...DEFAULT_POLICY.features, filesEnabled: false } } });
+ expect(snapshotHostFeatures()).toMatchObject({ files: false });
+ await expect(call('files.find', {})).rejects.toThrow(/Files is not available/);
+ usePolicyStore.setState({ policy: DEFAULT_POLICY });
+ client.supportsFiles.mockReturnValue(false);
+ expect(snapshotHostFeatures()).toMatchObject({ files: false });
+ await expect(call('files.list')).rejects.toThrow(/Files is not available/);
+ });
+});
+
+describe('files.find and files.list', () => {
+ it('finds own and shared files, without the trash, symlinks and the folders above a shared file', async () => {
+ const all = (await call('files.find', { limit: 50 })) as { id: string }[];
+ const ids = all.map((f) => f.id);
+ expect(ids).not.toEqual(expect.arrayContaining(['trash']));
+ for (const hidden of ['trash', 'batch', 'gone', 'link', 'bob:root']) expect(ids).not.toContain(hidden);
+ expect(ids).toEqual(expect.arrayContaining(['notes', 'report', 'bob:plan', 'docs']));
+ // Newest first.
+ expect(ids[0]).toBe('report');
+ const plan = all.find((f) => f.id === 'bob:plan');
+ expect(plan).toMatchObject({ name: 'Bob plan.txt', kind: 'file', shared: true, owner: 'Bob', path: '/Bob home' });
+ expect(all.find((f) => f.id === 'notes')).toEqual({
+ id: 'notes', name: 'notes.md', path: '/Documents', parentId: 'docs', kind: 'file', mimeType: 'text/markdown', size: 100, modified: '2026-10-30T10:00:00Z',
+ });
+ });
+
+ it('filters by words, kind, folder and dates, and keeps the listing for a minute', async () => {
+ const names = async (q: unknown) => ((await call('files.find', q)) as { name: string }[]).map((f) => f.name);
+ expect(await names({ text: 'NOTES' })).toEqual(['notes.md', 'team notes.md']);
+ expect(await names({ text: 'notes', folderId: 'docs' })).toEqual(['notes.md']);
+ expect(await names({ kind: 'spreadsheet' })).toEqual(['budget 2026.xlsx']);
+ expect(await names({ kind: 'image' })).toEqual(['whiteboard.jpg']);
+ expect(await names({ kind: 'folder' })).toEqual(['Documents', 'Team', 'Plans']);
+ expect(await names({ kind: 'document', modifiedAfter: '2026-10-25' })).toEqual(['report.pdf', 'notes.md']);
+ expect(await names({ modifiedBefore: '2026-09-02', kind: 'image' })).toEqual(['whiteboard.jpg']);
+ expect(await names({ limit: 1 })).toEqual(['report.pdf']);
+ expect(client.listAllFileNodesAcrossAccounts).toHaveBeenCalledTimes(1);
+ await expect(call('files.find', { kind: 'video' })).rejects.toThrow(/kind must be one of/);
+ await expect(call('files.find', { folderId: 'trash' })).rejects.toThrow(/no file or folder/);
+ });
+
+ it('lists the top of the drive with what others shared, and a folder', async () => {
+ const top = ((await call('files.list')) as { name: string }[]).map((f) => f.name);
+ expect(top).toEqual(['Documents', 'Team', 'Bob plan.txt', 'report.pdf']);
+ const docs = ((await call('files.list', 'docs')) as { name: string }[]).map((f) => f.name);
+ expect(docs).toEqual(['budget 2026.xlsx', 'notes.md', 'whiteboard.jpg']);
+ await expect(call('files.list', 'notes')).rejects.toThrow(/not a folder/);
+ });
+});
+
+describe('files.getText', () => {
+ it('reads a file with the attachment reader, and a shared one from its owner\'s account', async () => {
+ const out = await call('files.getText', 'notes');
+ expect(out).toMatchObject({ name: 'notes.md', format: 'text', status: 'ok', text: '# Notes\n\nLaunch on 12 December.', truncated: false });
+ expect(client.fetchBlobArrayBuffer).toHaveBeenLastCalledWith('blob-notes', 'notes.md', 'text/markdown', undefined, MAX_TEXT_SOURCE_BYTES + 1);
+ await call('files.getText', 'bob:plan');
+ expect(client.fetchBlobArrayBuffer).toHaveBeenLastCalledWith('blob-bob:plan', 'Bob plan.txt', 'text/plain', 'bob', MAX_TEXT_SOURCE_BYTES + 1);
+ await expect(call('files.getText', 'docs')).rejects.toThrow(/is a folder/);
+ await expect(call('files.getText', 'gone')).rejects.toThrow(/no file or folder/);
+ });
+});
+
+describe('files.attach', () => {
+ it('attaches to the open draft by blob, with the login that holds the files', async () => {
+ const attachFile = vi.fn(async () => true);
+ const { id } = registerComposer({
+ readBody: () => ({ html: '', text: '' }), readSelection: () => ({ html: '', text: '', empty: true }),
+ replaceBody: () => true, replaceSelection: () => true, attach: vi.fn(async () => true), attachFile,
+ });
+ const out = await call('files.attach', ['notes', 'bob:plan'], { composerId: id });
+ expect(out).toEqual({ attached: ['notes.md', 'Bob plan.txt'], refused: [] });
+ expect(attachFile).toHaveBeenCalledWith({ blobId: 'blob-notes', name: 'notes.md', type: 'text/markdown', size: 100, sourceAccountId: 'login-a' });
+ await expect(call('files.attach', ['docs'], { composerId: id })).rejects.toThrow(/is a folder/);
+ await expect(call('files.attach', ['notes'], { composerId: 'gone' })).rejects.toThrow(/not open any more/);
+ });
+
+ it('opens a new draft with the files through compose.open', async () => {
+ const out = await call('files.attach', ['report'], { newDraft: { to: ['bob@example.org'], subject: 'Report' } });
+ expect(out).toEqual({ attached: ['report.pdf'], refused: [] });
+ expect(composeOpen).toHaveBeenCalledWith(ASSISTANT, {
+ to: ['bob@example.org'], subject: 'Report',
+ attachments: [{ blobId: 'blob-report', name: 'report.pdf', type: 'application/pdf', size: 100 }],
+ });
+ });
+});
+
+describe('writes stay in the user\'s own drive', () => {
+ const create = (input: Record) => call('files.create', { content: '# Minutes', type: 'text/markdown', ...input });
+
+ it('creates a Markdown file in a folder, with the upload hooks, and lets the server number a taken name', async () => {
+ const before = vi.fn(() => true);
+ const after = vi.fn();
+ fileHooks.onBeforeFileUpload.register('other', before);
+ fileHooks.onAfterFileUpload.register('other', after);
+ const out = (await create({ folderId: 'docs', name: 'notes.md' })) as { file: { name: string; path: string } };
+ expect(before).toHaveBeenCalledWith({ name: 'notes.md', size: 9, type: 'text/markdown', path: '/Documents' });
+ expect(client.uploadBlob).toHaveBeenCalledTimes(1);
+ // Never an overwrite: createFileNode sends onExists rename ("notes (2).md").
+ expect(client.createFileNode).toHaveBeenCalledWith('notes.md', 'up-1', 'text/markdown', 9, 'docs');
+ expect(client.updateFileNode).not.toHaveBeenCalled();
+ expect(client.replaceFileNodeBlob).not.toHaveBeenCalled();
+ expect(out.file).toMatchObject({ path: '/Documents', parentId: 'docs', kind: 'file', mimeType: 'text/markdown' });
+ await vi.waitFor(() => expect(after).toHaveBeenCalledWith(expect.objectContaining({ name: 'notes.md', type: 'file', path: '/Documents' })));
+ });
+
+ it('stops when another plugin vetoes the upload', async () => {
+ fileHooks.onBeforeFileUpload.register('dlp', () => false);
+ await expect(create({ name: 'secret.md' })).rejects.toThrow(/another plugin stopped this/);
+ expect(client.uploadBlob).not.toHaveBeenCalled();
+ expect(client.createFileNode).not.toHaveBeenCalled();
+ });
+
+ it('refuses shared folders, other people\'s drives and the trash', async () => {
+ await expect(create({ folderId: 'team', name: 'a.md' })).rejects.toThrow(/shared with others/);
+ await expect(create({ folderId: 'team-sub', name: 'a.md' })).rejects.toThrow(/shared with others/);
+ await expect(create({ folderId: 'bob:root', name: 'a.md' })).rejects.toThrow(/drive someone shared with you/);
+ await expect(create({ folderId: 'batch', name: 'a.md' })).rejects.toThrow(/in your own files/);
+ await expect(create({ folderId: 'trash', name: 'a.md' })).rejects.toThrow(/in your own files/);
+ await expect(call('files.createFolder', { parentId: 'team', name: 'New' })).rejects.toThrow(/shared with others/);
+ await expect(call('files.move', 'notes', 'team-sub')).rejects.toThrow(/shared with others/);
+ await expect(call('files.move', 'notes', 'bob:root')).rejects.toThrow(/another person's drive/);
+ await expect(call('files.move', 'team-notes', 'docs')).rejects.toThrow(/shared with others/);
+ await expect(call('files.move', 'notes', 'trash')).rejects.toThrow(/in your own files/);
+ await expect(call('files.rename', 'team-notes', 'x.md')).rejects.toThrow(/shared with others/);
+ await expect(call('files.rename', 'bob:plan', 'x.txt')).rejects.toThrow(/drive someone shared with you/);
+ await expect(call('files.saveAttachments', 'm1', ['blob-inv'], 'team')).rejects.toThrow(/shared with others/);
+ await expect(create({ replace: 'team-notes' })).rejects.toThrow(/shared with others/);
+ expect(client.uploadBlob).not.toHaveBeenCalled();
+ expect(client.createFileNode).not.toHaveBeenCalled();
+ expect(client.createFileDirectory).not.toHaveBeenCalled();
+ expect(client.updateFileNode).not.toHaveBeenCalled();
+ });
+
+ it('refuses names the server would refuse, and says why', async () => {
+ await expect(create({ name: 'con.txt' })).rejects.toThrow(/reserved/);
+ await expect(create({ name: 'a:b.md' })).rejects.toThrow(/may not contain :/);
+ await expect(create({ name: 'a\u0001b.md' })).rejects.toThrow(/control characters/);
+ await expect(call('files.rename', 'notes', 'NUL')).rejects.toThrow(/reserved/);
+ await expect(call('files.rename', 'notes', 'budget 2026.xlsx')).rejects.toThrow(/already something named/);
+ await expect(call('files.createFolder', { name: '.Trash' })).rejects.toThrow(/trash/);
+ await expect(create({ name: 'a.md', type: 'application/pdf' })).rejects.toThrow(/type must be/);
+ expect(client.uploadBlob).not.toHaveBeenCalled();
+ });
+
+ it('replaces a text file and keeps the old version in the Files trash', async () => {
+ const out = (await create({ replace: 'notes', content: 'New text' })) as { file: { id: string; size: number }; kept: unknown };
+ // A trash folder for this change, under the existing .Trash, named like a delete from Documents.
+ expect(client.createFileDirectory).toHaveBeenCalledTimes(1);
+ expect(client.createFileDirectory).toHaveBeenCalledWith(expect.stringMatching(/^\d{13}~docs$/), 'trash');
+ const batchId = (await client.createFileDirectory.mock.results[0].value).id;
+ // The old version: a copy pointing at the old blob.
+ expect(client.createFileNode).toHaveBeenCalledWith('notes.md', 'blob-notes', 'text/markdown', 100, batchId);
+ expect(client.replaceFileNodeBlob).toHaveBeenCalledWith('notes', 'up-1');
+ // The copy is made before the content changes.
+ expect(client.createFileNode.mock.invocationCallOrder[0]).toBeLessThan(client.replaceFileNodeBlob.mock.invocationCallOrder[0]);
+ expect(out).toMatchObject({ file: { id: 'notes', size: 8 }, kept: { trashFolder: '.Trash', name: 'notes.md' } });
+ });
+
+ it('makes the trash folder when there is none yet, and only replaces text files', async () => {
+ own = own.filter((n) => !['trash', 'batch', 'gone'].includes(n.id));
+ await create({ replace: 'notes', content: 'New' });
+ expect(client.createFileDirectory.mock.calls[0]).toEqual(['.Trash', null]);
+ await expect(create({ replace: 'budget' })).rejects.toThrow(/only text and Markdown/);
+ await expect(create({ replace: 'docs' })).rejects.toThrow(/is a folder/);
+ });
+
+ it('creates a folder and tells plugins', async () => {
+ const seen = vi.fn();
+ fileHooks.onDirectoryCreate.register('other', seen);
+ const out = await call('files.createFolder', { parentId: 'docs', name: '2026' });
+ expect(client.createFileDirectory).toHaveBeenCalledWith('2026', 'docs');
+ expect(out).toMatchObject({ name: '2026', kind: 'folder', path: '/Documents', parentId: 'docs' });
+ await vi.waitFor(() => expect(seen).toHaveBeenCalledWith(expect.objectContaining({ name: '2026', type: 'directory', path: '/Documents' })));
+ });
+
+ it('moves within the own drive with the move hooks, and says where it was', async () => {
+ const moved = vi.fn();
+ fileHooks.onFileMove.register('other', moved);
+ const out = await call('files.move', 'notes', null);
+ expect(client.updateFileNode).toHaveBeenCalledWith('notes', { parentId: null });
+ expect(out).toMatchObject({ file: { id: 'notes', parentId: null, path: '/' }, previousFolderId: 'docs' });
+ await vi.waitFor(() => expect(moved).toHaveBeenCalledWith({ files: [expect.objectContaining({ id: 'notes' })], toPath: '/' }));
+ await expect(call('files.move', 'docs', 'docs')).rejects.toThrow(/into itself/);
+ await expect(call('files.move', 'budget', 'docs')).rejects.toThrow(/already there/);
+ });
+
+ it('stops a move and a rename another plugin vetoes', async () => {
+ fileHooks.onBeforeFileMove.register('dlp', () => false);
+ fileHooks.onBeforeFileRename.register('dlp', () => false);
+ await expect(call('files.move', 'notes', null)).rejects.toThrow(/another plugin stopped this/);
+ await expect(call('files.rename', 'notes', 'minutes.md')).rejects.toThrow(/another plugin stopped this/);
+ expect(client.updateFileNode).not.toHaveBeenCalled();
+ });
+
+ it('renames with the rename hooks and says the old name', async () => {
+ const before = vi.fn(() => true);
+ fileHooks.onBeforeFileRename.register('other', before);
+ const out = await call('files.rename', 'notes', 'minutes.md');
+ expect(before).toHaveBeenCalledWith({ file: expect.objectContaining({ id: 'notes', name: 'notes.md', path: '/Documents' }), newName: 'minutes.md' });
+ expect(client.updateFileNode).toHaveBeenCalledWith('notes', { name: 'minutes.md' });
+ expect(out).toMatchObject({ file: { name: 'minutes.md' }, previousName: 'notes.md' });
+ });
+
+ it('saves attachments by blob into an own folder, and another plugin can stop one', async () => {
+ const out = await call('files.saveAttachments', 'm1', ['blob-inv'], 'docs');
+ expect(client.createFileNode).toHaveBeenCalledWith('invoice.pdf', 'blob-inv', 'application/pdf', 1234, 'docs');
+ expect(out).toMatchObject({ saved: [{ name: 'invoice.pdf', path: '/Documents', size: 1234 }], stopped: [] });
+ await expect(call('files.saveAttachments', 'm1', ['blob-other'], 'docs')).rejects.toThrow(/not an attachment of this message/);
+ fileHooks.onBeforeFileUpload.register('dlp', () => false);
+ expect(await call('files.saveAttachments', 'm1', ['blob-inv'], null)).toEqual({ saved: [], stopped: ['invoice.pdf'] });
+ expect(client.createFileNode).toHaveBeenCalledTimes(1);
+ });
+
+ it('refreshes the open Files view and the listing after a change', async () => {
+ const refresh = vi.fn(async () => {});
+ useFileStore.setState({ client: client as unknown as IJMAPClient, hasListed: true, refresh });
+ await call('files.find', {});
+ await call('files.rename', 'notes', 'minutes.md');
+ expect(refresh).toHaveBeenCalledTimes(1);
+ await call('files.find', {});
+ expect(client.listAllFileNodesAcrossAccounts).toHaveBeenCalledTimes(2);
+ });
+
+ it('allows 20 changes a minute', async () => {
+ for (let i = 0; i < 20; i++) await call('files.createFolder', { name: `F${i}` });
+ await expect(call('files.createFolder', { name: 'F20' })).rejects.toThrow(/at most 20 changes/);
+ });
+});
+
+
+describe('files.getImage', () => {
+ it('shrinks a picture, reads the photo\'s date and place only when asked, and refuses SVG', async () => {
+ own.push(file('drawing', 'drawing.svg', 'docs', 'image/svg+xml', '2026-01-01T00:00:00Z'));
+ const plain = await call('files.getImage', 'photo');
+ expect(plain).toEqual({ name: 'whiteboard.jpg', type: 'image/jpeg', width: 800, height: 600, data: 'AQID', status: 'ok' });
+ expect(exif).not.toHaveBeenCalled();
+ const withPlace = await call('files.getImage', 'photo', { exif: true });
+ expect(withPlace).toMatchObject({ exif: { taken: '2026-10-01T10:00:00', lat: 52.5, lon: 13.4 } });
+ const fetches = client.fetchBlobArrayBuffer.mock.calls.length;
+ expect(await call('files.getImage', 'drawing')).toMatchObject({ status: 'unsupported', data: '' });
+ // SVG can load remote content: it is never fetched.
+ expect(client.fetchBlobArrayBuffer.mock.calls.length).toBe(fetches);
+ });
+
+ it('allows 30 reads a minute', async () => {
+ for (let i = 0; i < 30; i++) await call('files.getImage', 'photo');
+ await expect(call('files.getImage', 'photo')).rejects.toThrow(/too many reads/);
+ });
+
+ // The size in the listing may be wrong: the download stops past the limit.
+ it('stops a download past 20 MB and answers too_big', async () => {
+ client.fetchBlobArrayBuffer.mockResolvedValueOnce(new ArrayBuffer(MAX_TEXT_SOURCE_BYTES + 1));
+ expect(await call('files.getText', 'notes')).toMatchObject({ status: 'too_big', text: '' });
+ expect(client.fetchBlobArrayBuffer).toHaveBeenLastCalledWith('blob-notes', 'notes.md', 'text/markdown', undefined, MAX_TEXT_SOURCE_BYTES + 1);
+ client.fetchBlobArrayBuffer.mockResolvedValueOnce(new ArrayBuffer(MAX_TEXT_SOURCE_BYTES + 1));
+ expect(await call('files.getImage', 'photo')).toMatchObject({ status: 'too_big' });
+ expect(shrink).not.toHaveBeenCalled();
+ });
+});
+
+describe('the review fixes', () => {
+ // compose.open itself runs no attachment checks: files.attach runs them for a new draft.
+ it('runs the attachment intercept and the size limit for a new draft, and leaves vetoed files out', async () => {
+ emailHooks.onBeforeAttachmentUpload.register('dlp', (info: { name: string }) => info.name !== 'report.pdf');
+ const vetoed = await call('files.attach', ['report'], { newDraft: { to: ['bob@example.org'] } });
+ expect(vetoed).toEqual({ attached: [], refused: ['report.pdf'] });
+ expect(composeOpen).not.toHaveBeenCalled();
+ const some = await call('files.attach', ['report', 'notes'], { newDraft: {} });
+ expect(some).toEqual({ attached: ['notes.md'], refused: ['report.pdf'] });
+ expect(composeOpen).toHaveBeenCalledWith(ASSISTANT, { attachments: [{ blobId: 'blob-notes', name: 'notes.md', type: 'text/markdown', size: 100 }] });
+ emailHooks.onBeforeAttachmentUpload.clear();
+ client.getMaxSizeAttachmentsPerEmail = vi.fn(() => 150);
+ const fit = await call('files.attach', ['notes', 'budget'], { newDraft: {} });
+ expect(fit).toEqual({ attached: ['notes.md'], refused: ['budget 2026.xlsx'] });
+ });
+
+ it('refuses a write when a folder above is missing from the listing', async () => {
+ own.push(folder('orphan', 'Orphan', 'gone-parent'), file('lost', 'lost.md', 'orphan', 'text/markdown', '2026-01-01T00:00:00Z'));
+ await expect(call('files.create', { folderId: 'orphan', name: 'a.md', content: 'x', type: 'text/markdown' })).rejects.toThrow(/couldn't be read/);
+ await expect(call('files.rename', 'lost', 'found.md')).rejects.toThrow(/couldn't be read/);
+ expect(client.uploadBlob).not.toHaveBeenCalled();
+ expect(client.updateFileNode).not.toHaveBeenCalled();
+ });
+
+ it('tells plugins about a move with the item as it was, like the Files app', async () => {
+ const moved = vi.fn();
+ fileHooks.onFileMove.register('other', moved);
+ await call('files.move', 'notes', null);
+ await vi.waitFor(() => expect(moved).toHaveBeenCalledWith({ files: [expect.objectContaining({ id: 'notes', path: '/Documents' })], toPath: '/' }));
+ });
+
+ it('counts refused writes, before reading the drive', async () => {
+ for (let i = 0; i < 20; i++) await expect(call('files.createFolder', { parentId: 'team', name: `F${i}` })).rejects.toThrow(/shared with others/);
+ await expect(call('files.createFolder', { name: 'F20' })).rejects.toThrow(/at most 20 changes/);
+ expect(client.listAllFileNodes).toHaveBeenCalledTimes(20);
+ });
+
+ it('takes the old version out of the trash again when the server refuses the new content', async () => {
+ client.replaceFileNodeBlob.mockRejectedValueOnce(new Error('modified: invalid'));
+ await expect(call('files.create', { replace: 'notes', content: 'New', type: 'text/markdown' })).rejects.toThrow('modified: invalid');
+ const batchId = (await client.createFileDirectory.mock.results[0].value).id;
+ expect(client.destroyFileNodes).toHaveBeenCalledWith([batchId]);
+ });
+});
diff --git a/lib/__tests__/plugin-host-filters.test.ts b/lib/__tests__/plugin-host-filters.test.ts
new file mode 100644
index 000000000..d9a2811e7
--- /dev/null
+++ b/lib/__tests__/plugin-host-filters.test.ts
@@ -0,0 +1,143 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import type { Mailbox } from '../jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { useFilterStore } from '@/stores/filter-store';
+import { generateScript } from '../sieve/generator';
+import { parseScript } from '../sieve/parser';
+import { clearUndoTokens } from '../plugin-sandbox/assistant/c.undo';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+import { dispatchApiCall } from '../plugin-sandbox/host-api';
+
+function plugin(id: string, perms: string[]): InstalledPlugin {
+ return {
+ id, name: id, version: '1.0.0', author: 'test', description: '',
+ type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+}
+const ASSISTANT = plugin('assistant', ['filters:read', 'filters:write']);
+const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
+
+const mailboxes = [
+ { id: 'inbox', name: 'Inbox', role: 'inbox' },
+ { id: 'news', name: 'Newsletters', role: null },
+ { id: 'trash', name: 'Trash', role: 'trash' },
+ // As the client lists folders: without their share maps.
+ { id: 'team', name: 'Team', role: null },
+ { id: 'team-sub', name: 'Projects', role: null, parentId: 'team' },
+ { id: 'flaky', name: 'Flaky', role: null },
+ { id: 'acc2:box', name: 'Theirs', role: null, isShared: true, accountId: 'acc2' },
+] as unknown as Mailbox[];
+
+/** A server with one filters script, in memory. */
+function server(initial: string) {
+ const state = { content: initial, active: 's1' as string | null };
+ const client = {
+ supportsSieve: () => true,
+ getAccountId: () => 'acc1',
+ getSieveAccountId: () => 'acc1',
+ getSieveCapabilities: () => ({ sieveExtensions: ['fileinto', 'imap4flags', 'mailbox', 'mailboxid'] }),
+ getSieveScripts: vi.fn(async () => [{ id: 's1', name: 'filters', blobId: 'b1', isActive: state.active === 's1' }]),
+ getSieveScriptContent: vi.fn(async () => state.content),
+ updateSieveScript: vi.fn(async (_id: string, content: string) => { state.content = content; }),
+ createSieveScript: vi.fn(),
+ activateSieveScript: vi.fn(),
+ deactivateSieveScript: vi.fn(),
+ deleteSieveScript: vi.fn(),
+ getMailboxes: vi.fn(async () => mailboxes),
+ supportsMailboxSharing: () => true,
+ getMailboxShareWith: vi.fn(async (id: string) => {
+ if (id === 'flaky') throw new Error('Mailbox/get failed');
+ return id === 'team' ? { u2: { mayReadItems: true } } : null;
+ }),
+ } as unknown as IJMAPClient;
+ return { client, state };
+}
+
+const RULE = {
+ name: 'Newsletters',
+ conditions: [{ field: 'from', op: 'contains', value: 'newsletter@shop.example' }],
+ actions: [{ type: 'move', mailboxId: 'news' }, { type: 'mark_read' }],
+};
+
+let srv: ReturnType;
+beforeEach(() => {
+ clearUndoTokens();
+ srv = server(generateScript([], undefined, {}));
+ useAuthStore.setState({ client: srv.client });
+ useSettingsStore.setState({ emailKeywords: [{ id: 'work', label: 'Work', color: 'blue' }] } as never);
+ useFilterStore.setState({ selectedAccountId: null } as never);
+});
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+});
+
+describe('api.filters', () => {
+ it('adds a rule at the top of the account\'s filters and takes it back', async () => {
+ const { id, undoToken } = await call(ASSISTANT, 'filters.create', RULE) as { id: string; undoToken: string };
+ const rules = parseScript(srv.state.content).rules;
+ expect(rules).toHaveLength(1);
+ expect(rules[0]).toMatchObject({
+ id, name: 'Newsletters', matchType: 'all',
+ conditions: [{ field: 'from', comparator: 'contains', value: 'newsletter@shop.example' }],
+ actions: [{ type: 'move', mailboxId: 'news', value: 'Newsletters' }, { type: 'mark_read' }],
+ });
+ const listed = await call(ASSISTANT, 'filters.list') as { editable: boolean; rules: { name: string; actions: unknown[] }[] };
+ expect(listed.editable).toBe(true);
+ expect(listed.rules[0].actions[0]).toEqual({ type: 'move', mailboxId: 'news', folder: 'Newsletters' });
+
+ await call(ASSISTANT, 'filters.undo', undoToken);
+ expect(parseScript(srv.state.content).rules).toHaveLength(0);
+ });
+
+ it('refuses forward, redirect, reject, discard and keep, whatever else the rule does', async () => {
+ for (const type of ['forward', 'redirect', 'reject', 'discard', 'keep']) {
+ const rule = { ...RULE, actions: [{ type: 'mark_read' }, { type, value: 'x@example.com' }] };
+ await expect(call(ASSISTANT, 'filters.create', rule)).rejects.toThrow(new RegExp(`"${type}" is not allowed`));
+ }
+ expect(srv.client.updateSieveScript).not.toHaveBeenCalled();
+ });
+
+ it('files mail only into folders of the account that nobody else reads, never the trash', async () => {
+ for (const mailboxId of ['acc2:box', 'nope']) {
+ await expect(call(ASSISTANT, 'filters.create', { ...RULE, actions: [{ type: 'copy', mailboxId }] })).rejects.toThrow(/only the user reads/);
+ }
+ // Shared with someone, inside a shared folder, or the server didn't say.
+ for (const mailboxId of ['team', 'team-sub', 'flaky']) {
+ await expect(call(ASSISTANT, 'filters.create', { ...RULE, actions: [{ type: 'copy', mailboxId }] })).rejects.toThrow(/shared with other people/);
+ }
+ await expect(call(ASSISTANT, 'filters.create', { ...RULE, actions: [{ type: 'move', mailboxId: 'trash' }] })).rejects.toThrow(/trash/);
+ expect(srv.client.updateSieveScript).not.toHaveBeenCalled();
+ });
+
+ it('checks the rule like the rule editor does', async () => {
+ await expect(call(ASSISTANT, 'filters.create', { ...RULE, name: ' ' })).rejects.toThrow(/name/);
+ await expect(call(ASSISTANT, 'filters.create', { ...RULE, conditions: [] })).rejects.toThrow(/conditions/);
+ await expect(call(ASSISTANT, 'filters.create', { ...RULE, conditions: [{ field: 'all', op: 'any' }] })).rejects.toThrow(/field must be/);
+ await expect(call(ASSISTANT, 'filters.create', { ...RULE, conditions: [{ field: 'header', op: 'contains', value: 'x', headerName: 'bad name' }] })).rejects.toThrow(/header name/);
+ await expect(call(ASSISTANT, 'filters.create', { ...RULE, actions: [{ type: 'stop' }] })).rejects.toThrow(/besides stop/);
+ await expect(call(ASSISTANT, 'filters.create', { ...RULE, actions: [{ type: 'add_label', tagId: 'nope' }] })).rejects.toThrow(/tags/);
+ });
+
+ it('leaves a hand-edited script alone', async () => {
+ // Metadata that doesn't parse makes the whole script opaque.
+ srv.state.content = '/* @metadata:begin\n{ not json\n@metadata:end */\nkeep;\n';
+ const listed = await call(ASSISTANT, 'filters.list') as { editable: boolean };
+ expect(listed.editable).toBe(false);
+ await expect(call(ASSISTANT, 'filters.create', RULE)).rejects.toThrow(/edited by hand/);
+ });
+
+ it('needs filters:write to add and keeps the undo to its plugin', async () => {
+ await expect(call(plugin('reader', ['filters:read']), 'filters.create', RULE)).rejects.toThrow(/lacks permission "filters:write"/);
+ const { undoToken } = await call(ASSISTANT, 'filters.create', RULE) as { undoToken: string };
+ await expect(call(plugin('other', ['filters:write']), 'filters.undo', undoToken)).rejects.toThrow(/can't be undone/);
+ expect(parseScript(srv.state.content).rules).toHaveLength(1);
+ });
+});
diff --git a/lib/__tests__/plugin-host-search-position.test.ts b/lib/__tests__/plugin-host-search-position.test.ts
new file mode 100644
index 000000000..452a2ea20
--- /dev/null
+++ b/lib/__tests__/plugin-host-search-position.test.ts
@@ -0,0 +1,56 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+import { dispatchApiCall } from '../plugin-sandbox/host-api';
+
+// api.email.search pages with `position`, so a plugin can go past the
+// first 50 results (the assistant's batches of more than 50 messages).
+
+const READER = {
+ id: 'reader', name: 'Reader', version: '1.0.0', author: 'test', description: '',
+ type: 'hook', permissions: ['email:read'], grantedPermissions: ['email:read'], entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+} as InstalledPlugin;
+const search = (query: unknown) => dispatchApiCall(READER, 'email.search', [query]);
+
+let client: Record>;
+
+beforeEach(() => {
+ client = {
+ getEmails: vi.fn(async () => ({ emails: [], hasMore: false, total: 0 })),
+ getMailboxes: vi.fn(async () => []),
+ getAccountId: vi.fn(() => 'acc1'),
+ };
+ useAuthStore.setState({ client: client as unknown as IJMAPClient });
+ useEmailStore.setState({ mailboxes: [], selectedEmail: null });
+});
+
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+});
+
+describe('api.email.search position', () => {
+ it('asks the server for the page after `position` results', async () => {
+ await search({ text: 'invoice', limit: 50, position: 50 });
+ expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 50, 50, undefined, false, { text: 'invoice' });
+ });
+
+ it('starts at the first result without it', async () => {
+ await search({ text: 'invoice' });
+ expect(client.getEmails).toHaveBeenCalledWith(undefined, undefined, 20, 0, undefined, false, { text: 'invoice' });
+ });
+
+ it('takes whole numbers from 0 to 1,000,000 only', async () => {
+ await expect(search({ text: 'x', position: -1 })).rejects.toThrow(/position must be a whole number from 0 to 1000000/);
+ await expect(search({ text: 'x', position: 1.5 })).rejects.toThrow(/position must be a whole number/);
+ await expect(search({ text: 'x', position: '10' })).rejects.toThrow(/position must be a whole number/);
+ expect(client.getEmails).not.toHaveBeenCalled();
+ });
+});
diff --git a/lib/__tests__/plugin-host-set-settings.test.ts b/lib/__tests__/plugin-host-set-settings.test.ts
new file mode 100644
index 000000000..c4828e4dd
--- /dev/null
+++ b/lib/__tests__/plugin-host-set-settings.test.ts
@@ -0,0 +1,140 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import { useAuthStore } from '@/stores/auth-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useLocaleStore } from '@/stores/locale-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { settingsHooks } from '../plugin-hooks';
+import { clearUndoTokens } from '../plugin-sandbox/assistant/c.undo';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+import { dispatchApiCall } from '../plugin-sandbox/host-api';
+
+function plugin(id: string, perms: string[]): InstalledPlugin {
+ return {
+ id, name: id, version: '1.0.0', author: 'test', description: '',
+ type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+}
+const ASSISTANT = plugin('assistant', ['settings:read', 'settings:change']);
+const call = (p: InstalledPlugin, method: string, ...args: unknown[]) => dispatchApiCall(p, method, args);
+type Change = { applied: Record; refused: Record; undoToken: string | null };
+
+const policy = usePolicyStore.getState().policy;
+function restrict(restrictions: Record, features: Record = {}) {
+ usePolicyStore.setState({ policy: { ...policy, restrictions: { ...policy.restrictions, ...restrictions }, features: { ...policy.features, ...features } } } as never);
+}
+
+beforeEach(() => {
+ clearUndoTokens();
+ useAuthStore.setState({ client: { getAccountId: () => 'acc1' } as unknown as IJMAPClient });
+ useLocaleStore.setState({ locale: 'en' });
+ useSettingsStore.setState({ timeFormat: '24h', deleteAction: 'trash', showPreview: true } as never);
+ usePolicyStore.setState({ policy } as never);
+});
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+ usePolicyStore.setState({ policy } as never);
+});
+
+describe('api.app.describeSettings', () => {
+ it('says what a change would do in the user\'s words', async () => {
+ const out = await call(ASSISTANT, 'app.describeSettings', { timeFormat: '12h' }) as Record>;
+ expect(out.timeFormat).toMatchObject({
+ label: 'Time format', page: 'general', current: '24h', next: '12h', allowed: true,
+ });
+ expect(out.timeFormat.currentLabel).toMatch(/24/);
+ expect(out.timeFormat.nextLabel).toMatch(/12/);
+ expect(out.timeFormat.options).toHaveLength(2);
+ });
+});
+
+describe('api.app.setSettings', () => {
+ it('changes a setting through the store, reads it back and puts it back on undo', async () => {
+ const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h' }) as Change;
+ expect(result.applied).toEqual({ timeFormat: { old: '24h', new: '12h' } });
+ expect(useSettingsStore.getState().timeFormat).toBe('12h');
+ // The settings page counts it as the user's own choice.
+ expect(useSettingsStore.getState().explicitSettings).toContain('timeFormat');
+
+ const undone = await call(ASSISTANT, 'app.undoSettings', result.undoToken) as { restored: string[] };
+ expect(undone.restored).toEqual(['timeFormat']);
+ expect(useSettingsStore.getState().timeFormat).toBe('24h');
+ });
+
+ it('refuses what is not on the allow-list, values it rules out, and unknown keys', async () => {
+ const result = await call(ASSISTANT, 'app.setSettings', {
+ externalContentPolicy: 'allow', deleteAction: 'permanent', readReceiptResponse: 'always', confirmBulkDelete: false, fooBar: 1,
+ }) as Change;
+ expect(result.refused).toEqual({
+ externalContentPolicy: 'not_allowed', deleteAction: 'bad_value', readReceiptResponse: 'bad_value', confirmBulkDelete: 'bad_value', fooBar: 'unknown',
+ });
+ expect(result.undoToken).toBeNull();
+ expect(useSettingsStore.getState().deleteAction).toBe('trash');
+ });
+
+ it('refuses settings the admin locked or hid, rows a gate hides, and values outside a restriction', async () => {
+ restrict({ timeFormat: { locked: true }, showPreview: { hidden: true }, markAsReadDelay: { allowedValues: [0] } }, { unifiedCrossAccountEnabled: false });
+ const result = await call(ASSISTANT, 'app.setSettings', {
+ timeFormat: '12h', showPreview: false, unifiedCrossAccount: true, markAsReadDelay: 3000,
+ }) as Change;
+ expect(result.refused).toEqual({ timeFormat: 'locked', showPreview: 'hidden', unifiedCrossAccount: 'unavailable', markAsReadDelay: 'bad_value' });
+ expect(useSettingsStore.getState().timeFormat).toBe('24h');
+ });
+
+ it('says so when another plugin stops the change', async () => {
+ const off = settingsHooks.onBeforeSettingChange.register('guard', (async () => false) as never);
+ try {
+ const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h' }) as Change;
+ expect(result.refused).toEqual({ timeFormat: 'stopped' });
+ expect(useSettingsStore.getState().timeFormat).toBe('24h');
+ } finally {
+ off.dispose();
+ }
+ }, 10_000);
+
+ it('undo writes the exact old value, but not where the admin has locked it since', async () => {
+ useSettingsStore.setState({ deleteAction: 'permanent' } as never);
+ const result = await call(ASSISTANT, 'app.setSettings', { deleteAction: 'trash', timeFormat: '12h' }) as Change;
+ restrict({ timeFormat: { locked: true } });
+ const undone = await call(ASSISTANT, 'app.undoSettings', result.undoToken) as { restored: string[]; refused: Record };
+ expect(undone).toEqual({ restored: ['deleteAction'], refused: { timeFormat: 'locked' } });
+ // A value the assistant may not set, put back because the user had it.
+ expect(useSettingsStore.getState().deleteAction).toBe('permanent');
+ });
+
+ it('takes the names app.getSettings uses for language and the calendar view', async () => {
+ const result = await call(ASSISTANT, 'app.setSettings', { language: 'fr', calendarView: 'week' }) as Change;
+ expect(Object.keys(result.applied).sort()).toEqual(['calendarView', 'language']);
+ expect(useLocaleStore.getState().locale).toBe('fr');
+ expect(useCalendarStore.getState().viewMode).toBe('week');
+ const described = await call(ASSISTANT, 'app.describeSettings', ['language']) as Record;
+ expect(described.language).toMatchObject({ allowed: true, current: 'fr' });
+ await call(ASSISTANT, 'app.undoSettings', result.undoToken);
+ expect(useLocaleStore.getState().locale).toBe('en');
+ });
+
+ it('undo checks what a change checks: hidden, a gate and the restriction now', async () => {
+ useSettingsStore.setState({ markAsReadDelay: 0, unifiedCrossAccount: false } as never);
+ restrict({}, { unifiedCrossAccountEnabled: true });
+ const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h', markAsReadDelay: 3000, unifiedCrossAccount: true }) as Change;
+ expect(Object.keys(result.applied).sort()).toEqual(['markAsReadDelay', 'timeFormat', 'unifiedCrossAccount']);
+ restrict({ timeFormat: { hidden: true }, markAsReadDelay: { allowedValues: [3000] } }, { unifiedCrossAccountEnabled: false });
+ const undone = await call(ASSISTANT, 'app.undoSettings', result.undoToken) as { restored: string[]; refused: Record };
+ expect(undone).toEqual({ restored: [], refused: { timeFormat: 'hidden', markAsReadDelay: 'bad_value', unifiedCrossAccount: 'unavailable' } });
+ expect(useSettingsStore.getState().timeFormat).toBe('12h');
+ });
+
+ it('needs settings:change, and keeps the undo to its plugin', async () => {
+ await expect(call(plugin('reader', ['settings:read']), 'app.setSettings', { timeFormat: '12h' })).rejects.toThrow(/lacks permission "settings:change"/);
+ const result = await call(ASSISTANT, 'app.setSettings', { timeFormat: '12h' }) as Change;
+ await expect(call(plugin('other', ['settings:change']), 'app.undoSettings', result.undoToken)).rejects.toThrow(/can't be undone/);
+ expect(useSettingsStore.getState().timeFormat).toBe('12h');
+ });
+});
diff --git a/lib/__tests__/plugin-host-settings-apis.test.ts b/lib/__tests__/plugin-host-settings-apis.test.ts
index 7cacda476..c52a9142d 100644
--- a/lib/__tests__/plugin-host-settings-apis.test.ts
+++ b/lib/__tests__/plugin-host-settings-apis.test.ts
@@ -185,6 +185,8 @@ describe('api.vacation', () => {
it('reads the reply', async () => {
expect(await call(READ_ONLY, 'vacation.get')).toEqual({
isEnabled: true, subject: 'Away', textBody: 'Back in June', htmlBody: '', fromDate: null, toDate: '2026-06-01T00:00:00Z',
+ // No forward is set up for the out-of-office.
+ forwardActive: false,
});
});
diff --git a/lib/__tests__/plugin-host-thread.test.ts b/lib/__tests__/plugin-host-thread.test.ts
new file mode 100644
index 000000000..c24cd1ead
--- /dev/null
+++ b/lib/__tests__/plugin-host-thread.test.ts
@@ -0,0 +1,161 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin, EmailReadView } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import type { Email, Mailbox } from '../jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+import { dispatchApiCall, PERM_PER_METHOD } from '../plugin-sandbox/host-api';
+import { resetPluginRateLimits } from '../plugin-sandbox/host-api-input';
+import { snapshotHostFeatures } from '../plugin-sandbox/host-features';
+import { withAssistantRuntime } from '../plugin-sandbox/assistant/runtime';
+
+function plugin(perms: string[], id = 'ai-assistant'): InstalledPlugin {
+ return {
+ id, name: 'AI Assistant', version: '2.2.0', author: 'test', description: '',
+ type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+}
+
+const ASSISTANT = plugin(['email:read']);
+const call = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'email.getThread', args) as Promise;
+
+const mail = (id: string, hoursAfter: number, subject: string, threadId = 't1'): Email => ({
+ id, threadId, mailboxIds: { inbox: true }, keywords: {}, subject, from: [{ name: 'Bob', email: 'bob@example.org' }],
+ receivedAt: new Date(Date.UTC(2026, 9, 1, hoursAfter)).toISOString(),
+ textBody: [{ partId: '1', blobId: `b-${id}`, size: 1, type: 'text/plain' }],
+ bodyValues: { 1: { value: `${subject} body` } },
+} as unknown as Email);
+
+// The server lists a thread's messages in its own order; the API sorts them.
+const THREAD = [mail('m3', 30, 'Re: Re: Report'), mail('m1', 10, 'Report'), mail('m2', 20, 'Re: Report')];
+
+let client: Record>;
+
+beforeEach(() => {
+ resetPluginRateLimits();
+ client = {
+ getEmail: vi.fn(async (id: string, accountId?: string) => {
+ if (accountId === 'shared-acc') return id === 's1' ? mail('s1', 1, 'Team news', 'ts') : null;
+ return THREAD.find((e) => e.id === id) ?? (id === 'lonely' ? mail('lonely', 5, 'Alone', 'tl') : null);
+ }),
+ getThreadEmails: vi.fn(async (threadId: string, accountId?: string) => {
+ if (threadId === 't1' && !accountId) return THREAD;
+ if (threadId === 'ts' && accountId === 'shared-acc') return [mail('s2', 2, 'Re: Team news', 'ts'), mail('s1', 1, 'Team news', 'ts')];
+ if (threadId === 'big') return Array.from({ length: 60 }, (_, i) => mail(`b${i}`, i, `Part ${i}`, 'big'));
+ return [];
+ }),
+ getSomeEmails: vi.fn(async (ids: string[], accountId?: string) => {
+ const all = accountId === 'shared-acc' ? [mail('s1', 1, 'Team news', 'ts')] : [...THREAD, mail('lonely', 5, 'Alone', '')];
+ return all.filter((e) => ids.includes(e.id));
+ }),
+ getThread: vi.fn(async (threadId: string, accountId?: string) => {
+ if (threadId === 't1' && !accountId) return { id: 't1', emailIds: ['m1', 'm2', 'm3'] };
+ if (threadId === 'ts' && accountId === 'shared-acc') return { id: 'ts', emailIds: ['s1', 's2'] };
+ return null;
+ }),
+ getAccountId: vi.fn(() => 'acc1'),
+ getMailboxes: vi.fn(async () => [{ id: 'shared-inbox', name: 'Team', isShared: true, accountId: 'shared-acc' } as unknown as Mailbox]),
+ };
+ useAuthStore.setState({ client: client as unknown as IJMAPClient });
+ useEmailStore.setState({ selectedEmail: null, emails: [], mailboxes: [], selectedMailbox: '' });
+});
+
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+});
+
+describe('api.email.getThread', () => {
+ it('is behind email:read, offered in the runtime and announced in the host features', async () => {
+ expect(PERM_PER_METHOD['email.getThread']).toBe('email:read');
+ await expect(call(plugin(['email:blob-read']), 'm2')).rejects.toThrow(/lacks permission "email:read"/);
+ expect(client.getThreadEmails).not.toHaveBeenCalled();
+ expect(snapshotHostFeatures()).toMatchObject({ threads: true });
+ const calls: unknown[][] = [];
+ const api = withAssistantRuntime({} as { email?: { getThread?: (...a: unknown[]) => unknown } }, async (method, args) => { calls.push([method, args]); return []; });
+ await api.email?.getThread?.('m2');
+ expect(calls).toEqual([['email.getThread', ['m2', undefined]]]);
+ });
+
+ it('returns every message of the conversation, oldest first, with their text', async () => {
+ const thread = await call(ASSISTANT, 'm2');
+ expect(thread.map((e) => [e.id, e.subject, e.text, e.accountId])).toEqual([
+ ['m1', 'Report', 'Report body', 'acc1'],
+ ['m2', 'Re: Report', 'Re: Report body', 'acc1'],
+ ['m3', 'Re: Re: Report', 'Re: Re: Report body', 'acc1'],
+ ]);
+ // Only the newest 50 are fetched from the server.
+ expect(client.getThreadEmails).toHaveBeenCalledWith('t1', undefined, { newest: 50 });
+ });
+
+ it('reads a shared account\'s conversation from that account', async () => {
+ const thread = await call(ASSISTANT, 's1', { accountId: 'shared-acc' });
+ expect(thread.map((e) => [e.id, e.accountId])).toEqual([['s1', 'shared-acc'], ['s2', 'shared-acc']]);
+ expect(client.getThreadEmails).toHaveBeenCalledWith('ts', 'shared-acc', { newest: 50 });
+ });
+
+ it('refuses an account this login does not show, and a missing message', async () => {
+ await expect(call(ASSISTANT, 'm2', { accountId: 'stranger' })).rejects.toThrow(/no message with id "m2"/);
+ await expect(call(ASSISTANT, 'nope')).rejects.toThrow(/no message with id "nope"/);
+ expect(client.getThreadEmails).not.toHaveBeenCalled();
+ });
+
+ it('gives the message alone when the server lists no thread, and at most the newest 50', async () => {
+ expect((await call(ASSISTANT, 'lonely')).map((e) => e.id)).toEqual(['lonely']);
+ client.getEmail.mockResolvedValueOnce(mail('b59', 59, 'Part 59', 'big'));
+ const big = await call(ASSISTANT, 'b59');
+ expect(big).toHaveLength(50);
+ expect(big[0].id).toBe('b10');
+ expect(big[49].id).toBe('b59');
+ });
+
+ it('checks its options and allows 60 reads a minute', async () => {
+ await expect(call(ASSISTANT, 'm2', { threadId: 't1' })).rejects.toThrow(/unknown property "threadId"/);
+ await expect(call(ASSISTANT, 42)).rejects.toThrow(/emailId must be a string/);
+ resetPluginRateLimits();
+ for (let i = 0; i < 60; i++) await call(ASSISTANT, 'm1');
+ await expect(call(ASSISTANT, 'm1')).rejects.toThrow(/too many reads/);
+ });
+});
+
+describe('api.email.getThreadCount', () => {
+ const count = (p: InstalledPlugin, ...args: unknown[]) => dispatchApiCall(p, 'email.getThreadCount', args) as Promise;
+
+ it('is behind email:read and offered in the runtime', async () => {
+ expect(PERM_PER_METHOD['email.getThreadCount']).toBe('email:read');
+ await expect(count(plugin(['email:blob-read']), 'm2')).rejects.toThrow(/lacks permission "email:read"/);
+ const calls: unknown[][] = [];
+ const api = withAssistantRuntime({} as { email?: { getThreadCount?: (...a: unknown[]) => unknown } }, async (method, args) => { calls.push([method, args]); return 0; });
+ await api.email?.getThreadCount?.('m2');
+ expect(calls).toEqual([['email.getThreadCount', ['m2', undefined]]]);
+ });
+
+ it('counts the messages of the conversation without reading any of them', async () => {
+ expect(await count(ASSISTANT, 'm2')).toBe(3);
+ expect(client.getThread).toHaveBeenCalledWith('t1', undefined);
+ expect(client.getEmail).not.toHaveBeenCalled();
+ expect(client.getThreadEmails).not.toHaveBeenCalled();
+ // A message without a thread is a conversation of one.
+ expect(await count(ASSISTANT, 'lonely')).toBe(1);
+ });
+
+ it('counts in a shared account there, and refuses accounts this login does not show', async () => {
+ expect(await count(ASSISTANT, 's1', { accountId: 'shared-acc' })).toBe(2);
+ expect(client.getThread).toHaveBeenCalledWith('ts', 'shared-acc');
+ await expect(count(ASSISTANT, 'm2', { accountId: 'stranger' })).rejects.toThrow(/no message with id "m2"/);
+ expect(client.getSomeEmails).not.toHaveBeenCalledWith(['m2'], 'stranger');
+ await expect(count(ASSISTANT, 'nope')).rejects.toThrow(/no message with id "nope"/);
+ });
+
+ it('has its own limit of 120 a minute, apart from getThread\'s', async () => {
+ for (let i = 0; i < 60; i++) await call(ASSISTANT, 'm1');
+ await expect(call(ASSISTANT, 'm1')).rejects.toThrow(/too many reads/);
+ for (let i = 0; i < 120; i++) await count(ASSISTANT, 'm1');
+ await expect(count(ASSISTANT, 'm1')).rejects.toThrow(/too many reads/);
+ });
+});
diff --git a/lib/__tests__/plugin-host-vacation-forward.test.ts b/lib/__tests__/plugin-host-vacation-forward.test.ts
new file mode 100644
index 000000000..bd6d58238
--- /dev/null
+++ b/lib/__tests__/plugin-host-vacation-forward.test.ts
@@ -0,0 +1,93 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { InstalledPlugin } from '../plugin-types';
+import type { IJMAPClient } from '../jmap/client-interface';
+import { useAuthStore } from '@/stores/auth-store';
+import { useVacationStore } from '@/stores/vacation-store';
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+import { dispatchApiCall } from '../plugin-sandbox/host-api';
+
+// The out-of-office's forward and the host: vacation.get says it can't tell
+// when the filters couldn't be read (instead of "no forward"), and no plugin
+// may switch the reply on while it forwards mail or while that isn't known.
+
+function plugin(perms: string[]): InstalledPlugin {
+ return {
+ id: 'ooo', name: 'ooo', version: '1.0.0', author: 'test', description: '',
+ type: 'hook', permissions: perms, grantedPermissions: perms, entrypoint: 'index.js',
+ enabled: true, status: 'running', settings: {},
+ };
+}
+const P = plugin(['vacation:read', 'vacation:write']);
+const call = (method: string, ...args: unknown[]) => dispatchApiCall(P, method, args);
+
+const REPLY = { id: 'singleton', isEnabled: false, fromDate: null, toDate: null, subject: 'Away', textBody: 'Back soon', htmlBody: null };
+const FORWARDING = [
+ '/* @metadata:begin',
+ JSON.stringify({ version: 2, rules: [], includeVacation: true, vacationForward: { enabled: true, to: 'x@example.com', keepCopy: true } }),
+ '@metadata:end */',
+ 'require ["include"];',
+ 'include :personal :optional "vacation";',
+].join('\n');
+
+let script: string | null;
+let sieveFails: boolean;
+let client: IJMAPClient;
+
+beforeEach(() => {
+ script = null;
+ sieveFails = false;
+ client = {
+ getAccountId: () => 'acc1',
+ supportsVacationResponse: () => true,
+ supportsSieve: () => true,
+ getSieveAccountId: () => 'acc1',
+ getSieveCapabilities: () => ({ sieveExtensions: ['include', 'vacation', 'copy'] }),
+ getVacationResponse: vi.fn(async () => REPLY),
+ setVacationResponse: vi.fn(async () => {}),
+ getSieveScripts: vi.fn(async () => {
+ if (sieveFails) throw new Error('SieveScript/get failed');
+ return script ? [{ id: 's1', name: 'filters', blobId: 'b1', isActive: true }] : [];
+ }),
+ getSieveScriptContent: vi.fn(async () => script ?? ''),
+ } as unknown as IJMAPClient;
+ useAuthStore.setState({ client });
+ useVacationStore.getState().clearState();
+});
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+});
+
+describe('vacation.get', () => {
+ it('says whether the out-of-office forwards, and null when the filters can\'t be read', async () => {
+ expect(await call('vacation.get')).toMatchObject({ forwardActive: false });
+ script = FORWARDING;
+ expect(await call('vacation.get')).toMatchObject({ forwardActive: true });
+ sieveFails = true;
+ expect(await call('vacation.get')).toMatchObject({ forwardActive: null });
+ });
+});
+
+describe('vacation.set', () => {
+ it('refuses to switch the reply on while it forwards mail, for any plugin', async () => {
+ script = FORWARDING;
+ await expect(call('vacation.set', { isEnabled: true, subject: 'Away' })).rejects.toThrow(/forwards mail to another address/);
+ expect(client.setVacationResponse).not.toHaveBeenCalled();
+ });
+
+ it('refuses while that can\'t be read, and still lets a plugin switch it off', async () => {
+ sieveFails = true;
+ await expect(call('vacation.set', { isEnabled: true, subject: 'Away' })).rejects.toThrow(/can't be read/);
+ expect(client.setVacationResponse).not.toHaveBeenCalled();
+ await call('vacation.set', { isEnabled: false });
+ expect(client.setVacationResponse).toHaveBeenCalledTimes(1);
+ });
+
+ it('switches it on when no forward is set', async () => {
+ await call('vacation.set', { isEnabled: true, subject: 'Away' });
+ expect(client.setVacationResponse).toHaveBeenCalledWith(expect.objectContaining({ isEnabled: true }), undefined);
+ });
+});
diff --git a/lib/__tests__/plugin-permission-coverage.test.ts b/lib/__tests__/plugin-permission-coverage.test.ts
index ececac6f5..b05e7d376 100644
--- a/lib/__tests__/plugin-permission-coverage.test.ts
+++ b/lib/__tests__/plugin-permission-coverage.test.ts
@@ -86,6 +86,24 @@ describe('plugin permissions', () => {
'drafts.create': 'email:write',
'email.forward': 'email:forward',
'notifications.show': 'ui:notifications',
+ 'files.find': 'files:read',
+ 'files.getText': 'files:read',
+ 'files.attach': 'files:read',
+ 'files.create': 'files:write',
+ 'files.saveAttachments': 'files:write',
+ 'files.move': 'files:write',
+ 'files.rename': 'files:write',
+ // The AI assistant's actions (lib/plugin-sandbox/assistant/c.ts).
+ 'calendar.respond': 'calendar:respond',
+ 'calendar.undoDelete': 'calendar:write',
+ 'filters.list': 'filters:read',
+ 'filters.create': 'filters:write',
+ 'filters.undo': 'filters:write',
+ 'email.batch': 'email:write',
+ 'email.undoBatch': 'email:write',
+ 'app.describeSettings': 'settings:read',
+ 'app.setSettings': 'settings:change',
+ 'app.undoSettings': 'settings:change',
});
});
});
diff --git a/lib/__tests__/plugin-projection.test.ts b/lib/__tests__/plugin-projection.test.ts
index 40ffdf6f2..255d764e6 100644
--- a/lib/__tests__/plugin-projection.test.ts
+++ b/lib/__tests__/plugin-projection.test.ts
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
-import { contactToView, emailSummary, emailToReadView, eventToView, taskToView } from '../plugin-projection';
-import type { CalendarEvent, CalendarTask, ContactCard, Email } from '../jmap/types';
+import { contactToView, emailSummary, emailToReadView, eventToView, fileToView, taskToView } from '../plugin-projection';
+import type { CalendarEvent, CalendarTask, ContactCard, Email, FileNode } from '../jmap/types';
+import { resourceToView, type FileResource } from '@/stores/file-store';
describe('plugin projections', () => {
it('writes contact dates without a year as --MM-DD', () => {
@@ -57,7 +58,7 @@ describe('emailToReadView attachments', () => {
expect(view.attachments).toEqual([
{ blobId: 'b-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, inline: false },
- { blobId: 'b-img', name: 'logo.png', type: 'image/png', size: 99, inline: true },
+ { blobId: 'b-img', name: 'logo.png', type: 'image/png', size: 99, inline: true, cid: 'logo@x' },
]);
});
});
@@ -91,3 +92,28 @@ describe('emailToReadView text', () => {
expect(view.text).toBe('a is literal\nline two');
});
});
+
+describe('fileToView', () => {
+ // Folder or file by fileNodeKind: Stalwart 1.0's symlinks have no blob
+ // but are no folders, and an old blob-backed file marked "d" (#379) is a file.
+ it('tells folders from files by the node kind', () => {
+ const base = { parentId: null, size: 3, created: '', modified: '2026-01-01T00:00:00Z' };
+ const view = (n: Partial) => fileToView({ ...base, id: 'n', name: 'x', type: 'text/plain', blobId: 'b', ...n } as FileNode, '/');
+ expect(view({ nodeType: 'symlink', blobId: null, type: '' }).type).toBe('file');
+ expect(view({ type: 'd', blobId: 'b-marker' }).type).toBe('file');
+ expect(view({ nodeType: 'directory', blobId: null, type: '' })).toMatchObject({ type: 'directory', mimeType: '' });
+ // 0.16 before 0.16.6: no nodeType, no blob, a folder.
+ expect(view({ blobId: null, type: 'd' }).type).toBe('directory');
+ expect(view({}).type).toBe('file');
+ });
+});
+
+// The Files view's items reach plugins through the same projection: a 1.0
+// symlink there has no blob, and was handed to plugins as a folder.
+describe('resourceToView', () => {
+ it('hands a symlink over as a file, a folder as a folder', () => {
+ const base = { id: 'x', parentId: null, name: 'x', contentLength: 0, contentType: '', lastModified: '', blobId: null } as unknown as FileResource;
+ expect(resourceToView({ ...base, isDirectory: false, isSymlink: true }, '/').type).toBe('file');
+ expect(resourceToView({ ...base, isDirectory: true }, '/').type).toBe('directory');
+ });
+});
diff --git a/lib/__tests__/settings-schema.test.ts b/lib/__tests__/settings-schema.test.ts
new file mode 100644
index 000000000..a9fdd4c4a
--- /dev/null
+++ b/lib/__tests__/settings-schema.test.ts
@@ -0,0 +1,237 @@
+import { describe, expect, it } from 'vitest';
+import enMessages from '@/locales/en/common.json';
+import {
+ COMPOSE_FONT_SIZE_IDS,
+ DENIED_SETTINGS,
+ HOVER_ACTION_IDS,
+ MONTH_TIMED_EVENT_STYLE_IDS,
+ NOTIFICATION_SOUND_IDS,
+ NOT_YET_SETTINGS,
+ OFF_LABEL,
+ ON_LABEL,
+ SETTINGS_SCHEMA,
+ isAllowedSettingValue,
+ settingValueLabelKey,
+ type SettingSchemaEntry,
+} from '@/lib/settings-schema';
+import { ALL_HOVER_ACTIONS, MONTH_TIMED_EVENT_STYLES, useSettingsStore } from '@/stores/settings-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { useLocaleStore } from '@/stores/locale-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
+import { FONT_SIZES as COMPOSER_FONT_SIZES } from '@/components/email/font-size';
+import { UNLOCKABLE_SETTING_KEYS } from '@/lib/accessibility-settings';
+
+// JSON.parse keeps the last of a duplicate key, as next-intl sees it.
+const messages = enMessages as unknown as Record;
+const lookup = (key: string): unknown =>
+ key.split('.').reduce((node, part) => (node && typeof node === 'object' ? (node as Record)[part] : undefined), messages);
+
+/** The settings store's own keys: its state without the actions. */
+function storeKeys(): string[] {
+ const state = useSettingsStore.getState() as unknown as Record;
+ return Object.keys(state).filter((key) => typeof state[key] !== 'function');
+}
+
+function currentValue(entry: SettingSchemaEntry): unknown {
+ switch (entry.store) {
+ case 'settings': return (useSettingsStore.getState() as unknown as Record)[entry.key];
+ case 'theme': return useThemeStore.getState().theme;
+ case 'locale': return useLocaleStore.getState().locale;
+ case 'calendar': return useCalendarStore.getState().viewMode;
+ }
+}
+
+const entries = Object.values(SETTINGS_SCHEMA);
+const schema = (key: string) => {
+ const entry = SETTINGS_SCHEMA[key];
+ if (!entry) throw new Error(`${key} is not in SETTINGS_SCHEMA`);
+ return entry;
+};
+
+describe('SETTINGS_SCHEMA', () => {
+ it('names keys the stores have', () => {
+ const keys = new Set(storeKeys());
+ for (const entry of entries) {
+ expect(entry.key).toBeTruthy();
+ if (entry.store === 'settings') expect(keys.has(entry.key), entry.key).toBe(true);
+ else expect(['theme', 'locale', 'viewMode']).toContain(entry.key);
+ }
+ expect(SETTINGS_SCHEMA.theme.store).toBe('theme');
+ expect(SETTINGS_SCHEMA.locale.store).toBe('locale');
+ expect(SETTINGS_SCHEMA.viewMode.store).toBe('calendar');
+ });
+
+ it('uses row and value labels that exist in English', () => {
+ expect(typeof lookup(ON_LABEL)).toBe('string');
+ expect(typeof lookup(OFF_LABEL)).toBe('string');
+ for (const entry of entries) {
+ expect(typeof lookup(entry.label), `${entry.key}: ${entry.label}`).toBe('string');
+ if (entry.kind.type === 'choice' || entry.kind.type === 'list') {
+ for (const option of entry.kind.options) {
+ if (option.label === null) continue;
+ expect(typeof lookup(option.label), `${entry.key}=${String(option.value)}: ${option.label}`).toBe('string');
+ }
+ }
+ }
+ expect(typeof lookup(settingValueLabelKey(schema('timeZone'), 'auto')!)).toBe('string');
+ });
+
+ it('allows every default value, except an off safety net that may only be turned on', () => {
+ const refused: string[] = [];
+ for (const entry of entries) {
+ const value = currentValue(entry);
+ if (!isAllowedSettingValue(entry, value)) refused.push(`${entry.key}=${JSON.stringify(value)}`);
+ }
+ // Confirm delete is off by default; a plugin may turn it on, not off.
+ // Sender favicons are on by default; a plugin may only switch them off.
+ expect(refused.sort()).toEqual(['confirmDelete=false', 'senderFavicons=true']);
+ });
+
+ it('marks presets, third-party rows and the inverted switch', () => {
+ for (const entry of entries) {
+ expect(!!entry.preset, entry.key).toBe(entry.store === 'settings' && UNLOCKABLE_SETTING_KEYS.has(entry.key));
+ }
+ expect(schema('senderFavicons').thirdParty).toBe(true);
+ expect(schema('showAvatarsInJunk').thirdParty).toBe(true);
+ expect(schema('disableThreading').inverted).toBe(true);
+ expect(schema('unifiedCrossAccount').gate).toBe('unifiedCrossAccountEnabled');
+ expect(schema('enableCrossUnreadView').gate).toBe('crossUnreadViewEnabled');
+ expect(schema('archiveMode').gate).toBe('archiveActionEnabled');
+ expect(schema('hoverActions').gate).toBe('hoverActionsConfigEnabled');
+ expect(schema('enableCalendarTasks').gate).toBe('calendarTasksEnabled');
+ });
+
+ it('keeps its copies of app lists in step with the app', () => {
+ expect(HOVER_ACTION_IDS.map(([id, labelKey]) => ({ id, labelKey }))).toEqual(ALL_HOVER_ACTIONS);
+ expect([...MONTH_TIMED_EVENT_STYLE_IDS]).toEqual([...MONTH_TIMED_EVENT_STYLES]);
+ expect([...NOTIFICATION_SOUND_IDS]).toEqual(NOTIFICATION_SOUNDS.map((s) => s.id));
+ expect([...COMPOSE_FONT_SIZE_IDS]).toEqual([...COMPOSER_FONT_SIZES]);
+ });
+});
+
+describe('DENIED_SETTINGS and NOT_YET_SETTINGS', () => {
+ it('refuses the privacy, mail-sending and dead settings', () => {
+ for (const key of [
+ 'externalContentPolicy', 'trustedSenders', 'signatureDetails', 'pushRelayUrl', 'defaultReplyMode',
+ 'readAloudVoice', 'sessionTimeout', 'debugMode', 'settingsSyncDisabled',
+ ]) {
+ expect(SETTINGS_SCHEMA[key], key).toBeUndefined();
+ expect(DENIED_SETTINGS, key).toContain(key);
+ }
+ });
+
+ it('decides every key of the settings store exactly once', () => {
+ const allowed = Object.keys(SETTINGS_SCHEMA);
+ const all = [...allowed, ...DENIED_SETTINGS, ...NOT_YET_SETTINGS];
+ expect(new Set(all).size, 'a key is in two lists').toBe(all.length);
+ const undecided = storeKeys().filter((key) => !all.includes(key));
+ expect(undecided).toEqual([]);
+ // And no list names a key the app doesn't have.
+ const known = new Set([...storeKeys(), 'theme', 'locale', 'viewMode', 'activeThemeId']);
+ expect(all.filter((key) => !known.has(key))).toEqual([]);
+ });
+});
+
+describe('isAllowedSettingValue', () => {
+ const refuse = (key: string, value: unknown) =>
+ expect(isAllowedSettingValue(schema(key), value), `${key}=${JSON.stringify(value)}`).toBe(false);
+ const allow = (key: string, value: unknown) =>
+ expect(isAllowedSettingValue(schema(key), value), `${key}=${JSON.stringify(value)}`).toBe(true);
+
+ it('refuses the values the plan rules out', () => {
+ refuse('deleteAction', 'permanent');
+ refuse('readReceiptResponse', 'always');
+ refuse('confirmDelete', false);
+ refuse('confirmBulkDelete', false);
+ refuse('permanentlyDeleteJunk', true);
+ refuse('postExportAction', 'trash');
+ refuse('timeZone', 'Mars/Base');
+ refuse('composeFontFamily', 'Comic Sans MS; background: url(https://evil.example/x)');
+ refuse('composeFontSize', '13px');
+ // They make the server fetch the sender's domain.
+ refuse('senderFavicons', true);
+ refuse('showAvatarsInJunk', true);
+ });
+
+ it('allows the values the pages offer', () => {
+ allow('timeFormat', '12h');
+ allow('timeZone', 'Europe/Berlin');
+ allow('timeZone', 'auto');
+ allow('deleteAction', 'trash-and-read');
+ allow('confirmDelete', true);
+ allow('confirmBulkDelete', true);
+ allow('permanentlyDeleteJunk', false);
+ allow('senderFavicons', false);
+ allow('showAvatarsInJunk', false);
+ allow('postExportAction', 'archive');
+ allow('composeFontFamily', 'serif');
+ allow('composeFontSize', '18px');
+ allow('readReceiptResponse', 'never');
+ });
+
+ it('checks types and ranges', () => {
+ refuse('timeFormat', '13h');
+ refuse('showPreview', 'true');
+ refuse('emailsPerPage', '50');
+ allow('emailsPerPage', 100);
+ refuse('emailsPerPage', 1000);
+ refuse('markAsReadDelay', 1000);
+ allow('markAsReadDelay', -1);
+ allow('calendarDayStartHour', 0);
+ refuse('calendarDayStartHour', 24);
+ refuse('calendarDayStartHour', 7.5);
+ allow('calendarDayEndHour', 24);
+ refuse('calendarDayEndHour', 0);
+ refuse('timeZone', '+01:00');
+ refuse('timeZone', 'europe/berlin');
+ refuse('timeZone', 42);
+ allow('locale', 'de');
+ allow('locale', 'zh-TW');
+ allow('locale', '');
+ refuse('locale', 'xx');
+ allow('theme', 'dark');
+ refuse('theme', 'neon');
+ allow('viewMode', 'agenda');
+ refuse('viewMode', 'tasks');
+ });
+
+ it('checks lists', () => {
+ allow('calendarWorkingDays', [1, 2, 3]);
+ refuse('calendarWorkingDays', []);
+ refuse('calendarWorkingDays', [1, 1]);
+ refuse('calendarWorkingDays', [7]);
+ allow('hoverActions', ['star', 'spam']);
+ allow('hoverActions', []);
+ refuse('hoverActions', ['star', 'star']);
+ refuse('hoverActions', ['forward']);
+ allow('attachmentReminderKeywords', ['attached', 'anbei']);
+ refuse('attachmentReminderKeywords', ['Attached']);
+ refuse('attachmentReminderKeywords', [' attached']);
+ refuse('attachmentReminderKeywords', ['a\nb']);
+ refuse('attachmentReminderKeywords', ['x'.repeat(41)]);
+ refuse('attachmentReminderKeywords', Array.from({ length: 51 }, (_, i) => `w${i}`));
+ refuse('attachmentReminderKeywords', 'attached');
+ });
+});
+
+describe('settingValueLabelKey', () => {
+ it('gives the page labels, turning the inverted switch round', () => {
+ expect(settingValueLabelKey(schema('showPreview'), true)).toBe(ON_LABEL);
+ expect(settingValueLabelKey(schema('showPreview'), false)).toBe(OFF_LABEL);
+ // disableThreading true = "Group conversations" off.
+ expect(settingValueLabelKey(schema('disableThreading'), true)).toBe(OFF_LABEL);
+ expect(settingValueLabelKey(schema('disableThreading'), false)).toBe(ON_LABEL);
+ expect(settingValueLabelKey(schema('timeFormat'), '12h')).toBe('settings.language_region.time_format.12h');
+ expect(settingValueLabelKey(schema('markAsReadDelay'), -1)).toBe('settings.email_behavior.mark_read.never');
+ expect(settingValueLabelKey(schema('firstDayOfWeek'), 0)).toBe('calendar.days.sunday');
+ });
+
+ it('gives null for formatted values', () => {
+ expect(settingValueLabelKey(schema('calendarDayStartHour'), 8)).toBeNull();
+ expect(settingValueLabelKey(schema('readAloudRate'), 1.25)).toBeNull();
+ expect(settingValueLabelKey(schema('toastDuration'), 10)).toBeNull();
+ expect(settingValueLabelKey(schema('toastDuration'), 0)).toBe('settings.accessibility.toast_duration.until_closed');
+ expect(settingValueLabelKey(schema('timeZone'), 'Europe/Berlin')).toBeNull();
+ });
+});
diff --git a/lib/attachment-text/__tests__/attachment-text.test.ts b/lib/attachment-text/__tests__/attachment-text.test.ts
new file mode 100644
index 000000000..746e3cb7a
--- /dev/null
+++ b/lib/attachment-text/__tests__/attachment-text.test.ts
@@ -0,0 +1,228 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { strToU8, zipSync, type Zippable } from 'fflate';
+import { extractText, textFormatOf } from '@/lib/attachment-text';
+import { setPdfLoader, type PdfLib } from '@/lib/attachment-text/pdf';
+import { officeText } from '@/lib/attachment-text/office';
+
+// Office files are ZIPs of XML: the fixtures are built here, as small as a
+// real file of each kind can be while still holding the parts read.
+
+const zip = (files: Record): Uint8Array => {
+ const entries: Zippable = {};
+ for (const [name, value] of Object.entries(files)) entries[name] = typeof value === 'string' ? strToU8(value) : value;
+ return zipSync(entries, { level: 6 });
+};
+
+const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
+const DOCX = zip({
+ '[Content_Types].xml': ' ',
+ 'word/document.xml': `
+ Minutes of the meeting
+ Budget: 4,500 EUR 9,000 EUR
+ Who What
+ Carol Press release
+ `,
+});
+
+const S = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
+const xlsxFiles = (rows = 2) => ({
+ 'xl/workbook.xml': ` `,
+ 'xl/_rels/workbook.xml.rels': ' ',
+ 'xl/sharedStrings.xml': `month visitors Aug ust, late `,
+ 'xl/worksheets/sheet1.xml': `
+ 0 1
+ ${Array.from({ length: rows - 1 }, (_, i) => `2 ${1200 + i} 1
`).join('')}
+ `,
+ 'xl/worksheets/sheet2.xml': ` `,
+});
+
+const P = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"';
+const slide = (texts: string[]) => `${texts.map((t) => `${t} `).join('')} `;
+const PPTX = zip({
+ 'ppt/presentation.xml': ` `,
+ 'ppt/slides/slide10.xml': slide(['Thanks']),
+ 'ppt/slides/slide2.xml': slide(['Launch plan', 'Codeword PELICAN']),
+ 'ppt/slides/slide1.xml': slide(['Welcome']),
+});
+
+const O = 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0"';
+const odf = (body: string, extra: Record = {}) => zip({
+ mimetype: 'application/vnd.oasis.opendocument.text',
+ 'META-INF/manifest.xml': ' ',
+ 'content.xml': `${body} `,
+ ...extra,
+});
+const ODT = odf('Report Two spaces and a breaka footnote ');
+const ODS = odf(`
+ item cost
+ ads, print 4500
+
+ `);
+const ODP = odf('Hello slide Second ');
+
+const text = (s: string) => new TextEncoder().encode(s);
+
+afterEach(() => setPdfLoader());
+
+describe('textFormatOf', () => {
+ it('goes by the extension first, then the media type', () => {
+ expect(textFormatOf('notes.TXT', 'application/octet-stream')).toBe('text');
+ expect(textFormatOf('report', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')).toBe('docx');
+ expect(textFormatOf('x.ods', '')).toBe('ods');
+ expect(textFormatOf('', 'text/x-python')).toBe('text');
+ expect(textFormatOf('old.doc', 'application/msword')).toBe('other');
+ expect(textFormatOf('photo.jpg', 'image/jpeg')).toBe('other');
+ });
+});
+
+describe('plain formats', () => {
+ it('reads text in UTF-8 and Windows-1252 and keeps CSV as it is', async () => {
+ expect(await extractText(text('Grüße\r\nfrom Bob'), { name: 'a.txt' })).toMatchObject({ format: 'text', status: 'ok', text: 'Grüße\nfrom Bob', truncated: false });
+ expect((await extractText(new Uint8Array([0x47, 0x72, 0xfc, 0xdf, 0x65]), { name: 'a.txt' })).text).toBe('Grüße');
+ expect(await extractText(text('month,visitors\nAugust,1200\n'), { name: 'figures.csv' })).toMatchObject({ format: 'csv', text: 'month,visitors\nAugust,1200' });
+ });
+
+ it('turns HTML into text', async () => {
+ const out = await extractText(text('Agenda Launch plan
'), { type: 'text/html' });
+ expect(out.status).toBe('ok');
+ expect(out.text).toContain('Agenda');
+ expect(out.text).toContain('Launch plan');
+ expect(out.text).toContain('[Image: logo]');
+ expect(out.text).not.toContain('alert');
+ });
+
+ it('writes one line per event of a calendar file', async () => {
+ const ics = [
+ 'BEGIN:VCALENDAR', 'METHOD:REQUEST', 'BEGIN:VEVENT', 'SUMMARY:Launch review\\, part 2',
+ 'DTSTART;TZID=Europe/Berlin:20261105T090000', 'DTEND;TZID=Europe/Berlin:20261105T100000',
+ 'LOCATION:Room 4', 'ORGANIZER;CN="Bob, the boss":mailto:bob@example.org', 'DESCRIPTION:Bring the', ' figures', 'END:VEVENT',
+ 'BEGIN:VEVENT', 'SUMMARY:Holiday', 'DTSTART;VALUE=DATE:20261224', 'END:VEVENT', 'END:VCALENDAR',
+ ].join('\r\n');
+ const out = await extractText(text(ics), { name: 'invite.ics' });
+ expect(out.text).toBe([
+ 'event: Launch review, part 2, 2026-11-05 09:00 (Europe/Berlin), 2026-11-05 10:00 (Europe/Berlin), Room 4, organiser Bob, the boss ',
+ ' Bring thefigures',
+ 'event: Holiday, 2026-12-24',
+ ].join('\n'));
+ });
+
+ it('writes name, addresses, phones and organisation of an address card', async () => {
+ const vcf = 'BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Carol Example\r\nN:Example;Carol;;;\r\nEMAIL;TYPE=work:carol@example.org\r\nTEL;TYPE=cell:+49 170 123\r\nORG:Example GmbH\r\nEND:VCARD\r\n';
+ const out = await extractText(text(vcf), { name: 'carol.vcf' });
+ expect(out.status).toBe('ok');
+ expect(out.text).toContain('contact: Carol Example');
+ expect(out.text).toContain('email: carol@example.org');
+ expect(out.text).toContain('phone: +49 170 123');
+ expect(out.text).toContain('organisation: Example GmbH');
+ });
+
+ it('cuts at maxChars and says so', async () => {
+ const out = await extractText(text('word '.repeat(100)), { name: 'a.txt', maxChars: 42 });
+ expect(out.truncated).toBe(true);
+ expect(out.text.length).toBeLessThanOrEqual(42);
+ expect(out.text.endsWith('word')).toBe(true);
+ });
+
+ it('answers no_text for an empty file and unsupported for binary bytes and images', async () => {
+ expect((await extractText(text(' \n'), { name: 'a.txt' })).status).toBe('no_text');
+ expect((await extractText(new Uint8Array([1, 0, 2, 0]), { name: 'a.txt' })).status).toBe('unsupported');
+ expect((await extractText(new Uint8Array([0xff, 0xd8, 0xff]), { name: 'a.jpg', type: 'image/jpeg' })).status).toBe('unsupported');
+ });
+
+ it('refuses a file over 20 MB without reading it', async () => {
+ expect(await extractText(new Uint8Array(20 * 1024 * 1024 + 1), { name: 'a.txt' })).toMatchObject({ status: 'too_big', text: '' });
+ });
+});
+
+describe('Office files', () => {
+ it('reads a docx: paragraphs, tabs, tables, no deleted text', async () => {
+ const out = await extractText(DOCX, { name: 'minutes.docx' });
+ expect(out).toMatchObject({ format: 'docx', status: 'ok' });
+ expect(out.text).toBe('Minutes of the meeting\nBudget: \t4,500 EUR\nWho | What\nCarol | Press release');
+ });
+
+ it('reads an xlsx sheet by sheet as CSV rows, shared strings resolved, columns kept', async () => {
+ const out = await extractText(zip(xlsxFiles()), { name: 'figures.xlsx' });
+ expect(out).toMatchObject({ format: 'xlsx', status: 'ok', truncated: false, sheets: ['Figures', 'Empty'] });
+ expect(out.text).toBe('Sheet: Figures\nmonth,visitors\n"August, late",,1200,TRUE\n\nSheet: Empty');
+ });
+
+ it('reads at most 2,000 rows of a sheet', async () => {
+ const out = await extractText(zip(xlsxFiles(2005)), { name: 'big.xlsx', maxChars: 100_000 });
+ expect(out.truncated).toBe(true);
+ expect(out.text.split('\n').filter((l) => l.startsWith('"August')).length).toBe(1999);
+ });
+
+ it('reads a pptx slide by slide in their order', async () => {
+ const out = await extractText(PPTX, { name: 'deck.pptx' });
+ expect(out.text).toBe('Slide 1\nWelcome\n\nSlide 2\nLaunch plan\nCodeword PELICAN\n\nSlide 3\nThanks');
+ });
+
+ it('reads odt, ods and odp', async () => {
+ expect((await extractText(ODT, { name: 'r.odt' })).text).toBe('Report\nTwo spaces\tand a\nbreak');
+ const ods = await extractText(ODS, { name: 'b.ods' });
+ expect(ods).toMatchObject({ status: 'ok', sheets: ['Budget'] });
+ expect(ods.text).toBe('Sheet: Budget\nitem,cost\n"ads, print",4500,4500\n"ads, print",4500,4500');
+ expect((await extractText(ODP, { name: 'p.odp' })).text).toBe('Slide 1\nHello slide\n\nSlide 2\nSecond');
+ });
+
+ it('refuses a ZIP bomb: stops unpacking past the cap', async () => {
+ const bomb = zip({ 'word/document.xml': `${'A'.repeat(3 * 1024 * 1024)} ` });
+ expect(bomb.byteLength).toBeLessThan(64 * 1024);
+ expect(await officeText(bomb, 'docx', { inflateCap: 1024 * 1024 })).toMatchObject({ status: 'too_big', text: '' });
+ // The same file under the real cap is read.
+ expect((await officeText(bomb, 'docx')).status).toBe('ok');
+ });
+
+ it('answers unsupported for the legacy formats and encrypted for a protected file', async () => {
+ const cfb = new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0, 0, 0, 0]);
+ expect((await extractText(cfb, { name: 'old.doc', type: 'application/msword' })).status).toBe('unsupported');
+ expect((await extractText(cfb, { name: 'old.xls' })).status).toBe('unsupported');
+ // A compound file without an EncryptedPackage stream is a legacy file under a new name (limits.test.ts has the protected one).
+ expect((await extractText(cfb, { name: 'secret.docx' })).status).toBe('unsupported');
+ const odfLocked = odf(' ', { 'META-INF/manifest.xml': ' ' });
+ expect((await extractText(odfLocked, { name: 'secret.odt' })).status).toBe('encrypted');
+ });
+
+ it('answers failed for a file that is not a ZIP', async () => {
+ expect((await extractText(text('not a zip at all'), { name: 'broken.docx' })).status).toBe('failed');
+ });
+});
+
+describe('PDF', () => {
+ const fakePdf = (pages: string[][], error?: Error): PdfLib => ({
+ getDocument: () => ({
+ promise: error ? Promise.reject(error) : Promise.resolve({
+ numPages: pages.length,
+ getPage: async (n: number) => ({
+ getTextContent: async () => ({ items: pages[n - 1].map((str, i, all) => ({ str, hasEOL: i < all.length - 1 })) }),
+ }),
+ }),
+ destroy: async () => {},
+ }),
+ });
+
+ it('reads the text of each page', async () => {
+ setPdfLoader(async () => fakePdf([['Workshop agenda', '09:30 Launch plan (codeword PELICAN)'], ['11:00 Budget and the rest of the day']]));
+ const out = await extractText(text('%PDF-1.4'), { name: 'agenda.pdf' });
+ expect(out).toMatchObject({ format: 'pdf', status: 'ok', pages: 2, truncated: false });
+ expect(out.text).toBe('Page 1\nWorkshop agenda\n09:30 Launch plan (codeword PELICAN)\n\nPage 2\n11:00 Budget and the rest of the day');
+ });
+
+ it('reads at most 50 pages', async () => {
+ setPdfLoader(async () => fakePdf(Array.from({ length: 60 }, (_, i) => [`This is page number ${i + 1} of the report`])));
+ const out = await extractText(text('%PDF'), { name: 'long.pdf', maxChars: 100_000 });
+ expect(out).toMatchObject({ status: 'ok', pages: 60, truncated: true });
+ expect(out.text).toContain('page number 50 ');
+ expect(out.text).not.toContain('page number 51 ');
+ });
+
+ it('says a scan has no text, and a protected PDF is encrypted', async () => {
+ setPdfLoader(async () => fakePdf([[''], ['3'], ['']]));
+ expect(await extractText(text('%PDF'), { name: 'scan.pdf' })).toMatchObject({ status: 'no_text', pages: 3, text: '' });
+ setPdfLoader(async () => fakePdf([], Object.assign(new Error('No password given'), { name: 'PasswordException' })));
+ expect((await extractText(text('%PDF'), { name: 'locked.pdf' })).status).toBe('encrypted');
+ setPdfLoader(async () => fakePdf([], new Error('Invalid PDF structure')));
+ expect((await extractText(text('%PDF'), { name: 'broken.pdf' })).status).toBe('failed');
+ });
+});
diff --git a/lib/attachment-text/__tests__/limits.test.ts b/lib/attachment-text/__tests__/limits.test.ts
new file mode 100644
index 000000000..ef7e5c0e0
--- /dev/null
+++ b/lib/attachment-text/__tests__/limits.test.ts
@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'vitest';
+import { strToU8, zipSync, type Zippable } from 'fflate';
+import { clipText, extractText } from '@/lib/attachment-text';
+import { officeText } from '@/lib/attachment-text/office';
+
+// The limits of the text extraction: whatever the file says, a read stays
+// within its character budget, a sheet within its caps, an XML part within
+// its size, and nothing a file names (a DTD) is followed.
+
+const zip = (files: Record): Uint8Array => {
+ const entries: Zippable = {};
+ for (const [name, value] of Object.entries(files)) entries[name] = strToU8(value);
+ return zipSync(entries, { level: 9 });
+};
+
+const O = 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0"';
+const ods = (tables: string) => zip({
+ mimetype: 'application/vnd.oasis.opendocument.spreadsheet',
+ 'content.xml': `${tables} `,
+});
+const odsTable = (name: string, rows: string) => `${rows} `;
+const odsRow = (cells: string[], repeat = 1) => `${cells.map((c) => `${c} `).join('')} `;
+
+const S = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
+const xlsx = (sheets: string[], shared: string[] = []) => zip({
+ 'xl/workbook.xml': `${sheets.map((_, i) => ` `).join('')} `,
+ 'xl/_rels/workbook.xml.rels': `${sheets.map((_, i) => ` `).join('')} `,
+ 'xl/sharedStrings.xml': `${shared.map((s) => `${s} `).join('')} `,
+ ...Object.fromEntries(sheets.map((rows, i) => [`xl/worksheets/sheet${i + 1}.xml`, `${rows} `])),
+});
+
+const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
+const docx = (body: string, prolog = '') => zip({ 'word/document.xml': `${prolog}${body} ` });
+
+describe('the character budget', () => {
+ it('stops a tiny ODS that repeats a cell 100 × 2,000 times at the budget', async () => {
+ // One 100-character cell, repeated across 100 columns and 2,000 rows:
+ // without a budget that makes 20 million characters out of a few hundred
+ // bytes (200 million with a 1,000-character cell).
+ const bomb = ods(odsTable('Bomb', `${'x'.repeat(100)} `));
+ expect(bomb.byteLength).toBeLessThan(1024);
+ const out = await officeText(bomb, 'ods', { budget: 5_000 });
+ expect(out.status).toBe('ok');
+ expect(out.truncated).toBe(true);
+ expect(out.text.length).toBeLessThanOrEqual(5_000);
+ const read = await extractText(bomb, { name: 'bomb.ods', maxChars: 3_000 });
+ expect(read.text.length).toBeLessThanOrEqual(3_000);
+ expect(read.truncated).toBe(true);
+ });
+
+ it('stops an xlsx whose cells all point at one big shared string, and caps a cell', async () => {
+ const rows = Array.from({ length: 2000 }, (_, i) => `0
`).join('');
+ const file = xlsx([rows], ['y'.repeat(50_000)]);
+ const out = await officeText(file, 'xlsx', { budget: 20_000 });
+ expect(out.truncated).toBe(true);
+ expect(out.text.length).toBeLessThanOrEqual(20_000);
+ const firstRow = (await extractText(file, { name: 'big.xlsx', maxChars: 100_000 })).text.split('\n')[1];
+ expect(firstRow.length).toBeLessThanOrEqual(4_000);
+ });
+
+ it('stops a long Word file at the budget', async () => {
+ const long = docx(Array.from({ length: 5000 }, (_, i) => `Paragraph ${i} with some words in it. `).join(''));
+ const out = await officeText(long, 'docx', { budget: 1_000 });
+ expect(out).toMatchObject({ status: 'ok', truncated: true });
+ expect(out.text.length).toBeLessThanOrEqual(1_000);
+ });
+});
+
+describe('sheet caps', () => {
+ it('reads at most 5 sheets of an xlsx and of an ods, and says so', async () => {
+ const x = await extractText(xlsx(Array.from({ length: 7 }, () => '1
')), { name: 'many.xlsx' });
+ expect(x).toMatchObject({ status: 'ok', truncated: true, sheets: ['S1', 'S2', 'S3', 'S4', 'S5'] });
+ const o = await extractText(ods(Array.from({ length: 7 }, (_, i) => odsTable(`T${i + 1}`, odsRow(['1']))).join('')), { name: 'many.ods' });
+ expect(o).toMatchObject({ status: 'ok', truncated: true, sheets: ['T1', 'T2', 'T3', 'T4', 'T5'] });
+ });
+
+ it('reads at most 2,000 rows of an ods sheet', async () => {
+ const out = await extractText(ods(odsTable('Rows', Array.from({ length: 2005 }, (_, i) => odsRow([`r${i}`])).join(''))), { name: 'rows.ods', maxChars: 100_000 });
+ expect(out.truncated).toBe(true);
+ expect(out.text.split('\n').filter((l) => /^r\d+$/.test(l))).toHaveLength(2000);
+ });
+});
+
+describe('what a file may not do', () => {
+ it('answers too_big for one XML part over the part cap', async () => {
+ const big = docx(`${'z'.repeat(200_000)} `);
+ expect((await officeText(big, 'docx', { partCap: 100_000 })).status).toBe('too_big');
+ expect((await officeText(big, 'docx')).status).toBe('ok');
+ });
+
+ it('refuses an Office XML part with a DTD', async () => {
+ const dtd = docx('&a;&a;&a; ', ']>');
+ expect(await extractText(dtd, { name: 'dtd.docx' })).toMatchObject({ status: 'failed', text: '' });
+ });
+
+ it('answers encrypted for a ZIP entry with the encryption flag', async () => {
+ const file = docx('secret ');
+ // Set bit 0 of the general purpose flag in the central directory entry.
+ for (let i = file.length - 22; i >= 0; i--) {
+ if (file[i] === 0x50 && file[i + 1] === 0x4b && file[i + 2] === 0x01 && file[i + 3] === 0x02) { file[i + 8] |= 1; break; }
+ }
+ expect((await extractText(file, { name: 'locked.docx' })).status).toBe('encrypted');
+ });
+
+ it('answers failed for a docx without its document part', async () => {
+ expect((await extractText(zip({ '[Content_Types].xml': ' ' }), { name: 'empty.docx' })).status).toBe('failed');
+ });
+
+ it('tells a password-protected Office file from a legacy one under a new name', async () => {
+ const cfb = (extra: number[]) => new Uint8Array([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, ...new Array(64).fill(0), ...extra]);
+ const utf16 = (s: string) => [...s].flatMap((c) => [c.charCodeAt(0), 0]);
+ expect((await extractText(cfb(utf16('EncryptedPackage')), { name: 'locked.docx' })).status).toBe('encrypted');
+ expect((await extractText(cfb(utf16('WordDocument')), { name: 'renamed.docx' })).status).toBe('unsupported');
+ });
+
+ it('answers unsupported for a message file instead of its raw MIME', async () => {
+ const eml = new TextEncoder().encode('From: a@example.org\r\nContent-Type: text/plain\r\nContent-Transfer-Encoding: base64\r\n\r\nSGVsbG8=\r\n');
+ expect((await extractText(eml, { name: 'forwarded.eml', type: 'message/rfc822' })).status).toBe('unsupported');
+ });
+});
+
+describe('maxChars', () => {
+ const words = (n: number) => new TextEncoder().encode('word '.repeat(n));
+
+ it('is at most 100,000 even when a caller asks for more', async () => {
+ const out = await extractText(words(30_000), { name: 'a.txt', maxChars: 500_000 });
+ expect(out.truncated).toBe(true);
+ expect(out.text.length).toBeLessThanOrEqual(100_000);
+ });
+
+ it('falls back to 20,000 for a value that is not a number', async () => {
+ const out = await extractText(words(10_000), { name: 'a.txt', maxChars: Number.NaN });
+ expect(out.text.length).toBeGreaterThan(19_000);
+ expect(out.text.length).toBeLessThanOrEqual(20_000);
+ });
+
+ it('never cuts a character in half', () => {
+ const out = clipText(`${'a'.repeat(9)}😀b`, 10);
+ expect(out).toEqual({ text: 'aaaaaaaaa', truncated: true });
+ });
+});
diff --git a/lib/attachment-text/__tests__/loading.test.ts b/lib/attachment-text/__tests__/loading.test.ts
new file mode 100644
index 000000000..9fcd862f2
--- /dev/null
+++ b/lib/attachment-text/__tests__/loading.test.ts
@@ -0,0 +1,73 @@
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import * as publicApi from '@/lib/attachment-text';
+import { extractText, MAX_EXTRACTIONS_AT_ONCE } from '@/lib/attachment-text';
+import { setPdfLoader, type PdfLib } from '@/lib/attachment-text/pdf';
+
+// How the extraction loads and runs: a Blob in (Files), at most two at a
+// time, the readers loaded only when the host reads an attachment.
+
+const root = path.resolve(__dirname, '../../..');
+const source = (file: string) => readFileSync(path.join(root, file), 'utf8');
+
+afterEach(() => setPdfLoader());
+
+describe('extractText', () => {
+ it('reads a Blob, and answers too_big for a large one without reading it', async () => {
+ expect(await extractText(new Blob(['Notes from Files']), { name: 'notes.txt' })).toMatchObject({ status: 'ok', text: 'Notes from Files' });
+ let read = false;
+ const big = new Blob([new Uint8Array(20 * 1024 * 1024 + 1)]);
+ big.arrayBuffer = async () => { read = true; return new ArrayBuffer(0); };
+ expect(await extractText(big, { name: 'big.txt' })).toMatchObject({ status: 'too_big' });
+ expect(read).toBe(false);
+ });
+
+ it('runs at most two extractions at a time; the others wait their turn', async () => {
+ expect(MAX_EXTRACTIONS_AT_ONCE).toBe(2);
+ let active = 0;
+ let most = 0;
+ const release: (() => void)[] = [];
+ const lib: PdfLib = {
+ getDocument: () => {
+ active++;
+ most = Math.max(most, active);
+ return {
+ promise: new Promise((resolve) => release.push(() => resolve({
+ numPages: 1,
+ getPage: async () => ({ getTextContent: async () => ({ items: [{ str: 'A page with enough text on it to count' }] }) }),
+ }))),
+ destroy: async () => { active--; },
+ };
+ },
+ };
+ setPdfLoader(async () => lib);
+ const reads = Array.from({ length: 5 }, () => extractText(new Uint8Array([37, 80, 68, 70]), { name: 'a.pdf' }));
+ for (let round = 0; round < 10; round++) {
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ release.splice(0).forEach((go) => go());
+ }
+ const done = await Promise.all(reads);
+ expect(done.every((r) => r.status === 'ok')).toBe(true);
+ expect(most).toBe(2);
+ });
+
+ it('keeps the pdf.js test seam out of the public module', () => {
+ expect('setPdfLoader' in publicApi).toBe(false);
+ });
+});
+
+describe('loading', () => {
+ it('the host loads the readers only when an attachment is read', () => {
+ const b = source('lib/plugin-sandbox/assistant/b.ts');
+ expect(b).not.toMatch(/^import [^;]* from '@\/lib\/attachment-text';/m);
+ expect(b).toContain("await import('@/lib/attachment-text')");
+ });
+
+ it('the sandbox uses the runtime\'s own network timeout for reads', () => {
+ const runtime = source('lib/plugin-sandbox/assistant/b.runtime.ts');
+ expect(runtime).toContain("import { NETWORK_API_TIMEOUT_MS } from '../api-timeouts';");
+ expect(runtime).not.toMatch(/\d{2,3}_000/);
+ expect(source('lib/plugin-sandbox/runtime.tsx')).toContain("from './api-timeouts';");
+ });
+});
diff --git a/lib/attachment-text/__tests__/office-structure.test.ts b/lib/attachment-text/__tests__/office-structure.test.ts
new file mode 100644
index 000000000..5d59cf091
--- /dev/null
+++ b/lib/attachment-text/__tests__/office-structure.test.ts
@@ -0,0 +1,87 @@
+import { describe, expect, it } from 'vitest';
+import { strToU8, zipSync, type Zippable } from 'fflate';
+import { extractText } from '@/lib/attachment-text';
+
+// What an Office file means rather than how it is stored: text boxes once,
+// moved text once, slides in the order the deck shows them, the main part
+// wherever the package says it is, dates as dates.
+
+const zip = (files: Record): Uint8Array => {
+ const entries: Zippable = {};
+ for (const [name, value] of Object.entries(files)) entries[name] = strToU8(value);
+ return zipSync(entries);
+};
+
+const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:v="urn:schemas-microsoft-com:vml"';
+const PKG = 'xmlns="http://schemas.openxmlformats.org/package/2006/relationships"';
+const OFFICE_DOC = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument';
+
+describe('Word', () => {
+ it('reads a text box once, on its own line, and moved text once', async () => {
+ const box = (text: string) => `${text} `;
+ const doc = zip({
+ 'word/document.xml': `
+ Before the box.
+ ${box('Box text')}
+ ${box('Box text')}
+
+ Old place New place
+ `,
+ });
+ expect((await extractText(doc, { name: 'box.docx' })).text).toBe('Before the box.\nBox text\nNew place');
+ });
+
+ it('finds the main part where the package says it is', async () => {
+ const doc = zip({
+ '_rels/.rels': ` `,
+ 'word/main.xml': `From the main part `,
+ });
+ expect(await extractText(doc, { name: 'moved.docx' })).toMatchObject({ status: 'ok', text: 'From the main part' });
+ });
+});
+
+const P = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"';
+const shape = (text: string) => `${text} `;
+const slide = (body: string) => `${body} `;
+
+describe('PowerPoint', () => {
+ it('reads the slides in the order of the deck, not of the files', async () => {
+ const deck = zip({
+ '_rels/.rels': ` `,
+ 'ppt/presentation.xml': ` `,
+ 'ppt/_rels/presentation.xml.rels': ` `,
+ 'ppt/slides/slide1.xml': slide(shape('Shown second')),
+ 'ppt/slides/slide2.xml': slide(shape('Shown first')),
+ });
+ expect((await extractText(deck, { name: 'deck.pptx' })).text).toBe('Slide 1\nShown first\n\nSlide 2\nShown second');
+ });
+
+ it('reads a shape with a fallback copy once', async () => {
+ const deck = zip({
+ 'ppt/presentation.xml': ` `,
+ 'ppt/slides/slide1.xml': slide(`${shape('Shape text')} ${shape('Shape text')} `),
+ });
+ expect((await extractText(deck, { name: 'deck.pptx' })).text).toBe('Slide 1\nShape text');
+ });
+});
+
+const S = 'xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"';
+const book = (cells: string, { date1904 = false } = {}) => zip({
+ 'xl/workbook.xml': `${date1904 ? ' ' : ''} `,
+ 'xl/_rels/workbook.xml.rels': ` `,
+ 'xl/styles.xml': `
+ `,
+ 'xl/worksheets/sheet1.xml': `${cells}
`,
+});
+
+describe('Excel dates', () => {
+ it('writes date cells as ISO dates, by built-in and custom formats', async () => {
+ const out = await extractText(book('46326 46326.5 46326 46326 0.75 '), { name: 'dates.xlsx' });
+ expect(out.text).toBe('Sheet: Dates\n2026-10-31,2026-10-31 12:00,46326,46326,18:00');
+ });
+
+ it('counts from 1904 when the workbook says so', async () => {
+ const out = await extractText(book('44864 ', { date1904: true }), { name: 'mac.xlsx' });
+ expect(out.text).toBe('Sheet: Dates\n2026-10-31');
+ });
+});
diff --git a/lib/attachment-text/__tests__/pdf-real.test.ts b/lib/attachment-text/__tests__/pdf-real.test.ts
new file mode 100644
index 000000000..04174dfc8
--- /dev/null
+++ b/lib/attachment-text/__tests__/pdf-real.test.ts
@@ -0,0 +1,40 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { extractText } from '@/lib/attachment-text';
+import { setPdfLoader, type PdfLib } from '@/lib/attachment-text/pdf';
+
+// The PDF the test rig seeds: one page, Helvetica, a few lines of text.
+function pdf(lines: string[]): Uint8Array {
+ const content = `BT /F1 12 Tf 72 720 Td ${lines.map((l, i) => `${i ? '0 -16 Td ' : ''}(${l.replace(/[()\\]/g, (m) => '\\' + m)}) Tj`).join(' ')} ET`;
+ const objs = [
+ '<< /Type /Catalog /Pages 2 0 R >>',
+ '<< /Type /Pages /Kids [3 0 R] /Count 1 >>',
+ '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>',
+ `<< /Length ${content.length} >>\nstream\n${content}\nendstream`,
+ '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>',
+ ];
+ let out = '%PDF-1.4\n';
+ const offs: number[] = [];
+ objs.forEach((o, i) => { offs.push(out.length); out += `${i + 1} 0 obj\n${o}\nendobj\n`; });
+ const xref = out.length;
+ out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n${offs.map((o) => `${String(o).padStart(10, '0')} 00000 n \n`).join('')}trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
+ return new TextEncoder().encode(out);
+}
+
+afterEach(() => setPdfLoader());
+
+describe('PDF with the real pdf.js', () => {
+ it('reads the text of a one-page PDF', async () => {
+ // The app loads the browser build with its worker file; vitest runs in
+ // Node, which needs the legacy build (its worker runs in the same thread).
+ setPdfLoader(async () => {
+ const lib = await import('pdfjs-dist/legacy/build/pdf.mjs');
+ // pdf.js takes an already loaded worker module from here.
+ // @ts-expect-error -- pdf.js ships no types for the worker module
+ (globalThis as { pdfjsWorker?: unknown }).pdfjsWorker = await import('pdfjs-dist/legacy/build/pdf.worker.mjs');
+ return lib as unknown as PdfLib;
+ });
+ const out = await extractText(pdf(['Workshop agenda', '09:30 Launch plan (codeword PELICAN)']), { name: 'agenda.pdf' });
+ expect(out).toMatchObject({ status: 'ok', pages: 1 });
+ expect(out.text).toContain('codeword PELICAN');
+ }, 30_000);
+});
diff --git a/lib/attachment-text/__tests__/pdf.test.ts b/lib/attachment-text/__tests__/pdf.test.ts
new file mode 100644
index 000000000..5b2608fe0
--- /dev/null
+++ b/lib/attachment-text/__tests__/pdf.test.ts
@@ -0,0 +1,70 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { pdfText, setPdfLoader, type PdfLib } from '@/lib/attachment-text/pdf';
+import { extractText } from '@/lib/attachment-text';
+
+// pdf.js is stood in for: each page is a list of text lines.
+
+const calls = { pages: 0, sources: [] as unknown[], destroyed: 0 };
+
+function fakePdf(pages: string[][], { hang = false } = {}): PdfLib {
+ return {
+ getDocument: (src) => {
+ calls.sources.push(src);
+ return {
+ promise: hang ? new Promise(() => {}) : Promise.resolve({
+ numPages: pages.length,
+ getPage: async (n: number) => {
+ calls.pages++;
+ return { getTextContent: async () => ({ items: pages[n - 1].map((str, i, all) => ({ str, hasEOL: i < all.length - 1 })) }) };
+ },
+ }),
+ destroy: async () => { calls.destroyed++; },
+ };
+ },
+ };
+}
+
+const pdf = new TextEncoder().encode('%PDF-1.4');
+
+afterEach(() => {
+ setPdfLoader();
+ calls.pages = 0;
+ calls.sources.length = 0;
+ calls.destroyed = 0;
+});
+
+describe('pdfText', () => {
+ it('reads a typed cover letter in front of a scan', async () => {
+ setPdfLoader(async () => fakePdf([['Dear Alice, please find the signed contract attached.'], [''], [''], ['']]));
+ const out = await extractText(pdf, { name: 'scan.pdf' });
+ expect(out).toMatchObject({ status: 'ok', pages: 4 });
+ expect(out.text).toContain('signed contract');
+ });
+
+ it('stops reading pages once the budget is used up', async () => {
+ setPdfLoader(async () => fakePdf(Array.from({ length: 50 }, (_, i) => [`Page ${i + 1}: ${'word '.repeat(200)}`])));
+ const out = await pdfText(pdf, { budget: 2_500 });
+ expect(out).toMatchObject({ status: 'ok', truncated: true, pages: 50 });
+ expect(calls.pages).toBeLessThanOrEqual(4);
+ expect(out.text.length).toBeLessThanOrEqual(2_500);
+ });
+
+ it('never lets pdf.js evaluate scripts', async () => {
+ setPdfLoader(async () => fakePdf([['Some text that is long enough to count']]));
+ await pdfText(pdf);
+ expect(calls.sources[0]).toMatchObject({ isEvalSupported: false });
+ });
+
+ it('gives up after the timeout and stops pdf.js', async () => {
+ vi.useFakeTimers();
+ try {
+ setPdfLoader(async () => fakePdf([], { hang: true }));
+ const pending = pdfText(pdf, { timeoutMs: 1_000 });
+ await vi.advanceTimersByTimeAsync(1_001);
+ expect(await pending).toMatchObject({ status: 'failed' });
+ expect(calls.destroyed).toBe(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
diff --git a/lib/attachment-text/__tests__/plain.test.ts b/lib/attachment-text/__tests__/plain.test.ts
new file mode 100644
index 000000000..83b641fac
--- /dev/null
+++ b/lib/attachment-text/__tests__/plain.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from 'vitest';
+import { extractText } from '@/lib/attachment-text';
+
+// HTML and calendar files: reading takes time in step with the file, and an
+// event is described by its own properties only.
+
+const text = (s: string) => new TextEncoder().encode(s);
+
+describe('HTML', () => {
+ it('reads an HTML file full of unclosed tags in a moment', async () => {
+ // 400 KB of "Agenda${' {
+ const started = performance.now();
+ // Numbering each item by searching the list made 20,000 items take far longer than this.
+ const out = await extractText(text(`${'item '.repeat(20_000)} `), { name: 'list.html', maxChars: 1_000 });
+ expect(performance.now() - started).toBeLessThan(8_000);
+ expect(out.text.startsWith('item')).toBe(true);
+ }, 20_000);
+
+ it('leaves out scripts, styles, templates and the head', async () => {
+ const out = await extractText(text('T Body
ns tpl '), { name: 'a.html' });
+ expect(out.text).toBe('Body');
+ });
+
+ it('reads at most 5 million characters of HTML, and says so', async () => {
+ // Little text, a lot of source: only the cap can make this truncated.
+ const out = await extractText(text(`Body
`), { name: 'huge.html' });
+ expect(out).toMatchObject({ status: 'ok', text: 'Body', truncated: true });
+ });
+});
+
+describe('calendar files', () => {
+ it('ignores what an alarm inside an event says', async () => {
+ const ics = [
+ 'BEGIN:VCALENDAR', 'BEGIN:VEVENT', 'SUMMARY:Dentist', 'DTSTART:20261105T090000Z',
+ 'BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:Reminder', 'LOCATION:Nowhere', 'END:VALARM',
+ 'LOCATION:Main street 1', 'END:VEVENT', 'END:VCALENDAR',
+ ].join('\r\n');
+ expect((await extractText(text(ics), { name: 'a.ics' })).text).toBe('event: Dentist, 2026-11-05 09:00 UTC, Main street 1');
+ });
+});
diff --git a/lib/attachment-text/index.ts b/lib/attachment-text/index.ts
new file mode 100644
index 000000000..d6102042f
--- /dev/null
+++ b/lib/attachment-text/index.ts
@@ -0,0 +1,137 @@
+// The text of an attachment or a file, for a reader that needs words rather
+// than layout (the AI assistant's plugin, Files). Picks the format by the
+// file name, then the media type, and hands the bytes to its reader:
+// plain.ts (text, CSV, HTML, calendar files, address cards), pdf.ts
+// (pdf.js), office.ts (docx, xlsx, pptx, odt, ods, odp). Never throws: a
+// file that can't be read answers with a status. Import it on demand: the
+// readers are only loaded when a file is read.
+
+import { MAX_HTML_CHARS, decodeText, htmlText, icsToText, looksLikeText, vcfText } from './plain';
+import { pdfText } from './pdf';
+import { officeText } from './office';
+import {
+ DEFAULT_TEXT_CHARS, MAX_TEXT_CHARS, MAX_TEXT_SOURCE_BYTES, TEXT_BUDGET_SLACK,
+ type ExtractOptions, type ExtractedText, type TextFormat,
+} from './types';
+
+export * from './types';
+
+const BY_EXTENSION: Record = {
+ txt: 'text', text: 'text', md: 'text', markdown: 'text', log: 'text', json: 'text', xml: 'text',
+ yaml: 'text', yml: 'text', ini: 'text', tsv: 'csv', csv: 'csv',
+ htm: 'html', html: 'html', xhtml: 'html', ics: 'ics', ical: 'ics', ifb: 'ics', vcf: 'vcf', vcard: 'vcf',
+ pdf: 'pdf', docx: 'docx', docm: 'docx', dotx: 'docx', xlsx: 'xlsx', xlsm: 'xlsx', xltx: 'xlsx',
+ pptx: 'pptx', pptm: 'pptx', potx: 'pptx', odt: 'odt', ott: 'odt', ods: 'ods', ots: 'ods', odp: 'odp', otp: 'odp',
+};
+
+const BY_TYPE: Record = {
+ 'text/plain': 'text', 'text/markdown': 'text', 'application/json': 'text', 'application/xml': 'text', 'text/xml': 'text',
+ 'text/csv': 'csv', 'text/tab-separated-values': 'csv', 'text/html': 'html', 'application/xhtml+xml': 'html',
+ 'text/calendar': 'ics', 'application/ics': 'ics', 'text/vcard': 'vcf', 'text/x-vcard': 'vcf', 'text/directory': 'vcf',
+ 'application/pdf': 'pdf',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
+ 'application/vnd.oasis.opendocument.text': 'odt',
+ 'application/vnd.oasis.opendocument.spreadsheet': 'ods',
+ 'application/vnd.oasis.opendocument.presentation': 'odp',
+};
+
+/** The format to read a file as: by its name's extension, else its media type, else 'other'. */
+export function textFormatOf(name = '', type = ''): TextFormat {
+ const ext = /\.([a-z0-9]+)$/i.exec(name.trim())?.[1]?.toLowerCase();
+ if (ext && BY_EXTENSION[ext]) return BY_EXTENSION[ext];
+ const mediaType = type.split(';')[0].trim().toLowerCase();
+ if (BY_TYPE[mediaType]) return BY_TYPE[mediaType];
+ // Other text types (text/x-python, text/rfc822-headers, ...); not text/html, handled above.
+ // A forwarded message (.eml, message/rfc822) is MIME, not text: 'other' until it has a reader.
+ if (mediaType.startsWith('text/')) return 'text';
+ return 'other';
+}
+
+/** Cut `text` at `maxChars`, at a line or word end when one is near. */
+export function clipText(text: string, maxChars: number): { text: string; truncated: boolean } {
+ if (text.length <= maxChars) return { text, truncated: false };
+ // Never half of a character outside the Basic Multilingual Plane (an emoji).
+ const end = /[\uD800-\uDBFF]/.test(text[maxChars - 1] ?? '') ? maxChars - 1 : maxChars;
+ const cut = text.slice(0, end);
+ const at = Math.max(cut.lastIndexOf('\n'), cut.lastIndexOf(' '));
+ return { text: (at > maxChars * 0.9 ? cut.slice(0, at) : cut).trimEnd(), truncated: true };
+}
+
+const empty = (format: TextFormat, status: ExtractedText['status']): ExtractedText => ({ format, status, text: '', truncated: false });
+
+async function read(bytes: Uint8Array, format: TextFormat, budget: number): Promise> {
+ switch (format) {
+ case 'text':
+ case 'csv':
+ if (!looksLikeText(bytes)) return { status: 'unsupported', text: '', truncated: false };
+ return { status: 'ok', text: decodeText(bytes).replace(/\r\n?/g, '\n'), truncated: false };
+ case 'html':
+ {
+ const html = decodeText(bytes);
+ const cut = html.length > MAX_HTML_CHARS;
+ return { status: 'ok', text: htmlText(cut ? html.slice(0, MAX_HTML_CHARS) : html), truncated: cut };
+ }
+ case 'ics':
+ return { status: 'ok', text: icsToText(decodeText(bytes)), truncated: false };
+ case 'vcf':
+ return { status: 'ok', text: vcfText(decodeText(bytes)), truncated: false };
+ case 'pdf':
+ return pdfText(bytes, { budget });
+ case 'docx': case 'xlsx': case 'pptx': case 'odt': case 'ods': case 'odp':
+ return officeText(bytes, format, { budget });
+ default:
+ return { status: 'unsupported', text: '', truncated: false };
+ }
+}
+
+/** Extractions running at once: each holds a file, and an Office file's DOM, on the main thread. */
+export const MAX_EXTRACTIONS_AT_ONCE = 2;
+let running = 0;
+const waiting: (() => void)[] = [];
+
+/** Runs `work` when fewer than MAX_EXTRACTIONS_AT_ONCE extractions are running. */
+async function inTurn(work: () => Promise): Promise {
+ while (running >= MAX_EXTRACTIONS_AT_ONCE) await new Promise((resolve) => waiting.push(resolve));
+ running++;
+ try {
+ return await work();
+ } finally {
+ running--;
+ waiting.shift()?.();
+ }
+}
+
+/**
+ * The text of a file (its bytes, or a Blob such as a file in Files).
+ * Statuses: too_big over MAX_TEXT_SOURCE_BYTES (or an Office file that
+ * unpacks to more than 50 MB), unsupported for images, archives and the
+ * legacy .doc/.xls/.ppt, no_text for a scanned PDF or a file with nothing
+ * in it, encrypted, failed. At most MAX_EXTRACTIONS_AT_ONCE run at a time;
+ * the others wait their turn.
+ */
+export async function extractText(data: ArrayBuffer | Uint8Array | Blob, options: ExtractOptions = {}): Promise {
+ const format = textFormatOf(options.name, options.type);
+ const size = data instanceof Blob ? data.size : data.byteLength;
+ if (size > MAX_TEXT_SOURCE_BYTES) return empty(format, 'too_big');
+ // Files calls this directly: anything but a positive number means the default.
+ const asked = Number(options.maxChars);
+ const maxChars = Number.isFinite(asked) && asked >= 1 ? Math.min(MAX_TEXT_CHARS, Math.floor(asked)) : DEFAULT_TEXT_CHARS;
+ // A legacy Office file (and anything else) without a reader.
+ if (format === 'other') return empty(format, 'unsupported');
+ let result: Omit;
+ try {
+ result = await inTurn(async () => {
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data instanceof Blob ? await data.arrayBuffer() : data);
+ return read(bytes, format, maxChars + TEXT_BUDGET_SLACK);
+ });
+ } catch {
+ return empty(format, 'failed');
+ }
+ if (result.status !== 'ok') return { ...result, format, text: '' };
+ const text = result.text.trim();
+ if (!text) return { ...result, format, status: 'no_text', text: '', truncated: false };
+ const clipped = clipText(text, maxChars);
+ return { ...result, format, text: clipped.text, truncated: result.truncated || clipped.truncated };
+}
diff --git a/lib/attachment-text/office.ts b/lib/attachment-text/office.ts
new file mode 100644
index 000000000..845c30410
--- /dev/null
+++ b/lib/attachment-text/office.ts
@@ -0,0 +1,551 @@
+// Text out of Office files: Word, Excel and PowerPoint (docx, xlsx, pptx)
+// and OpenDocument (odt, ods, odp). All are ZIP archives of XML; only the
+// entries holding text are unpacked (fflate, through the mail import's ZIP
+// reader) and read with DOMParser. No layout, no styles: the reader of the
+// text is a language model.
+//
+// Nothing in the file is trusted. The sizes in a ZIP's headers are whatever
+// the sender wrote, so the bytes actually unpacked are counted: reading
+// stops past MAX_INFLATED_BYTES for the whole file and MAX_XML_PART_BYTES
+// for one part (too_big). A few bytes of XML can stand for millions of
+// characters (a cell repeated across a sheet), so every reader writes into
+// a TextSink and stops when the read's budget is used up. Parts with a DTD
+// are refused.
+
+import { listZipEntries, streamZipEntry, ZipFormatError, type ZipEntry } from '@/lib/mail-import/zip-reader';
+import {
+ MAX_CELL_CHARS, MAX_INFLATED_BYTES, MAX_SHEET_ROWS, MAX_SHEETS, MAX_TEXT_CHARS, MAX_XML_PART_BYTES, TEXT_BUDGET_SLACK,
+ type ExtractedText, type TextFormat,
+} from './types';
+import { TextSink } from './sink';
+
+export type OfficeFormat = Extract;
+type Result = Omit;
+
+export interface OfficeOptions {
+ /** Characters to write at most (default MAX_TEXT_CHARS plus slack). */
+ budget?: number;
+ /** Bytes the whole file may unpack to (default MAX_INFLATED_BYTES). */
+ inflateCap?: number;
+ /** Bytes one XML part may unpack to (default MAX_XML_PART_BYTES). */
+ partCap?: number;
+}
+
+/** The file unpacks to more than a cap. */
+class TooBig extends Error {}
+/** The file is protected by a password. */
+class Encrypted extends Error {}
+/** The file asks for something a reader never does (a DTD). */
+class Refused extends Error {}
+
+/** Inflated chunks at most this big per step (a deflate block expands about 1000 times). */
+const INPUT_CHUNK = 16 * 1024;
+
+/** Reads entries of one archive, counting every byte unpacked against the caps. */
+class Unpacker {
+ private used = 0;
+ constructor(
+ private readonly file: Blob,
+ readonly entries: ZipEntry[],
+ private readonly cap: number,
+ private readonly partCap: number,
+ ) {}
+
+ has(name: string): boolean {
+ return this.entries.some((e) => e.name === name);
+ }
+
+ async bytes(name: string): Promise {
+ const entry = this.entries.find((e) => e.name === name && !e.directory);
+ if (!entry) return null;
+ if (entry.encrypted) throw new Encrypted();
+ const chunks: Uint8Array[] = [];
+ let total = 0;
+ for await (const chunk of streamZipEntry(this.file, entry, INPUT_CHUNK)) {
+ total += chunk.byteLength;
+ this.used += chunk.byteLength;
+ if (this.used > this.cap || total > this.partCap) throw new TooBig();
+ chunks.push(chunk);
+ }
+ const out = new Uint8Array(total);
+ let at = 0;
+ for (const chunk of chunks) {
+ out.set(chunk, at);
+ at += chunk.byteLength;
+ }
+ return out;
+ }
+
+ async xml(name: string): Promise {
+ const data = await this.bytes(name);
+ if (!data) return null;
+ const source = new TextDecoder('utf-8').decode(data);
+ // Office parts never need a DTD; entities from one only blow text up.
+ if (/ 0) throw new ZipFormatError(`${name} is not XML`);
+ return doc;
+ }
+}
+
+// ─── XML helpers (by local name, whatever the prefix) ────────
+
+function kids(el: Element, local?: string): Element[] {
+ return Array.from(el.children).filter((c) => !local || c.localName === local);
+}
+
+function all(root: Document | Element, local: string): Element[] {
+ return Array.from(root.getElementsByTagNameNS('*', local));
+}
+
+function attr(el: Element, local: string): string | null {
+ for (const a of Array.from(el.attributes)) if (a.localName === local) return a.value;
+ return null;
+}
+
+/** The r:id of a reference to another part (an attribute named id in a namespace, not a plain id). */
+function relId(el: Element): string | null {
+ for (const a of Array.from(el.attributes)) if (a.localName === 'id' && a.namespaceURI) return a.value;
+ return null;
+}
+
+const cell = (text: string) => (text.length > MAX_CELL_CHARS ? text.slice(0, MAX_CELL_CHARS) : text);
+
+/** A cell for CSV-like rows: quoted when it holds a comma, a quote or a line break. */
+function csvCell(value: string): string {
+ return /[",\n\r]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
+}
+
+function csvRow(cells: string[]): string {
+ let end = cells.length;
+ while (end > 0 && !cells[end - 1]) end--;
+ return cells.slice(0, end).map(csvCell).join(',');
+}
+
+// ─── Package parts (docx, xlsx, pptx) ────────────────────────
+
+function normalisePath(path: string): string {
+ const parts: string[] = [];
+ for (const part of path.split('/')) {
+ if (part === '..') parts.pop();
+ else if (part && part !== '.') parts.push(part);
+ }
+ return parts.join('/');
+}
+
+interface Relation { target: string; type: string }
+
+/** The relations of `part` (its _rels/.rels next to it): rId → target path and type. */
+async function relationsOf(zip: Unpacker, part: string): Promise> {
+ const slash = part.lastIndexOf('/');
+ const dir = slash === -1 ? '' : part.slice(0, slash);
+ const relsPath = part === '' ? '_rels/.rels' : `${dir ? `${dir}/` : ''}_rels/${part.slice(slash + 1)}.rels`;
+ const rels = await zip.xml(relsPath);
+ const map = new Map();
+ if (!rels) return map;
+ for (const rel of all(rels, 'Relationship')) {
+ const id = rel.getAttribute('Id');
+ const target = rel.getAttribute('Target');
+ if (!id || !target || rel.getAttribute('TargetMode') === 'External') continue;
+ map.set(id, {
+ target: target.startsWith('/') ? normalisePath(target) : normalisePath(`${dir}/${target}`),
+ type: rel.getAttribute('Type') ?? '',
+ });
+ }
+ return map;
+}
+
+/** The package's main part (document, workbook, presentation), as its _rels/.rels names it. */
+async function mainPart(zip: Unpacker, fallback: string): Promise {
+ for (const rel of (await relationsOf(zip, '')).values()) {
+ if (rel.type.endsWith('/officeDocument')) return rel.target;
+ }
+ return fallback;
+}
+
+/** Elements of `local` name under `root`, in document order, leaving out mc:Fallback copies. */
+function without(root: Element, local: string, skip: ReadonlySet = FALLBACK): Element[] {
+ const out: Element[] = [];
+ const walk = (el: Element) => {
+ for (const c of Array.from(el.children)) {
+ if (skip.has(c.localName)) continue;
+ if (c.localName === local) out.push(c);
+ else walk(c);
+ }
+ };
+ walk(root);
+ return out;
+}
+const FALLBACK: ReadonlySet = new Set(['Fallback']);
+
+// ─── Word (docx) ─────────────────────────────────────────────
+
+/**
+ * Not part of what the reader sees: deleted and moved-away text of tracked
+ * changes, field codes, and the Fallback copy of anything in
+ * mc:AlternateContent (the Choice holds the same).
+ */
+const WORD_SKIP: ReadonlySet = new Set(['delText', 'instrText', 'del', 'moveFrom', 'Fallback', 'sectPr']);
+
+/** A paragraph's text; the paragraphs of text boxes in it go to `boxes`, each its own line. */
+function wordRuns(el: Element, boxes: string[]): string {
+ let out = '';
+ for (const c of Array.from(el.children)) {
+ if (WORD_SKIP.has(c.localName)) continue;
+ switch (c.localName) {
+ case 't': out += c.textContent ?? ''; break;
+ case 'tab': out += '\t'; break;
+ case 'br': case 'cr': out += '\n'; break;
+ case 'txbxContent': {
+ const inner = new TextSink(MAX_CELL_CHARS);
+ wordBlocks(c, inner);
+ const text = inner.text();
+ if (text) boxes.push(text);
+ break;
+ }
+ default: out += wordRuns(c, boxes);
+ }
+ }
+ return out;
+}
+
+function wordBlocks(el: Element, out: TextSink): void {
+ for (const c of Array.from(el.children)) {
+ if (out.full) return;
+ if (WORD_SKIP.has(c.localName)) continue;
+ if (c.localName === 'p') {
+ const boxes: string[] = [];
+ const text = wordRuns(c, boxes);
+ if (text || !boxes.length) out.line(text);
+ for (const box of boxes) out.line(box);
+ } else if (c.localName === 'tbl') {
+ for (const row of kids(c, 'tr')) {
+ const cells = kids(row, 'tc').map((td) => {
+ const inner = new TextSink(MAX_CELL_CHARS);
+ wordBlocks(td, inner);
+ return inner.text().replace(/\s*\n\s*/g, ' ');
+ });
+ if (!out.line(cells.join(' | '))) return;
+ }
+ out.line('');
+ } else wordBlocks(c, out);
+ }
+}
+
+async function docx(zip: Unpacker, sink: TextSink): Promise {
+ const doc = await zip.xml(await mainPart(zip, 'word/document.xml'));
+ if (!doc) return { status: 'failed', text: '', truncated: false };
+ wordBlocks(all(doc, 'body')[0] ?? doc.documentElement, sink);
+ return { status: 'ok', text: sink.text(), truncated: sink.full };
+}
+
+// ─── Excel (xlsx) ────────────────────────────────────────────
+
+/** "BC12" → 54 (0-based column of the cell reference). */
+function columnOf(ref: string | null): number | null {
+ const m = /^([A-Z]+)\d*$/i.exec(ref ?? '');
+ if (!m) return null;
+ let n = 0;
+ for (const ch of m[1].toUpperCase()) n = n * 26 + (ch.charCodeAt(0) - 64);
+ return n - 1;
+}
+
+/** Built-in number formats that are dates or times (ECMA-376 18.8.30). */
+const DATE_FORMAT_IDS = new Set([14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]);
+
+/** True for a custom format code that shows a date or a time (d, m with y, y, h, s outside quotes and brackets). */
+export function isDateFormatCode(code: string): boolean {
+ const bare = code.replace(/"[^"]*"/g, '').replace(/\\./g, '').replace(/\[[^\]]*\]/g, '');
+ return /[dyhs]/i.test(bare);
+}
+
+/** The cell styles (index in cellXfs) whose number format is a date or a time. */
+async function dateStyles(zip: Unpacker, path: string): Promise> {
+ const out = new Set();
+ const styles = await zip.xml(path);
+ if (!styles) return out;
+ const custom = new Map();
+ for (const fmt of all(styles, 'numFmt')) custom.set(Number(fmt.getAttribute('numFmtId')), fmt.getAttribute('formatCode') ?? '');
+ const xfs = all(styles, 'cellXfs')[0];
+ kids(xfs ?? styles.documentElement, 'xf').forEach((xf, index) => {
+ if (!xfs) return;
+ const id = Number(xf.getAttribute('numFmtId') ?? 0);
+ const code = custom.get(id);
+ if (code !== undefined ? isDateFormatCode(code) : DATE_FORMAT_IDS.has(id)) out.add(index);
+ });
+ return out;
+}
+
+/** A date serial as "2026-10-31", "2026-10-31 12:00" or, below one day, "12:00". */
+export function excelDate(serial: number, date1904 = false): string | null {
+ if (!Number.isFinite(serial) || serial < 0 || serial > 2_958_465) return null;
+ const days = Math.floor(serial);
+ const seconds = Math.round((serial - days) * 86400);
+ // 1899-12-30 makes Excel's day 60 (a 29 February 1900 that never was) come out right after it.
+ const base = date1904 ? Date.UTC(1904, 0, 1) : Date.UTC(1899, 11, 30);
+ const iso = new Date(base + days * 86_400_000 + seconds * 1000).toISOString();
+ if (days === 0 && !date1904) return iso.slice(11, 16);
+ return seconds ? `${iso.slice(0, 10)} ${iso.slice(11, 16)}` : iso.slice(0, 10);
+}
+
+async function xlsx(zip: Unpacker, sink: TextSink): Promise {
+ const bookPath = await mainPart(zip, 'xl/workbook.xml');
+ const book = await zip.xml(bookPath);
+ if (!book) return { status: 'failed', text: '', truncated: false };
+ const rels = await relationsOf(zip, bookPath);
+ const dir = bookPath.includes('/') ? bookPath.slice(0, bookPath.lastIndexOf('/')) : '';
+ const related = (type: string, fallback: string) => [...rels.values()].find((r) => r.type.endsWith(type))?.target ?? fallback;
+ const shared: string[] = [];
+ const strings = await zip.xml(related('/sharedStrings', `${dir}/sharedStrings.xml`));
+ if (strings) {
+ for (const si of all(strings, 'si')) {
+ // Phonetic runs (rPh) are reading aids, not the cell's text.
+ shared.push(cell(all(si, 't').filter((t) => t.parentElement?.localName !== 'rPh').map((t) => t.textContent ?? '').join('')));
+ }
+ }
+ const dates = await dateStyles(zip, related('/styles', `${dir}/styles.xml`));
+ const pr = all(book, 'workbookPr')[0];
+ const date1904 = pr?.getAttribute('date1904') === '1' || pr?.getAttribute('date1904') === 'true';
+ const sheets = all(book, 'sheet');
+ const names: string[] = [];
+ let truncated = sheets.length > MAX_SHEETS;
+ for (const sheet of sheets.slice(0, MAX_SHEETS)) {
+ if (sink.full) break;
+ const name = sheet.getAttribute('name') ?? `Sheet ${names.length + 1}`;
+ const rid = relId(sheet);
+ const path = rid ? rels.get(rid)?.target : undefined;
+ const doc = path ? await zip.xml(path) : null;
+ names.push(name);
+ if (names.length > 1) sink.line('');
+ sink.line(`Sheet: ${name}`);
+ if (!doc) continue;
+ const rowEls = all(doc, 'row');
+ if (rowEls.length > MAX_SHEET_ROWS) truncated = true;
+ for (const row of rowEls.slice(0, MAX_SHEET_ROWS)) {
+ const cells: string[] = [];
+ for (const c of kids(row, 'c')) {
+ const col = columnOf(c.getAttribute('r')) ?? cells.length;
+ if (col > 1000) continue;
+ const type = c.getAttribute('t');
+ const v = kids(c, 'v')[0]?.textContent ?? '';
+ let value: string;
+ if (type === 's') value = shared[Number(v)] ?? '';
+ else if (type === 'inlineStr') value = cell(all(c, 't').map((t) => t.textContent ?? '').join(''));
+ else if (type === 'b') value = v === '1' ? 'TRUE' : v === '0' ? 'FALSE' : v;
+ else if ((!type || type === 'n') && v && dates.has(Number(c.getAttribute('s') ?? 0))) value = excelDate(Number(v), date1904) ?? v;
+ else value = cell(v);
+ while (cells.length < col) cells.push('');
+ cells[col] = value;
+ }
+ const line = csvRow(cells);
+ if (line && !sink.line(line)) break;
+ }
+ }
+ return { status: 'ok', text: sink.text(), truncated: truncated || sink.full, sheets: names };
+}
+
+// ─── PowerPoint (pptx) ───────────────────────────────────────
+
+/** The slides in the order the deck shows them (sldIdLst), else by their file numbers. */
+async function slidePaths(zip: Unpacker, presentation: string): Promise {
+ const pres = await zip.xml(presentation);
+ if (pres) {
+ const rels = await relationsOf(zip, presentation);
+ const ordered = all(pres, 'sldId').map((s) => rels.get(relId(s) ?? '')?.target).filter((p): p is string => !!p);
+ if (ordered.length) return ordered;
+ }
+ return zip.entries
+ .map((e) => ({ name: e.name, n: Number(/^ppt\/slides\/slide(\d+)\.xml$/.exec(e.name)?.[1]) }))
+ .filter((s) => Number.isFinite(s.n))
+ .sort((a, b) => a.n - b.n)
+ .map((s) => s.name);
+}
+
+async function pptx(zip: Unpacker, sink: TextSink): Promise {
+ const presentation = await mainPart(zip, 'ppt/presentation.xml');
+ const slides = await slidePaths(zip, presentation);
+ if (!slides.length && !zip.has(presentation)) return { status: 'failed', text: '', truncated: false };
+ for (const [i, slide] of slides.entries()) {
+ if (sink.full) break;
+ const doc = await zip.xml(slide);
+ if (!doc) continue;
+ if (i > 0) sink.line('');
+ sink.line(`Slide ${i + 1}`);
+ for (const p of without(doc.documentElement, 'p')) {
+ const text = all(p, 't').map((t) => t.textContent ?? '').join('').trim();
+ if (text && !sink.line(text)) break;
+ }
+ }
+ return { status: 'ok', text: sink.text(), truncated: sink.full };
+}
+
+// ─── OpenDocument (odt, ods, odp) ────────────────────────────
+
+/** A paragraph's text: spans, spaces (text:s), tabs and line breaks; notes left out. */
+function odfRuns(el: Element): string {
+ let out = '';
+ for (const node of Array.from(el.childNodes)) {
+ if (node.nodeType === 3) { out += node.nodeValue ?? ''; continue; }
+ if (node.nodeType !== 1) continue;
+ const c = node as Element;
+ switch (c.localName) {
+ case 's': out += ' '.repeat(Math.min(100, Math.max(1, Number(attr(c, 'c')) || 1))); break;
+ case 'tab': out += '\t'; break;
+ case 'line-break': out += '\n'; break;
+ case 'note': case 'annotation': break;
+ default: out += odfRuns(c);
+ }
+ }
+ return out;
+}
+
+/** Most columns of one row read. */
+const MAX_COLUMNS = 1000;
+
+/**
+ * The rows of a table:table, repeated rows and cells expanded, handed to
+ * `onRow` one by one until it answers false or MAX_SHEET_ROWS are done.
+ * A row's cells stop once their text passes `rowChars`, so one row never
+ * holds more than the read can use. True when rows were left over.
+ */
+function odfTableRows(table: Element, rowChars: number, onRow: (cells: string[]) => boolean): boolean {
+ let rows = 0;
+ for (const row of all(table, 'table-row')) {
+ // Only rows of this table, not of a table nested in a cell.
+ let owner: Element | null = row.parentElement;
+ while (owner && owner.localName !== 'table') owner = owner.parentElement;
+ if (owner !== table) continue;
+ const cells: string[] = [];
+ let chars = 0;
+ for (const td of kids(row).filter((c) => c.localName === 'table-cell' || c.localName === 'covered-table-cell')) {
+ if (chars > rowChars || cells.length >= MAX_COLUMNS) break;
+ const text = cell(all(td, 'p').map(odfRuns).join('\n').trim());
+ const repeat = Math.max(1, Number(attr(td, 'number-columns-repeated')) || 1);
+ // A run of empty cells to the sheet's edge says "repeated 16384 times".
+ for (let i = 0; i < repeat && cells.length < MAX_COLUMNS && chars <= rowChars; i++) {
+ cells.push(text);
+ chars += text.length + 1;
+ }
+ }
+ const repeatRows = Math.max(1, Number(attr(row, 'number-rows-repeated')) || 1);
+ const empty = cells.every((c) => !c);
+ for (let i = 0; i < (empty ? 1 : repeatRows); i++) {
+ if (rows >= MAX_SHEET_ROWS) return true;
+ rows++;
+ if (!onRow(cells)) return true;
+ }
+ }
+ return false;
+}
+
+function odfBlocks(el: Element, out: TextSink): void {
+ for (const c of Array.from(el.children)) {
+ if (out.full) return;
+ if (c.localName === 'p' || c.localName === 'h') out.line(odfRuns(c));
+ else if (c.localName === 'table') {
+ odfTableRows(c, out.budget, (row) => {
+ const line = row.filter(Boolean).join(' | ');
+ return line ? out.line(line) : true;
+ });
+ out.line('');
+ } else if (c.localName !== 'tracked-changes' && c.localName !== 'sequence-decls') odfBlocks(c, out);
+ }
+}
+
+async function odf(zip: Unpacker, format: 'odt' | 'ods' | 'odp', sink: TextSink): Promise {
+ const manifest = await zip.bytes('META-INF/manifest.xml');
+ if (manifest && /encryption-data/.test(new TextDecoder('utf-8').decode(manifest))) throw new Encrypted();
+ const doc = await zip.xml('content.xml');
+ if (!doc) return { status: 'failed', text: '', truncated: false };
+ if (format === 'odt') {
+ const body = all(doc, 'text')[0];
+ if (body) odfBlocks(body, sink);
+ return { status: 'ok', text: sink.text(), truncated: sink.full };
+ }
+ if (format === 'ods') {
+ const sheet = all(doc, 'spreadsheet')[0];
+ const tables = sheet ? kids(sheet, 'table') : [];
+ const names: string[] = [];
+ let truncated = tables.length > MAX_SHEETS;
+ for (const table of tables.slice(0, MAX_SHEETS)) {
+ if (sink.full) break;
+ const name = attr(table, 'name') ?? `Sheet ${names.length + 1}`;
+ names.push(name);
+ if (names.length > 1) sink.line('');
+ sink.line(`Sheet: ${name}`);
+ const more = odfTableRows(table, sink.budget, (row) => {
+ const line = csvRow(row);
+ return line ? sink.line(line) : true;
+ });
+ if (more) truncated = true;
+ }
+ return { status: 'ok', text: sink.text(), truncated: truncated || sink.full, sheets: names };
+ }
+ const pres = all(doc, 'presentation')[0];
+ for (const [i, page] of (pres ? kids(pres, 'page') : []).entries()) {
+ if (sink.full) break;
+ if (i > 0) sink.line('');
+ sink.line(`Slide ${i + 1}`);
+ for (const p of all(page, 'p')) {
+ const text = odfRuns(p).trim();
+ if (text && !sink.line(text)) break;
+ }
+ }
+ return { status: 'ok', text: sink.text(), truncated: sink.full };
+}
+
+// ─── Entry point ─────────────────────────────────────────────
+
+const CFB_MAGIC = [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1];
+
+/** True for an OLE compound file: legacy .doc/.xls/.ppt, or an Office file with a password. */
+export function isCompoundFile(bytes: Uint8Array): boolean {
+ return CFB_MAGIC.every((b, i) => bytes[i] === b);
+}
+
+/** The UTF-16LE bytes of `name`, as a compound file's directory stores stream names. */
+function utf16(name: string): number[] {
+ return [...name].flatMap((c) => [c.charCodeAt(0), 0]);
+}
+const ENCRYPTED_STREAMS = [utf16('EncryptedPackage'), utf16('EncryptionInfo')];
+
+function includesBytes(haystack: Uint8Array, needle: number[]): boolean {
+ const first = needle[0];
+ for (let i = haystack.indexOf(first); i !== -1 && i <= haystack.length - needle.length; i = haystack.indexOf(first, i + 1)) {
+ let j = 1;
+ while (j < needle.length && haystack[i + j] === needle[j]) j++;
+ if (j === needle.length) return true;
+ }
+ return false;
+}
+
+/** A compound file holding an encrypted Office package (a docx, xlsx or pptx with a password). */
+export function isEncryptedPackage(bytes: Uint8Array): boolean {
+ return ENCRYPTED_STREAMS.some((name) => includesBytes(bytes, name));
+}
+
+/**
+ * The text of an Office file. A password-protected docx, xlsx or pptx is a
+ * compound file holding an EncryptedPackage stream: encrypted; any other
+ * compound file is a legacy .doc/.xls/.ppt under a new name: unsupported.
+ */
+export async function officeText(bytes: Uint8Array, format: OfficeFormat, options: OfficeOptions = {}): Promise {
+ const { budget = MAX_TEXT_CHARS + TEXT_BUDGET_SLACK, inflateCap = MAX_INFLATED_BYTES, partCap = MAX_XML_PART_BYTES } = options;
+ if (isCompoundFile(bytes)) return { status: isEncryptedPackage(bytes) ? 'encrypted' : 'unsupported', text: '', truncated: false };
+ try {
+ const file = new Blob([bytes as BlobPart]);
+ const zip = new Unpacker(file, await listZipEntries(file), inflateCap, partCap);
+ const sink = new TextSink(budget);
+ switch (format) {
+ case 'docx': return await docx(zip, sink);
+ case 'xlsx': return await xlsx(zip, sink);
+ case 'pptx': return await pptx(zip, sink);
+ default: return await odf(zip, format, sink);
+ }
+ } catch (err) {
+ if (err instanceof TooBig) return { status: 'too_big', text: '', truncated: false };
+ if (err instanceof Encrypted) return { status: 'encrypted', text: '', truncated: false };
+ if (err instanceof ZipFormatError && /Encrypted entry/.test(err.message)) return { status: 'encrypted', text: '', truncated: false };
+ return { status: 'failed', text: '', truncated: false };
+ }
+}
diff --git a/lib/attachment-text/pdf.ts b/lib/attachment-text/pdf.ts
new file mode 100644
index 000000000..ba51eac06
--- /dev/null
+++ b/lib/attachment-text/pdf.ts
@@ -0,0 +1,103 @@
+// Text out of a PDF with pdf.js, the same library and worker the Files
+// viewer loads. Imported on demand, so nothing is fetched until a PDF is
+// read. Runs in the app page (the worker is a same-origin file; the CSP
+// refuses blob: workers).
+
+import { liteMountedAssetUrl } from '@/lib/lite';
+import { MAX_PDF_PAGES, MAX_TEXT_CHARS, MIN_PDF_CHARS_PER_PAGE, TEXT_BUDGET_SLACK, type ExtractedText } from './types';
+import { TextSink } from './sink';
+
+/** The part of pdf.js used here (tests pass a stand-in). */
+export interface PdfLib {
+ getDocument(src: { data: Uint8Array; isEvalSupported?: boolean }): { promise: Promise; destroy(): Promise };
+}
+interface PdfDoc {
+ numPages: number;
+ getPage(n: number): Promise<{ getTextContent(): Promise<{ items: unknown[] }> }>;
+}
+
+/** Longest a PDF may take to read before the read gives up. */
+export const PDF_TIMEOUT_MS = 30_000;
+
+async function loadPdfJs(): Promise {
+ const pdfjs = await import('pdfjs-dist');
+ pdfjs.GlobalWorkerOptions.workerSrc = liteMountedAssetUrl(new URL(
+ 'pdfjs-dist/build/pdf.worker.min.mjs',
+ import.meta.url,
+ ).toString());
+ return pdfjs as unknown as PdfLib;
+}
+
+let loader: () => Promise = loadPdfJs;
+
+/** Swap the pdf.js loader (tests). Without an argument the real one comes back. */
+export function setPdfLoader(next?: () => Promise): void {
+ loader = next ?? loadPdfJs;
+}
+
+/** The text items of one page as lines: a new line where pdf.js marks one. */
+function pageText(items: unknown[]): string {
+ let out = '';
+ for (const item of items) {
+ if (!item || typeof item !== 'object' || !('str' in item)) continue;
+ const { str, hasEOL } = item as { str: unknown; hasEOL?: unknown };
+ if (typeof str === 'string') out += str;
+ if (hasEOL === true) out += '\n';
+ else if (typeof str === 'string' && str && !/\s$/.test(str)) out += ' ';
+ }
+ return out.replace(/[ \t]+\n/g, '\n').replace(/[ \t]{2,}/g, ' ').trim();
+}
+
+class TimedOut extends Error {}
+
+export interface PdfOptions {
+ /** Characters to read at most; reading stops at the page that fills it. */
+ budget?: number;
+ /** Longest the read may take (default PDF_TIMEOUT_MS). */
+ timeoutMs?: number;
+}
+
+/**
+ * The text of at most MAX_PDF_PAGES pages, a "Page n" line before each,
+ * stopping once `budget` characters are read. no_text when no page has
+ * MIN_PDF_CHARS_PER_PAGE characters (a scan; a typed cover letter in front
+ * of one is read); encrypted for a PDF that needs a password; failed after
+ * `timeoutMs`. Scripts in the PDF are never evaluated.
+ */
+export async function pdfText(bytes: Uint8Array, { budget = MAX_TEXT_CHARS + TEXT_BUDGET_SLACK, timeoutMs = PDF_TIMEOUT_MS }: PdfOptions = {}): Promise> {
+ const lib = await loader();
+ // pdf.js may take over the buffer: give it a copy.
+ const task = lib.getDocument({ data: bytes.slice(), isEvalSupported: false });
+ let timer: ReturnType | undefined;
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new TimedOut()), timeoutMs); });
+ const read = async (): Promise> => {
+ const doc = await task.promise;
+ const pages = doc.numPages;
+ const last = Math.min(pages, MAX_PDF_PAGES);
+ const sink = new TextSink(budget);
+ let readable = false;
+ let n = 0;
+ while (n < last && !sink.full) {
+ n++;
+ const page = await doc.getPage(n);
+ const text = pageText((await page.getTextContent()).items);
+ if (text.replace(/\s/g, '').length >= MIN_PDF_CHARS_PER_PAGE) readable = true;
+ if (!text) continue;
+ if (pages > 1) sink.line(`${n > 1 ? '\n' : ''}Page ${n}`);
+ sink.line(text);
+ }
+ if (!readable) return { status: 'no_text', text: '', truncated: false, pages };
+ return { status: 'ok', text: sink.text(), truncated: sink.full || n < pages, pages };
+ };
+ try {
+ return await Promise.race([read(), timeout]);
+ } catch (err) {
+ const name = (err as { name?: unknown } | null)?.name;
+ if (name === 'PasswordException') return { status: 'encrypted', text: '', truncated: false };
+ return { status: 'failed', text: '', truncated: false };
+ } finally {
+ clearTimeout(timer);
+ // Also stops a read still running after the timeout.
+ void task.destroy().catch(() => {});
+ }
+}
diff --git a/lib/attachment-text/plain.ts b/lib/attachment-text/plain.ts
new file mode 100644
index 000000000..008098939
--- /dev/null
+++ b/lib/attachment-text/plain.ts
@@ -0,0 +1,157 @@
+// Text out of the formats that already are text: plain text, CSV, HTML,
+// calendar files (ICS) and address cards (VCF).
+
+import { htmlToPlainText } from '@/lib/html-to-text';
+import { parseVCard } from '@/lib/vcard';
+import type { ContactCard } from '@/lib/jmap/types';
+
+/**
+ * Bytes as text: UTF-8 (with or without a byte order mark), UTF-16 with a
+ * byte order mark, else Windows-1252, which never fails.
+ */
+export function decodeText(bytes: Uint8Array): string {
+ if (bytes[0] === 0xff && bytes[1] === 0xfe) return new TextDecoder('utf-16le').decode(bytes.subarray(2));
+ if (bytes[0] === 0xfe && bytes[1] === 0xff) return new TextDecoder('utf-16be').decode(bytes.subarray(2));
+ try {
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: false }).decode(bytes);
+ } catch {
+ return new TextDecoder('windows-1252').decode(bytes);
+ }
+}
+
+/** True when the bytes look like text rather than a binary file (no NUL bytes in the first 4 KB). */
+export function looksLikeText(bytes: Uint8Array): boolean {
+ const head = bytes.subarray(0, 4096);
+ if (head[0] === 0xff && head[1] === 0xfe) return true;
+ if (head[0] === 0xfe && head[1] === 0xff) return true;
+ return !head.includes(0);
+}
+
+/** Characters of HTML read at most; the rest of a larger file is left out. */
+export const MAX_HTML_CHARS = 5_000_000;
+
+/**
+ * The readable text of an HTML file: its body without scripts, styles and
+ * templates. Parsed with DOMParser (linear in the file, whatever its tags);
+ * list markers left out, since numbering them is slow on long lists.
+ */
+export function htmlText(html: string): string {
+ const doc = new DOMParser().parseFromString(html, 'text/html');
+ for (const el of Array.from(doc.querySelectorAll('script, style, noscript, template'))) el.remove();
+ return htmlToPlainText(doc.body?.innerHTML ?? '', { paragraphSpacing: true, imageAlt: (alt) => `[Image: ${alt}]` });
+}
+
+// ─── ICS ─────────────────────────────────────────────────────
+
+interface IcsLine { name: string; params: Record; value: string }
+
+function unfold(text: string): string[] {
+ return text.replace(/\r\n?/g, '\n').replace(/\n[ \t]/g, '').split('\n');
+}
+
+function parseIcsLine(line: string): IcsLine | null {
+ // NAME;PARAM=a;PARAM="b:c":value. A colon inside a quoted parameter is not the separator.
+ let quoted = false;
+ let colon = -1;
+ for (let i = 0; i < line.length; i++) {
+ const ch = line[i];
+ if (ch === '"') quoted = !quoted;
+ else if (ch === ':' && !quoted) { colon = i; break; }
+ }
+ if (colon < 0) return null;
+ const [name, ...rawParams] = line.slice(0, colon).split(';');
+ const params: Record = {};
+ for (const p of rawParams) {
+ const eq = p.indexOf('=');
+ if (eq > 0) params[p.slice(0, eq).toUpperCase()] = p.slice(eq + 1).replace(/^"|"$/g, '');
+ }
+ return { name: name.toUpperCase(), params, value: line.slice(colon + 1) };
+}
+
+function icsText(value: string): string {
+ return value.replace(/\\[nN]/g, ' ').replace(/\\([,;\\])/g, '$1').trim();
+}
+
+/** 20261105T090000Z → "2026-11-05 09:00 UTC"; with TZID the zone in brackets; a date alone stays a date. */
+function icsWhen(line: IcsLine | undefined): string {
+ if (!line) return '';
+ const m = /^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})\d{0,2}(Z?))?$/.exec(line.value.trim());
+ if (!m) return line.value.trim();
+ const date = `${m[1]}-${m[2]}-${m[3]}`;
+ if (!m[4]) return date;
+ const zone = m[6] ? ' UTC' : line.params.TZID ? ` (${line.params.TZID})` : '';
+ return `${date} ${m[4]}:${m[5]}${zone}`;
+}
+
+function organiser(line: IcsLine | undefined): string {
+ if (!line) return '';
+ const address = line.value.replace(/^mailto:/i, '').trim();
+ const name = line.params.CN?.trim();
+ return name && address ? `${name} <${address}>` : (name || address);
+}
+
+/** One line per event and task: "event: title, start, end, location, organiser …". */
+export function icsToText(ics: string): string {
+ const out: string[] = [];
+ let current: Map | null = null;
+ let kind = '';
+ // Inside a component of the event (an alarm): its properties aren't the event's.
+ let nested = 0;
+ for (const raw of unfold(ics)) {
+ const line = parseIcsLine(raw);
+ if (!line) continue;
+ if (current && line.name === 'BEGIN') { nested++; continue; }
+ if (current && nested > 0) {
+ if (line.name === 'END') nested--;
+ continue;
+ }
+ if (line.name === 'BEGIN' && /^(VEVENT|VTODO)$/i.test(line.value.trim())) {
+ current = new Map();
+ kind = line.value.trim().toUpperCase() === 'VTODO' ? 'task' : 'event';
+ continue;
+ }
+ if (line.name === 'END' && current && /^(VEVENT|VTODO)$/i.test(line.value.trim())) {
+ const parts = [
+ icsText(current.get('SUMMARY')?.value ?? '') || '(no title)',
+ icsWhen(current.get('DTSTART')),
+ icsWhen(current.get('DTEND') ?? current.get('DUE')),
+ icsText(current.get('LOCATION')?.value ?? ''),
+ ];
+ const org = organiser(current.get('ORGANIZER'));
+ if (org) parts.push(`organiser ${org}`);
+ out.push(`${kind}: ${parts.filter(Boolean).join(', ')}`);
+ const description = icsText(current.get('DESCRIPTION')?.value ?? '');
+ if (description) out.push(` ${description}`);
+ current = null;
+ continue;
+ }
+ if (current && !current.has(line.name)) current.set(line.name, line);
+ }
+ return out.join('\n');
+}
+
+// ─── VCF ─────────────────────────────────────────────────────
+
+function cardName(card: ContactCard): string {
+ const full = card.name?.full?.trim();
+ if (full) return full;
+ return (card.name?.components ?? [])
+ .filter((c) => c.kind !== 'separator')
+ .map((c) => c.value)
+ .join(' ')
+ .trim();
+}
+
+/** One block per card: name, email addresses, phone numbers, organisation. */
+export function vcfText(vcf: string): string {
+ return parseVCard(vcf).map((card) => {
+ const lines = [`contact: ${cardName(card) || '(no name)'}`];
+ const emails = Object.values(card.emails ?? {}).map((e) => e.address).filter(Boolean);
+ const phones = Object.values(card.phones ?? {}).map((p) => p.number).filter(Boolean);
+ const orgs = Object.values(card.organizations ?? {}).map((o) => o.name).filter(Boolean);
+ if (emails.length) lines.push(` email: ${emails.join(', ')}`);
+ if (phones.length) lines.push(` phone: ${phones.join(', ')}`);
+ if (orgs.length) lines.push(` organisation: ${orgs.join(', ')}`);
+ return lines.join('\n');
+ }).join('\n');
+}
diff --git a/lib/attachment-text/sink.ts b/lib/attachment-text/sink.ts
new file mode 100644
index 000000000..891417197
--- /dev/null
+++ b/lib/attachment-text/sink.ts
@@ -0,0 +1,28 @@
+// Where a reader writes its lines: it takes text until the read's character
+// budget is used up, then says it is full, so a file whose few bytes stand
+// for millions of characters (repeated cells, a long PDF) is never copied
+// out in full before the final cut.
+
+export class TextSink {
+ private readonly lines: string[] = [];
+ private used = 0;
+ /** The budget is used up: whatever comes now is dropped, and the text is cut. */
+ full = false;
+
+ constructor(readonly budget: number) {}
+
+ /** Adds one line (cut to what is left); false once the budget is used up. */
+ line(text: string): boolean {
+ if (this.full) return false;
+ const room = this.budget - this.used;
+ const kept = text.length > room ? text.slice(0, room) : text;
+ this.lines.push(kept);
+ this.used += kept.length + 1;
+ if (kept.length < text.length || this.used >= this.budget) this.full = true;
+ return !this.full;
+ }
+
+ text(): string {
+ return this.lines.map((l) => l.replace(/[ \t]+$/g, '')).join('\n').replace(/\n{3,}/g, '\n\n').trim();
+ }
+}
diff --git a/lib/attachment-text/types.ts b/lib/attachment-text/types.ts
new file mode 100644
index 000000000..2bb751e3b
--- /dev/null
+++ b/lib/attachment-text/types.ts
@@ -0,0 +1,60 @@
+// Shapes and limits of the text extraction for attachments and files.
+// Pure: no imports from the app, so the plugin host and Files can share it.
+
+/** What kind of file the text came from. */
+export type TextFormat =
+ | 'text' | 'csv' | 'html' | 'ics' | 'vcf' | 'pdf'
+ | 'docx' | 'xlsx' | 'pptx' | 'odt' | 'ods' | 'odp' | 'other';
+
+/**
+ * ok: `text` holds the text. too_big: over the size limit, not read.
+ * unsupported: a format without a reader (images, legacy .doc/.xls/.ppt,
+ * archives). no_text: nothing to read, e.g. a scanned PDF. encrypted:
+ * protected by a password. failed: the file is damaged or reading broke.
+ */
+export type TextStatus = 'ok' | 'too_big' | 'unsupported' | 'no_text' | 'encrypted' | 'failed';
+
+export interface ExtractedText {
+ format: TextFormat;
+ status: TextStatus;
+ /** The text, '' unless status is ok. */
+ text: string;
+ /** True when the text was cut at maxChars, or a PDF or sheet had more than was read. */
+ truncated: boolean;
+ /** PDFs: the number of pages in the file. */
+ pages?: number;
+ /** Spreadsheets: the names of the sheets read. */
+ sheets?: string[];
+}
+
+export interface ExtractOptions {
+ /** File name; its extension picks the format first. */
+ name?: string;
+ /** Media type, used when the name says nothing. */
+ type?: string;
+ /** Longest text returned (default DEFAULT_TEXT_CHARS, at most MAX_TEXT_CHARS). */
+ maxChars?: number;
+}
+
+/** Text returned when the caller doesn't ask for a length. */
+export const DEFAULT_TEXT_CHARS = 20_000;
+/** Longest text one read returns. */
+export const MAX_TEXT_CHARS = 100_000;
+/** Largest file read for its text. */
+export const MAX_TEXT_SOURCE_BYTES = 20 * 1024 * 1024;
+/** Pages of a PDF read at most. */
+export const MAX_PDF_PAGES = 50;
+/** Fewer characters per page than this on average: the PDF has no text layer. */
+export const MIN_PDF_CHARS_PER_PAGE = 20;
+/** Sheets of a spreadsheet read at most. */
+export const MAX_SHEETS = 5;
+/** Rows per sheet read at most. */
+export const MAX_SHEET_ROWS = 2_000;
+/** Bytes an Office file may unpack to, all entries read together (zip bombs). */
+export const MAX_INFLATED_BYTES = 50 * 1024 * 1024;
+/** Bytes one XML part of an Office file may unpack to: DOMParser holds it all in memory. */
+export const MAX_XML_PART_BYTES = 12 * 1024 * 1024;
+/** Characters of one spreadsheet cell, after which it is cut. */
+export const MAX_CELL_CHARS = 4_000;
+/** Readers stop this many characters past maxChars, so the final cut can end at a word. */
+export const TEXT_BUDGET_SLACK = 2_000;
diff --git a/lib/calendar-rsvp.ts b/lib/calendar-rsvp.ts
new file mode 100644
index 000000000..edb679482
--- /dev/null
+++ b/lib/calendar-rsvp.ts
@@ -0,0 +1,158 @@
+// Answering a calendar invitation: the reader's invitation banner and the
+// plugin call calendar.respond take the same steps, here.
+//
+// The iMIP REPLY to the organiser is sent by the server: rsvpEvent and
+// updateEvent pass sendSchedulingMessages=true to CalendarEvent/set and
+// Stalwart queues the iTIP REPLY itself. A reply sent from here as well
+// produced duplicate emails.
+
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { CalendarEvent, CalendarParticipant, Email } from '@/lib/jmap/types';
+import {
+ extractMethodFromRawIcs, findCalendarAttachment, findParticipantByEmails, getInvitationMethod,
+ type InvitationMethod,
+} from '@/lib/calendar-invitation';
+
+export type RsvpStatus = 'accepted' | 'tentative' | 'declined';
+export type ParticipantMatch = { id: string; participant: CalendarParticipant };
+
+/** The calendar store's calls the answer goes through (so plugin hooks run and the calendar shows it). */
+export interface RsvpStore {
+ rsvpEvent: (client: IJMAPClient, eventId: string, participantId: string, status: string, replyTo?: Record | null, scope?: 'occurrence' | 'series', options?: { sentBy?: string }) => Promise;
+ updateEvent: (client: IJMAPClient, id: string, updates: Partial, sendSchedulingMessages?: boolean) => Promise;
+ importEvents: (client: IJMAPClient, events: Partial[], calendarId: string, options?: { throwIfNoneImported?: boolean }) => Promise;
+ /** The store's events right now (read after an import). */
+ events: () => CalendarEvent[];
+}
+
+export interface RsvpRequest {
+ client: IJMAPClient;
+ store: RsvpStore;
+ /** The invitation as parsed from the message. */
+ parsedEvent: Partial;
+ /** The copy in the calendar, if it is there. */
+ existingEvent: CalendarEvent | null | undefined;
+ /** The user's participant as found before (stored copy first, else the invitation). */
+ myParticipant: ParticipantMatch | null;
+ /** The addresses that pick "your" participant. */
+ actingEmails: readonly string[];
+ /** The calendar a new copy goes into. */
+ calendarId: string | undefined;
+ /** The shared or group account the message belongs to, else null. */
+ sharedMailAccount: string | null;
+ /** Answering for a person as their delegate: SENT-BY names the user. */
+ answerOptions?: { sentBy: string };
+ /** The stored copy with its participants loaded (or the shared account's copy). */
+ resolveExisting: () => Promise;
+ /**
+ * The copy an import just made, from the server, when the store doesn't
+ * hold it (an event outside the range the calendar has loaded).
+ */
+ findImported?: (uid: string) => Promise;
+}
+
+/**
+ * What came of an answer: sent; only added to the calendar (the user isn't
+ * in its participants); stopped by a plugin (which said why); or failed.
+ */
+export type RsvpOutcome = 'answered' | 'added' | 'stopped' | 'failed';
+
+function buildParticipantsForRsvp(
+ event: Partial,
+ participantId: string,
+ status: RsvpStatus,
+): Record[string]> | null {
+ if (!event.participants) return null;
+ return Object.fromEntries(
+ Object.entries(event.participants).map(([id, participant]) => [
+ id,
+ { ...participant, participationStatus: id === participantId ? status : participant.participationStatus },
+ ]),
+ );
+}
+
+/**
+ * Answer the invitation: on the stored copy when the user is among its
+ * participants; by repairing a stored copy that lost its participants; or by
+ * adding the invitation to the calendar first and answering on the new copy.
+ * Throws what the server refused (said to the user as it is).
+ */
+export async function answerInvitation(req: RsvpRequest, status: RsvpStatus): Promise {
+ const { client, store, parsedEvent, existingEvent, myParticipant, actingEmails, calendarId, sharedMailAccount, answerOptions } = req;
+ const replyTo = parsedEvent.replyTo
+ || (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
+ const rsvp = (eventId: string, participantId: string) => (answerOptions
+ ? store.rsvpEvent(client, eventId, participantId, status, replyTo, undefined, answerOptions)
+ : store.rsvpEvent(client, eventId, participantId, status, replyTo));
+
+ // A copy in a shared or group account may not be loaded yet: look it up
+ // there before adding the invitation again.
+ const eventForRsvp = existingEvent || sharedMailAccount ? await req.resolveExisting() : null;
+ const existingParticipant = eventForRsvp ? findParticipantByEmails(eventForRsvp, actingEmails) : null;
+ const canFallbackToParsedParticipant = Boolean(
+ existingEvent && myParticipant
+ && (!eventForRsvp?.participants || Object.keys(eventForRsvp.participants).length === 0),
+ );
+
+ if (eventForRsvp && existingParticipant) {
+ // false: a plugin stopped it and said why; keep the old answer.
+ return (await rsvp(eventForRsvp.id, existingParticipant.id)) ? 'answered' : 'stopped';
+ }
+ if (eventForRsvp && canFallbackToParsedParticipant && myParticipant) {
+ const repaired = buildParticipantsForRsvp(parsedEvent, myParticipant.id, status);
+ if (!repaired) return 'failed';
+ const updated = await store.updateEvent(client, eventForRsvp.id, {
+ participants: repaired,
+ // Stalwart routes the iTIP REPLY via the stored ORGANIZER
+ // (organizerCalendarAddress; the RFC 8984 replyTo is retired).
+ // Only repair a missing organizer - attendees may not modify it.
+ ...(replyTo?.imip && !eventForRsvp.organizerCalendarAddress ? { organizerCalendarAddress: replyTo.imip } : {}),
+ }, true);
+ return updated ? 'answered' : 'stopped';
+ }
+ if (eventForRsvp || !calendarId) return 'failed';
+
+ // A refusal (Stalwart 1.0's participant limit, say) is thrown as it is.
+ const imported = await store.importEvents(client, [parsedEvent], calendarId, { throwIfNoneImported: true });
+ if (imported === null) return 'stopped';
+ if (imported <= 0) return 'failed';
+ const newEvent = store.events().find(
+ (e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared),
+ ) ?? (parsedEvent.uid && req.findImported ? await req.findImported(parsedEvent.uid) : null);
+ const participant = myParticipant || (newEvent ? findParticipantByEmails(newEvent, actingEmails) : null);
+ if (!newEvent || !participant) return 'added';
+ return (await rsvp(newEvent.id, participant.id)) ? 'answered' : 'stopped';
+}
+
+/** The user's participant, unless the user only organises the event (then no answer is due). */
+export function attendeeOf(match: ParticipantMatch | null): { participant: ParticipantMatch | null; isOrganizer: boolean } {
+ const roles = match?.participant.roles;
+ const isOrganizer = roles ? Boolean((roles.owner || roles.chair) && !roles.attendee) : false;
+ return { participant: match && !isOrganizer ? match : null, isOrganizer };
+}
+
+/** The invitation in a message of the user's own account, read without the banner. */
+export interface OwnInvitation {
+ parsedEvent: Partial;
+ method: InvitationMethod;
+}
+
+/**
+ * Parse the invitation a message of the signed-in account carries: its
+ * first event and its METHOD (from the message, else the raw ICS, since
+ * JMAP drops the Content-Type parameters). Null when it carries none.
+ */
+export async function readOwnInvitation(client: IJMAPClient, email: Email): Promise {
+ const attachment = findCalendarAttachment(email);
+ if (!attachment) return null;
+ const accountId = client.getAccountId();
+ const [events, rawText] = await Promise.all([
+ client.parseCalendarEvents(accountId, attachment.blobId),
+ client.fetchBlob(attachment.blobId, 'invite.ics', 'text/calendar', accountId).then((b) => b.text()).catch(() => null),
+ ]);
+ const parsedEvent = events[0];
+ if (!parsedEvent) return null;
+ let method = getInvitationMethod(parsedEvent, { email, attachment });
+ if (method === 'unknown' && rawText) method = extractMethodFromRawIcs(rawText);
+ return { parsedEvent, method };
+}
diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts
index 0eaa08e6b..8a9377ec4 100644
--- a/lib/demo/demo-client.ts
+++ b/lib/demo/demo-client.ts
@@ -1702,6 +1702,13 @@ export class DemoJMAPClient implements IJMAPClient {
if (node) Object.assign(node, updates);
}
+ async replaceFileNodeBlob(id: string, blobId: string): Promise {
+ const node = this.data.fileNodes.find(n => n.id === id);
+ if (!node) throw new Error('No such file');
+ const blob = this.blobStore.get(blobId);
+ Object.assign(node, { blobId, ...(blob ? { size: blob.size } : {}), modified: new Date().toISOString() });
+ }
+
async updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }> {
const updated: string[] = [];
for (const [id, patch] of Object.entries(updates)) {
diff --git a/lib/exif.ts b/lib/exif.ts
new file mode 100644
index 000000000..4c5a0cf7c
--- /dev/null
+++ b/lib/exif.ts
@@ -0,0 +1,145 @@
+// The date and place a photo was taken, from the EXIF block of a JPEG
+// (APP1 "Exif"): DateTimeOriginal and the GPS position. Nothing else is
+// read. Never throws: a damaged block gives what could be read, or nothing.
+
+export interface PhotoExif {
+ /** When the photo was taken, "2026-10-31T14:05:00" (with "+01:00" when the camera wrote its offset). */
+ taken?: string;
+ /** Latitude and longitude in degrees, north and east positive, five decimals. */
+ lat?: number;
+ lon?: number;
+}
+
+const TAG_EXIF_IFD = 0x8769;
+const TAG_GPS_IFD = 0x8825;
+const TAG_DATETIME_ORIGINAL = 0x9003;
+const TAG_OFFSET_TIME_ORIGINAL = 0x9011;
+const GPS_LAT_REF = 1;
+const GPS_LAT = 2;
+const GPS_LON_REF = 3;
+const GPS_LON = 4;
+
+/** The TIFF block inside the first APP1 Exif segment, or null. */
+function exifBlock(bytes: Uint8Array): DataView | null {
+ if (bytes[0] !== 0xff || bytes[1] !== 0xd8) return null;
+ let at = 2;
+ while (at + 4 <= bytes.length) {
+ if (bytes[at] !== 0xff) return null;
+ const marker = bytes[at + 1];
+ // Start of scan, end of image: no more headers.
+ if (marker === 0xda || marker === 0xd9) return null;
+ const length = (bytes[at + 2] << 8) | bytes[at + 3];
+ if (length < 2) return null;
+ if (marker === 0xe1 && at + 10 <= bytes.length
+ && bytes[at + 4] === 0x45 && bytes[at + 5] === 0x78 && bytes[at + 6] === 0x69 && bytes[at + 7] === 0x66 && bytes[at + 8] === 0 && bytes[at + 9] === 0) {
+ const start = at + 10;
+ const end = Math.min(bytes.length, at + 2 + length);
+ return new DataView(bytes.buffer, bytes.byteOffset + start, Math.max(0, end - start));
+ }
+ at += 2 + length;
+ }
+ return null;
+}
+
+interface Entry { type: number; count: number; valueAt: number }
+
+class Tiff {
+ readonly little: boolean;
+ constructor(readonly view: DataView) {
+ const order = view.getUint16(0);
+ if (order !== 0x4949 && order !== 0x4d4d) throw new Error('not TIFF');
+ this.little = order === 0x4949;
+ }
+ u16(at: number): number { return this.view.getUint16(at, this.little); }
+ u32(at: number): number { return this.view.getUint32(at, this.little); }
+ firstIfd(): number { return this.u32(4); }
+
+ /** The entries of the IFD at `at`, by tag. */
+ ifd(at: number): Map {
+ const out = new Map();
+ if (at < 8 || at + 2 > this.view.byteLength) return out;
+ const n = this.u16(at);
+ for (let i = 0; i < n && i < 500; i++) {
+ const e = at + 2 + i * 12;
+ if (e + 12 > this.view.byteLength) break;
+ const type = this.u16(e + 2);
+ const count = this.u32(e + 4);
+ const size = ({ 1: 1, 2: 1, 3: 2, 4: 4, 5: 8, 7: 1, 9: 4, 10: 8 } as Record)[type] ?? 1;
+ // Values of 4 bytes or less sit in the entry itself, longer ones at an offset.
+ const valueAt = size * count <= 4 ? e + 8 : this.u32(e + 8);
+ out.set(this.u16(e), { type, count, valueAt });
+ }
+ return out;
+ }
+
+ ascii(entry: Entry | undefined): string | undefined {
+ if (!entry || entry.type !== 2 || entry.valueAt + entry.count > this.view.byteLength) return undefined;
+ let s = '';
+ for (let i = 0; i < entry.count; i++) {
+ const c = this.view.getUint8(entry.valueAt + i);
+ if (c === 0) break;
+ s += String.fromCharCode(c);
+ }
+ return s.trim() || undefined;
+ }
+
+ rationals(entry: Entry | undefined): number[] | undefined {
+ if (!entry || entry.type !== 5 || entry.valueAt + entry.count * 8 > this.view.byteLength) return undefined;
+ const out: number[] = [];
+ for (let i = 0; i < entry.count; i++) {
+ const den = this.u32(entry.valueAt + i * 8 + 4);
+ out.push(den === 0 ? NaN : this.u32(entry.valueAt + i * 8) / den);
+ }
+ return out;
+ }
+
+ long(entry: Entry | undefined): number | undefined {
+ if (!entry || (entry.type !== 4 && entry.type !== 3)) return undefined;
+ return entry.type === 4 ? this.u32(entry.valueAt) : this.u16(entry.valueAt);
+ }
+}
+
+/** "2026:10:31 14:05:00" → "2026-10-31T14:05:00". */
+function exifDate(value: string | undefined, offset: string | undefined): string | undefined {
+ const m = /^(\d{4}):(\d{2}):(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/.exec(value ?? '');
+ if (!m || m[1] === '0000') return undefined;
+ const zone = /^[+-]\d{2}:\d{2}$/.test(offset ?? '') ? offset : '';
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}${zone}`;
+}
+
+function degrees(parts: number[] | undefined, ref: string | undefined, negative: string): number | undefined {
+ if (!parts || parts.length < 1 || parts.some((p) => !Number.isFinite(p))) return undefined;
+ const [d, m = 0, s = 0] = parts;
+ const value = (d + m / 60 + s / 3600) * (ref?.toUpperCase() === negative ? -1 : 1);
+ return Math.round(value * 1e5) / 1e5;
+}
+
+/** The date and place in a JPEG's EXIF block; {} when there is none. */
+export function readPhotoExif(data: ArrayBuffer | Uint8Array): PhotoExif {
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
+ const out: PhotoExif = {};
+ try {
+ const view = exifBlock(bytes);
+ if (!view) return out;
+ const tiff = new Tiff(view);
+ const ifd0 = tiff.ifd(tiff.firstIfd());
+ const exifAt = tiff.long(ifd0.get(TAG_EXIF_IFD));
+ const exif = exifAt ? tiff.ifd(exifAt) : new Map();
+ // Only the date the photo was taken: IFD0's DateTime is when the file was last changed.
+ const taken = exifDate(tiff.ascii(exif.get(TAG_DATETIME_ORIGINAL)), tiff.ascii(exif.get(TAG_OFFSET_TIME_ORIGINAL)));
+ if (taken) out.taken = taken;
+ const gpsAt = tiff.long(ifd0.get(TAG_GPS_IFD));
+ if (gpsAt) {
+ const gps = tiff.ifd(gpsAt);
+ const lat = degrees(tiff.rationals(gps.get(GPS_LAT)), tiff.ascii(gps.get(GPS_LAT_REF)), 'S');
+ const lon = degrees(tiff.rationals(gps.get(GPS_LON)), tiff.ascii(gps.get(GPS_LON_REF)), 'W');
+ if (lat !== undefined && lon !== undefined && Math.abs(lat) <= 90 && Math.abs(lon) <= 180) {
+ out.lat = lat;
+ out.lon = lon;
+ }
+ }
+ } catch {
+ // What was read so far stays.
+ }
+ return out;
+}
diff --git a/lib/extension-package.ts b/lib/extension-package.ts
index 4ed0183dd..92942537f 100644
--- a/lib/extension-package.ts
+++ b/lib/extension-package.ts
@@ -442,6 +442,20 @@ export function checkPluginManifest(m: Record, opts: { appVersi
if (unknown.length > 0) errors.push(`Unknown permissions: ${unknown.join(', ')} (this Bulwark version does not offer them)`);
}
}
+ // Permissions a plugin uses when the webmail offers them: the ones this
+ // version knows are granted like "permissions" (and shown for consent the
+ // same way); unknown ones are left out, so one package installs on older
+ // and newer webmails alike and checks api.host.features for what it got.
+ if (m.optionalPermissions !== undefined) {
+ if (!Array.isArray(m.optionalPermissions) || m.optionalPermissions.some((p) => typeof p !== 'string')) {
+ errors.push('"optionalPermissions" must be a list of strings');
+ } else {
+ const asked = [...new Set(m.optionalPermissions as string[])].filter((p) => !DEPRECATED_PERMISSIONS.includes(p));
+ const unknown = asked.filter((p) => !KNOWN_PERMISSIONS.has(p));
+ if (unknown.length > 0) warnings.push(`Left out optional permissions this Bulwark version does not offer: ${unknown.join(', ')}`);
+ permissions = [...new Set([...permissions, ...asked.filter((p) => KNOWN_PERMISSIONS.has(p))])];
+ }
+ }
if (tier === 'privileged' && !permissions.includes('crypto:full')) {
errors.push('A privileged plugin must request the "crypto:full" permission');
}
diff --git a/lib/files-save.ts b/lib/files-save.ts
new file mode 100644
index 000000000..1a793bebf
--- /dev/null
+++ b/lib/files-save.ts
@@ -0,0 +1,71 @@
+// Save attachments of a message to Files: the blobs are already on the
+// server, so each is filed by blobId with no download or upload. From
+// another account of the login (a shared or group mailbox) the blob is
+// copied into the Files account first (Blob/copy, RFC 8620 §6.3).
+//
+// Used by the reader's Save to Files dialog and by the plugin host
+// (api.files.saveAttachments). Plugins hear about each file as about an
+// upload in Files: onBeforeFileUpload may stop it, onAfterFileUpload
+// reports it.
+
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { FileNode } from '@/lib/jmap/types';
+import { fileHooks } from '@/lib/plugin-hooks';
+import { fileToView } from '@/lib/plugin-projection';
+
+/** What saving needs of an attachment: its blob, name, type and size. */
+export interface SaveableBlob {
+ blobId: string;
+ name?: string | null;
+ type?: string | null;
+ size: number;
+}
+
+export interface SaveToFilesOptions {
+ /** The mail login's client; its Files account takes the files. */
+ client: IJMAPClient;
+ /** The folder to save into; null is the top of the user's own drive. */
+ parentId: string | null;
+ /** The folder's path, for plugins ('/' for the top). */
+ path: string;
+ /**
+ * The JMAP account the blobs are in, when not the Files account (a
+ * shared or group mailbox of the same login).
+ */
+ blobAccountId?: string;
+}
+
+export interface SaveToFilesResult {
+ /** The files created, with the names the server gave them. */
+ saved: FileNode[];
+ /** Names a plugin stopped (onBeforeFileUpload). */
+ stopped: string[];
+}
+
+/**
+ * Files each blob into `parentId`. A name that is taken becomes
+ * "name (2).ext" on the server. Stops at the first error and passes it on;
+ * files saved before it stay.
+ */
+export async function saveBlobsToFiles(blobs: SaveableBlob[], { client, parentId, path, blobAccountId }: SaveToFilesOptions): Promise {
+ const saved: FileNode[] = [];
+ const stopped: string[] = [];
+ const filesAccountId = blobAccountId ? client.getFilesAccountId() : undefined;
+ const copy = !!blobAccountId && !!filesAccountId && blobAccountId !== filesAccountId;
+ for (const blob of blobs) {
+ const name = blob.name || 'attachment';
+ const type = blob.type || 'application/octet-stream';
+ if (!(await fileHooks.onBeforeFileUpload.intercept({ name, size: blob.size, type, path }))) {
+ stopped.push(name);
+ continue;
+ }
+ const blobId = copy
+ ? (await client.copyBlobs(blobAccountId!, [blob.blobId], filesAccountId!))[blob.blobId]
+ : blob.blobId;
+ const node = await client.createFileNode(name, blobId, type, blob.size, parentId);
+ // The server's answer to a create may leave the size out.
+ saved.push(node.size ? node : { ...node, size: blob.size });
+ void fileHooks.onAfterFileUpload.emit(fileToView(node, path));
+ }
+ return { saved, stopped };
+}
diff --git a/lib/global-search/__tests__/providers.test.ts b/lib/global-search/__tests__/providers.test.ts
index c24e7dcfa..111bf736c 100644
--- a/lib/global-search/__tests__/providers.test.ts
+++ b/lib/global-search/__tests__/providers.test.ts
@@ -445,4 +445,21 @@ describe('files provider', () => {
expect(hits.map((h) => h.id)).toEqual(['d']);
expect(hits[0]).toMatchObject({ isFolder: true });
});
+
+ // The Files trash is a folder the app keeps (.Trash, one folder per
+ // delete): neither it nor what was deleted is a hit.
+ it('leaves the Files trash and everything in it out of the hits', async () => {
+ const listing = [
+ { id: 'zebra-live', parentId: null, name: 'zebra.txt', type: 'text/plain', blobId: 'b1', size: 1, created: '', modified: '' },
+ { id: 'trash', parentId: null, name: '.Trash', type: 'd', blobId: null, size: 0, created: '', modified: '' },
+ { id: 'batch', parentId: 'trash', name: '1730112000000~zebra', type: 'd', blobId: null, size: 0, created: '', modified: '' },
+ { id: 'zebra-gone', parentId: 'batch', name: 'zebra old.txt', type: 'text/plain', blobId: 'b2', size: 1, created: '', modified: '' },
+ ];
+ invalidateFileSearchCache();
+ const acc = account('login-t', { listAllFileNodesAcrossAccounts: vi.fn(async () => listing) });
+ const { hits } = await filesProvider.remote(parseSearchQuery('zebra'), acc, { limit: 10, signal });
+ expect(hits.map((h) => h.id)).toEqual(['zebra-live']);
+ const trash = await filesProvider.remote(parseSearchQuery('trash'), acc, { limit: 10, signal });
+ expect(trash.hits).toEqual([]);
+ });
});
diff --git a/lib/global-search/providers/files.ts b/lib/global-search/providers/files.ts
index 480345346..7388e666c 100644
--- a/lib/global-search/providers/files.ts
+++ b/lib/global-search/providers/files.ts
@@ -2,6 +2,7 @@ import { matchesTerms, type ParsedQuery } from '@/lib/global-search/query-parser
import type { FileHit, SearchAccount, SearchProvider } from '@/lib/global-search/types';
import type { FileNode } from '@/lib/jmap/types';
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
+import { findTrashFolder, trashSubtreeIds } from '@/lib/files-trash';
import { useFileStore } from '@/stores/file-store';
/**
@@ -93,9 +94,11 @@ export function rawFileNodeId(node: FileNode): string {
function filterNodes(parsed: ParsedQuery, nodes: FileNode[], account: SearchAccount, limit: number, source: 'local' | 'remote'): FileHit[] {
const hits: FileHit[] = [];
+ // The Files trash (.Trash, one folder per delete, what was deleted) is no hit.
+ const trashed = trashSubtreeIds(nodes, findTrashFolder(nodes)?.id);
for (const node of nodes) {
// The owner's folders above a shared node only feed the path below.
- if (node.discoverableOnly) continue;
+ if (node.discoverableOnly || trashed.has(node.id)) continue;
const kind = fileNodeKind(node);
// A symlink has nothing a hit could open.
if (kind === 'symlink') continue;
diff --git a/lib/image-shrink.ts b/lib/image-shrink.ts
new file mode 100644
index 000000000..03579d0a5
--- /dev/null
+++ b/lib/image-shrink.ts
@@ -0,0 +1,154 @@
+// Shrinks an image for a reader that only needs to see it, such as an AI
+// model: decoded with createImageBitmap (which turns photos upright from
+// their EXIF orientation and takes the first frame of an animation), drawn
+// smaller on a canvas, and saved as JPEG on white, or as PNG when the image
+// has see-through parts. Images too small to show anything (tracking pixels,
+// spacers, icons) are not drawn at all.
+
+import { IMAGE_HEADER_BYTES, imageDimensions } from './image-size';
+
+/** Default and allowed range of the long edge, in pixels. */
+export const DEFAULT_IMAGE_SIDE = 1568;
+export const MIN_IMAGE_SIDE = 64;
+export const MAX_IMAGE_SIDE = 2576;
+/** Largest image this returns, in bytes. */
+export const MAX_SHRUNK_IMAGE_BYTES = 1.5 * 1024 * 1024;
+/** Both sides at most this: a tracking pixel. */
+const TRACKER_SIDE = 2;
+/** A side under this, or an area under TINY_AREA: too small to matter. */
+const TINY_SIDE = 64;
+const TINY_AREA = 128 * 128;
+
+export type ShrinkStatus = 'ok' | 'tiny' | 'tracker' | 'too_big' | 'unsupported' | 'failed';
+
+export interface ShrinkResult {
+ status: ShrinkStatus;
+ /** 'image/jpeg' or 'image/png' when ok. */
+ type?: 'image/jpeg' | 'image/png';
+ /** Size of the result when ok, else of the image as decoded (when it could be). */
+ width?: number;
+ height?: number;
+ /** The encoded image when ok. */
+ bytes?: Uint8Array;
+}
+
+type AnyCanvas = OffscreenCanvas | HTMLCanvasElement;
+
+function makeCanvas(width: number, height: number): AnyCanvas {
+ if (typeof OffscreenCanvas !== 'undefined') return new OffscreenCanvas(width, height);
+ const canvas = document.createElement('canvas');
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+}
+
+async function encode(canvas: AnyCanvas, type: 'image/jpeg' | 'image/png', quality?: number): Promise {
+ if ('convertToBlob' in canvas) return canvas.convertToBlob({ type, quality });
+ return new Promise((resolve) => (canvas as HTMLCanvasElement).toBlob(resolve, type, quality));
+}
+
+/** True when any pixel is not fully opaque. */
+function hasTransparency(ctx: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D, width: number, height: number): boolean {
+ const { data } = ctx.getImageData(0, 0, width, height);
+ for (let i = 3; i < data.length; i += 4) if (data[i] < 255) return true;
+ return false;
+}
+
+/** Long edge `maxSide` at most, aspect kept, never larger than the source. */
+export function fitSize(width: number, height: number, maxSide: number): { width: number; height: number } {
+ const scale = Math.min(1, maxSide / Math.max(width, height));
+ return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
+}
+
+/** Types the browser may decode that can carry transparency. */
+const ALPHA_TYPES = /^image\/(png|webp|gif|avif)$/i;
+
+/** Most pixels an image may declare; a larger one is not decoded (its pixels alone could fill the memory). */
+export const MAX_IMAGE_PIXELS = 50_000_000;
+
+/**
+ * Decodes with the photo's EXIF orientation and, when given, a smaller
+ * width. Browsers before imageOrientation (or resize) throw a TypeError on
+ * the options: then without them.
+ */
+async function decode(blob: Blob, resizeWidth: number | null): Promise {
+ try {
+ return await createImageBitmap(blob, {
+ imageOrientation: 'from-image',
+ // Only the width: the browser keeps the aspect, whichever way EXIF turns the photo.
+ ...(resizeWidth ? { resizeWidth, resizeQuality: 'high' as const } : {}),
+ });
+ } catch (err) {
+ if (err instanceof TypeError) return createImageBitmap(blob);
+ throw err;
+ }
+}
+
+/**
+ * The image in `blob`, at most `maxSide` on its long edge and
+ * MAX_SHRUNK_IMAGE_BYTES in size. JPEG at quality 0.85, then 0.7, then
+ * smaller by a quarter per step until it fits; PNG when transparent.
+ * `small`: also shrink images below the "tiny" limits (still not trackers).
+ * The size comes from the file's header before anything is decoded: a
+ * format whose header isn't read here is unsupported, more than
+ * MAX_IMAGE_PIXELS too_big, trackers and tiny images are never decoded.
+ */
+export async function shrinkImage(blob: Blob, { maxSide = DEFAULT_IMAGE_SIDE, small = false }: { maxSide?: number; small?: boolean } = {}): Promise {
+ if (typeof createImageBitmap !== 'function') return { status: 'unsupported' };
+ const declared = imageDimensions(new Uint8Array(await blob.slice(0, IMAGE_HEADER_BYTES).arrayBuffer()));
+ // HEIC, TIFF, SVG, a damaged file: no size to check, nothing to decode.
+ if (!declared || declared.width < 1 || declared.height < 1) return { status: 'unsupported' };
+ const { width: w, height: h } = declared;
+ if (w * h > MAX_IMAGE_PIXELS) return { status: 'too_big', width: w, height: h };
+ if (w <= TRACKER_SIDE && h <= TRACKER_SIDE) return { status: 'tracker', width: w, height: h };
+ if (!small && (Math.min(w, h) < TINY_SIDE || w * h < TINY_AREA)) return { status: 'tiny', width: w, height: h };
+ const side = Math.max(MIN_IMAGE_SIDE, Math.min(MAX_IMAGE_SIDE, Math.round(maxSide)));
+ const target = fitSize(w, h, side);
+ let bitmap: ImageBitmap;
+ try {
+ bitmap = await decode(blob, target.width < w ? target.width : null);
+ } catch {
+ // A broken file: nothing the browser can show.
+ return { status: 'unsupported', width: w, height: h };
+ }
+ try {
+ let size = fitSize(bitmap.width, bitmap.height, side);
+ let transparent: boolean | null = ALPHA_TYPES.test(blob.type) ? null : false;
+ for (let step = 0; step < 12; step++) {
+ const canvas = makeCanvas(size.width, size.height);
+ const ctx = canvas.getContext('2d') as OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D | null;
+ if (!ctx) return { status: 'failed' };
+ ctx.drawImage(bitmap, 0, 0, size.width, size.height);
+ if (transparent === null) transparent = hasTransparency(ctx, size.width, size.height);
+ const tries: [('image/jpeg' | 'image/png'), number | undefined][] = transparent ? [['image/png', undefined]] : [['image/jpeg', 0.85], ['image/jpeg', 0.7]];
+ if (!transparent) {
+ // JPEG has no transparency: what shows through becomes white, not black.
+ ctx.globalCompositeOperation = 'destination-over';
+ ctx.fillStyle = '#ffffff';
+ ctx.fillRect(0, 0, size.width, size.height);
+ }
+ for (const [type, quality] of tries) {
+ const out = await encode(canvas, type, quality);
+ if (!out) return { status: 'failed' };
+ if (out.size <= MAX_SHRUNK_IMAGE_BYTES) {
+ return { status: 'ok', type, width: size.width, height: size.height, bytes: new Uint8Array(await out.arrayBuffer()) };
+ }
+ }
+ const next = { width: Math.floor(size.width * 0.75), height: Math.floor(size.height * 0.75) };
+ if (Math.max(next.width, next.height) < MIN_IMAGE_SIDE) break;
+ size = next;
+ }
+ return { status: 'too_big', width: w, height: h };
+ } catch {
+ return { status: 'failed' };
+ } finally {
+ bitmap.close?.();
+ }
+}
+
+/** Bytes as base64, in pieces so a large image doesn't overflow the call stack. */
+export function toBase64(bytes: Uint8Array): string {
+ let binary = '';
+ for (let i = 0; i < bytes.length; i += 0x8000) binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
+ return btoa(binary);
+}
diff --git a/lib/image-size.ts b/lib/image-size.ts
new file mode 100644
index 000000000..6ab86e077
--- /dev/null
+++ b/lib/image-size.ts
@@ -0,0 +1,66 @@
+// The width and height an image file declares in its header, read before
+// anything decodes it: PNG (IHDR), GIF, JPEG (the SOF segment), WebP
+// (VP8, VP8L, VP8X) and BMP. A file can declare a size whose pixels alone
+// would fill the memory, so callers check this first.
+
+export interface ImageDimensions {
+ width: number;
+ height: number;
+}
+
+/** Bytes of a file's start that hold the size of every format read here, EXIF blocks before a JPEG's SOF included. */
+export const IMAGE_HEADER_BYTES = 512 * 1024;
+
+const u16be = (b: Uint8Array, at: number) => (b[at] << 8) | b[at + 1];
+const u16le = (b: Uint8Array, at: number) => b[at] | (b[at + 1] << 8);
+const u24le = (b: Uint8Array, at: number) => b[at] | (b[at + 1] << 8) | (b[at + 2] << 16);
+const u32be = (b: Uint8Array, at: number) => ((b[at] << 24) >>> 0) + ((b[at + 1] << 16) | (b[at + 2] << 8) | b[at + 3]);
+const ascii = (b: Uint8Array, at: number, text: string) => [...text].every((c, i) => b[at + i] === c.charCodeAt(0));
+
+function jpeg(b: Uint8Array): ImageDimensions | null {
+ let at = 2;
+ while (at + 9 < b.length) {
+ if (b[at] !== 0xff) return null;
+ const marker = b[at + 1];
+ // Fill bytes, and markers without a length (restart markers, TEM).
+ if (marker === 0xff) { at++; continue; }
+ if ((marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { at += 2; continue; }
+ if (marker === 0xd9 || marker === 0xda) return null;
+ const length = u16be(b, at + 2);
+ // Start of frame: every SOF but DHT (C4), JPG (C8) and DAC (CC).
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
+ return { height: u16be(b, at + 5), width: u16be(b, at + 7) };
+ }
+ if (length < 2) return null;
+ at += 2 + length;
+ }
+ return null;
+}
+
+function webp(b: Uint8Array): ImageDimensions | null {
+ if (b.length >= 30 && ascii(b, 12, 'VP8 ') && b[23] === 0x9d && b[24] === 0x01 && b[25] === 0x2a) {
+ return { width: u16le(b, 26) & 0x3fff, height: u16le(b, 28) & 0x3fff };
+ }
+ if (ascii(b, 12, 'VP8L') && b[20] === 0x2f) {
+ return {
+ width: 1 + (((b[22] & 0x3f) << 8) | b[21]),
+ height: 1 + (((b[24] & 0x0f) << 10) | (b[23] << 2) | ((b[22] & 0xc0) >> 6)),
+ };
+ }
+ if (b.length >= 30 && ascii(b, 12, 'VP8X')) return { width: 1 + u24le(b, 24), height: 1 + u24le(b, 27) };
+ return null;
+}
+
+/** The size the file declares, or null for a format not read here (or a damaged header). */
+export function imageDimensions(bytes: Uint8Array): ImageDimensions | null {
+ const b = bytes;
+ if (b.length >= 24 && b[0] === 0x89 && ascii(b, 1, 'PNG') && ascii(b, 12, 'IHDR')) return { width: u32be(b, 16), height: u32be(b, 20) };
+ if (b.length >= 10 && ascii(b, 0, 'GIF8')) return { width: u16le(b, 6), height: u16le(b, 8) };
+ if (b.length >= 4 && b[0] === 0xff && b[1] === 0xd8) return jpeg(b);
+ if (b.length >= 25 && ascii(b, 0, 'RIFF') && ascii(b, 8, 'WEBP')) return webp(b);
+ if (b.length >= 26 && ascii(b, 0, 'BM')) {
+ const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
+ return { width: Math.abs(view.getInt32(18, true)), height: Math.abs(view.getInt32(22, true)) };
+ }
+ return null;
+}
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index c5419f48e..377e61525 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -404,7 +404,8 @@ export interface IJMAPClient {
// ── Threads ───────────────────────────────────────────────────
getThread(threadId: string, accountId?: string): Promise;
getThreads(threadIds: string[], accountId?: string): Promise;
- getThreadEmails(threadId: string, accountId?: string): Promise;
+ /** `newest`: only that many of the thread's newest messages are fetched (Thread/get lists them oldest first). */
+ getThreadEmails(threadId: string, accountId?: string, options?: { newest?: number }): Promise;
// ── Compose / Send ────────────────────────────────────────────
createDraft(
@@ -699,6 +700,12 @@ export interface IJMAPClient {
*/
copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise>;
updateFileNode(id: string, updates: Partial>): Promise;
+ /**
+ * Point a file at new content (a blob already uploaded): same node, same
+ * name and place, new blob. The server stamps `modified`. Throws when
+ * refused.
+ */
+ replaceFileNodeBlob(id: string, blobId: string): Promise;
updateFileNodes(updates: Record>>): Promise<{ updated: string[]; notUpdated: Record }>;
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
copyFileNode(id: string, newName: string, parentId: string | null, listings?: FileNodeListings): Promise;
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index a5168fc8a..6d35dcdfa 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -4726,17 +4726,19 @@ export class JMAPClient implements IJMAPClient {
}
}
- async getThreadEmails(threadId: string, accountId?: string): Promise {
+ async getThreadEmails(threadId: string, accountId?: string, options?: { newest?: number }): Promise {
try {
const targetAccountId = accountId || this.accountId;
const thread = await this.getThread(threadId, accountId);
if (!thread?.emailIds?.length) {
return [];
}
+ // Thread/get lists a thread's messages oldest first (RFC 8621 3.1).
+ const ids = options?.newest !== undefined && options.newest > 0 ? thread.emailIds.slice(-options.newest) : thread.emailIds;
const emails: Email[] = [];
- for (const batchIds of batched(thread.emailIds, this.getMaxObjectsInGet())) {
+ for (const batchIds of batched(ids, this.getMaxObjectsInGet())) {
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
@@ -7189,10 +7191,13 @@ export class JMAPClient implements IJMAPClient {
}
async deactivateSieveScript(accountId?: string): Promise {
+ // RFC 9661 deactivates with onSuccessDeactivateScript. Stalwart (0.16 and
+ // 1.0) answers onSuccessActivateScript: null without error but leaves the
+ // script active, so a script could not be removed afterwards.
const response = await this.request([
["SieveScript/set", {
accountId: accountId || this.getSieveAccountId(),
- onSuccessActivateScript: null,
+ onSuccessDeactivateScript: true,
}, "0"]
], this.sieveUsing());
@@ -10587,6 +10592,29 @@ export class JMAPClient implements IJMAPClient {
}
}
+ async replaceFileNodeBlob(id: string, blobId: string): Promise {
+ const node = this.resolveFileNodeId(id);
+ const response = await this.request(
+ [["FileNode/set", {
+ accountId: node.accountId,
+ // `modified: null` has the server stamp the change: Stalwart 1.0
+ // leaves a client-set `modified` alone on a content change (see
+ // updateFileNodeBlob in lib/wopi/files.ts); 0.16 bumps it anyway.
+ update: { [node.id as string]: { blobId, modified: null } },
+ }, "fns0"]],
+ this.fileUsing(),
+ );
+
+ const result = response.methodResponses?.[0];
+ if (!result || result[0] === "error") {
+ throw jmapError(result?.[1]?.description || "FileNode/set update failed", result?.[1]);
+ }
+ const refused = result[1].notUpdated?.[node.id as string];
+ if (refused) {
+ throw jmapError(refused.description || "Failed to replace the file's content", refused);
+ }
+ }
+
/** Group FileNode ids by the account that owns them. */
private groupFileNodeIds(entries: Array<[string, T]>): Map> {
const groups = new Map>();
diff --git a/lib/mailbox-shared-out.ts b/lib/mailbox-shared-out.ts
new file mode 100644
index 000000000..20212ee32
--- /dev/null
+++ b/lib/mailbox-shared-out.ts
@@ -0,0 +1,40 @@
+// Whether a folder is shared with other people, as the server says. The
+// folder lists the app loads don't carry the share map (Mailbox/get without
+// shareWith), so a plugin's move or filter into a folder is checked here,
+// folder by folder, before it may send mail where someone else reads it.
+
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Mailbox } from '@/lib/jmap/types';
+
+/**
+ * True when the folder, a folder above it, or (with `descendants`) a folder
+ * below it is shared with anyone. Fails closed: an answer the server
+ * refuses or a client that can't ask counts as shared. False only when the
+ * server has no folder sharing at all, or every share map is empty.
+ */
+export async function isSharedOut(
+ client: IJMAPClient,
+ mailboxes: Pick[],
+ mailboxId: string,
+ accountId?: string,
+ { descendants = false }: { descendants?: boolean } = {},
+): Promise {
+ if (!client.supportsMailboxSharing?.(accountId)) return false;
+ if (typeof client.getMailboxShareWith !== 'function') return true;
+ const byId = new Map(mailboxes.map((m) => [m.id, m]));
+ const ids: string[] = [];
+ for (let id: string | null | undefined = mailboxId; id && !ids.includes(id); id = byId.get(id)?.parentId) ids.push(id);
+ if (descendants) {
+ const below = (root: string): string[] => mailboxes.filter((m) => m.parentId === root).flatMap((m) => [m.id, ...below(m.id)]);
+ for (const id of below(mailboxId)) if (!ids.includes(id)) ids.push(id);
+ }
+ for (const id of ids) {
+ try {
+ const shareWith = await client.getMailboxShareWith(id, accountId);
+ if (shareWith && Object.keys(shareWith).length > 0) return true;
+ } catch {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/lib/plugin-projection.ts b/lib/plugin-projection.ts
index 40ec3d697..ba8b16126 100644
--- a/lib/plugin-projection.ts
+++ b/lib/plugin-projection.ts
@@ -12,6 +12,8 @@ import type {
import type { EmailTemplate } from '@/lib/template-types';
import { generateEmailSource } from '@/lib/email-source';
import { isServerSetKeyword } from '@/lib/jmap/server-keywords';
+import { fileNodeKind } from '@/lib/jmap/filenode-kind';
+import { attachmentExtras, cidAltTexts } from '@/lib/plugin-sandbox/assistant/b.projection';
import { htmlToPlainText } from '@/lib/html-to-text';
// Resolve a message's plain-text body from its JMAP body parts. Plugins that
@@ -45,6 +47,7 @@ export function plainTextFromEmail(email: Email): string {
* carry it, others only the caller knows (see EmailReadView.accountId).
*/
export function emailToReadView(email: Email, accountId = email.sourceAccountId): EmailReadView {
+ const alts = cidAltTexts(email);
return {
id: email.id,
...(accountId ? { accountId } : {}),
@@ -66,6 +69,7 @@ export function emailToReadView(email: Email, accountId = email.sourceAccountId)
type: part.type || 'application/octet-stream',
size: part.size || 0,
inline: part.disposition === 'inline' || !!part.cid,
+ ...attachmentExtras(part, alts),
})),
preview: email.preview || '',
text: plainTextFromEmail(email),
@@ -188,8 +192,12 @@ export function taskToView(task: CalendarTask): TaskView {
};
}
+/**
+ * A FileNode as plugins see it. Folder or file by fileNodeKind (Stalwart
+ * 1.0's symlinks have no blob either, but hold nothing: they count as files).
+ */
export function fileToView(node: FileNode, path = ''): FileResourceView {
- const isDir = node.type === 'd' || node.blobId === null;
+ const isDir = fileNodeKind(node) === 'directory';
return {
id: node.id,
name: node.name,
diff --git a/lib/plugin-sandbox/api-timeouts.ts b/lib/plugin-sandbox/api-timeouts.ts
new file mode 100644
index 000000000..677f31049
--- /dev/null
+++ b/lib/plugin-sandbox/api-timeouts.ts
@@ -0,0 +1,9 @@
+// How long the sandbox runtime waits for the host to answer an api call.
+
+export const DEFAULT_API_TIMEOUT_MS = 30_000;
+// http.post / http.fetch can be carrying an attachment to an external store,
+// which is a transfer rather than a round-trip - 30s is not enough for the
+// files a plugin has any reason to offload. Still bounded so a hung host
+// can't leak the pending promise. Reads that download a file (attachments,
+// their text and pictures) use it too.
+export const NETWORK_API_TIMEOUT_MS = 120_000;
diff --git a/lib/plugin-sandbox/assistant/README.md b/lib/plugin-sandbox/assistant/README.md
new file mode 100644
index 000000000..cf0d413aa
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/README.md
@@ -0,0 +1,27 @@
+# Host APIs for the AI assistant (2.2)
+
+Host calls the assistant plugin needs, one set of files per area, so the
+areas can be built side by side without editing the same lines:
+
+| Area | What | Files |
+| --- | --- | --- |
+| b | More context: attachment text and images, threads, the list selection | `b.types.ts`, `b.ts`, `b.runtime.ts` |
+| c | More actions: invitations, filters, batches, out-of-office, settings | `c.types.ts`, `c.ts`, `c.runtime.ts` |
+| d | Files: find, read, attach, save, create | `d.types.ts`, `d.ts`, `d.runtime.ts` |
+| f | The panel in every view | `f.types.ts`, `f.ts`, `f.runtime.ts` |
+
+Each area fills its own three files:
+
+- ` .types.ts` (pure, no imports from the app): `PERMISSIONS`, the
+ permissions the area adds (they join `ALL_PERMISSIONS`), and
+ ` HostFeatures`, the flags it adds to `api.host.features`.
+- ` .ts` (host side): `PERMS` (method → permission, joins
+ `PERM_PER_METHOD`), `dispatch(plugin, method, args)` (returns `NOT_MINE`
+ for methods it doesn't own) and `features()` (its flags right now).
+- ` .runtime.ts` (inside the sandbox): `runtime(callApi)`, the methods
+ it adds to the plugin's `api`, by namespace (`{ email: { getThread } }`).
+
+`index.ts`, `runtime.ts` and `types.ts` merge the areas; host-api.ts,
+host-features.ts, runtime.tsx and plugin-types.ts read only those. A new
+permission needs a check (a method in `PERMS`, a slot or a hook), its texts
+in every locale under `plugins.permissions.`, and docs.
diff --git a/lib/plugin-sandbox/assistant/b.list-selection.ts b/lib/plugin-sandbox/assistant/b.list-selection.ts
new file mode 100644
index 000000000..43c8b322c
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/b.list-selection.ts
@@ -0,0 +1,47 @@
+// Area b of the assistant's host APIs: the messages ticked in the list, as
+// the reader panel's `listSelection` prop, so the assistant can work on
+// several at once ("summarise these").
+
+import { useMemo } from 'react';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import { resolveEmailBlobContext, resolveSelectedEmails, useEmailStore } from '@/stores/email-store';
+import { useAuthStore } from '@/stores/auth-store';
+import type { ListSelectionView } from './b.types';
+
+/** Most messages the prop names; `total` still counts them all. */
+export const MAX_LIST_SELECTION = 50;
+
+type SelectionState = Parameters[0];
+
+/**
+ * The ticked messages when two or more are, else null. Each names the
+ * account it is in, so api.email.get and getThread read it from there.
+ * Rows of another connected login are left out of `emails` (this login
+ * can't read them) but counted in `total`.
+ */
+export function listSelectionOf(state: SelectionState, client: IJMAPClient | null): ListSelectionView | null {
+ const total = state.selectedEmailKeys.size;
+ if (total < 2) return null;
+ const emails: ListSelectionView['emails'] = [];
+ for (const email of resolveSelectedEmails(state)) {
+ if (emails.length >= MAX_LIST_SELECTION) break;
+ const owner = client ? resolveEmailBlobContext(email, client) : null;
+ if (owner && owner.client !== client) continue;
+ const accountId = owner?.accountId ?? email.sourceAccountId ?? client?.getAccountId();
+ emails.push({ id: email.id, ...(accountId ? { accountId } : {}) });
+ }
+ return { emails, total };
+}
+
+/**
+ * listSelectionOf for the list on screen; the same object while the
+ * selection stays the same. The store is read through one selector that
+ * answers a string, so new mail, a search or a moved message re-render the
+ * caller only when the ticked messages change. `enabled` false (a Pro tab,
+ * whose panel isn't about the main list) always answers null.
+ */
+export function useListSelection(enabled = true): ListSelectionView | null {
+ const client = useAuthStore((s) => s.client);
+ const key = useEmailStore((s) => (enabled ? JSON.stringify(listSelectionOf(s, client)) : 'null'));
+ return useMemo(() => JSON.parse(key) as ListSelectionView | null, [key]);
+}
diff --git a/lib/plugin-sandbox/assistant/b.projection.ts b/lib/plugin-sandbox/assistant/b.projection.ts
new file mode 100644
index 000000000..372a3e00e
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/b.projection.ts
@@ -0,0 +1,64 @@
+// Area b of the assistant's host APIs: what the message view adds to each
+// attachment. An image the HTML shows inline (` `) gets its
+// Content-ID and the alt text the sender wrote for it, so the assistant can
+// name a picture without looking at it.
+
+import type { Email } from '@/lib/jmap/types';
+import type { AttachmentExtrasB, EmailReadViewExtrasB } from './b.types';
+
+const ENTITIES: Record = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
+
+function decodeEntities(value: string): string {
+ return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, name: string) => {
+ if (name[0] === '#') {
+ const code = name[1] === 'x' || name[1] === 'X' ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10);
+ return Number.isFinite(code) && code > 0 && code < 0x110000 ? String.fromCodePoint(code) : whole;
+ }
+ return ENTITIES[name.toLowerCase()] ?? whole;
+ });
+}
+
+/** The value of one attribute of a tag's source, or null. */
+function attribute(tag: string, name: string): string | null {
+ const m = new RegExp(`\\s${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i').exec(tag);
+ if (!m) return null;
+ return decodeEntities(m[1] ?? m[2] ?? m[3] ?? '');
+}
+
+/** "", "logo1@aa", "cid:logo1%40aa" → "logo1@aa" (lower case, for comparing). */
+export function normaliseCid(value: string): string {
+ let id = value.trim().replace(/^cid:/i, '').replace(/^<|>$/g, '');
+ try { id = decodeURIComponent(id); } catch { /* keep it as written */ }
+ return id.toLowerCase();
+}
+
+/** Content-ID → alt text, from the ` ` tags of the message's HTML. */
+export function cidAltTexts(email: Pick): Map {
+ const out = new Map();
+ const values = email.bodyValues ?? {};
+ for (const part of email.htmlBody ?? []) {
+ const html = values[part.partId]?.value;
+ if (typeof html !== 'string') continue;
+ for (const [tag] of html.matchAll(/ ]*>/gi)) {
+ const src = attribute(tag, 'src');
+ if (!src || !/^cid:/i.test(src.trim())) continue;
+ const alt = (attribute(tag, 'alt') ?? attribute(tag, 'title') ?? '').replace(/\s+/g, ' ').trim();
+ const cid = normaliseCid(src);
+ if (alt && !out.has(cid)) out.set(cid, alt.slice(0, 500));
+ }
+ }
+ return out;
+}
+
+/** EmailReadView.blobsReadable: false when the signed-in login can't read the message's parts, else left out. */
+export function readability(readable: boolean): EmailReadViewExtrasB {
+ return readable ? {} : { blobsReadable: false };
+}
+
+/** `cid` and `alt` of one attachment, for EmailReadView.attachments (empty for a plain attachment). */
+export function attachmentExtras(part: { cid?: string | null }, alts: Map): AttachmentExtrasB {
+ if (!part.cid) return {};
+ const cid = part.cid.replace(/^<|>$/g, '');
+ const alt = alts.get(normaliseCid(cid));
+ return { cid, ...(alt ? { alt } : {}) };
+}
diff --git a/lib/plugin-sandbox/assistant/b.runtime.ts b/lib/plugin-sandbox/assistant/b.runtime.ts
new file mode 100644
index 000000000..be96b52cb
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/b.runtime.ts
@@ -0,0 +1,41 @@
+// Area b of the assistant's host APIs, inside the plugin sandbox (see README.md).
+
+import type { CallApi, RuntimeAdditions } from './runtime';
+import type { EmailReadView } from '../../plugin-types';
+import type { AttachmentImageOptions, AttachmentImageView, AttachmentTextOptions, AttachmentTextView } from './b.types';
+import { NETWORK_API_TIMEOUT_MS } from '../api-timeouts';
+
+/** The methods this area adds to the plugin's api, by namespace. */
+export function runtime(callApi: CallApi): RuntimeAdditions {
+ return {
+ email: {
+ /**
+ * The text of one attachment of a message (email:blob-read, 30 a
+ * minute): PDF, Word, Excel, PowerPoint, OpenDocument, text, CSV,
+ * HTML, calendar and address files. See AttachmentTextView.status.
+ */
+ getAttachmentText: (emailId: string, blobId: string, options?: AttachmentTextOptions) =>
+ callApi('email.getAttachmentText', [emailId, blobId, options], NETWORK_API_TIMEOUT_MS) as Promise,
+ /**
+ * An image attachment or inline image of a message, shrunk to maxSide
+ * (default 1568 px) and at most 1.5 MB, as base64 (email:blob-read, 30
+ * a minute). Tracking pixels and tiny images answer a status instead.
+ * The photo's date and place only with exif: true.
+ */
+ getAttachmentImage: (emailId: string, blobId: string, options?: AttachmentImageOptions) =>
+ callApi('email.getAttachmentImage', [emailId, blobId, options], NETWORK_API_TIMEOUT_MS) as Promise,
+ /**
+ * Every message of the conversation a message belongs to, oldest
+ * first, at most the newest 50 (email:read, 60 a minute).
+ */
+ getThread: (emailId: string, options?: { accountId?: string }) =>
+ callApi('email.getThread', [emailId, options], NETWORK_API_TIMEOUT_MS) as Promise,
+ /**
+ * How many messages the conversation of a message has, without
+ * reading them (email:read, 120 a minute, apart from getThread's).
+ */
+ getThreadCount: (emailId: string, options?: { accountId?: string }) =>
+ callApi('email.getThreadCount', [emailId, options]) as Promise,
+ },
+ };
+}
diff --git a/lib/plugin-sandbox/assistant/b.ts b/lib/plugin-sandbox/assistant/b.ts
new file mode 100644
index 000000000..43e634446
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/b.ts
@@ -0,0 +1,208 @@
+// Area b of the assistant's host APIs, host side (see README.md): more
+// context for the assistant. The text of an attachment, an image
+// attachment shrunk to what a model needs, and a whole conversation.
+
+import type { EmailReadView, InstalledPlugin, Permission } from '../../plugin-types';
+import type { IJMAPClient } from '../../jmap/client-interface';
+import type { Attachment, Email } from '../../jmap/types';
+import { NOT_MINE } from './not-mine';
+import type { AreaHostFeaturesB, AttachmentImageView, AttachmentTextView } from './b.types';
+import { attachmentExtras, cidAltTexts } from './b.projection';
+import { readNamedEmail, sourceAccount } from '../host-api-mail';
+import { readBoolean, readId, readInteger, readObject, sessionClient, withinRate } from '../host-api-input';
+// The readers (pdf.js, the Office reader) load only when an attachment is read.
+import { DEFAULT_TEXT_CHARS, MAX_TEXT_CHARS, MAX_TEXT_SOURCE_BYTES } from '@/lib/attachment-text/types';
+import { DEFAULT_IMAGE_SIDE, MAX_IMAGE_SIDE, MIN_IMAGE_SIDE, shrinkImage, toBase64 } from '@/lib/image-shrink';
+import { readPhotoExif } from '@/lib/exif';
+import { emailToReadView } from '@/lib/plugin-projection';
+
+/** Method → permission, joined into PERM_PER_METHOD. */
+export const PERMS: Readonly> = {
+ 'email.getAttachmentText': 'email:blob-read',
+ 'email.getAttachmentImage': 'email:blob-read',
+ 'email.getThread': 'email:read',
+ 'email.getThreadCount': 'email:read',
+};
+
+/** Attachment reads (text) a plugin may make per minute. */
+export const MAX_ATTACHMENT_TEXT_PER_MINUTE = 30;
+/** Attachment reads (images) a plugin may make per minute. */
+export const MAX_ATTACHMENT_IMAGE_PER_MINUTE = 30;
+/** Conversations a plugin may read per minute. */
+export const MAX_THREADS_PER_MINUTE = 60;
+/** Conversation sizes a plugin may ask for per minute (a panel probing whether to offer a thread read). */
+export const MAX_THREAD_COUNTS_PER_MINUTE = 120;
+/** Most messages api.email.getThread returns (the newest of a longer conversation). */
+export const MAX_THREAD_MESSAGES = 50;
+
+interface FoundAttachment {
+ client: IJMAPClient;
+ email: Email;
+ accountId: string | undefined;
+ part: Attachment;
+}
+
+/**
+ * One attachment of one message. The blob has to be an attachment of that
+ * message, as for api.email.getAttachment; the message is found the way
+ * api.email.get finds it (the account the plugin names, the list's row,
+ * the signed-in account).
+ */
+async function findAttachment(method: string, emailIdArg: unknown, blobIdArg: unknown, accountArg: unknown): Promise {
+ const emailId = readId(emailIdArg, method, 'emailId');
+ const blobId = readId(blobIdArg, method, 'blobId');
+ const client = sessionClient(method);
+ const found = await readNamedEmail(client, emailId, method, accountArg);
+ if (!found) throw new Error(`${method}: no message with id "${emailId}"`);
+ const part = (found.email.attachments ?? []).find((a) => a.blobId === blobId);
+ if (!part) throw new Error(`${method}: that blob is not an attachment of this message`);
+ return { client, email: found.email, accountId: found.accountId, part };
+}
+
+function fetchPart({ client, accountId, part }: FoundAttachment): Promise {
+ return client.fetchBlobArrayBuffer(part.blobId, part.name || undefined, part.type || undefined, ...(accountId ? [accountId] : []));
+}
+
+/** The text of one attachment of one message (see AttachmentTextView). */
+export async function doEmailGetAttachmentText(
+ plugin: InstalledPlugin,
+ emailIdArg: unknown,
+ blobIdArg: unknown,
+ optionsArg?: unknown,
+): Promise {
+ const method = 'email.getAttachmentText';
+ readId(emailIdArg, method, 'emailId');
+ readId(blobIdArg, method, 'blobId');
+ const opts = readObject(optionsArg, method, 'options', ['maxChars', 'accountId'], true);
+ const maxChars = opts.maxChars === undefined ? DEFAULT_TEXT_CHARS : readInteger(opts.maxChars, method, 'maxChars', 1, MAX_TEXT_CHARS);
+ if (!withinRate(method, plugin.id, MAX_ATTACHMENT_TEXT_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`);
+ const found = await findAttachment(method, emailIdArg, blobIdArg, opts.accountId);
+ const { part } = found;
+ const name = part.name || 'attachment';
+ const type = part.type || 'application/octet-stream';
+ const base = { name, type, size: part.size || 0 };
+ const { extractText, textFormatOf } = await import('@/lib/attachment-text');
+ if ((part.size || 0) > MAX_TEXT_SOURCE_BYTES) {
+ return { ...base, format: textFormatOf(name, type), status: 'too_big', text: '', truncated: false };
+ }
+ const data = await fetchPart(found);
+ const result = await extractText(data, { name, type, maxChars });
+ return { ...base, size: data.byteLength, ...result };
+}
+
+const IMAGE_NAME = /\.(jpe?g|png|gif|webp|bmp|avif|heic|heif|tiff?)$/i;
+
+/** Raster images by type or name. SVG is left out: it can load remote content. */
+function isRasterImage(name: string, type: string): boolean {
+ const mediaType = type.split(';')[0].trim().toLowerCase();
+ if (mediaType === 'image/svg+xml' || /\.svgz?$/i.test(name)) return false;
+ return mediaType.startsWith('image/') || IMAGE_NAME.test(name);
+}
+
+/**
+ * An image attachment (or an inline image) of one message, shrunk in the
+ * app to `maxSide` on its long edge and at most 1.5 MB, as base64. Only
+ * the message's own parts: images the HTML loads from the web are never
+ * fetched. The photo's date and place only with `exif: true`.
+ */
+export async function doEmailGetAttachmentImage(
+ plugin: InstalledPlugin,
+ emailIdArg: unknown,
+ blobIdArg: unknown,
+ optionsArg?: unknown,
+): Promise {
+ const method = 'email.getAttachmentImage';
+ readId(emailIdArg, method, 'emailId');
+ readId(blobIdArg, method, 'blobId');
+ const opts = readObject(optionsArg, method, 'options', ['maxSide', 'exif', 'accountId'], true);
+ const maxSide = opts.maxSide === undefined ? DEFAULT_IMAGE_SIDE : readInteger(opts.maxSide, method, 'maxSide', MIN_IMAGE_SIDE, MAX_IMAGE_SIDE);
+ const wantExif = opts.exif === undefined ? false : readBoolean(opts.exif, method, 'exif');
+ if (!withinRate(method, plugin.id, MAX_ATTACHMENT_IMAGE_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`);
+ const found = await findAttachment(method, emailIdArg, blobIdArg, opts.accountId);
+ const { part, email } = found;
+ const name = part.name || 'image';
+ const sourceType = part.type || 'application/octet-stream';
+ const { alt } = attachmentExtras(part, cidAltTexts(email));
+ const answer = (status: AttachmentImageView['status'], width = 0, height = 0): AttachmentImageView => ({
+ name, type: sourceType, width, height, data: '', status, ...(alt ? { alt } : {}),
+ });
+ if (!isRasterImage(name, sourceType)) return answer('unsupported');
+ if ((part.size || 0) > MAX_TEXT_SOURCE_BYTES) return answer('too_big');
+ const data = await fetchPart(found);
+ const shrunk = await shrinkImage(new Blob([data], { type: sourceType }), { maxSide });
+ if (shrunk.status !== 'ok' || !shrunk.bytes || !shrunk.type) return answer(shrunk.status === 'ok' ? 'failed' : shrunk.status, shrunk.width, shrunk.height);
+ const exif = wantExif ? readPhotoExif(data) : null;
+ return {
+ name,
+ type: shrunk.type,
+ width: shrunk.width ?? 0,
+ height: shrunk.height ?? 0,
+ data: toBase64(shrunk.bytes),
+ status: 'ok',
+ ...(alt ? { alt } : {}),
+ ...(exif && Object.keys(exif).length ? { exif } : {}),
+ };
+}
+
+/**
+ * Every message of the conversation `emailId` belongs to, oldest first, as
+ * read views (at most the newest MAX_THREAD_MESSAGES). The message is found
+ * the way api.email.get finds it, and the conversation is read from the
+ * same account.
+ */
+export async function doEmailGetThread(plugin: InstalledPlugin, emailIdArg: unknown, optionsArg?: unknown): Promise {
+ const method = 'email.getThread';
+ const emailId = readId(emailIdArg, method, 'emailId');
+ const opts = readObject(optionsArg, method, 'options', ['accountId'], true);
+ if (!withinRate(method, plugin.id, MAX_THREADS_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`);
+ const client = sessionClient(method);
+ const found = await readNamedEmail(client, emailId, method, opts.accountId);
+ if (!found) throw new Error(`${method}: no message with id "${emailId}"`);
+ const accountId = found.accountId ?? client.getAccountId();
+ // Only the newest messages are fetched, with their bodies; a thread of thousands stays one small read.
+ const emails = found.email.threadId ? await client.getThreadEmails(found.email.threadId, found.accountId, { newest: MAX_THREAD_MESSAGES }) : [];
+ // A server that knows no thread for it: the message on its own.
+ const list = emails.length ? emails : [found.email];
+ return list
+ .slice()
+ .sort((a, b) => String(a.receivedAt ?? '').localeCompare(String(b.receivedAt ?? '')))
+ .slice(-MAX_THREAD_MESSAGES)
+ .map((email) => emailToReadView(email, accountId));
+}
+
+/**
+ * How many messages the conversation of `emailId` has (Thread/get; no
+ * message bodies are read). 1 for a message without a thread. Routed like
+ * api.email.get; its own rate limit, so probing it never uses up getThread's.
+ */
+export async function doEmailGetThreadCount(plugin: InstalledPlugin, emailIdArg: unknown, optionsArg?: unknown): Promise {
+ const method = 'email.getThreadCount';
+ const emailId = readId(emailIdArg, method, 'emailId');
+ const opts = readObject(optionsArg, method, 'options', ['accountId'], true);
+ if (!withinRate(method, plugin.id, MAX_THREAD_COUNTS_PER_MINUTE)) throw new Error(`${method}: too many reads, try again in a minute`);
+ const client = sessionClient(method);
+ const accountId = await sourceAccount(client, emailId, method, opts.accountId);
+ if (accountId === null) throw new Error(`${method}: no message with id "${emailId}"`);
+ // The summary only (no bodies), for its thread id.
+ const [email] = await client.getSomeEmails([emailId], accountId);
+ if (!email) throw new Error(`${method}: no message with id "${emailId}"`);
+ if (!email.threadId) return 1;
+ const thread = await client.getThread(email.threadId, accountId);
+ return Math.max(1, thread?.emailIds?.length ?? 1);
+}
+
+/** Runs a method of this area; NOT_MINE for any other. */
+export async function dispatch(plugin: InstalledPlugin, method: string, args: unknown[]): Promise {
+ switch (method) {
+ case 'email.getAttachmentText': return doEmailGetAttachmentText(plugin, args[0], args[1], args[2]);
+ case 'email.getAttachmentImage': return doEmailGetAttachmentImage(plugin, args[0], args[1], args[2]);
+ case 'email.getThread': return doEmailGetThread(plugin, args[0], args[1]);
+ case 'email.getThreadCount': return doEmailGetThreadCount(plugin, args[0], args[1]);
+ default: return NOT_MINE;
+ }
+}
+
+/** This area's flags in api.host.features, right now. */
+export function features(): AreaHostFeaturesB {
+ return { attachmentText: true, attachmentImages: true, threads: true, listSelection: true };
+}
diff --git a/lib/plugin-sandbox/assistant/b.types.ts b/lib/plugin-sandbox/assistant/b.types.ts
new file mode 100644
index 000000000..5775dbc33
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/b.types.ts
@@ -0,0 +1,108 @@
+// Area b of the assistant's host APIs: its permissions and its flags in
+// api.host.features (see README.md). Pure: no imports from the app.
+
+/** Permissions this area adds to ALL_PERMISSIONS. */
+export const PERMISSIONS = [] as const;
+
+/** Flags this area adds to api.host.features (missing on older webmails: read them as `=== true`). */
+export interface AreaHostFeaturesB {
+ /** api.email.getAttachmentText: the text of an attachment (PDF, Office, text, calendar and address files). */
+ attachmentText?: boolean;
+ /** api.email.getAttachmentImage: an image attachment, shrunk for a model. */
+ attachmentImages?: boolean;
+ /** api.email.getThread: every message of a conversation; api.email.getThreadCount: how many there are. */
+ threads?: boolean;
+ /** The reader panel (email-detail-sidebar) gets the messages ticked in the list as `listSelection`. */
+ listSelection?: boolean;
+}
+
+/** What EmailReadView adds for the assistant. */
+export interface EmailReadViewExtrasB {
+ /**
+ * false: this login can't read the message's attachments, text or
+ * conversation through the host (it belongs to another connected login,
+ * as in a unified view), so attachment, image and thread reads of it
+ * fail; offer none. Left out when they can be read.
+ */
+ blobsReadable?: boolean;
+}
+
+/** What EmailReadView.attachments adds for an image the message shows inline. */
+export interface AttachmentExtrasB {
+ /** The Content-ID the message's HTML refers to (` `), without angle brackets. */
+ cid?: string;
+ /** The alt text of that image in the HTML, when the sender wrote one. */
+ alt?: string;
+}
+
+/**
+ * What api.email.getAttachmentText answers. `status`: ok (`text` holds the
+ * text), too_big (over 20 MB, or an Office file that unpacks to more than
+ * 50 MB), unsupported (images, archives, legacy .doc/.xls/.ppt), no_text
+ * (a scanned PDF, an empty file), encrypted, failed.
+ */
+export interface AttachmentTextView {
+ name: string;
+ type: string;
+ /** Size of the attachment in bytes. */
+ size: number;
+ format: 'text' | 'csv' | 'html' | 'ics' | 'vcf' | 'pdf' | 'docx' | 'xlsx' | 'pptx' | 'odt' | 'ods' | 'odp' | 'other';
+ status: 'ok' | 'too_big' | 'unsupported' | 'no_text' | 'encrypted' | 'failed';
+ /** The text, '' unless status is ok. */
+ text: string;
+ /** True when the text was cut at maxChars, or a PDF or spreadsheet had more than was read. */
+ truncated: boolean;
+ /** PDFs: the number of pages in the file (at most 50 are read). */
+ pages?: number;
+ /** Spreadsheets: the names of the sheets read (at most 5, 2,000 rows each). */
+ sheets?: string[];
+}
+
+export interface AttachmentTextOptions {
+ /** Longest text returned, 1 to 100,000 characters (default 20,000). */
+ maxChars?: number;
+ /** The account the message is in (EmailSummary.accountId), for shared and group mail. */
+ accountId?: string;
+}
+
+/**
+ * What api.email.getAttachmentImage answers. `status`: ok (`data` holds the
+ * image), tracker (1-2 px), tiny (a side under 64 px or under 128x128 in
+ * area), too_big (over 20 MB, or still over 1.5 MB at 64 px), unsupported
+ * (not a picture the browser can show: HEIC, TIFF, SVG, not an image),
+ * failed.
+ */
+export interface AttachmentImageView {
+ name: string;
+ /** The type of `data`: image/jpeg, or image/png for a picture with see-through parts. Unless ok, the attachment's own type. */
+ type: string;
+ /** Size of `data` in pixels when ok; of the picture as it is (when it could be read) otherwise; else 0. */
+ width: number;
+ height: number;
+ /** The image, base64 without a data: prefix; '' unless status is ok. */
+ data: string;
+ status: 'ok' | 'tiny' | 'tracker' | 'too_big' | 'unsupported' | 'failed';
+ /** The alt text the message's HTML gives this inline image. */
+ alt?: string;
+ /** With `exif: true`: when and where a photo was taken, when the file says. */
+ exif?: { taken?: string; lat?: number; lon?: number };
+}
+
+export interface AttachmentImageOptions {
+ /** Longest edge in pixels, 64 to 2,576 (default 1,568). */
+ maxSide?: number;
+ /** Also read the photo's date and place (EXIF). Off unless the user asked for them. */
+ exif?: boolean;
+ /** The account the message is in (EmailSummary.accountId), for shared and group mail. */
+ accountId?: string;
+}
+
+/**
+ * The reader panel's `listSelection` prop (email:read): the messages ticked
+ * in the list when two or more are, else null. `emails` names at most 50,
+ * each with the account it is in; `total` counts every ticked message.
+ */
+export interface ListSelectionView {
+ emails: { id: string; accountId?: string }[];
+ total: number;
+}
diff --git a/lib/plugin-sandbox/assistant/c.batch.ts b/lib/plugin-sandbox/assistant/c.batch.ts
new file mode 100644
index 000000000..15061edf0
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/c.batch.ts
@@ -0,0 +1,158 @@
+// Area c, batches: email.batch / email.undoBatch. One change to up to 200
+// messages of the signed-in account, through the email store's own batch
+// functions (so their plugin hooks run and the list updates), with restore
+// points taken first and one entry in the app's undo history: the plugin's
+// Undo and Ctrl+Z take back the same thing, once.
+
+import type { Email } from '../../jmap/types';
+import type { IJMAPClient } from '../../jmap/client-interface';
+import { useEmailStore, type EmailRestoreBatch, type BatchActionTarget } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { isArchiveActionEnabled } from '../../archive-gate';
+import { recordUndo, runUndo } from '../../undo-history';
+import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from '../../thread-utils';
+import { readId, readObject, readString, sessionClient, withinRate } from '../host-api-input';
+import { MAX_PLUGIN_MOVES_PER_MINUTE } from '../host-api-mail';
+import { isSharedOut } from '../../mailbox-shared-out';
+import { issueUndoToken, undoAccount, withUndoToken } from './c.undo';
+import type { MailBatchAction, MailBatchResult } from './c.types';
+
+/** Most messages one email.batch call changes. */
+export const MAX_PLUGIN_BATCH = 200;
+const ACTIONS: readonly MailBatchAction[] = ['archive', 'move', 'read', 'unread', 'tag', 'untag'];
+const FIELDS = ['id', 'mailboxIds', 'keywords', 'receivedAt', 'threadId'];
+
+interface BatchUndo {
+ entryId: string;
+ /** Runs the undo once; the history entry and the plugin's call share it. */
+ run: () => Promise;
+}
+
+/** Runs `fn` once while it runs or after it went through; a failure may be tried again. */
+function once(fn: () => Promise): () => Promise {
+ let promise: Promise | null = null;
+ return () => (promise ??= fn().catch((err: unknown) => {
+ promise = null;
+ throw err;
+ }));
+}
+
+function readIds(value: unknown, method: string): string[] {
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`${method}: emailIds must be a non-empty array`);
+ if (value.length > MAX_PLUGIN_BATCH) throw new Error(`${method}: at most ${MAX_PLUGIN_BATCH} messages at once`);
+ return [...new Set(value.map((id, i) => readId(id, method, `emailIds[${i}]`)))];
+}
+
+const hasTag = (email: Email, tagId: string) => !!(email.keywords?.[KEYWORD_PREFIX + tagId] || email.keywords?.[KEYWORD_PREFIX_LEGACY + tagId]);
+
+function withTag(email: Email, tagId: string, on: boolean): Email {
+ const keywords = { ...email.keywords };
+ if (on) keywords[KEYWORD_PREFIX + tagId] = true;
+ else { delete keywords[KEYWORD_PREFIX + tagId]; delete keywords[KEYWORD_PREFIX_LEGACY + tagId]; }
+ return { ...email, keywords };
+}
+
+/**
+ * Change many messages of the signed-in account at once: archive (by the
+ * user's archiveMode), move into one of its own folders (not a shared one,
+ * not the trash), mark read or unread, put a tag on or take it off.
+ */
+export async function doEmailBatch(pluginId: string, input: unknown): Promise {
+ const method = 'email.batch';
+ const data = readObject(input, method, 'batch', ['emailIds', 'action', 'mailboxId', 'tagId']);
+ const ids = readIds(data.emailIds, method);
+ const action = data.action as MailBatchAction;
+ if (!ACTIONS.includes(action)) throw new Error(`${method}: action must be one of ${ACTIONS.join(', ')}`);
+ if (action !== 'move' && data.mailboxId !== undefined) throw new Error(`${method}: mailboxId is only for move`);
+ if (action !== 'tag' && action !== 'untag' && data.tagId !== undefined) throw new Error(`${method}: tagId is only for tag and untag`);
+ const client = sessionClient(method);
+ const store = useEmailStore.getState();
+
+ let mailboxId = '';
+ if (action === 'move') {
+ mailboxId = readId(data.mailboxId, method, 'mailboxId');
+ // Only the account's own folders, never one shared with other people.
+ const box = store.mailboxes.find((m) => m.id === mailboxId && !m.isShared);
+ // The share map isn't in the folder list: ask the server, for the folder
+ // and the folders above it (a shared parent shares its children).
+ if (!box || (await isSharedOut(client, store.mailboxes, mailboxId))) {
+ throw new Error(`${method}: mailboxId is not a folder of this account that only the user reads`);
+ }
+ if (box.role === 'trash') throw new Error(`${method}: messages can't be moved to the trash this way`);
+ }
+ let tagId = '';
+ if (action === 'tag' || action === 'untag') {
+ tagId = readString(data.tagId, method, 'tagId', 100, { min: 1, trim: true });
+ if (!useSettingsStore.getState().emailKeywords.some((k) => k.id === tagId)) throw new Error(`${method}: tagId is not one of the user's tags`);
+ }
+ if (action === 'archive' && !isArchiveActionEnabled()) throw new Error(`${method}: archiving is switched off by the administrator`);
+ if ((action === 'archive' || action === 'move') && !withinRate('email.move', pluginId, MAX_PLUGIN_MOVES_PER_MINUTE)) {
+ throw new Error(`${method}: too many moves, try again in a minute`);
+ }
+
+ // The messages as the server has them now, from the signed-in account only.
+ const emails = (await client.getEmailFields(ids, FIELDS, undefined)) as unknown as Email[];
+ const found = emails.filter((e) => e && typeof e.id === 'string' && ids.includes(e.id));
+ const missing = ids.length - found.length;
+ const target: BatchActionTarget = { emailIds: found.map((e) => e.id), client, emails: found };
+ const none: MailBatchResult = { done: 0, failed: ids.length, undoToken: null };
+ if (found.length === 0) return none;
+
+ let done = 0;
+ let undo: () => Promise;
+ let label: string;
+ if (action === 'archive' || action === 'move') {
+ const batches: EmailRestoreBatch[] = [{
+ client, accountId: undefined,
+ points: found.map((e) => ({ id: e.id, mailboxIds: { ...e.mailboxIds }, seen: !!e.keywords?.$seen })),
+ }];
+ const ok = action === 'archive'
+ ? await store.batchArchive(client, target)
+ : await store.batchMoveToMailbox(client, mailboxId, target);
+ if (!ok) return none;
+ done = found.length;
+ undo = () => useEmailStore.getState().undoMove(client, batches, []);
+ label = `${action} ${done}`;
+ } else if (action === 'read' || action === 'unread') {
+ const read = action === 'read';
+ const changed = found.filter((e) => !!e.keywords?.$seen !== read).map((e) => e.id);
+ if (changed.length > 0 && !(await store.batchMarkAsRead(client, read, { emailIds: changed, client }))) return none;
+ done = found.length;
+ undo = async () => {
+ if (changed.length > 0 && !(await useEmailStore.getState().batchMarkAsRead(client, !read, { emailIds: changed, client }))) {
+ throw new Error('stopped');
+ }
+ };
+ label = `${action} ${done}`;
+ } else {
+ const add = action === 'tag';
+ const changed = found.filter((e) => hasTag(e, tagId) !== add);
+ if (changed.length > 0 && !(await store.batchSetTag(client, tagId, add, target))) return none;
+ done = found.length;
+ const after = changed.map((e) => withTag(e, tagId, add));
+ undo = async () => {
+ if (after.length > 0 && !(await useEmailStore.getState().batchSetTag(client, tagId, !add, { emailIds: after.map((e) => e.id), client, emails: after }))) {
+ throw new Error('stopped');
+ }
+ };
+ label = `${action} ${done}`;
+ }
+
+ const run = once(undo);
+ // One entry in the app's undo history: Ctrl+Z reaches it too.
+ const entry = recordUndo(`Plugin ${pluginId}: ${label} messages`, () => { void run().catch(() => { /* the plugin's undo reports it */ }); });
+ const token = issueUndoToken(pluginId, undoAccount(client), 'batch', { entryId: entry.id, run } satisfies BatchUndo);
+ return { done, failed: missing, undoToken: token };
+}
+
+/** Take back an email.batch: the messages go back to their folders, read state and tags. */
+export async function doEmailUndoBatch(pluginId: string, token: unknown): Promise {
+ const method = 'email.undoBatch';
+ const client: IJMAPClient = sessionClient(method);
+ await withUndoToken(method, pluginId, undoAccount(client), 'batch', token, async ({ entryId, run }) => {
+ // Off the history first, so Ctrl+Z can't run it again; an entry already
+ // taken by Ctrl+Z has run (or is running) the same undo.
+ runUndo(entryId);
+ await run();
+ });
+}
diff --git a/lib/plugin-sandbox/assistant/c.calendar.ts b/lib/plugin-sandbox/assistant/c.calendar.ts
new file mode 100644
index 000000000..9605c6565
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/c.calendar.ts
@@ -0,0 +1,177 @@
+// Area c, calendar: answering an invitation (calendar.respond) and deleting
+// an event so it can be taken back (calendar.deleteEvent with an undo token,
+// calendar.undoDelete).
+
+import type { CalendarEvent, Calendar } from '../../jmap/types';
+import type { IJMAPClient } from '../../jmap/client-interface';
+import { useAuthStore } from '@/stores/auth-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { findParticipantByEmails, getInvitationTrustAssessment } from '../../calendar-invitation';
+import { answerInvitation, attendeeOf, readOwnInvitation, type RsvpStatus } from '../../calendar-rsvp';
+import { readId, sessionClient } from '../host-api-input';
+import { readNamedEmail } from '../host-api-mail';
+import { doCalendarDeleteEvent } from '../host-api-calendar';
+import { issueUndoToken, undoAccount, withUndoToken } from './c.undo';
+import type { EventDeleteResult, InvitationResponseView } from './c.types';
+
+const STATUSES: readonly RsvpStatus[] = ['accepted', 'tentative', 'declined'];
+
+/** Every address the user sends as, primary first, lowercased. */
+function identityEmails(): string[] {
+ const { primaryIdentity, identities } = useAuthStore.getState();
+ const emails = [primaryIdentity?.email, ...identities.map((i) => i.email)]
+ .filter((address): address is string => Boolean(address))
+ .map((address) => address.toLowerCase());
+ return [...new Set(emails)];
+}
+
+function ownCalendars(): Calendar[] {
+ return useCalendarStore.getState().calendars.filter((c) => !c.isShared && !c.isTasksOnly);
+}
+
+/** The copy of the invitation in the user's own calendar, with its participants loaded. */
+async function ownCopy(client: IJMAPClient, uid: string | undefined): Promise {
+ if (!uid) return null;
+ const known = useCalendarStore.getState().events.find((e) => e.uid === uid && !e.isShared);
+ if (known) return known;
+ const found = (await client.queryCalendarEvents({ uid })).find((e) => !e.recurrenceId) ?? null;
+ if (found) {
+ useCalendarStore.setState((s) => (s.events.some((e) => e.id === found.id) ? s : { events: [...s.events, found] }));
+ }
+ return found;
+}
+
+/**
+ * Answer the invitation a message of the user's own account carries, the
+ * way the reader's banner answers it: the calendar store writes the answer
+ * and the server sends the reply to the organiser. Resolves to what was
+ * answered, or null when another plugin stopped it. Refuses a message that
+ * is no invitation (a reply, a cancellation), an event the user organises,
+ * one whose sender doesn't check out, and works only while invitations are
+ * read (the setting) and the account has a calendar.
+ */
+export async function doCalendarRespond(emailIdArg: unknown, statusArg: unknown): Promise {
+ const method = 'calendar.respond';
+ const emailId = readId(emailIdArg, method, 'emailId');
+ if (!STATUSES.includes(statusArg as RsvpStatus)) throw new Error(`${method}: status must be one of ${STATUSES.join(', ')}`);
+ const status = statusArg as RsvpStatus;
+ const client = sessionClient(method);
+ if (!useSettingsStore.getState().calendarInvitationParsingEnabled) {
+ throw new Error(`${method}: reading invitations is switched off in Settings > Calendar`);
+ }
+ const calendarState = useCalendarStore.getState();
+ if (!calendarState.supportsCalendar) throw new Error(`${method}: this account has no calendar`);
+
+ const named = await readNamedEmail(client, emailId, method);
+ if (!named) throw new Error(`${method}: no message with id "${emailId}"`);
+ // Mail of a shared or group mailbox is answered as that account: only in
+ // the reader. Search results name the user's own account too.
+ if (named.accountId !== undefined && named.accountId !== client.getAccountId()) throw new Error(`${method}: answer invitations of other accounts in the reader`);
+ const { email } = named;
+
+ const invitation = await readOwnInvitation(client, email);
+ if (!invitation) throw new Error(`${method}: the message has no invitation`);
+ const { parsedEvent, method: itip } = invitation;
+ if (itip !== 'request') throw new Error(`${method}: the message is not an invitation that asks for an answer`);
+
+ const emails = identityEmails();
+ // Always checked, also when From is one of the user's own addresses (it
+ // can be forged): only a sender the server verified passes a mismatch.
+ const trust = getInvitationTrustAssessment(parsedEvent, email, itip, emails);
+ if (trust?.level === 'warning') throw new Error(`${method}: the invitation may not come from who it names; answer it in the reader`);
+
+ const existing = await ownCopy(client, parsedEvent.uid);
+ const { participant: myParticipant, isOrganizer } = attendeeOf(
+ (existing ? findParticipantByEmails(existing, emails) : null) ?? findParticipantByEmails(parsedEvent, emails),
+ );
+ if (isOrganizer) throw new Error(`${method}: the user organises this event`);
+ const calendars = ownCalendars();
+ const calendarId = (calendars.find((c) => c.isDefault) ?? calendars[0])?.id;
+
+ const store = useCalendarStore.getState();
+ const outcome = await answerInvitation({
+ client,
+ store: { rsvpEvent: store.rsvpEvent, updateEvent: store.updateEvent, importEvents: store.importEvents, events: () => useCalendarStore.getState().events },
+ parsedEvent,
+ existingEvent: existing,
+ myParticipant,
+ actingEmails: emails,
+ calendarId,
+ sharedMailAccount: null,
+ findImported: (uid) => ownCopy(client, uid),
+ resolveExisting: async () => {
+ if (!existing) return null;
+ if (existing.participants && Object.keys(existing.participants).length > 0) return existing;
+ const hydrated = await client.getCalendarEvent(existing.id);
+ if (!hydrated) return existing;
+ useCalendarStore.setState((s) => ({ events: s.events.map((e) => (e.id === hydrated.id ? hydrated : e)) }));
+ return hydrated;
+ },
+ }, status);
+ if (outcome === 'stopped') return null;
+ if (outcome === 'added') throw new Error(`${method}: the event was added to the calendar, but the user is not among its participants, so no answer was sent`);
+ if (outcome === 'failed') throw new Error(`${method}: the invitation could not be answered`);
+ const organizer = parsedEvent.organizerCalendarAddress ?? parsedEvent.replyTo?.imip ?? null;
+ return {
+ status,
+ title: parsedEvent.title ?? '',
+ start: parsedEvent.start ?? null,
+ organizer: organizer ? organizer.replace(/^mailto:/i, '') : null,
+ };
+}
+
+// ─── Delete with undo ─────────────────────────────────────────
+
+/** What the server sets itself, and the app's own fields: left out when the event is created again. */
+const NOT_RECREATED = [
+ 'id', 'created', 'updated', 'sequence', 'isDraft', 'isOrigin', 'baseEventId', 'blobId', 'utcStart', 'utcEnd',
+ 'originalId', 'originalCalendarIds', 'accountId', 'accountName', 'isShared',
+] as const;
+
+/** A single event of the user's own calendars: the only kind an undo can put back as it was. */
+function restorable(event: CalendarEvent | null, id: string): event is CalendarEvent {
+ return !!event && event.id === id && !event.isShared && !event.recurrenceId
+ && !(event.recurrenceRules && event.recurrenceRules.length > 0)
+ && !(event.recurrenceOverrides && Object.keys(event.recurrenceOverrides).length > 0);
+}
+
+/**
+ * calendar.deleteEvent as before (one occurrence when given an occurrence's
+ * id; never sends cancellations), and now with an undo token for a single
+ * event of the user's own calendars. The host keeps the copy; undoDelete
+ * creates it again with the same uid.
+ */
+export async function doCalendarDeleteWithUndo(pluginId: string, idArg: unknown): Promise {
+ const method = 'calendar.deleteEvent';
+ const id = readId(idArg, method);
+ const client = sessionClient(method);
+ const event = await client.getCalendarEvent(id).catch(() => null);
+ if (!restorable(event, id)) {
+ await doCalendarDeleteEvent(id);
+ return { undoToken: null };
+ }
+ if (!useCalendarStore.getState().events.some((e) => e.id === id)) {
+ useCalendarStore.setState((s) => ({ events: [...s.events, event] }));
+ }
+ const deleted = await useCalendarStore.getState().deleteEvent(client, id, false);
+ if (!deleted) return { undoToken: null };
+ const copy: Record = { ...event };
+ for (const key of NOT_RECREATED) delete copy[key];
+ return { undoToken: issueUndoToken(pluginId, undoAccount(client), 'event', copy) };
+}
+
+/**
+ * Puts back an event calendar.deleteEvent deleted, from the host's copy.
+ * Sends no invitations. When it can't be created (another plugin stopped
+ * it, the server refused) the token stays for another try.
+ */
+export async function doCalendarUndoDelete(pluginId: string, tokenArg: unknown): Promise<{ id: string }> {
+ const method = 'calendar.undoDelete';
+ const client = sessionClient(method);
+ return withUndoToken, { id: string }>(method, pluginId, undoAccount(client), 'event', tokenArg, async (copy) => {
+ const created = await useCalendarStore.getState().createEvent(client, copy, false);
+ if (!created) throw new Error(`${method}: the event was not created again`);
+ return { id: created.id };
+ });
+}
diff --git a/lib/plugin-sandbox/assistant/c.filters.ts b/lib/plugin-sandbox/assistant/c.filters.ts
new file mode 100644
index 000000000..c1cc035fe
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/c.filters.ts
@@ -0,0 +1,243 @@
+// Area c, filters: filters.list / create / undo over the model Settings >
+// Filters uses (lib/filters/account-filters.ts). A rule is checked the way
+// the rule editor checks it, and nothing that sends mail elsewhere or drops
+// it may be in it: no forward or redirect, no reject (it answers the
+// sender), no discard (it deletes), no keep. Moves and copies go only into
+// folders of the filters' own account that nobody else shares.
+
+import type { FilterAction, FilterCondition, FilterRule } from '../../jmap/sieve-types';
+import type { IJMAPClient } from '../../jmap/client-interface';
+import type { Mailbox } from '../../jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useFilterStore } from '@/stores/filter-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import {
+ OpaqueFiltersError, readAccountFilters, restoreAccountFilters, updateAccountFilters, FiltersChangedError,
+ FiltersSaveVetoedError, type FiltersChange,
+} from '../../filters/account-filters';
+import { insertRuleAtTop, mailboxSievePath } from '../../filters/quick-rules';
+import { isValidSieveHeaderName } from '../../sieve/generator';
+import { KEYWORD_PREFIX, KEYWORD_PREFIX_LEGACY } from '../../thread-utils';
+import { generateUUID } from '../../utils';
+import { isPlainRecord, readId, readObject, readString, sessionClient } from '../host-api-input';
+import { isSharedOut } from '../../mailbox-shared-out';
+import { issueUndoToken, undoAccount, withUndoToken } from './c.undo';
+import type {
+ FilterCreateResult, FilterRuleActionType, FilterRuleField, FilterRuleInfo, FilterRuleOp, FiltersListView,
+} from './c.types';
+
+const MAX_CONDITIONS = 10;
+const MAX_ACTIONS = 10;
+const MAX_VALUES = 20;
+const MAX_VALUE = 500;
+
+const ADDRESS_OPS: FilterRuleOp[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with', 'address_is', 'domain_is'];
+const TEXT_OPS: FilterRuleOp[] = ['contains', 'not_contains', 'is', 'not_is', 'starts_with', 'ends_with'];
+/** Which comparisons each field takes. */
+export const FILTER_FIELD_OPS: Readonly> = {
+ from: ADDRESS_OPS, to: ADDRESS_OPS, cc: ADDRESS_OPS,
+ subject: TEXT_OPS, body: ['contains', 'not_contains'], header: TEXT_OPS,
+ size: ['greater_than', 'less_than'],
+ attachment: ['has_any', 'has_type'],
+};
+/** The only actions a plugin's rule may have. */
+export const FILTER_ACTIONS: readonly FilterRuleActionType[] = ['move', 'copy', 'mark_read', 'star', 'add_label', 'stop'];
+
+function sieveClient(method: string): IJMAPClient {
+ const client = sessionClient(method);
+ if (!client.supportsSieve()) throw new Error(`${method}: the mail server has no filters`);
+ return client;
+}
+
+/** The folders of the filters' account, as the server has them. */
+async function sieveMailboxes(client: IJMAPClient, accountId: string): Promise {
+ return client.getMailboxes(accountId === client.getAccountId() ? undefined : accountId);
+}
+
+const tagIdOf = (keyword: string | undefined) => (keyword?.startsWith(KEYWORD_PREFIX)
+ ? keyword.slice(KEYWORD_PREFIX.length)
+ : keyword?.startsWith(KEYWORD_PREFIX_LEGACY) ? keyword.slice(KEYWORD_PREFIX_LEGACY.length) : keyword);
+
+function ruleToInfo(rule: FilterRule, mailboxes: Mailbox[]): FilterRuleInfo {
+ return {
+ id: rule.id,
+ name: rule.name,
+ isActive: rule.enabled,
+ editable: !rule.origin || rule.origin === 'bulwark',
+ match: rule.matchType,
+ conditions: rule.conditions.map((c) => ({
+ field: c.field, op: c.comparator, value: Array.isArray(c.value) ? [...c.value] : c.value,
+ ...(c.headerName ? { headerName: c.headerName } : {}),
+ })),
+ actions: rule.actions.map((a) => {
+ if (a.type === 'move' || a.type === 'copy') {
+ const box = a.mailboxId ? mailboxes.find((m) => m.id === a.mailboxId) : undefined;
+ return { type: a.type, ...(a.mailboxId ? { mailboxId: a.mailboxId } : {}), folder: box ? mailboxSievePath(mailboxes, box.id) : (a.value ?? '') };
+ }
+ if (a.type === 'add_label') return { type: a.type, tagId: tagIdOf(a.value) ?? '' };
+ if (a.type === 'forward') return { type: a.type, value: a.value ?? '' };
+ return { type: a.type };
+ }),
+ };
+}
+
+/** The account's filter rules, and whether rules can be added. */
+export async function doFiltersList(): Promise {
+ const method = 'filters.list';
+ const client = sessionClient(method);
+ if (!client.supportsSieve()) return { supported: false, editable: false, rules: [] };
+ const accountId = client.getSieveAccountId();
+ const [filters, mailboxes] = await Promise.all([readAccountFilters(client, accountId), sieveMailboxes(client, accountId)]);
+ if (filters.parsed.isOpaque) return { supported: true, editable: false, rules: [] };
+ return { supported: true, editable: true, rules: filters.parsed.rules.map((r) => ruleToInfo(r, mailboxes)) };
+}
+
+function readValues(value: unknown, method: string, field: string): string[] {
+ const list = Array.isArray(value) ? value : [value];
+ if (list.length === 0 || list.length > MAX_VALUES) throw new Error(`${method}: ${field} takes 1 to ${MAX_VALUES} values`);
+ return list.map((v, i) => {
+ const text = readString(v, method, `${field}[${i}]`, MAX_VALUE, { min: 1, trim: true });
+ // eslint-disable-next-line no-control-regex
+ if (/[\u0000-\u001f\u007f]/.test(text)) throw new Error(`${method}: ${field} has a line break or a control character`);
+ return text;
+ });
+}
+
+function readCondition(entry: unknown, method: string, i: number): FilterCondition {
+ const at = `conditions[${i}]`;
+ const c = readObject(entry, method, at, ['field', 'op', 'value', 'headerName']);
+ const field = c.field as FilterRuleField;
+ const ops = FILTER_FIELD_OPS[field];
+ if (!ops) throw new Error(`${method}: ${at}.field must be one of ${Object.keys(FILTER_FIELD_OPS).join(', ')}`);
+ const op = c.op as FilterRuleOp;
+ if (!ops.includes(op)) throw new Error(`${method}: ${at}.op for ${field} must be one of ${ops.join(', ')}`);
+ const condition: FilterCondition = { field, comparator: op, value: '' };
+ if (op === 'has_any') {
+ if (c.value !== undefined && c.value !== '') throw new Error(`${method}: ${at} takes no value`);
+ } else if (field === 'size') {
+ const size = typeof c.value === 'number' ? String(c.value) : c.value;
+ if (typeof size !== 'string' || !/^\d{1,10}$/.test(size)) throw new Error(`${method}: ${at}.value must be a size in bytes`);
+ condition.value = size;
+ } else {
+ const values = readValues(c.value, method, `${at}.value`);
+ condition.value = values.length === 1 ? values[0] : values;
+ }
+ if (field === 'header') {
+ const name = readString(c.headerName, method, `${at}.headerName`, 100, { min: 1, trim: true });
+ if (!isValidSieveHeaderName(name)) throw new Error(`${method}: ${at}.headerName is not a header name`);
+ condition.headerName = name;
+ } else if (c.headerName !== undefined) {
+ throw new Error(`${method}: ${at}.headerName is only for field header`);
+ }
+ return condition;
+}
+
+function readAction(entry: unknown, method: string, i: number, mailboxes: Mailbox[]): FilterAction {
+ const at = `actions[${i}]`;
+ if (!isPlainRecord(entry)) throw new Error(`${method}: ${at} must be an object`);
+ const type = entry.type;
+ if (!FILTER_ACTIONS.includes(type as FilterRuleActionType)) {
+ // Forward, redirect, reject, discard and keep are refused by name, so a
+ // plugin (or a model behind it) hears that they are not allowed at all.
+ throw new Error(`${method}: ${at}.type "${String(type)}" is not allowed; a rule may only ${FILTER_ACTIONS.join(', ')}`);
+ }
+ const a = readObject(entry, method, at, ['type', 'mailboxId', 'tagId']);
+ if (type === 'move' || type === 'copy') {
+ const id = readId(a.mailboxId, method, `${at}.mailboxId`);
+ const box = mailboxes.find((m) => m.id === id);
+ // Only the account's own folders, and none that is shared with others:
+ // a rule must not file mail where other people read it.
+ if (!box || box.isShared) {
+ throw new Error(`${method}: ${at}.mailboxId is not a folder of this account that only the user reads`);
+ }
+ if (box.role === 'trash') throw new Error(`${method}: ${at} may not move mail to the trash`);
+ if (a.tagId !== undefined) throw new Error(`${method}: ${at}.tagId is only for add_label`);
+ return { type, mailboxId: id, value: mailboxSievePath(mailboxes, id) };
+ }
+ if (a.mailboxId !== undefined) throw new Error(`${method}: ${at}.mailboxId is only for move and copy`);
+ if (type === 'add_label') {
+ const tagId = readString(a.tagId, method, `${at}.tagId`, 100, { min: 1, trim: true });
+ if (!useSettingsStore.getState().emailKeywords.some((k) => k.id === tagId)) throw new Error(`${method}: ${at}.tagId is not one of the user's tags`);
+ return { type, value: tagId };
+ }
+ if (a.tagId !== undefined) throw new Error(`${method}: ${at}.tagId is only for add_label`);
+ return { type: type as FilterAction['type'] };
+}
+
+/** The rule a plugin asked for, checked like the rule editor checks one. */
+export function readFilterRule(input: unknown, method: string, mailboxes: Mailbox[]): FilterRule {
+ const data = readObject(input, method, 'rule', ['name', 'match', 'conditions', 'actions']);
+ const name = readString(data.name, method, 'name', 100, { min: 1, trim: true });
+ const match = data.match === undefined ? 'all' : data.match;
+ if (match !== 'all' && match !== 'any') throw new Error(`${method}: match must be all or any`);
+ if (!Array.isArray(data.conditions) || data.conditions.length === 0 || data.conditions.length > MAX_CONDITIONS) {
+ throw new Error(`${method}: conditions must hold 1 to ${MAX_CONDITIONS} conditions`);
+ }
+ if (!Array.isArray(data.actions) || data.actions.length === 0 || data.actions.length > MAX_ACTIONS) {
+ throw new Error(`${method}: actions must hold 1 to ${MAX_ACTIONS} actions`);
+ }
+ const conditions = data.conditions.map((c, i) => readCondition(c, method, i));
+ const actions = data.actions.map((a, i) => readAction(a, method, i, mailboxes));
+ if (!actions.some((a) => a.type !== 'stop')) throw new Error(`${method}: the rule must do something besides stop`);
+ const stop = actions.some((a) => a.type === 'stop');
+ return {
+ id: generateUUID(),
+ name,
+ enabled: true,
+ matchType: match,
+ conditions,
+ actions: actions.filter((a) => a.type !== 'stop'),
+ stopProcessing: stop,
+ };
+}
+
+/** Let an open Settings > Filters page show the change. */
+function refreshFilterStore(client: IJMAPClient, accountId: string): void {
+ const filters = useFilterStore.getState();
+ if (useAuthStore.getState().client === client && filters.selectedAccountId === accountId) {
+ void filters.fetchFilters(client, accountId);
+ }
+}
+
+/** Add a rule at the top of the account's filters. Resolves to its id and an undo token. */
+export async function doFiltersCreate(pluginId: string, input: unknown): Promise {
+ const method = 'filters.create';
+ const client = sieveClient(method);
+ const accountId = client.getSieveAccountId();
+ const mailboxes = await sieveMailboxes(client, accountId);
+ const rule = readFilterRule(input, method, mailboxes);
+ // The share map isn't in the folder list: ask the server for each target
+ // and the folders above it, and refuse a folder someone else reads.
+ for (const action of rule.actions) {
+ if ((action.type === 'move' || action.type === 'copy') && action.mailboxId
+ && (await isSharedOut(client, mailboxes, action.mailboxId, accountId === client.getAccountId() ? undefined : accountId))) {
+ throw new Error(`${method}: the folder "${action.value}" is shared with other people; a rule may not file mail there`);
+ }
+ }
+ let change: FiltersChange | null;
+ try {
+ change = await updateAccountFilters(client, accountId, (rules) => insertRuleAtTop(rules, rule));
+ } catch (err) {
+ if (err instanceof OpaqueFiltersError) throw new Error(`${method}: the filters were edited by hand; add the rule in Settings > Filters`);
+ if (err instanceof FiltersSaveVetoedError) throw new Error(`${method}: another plugin stopped the change`);
+ throw err;
+ }
+ if (!change) throw new Error(`${method}: nothing was written`);
+ refreshFilterStore(client, accountId);
+ return { id: rule.id, undoToken: issueUndoToken(pluginId, undoAccount(client), 'filters', change) };
+}
+
+/** Take back a rule filters.create added: the script gets its earlier bytes back. */
+export async function doFiltersUndo(pluginId: string, token: unknown): Promise {
+ const method = 'filters.undo';
+ const client = sieveClient(method);
+ await withUndoToken(method, pluginId, undoAccount(client), 'filters', token, async (change) => {
+ try {
+ await restoreAccountFilters(client, change);
+ } catch (err) {
+ if (err instanceof FiltersChangedError) throw new Error(`${method}: the filters changed since; change them in Settings > Filters`);
+ throw err;
+ }
+ refreshFilterStore(client, change.accountId);
+ });
+}
diff --git a/lib/plugin-sandbox/assistant/c.runtime.ts b/lib/plugin-sandbox/assistant/c.runtime.ts
new file mode 100644
index 000000000..8e1aa2ba1
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/c.runtime.ts
@@ -0,0 +1,39 @@
+// Area c of the assistant's host APIs, inside the plugin sandbox (see README.md).
+
+import type { CallApi, RuntimeAdditions } from './runtime';
+import type {
+ FilterRuleInput, InvitationAnswer, MailBatchInput, SettingValue,
+} from './c.types';
+
+/** The methods this area adds to the plugin's api, by namespace. */
+export function runtime(callApi: CallApi): RuntimeAdditions {
+ return {
+ calendar: {
+ // Answer an invitation in a message of the user's own account, as the
+ // reader's banner does; the server sends the reply to the organiser
+ // (permission calendar:respond).
+ respond: (emailId: string, status: InvitationAnswer) => callApi('calendar.respond', [emailId, status]),
+ // Put back an event calendar.deleteEvent deleted, from its undo token (calendar:write).
+ undoDelete: (undoToken: string) => callApi('calendar.undoDelete', [undoToken]),
+ },
+ filters: {
+ // The account's filter rules (filters:read).
+ list: () => callApi('filters.list', []),
+ // Add a rule at the top; no forwarding, rejecting or deleting rules (filters:write).
+ create: (rule: FilterRuleInput) => callApi('filters.create', [rule]),
+ undo: (undoToken: string) => callApi('filters.undo', [undoToken]),
+ },
+ email: {
+ // One change to up to 200 messages of the account, with one Undo (email:write).
+ batch: (batch: MailBatchInput) => callApi('email.batch', [batch]),
+ undoBatch: (undoToken: string) => callApi('email.undoBatch', [undoToken]),
+ },
+ app: {
+ // Settings in the user's language: label, page, current and new value (settings:read).
+ describeSettings: (changes: Record | string[]) => callApi('app.describeSettings', [changes]),
+ // Change settings on the allow-list (settings:change).
+ setSettings: (changes: Record) => callApi('app.setSettings', [changes]),
+ undoSettings: (undoToken: string) => callApi('app.undoSettings', [undoToken]),
+ },
+ };
+}
diff --git a/lib/plugin-sandbox/assistant/c.settings.ts b/lib/plugin-sandbox/assistant/c.settings.ts
new file mode 100644
index 000000000..09497b8e5
--- /dev/null
+++ b/lib/plugin-sandbox/assistant/c.settings.ts
@@ -0,0 +1,291 @@
+// Area c, settings: app.describeSettings, app.setSettings and
+// app.undoSettings over the allow-list in lib/settings-schema.ts.
+//
+// A change is checked at the moment it is made, the way the settings page
+// would show it: the key must be in the allow-list, the value one the
+// setting may take there, the row must be shown (its feature gate, its
+// page's gate, no-third-party mode, the admin's hidden flag), not locked by
+// the admin and within the admin's restriction. It is written through the
+// same setters the pages use (updateSetting, setTheme, setLocale,
+// setViewMode), so plugin hooks run, the open page shows it and it syncs;
+// then it is read back, since another plugin may have stopped it.
+
+import { useSettingsStore } from '@/stores/settings-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { useLocaleStore } from '@/stores/locale-store';
+import { useCalendarStore } from '@/stores/calendar-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { settingsHooks } from '../../plugin-hooks';
+import { noThirdPartyTraffic } from '../../third-party-mode';
+import { toastDurationAtLeast } from '../../accessibility-settings';
+import { detectBrowserLocale } from '@/i18n/detect-locale';
+import { getByPath } from '../../settings-search';
+import {
+ DENIED_SETTINGS, PAGE_GATES, SETTINGS_SCHEMA, isAllowedSettingValue, settingValueLabelKey,
+ type SettingSchemaEntry,
+} from '../../settings-schema';
+import { isPlainRecord, sessionClient } from '../host-api-input';
+import { messagesFor } from '../host-api-app';
+import { issueUndoToken, undoAccount, withUndoToken } from './c.undo';
+import type {
+ SettingDescription, SettingRefusal, SettingValue, SettingsChangeResult, SettingsUndoResult,
+} from './c.types';
+
+/** Most settings one call names. */
+export const MAX_PLUGIN_SETTINGS = 20;
+/** How long a change may wait for another plugin's onBeforeSettingChange. */
+const VETO_WAIT_MS = 3000;
+
+type Messages = Record;
+
+export function currentSettingValue(entry: SettingSchemaEntry): SettingValue | null {
+ let value: unknown;
+ switch (entry.store) {
+ case 'theme': value = (useThemeStore.getState() as unknown as { theme?: unknown }).theme; break;
+ case 'locale': value = useLocaleStore.getState().locale; break;
+ case 'calendar': value = useCalendarStore.getState().viewMode; break;
+ default: value = (useSettingsStore.getState() as unknown as Record)[entry.key];
+ }
+ if (Array.isArray(value)) return [...value] as SettingValue;
+ return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' ? value : null;
+}
+
+const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b);
+
+/**
+ * app.getSettings names two settings after what they mean to a reader
+ * (language, calendarView); setSettings takes those names too.
+ */
+const SETTING_ALIASES: Readonly> = { language: 'locale', calendarView: 'viewMode' };
+export const canonicalSetting = (name: string): string => SETTING_ALIASES[name] ?? name;
+const schemaEntry = (key: string): SettingSchemaEntry | undefined => (Object.prototype.hasOwnProperty.call(SETTINGS_SCHEMA, key) ? SETTINGS_SCHEMA[key] : undefined);
+
+function gateOn(gate: Parameters['isFeatureEnabled']>[0]): boolean {
+ try { return usePolicyStore.getState().isFeatureEnabled(gate); } catch { return true; }
+}
+
+/**
+ * Why `key` can't be changed (to `value`, when given) right now, or null.
+ * `pending` holds the other values of the same call (calendar hours are
+ * checked against each other).
+ */
+export function settingRefusal(name: string, value?: unknown, pending: Record = {}): SettingRefusal | null {
+ const key = canonicalSetting(name);
+ const entry = schemaEntry(key);
+ if (!entry) return DENIED_SETTINGS.includes(key) || key in (useSettingsStore.getState() as object) ? 'not_allowed' : 'unknown';
+ const policy = usePolicyStore.getState();
+ if (entry.gate && !gateOn(entry.gate)) return 'unavailable';
+ const pageGate = PAGE_GATES[entry.page];
+ if (pageGate && !gateOn(pageGate)) return 'unavailable';
+ if (entry.thirdParty && noThirdPartyTraffic()) return 'unavailable';
+ if (entry.store === 'settings' && policy.isSettingHidden(key)) return 'hidden';
+ if (entry.store === 'settings' && policy.isSettingLocked(key) && !entry.preset) return 'locked';
+ if (entry.store === 'theme' && policy.getColorModePolicy().locked) return 'locked';
+ if (value === undefined) return null;
+ if (!isAllowedSettingValue(entry, value)) return 'bad_value';
+ if (entry.kind.type === 'choice' || entry.kind.type === 'list') {
+ const values = Array.isArray(value) ? value : [value];
+ const gated = entry.kind.options.filter((o) => o.gate && !gateOn(o.gate));
+ if (gated.some((o) => values.includes(o.value))) return 'unavailable';
+ }
+ const restricted = restrictionRefusal(entry, value);
+ if (restricted) return restricted;
+ if (key === 'calendarDayStartHour' || key === 'calendarDayEndHour') {
+ const state = useSettingsStore.getState();
+ const start = Number(key === 'calendarDayStartHour' ? value : pending.calendarDayStartHour ?? state.calendarDayStartHour);
+ const end = Number(key === 'calendarDayEndHour' ? value : pending.calendarDayEndHour ?? state.calendarDayEndHour);
+ if (!(end > start)) return 'bad_value';
+ }
+ return null;
+}
+
+/** The admin's restriction on the value (allowed values, minimum, maximum), or null. */
+function restrictionRefusal(entry: SettingSchemaEntry, value: unknown): SettingRefusal | null {
+ const key = entry.key;
+ const restriction = entry.store === 'settings' ? usePolicyStore.getState().getRestriction(key) : undefined;
+ if (!restriction) return null;
+ if (restriction.allowedValues && !restriction.allowedValues.some((v) => same(v, value))) return 'bad_value';
+ if (typeof value === 'number') {
+ if (key === 'toastDuration' && typeof restriction.min === 'number') {
+ if (!toastDurationAtLeast(value, restriction.min)) return 'bad_value';
+ } else if (typeof restriction.min === 'number' && value < restriction.min) return 'bad_value';
+ if (typeof restriction.max === 'number' && value > restriction.max) return 'bad_value';
+ }
+ return null;
+}
+
+function format(messages: Messages, key: string, params: Record = {}): string | null {
+ const raw = getByPath(messages, key);
+ if (typeof raw !== 'string') return null;
+ return raw.replace(/\{(\w+)\}/g, (all, name: string) => params[name] ?? all);
+}
+
+function userLocale(): string {
+ return useLocaleStore.getState().locale || detectBrowserLocale('en');
+}
+
+/** A value in the user's words: the page's own label, else a formatted value. */
+export function settingValueLabel(entry: SettingSchemaEntry, value: unknown, messages: Messages, locale = userLocale()): string {
+ if (value === null || value === undefined) return '';
+ const key = settingValueLabelKey(entry, value);
+ if (key) {
+ const zone = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return 'UTC'; } })();
+ const text = format(messages, key, { zone });
+ if (text) return text;
+ }
+ const kind = entry.kind;
+ if ((kind.type === 'choice' || kind.type === 'list')) {
+ const label = (v: unknown) => {
+ const option = kind.options.find((o) => o.value === v);
+ return (option?.label && format(messages, option.label)) || option?.text || String(v);
+ };
+ return Array.isArray(value) ? value.map(label).join(', ') : label(value);
+ }
+ if (kind.type === 'hour' && typeof value === 'number') return `${String(value).padStart(2, '0')}:00`;
+ if (kind.type === 'days' && Array.isArray(value)) {
+ // 2023-01-01 was a Sunday (day 0).
+ return value.map((d) => {
+ try { return new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2023, 0, 1 + Number(d)))); } catch { return String(d); }
+ }).join(', ');
+ }
+ if (kind.type === 'locale' && typeof value === 'string' && value && value !== 'auto') {
+ try { return new Intl.DisplayNames([value], { type: 'language' }).of(value) ?? value; } catch { return value; }
+ }
+ return Array.isArray(value) ? value.join(', ') : String(value);
+}
+
+function readChanges(arg: unknown, method: string): Record {
+ if (!isPlainRecord(arg)) throw new Error(`${method}: changes must be an object of setting names and values`);
+ const keys = Object.keys(arg);
+ if (keys.length === 0 || keys.length > MAX_PLUGIN_SETTINGS) throw new Error(`${method}: name 1 to ${MAX_PLUGIN_SETTINGS} settings`);
+ return arg;
+}
+
+/**
+ * The settings named, in the user's language: label, page, current value
+ * and, for a change, the new one, and whether it may be made now. `arg` is
+ * an object of changes or an array of setting names.
+ */
+export async function doDescribeSettings(arg: unknown): Promise