mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
Merge the AI assistant everywhere (plugin 2.2 host side)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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">
|
||||
<>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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=');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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 & 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' });
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" days""/></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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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 }>>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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 } : {}) };
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user