Merge the AI assistant everywhere (plugin 2.2 host side)

This commit is contained in:
Linus Rath
2026-11-03 01:57:36 +01:00
168 changed files with 11533 additions and 454 deletions
+4 -2
View File
@@ -404,9 +404,10 @@ function MobileHeaderSkeleton() {
* The reader's top toolbar while a message loads. The loaded reader lifts its
* toolbar above the message row on a tablet or desktop (EmailViewer's
* `liftToolbar`); its loading state draws this there too, so the row below
* doesn't move when the message arrives.
* doesn't move when the message arrives. `end` is a real control at the end
* of the row (the reader's plugin panel button), where the loaded toolbar has it.
*/
export function MailMessageToolbarSkeleton() {
export function MailMessageToolbarSkeleton({ end }: { end?: ReactNode } = {}) {
const button = "h-8 w-20 rounded-md";
return (
<div className="bg-background flex-shrink-0 border-b border-border">
@@ -438,6 +439,7 @@ export function MailMessageToolbarSkeleton() {
<Block className={button} />
<Block className={cn(button, "max-md:hidden")} />
<Block className="h-8 w-8 rounded-md" />
{end}
</div>
</div>
</div>
@@ -0,0 +1,153 @@
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
import { useCalendarStore } from '@/stores/calendar-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useToastStore } from '@/stores/toast-store';
import { useTaskStore } from '@/stores/task-store';
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
// The plugin side panel (app-side-panel) in Calendar: its button at the end
// of the toolbar row, the panel last in the row under it, and the event the
// user opened in its context.
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
const media = vi.hoisted(() => ({ mobile: false, desktop: true }));
vi.mock('@/hooks/use-media-query', () => ({
useIsMobile: () => media.mobile,
useIsDesktop: () => media.desktop,
useIsTablet: () => !media.mobile && !media.desktop,
useMediaQuery: () => false,
}));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-refresh-gesture', () => ({ useRefreshGesture: () => ({ indicator: null }) }));
vi.mock('@/hooks/use-calendar-push-refresh', () => ({ useCalendarPushRefresh: () => {} }));
vi.mock('@/hooks/use-account-principal-emails', () => ({ useAccountPrincipalEmails: () => [] }));
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
const config = vi.hoisted(() => ({ demoMode: false }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => config }));
const lite = vi.hoisted(() => ({ on: false }));
vi.mock('@/lib/lite', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/lite')>()),
get IS_LITE() { return lite.on; },
}));
import { CalendarApp } from '../calendar-app';
vi.setConfig({ testTimeout: 30000 });
const RIGHTS = {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
};
const calendars = [{
id: 'cal', name: 'Personal', color: '#123456', isDefault: true, sortOrder: 0, description: null,
isSubscribed: true, isVisible: true, includeInAvailability: 'all', defaultAlertsWithTime: null,
defaultAlertsWithoutTime: null, timeZone: null, shareWith: null, myRights: RIGHTS,
}] as unknown as Calendar[];
function makeEvent(overrides: Partial<CalendarEvent> = {}): CalendarEvent {
return {
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Standup', description: '', start: '2026-10-12T10:00:00',
duration: 'PT1H', timeZone: null, showWithoutTime: false, calendarIds: { cal: true },
participants: null, alerts: null, recurrenceRules: null, isOrigin: true, status: 'confirmed',
...overrides,
} as unknown as CalendarEvent;
}
const client = {
getAccountId: () => 'acc',
getMaxParticipantsPerEvent: () => null,
supportsCalendars: () => true,
queryCalendarEvents: vi.fn().mockResolvedValue([]),
getCalendarEvent: vi.fn().mockResolvedValue(null),
getCalendarTasks: vi.fn().mockResolvedValue([]),
};
const store = {
fetchCalendars: vi.fn(),
fetchEvents: vi.fn(),
refreshAllSubscriptions: vi.fn(),
createEvent: vi.fn(),
updateEvent: vi.fn(),
deleteEvent: vi.fn(),
rsvpEvent: vi.fn(),
};
function setUp(events: CalendarEvent[] = [makeEvent()], viewMode: 'month' | 'week' | 'day' | 'agenda' | 'tasks' = 'week') {
for (const fn of Object.values(store)) fn.mockReset();
store.createEvent.mockImplementation(async (_c: unknown, data: Partial<CalendarEvent>) => ({ ...makeEvent(), ...data, id: 'new' }));
store.updateEvent.mockResolvedValue(true);
store.deleteEvent.mockResolvedValue(true);
useAuthStore.setState({ isAuthenticated: true, client: client as never, isLoading: false, activeAccountId: 'acc' });
useCalendarStore.setState({
calendars, events, supportsCalendar: true, selectedCalendarIds: ['cal'], viewMode,
selectedDate: new Date(2026, 9, 12), isLoading: false, isLoadingEvents: false, error: null,
...store,
} as never);
useSettingsStore.setState({ calendarHoverPreview: 'instant', enableCalendarTasks: true, proInterface: false } as never);
useToastStore.setState({ toasts: [] } as never);
useTaskStore.setState({ tasks: [] } as never);
client.getCalendarTasks.mockResolvedValue([]);
}
beforeEach(() => {
media.mobile = false;
media.desktop = true;
localStorage.clear();
setUp();
});
afterEach(() => clearSlotOffers());
const frame = () => screen.queryByTestId('plugin-frame-app-side-panel');
describe('Calendar side panel', () => {
it('has no button and no panel without a plugin', () => {
render(<CalendarApp />);
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
expect(frame()).toBeNull();
});
it('puts the button at the end of the toolbar row and the panel under it, with the open event', async () => {
offerSlots('p1', ['app-side-panel']);
render(<CalendarApp />);
const toggle = document.querySelector<HTMLElement>('[data-plugin-panel-toggle]')!;
const row = screen.getByTestId('calendar-view-menu').closest('.h-14')!;
// The last control of the row: after the view menu.
const buttons = Array.from(row.querySelectorAll('button'));
expect(buttons[buttons.length - 1]).toBe(toggle);
expect(toggle).toHaveAccessibleName('plugin_panel');
expect(toggle).toHaveAttribute('aria-pressed', 'true');
// Open by default; the last thing in the row below the toolbar.
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
expect(panel.parentElement!.lastElementChild).toBe(panel);
expect(panel.parentElement!.getAttribute('data-tour')).toBe('calendar-view');
expect(framePropsOf(frame()!)).toEqual({ view: 'calendar', context: {}, focusRequest: 0 });
fireEvent.click(screen.getAllByRole('button', { name: /Standup/ })[0]);
await screen.findByRole('dialog', { name: 'Standup' });
await waitFor(() => expect((framePropsOf(frame()!).context as { event?: { title: string } }).event?.title).toBe('Standup'));
fireEvent.click(toggle);
expect(frame()).toBeNull();
expect(toggle).toHaveAttribute('aria-pressed', 'false');
});
it('has no panel on a phone', () => {
media.mobile = true;
media.desktop = false;
offerSlots('p1', ['app-side-panel']);
act(() => { render(<CalendarApp />); });
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
expect(frame()).toBeNull();
});
});
+10
View File
@@ -118,6 +118,7 @@ import { MvTopBar } from "@/components/layout/mv-top-bar";
import { useMountainViewShell } from "@/hooks/use-mountain-view-shell";
import { calendarHooks } from "@/lib/plugin-hooks";
import { eventToView } from "@/lib/plugin-projection";
import { useAppSidePanel } from "@/components/plugins/app-side-panel";
import { AppMain, SkipLink } from "@/components/layout/app-landmarks";
import { calendarShortcutAction } from "@/lib/calendar-shortcuts";
import { isAppShortcutBlocked, isArrowKeyWidgetTarget } from "@/lib/keyboard";
@@ -1763,6 +1764,11 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
// event gets the primary ring).
const selectedEventId = detailPinned ? detailEvent?.id ?? null : showEventModal ? editEvent?.id ?? null : null;
// The plugin side panel (app-side-panel), with the event the user opened.
const openEvent = detailPinned ? detailEvent : showEventModal ? editEvent : null;
const sidePanelContext = useMemo(() => (openEvent ? { event: eventToView(openEvent) } : {}), [openEvent]);
const sidePanel = useAppSidePanel({ view: "calendar", context: sidePanelContext });
const visibleEvents = useMemo(() => {
const filtered = events.filter((e) => {
if (!e.start || !e.calendarIds) return false;
@@ -2142,6 +2148,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
/>
) : null}
onCloseSearch={leaveSearch}
panelToggle={sidePanel.toggle}
/>
);
@@ -2321,6 +2328,7 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
{/* isolate keeps the views' sticky headers (z-50) below the toolbar's
import dropdown, which overlaps this area (#1049). */}
<div
ref={sidePanel.rowRef}
className="flex flex-1 overflow-hidden relative isolate"
data-tour="calendar-view"
// Where the focus goes after an event is saved or deleted and
@@ -2397,6 +2405,8 @@ export function CalendarApp({ linkSegments: routeSegments }: CalendarAppProps =
<Plus className="h-6 w-6" />
</Button>
)}
{sidePanel.panel}
</div>
</AppMain>
</div>
+6
View File
@@ -61,6 +61,8 @@ interface CalendarToolbarProps {
mobileSearchField?: ReactNode;
/** Phones: closes the search row. */
onCloseSearch?: () => void;
/** The plugin side panel's button, at the very end of the row (desktop and tablet). */
panelToggle?: ReactNode;
}
// Shortcut keys handled by the calendar app (see its keydown handler).
@@ -101,6 +103,7 @@ export function CalendarToolbar({
onOpenSearch,
mobileSearchField,
onCloseSearch,
panelToggle,
}: CalendarToolbarProps) {
const t = useTranslations("calendar");
const paint = useCvdColor();
@@ -484,6 +487,9 @@ export function CalendarToolbar({
))}
</ContextMenu>
</div>
{/* The row is px-3; the margin puts the button where the reader has it (px-4, lg:px-6). */}
{panelToggle && <div className="flex items-center ms-1 me-1 lg:me-3" data-panel-toggle-slot="">{panelToggle}</div>}
</div>
);
}
@@ -0,0 +1,132 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ContactCard } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { clearSlotOffers, framePropsOf, offerSlots } from '@/components/plugins/__tests__/fake-slot-offer';
import { setActionDispatcher } from '@/lib/plugin-sandbox/actions';
// The plugin side panel (app-side-panel) in Contacts: a row of its own over
// the contact and the panel, with the open contact's name and the button at
// its end, and the contact in the panel's context.
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
const media = vi.hoisted(() => ({ mobile: false }));
vi.mock('@/hooks/use-media-query', async (importOriginal) => ({
...(await importOriginal<typeof import('@/hooks/use-media-query')>()),
useIsMobile: () => media.mobile,
}));
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: vi.fn(), back: vi.fn() }),
useParams: () => ({ locale: 'en' }),
usePathname: () => '/en/contacts',
useSearchParams: () => new URLSearchParams(),
}));
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => <nav data-testid="rail" /> }));
vi.mock('@/components/layout/sidebar-apps-modal', () => ({ SidebarAppsModal: () => null }));
vi.mock('@/components/layout/inline-app-view', () => ({ InlineAppView: () => null }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/plugins/app-top-banner-slot', () => ({ AppTopBannerSlot: () => null }));
vi.mock('@/hooks/use-sidebar-apps', () => ({
useSidebarApps: () => ({
showAppsModal: false, inlineApp: null, loadedApps: [], handleManageApps: vi.fn(),
handleInlineApp: vi.fn(), closeInlineApp: vi.fn(), closeAppsModal: vi.fn(),
}),
}));
import { ContactsApp } from '../contacts-app';
import { useAuthStore } from '@/stores/auth-store';
import { useContactStore } from '@/stores/contact-store';
const alice: ContactCard = {
id: 'c-alice',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Alice' }, { kind: 'surname', value: 'Smith' }], isOrdered: true },
emails: { e0: { address: 'alice@example.com' } },
};
const bob: ContactCard = {
id: 'c-bob',
addressBookIds: {},
name: { components: [{ kind: 'given', value: 'Bob' }, { kind: 'surname', value: 'Jones' }], isOrdered: true },
};
function renderContacts() {
act(() => {
useAuthStore.setState({ isAuthenticated: true, client: { supportsPrincipals: () => false } as unknown as IJMAPClient, isLoading: false });
useContactStore.setState({
contacts: [alice, bob], addressBooks: [], selectedContactId: null, selectedContactIds: new Set(),
searchQuery: '', supportsSync: false, contactsLoaded: true,
});
});
return render(<ContactsApp />);
}
afterEach(() => {
media.mobile = false;
setActionDispatcher(null);
clearSlotOffers();
localStorage.clear();
});
const frame = () => screen.queryByTestId('plugin-frame-app-side-panel');
describe('Contacts side panel', () => {
it('adds no row without a plugin', () => {
renderContacts();
expect(document.querySelector('[data-contacts-panel-row]')).toBeNull();
expect(document.querySelector('[data-plugin-panel-toggle]')).toBeNull();
});
it("puts the open contact's name and the button in an h-14 row over the contact and the panel", () => {
offerSlots('p1', ['app-side-panel']);
renderContacts();
const row = document.querySelector<HTMLElement>('[data-contacts-panel-row]')!;
expect(row.className).toContain('h-14');
const toggle = row.querySelector<HTMLElement>('[data-plugin-panel-toggle]')!;
expect(row.lastElementChild).toBe(toggle);
expect(toggle).toHaveAccessibleName('plugin_panel');
expect(toggle).toHaveAttribute('aria-pressed', 'true');
expect(framePropsOf(frame()!)).toEqual({ view: 'contacts', context: { selectedCount: 0 }, focusRequest: 0 });
// The panel is the last thing in the row under it.
const panel = document.querySelector<HTMLElement>('[data-app-side-panel]')!;
expect(panel.parentElement!.lastElementChild).toBe(panel);
expect(row.nextElementSibling).toBe(panel.parentElement);
fireEvent.click(screen.getByText('Alice Smith'));
expect(row.firstElementChild).toHaveTextContent('Alice Smith');
expect(framePropsOf(frame()!).context).toMatchObject({ contact: { id: 'c-alice', fullName: 'Alice Smith', emails: ['alice@example.com'] }, selectedCount: 0 });
fireEvent.click(toggle);
expect(frame()).toBeNull();
// The row and its button stay, so the panel can come back.
expect(row.querySelector('[data-plugin-panel-toggle]')).toHaveAttribute('aria-pressed', 'false');
});
it('keeps what was typed in a contact form when the panel starts docking', () => {
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
fireEvent.click(screen.getByRole('button', { name: /detail.edit|edit/ }));
fireEvent.change(screen.getByPlaceholderText('middle_name'), { target: { value: 'Marie' } });
const field = screen.getByPlaceholderText('middle_name');
act(() => offerSlots('p1', ['app-side-panel']));
expect(document.querySelector('[data-contacts-panel-row]')).not.toBeNull();
// The same field, with what the user typed.
expect(field.isConnected).toBe(true);
expect(screen.getByPlaceholderText('middle_name')).toHaveValue('Marie');
});
it('puts the phone button over an open contact, where the list and its header are gone', () => {
media.mobile = true;
const dispatch = vi.fn().mockResolvedValue(undefined);
setActionDispatcher(dispatch);
offerSlots('p1', ['app-side-panel']);
renderContacts();
fireEvent.click(screen.getByText('Alice Smith'));
const buttons = document.querySelectorAll<HTMLElement>('[data-app-side-panel-phone]');
expect(buttons).toHaveLength(1);
fireEvent.click(buttons[0]);
const [, surface, action, ctx] = dispatch.mock.calls[0];
expect([surface, action]).toEqual(['app-side-panel', 'open']);
expect((ctx as { context: { contact?: { fullName: string } } }).context.contact?.fullName).toBe('Alice Smith');
});
});
+35 -5
View File
@@ -23,6 +23,8 @@ import { DirectoryEntryDetail } from "@/components/contacts/directory-entry-deta
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { exportContacts } from "@/components/contacts/contact-export";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AppSidePanelPhoneButton, useAppSidePanel } from "@/components/plugins/app-side-panel";
import { contactToView } from "@/lib/plugin-projection";
import { useStrictestDevices } from "@/hooks/use-device-policy";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail, type DirectoryEntry } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -392,6 +394,20 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
const selectedGroup = selectedGroupId ? contacts.find(c => c.id === selectedGroupId) || null : null;
const selectedGroupMembers = useMemo(() => selectedGroupId ? getGroupMembers(selectedGroupId) : [], [selectedGroupId, getGroupMembers]);
// The plugin side panel (app-side-panel), with the contact the user opened
// and how many are ticked. On desktop it sits under a row of its own with
// the open contact's name and the panel's button.
const openContact = (view === "detail" || view === "edit") && activeCategory !== "directory" && selectedContact?.kind !== "group" ? selectedContact : null;
const selectedCount = selectedContactIds.size;
const sidePanelContext = useMemo(() => ({
...(openContact ? { contact: contactToView(openContact) } : {}),
selectedCount,
}), [openContact, selectedCount]);
const sidePanel = useAppSidePanel({ view: "contacts", context: sidePanelContext });
const sidePanelTitle = openContact
? getContactDisplayName(openContact)
: view === "group-detail" && selectedGroup ? getContactDisplayName(selectedGroup) : "";
// Collect all unique keywords across contacts
const allKeywords = useMemo(() => {
const kws = new Set<string>();
@@ -1439,11 +1455,20 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
</>
)}
{/* Panel 3: Detail / Form */}
{/* Panel 3: Detail / Form. With a plugin side panel, a row with the
contact's name and the panel's button sits over it, like the
reader's toolbar. The same element either way, so a form keeps
what was typed when the panel starts or stops docking. */}
{showRightPanel && (
<div className="flex-1 min-w-0 flex flex-col">
<div className="flex-1 min-w-0 flex flex-col" data-contacts-side-panel-column="">
{sidePanel.docks && (
<div className="flex items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0" data-contacts-panel-row="">
<div className="flex-1 min-w-0 truncate text-sm font-semibold" title={sidePanelTitle || undefined}>{sidePanelTitle}</div>
{sidePanel.toggle}
</div>
)}
{isMobile && (
<div className="px-3 py-2 border-b border-border">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<Button
variant="ghost"
size="sm"
@@ -1453,10 +1478,15 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
<ArrowLeft className="w-4 h-4 me-2" />
{returnToEmail ? t("back_to_email") : t("back_to_contacts")}
</Button>
{/* The list's header with its button is hidden while a contact is open. */}
<AppSidePanelPhoneButton className="ms-auto" />
</div>
)}
<div className="flex-1 min-h-0">
{renderRightPanel()}
<div ref={sidePanel.rowRef} className="relative flex flex-1 min-h-0">
<div className="flex-1 min-w-0 min-h-0">
{renderRightPanel()}
</div>
{sidePanel.panel}
</div>
</div>
)}
@@ -113,7 +113,7 @@ describe('email-detail-sidebar', () => {
rerender(<EmailViewer email={textMail('Hi')} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
expect(screen.getByRole('button', { name: 'plugin_panel_show' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'plugin_panel', pressed: false })).toBeInTheDocument();
});
it('lays the panel over the message when opened anyway, instead of squeezing it', () => {
@@ -121,7 +121,7 @@ describe('email-detail-sidebar', () => {
offerSlots('p1', ['email-detail-sidebar']);
viewer(textMail('Hi'));
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel', pressed: false }));
const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
expect(panel.getAttribute('data-plugin-detail-panel')).toBe('overlay');
expect(panel.className).toContain('absolute');
@@ -168,7 +168,7 @@ describe('email-detail-sidebar', () => {
offerSlots('p1', ['email-detail-sidebar'], { startFolded: ['email-detail-sidebar'] });
const { unmount } = viewer(textMail('Hi'));
expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
fireEvent.click(screen.getByRole('button', { name: 'plugin_panel', pressed: false }));
expect(screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!.getAttribute('data-plugin-detail-panel')).toBe('docked');
unmount();
viewer(textMail('Hi'));
@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { FileNode } from '@/lib/jmap/types';
import { useToastStore } from '@/stores/toast-store';
import { fileHooks } from '@/lib/plugin-hooks';
import { SaveToFilesDialog } from '../save-to-files-dialog';
// "Save to Files" files an attachment in a folder of the Files app by its
@@ -62,4 +63,21 @@ describe('SaveToFilesDialog', () => {
fireEvent.click(saveHere);
await waitFor(() => expect(createFileNode).toHaveBeenCalledWith('invoice.pdf', 'blob-1', 'application/pdf', 1234, null));
});
// The dialog used to file the blob without the upload hooks, so a plugin
// that checks what goes into Files never heard about it.
it('asks plugins first, as an upload into Files does, and saves nothing when one says no', async () => {
const createFileNode = vi.fn().mockResolvedValue({});
const veto = vi.fn(() => false);
const reg = fileHooks.onBeforeFileUpload.register('dlp', veto);
const client = { listAllFileNodes: vi.fn().mockResolvedValue(nodes), createFileNode } as unknown as IJMAPClient;
render(<SaveToFilesDialog attachment={attachment} client={client} onClose={vi.fn()} />);
fireEvent.click(await screen.findByText('Documents'));
fireEvent.click(screen.getByRole('button', { name: 'save_here' }));
await waitFor(() => expect(veto).toHaveBeenCalledWith({ name: 'invoice.pdf', size: 1234, type: 'application/pdf', path: '/Documents' }));
expect(createFileNode).not.toHaveBeenCalled();
reg.dispose();
});
});
@@ -0,0 +1,102 @@
import { act, cleanup, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// The reader panel hears which messages are ticked in the list
// (`listSelection`), so the AI assistant can work on several at once.
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
const seen: Record<string, unknown>[] = [];
vi.mock('@/components/plugins/plugin-slot', () => ({
PluginSlot: ({ name, extraProps }: { name: string; extraProps?: Record<string, unknown> }) => {
if (name === 'email-detail-sidebar' && extraProps) seen.push(extraProps);
return React.createElement('div', { 'data-slot': name });
},
}));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({
usePluginSlotOffers: (slot: string) => (slot === 'email-detail-sidebar' ? [{ pluginId: 'p', order: 100 }] : []),
}));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { EmailViewer } = await import('../email-viewer');
const { useEmailStore } = await import('@/stores/email-store');
const { useAuthStore } = await import('@/stores/auth-store');
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
const row = (id: string) => ({
id, threadId: `t-${id}`, mailboxIds: { inbox: true }, keywords: {}, subject: id, receivedAt: '2026-01-01T00:00:00Z',
from: [{ name: 'Bob', email: 'bob@example.org' }], preview: '', hasAttachment: false, size: 1,
} as unknown as Email);
const client = { getAccountId: () => 'acc1' } as unknown as IJMAPClient;
afterEach(() => {
cleanup();
seen.length = 0;
useEmailStore.setState({ emails: [], selectedEmailKeys: new Set() });
useAuthStore.setState({ client: null });
});
describe('the reader panel prop listSelection', () => {
it('names the ticked messages when two or more are, and is null otherwise', () => {
useAuthStore.setState({ client });
useEmailStore.setState({ emails: [row('a'), row('b'), row('c')], selectedEmailKeys: new Set(['a']), selectedMailbox: 'inbox', mailboxes });
render(<EmailViewer email={null} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} />);
expect(seen.at(-1)).toMatchObject({ listSelection: null });
act(() => { useEmailStore.setState({ selectedEmailKeys: new Set(['a', 'c', 'b']) }); });
expect(seen.at(-1)?.listSelection).toEqual({
emails: [{ id: 'a', accountId: 'acc1' }, { id: 'b', accountId: 'acc1' }, { id: 'c', accountId: 'acc1' }],
total: 3,
});
// New mail arriving doesn't hand the panel a new object for the same selection.
const before = seen.at(-1)?.listSelection;
act(() => { useEmailStore.setState({ emails: [row('new'), row('a'), row('b'), row('c')] }); });
expect(seen.at(-1)?.listSelection).toBe(before);
});
it('does not render the reader again when the list changes but the ticks do not', () => {
useAuthStore.setState({ client });
useEmailStore.setState({ emails: [row('a'), row('b')], selectedEmailKeys: new Set(['a', 'b']), selectedMailbox: 'inbox', mailboxes });
render(<EmailViewer email={null} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} />);
const renders = seen.length;
act(() => { useEmailStore.setState({ emails: [row('new'), row('a'), row('b')] }); });
act(() => { useEmailStore.setState({ emails: [row('newer'), row('new'), row('a'), row('b')], threadEmailsCache: new Map() }); });
expect(seen.length).toBe(renders);
// A new tick does.
act(() => { useEmailStore.setState({ selectedEmailKeys: new Set(['a', 'b', 'new']) }); });
expect(seen.length).toBeGreaterThan(renders);
});
it('tells the panel when this login can not read the open message\'s parts', () => {
// An open message makes the viewer ask the login more; nothing of it matters here.
const client = new Proxy({ getAccountId: () => 'acc1' } as Record<string | symbol, unknown>, {
get: (target, key) => (key in target ? target[key] : key === 'then' ? undefined : () => undefined),
}) as unknown as IJMAPClient;
useAuthStore.setState({ client });
const message = { ...row('m'), textBody: [], htmlBody: [], attachments: [], bodyValues: {} } as unknown as Email;
const view = () => render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} blobSource={{ client: otherLogin, accountId: 'acc-2' }} />);
const otherLogin = { getAccountId: () => 'acc-2' } as unknown as IJMAPClient;
view();
expect((seen.at(-1)?.email as { blobsReadable?: boolean }).blobsReadable).toBe(false);
cleanup();
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} blobSource={{ client, accountId: undefined }} />);
expect(seen.at(-1)?.email).not.toHaveProperty('blobsReadable');
});
it('gives a panel outside the main list (a Pro tab) no ticks', () => {
useAuthStore.setState({ client });
useEmailStore.setState({ emails: [row('a'), row('b')], selectedEmailKeys: new Set(['a', 'b']), selectedMailbox: 'inbox', mailboxes });
render(<EmailViewer email={null} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} withListSelection={false} />);
expect(seen.at(-1)).toMatchObject({ listSelection: null });
});
});
@@ -57,14 +57,14 @@ describe('plugin detail panel and narrow messages', () => {
renderViewer();
expect(screen.queryByTestId('slot-email-detail-sidebar')).not.toBeInTheDocument();
expect(screen.getByLabelText('plugin_panel_show')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'plugin_panel', pressed: false })).toBeInTheDocument();
});
it('opens anyway when the user asks', () => {
reportWidth = 640;
renderViewer();
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); });
act(() => { fireEvent.click(screen.getByRole('button', { name: 'plugin_panel', pressed: false })); });
expect(screen.getByTestId('slot-email-detail-sidebar')).toBeInTheDocument();
});
@@ -41,6 +41,7 @@ class NoopResizeObserver {
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const { useSettingsStore } = await import('@/stores/settings-store');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
@@ -60,7 +61,7 @@ const show = () => render(
/** The plugin panel, open or folded to its toggle. */
const sidePanel = () =>
(document.querySelector('[data-plugin-detail-panel]') as HTMLElement | null)
?? (screen.getByLabelText('plugin_panel_show').closest('div') as HTMLElement);
?? (screen.getByLabelText('plugin_panel').closest('div') as HTMLElement);
/** An item of the top toolbar. */
const toolbarItem = () => document.querySelector('[data-overflow-item]') as HTMLElement;
@@ -68,8 +69,24 @@ const toolbarItem = () => document.querySelector('[data-overflow-item]') as HTML
afterEach(() => {
cleanup();
device.isMobile = false;
useSettingsStore.setState({ toolbarPosition: 'top' });
});
const view = (props: { email: Email | null; isLoading: boolean }) => (
<EmailViewer mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} {...props} />
);
/** The panel button: there is exactly one, in a toolbar row at the top of the reader, never in the panel. */
const theToggle = () => {
const all = document.querySelectorAll('[data-plugin-panel-toggle]');
expect(all).toHaveLength(1);
const toggle = all[0] as HTMLElement;
expect(toggle.closest('[data-viewer-top-toolbar="full-width"]')).toBeTruthy();
expect(toggle.closest('[data-plugin-detail-panel]')).toBeNull();
const root = document.querySelector('[data-tour="email-viewer"]') ?? toggle.closest('.relative.flex-col');
expect(root!.firstElementChild!.contains(toggle)).toBe(true);
return toggle;
};
describe('the reader toolbar and the plugin side panel', () => {
it('runs the toolbar over the side panel on a desktop', () => {
show();
@@ -122,22 +139,65 @@ describe('the reader toolbar and the plugin side panel', () => {
const toggle = document.querySelector('[data-plugin-panel-toggle]') as HTMLElement;
// At the end of the toolbar, not in the panel.
expect(toggle.closest('[data-plugin-detail-panel]')).toBeNull();
expect(toggle.getAttribute('aria-label')).toBe('plugin_panel_hide');
expect(toggle.getAttribute('aria-label')).toBe('plugin_panel');
expect(toggle.getAttribute('aria-pressed')).toBe('true');
// A landmark for screen readers, named after its plugin.
expect(screen.getByRole('complementary')).toBe(document.querySelector('[data-plugin-detail-panel]'));
act(() => { fireEvent.click(toggle); });
expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull();
// Folded, the panel leaves no strip of its own: the toolbar button brings it back.
expect(screen.getAllByLabelText('plugin_panel_show')).toHaveLength(1);
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel_show')); });
expect(screen.getAllByLabelText('plugin_panel')).toHaveLength(1);
act(() => { fireEvent.click(screen.getByLabelText('plugin_panel')); });
expect(document.querySelector('[data-plugin-detail-panel]')).toBeTruthy();
});
it('puts the panel button in the same toolbar row with no message open, folded or not, and while one loads', () => {
const { rerender } = render(view({ email: null, isLoading: false }));
const row = () => theToggle().closest('[data-viewer-top-toolbar]') as HTMLElement;
expect(row().hasAttribute('data-toolbar-toggle-only')).toBe(true);
// As high as the full toolbar.
expect(row().firstElementChild!.className).toContain('h-14');
expect(theToggle().getAttribute('title')).toBe('plugin_panel');
expect(theToggle().getAttribute('aria-pressed')).toBe('true');
act(() => { fireEvent.click(theToggle()); });
// Folded: no strip and no row of the panel's own, the same button in the same row.
expect(document.querySelector('[data-plugin-detail-panel]')).toBeNull();
expect(document.querySelector('[data-reader-side-panel]')!.children).toHaveLength(0);
expect(theToggle().getAttribute('aria-label')).toBe('plugin_panel');
expect(theToggle().getAttribute('aria-pressed')).toBe('false');
act(() => { fireEvent.click(theToggle()); });
// Open: the panel holds no button row above the plugin's own header.
const panel = document.querySelector('[data-plugin-detail-panel]') as HTMLElement;
expect(panel.querySelector('button')).toBeNull();
rerender(view({ email: null, isLoading: true }));
theToggle();
});
it('keeps the panel button for a scheduled message', () => {
const scheduled = { ...email, id: 'e3', isScheduled: true, scheduledSendAt: '2030-01-01T10:00:00Z', scheduledUndoStatus: 'pending' } as unknown as Email;
render(view({ email: scheduled, isLoading: false }));
theToggle();
});
it('keeps the panel button at the top when the toolbar is below the subject', () => {
useSettingsStore.setState({ toolbarPosition: 'below-subject' });
render(view({ email, isLoading: false }));
expect(theToggle().closest('[data-toolbar-toggle-only]')).toBeTruthy();
});
it('keeps the side panel frame from a message to none and back', () => {
mounts['email-detail-sidebar'] = 0;
const { rerender } = render(view({ email, isLoading: false }));
rerender(view({ email: null, isLoading: true }));
rerender(view({ email: null, isLoading: false }));
rerender(view({ email, isLoading: false }));
expect(mounts['email-detail-sidebar']).toBe(1);
});
it('loads a message with its toolbar where the loaded reader puts it', () => {
// The loading state lifts the toolbar above the message row too, so the
// row stays put when the message arrives: the same element at the same
// place (a moved row counts as a layout shift).
const view = (props: { email: Email | null; isLoading: boolean }) => (
<EmailViewer mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onDelete={() => {}} {...props} />
);
const { container, rerender } = render(view({ email: null, isLoading: true }));
const root = container.firstElementChild as HTMLElement;
expect(root.children).toHaveLength(2);
+21 -88
View File
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { answerInvitation } from '@/lib/calendar-rsvp';
import { createPortal } from 'react-dom';
import {
ArrowRight,
@@ -345,26 +346,6 @@ function buildInvitationChangeItems(
return changes;
}
function buildParticipantsForRsvp(
event: Partial<CalendarEvent>,
participantId: string,
status: 'accepted' | 'tentative' | 'declined',
): Record<string, NonNullable<CalendarEvent['participants']>[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,
},
]),
);
}
function getMethodIconTone(method: InvitationMethod, actorStatus?: string | null): string {
switch (method) {
case 'cancel':
@@ -736,78 +717,30 @@ export function CalendarInvitationBanner({ email }: CalendarInvitationBannerProp
setActionNotice(null);
setActionError(null);
setIsProcessing(true);
const replyToForRsvp = parsedEvent.replyTo
|| (parsedEvent.organizerCalendarAddress ? { imip: parsedEvent.organizerCalendarAddress } : null);
// The iMIP REPLY to the organizer is sent by the server: rsvpEvent /
// updateEvent below pass sendSchedulingMessages=true to CalendarEvent/set
// and Stalwart queues the iTIP REPLY itself. A manual client-side reply
// here produced duplicate emails.
try {
// 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
? await resolveExistingEventForRsvp()
: sharedMailAccount && parsedEvent.uid ? await loadSharedCopy(parsedEvent.uid) : null;
const existingEventParticipant = eventForRsvp
? findParticipantByEmails(eventForRsvp, actingEmails)
: null;
const canFallbackToParsedParticipant = Boolean(
existingEvent
&& myParticipant
&& (!eventForRsvp?.participants || Object.keys(eventForRsvp.participants).length === 0)
);
if (eventForRsvp && existingEventParticipant) {
// false: a plugin stopped it and said why; keep the old answer.
if (!(await (answerOptions ? rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, eventForRsvp.id, existingEventParticipant.id, status, replyToForRsvp)))) return;
const outcome = await answerInvitation({
client,
store: { rsvpEvent, updateEvent, importEvents, events: () => useCalendarStore.getState().events },
parsedEvent,
existingEvent,
myParticipant,
actingEmails,
calendarId: calId,
sharedMailAccount,
answerOptions,
resolveExisting: () => (existingEvent
? resolveExistingEventForRsvp()
: parsedEvent.uid ? loadSharedCopy(parsedEvent.uid) : Promise.resolve(null)),
}, status);
// 'stopped': a plugin stopped it and said why; keep the old answer.
if (outcome === 'answered') {
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
} else if (eventForRsvp && canFallbackToParsedParticipant && myParticipant) {
const repairedParticipants = buildParticipantsForRsvp(parsedEvent, myParticipant.id, status);
if (!repairedParticipants) {
setActionError(t('action_failed'));
} else {
const updated = await updateEvent(client, eventForRsvp.id, {
participants: repairedParticipants,
// 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.
...(replyToForRsvp?.imip && !eventForRsvp.organizerCalendarAddress
? { organizerCalendarAddress: replyToForRsvp.imip }
: {}),
}, true);
if (!updated) return;
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
setState('parsed');
}
} else if (eventForRsvp) {
setActionError(t('action_failed'));
} else if (calId) {
// A refusal (Stalwart 1.0's participant limit, say) lands in the
// catch below and is said as it is.
const imported = await importEvents(client, [parsedEvent], calId, { throwIfNoneImported: true });
if (imported === null) return;
if (imported > 0) {
const newEvent = useCalendarStore.getState().events.find(
(e) => e.uid === parsedEvent.uid && (sharedMailAccount ? e.accountId === sharedMailAccount : !e.isShared)
);
const participant = myParticipant
|| (newEvent ? findParticipantByEmails(newEvent, actingEmails) : null);
if (newEvent && participant) {
if (!(await (answerOptions ? rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp, undefined, answerOptions) : rsvpEvent(client, newEvent.id, participant.id, status, replyToForRsvp)))) return;
setRsvpStatus(status);
setActionNotice(t('rsvp_sent'));
} else {
setActionNotice(t('added'));
}
setState('parsed');
} else {
setActionError(t('action_failed'));
}
} else {
} else if (outcome === 'added') {
setActionNotice(t('added'));
setState('parsed');
} else if (outcome === 'failed') {
setActionError(t('action_failed'));
}
} catch (err) {
+52 -46
View File
@@ -1,9 +1,11 @@
"use client";
// The composer-sidebar and composer-sidebar-right plugin panels. Each has a
// width the user can drag (kept across composers) and a toggle to fold it
// to a thin strip. When the composer is too narrow for an open panel next
// to it, the panel folds by itself (right first); opening it anyway lays it
// width the user can drag (kept across composers) and a button in the
// composer's header that shows and hides it (the left panel's at the start,
// the right one's at the end, like the reader's). Folded, a panel takes no
// room at all. When the composer is too narrow for an open panel next to
// it, the panel folds by itself (right first); opening it anyway lays it
// over the composer instead of squeezing the message into a column nobody
// can write in.
@@ -20,8 +22,6 @@ import { cn } from "@/lib/utils";
export const COMPOSER_SIDEBAR_WIDTH = 260;
export const MIN_COMPOSER_SIDEBAR_WIDTH = 220;
export const MAX_COMPOSER_SIDEBAR_WIDTH = 480;
/** Width of a folded panel (the strip with its toggle). */
export const COMPOSER_SIDEBAR_STRIP = 37;
/** Below this window width the panels are not shown at all (the `md` breakpoint). */
export const COMPOSER_SIDEBAR_MIN_WINDOW = 768;
/**
@@ -31,7 +31,7 @@ export const COMPOSER_SIDEBAR_MIN_WINDOW = 768;
export const MIN_COMPOSER_WIDTH = 560;
export type ComposerSidebarSide = "left" | "right";
/** docked: open beside the composer; folded: a strip; overlay: open over the composer. */
/** docked: open beside the composer; folded: hidden; overlay: open over the composer. */
export type ComposerSidebarMode = "docked" | "folded" | "overlay";
interface SideState {
@@ -54,12 +54,11 @@ export function composerSidebarModes(
left: SideState,
right: SideState,
): Record<ComposerSidebarSide, ComposerSidebarMode | null> {
const present = (left.present ? 1 : 0) + (right.present ? 1 : 0);
let room = width > 0 ? width - MIN_COMPOSER_WIDTH - present * COMPOSER_SIDEBAR_STRIP : Infinity;
let room = width > 0 ? width - MIN_COMPOSER_WIDTH : Infinity;
const place = (side: SideState): ComposerSidebarMode | null => {
if (!side.present) return null;
if (side.collapsed) return "folded";
const extra = (side.width ?? COMPOSER_SIDEBAR_WIDTH) - COMPOSER_SIDEBAR_STRIP;
const extra = side.width ?? COMPOSER_SIDEBAR_WIDTH;
if (extra <= room) {
room -= extra;
return "docked";
@@ -186,55 +185,73 @@ export function useComposerSidebars(root: HTMLElement | null, composerId: string
return wantOpen;
}), []);
return { modes, open, close, focusRequests, widths, setSideWidth };
/** The header button: open (and focus the panel, as a plugin's request does) or close. */
const toggle = (side: ComposerSidebarSide) => {
if (modes[side] === "docked" || modes[side] === "overlay") {
close(side);
} else if (modes[side]) {
open(side);
setFocusRequests((prev) => ({ ...prev, [side]: prev[side] + 1 }));
}
};
return { modes, open, close, toggle, focusRequests, widths, setSideWidth };
}
/** The button in the composer's header that shows and hides one panel (none on phones). */
export function ComposerPanelToggle({
side,
mode,
onToggle,
className,
}: {
side: ComposerSidebarSide;
mode: ComposerSidebarMode | null;
onToggle: () => void;
className?: string;
}) {
const t = useTranslations("email_viewer");
if (!mode) return null;
const isOpen = mode !== "folded";
const Icon = side === "left" ? (isOpen ? PanelLeftClose : PanelLeftOpen) : (isOpen ? PanelRightClose : PanelRightOpen);
return (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggle(); }}
className={cn("hidden md:grid h-8 w-8 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground", className)}
title={t(isOpen ? "plugin_panel_hide" : "plugin_panel_show")}
aria-label={t(isOpen ? "plugin_panel_hide" : "plugin_panel_show")}
aria-pressed={isOpen}
data-composer-panel-toggle={side}
>
<Icon className="h-4 w-4" />
</button>
);
}
/** One plugin panel of the composer, in the mode useComposerSidebars picked. */
export function ComposerPluginSidebar({
side,
mode,
onOpen,
onClose,
extraProps,
width = COMPOSER_SIDEBAR_WIDTH,
onWidthChange,
}: {
side: ComposerSidebarSide;
mode: ComposerSidebarMode | null;
onOpen: () => void;
onClose: () => void;
/** What the panel's plugins get: the composer id, the draft and the selection (see the slot catalogue). */
extraProps?: Record<string, unknown>;
width?: number;
/** The user drags the panel's edge; without it the panel has no handle. */
onWidthChange?: (width: number) => void;
}) {
const t = useTranslations("email_viewer");
const dragStart = useRef(width);
if (!mode) return null;
const border = side === "left" ? "border-e" : "border-s";
const OpenIcon = side === "left" ? PanelLeftOpen : PanelRightOpen;
const CloseIcon = side === "left" ? PanelLeftClose : PanelRightClose;
// Folded: nothing on screen (the header button brings it back).
if (mode === "folded") {
return (
<div
className={cn("hidden md:flex flex-col items-center shrink-0 h-full border-border bg-background", border)}
style={{ width: COMPOSER_SIDEBAR_STRIP }}
data-composer-sidebar={side}
data-composer-sidebar-mode="folded"
>
<button
type="button"
onClick={onOpen}
className="m-1 grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
title={t("plugin_panel_show")}
aria-label={t("plugin_panel_show")}
>
<OpenIcon className="h-4 w-4" />
</button>
</div>
);
return <div hidden data-composer-sidebar={side} data-composer-sidebar-mode="folded" />;
}
// Docked beside the composer: a handle on the panel's inner edge. Dragging
@@ -266,17 +283,6 @@ export function ComposerPluginSidebar({
data-composer-sidebar={side}
data-composer-sidebar-mode={mode}
>
<div className={cn("flex items-center px-1 py-1 border-b border-border shrink-0", side === "left" ? "justify-end" : "justify-start")}>
<button
type="button"
onClick={onClose}
className="grid h-7 w-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
title={t("plugin_panel_hide")}
aria-label={t("plugin_panel_hide")}
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
{side === "left"
? <PluginSlot name="composer-sidebar" className="flex flex-col" extraProps={extraProps} />
+26 -7
View File
@@ -78,7 +78,7 @@ import { useIdentityStore } from "@/stores/identity-store";
import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useAccountStore } from "@/stores/account-store";
import { useSettingsStore } from "@/stores/settings-store";
import { ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars";
import { ComposerPanelToggle, ComposerPluginSidebar, useComposerSidebars } from "./composer-plugin-sidebars";
import { ComposerSelectionMenu, useComposerSelectionMenu } from "./composer-selection-menu";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
@@ -2525,7 +2525,9 @@ export function EmailComposer({
// offers "attach from there". No `file` is set, matching the existing
// draft-part hydration path above (a blobId-only attachment is already a
// supported shape, just never previously reachable from a plugin).
const handlePluginAttachmentAdd = useCallback(async (raw: PluginAttachmentUpload): Promise<boolean> => {
// `sourceAccountId` (host only, api.files.attach): the login whose Files
// hold the blob, so a draft sent from another login copies it over first.
const handlePluginAttachmentAdd = useCallback(async (raw: PluginAttachmentUpload, sourceAccountId?: string): Promise<boolean> => {
// The values come from a plugin: check them, and run the same intercept
// and size limit every other way of attaching a file goes through.
const upload = normalizePluginAttachment(raw);
@@ -2543,7 +2545,7 @@ export function EmailComposer({
toast.error(t('attachments_total_too_large', { max: formatFileSize(maxTotal) }));
return false;
}
setAttachments(prev => [...prev, upload]);
setAttachments(prev => [...prev, sourceAccountId ? { ...upload, sourceAccountId } : upload]);
return true;
}, [composerClient, t]);
// Stable props, so a re-render of the composer doesn't post them to the
@@ -3085,6 +3087,7 @@ export function EmailComposer({
return !!editor && replaceEditorSelection(editor, html, { below });
},
attach: (file) => pluginAttachRef.current(file as PluginAttachmentUpload),
attachFile: ({ sourceAccountId, ...file }) => pluginAttachRef.current(file as PluginAttachmentUpload, sourceAccountId),
});
setPluginComposerId(id);
return unregister;
@@ -3174,6 +3177,14 @@ export function EmailComposer({
[pluginDraft, pluginSelection, pluginComposerId, pluginSidebars.focusRequests.right],
);
// The buttons that show and hide the plugin panels, in the header.
const leftPanelToggle = pluginSidebars.modes.left
? <ComposerPanelToggle side="left" mode={pluginSidebars.modes.left} onToggle={() => pluginSidebars.toggle('left')} />
: null;
const rightPanelToggle = pluginSidebars.modes.right
? <ComposerPanelToggle side="right" mode={pluginSidebars.modes.right} onToggle={() => pluginSidebars.toggle('right')} />
: null;
// The right-click menu on selected text, when a plugin put entries in it.
const selectionMenu = useComposerSelectionMenu();
const readSelectedText = useCallback((): string => {
@@ -3213,7 +3224,8 @@ export function EmailComposer({
);
// A cloud picker attaches files, or puts share links in the text.
const pluginAttachmentSourceProps = useMemo(
() => ({ onAttach: handlePluginAttachmentAdd, insertText: pluginInsertText, composerId: pluginComposerId }),
// One argument only: the login of a blob is the host's to say (attachFile).
() => ({ onAttach: (raw: PluginAttachmentUpload) => handlePluginAttachmentAdd(raw), insertText: pluginInsertText, composerId: pluginComposerId }),
[handlePluginAttachmentAdd, pluginInsertText, pluginComposerId],
);
const pluginComposerIdRef = useRef(pluginComposerId);
@@ -4266,7 +4278,7 @@ export function EmailComposer({
<div role="status" aria-live="polite" aria-atomic="true" className="sr-only" data-testid="composer-live-region">
{liveMessage && <span key={liveMessage.n}>{liveMessage.text}</span>}
</div>
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} onOpen={() => pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} extraProps={pluginSidebarLeftProps} width={pluginSidebars.widths.left} onWidthChange={(w) => pluginSidebars.setSideWidth('left', w)} />
<ComposerPluginSidebar side="left" mode={pluginSidebars.modes.left} extraProps={pluginSidebarLeftProps} width={pluginSidebars.widths.left} onWidthChange={(w) => pluginSidebars.setSideWidth('left', w)} />
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
<div
className="flex flex-col h-full bg-background relative flex-1 min-w-0"
@@ -4298,6 +4310,8 @@ export function EmailComposer({
{mode === 'reply' ? t('reply') : mode === 'replyAll' ? t('reply_all') : mode === 'forward' ? t('forward') : t('new_message')}
</h3>
{saveStatus === 'saving' && <Save className="w-3.5 h-3.5 animate-pulse text-muted-foreground flex-shrink-0" aria-label={t('saving')} />}
{leftPanelToggle}
{rightPanelToggle}
{mvWindow.popOut && (
<button
type="button"
@@ -4334,6 +4348,8 @@ export function EmailComposer({
: t('new_message')}
</h3>
{saveStatus === 'saving' && <Save className="w-3.5 h-3.5 animate-pulse text-muted-foreground flex-shrink-0" aria-label={t('saving')} />}
{mvWindow.state !== "minimized" && leftPanelToggle}
{mvWindow.state !== "minimized" && rightPanelToggle}
<button
type="button"
onClick={(e) => { e.stopPropagation(); mvWindow.setState(mvWindow.state === "minimized" ? "normal" : "minimized"); }}
@@ -4363,7 +4379,9 @@ export function EmailComposer({
</button>
</div>
) : (
<div className="flex items-center gap-3 px-4 py-2 md:py-3 border-b bg-background">
<div className="flex items-center gap-3 px-4 py-2 md:py-0 md:h-14 border-b bg-background shrink-0" data-composer-header="">
{/* The left panel's button at the start, the right one's at the end: where each panel is. */}
{leftPanelToggle && <div className="hidden md:flex -ms-2">{leftPanelToggle}</div>}
<div className="flex-1 min-w-0 flex items-center gap-3">
<div className="flex items-center gap-2" data-testid="composer-save-status" data-status={saveStatus}>
<h3 className="font-semibold text-base">
@@ -4431,6 +4449,7 @@ export function EmailComposer({
{t('send')}
</Button>
)}
{rightPanelToggle}
{/* Close at the top end (R4). On a phone it moves to the start, as
the phone's mail apps have it, with Send at the end. */}
<IconButton
@@ -5538,7 +5557,7 @@ export function EmailComposer({
/>
)}
</div>
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} onOpen={() => pluginSidebars.open('right')} onClose={() => pluginSidebars.close('right')} extraProps={pluginSidebarRightProps} width={pluginSidebars.widths.right} onWidthChange={(w) => pluginSidebars.setSideWidth('right', w)} />
<ComposerPluginSidebar side="right" mode={pluginSidebars.modes.right} extraProps={pluginSidebarRightProps} width={pluginSidebars.widths.right} onWidthChange={(w) => pluginSidebars.setSideWidth('right', w)} />
<ComposerSelectionMenu menu={selectionMenu} composerId={pluginComposerId} onClipboard={clipboardFromMenu} />
</div>
);
+56 -58
View File
@@ -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 ? (
<PluginPanelToggle open={!sidebarCollapsed} onClick={toggleDetailSidebar} className={cn(mvViewer && "order-10")} shortcut={panelShortcutOf(detailSlots.map((o) => 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 ? (
<div
data-viewer-top-toolbar="full-width"
data-toolbar-toggle-only=""
className={cn("bg-background flex-shrink-0", !mvViewer && "border-b border-border")}
>
<div className={cn("px-2 sm:px-4 lg:px-6 flex items-center justify-end", mvViewer ? "h-12 lg:ps-4" : "h-14")}>
{panelToggle}
</div>
</div>
) : 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 && (
<div className="contents" data-reader-side-panel="">
{/* With a message open the toggle sits in its toolbar; without one, here. */}
{sidebarCollapsed && !panelToggleInToolbar && (
<div className="flex flex-col items-center border-s border-border bg-background">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(false);
if (readerTooNarrow) setDetailSidebarForcedOpen(true);
}}
className="h-8 w-8 m-1"
aria-label={t('plugin_panel_show')}
>
<PanelRightOpen className="w-4 h-4" />
</Button>
</div>
)}
{!sidebarCollapsed && (
<>
{!sidebarOverlay && (
@@ -3901,6 +3914,8 @@ ${printWatermark ? printWatermark.html : ''}
/>
)}
<div
role="complementary"
aria-label={panelLabelOf(detailSlots.map((o) => 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 && (
<div className="flex items-center justify-end px-1 py-1 border-b border-border shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => {
setDetailSidebarCollapsed(true);
setDetailSidebarForcedOpen(false);
}}
className="h-7 w-7"
aria-label={t('plugin_panel_hide')}
>
<PanelRightClose className="w-4 h-4" />
</Button>
</div>
)}
<div className="flex-1 min-h-0 overflow-y-auto">
<PluginSlot name="email-detail-sidebar" extraProps={pluginDetailProps} />
</div>
@@ -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) => (
<div
ref={setViewerRootEl}
className={cn("flex-1 flex flex-col h-full bg-background overflow-hidden relative", className)}
@@ -3954,10 +3955,18 @@ ${printWatermark ? printWatermark.html : ''}
// message pane, so a linked message keeps the shape it had before the app
// started.
// Its toolbar sits where the loaded reader puts its own: above the message
// row when lifted, so the row doesn't move when the message arrives.
// row when lifted, so the row doesn't move when the message arrives. The
// panel button is real in it, at the end of the row as in the loaded one.
if (isLoading && !email) {
const lifted = toolbarPosition === 'top' && liftToolbar;
return shell(<MailMessageSkeleton toolbar={!lifted} />, lifted ? <MailMessageToolbarSkeleton /> : null);
return shell(
<MailMessageSkeleton toolbar={!lifted} />,
lifted ? (
<div data-viewer-top-toolbar="full-width" className="flex-shrink-0">
<MailMessageToolbarSkeleton end={panelToggle} />
</div>
) : toggleOnlyToolbar,
);
}
if (!email) {
@@ -4494,22 +4503,11 @@ ${printWatermark ? printWatermark.html : ''}
)}
</div>
{/* The plugin side panel: shown or hidden for every message alike. */}
{hasDetailSidebar && !isMobile && (
<Button
variant="ghost"
size="icon"
onClick={toggleDetailSidebar}
className={cn("h-8 w-8", mvViewer && "order-10")}
title={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-label={t(sidebarCollapsed ? 'plugin_panel_show' : 'plugin_panel_hide')}
aria-pressed={!sidebarCollapsed}
data-plugin-panel-toggle=""
>
{sidebarCollapsed ? <PanelRightOpen className="w-4 h-4 text-muted-foreground" /> : <PanelRightClose className="w-4 h-4 text-muted-foreground" />}
</Button>
)}
{toolbarPosition === 'top' && panelToggle}
</div>
)}
{/* 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. */}
<div className="flex-1 min-h-0 flex flex-row overflow-hidden relative">
<>
+18 -10
View File
@@ -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<string>();
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);
@@ -223,6 +223,14 @@ describe('file-preview-section', () => {
onToggleStar: vi.fn(),
} as unknown as React.ComponentProps<typeof FileDetailsPanel>;
it('gives the details panel the same header height as the other side panels', () => {
render(<FileDetailsPanel {...panelProps} pluginFile={notesView} />);
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(<FileDetailsPanel {...panelProps} pluginFile={notesView} />);
@@ -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<typeof import('@/hooks/use-media-query')>()),
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(
<FileBrowser
currentPath="/"
resources={[folder, notes, photo]}
isLoading={false}
error={null}
selectedResources={new Set(selected)}
uploadProgress={null}
clipboard={null}
onNavigate={vi.fn()}
onSetSelection={vi.fn()}
onToggleSelect={vi.fn()}
onPreviewFile={vi.fn()}
onDownload={noop}
onCreateFolder={noop}
onUploadFiles={noop}
onUploadFolder={noop}
onCancelUpload={vi.fn()}
onDelete={noop}
onBatchDelete={noop}
onRename={noop}
onBatchDownload={noop}
onRefresh={noop}
onClearSelection={vi.fn()}
onCut={vi.fn()}
onCopy={vi.fn()}
onPaste={noop}
onMoveTo={noop}
onPreviewImage={vi.fn()}
onCreateFile={noop}
onDuplicate={noop}
getImageUrl={async () => ''}
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<HTMLElement>('[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<HTMLElement>('[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();
});
});
+25 -1
View File
@@ -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({
</div>
)}
<div className="relative flex flex-1 min-h-0">
<div ref={sidePanel.rowRef} className="relative flex flex-1 min-h-0">
<div
ref={scrollAreaRef}
className="relative flex-1 min-w-0 overflow-y-auto"
@@ -1357,6 +1380,7 @@ export function FileBrowser({
</Button>
)}
{sidePanel.panel}
</div>
</AppMain>
+2 -1
View File
@@ -59,7 +59,8 @@ export function FileDetailsPanel({
const pluginProps = useMemo(() => (pluginFile ? { file: pluginFile } : null), [pluginFile]);
const header = (
<div className="flex items-center justify-between px-4 h-11 border-b border-border flex-shrink-0">
// h-14 like the other side panels' headers (the contact panel, the plugin panels).
<div className="flex items-center justify-between px-4 h-14 box-content border-b border-border flex-shrink-0" data-details-header="">
<h2 className="text-sm font-semibold">{t("details")}</h2>
<Button variant="ghost" size="icon" className="h-7 w-7 -me-1.5" onClick={onClose} aria-label={t("close_details")}>
<X className="w-4 h-4" />
+8 -1
View File
@@ -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<null>();
@@ -122,6 +125,8 @@ export function FilesHeader({
const [dropCrumb, setDropCrumb] = useState<string | null>(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 ? <div className="flex items-center shrink-0 me-1 lg:me-3" data-panel-toggle-slot="">{panelToggle}</div> : null;
if (selection) {
return (
@@ -169,6 +174,7 @@ export function FilesHeader({
{compact && !selection.trash && <SelectionPluginActions files={selection.pluginFiles} mimeType={selection.pluginMimeType} overflow />}
<div className="flex-1" />
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
{toggleSlot}
</div>
);
}
@@ -360,6 +366,7 @@ export function FilesHeader({
</ToolbarButton>
)}
<DetailsToggle showDetails={showDetails} onToggle={onToggleDetails} label={t("details")} />
{toggleSlot}
<PhoneHeaderActions
below="md"
menuItems={(close) => (
@@ -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 (
<div data-phone-header-actions="" className={cn("flex items-center gap-1 flex-shrink-0 ms-auto", className)}>
{/* A plugin's side panel of this view, which opens as a dialog on a phone. */}
<AppSidePanelPhoneButton />
<PhoneHeaderMenu menuItems={menuItems} />
{/* 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. */}
@@ -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<typeof import('@/hooks/use-media-query')>()),
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 (
<div>
<div data-testid="toolbar">{panel.toggle}</div>
<div ref={panel.rowRef} data-testid="row">{panel.panel}</div>
<div data-testid="phone-header"><AppSidePanelPhoneButton /></div>
</div>
);
}
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(<Harness />);
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(<Harness />);
// 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(<Harness />);
expect(frame()).toBeNull();
fireEvent.click(toggle());
expect(frame()).not.toBeNull();
unmount();
// The choice holds in the next view.
render(<Harness view="calendar" context={{}} />);
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(<Harness />);
const panel = document.querySelector<HTMLElement>('[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(<Harness />);
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<HTMLElement>('[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(<Harness onOpen={onOpen} />);
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(<EmbeddedContext.Provider value><Harness /></EmbeddedContext.Provider>);
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(<Harness view="calendar" context={{ selectedCount: 2 }} />);
// 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(<Harness dock={false} />);
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(<div><AppSidePanelPhoneButton /></div>);
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(<Harness view="calendar" context={{ selectedCount: 1 }} />);
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(<Harness view="calendar" context={{ selectedCount: 2 }} />);
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(<Harness hidden onOpen={onOpen} />);
expect(frame()).toBeNull();
expect(toggle()).toHaveAttribute('aria-pressed', 'false');
expect(localStorage.getItem(APP_SIDE_PANEL_COLLAPSED_KEY)).toBeNull();
rerender(<Harness hidden={false} onOpen={onOpen} />);
expect(frame()).not.toBeNull();
// Opening it while hidden asks the view to make room.
rerender(<Harness hidden onOpen={onOpen} />);
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(<Harness />);
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<HTMLElement>('[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<HTMLElement>('[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(<Harness />);
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(<Harness />);
expect(toggle()).toHaveAttribute('title', 'plugin_panel (Alt+A)');
expect(toggle()).toHaveAttribute('aria-keyshortcuts', 'Alt+A');
expect(toggle()).toHaveAccessibleName('plugin_panel');
} finally {
dispose();
}
});
});
@@ -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 }) => <div data-testid="custom-frame" data-fill={fill ? 'true' : 'false'} />,
}));
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<HTMLElement>('[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(<PluginDialogHost />);
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(<PluginDialogHost />);
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(<PluginDialogHost />);
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);
});
+367
View File
@@ -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<HTMLButtonElement>;
shortcut?: string | null;
}) {
const t = useTranslations('email_viewer');
const label = t('plugin_panel');
return (
<Button
ref={buttonRef}
variant="ghost"
size="icon"
onClick={onClick}
className={cn('h-8 w-8 shrink-0', className)}
title={shortcut ? `${label} (${shortcut})` : label}
aria-label={label}
aria-pressed={open}
aria-keyshortcuts={shortcut ?? undefined}
data-plugin-panel-toggle=""
>
{open ? <PanelRightClose className="w-4 h-4 text-muted-foreground" /> : <PanelRightOpen className="w-4 h-4 text-muted-foreground" />}
</Button>
);
}
/** 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 (
<IconButton
size="icon-lg"
label={t('plugin_panel_show')}
onClick={() => openAppSidePanelDialog(entry)}
className={className}
data-app-side-panel-phone=""
>
<PanelRightOpen />
</IconButton>
);
}
// ─── 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<Record<string, boolean>>({});
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<boolean | null>(readChoice);
const [width, setWidth] = useState<number>(readWidth);
const widthAtStart = useRef(width);
const [forcedOpen, setForcedOpen] = useState(false);
const [focusRequest, setFocusRequest] = useState(0);
const [rowEl, setRowEl] = useState<HTMLElement | null>(null);
const [rowWidth, setRowWidth] = useState(0);
const toggleRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(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 ? (
<PluginPanelToggle open={!folded} onClick={() => setOpen(folded)} buttonRef={toggleRef} shortcut={panelShortcutOf(shownIds)} />
) : null;
const panel = docks && !folded ? (
<>
{!overlay && (
<ResizeHandle
pane="sidePanel"
value={width}
min={APP_SIDE_PANEL_MIN_WIDTH}
max={Math.min(APP_SIDE_PANEL_MAX_WIDTH, dockedMax)}
onResizeStart={() => { 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))}
/>
)}
<div
ref={panelRef}
role="complementary"
aria-label={panelLabelOf(shownIds, t('plugin_panel'))}
data-app-side-panel={overlay ? 'overlay' : 'docked'}
data-app-side-panel-view={view}
className={cn(
'flex flex-col h-full border-s border-border bg-background overflow-hidden shrink-0',
overlay && 'absolute inset-y-0 end-0 z-30 shadow-xl',
)}
style={overlay
? { width: Math.min(width, Math.max(APP_SIDE_PANEL_MIN_WIDTH, rowWidth - 48)) }
: { width, minWidth: width }}
>
<div className="flex-1 min-h-0 overflow-y-auto">
<PluginSlot name="app-side-panel" extraProps={{ view, context, focusRequest }} />
</div>
</div>
</>
) : null;
return { offered, docks, open: docks && !folded, wantsOpen: docks && !collapsed, toggle, panel, rowRef: setRowEl, close };
}
+30 -4
View File
@@ -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<DialogButtonSpec[]>([]);
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: '
</p>
<PluginDialogMenu name={name} onTurnOff={turnOff} compact />
</div>
<div className="min-h-0 flex-1 overflow-y-auto">{frame}</div>
<div className={cn('min-h-0 flex-1', fill ? 'flex flex-col overflow-hidden' : 'overflow-y-auto')}>{frame}</div>
{footer}
</div>
</div>
@@ -299,7 +308,9 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
<div
ref={panelRef}
className="flex w-full flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in zoom-in-95 duration-200"
style={{ maxWidth: custom ? (current.width ?? 560) : 448, maxHeight: '90vh' }}
style={fill
? { maxWidth: current.width ?? 560, height: fillHeight }
: { maxWidth: custom ? (current.width ?? 560) : 448, maxHeight: '90vh' }}
data-plugin-dialog={current.pluginId}
>
{custom ? (
@@ -319,7 +330,7 @@ function PluginDialogView({ current, layer }: { current: DialogRequest; layer: '
<X className="w-5 h-5" aria-hidden="true" />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
<div className={cn('min-h-0 flex-1 px-6 py-4', fill ? 'flex flex-col overflow-hidden' : 'overflow-y-auto')}>
{message}
{frame}
</div>
@@ -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 ? <Glyph className="w-4 h-4" aria-hidden="true" /> : null;
@@ -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
+1
View File
@@ -534,6 +534,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
currentMailboxRole={currentMailboxRole}
mailboxes={mailboxes}
className="flex-1"
withListSelection={false}
/>
</ErrorBoundary>
<ConfirmDialog {...confirmDialogProps} />
@@ -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<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
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(<ComposingSettings />);
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(<ComposingSettings />);
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(<ComposingSettings />);
for (const label of Object.values(ROWS)) expect(screen.queryByText(label), label).toBeNull();
});
});
@@ -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: () => <div>appearance pane</div> }));
vi.mock('@/components/settings/accessibility-settings', () => ({ AccessibilitySettings: () => <div>accessibility pane</div> }));
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(<SettingsApp linkSegments={[]} />);
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(<SettingsApp linkSegments={[]} />);
const row = document.querySelector<HTMLElement>('[data-settings-panel-row]')!;
expect(row.className).toContain('h-14');
const toggle = row.querySelector<HTMLElement>('[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<HTMLElement>('[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(<SettingsApp linkSegments={[]} />);
expect(document.querySelector('[data-settings-panel-row]')).toBeNull();
expect(document.querySelector('[data-app-side-panel]')).toBeNull();
const buttons = document.querySelectorAll<HTMLElement>('[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(<SettingsApp linkSegments={[]} />);
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);
});
});
+10 -4
View File
@@ -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 (
<div className="space-y-8">
<SettingsSection id="editor" title={tPage('title')} description={tPage('description')}>
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')}>
{!isSettingHidden('plainTextMode') && (
<SettingItem label={t('plain_text_mode.label')} description={t('plain_text_mode.description')} locked={isSettingLocked('plainTextMode')}>
<ToggleSwitch
checked={plainTextMode}
onChange={(checked) => updateSetting('plainTextMode', checked)}
/>
</SettingItem>
)}
<SettingItem label={t('recipient_mentions.label')} description={t('recipient_mentions.description')}>
<ToggleSwitch
@@ -98,15 +102,17 @@ export function ComposingSettings() {
/>
</SettingItem>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
{!isSettingHidden('autoSelectReplyIdentity') && (
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')} locked={isSettingLocked('autoSelectReplyIdentity')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
/>
</SettingItem>
)}
{autoSelectReplyIdentity && customFromEnabled && (
<SettingItem nested label={t('reply_identity_match.label')} description={t('reply_identity_match.description')}>
{autoSelectReplyIdentity && customFromEnabled && !isSettingHidden('replyIdentityMatch') && (
<SettingItem nested label={t('reply_identity_match.label')} description={t('reply_identity_match.description')} locked={isSettingLocked('replyIdentityMatch')}>
<Select
value={replyIdentityMatch}
onChange={(value) => updateSetting('replyIdentityMatch', value as ReplyIdentityMatch)}
+36 -7
View File
@@ -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 =
// "<Page> - Settings - <account> - <app>" (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 = (
<AppMain ref={attachContentScroll as (el: HTMLElement | null) => void} embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto">
<div className={effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) ? "px-6 py-6" : "max-w-3xl mx-auto px-6 py-6"}>
{/* The page's heading for screen readers; the sidebar shows which
page is open (the phone header has a visible one). */}
<h1 className="sr-only">{activeTabLabel}</h1>
{renderTabContent()}
</div>
</AppMain>
);
return (
<div className={cn("flex flex-col pt-[env(safe-area-inset-top)]", mvShell ? "bg-[var(--mv-canvas)]" : "bg-background", isEmbedded ? "h-full" : "h-dvh")}>
{!isEmbedded && <SkipLink>{tShell("skip_to_content")}</SkipLink>}
@@ -1183,14 +1203,23 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
onDoubleClick={() => { setSettingsSidebarWidth(256); localStorage.setItem('settings-sidebar-width', '256'); }}
/>
<AppMain ref={attachContentScroll as (el: HTMLElement | null) => void} embedded={isEmbedded} label={activeTabLabel} className="flex-1 overflow-y-auto">
<div className={effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) ? "px-6 py-6" : "max-w-3xl mx-auto px-6 py-6"}>
{/* The page's heading for screen readers; the sidebar shows which
page is open (the phone header has a visible one). */}
<h1 className="sr-only">{activeTabLabel}</h1>
{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. */}
<div className="flex-1 min-w-0 flex flex-col" data-settings-side-panel-column="">
{sidePanel.docks && (
<div className="flex items-center gap-2 h-14 box-content border-b border-border px-2 sm:px-4 lg:px-6 shrink-0" data-settings-panel-row="">
{/* The page's h1 below names it for screen readers. */}
<div className="flex-1 min-w-0 truncate text-sm font-semibold" aria-hidden="true">{activeTabLabel}</div>
{sidePanel.toggle}
</div>
)}
<div ref={sidePanel.rowRef} className="relative flex flex-1 min-h-0">
{settingsMain}
{sidePanel.panel}
</div>
</AppMain>
</div>
</>
)}
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
+80
View File
@@ -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<CalendarEvent>;
function request(over: Partial<RsvpRequest> = {}): RsvpRequest & { store: { rsvpEvent: ReturnType<typeof vi.fn>; updateEvent: ReturnType<typeof vi.fn>; importEvents: ReturnType<typeof vi.fn> } } {
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);
});
});
+86
View File
@@ -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();
});
});
+13
View File
@@ -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();
});
+66
View File
@@ -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);
});
});
+153
View File
@@ -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=');
});
});
+42
View File
@@ -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('<svg width="100000" height="100000">'))).toBeNull();
expect(imageDimensions(new Uint8Array(0))).toBeNull();
});
});
@@ -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.');
});
});
@@ -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<string, unknown>, { request, sieveUsing: () => ['urn:ietf:params:jmap:sieve'] });
await client.deactivateSieveScript('a1');
const [[calls]] = request.mock.calls as unknown as [[[string, Record<string, unknown>, string][]]];
expect(calls[0][1]).toEqual({ accountId: 'a1', onSuccessDeactivateScript: true });
});
});
+50
View File
@@ -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<typeof vi.spyOn>;
beforeEach(() => { fetchSpy = vi.spyOn(globalThis, 'fetch'); });
afterEach(() => { fetchSpy.mockRestore(); });
async function threadOf(ids: string[], options?: { newest?: number }): Promise<string[][]> {
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']);
});
});
+27
View File
@@ -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<string, unknown>, 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);
});
});
@@ -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();
});
});
@@ -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<unknown>) => ({ 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']]]);
});
});
@@ -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<Email> = {}) => ({ 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');
});
});
@@ -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 = [
'<p>Hi Alice, here is the logo idea:</p>',
'<p><img width="240" src="cid:logo1@aa" alt="Orange logo with a stripe"></p>',
"<img src='CID:chart%40aa' title='Sales &amp; costs'>",
'<img src="cid:plain@aa" alt="">',
'<img src="https://tracker.example.com/p.gif" alt="tracker" width="1" height="1">',
].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: '<logo1@aa>' },
{ 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('<Logo1%40AA>')).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' });
});
});
@@ -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<typeof vi.fn> };
const realParent = Object.getOwnPropertyDescriptor(window, 'parent');
const g = globalThis as { __pluginApi?: { host: { features: Record<string, boolean> } }; __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);
});
@@ -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 });
});
});
@@ -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');
});
});
@@ -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<string>(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<string, unknown>;
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);
});
});
@@ -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: '<logo1@aa>' },
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<string, { w: number; h: number }> = {
'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: '<p><img src="cid:logo1@aa" alt="Orange logo with a stripe"><img src="cid:px@aa"><img src="https://tracker.example.com/t.gif"></p>' } },
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<string, ReturnType<typeof vi.fn>>;
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/);
});
});
@@ -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('<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Launch moves to 12 December.</w:t></w:r></w:p></w:body></w:document>'),
});
const BLOBS: Record<string, Uint8Array> = {
'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<string, ReturnType<typeof vi.fn>>;
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' });
});
});
+147
View File
@@ -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<string, unknown> = { 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<typeof vi.fn>;
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"/);
});
});
@@ -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<CalendarEvent>;
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 });
});
});
+23 -7
View File
@@ -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<string, boolean>, plugin?: never) => ({ ...core, ...assistantFeatures(plugin) });
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
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);
});
+457
View File
@@ -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<typeof import('@/lib/image-shrink')>()), shrinkImage: shrink }));
vi.mock('@/lib/exif', () => ({ readPhotoExif: exif }));
vi.mock('../plugin-sandbox/host-api-compose', async (importOriginal) => ({
...(await importOriginal<typeof import('../plugin-sandbox/host-api-compose')>()),
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<FileNode> & { 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<FileNode> = {}) => node({ id, name, parentId, ...extra });
const file = (id: string, name: string, parentId: string | null, type: string, modified: string, extra: Partial<FileNode> = {}) =>
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<string, ReturnType<typeof vi.fn>>;
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<string, unknown> }, 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<string, unknown>) => 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]);
});
});
+143
View File
@@ -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<typeof server>;
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);
});
});
@@ -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<string, ReturnType<typeof vi.fn>>;
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();
});
});
@@ -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<string, { old: unknown; new: unknown }>; refused: Record<string, string>; undoToken: string | null };
const policy = usePolicyStore.getState().policy;
function restrict(restrictions: Record<string, unknown>, features: Record<string, boolean> = {}) {
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<string, Record<string, unknown>>;
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<string, string> };
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<string, { allowed: boolean; current: unknown }>;
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<string, string> };
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');
});
});
@@ -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,
});
});
+161
View File
@@ -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<EmailReadView[]>;
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<string, ReturnType<typeof vi.fn>>;
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<number>;
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/);
});
});
@@ -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);
});
});
@@ -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',
});
});
});
+29 -3
View File
@@ -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 <b> 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<FileNode>) => 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');
});
});
+237
View File
@@ -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<string, unknown>;
const lookup = (key: string): unknown =>
key.split('.').reduce<unknown>((node, part) => (node && typeof node === 'object' ? (node as Record<string, unknown>)[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<string, unknown>;
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<string, unknown>)[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();
});
});
@@ -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<string, string | Uint8Array>): 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': '<Types/>',
'word/document.xml': `<?xml version="1.0"?><w:document ${W}><w:body>
<w:p><w:r><w:t>Minutes of the meeting</w:t></w:r></w:p>
<w:p><w:r><w:t xml:space="preserve">Budget: </w:t></w:r><w:r><w:tab/><w:t>4,500 EUR</w:t></w:r><w:del><w:r><w:delText>9,000 EUR</w:delText></w:r></w:del></w:p>
<w:tbl><w:tr><w:tc><w:p><w:r><w:t>Who</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>What</w:t></w:r></w:p></w:tc></w:tr>
<w:tr><w:tc><w:p><w:r><w:t>Carol</w:t></w:r></w:p></w:tc><w:tc><w:p><w:r><w:t>Press release</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
<w:sectPr/></w:body></w:document>`,
});
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': `<workbook ${S}><sheets><sheet name="Figures" sheetId="1" r:id="rId1"/><sheet name="Empty" sheetId="2" r:id="rId2"/></sheets></workbook>`,
'xl/_rels/workbook.xml.rels': '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Target="/xl/worksheets/sheet2.xml"/></Relationships>',
'xl/sharedStrings.xml': `<sst ${S}><si><t>month</t></si><si><t>visitors</t></si><si><r><t>Aug</t></r><r><t>ust, late</t></r></si></sst>`,
'xl/worksheets/sheet1.xml': `<worksheet ${S}><sheetData>
<row r="1"><c r="A1" t="s"><v>0</v></c><c r="B1" t="s"><v>1</v></c></row>
${Array.from({ length: rows - 1 }, (_, i) => `<row r="${i + 2}"><c r="A${i + 2}" t="s"><v>2</v></c><c r="C${i + 2}"><v>${1200 + i}</v></c><c r="D${i + 2}" t="b"><v>1</v></c></row>`).join('')}
</sheetData></worksheet>`,
'xl/worksheets/sheet2.xml': `<worksheet ${S}><sheetData/></worksheet>`,
});
const P = 'xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"';
const slide = (texts: string[]) => `<p:sld ${P}><p:cSld><p:spTree>${texts.map((t) => `<p:sp><p:txBody><a:p><a:r><a:t>${t}</a:t></a:r></a:p></p:txBody></p:sp>`).join('')}</p:spTree></p:cSld></p:sld>`;
const PPTX = zip({
'ppt/presentation.xml': `<p:presentation ${P}/>`,
'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<string, string> = {}) => zip({
mimetype: 'application/vnd.oasis.opendocument.text',
'META-INF/manifest.xml': '<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"/>',
'content.xml': `<office:document-content ${O}><office:body>${body}</office:body></office:document-content>`,
...extra,
});
const ODT = odf('<office:text><text:h>Report</text:h><text:p>Two<text:s text:c="3"/>spaces<text:tab/>and a<text:line-break/>break<text:note><text:note-body><text:p>a footnote</text:p></text:note-body></text:note></text:p></office:text>');
const ODS = odf(`<office:spreadsheet><table:table table:name="Budget">
<table:table-row><table:table-cell><text:p>item</text:p></table:table-cell><table:table-cell><text:p>cost</text:p></table:table-cell></table:table-row>
<table:table-row table:number-rows-repeated="2"><table:table-cell><text:p>ads, print</text:p></table:table-cell><table:table-cell table:number-columns-repeated="2"><text:p>4500</text:p></table:table-cell></table:table-row>
<table:table-row table:number-rows-repeated="1048570"><table:table-cell table:number-columns-repeated="16384"/></table:table-row>
</table:table></office:spreadsheet>`);
const ODP = odf('<office:presentation><draw:page draw:name="p1"><draw:frame><draw:text-box><text:p>Hello slide</text:p></draw:text-box></draw:frame></draw:page><draw:page draw:name="p2"><draw:frame><draw:text-box><text:p>Second</text:p></draw:text-box></draw:frame></draw:page></office:presentation>');
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('<html><body><h1>Agenda</h1><p>Launch <b>plan</b></p><script>alert(1)</script><img alt="logo"></body></html>'), { 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 <bob@example.org>',
' 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': `<w:document ${W}><w:body><w:p><w:r><w:t>${'A'.repeat(3 * 1024 * 1024)}</w:t></w:r></w:p></w:body></w:document>` });
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('<office:text/>', { 'META-INF/manifest.xml': '<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0"><manifest:file-entry><manifest:encryption-data/></manifest:file-entry></manifest:manifest>' });
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');
});
});
@@ -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<string, string>): 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': `<office:document-content ${O}><office:body><office:spreadsheet>${tables}</office:spreadsheet></office:body></office:document-content>`,
});
const odsTable = (name: string, rows: string) => `<table:table table:name="${name}">${rows}</table:table>`;
const odsRow = (cells: string[], repeat = 1) => `<table:table-row table:number-rows-repeated="${repeat}">${cells.map((c) => `<table:table-cell><text:p>${c}</text:p></table:table-cell>`).join('')}</table:table-row>`;
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': `<workbook ${S}><sheets>${sheets.map((_, i) => `<sheet name="S${i + 1}" sheetId="${i + 1}" r:id="rId${i + 1}"/>`).join('')}</sheets></workbook>`,
'xl/_rels/workbook.xml.rels': `<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">${sheets.map((_, i) => `<Relationship Id="rId${i + 1}" Target="worksheets/sheet${i + 1}.xml"/>`).join('')}</Relationships>`,
'xl/sharedStrings.xml': `<sst ${S}>${shared.map((s) => `<si><t>${s}</t></si>`).join('')}</sst>`,
...Object.fromEntries(sheets.map((rows, i) => [`xl/worksheets/sheet${i + 1}.xml`, `<worksheet ${S}><sheetData>${rows}</sheetData></worksheet>`])),
});
const W = 'xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
const docx = (body: string, prolog = '') => zip({ 'word/document.xml': `${prolog}<w:document ${W}><w:body>${body}</w:body></w:document>` });
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', `<table:table-row table:number-rows-repeated="2000"><table:table-cell table:number-columns-repeated="100"><text:p>${'x'.repeat(100)}</text:p></table:table-cell></table:table-row>`));
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) => `<row r="${i + 1}"><c r="A${i + 1}" t="s"><v>0</v></c></row>`).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) => `<w:p><w:r><w:t>Paragraph ${i} with some words in it.</w:t></w:r></w:p>`).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 }, () => '<row r="1"><c r="A1"><v>1</v></c></row>')), { 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(`<w:p><w:r><w:t>${'z'.repeat(200_000)}</w:t></w:r></w:p>`);
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('<w:p><w:r><w:t>&a;&a;&a;</w:t></w:r></w:p>', '<?xml version="1.0"?><!DOCTYPE w:document [<!ENTITY a "aaaaaaaaaa">]>');
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('<w:p><w:r><w:t>secret</w:t></w:r></w:p>');
// 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': '<Types/>' }), { 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 });
});
});
@@ -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';");
});
});
@@ -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<string, string>): 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) => `<w:txbxContent><w:p><w:r><w:t>${text}</w:t></w:r></w:p></w:txbxContent>`;
const doc = zip({
'word/document.xml': `<w:document ${W}><w:body>
<w:p><w:r><w:t>Before the box.</w:t></w:r><w:r><mc:AlternateContent>
<mc:Choice Requires="wps"><w:drawing><wps:wsp><wps:txbx>${box('Box text')}</wps:txbx></wps:wsp></w:drawing></mc:Choice>
<mc:Fallback><w:pict><v:shape><v:textbox>${box('Box text')}</v:textbox></v:shape></w:pict></mc:Fallback>
</mc:AlternateContent></w:r></w:p>
<w:p><w:moveFrom><w:r><w:t>Old place</w:t></w:r></w:moveFrom><w:moveTo><w:r><w:t>New place</w:t></w:r></w:moveTo></w:p>
</w:body></w:document>`,
});
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': `<Relationships ${PKG}><Relationship Id="rId1" Type="${OFFICE_DOC}" Target="/word/main.xml"/></Relationships>`,
'word/main.xml': `<w:document ${W}><w:body><w:p><w:r><w:t>From the main part</w:t></w:r></w:p></w:body></w:document>`,
});
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) => `<p:sp><p:txBody><a:p><a:r><a:t>${text}</a:t></a:r></a:p></p:txBody></p:sp>`;
const slide = (body: string) => `<p:sld ${P}><p:cSld><p:spTree>${body}</p:spTree></p:cSld></p:sld>`;
describe('PowerPoint', () => {
it('reads the slides in the order of the deck, not of the files', async () => {
const deck = zip({
'_rels/.rels': `<Relationships ${PKG}><Relationship Id="rId1" Type="${OFFICE_DOC}" Target="ppt/presentation.xml"/></Relationships>`,
'ppt/presentation.xml': `<p:presentation ${P}><p:sldIdLst><p:sldId id="256" r:id="rId8"/><p:sldId id="257" r:id="rId7"/></p:sldIdLst></p:presentation>`,
'ppt/_rels/presentation.xml.rels': `<Relationships ${PKG}><Relationship Id="rId7" Target="slides/slide1.xml"/><Relationship Id="rId8" Target="slides/slide2.xml"/></Relationships>`,
'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': `<p:presentation ${P}/>`,
'ppt/slides/slide1.xml': slide(`<mc:AlternateContent><mc:Choice Requires="p14">${shape('Shape text')}</mc:Choice><mc:Fallback>${shape('Shape text')}</mc:Fallback></mc:AlternateContent>`),
});
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': `<workbook ${S}>${date1904 ? '<workbookPr date1904="1"/>' : ''}<sheets><sheet name="Dates" sheetId="1" r:id="rId1"/></sheets></workbook>`,
'xl/_rels/workbook.xml.rels': `<Relationships ${PKG}><Relationship Id="rId1" Target="worksheets/sheet1.xml"/></Relationships>`,
'xl/styles.xml': `<styleSheet ${S}><numFmts count="2"><numFmt numFmtId="164" formatCode="dd/mm/yyyy\\ hh:mm"/><numFmt numFmtId="165" formatCode="0.00&quot; days&quot;"/></numFmts>
<cellXfs count="5"><xf numFmtId="0"/><xf numFmtId="14"/><xf numFmtId="164"/><xf numFmtId="165"/><xf numFmtId="20"/></cellXfs></styleSheet>`,
'xl/worksheets/sheet1.xml': `<worksheet ${S}><sheetData><row r="1">${cells}</row></sheetData></worksheet>`,
});
describe('Excel dates', () => {
it('writes date cells as ISO dates, by built-in and custom formats', async () => {
const out = await extractText(book('<c r="A1" s="1"><v>46326</v></c><c r="B1" s="2"><v>46326.5</v></c><c r="C1" s="0"><v>46326</v></c><c r="D1" s="3"><v>46326</v></c><c r="E1" s="4"><v>0.75</v></c>'), { 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('<c r="A1" s="1"><v>44864</v></c>', { date1904: true }), { name: 'mac.xlsx' });
expect(out.text).toBe('Sheet: Dates\n2026-10-31');
});
});
@@ -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);
});
+70
View File
@@ -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();
}
});
});
@@ -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 "<head": a regular expression over the source took 8.6 s for this.
const started = performance.now();
const out = await extractText(text(`<p>Agenda</p>${'<head'.repeat(80_000)}`), { name: 'odd.html' });
expect(performance.now() - started).toBeLessThan(5_000);
expect(out.status).toBe('ok');
}, 20_000);
it('reads a long numbered list in a moment', async () => {
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(`<ol>${'<li>item</li>'.repeat(20_000)}</ol>`), { 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('<html><head><title>T</title><style>p{}</style></head><body><p>Body</p><script>alert(1)</script><noscript>ns</noscript><template>tpl</template></body></html>'), { 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(`<p>Body</p><!--${'x'.repeat(5_100_000)}-->`), { 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');
});
});
+137
View File
@@ -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<string, TextFormat> = {
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<string, TextFormat> = {
'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<Omit<ExtractedText, 'format'>> {
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<T>(work: () => Promise<T>): Promise<T> {
while (running >= MAX_EXTRACTIONS_AT_ONCE) await new Promise<void>((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<ExtractedText> {
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<ExtractedText, 'format'>;
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 };
}
+551
View File
@@ -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<TextFormat, 'docx' | 'xlsx' | 'pptx' | 'odt' | 'ods' | 'odp'>;
type Result = Omit<ExtractedText, 'format'>;
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<Uint8Array | null> {
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<Document | null> {
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 (/<!DOCTYPE/i.test(source)) throw new Refused();
const doc = new DOMParser().parseFromString(source, 'application/xml');
if (doc.getElementsByTagName('parsererror').length > 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/<name>.rels next to it): rId → target path and type. */
async function relationsOf(zip: Unpacker, part: string): Promise<Map<string, Relation>> {
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<string, Relation>();
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<string> {
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<string> = 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<string> = 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<string> = 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<Result> {
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<Set<number>> {
const out = new Set<number>();
const styles = await zip.xml(path);
if (!styles) return out;
const custom = new Map<number, string>();
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<Result> {
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<string[]> {
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<Result> {
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<Result> {
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<Result> {
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 };
}
}
+103
View File
@@ -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<PdfDoc>; destroy(): Promise<void> };
}
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<PdfLib> {
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<PdfLib> = loadPdfJs;
/** Swap the pdf.js loader (tests). Without an argument the real one comes back. */
export function setPdfLoader(next?: () => Promise<PdfLib>): 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<Omit<ExtractedText, 'format'>> {
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<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new TimedOut()), timeoutMs); });
const read = async (): Promise<Omit<ExtractedText, 'format'>> => {
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(() => {});
}
}
+157
View File
@@ -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<string, string>; 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<string, string> = {};
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<string, IcsLine> | 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');
}
+28
View File
@@ -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();
}
}
+60
View File
@@ -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;
+158
View File
@@ -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<string, string> | null, scope?: 'occurrence' | 'series', options?: { sentBy?: string }) => Promise<boolean>;
updateEvent: (client: IJMAPClient, id: string, updates: Partial<CalendarEvent>, sendSchedulingMessages?: boolean) => Promise<boolean>;
importEvents: (client: IJMAPClient, events: Partial<CalendarEvent>[], calendarId: string, options?: { throwIfNoneImported?: boolean }) => Promise<number | null>;
/** 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<CalendarEvent>;
/** 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<CalendarEvent | null | undefined>;
/**
* 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<CalendarEvent | null>;
}
/**
* 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<CalendarEvent>,
participantId: string,
status: RsvpStatus,
): Record<string, NonNullable<CalendarEvent['participants']>[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<RsvpOutcome> {
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<CalendarEvent>;
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<OwnInvitation | null> {
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 };
}
+7
View File
@@ -1702,6 +1702,13 @@ export class DemoJMAPClient implements IJMAPClient {
if (node) Object.assign(node, updates);
}
async replaceFileNodeBlob(id: string, blobId: string): Promise<void> {
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<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }> {
const updated: string[] = [];
for (const [id, patch] of Object.entries(updates)) {
+145
View File
@@ -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<number, Entry> {
const out = new Map<number, Entry>();
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<number, number>)[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<number, Entry>();
// 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;
}
+14
View File
@@ -442,6 +442,20 @@ export function checkPluginManifest(m: Record<string, unknown>, 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');
}
+71
View File
@@ -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<SaveToFilesResult> {
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 };
}
@@ -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([]);
});
});
+4 -1
View File
@@ -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;
+154
View File
@@ -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<Blob | null> {
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<ImageBitmap> {
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<ShrinkResult> {
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);
}
+66
View File
@@ -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;
}
+8 -1
View File
@@ -404,7 +404,8 @@ export interface IJMAPClient {
// ── Threads ───────────────────────────────────────────────────
getThread(threadId: string, accountId?: string): Promise<Thread | null>;
getThreads(threadIds: string[], accountId?: string): Promise<Thread[]>;
getThreadEmails(threadId: string, accountId?: string): Promise<Email[]>;
/** `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<Email[]>;
// ── Compose / Send ────────────────────────────────────────────
createDraft(
@@ -699,6 +700,12 @@ export interface IJMAPClient {
*/
copyBlobs(fromAccountId: string, blobIds: string[], toAccountId: string): Promise<Record<string, string>>;
updateFileNode(id: string, updates: Partial<Pick<FileNode, 'name' | 'parentId'>>): Promise<void>;
/**
* 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<void>;
updateFileNodes(updates: Record<string, Partial<Pick<FileNode, 'name' | 'parentId'>>>): Promise<{ updated: string[]; notUpdated: Record<string, string> }>;
destroyFileNodes(ids: string[]): Promise<{ destroyed: string[]; notDestroyed: string[] }>;
copyFileNode(id: string, newName: string, parentId: string | null, listings?: FileNodeListings): Promise<FileNode>;
+31 -3
View File
@@ -4726,17 +4726,19 @@ export class JMAPClient implements IJMAPClient {
}
}
async getThreadEmails(threadId: string, accountId?: string): Promise<Email[]> {
async getThreadEmails(threadId: string, accountId?: string, options?: { newest?: number }): Promise<Email[]> {
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<void> {
// 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<void> {
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<T>(entries: Array<[string, T]>): Map<string, Array<{ id: string; rawId: string; value: T }>> {
const groups = new Map<string, Array<{ id: string; rawId: string; value: T }>>();
+40
View File
@@ -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<Mailbox, 'id' | 'parentId'>[],
mailboxId: string,
accountId?: string,
{ descendants = false }: { descendants?: boolean } = {},
): Promise<boolean> {
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;
}
+9 -1
View File
@@ -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,
+9
View File
@@ -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;
+27
View File
@@ -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:
- `<area>.types.ts` (pure, no imports from the app): `PERMISSIONS`, the
permissions the area adds (they join `ALL_PERMISSIONS`), and
`<Area>HostFeatures`, the flags it adds to `api.host.features`.
- `<area>.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).
- `<area>.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.<key>`, and docs.
@@ -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<typeof resolveSelectedEmails>[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]);
}
@@ -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 (`<img src="cid:…">`) 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<string, string> = { 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>", "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 `<img src="cid:…" alt="…">` tags of the message's HTML. */
export function cidAltTexts(email: Pick<Email, 'htmlBody' | 'bodyValues'>): Map<string, string> {
const out = new Map<string, string>();
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(/<img\b[^>]*>/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<string, string>): AttachmentExtrasB {
if (!part.cid) return {};
const cid = part.cid.replace(/^<|>$/g, '');
const alt = alts.get(normaliseCid(cid));
return { cid, ...(alt ? { alt } : {}) };
}
+41
View File
@@ -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<AttachmentTextView>,
/**
* 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<AttachmentImageView>,
/**
* 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<EmailReadView[]>,
/**
* 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<number>,
},
};
}
+208
View File
@@ -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<Record<string, Permission | null>> = {
'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<FoundAttachment> {
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<ArrayBuffer> {
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<AttachmentTextView> {
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<AttachmentImageView> {
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<EmailReadView[]> {
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<number> {
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<unknown> {
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 };
}
+108
View File
@@ -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 (`<img src="cid:…">`), 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;
}
+158
View File
@@ -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<void>;
}
/** Runs `fn` once while it runs or after it went through; a failure may be tried again. */
function once(fn: () => Promise<void>): () => Promise<void> {
let promise: Promise<void> | 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<MailBatchResult> {
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<void>;
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<void> {
const method = 'email.undoBatch';
const client: IJMAPClient = sessionClient(method);
await withUndoToken<BatchUndo, void>(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();
});
}
+177
View File
@@ -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<CalendarEvent | null> {
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<InvitationResponseView | null> {
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<EventDeleteResult> {
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<string, unknown> = { ...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<Partial<CalendarEvent>, { 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 };
});
}

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