diff --git a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
index 06e07cf7d..b1d0063c1 100644
--- a/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
+++ b/components/calendar/__tests__/calendar-plugin-surfaces.test.tsx
@@ -56,6 +56,47 @@ describe('calendar-event-detail', () => {
expect(framePropsOf(frame).event).toMatchObject({ id: 'e1', uid: 'uid-1', title: 'Planning', calendarId: 'cal-1' });
});
+ it('moves the popover up when the plugin section makes it taller, so it stays in the window', () => {
+ let resize: (() => void) | null = null;
+ vi.stubGlobal('ResizeObserver', class {
+ constructor(cb: () => void) { resize = cb; }
+ observe() {}
+ unobserve() {}
+ disconnect() {}
+ });
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
+ Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
+ let height = 200;
+ const spy = vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockImplementation(function (this: HTMLElement) {
+ return this.getAttribute('role') === 'dialog' ? height : 0;
+ });
+ try {
+ offerSlots('p1', ['calendar-event-detail']);
+ // An event at 14:00 in the week view of a 1280x800 window.
+ render(
+ ,
+ );
+ const popover = screen.getByRole('dialog');
+ expect(popover.style.top).toBe('520px');
+
+ height = 420;
+ act(() => resize?.());
+ expect(popover.style.top).toBe(`${800 - 420 - 12}px`);
+ } finally {
+ spy.mockRestore();
+ vi.unstubAllGlobals();
+ }
+ });
+
it('shows the event in the modal view, and not in the form', () => {
offerSlots('p1', ['calendar-event-detail']);
render();
@@ -153,18 +194,27 @@ describe('calendar-day-header and mini-calendar-marks', () => {
expect(headerOf('2026-09-09').querySelector('[data-plugin-surface="calendar-day-header"]')).toHaveTextContent('Feast day');
});
- it('shows the label in the month cell header on desktop only', () => {
- act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', text: 'Feast day' }]));
+ it('shows the label in the month cell header, as a dot on phones', () => {
+ act(() => setDayLabels('hol', 'Holidays', [
+ { date: '2026-09-09', text: 'Feast day' },
+ { date: '2026-09-10', dot: true, title: 'Dot only' },
+ ]));
const month = { ...week, windowKey: 'month:2026-09-09', rangeStart: new Date(2026, 7, 31), rangeEnd: new Date(2026, 9, 4) };
const { unmount } = render();
const labels = document.querySelectorAll('[data-plugin-surface="calendar-day-header"]');
expect(labels).toHaveLength(1);
+ expect(labels[0]).toHaveTextContent('Feast day');
expect(labels[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
unmount();
render();
- expect(document.querySelector('[data-plugin-surface="calendar-day-header"]')).toBeNull();
+ const dots = [...document.querySelectorAll('[data-plugin-surface="calendar-day-header"]')];
+ expect(dots).toHaveLength(2);
+ for (const dot of dots) expect(dot).toHaveAttribute('data-compact');
+ expect(dots[0].closest('[role="gridcell"]')?.getAttribute('aria-label')).toMatch(/9/);
+ expect(dots[0].title).toBe('Feast day (Holidays)');
+ expect(dots[1].title).toBe('Dot only (Holidays)');
});
it('marks days with a dot in the small month calendar', () => {
@@ -183,4 +233,22 @@ describe('calendar-day-header and mini-calendar-marks', () => {
expect(dots[0].closest('button')).toHaveTextContent('9');
expect(dots[0].closest('button')).toHaveTextContent('A feast');
});
+
+ it('keeps the dot visible on today, whose filled circle has white text', () => {
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(new Date(2026, 8, 9, 12));
+ try {
+ act(() => setDayLabels('hol', 'Holidays', [{ date: '2026-09-09', dot: true, title: 'A feast' }]));
+ render(
+ ,
+ );
+ const dot = document.querySelector('[data-plugin-surface="mini-calendar-marks"]')!;
+ expect(dot.closest('button')).toHaveAttribute('aria-current', 'date');
+ // Its own colour, not the button's text colour, which is white on today.
+ expect(dot.className).not.toMatch(/bg-current/);
+ expect(dot.className).toMatch(/ring-background/);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
});
diff --git a/components/calendar/calendar-month-view.tsx b/components/calendar/calendar-month-view.tsx
index 1164876b5..cd413898a 100644
--- a/components/calendar/calendar-month-view.tsx
+++ b/components/calendar/calendar-month-view.tsx
@@ -380,7 +380,8 @@ export function CalendarMonthView({
>
{formatDayNumber(day)}
- {!isMobile && }
+ {/* Phones have no room for the text: a dot stands in for it. */}
+
{isMobile && !showChips ? (
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx
index 7a54d8f66..4ab0eba77 100644
--- a/components/calendar/event-detail-popover.tsx
+++ b/components/calendar/event-detail-popover.tsx
@@ -54,13 +54,18 @@ const POPOVER_GAP = 8;
const VIEWPORT_MARGIN = 12;
const MAX_HEIGHT = 480;
+/** As tall as MAX_HEIGHT, but never taller than the window. */
+function maxPopoverHeight(): number {
+ return Math.max(0, Math.min(MAX_HEIGHT, window.innerHeight - 2 * VIEWPORT_MARGIN));
+}
+
function computePosition(
anchorRect: DOMRect,
popoverHeight: number
): { top: number; left: number } {
const vw = window.innerWidth;
const vh = window.innerHeight;
- const clampedHeight = Math.min(popoverHeight, MAX_HEIGHT);
+ const clampedHeight = Math.min(popoverHeight, maxPopoverHeight());
const clampTop = (top: number) =>
Math.min(Math.max(VIEWPORT_MARGIN, top), vh - clampedHeight - VIEWPORT_MARGIN);
@@ -226,6 +231,19 @@ export function EventDetailPopover({
if (!ready) requestAnimationFrame(() => setReady(true));
}, [anchorRect, noteExpanded, showDeleteConfirm, ready]);
+ // Content that grows after the popover was placed (a plugin section reports
+ // its height a moment later) moves it again, so it stays in the window.
+ useEffect(() => {
+ const node = popoverRef.current;
+ if (!node || isMobile || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(() => {
+ const next = computePosition(anchorRect, node.offsetHeight);
+ setPosition((prev) => (prev && prev.top === next.top && prev.left === next.left ? prev : next));
+ });
+ observer.observe(node);
+ return () => observer.disconnect();
+ }, [anchorRect, isMobile]);
+
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
@@ -302,14 +320,14 @@ export function EventDetailPopover({
"fixed z-[60] bg-background border border-border shadow-xl overflow-hidden transition-[opacity,transform] duration-150 ease-out",
isMobile
? "inset-0 rounded-none flex flex-col"
- : "rounded-lg"
+ : "rounded-lg flex flex-col"
)}
style={isMobile ? {
opacity: 1,
transform: "none",
} : {
width: POPOVER_WIDTH,
- maxHeight: MAX_HEIGHT,
+ maxHeight: maxPopoverHeight(),
top: position?.top ?? -9999,
left: position?.left ?? -9999,
opacity: ready ? 1 : 0,
@@ -413,7 +431,7 @@ export function EventDetailPopover({
{/* Content */}
{/* Date & Time */}
diff --git a/components/calendar/plugin-day-label.tsx b/components/calendar/plugin-day-label.tsx
index 63ff9aa0f..5fc22e768 100644
--- a/components/calendar/plugin-day-label.tsx
+++ b/components/calendar/plugin-day-label.tsx
@@ -27,10 +27,16 @@ interface PluginDayLabelProps {
/** YYYY-MM-DD */
date: string;
className?: string;
+ /**
+ * calendar-day-header only: no room for text (the phone month view), so a
+ * dot stands for the day's labels; their text is its tooltip and is read
+ * out by screen readers.
+ */
+ compact?: boolean;
}
/** What plugins put on a calendar day. Renders nothing without labels. */
-export function PluginDayLabel({ surface, date, className }: PluginDayLabelProps) {
+export function PluginDayLabel({ surface, date, className, compact }: PluginDayLabelProps) {
const labels = useDayLabels(date);
if (surface === "mini-calendar-marks") {
@@ -42,13 +48,29 @@ export function PluginDayLabel({ surface, date, className }: PluginDayLabelProps
aria-hidden="true"
data-plugin-surface="mini-calendar-marks"
title={tooltip(marks)}
- className={cn("pointer-events-none absolute top-0.5 end-0.5 h-1 w-1 rounded-full bg-current opacity-70", className)}
+ className={cn("pointer-events-none absolute top-0 end-0 h-1.5 w-1.5 rounded-full bg-muted-foreground ring-1 ring-background", className)}
/>
{marks.map((l) => l.title ?? l.text ?? l.pluginName).join(", ")}
>
);
}
+ if (compact) {
+ const shown = labels.filter((l) => l.text || l.dot);
+ if (shown.length === 0) return null;
+ return (
+
+
+ {shown.map((l) => l.text ?? l.title ?? l.pluginName).join(", ")}
+
+ );
+ }
+
const withText = labels.filter((l) => l.text);
if (withText.length === 0) return null;
return (
diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx
index 9f1ca493c..eda652956 100644
--- a/components/email/__tests__/composer-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx
@@ -6,6 +6,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
import { clearOffers, lastSlotProps, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
import { useSettingsStore } from '@/stores/settings-store';
+import { SLOT_CATALOG } from '@/lib/plugin-slot-catalog';
// Plugin surfaces of the composer: frames above and below the message text
// (fed the draft, at most every 2 seconds), and entries in the Send and
@@ -280,6 +281,15 @@ describe('composer-above-body and composer-below-body', () => {
expect(typeof props.insertText).toBe('function');
});
+ it('put the attachment source in the plugin button row, where the catalogue says it is', () => {
+ offerSlots('p1', ['composer-attachment-source']);
+ render();
+
+ const frame = screen.getByTestId('slot-composer-attachment-source');
+ expect(frame.closest('[data-composer-plugin-row]')).not.toBeNull();
+ expect(SLOT_CATALOG['composer-attachment-source'].where).toMatch(/plugin button row/);
+ });
+
it('draw nothing without a plugin', () => {
const { container } = render();
expect(container.querySelector('[data-plugin-slot="composer-above-body"]')).toBeNull();
@@ -344,3 +354,70 @@ describe('composer-attach-menu', () => {
expect(screen.getByRole('button', { name: 'attach' })).toBeInTheDocument();
});
});
+
+describe('composer-sidebar and composer-sidebar-right', () => {
+ function stubWidth(width: number) {
+ class FixedWidthObserver {
+ constructor(private cb: ResizeObserverCallback) {}
+ observe(el: Element) {
+ if ((el as HTMLElement).dataset.testid !== 'email-composer') return;
+ this.cb([{ target: el, contentRect: { width, height: 700 } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
+ }
+ unobserve() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', FixedWidthObserver);
+ }
+ const panel = (side: 'left' | 'right') => document.querySelector(`[data-composer-sidebar="${side}"]`)!;
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ try {
+ localStorage.removeItem('composerPluginSidebarCollapsed:left');
+ localStorage.removeItem('composerPluginSidebarCollapsed:right');
+ } catch { /* ignore */ }
+ });
+
+ it('give both panels a fixed width beside a wide composer', () => {
+ stubWidth(1400);
+ offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']);
+ render();
+
+ for (const side of ['left', 'right'] as const) {
+ expect(panel(side).dataset.composerSidebarMode).toBe('docked');
+ expect(panel(side).style.width).toBe('260px');
+ }
+ expect(panel('left')).toContainElement(screen.getByTestId('slot-composer-sidebar'));
+ expect(panel('right')).toContainElement(screen.getByTestId('slot-composer-sidebar-right'));
+ });
+
+ it('fold the right panel, then both, when the composer would get too narrow', () => {
+ stubWidth(900);
+ offerSlots('p1', ['composer-sidebar', 'composer-sidebar-right']);
+ const { unmount } = render();
+ expect(panel('left').dataset.composerSidebarMode).toBe('docked');
+ expect(panel('right').dataset.composerSidebarMode).toBe('folded');
+ expect(screen.queryByTestId('slot-composer-sidebar-right')).toBeNull();
+ unmount();
+
+ // The reading pane of a 1280 px window: an open panel would leave the
+ // composer too narrow for its bottom bar.
+ stubWidth(792);
+ render();
+ expect(panel('left').dataset.composerSidebarMode).toBe('folded');
+ expect(panel('right').dataset.composerSidebarMode).toBe('folded');
+ });
+
+ it('open a folded panel over the composer when there is no room beside it', () => {
+ stubWidth(600);
+ offerSlots('p1', ['composer-sidebar-right']);
+ render();
+
+ fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
+ expect(panel('right').dataset.composerSidebarMode).toBe('overlay');
+ expect(panel('right').className).toContain('absolute');
+
+ fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_hide' }));
+ expect(panel('right').dataset.composerSidebarMode).toBe('folded');
+ });
+});
diff --git a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
index e228eb913..cba16b2d7 100644
--- a/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/mail-list-plugin-surfaces.test.tsx
@@ -155,6 +155,41 @@ describe('message-bulk-actions', () => {
expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
});
+ it('puts them behind one "more" button when the list is narrow', async () => {
+ // The list next to an open folder sidebar, or on a phone.
+ class NarrowObserver {
+ constructor(private cb: ResizeObserverCallback) {}
+ observe(el: Element) {
+ this.cb([{ target: el, contentRect: { width: 384, height: 48 } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
+ }
+ unobserve() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', NarrowObserver);
+ try {
+ const emails = [mail('a'), mail('b')];
+ useEmailStore.setState({ emails, selectedEmailKeys: new Set(['a', 'b']) });
+ act(() => setPluginActions('tasks', 'Tasks', 'message-bulk-actions', [
+ { id: 'make', label: 'Make tasks' },
+ { id: 'more', label: 'Remind me' },
+ ]));
+ render();
+
+ expect(screen.queryByRole('button', { name: 'Make tasks' })).toBeNull();
+ // "Clear selection" keeps its name for screen readers but drops the text.
+ const clear = screen.getByRole('button', { name: 'batch_actions.clear_selection' });
+ expect(clear).not.toHaveTextContent('batch_actions.clear_selection');
+
+ fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
+ const item = await screen.findByRole('menuitem', { name: 'Make tasks' });
+ expect(screen.getByRole('menuitem', { name: 'Remind me' })).toBeInTheDocument();
+ await act(async () => { fireEvent.click(item); });
+ expect(dispatch).toHaveBeenCalledWith('tasks', 'message-bulk-actions', 'make', { emailIds: ['a', 'b'] });
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+
it('adds them to the Mountain View toolbar too', async () => {
const emails = [mail('a'), mail('b')];
useEmailStore.setState({ emails, selectedEmailKeys: new Set(['b']) });
diff --git a/components/email/__tests__/reader-plugin-surfaces.test.tsx b/components/email/__tests__/reader-plugin-surfaces.test.tsx
index 588f2ee90..f4241ca62 100644
--- a/components/email/__tests__/reader-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/reader-plugin-surfaces.test.tsx
@@ -82,6 +82,60 @@ afterEach(() => {
setActionDispatcher(null);
});
+describe('email-detail-sidebar', () => {
+ // Reports a fixed width for whatever it observes, like a 584 px reading pane
+ // (three panes on a laptop).
+ function stubWidth(width: number) {
+ class FixedWidthObserver {
+ constructor(private cb: ResizeObserverCallback) {}
+ observe(el: Element) {
+ this.cb([{ target: el, contentRect: { width } } as unknown as ResizeObserverEntry], this as unknown as ResizeObserver);
+ }
+ unobserve() {}
+ disconnect() {}
+ }
+ vi.stubGlobal('ResizeObserver', FixedWidthObserver);
+ }
+
+ afterEach(() => {
+ vi.stubGlobal('ResizeObserver', NoopResizeObserver);
+ try { localStorage.removeItem('emailDetailSidebarCollapsed'); } catch { /* ignore */ }
+ });
+
+ it('starts collapsed when the message opens into a pane too narrow for it', () => {
+ stubWidth(584);
+ offerSlots('p1', ['email-detail-sidebar']);
+ // The reader mounts empty first, then a message opens.
+ const { rerender } = render(
+ ,
+ );
+ rerender();
+
+ expect(screen.queryByTestId('slot-email-detail-sidebar')).toBeNull();
+ expect(screen.getByRole('button', { name: 'plugin_panel_show' })).toBeInTheDocument();
+ });
+
+ it('lays the panel over the message when opened anyway, instead of squeezing it', () => {
+ stubWidth(584);
+ offerSlots('p1', ['email-detail-sidebar']);
+ viewer(textMail('Hi'));
+
+ fireEvent.click(screen.getByRole('button', { name: 'plugin_panel_show' }));
+ const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
+ expect(panel.getAttribute('data-plugin-detail-panel')).toBe('overlay');
+ expect(panel.className).toContain('absolute');
+ });
+
+ it('docks next to the message when there is room', () => {
+ stubWidth(1000);
+ offerSlots('p1', ['email-detail-sidebar']);
+ viewer(textMail('Hi'));
+
+ const panel = screen.getByTestId('slot-email-detail-sidebar').closest('[data-plugin-detail-panel]')!;
+ expect(panel.getAttribute('data-plugin-detail-panel')).toBe('docked');
+ });
+});
+
describe('reader-header and reader-below-subject', () => {
it('put the frames above the subject and under the sender, with the message', () => {
offerSlots('p1', ['reader-header', 'reader-below-subject']);
@@ -105,6 +159,14 @@ describe('reader-header and reader-below-subject', () => {
});
});
+describe('email-footer', () => {
+ it('hands the frame the id of the open message', () => {
+ offerSlots('p1', ['email-footer']);
+ viewer(textMail('Hi'));
+ expect(slotProps(screen.getByTestId('slot-email-footer'))).toEqual({ emailId: 'e1' });
+ });
+});
+
describe('reader-above-quote', () => {
it('sits between the new text of a plain-text reply and its folded quote', () => {
offerSlots('p1', ['reader-above-quote']);
@@ -116,6 +178,11 @@ describe('reader-above-quote', () => {
expect(follows(frame, quote)).toBe(true);
expect(follows(screen.getByText(/Sounds good/), frame)).toBe(true);
expect((slotProps(frame).email as { id: string }).id).toBe('e1');
+ // The frame renders inside the anchor, so the anchor must grow with it
+ // instead of letting the frame spill over the quote toggle and the footer.
+ const anchor = container.querySelector(`[${QUOTE_ANCHOR_ATTR}]`)!;
+ expect(anchor.contains(frame)).toBe(true);
+ expect(anchor.style.height).toBe('');
});
it('is not mounted for a message without a quote, or without a plugin', () => {
@@ -196,6 +263,48 @@ describe('thread-summary', () => {
});
});
+describe('reader surfaces in the phone conversation view', () => {
+ const conversation = [
+ textMail('Second', { id: 'm2', subject: 'Re: Plan', receivedAt: '2026-01-02T00:00:00Z' } as Partial),
+ textMail('First', { id: 'm1', subject: 'Plan', receivedAt: '2026-01-01T00:00:00Z' } as Partial),
+ ];
+ const thread = { threadKey: 't1', latestEmail: conversation[0], emails: conversation, emailCount: 2 } as unknown as ThreadGroup;
+
+ it('mounts the single reader\'s frames on the unfolded message only', () => {
+ offerSlots('p1', ['reader-header', 'reader-below-subject', 'email-banner', 'email-footer']);
+ render( {}} />);
+
+ // The latest message is unfolded, the older one (read) is folded.
+ for (const slot of ['reader-header', 'reader-below-subject', 'email-banner'] as const) {
+ const frames = screen.getAllByTestId(`slot-${slot}`);
+ expect(frames).toHaveLength(1);
+ expect((slotProps(frames[0]).email as { id: string }).id).toBe('m2');
+ }
+ const footer = screen.getAllByTestId('slot-email-footer');
+ expect(footer).toHaveLength(1);
+ expect(slotProps(footer[0])).toEqual({ emailId: 'm2' });
+ // Header on top, the body between banner and footer.
+ expect(follows(screen.getByTestId('slot-reader-header'), screen.getByTestId('slot-reader-below-subject'))).toBe(true);
+ expect(follows(screen.getByTestId('slot-email-banner'), screen.getByText('Second'))).toBe(true);
+ expect(follows(screen.getByText('Second'), footer[0])).toBe(true);
+ });
+
+ it('puts plugin reader actions behind a more button on the unfolded message', async () => {
+ act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate', mailboxRoles: ['inbox'] }]));
+ const { unmount } = render( {}} mailboxRole="inbox" />);
+
+ fireEvent.click(screen.getByRole('button', { name: 'more_actions' }));
+ const item = await screen.findByRole('menuitem', { name: 'Translate' });
+ await act(async () => { fireEvent.click(item); });
+ expect(dispatch).toHaveBeenCalledWith('p', 'reader-actions', 'translate', { emailId: 'm2' });
+ unmount();
+
+ // Kept to the folder roles the plugin asked for.
+ render( {}} mailboxRole="junk" />);
+ expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
+ });
+});
+
describe('reader-actions', () => {
it('adds the plugin buttons to the reader toolbar', async () => {
act(() => setPluginActions('p', 'Translator', 'reader-actions', [{ id: 'translate', label: 'Translate' }]));
@@ -230,6 +339,40 @@ describe('reader-actions', () => {
});
});
+describe('attachment-actions', () => {
+ const withAttachment = () => textMail('See attached', {
+ hasAttachment: true,
+ attachments: [{ partId: '2', blobId: 'blob-pdf', name: 'invoice.pdf', type: 'application/pdf', size: 1234, disposition: 'attachment' }],
+ } as Partial);
+
+ it('puts the frames next to the chip, not in its hover overlay', () => {
+ offerSlots('p1', ['attachment-actions']);
+ viewer(withAttachment());
+
+ // Desktop chip and phone chip are both in the DOM.
+ const frames = screen.getAllByTestId('slot-attachment-actions');
+ expect(frames.length).toBeGreaterThan(0);
+ for (const frame of frames) {
+ // Nothing between the frame and the page may hide it until hover.
+ for (let el: HTMLElement | null = frame; el; el = el.parentElement) {
+ expect(el.className).not.toMatch(/\bopacity-0\b/);
+ }
+ expect(frame.closest('[data-attachment-name]')).toBeNull();
+ expect(slotProps(frame)).toEqual({
+ attachment: { name: 'invoice.pdf', type: 'application/pdf', size: 1234, blobId: 'blob-pdf', emailId: 'e1' },
+ });
+ }
+ });
+
+ it('keeps the invisible hover overlay from taking taps', () => {
+ viewer(withAttachment());
+ const overlays = [...document.querySelectorAll('[data-attachment-name="invoice.pdf"] div')]
+ .filter((d) => /\bopacity-0\b/.test(d.className));
+ expect(overlays.length).toBeGreaterThan(0);
+ for (const overlay of overlays) expect(overlay.className).toMatch(/\bpointer-events-none\b/);
+ });
+});
+
describe('attachment-menu', () => {
const withAttachments = () => textMail('See attached', {
hasAttachment: true,
diff --git a/components/email/composer-plugin-sidebars.tsx b/components/email/composer-plugin-sidebars.tsx
new file mode 100644
index 000000000..c2fa7860a
--- /dev/null
+++ b/components/email/composer-plugin-sidebars.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+// The composer-sidebar and composer-sidebar-right plugin panels. Each gets a
+// fixed width and a toggle to fold it to a thin strip. When the composer is
+// too narrow for an open panel next to it, the panel folds by itself (right
+// first); opening it anyway lays it over the composer instead of squeezing
+// the message into a column nobody can write in.
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+import { PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen } from "@/components/icons";
+import { PluginSlot } from "@/components/plugins/plugin-slot";
+import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
+import { cn } from "@/lib/utils";
+
+/** Width of an open panel. */
+export const COMPOSER_SIDEBAR_WIDTH = 260;
+/** Width of a folded panel (the strip with its toggle). */
+export const COMPOSER_SIDEBAR_STRIP = 37;
+/**
+ * Narrowest the composer itself may get next to open panels: below this its
+ * bottom bar (Attach, Templates, Options, Discard, Send) runs into itself.
+ */
+export const MIN_COMPOSER_WIDTH = 560;
+
+export type ComposerSidebarSide = "left" | "right";
+/** docked: open beside the composer; folded: a strip; overlay: open over the composer. */
+export type ComposerSidebarMode = "docked" | "folded" | "overlay";
+
+interface SideState {
+ present: boolean;
+ /** Folded by the user. */
+ collapsed: boolean;
+ /** Opened by the user while there was no room for it. */
+ overlayOpen?: boolean;
+}
+
+/**
+ * How each panel is shown in a composer `width` px wide (0 = not measured
+ * yet: everything the user left open is shown open). The left panel gets
+ * room first, since the right one is folded first.
+ */
+export function composerSidebarModes(
+ width: number,
+ left: SideState,
+ right: SideState,
+): Record {
+ const present = (left.present ? 1 : 0) + (right.present ? 1 : 0);
+ let room = width > 0 ? width - MIN_COMPOSER_WIDTH - present * COMPOSER_SIDEBAR_STRIP : Infinity;
+ const place = (side: SideState): ComposerSidebarMode | null => {
+ if (!side.present) return null;
+ if (side.collapsed) return "folded";
+ const extra = COMPOSER_SIDEBAR_WIDTH - COMPOSER_SIDEBAR_STRIP;
+ if (extra <= room) {
+ room -= extra;
+ return "docked";
+ }
+ return side.overlayOpen ? "overlay" : "folded";
+ };
+ const l = place(left);
+ return { left: l, right: place(right) };
+}
+
+const storageKey = (side: ComposerSidebarSide) => `composerPluginSidebarCollapsed:${side}`;
+
+function readCollapsed(side: ComposerSidebarSide): boolean {
+ if (typeof window === "undefined") return false;
+ try { return localStorage.getItem(storageKey(side)) === "1"; } catch { return false; }
+}
+
+/** Layout state of both panels for a composer whose root element is `root`. */
+export function useComposerSidebars(root: HTMLElement | null) {
+ const hasLeft = usePluginSlotOffers("composer-sidebar").length > 0;
+ const hasRight = usePluginSlotOffers("composer-sidebar-right").length > 0;
+ const [width, setWidth] = useState(0);
+ const [collapsed, setCollapsed] = useState>(() => ({
+ left: readCollapsed("left"),
+ right: readCollapsed("right"),
+ }));
+ const [overlayOpen, setOverlayOpen] = useState>({ left: false, right: false });
+
+ useEffect(() => {
+ if (!root || (!hasLeft && !hasRight) || typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver((entries) => {
+ setWidth(Math.round(entries[0]?.contentRect.width ?? root.getBoundingClientRect().width));
+ });
+ observer.observe(root);
+ return () => observer.disconnect();
+ }, [root, hasLeft, hasRight]);
+
+ const modes = composerSidebarModes(
+ width,
+ { present: hasLeft, collapsed: collapsed.left, overlayOpen: overlayOpen.left },
+ { present: hasRight, collapsed: collapsed.right, overlayOpen: overlayOpen.right },
+ );
+
+ const setSideCollapsed = (side: ComposerSidebarSide, value: boolean) => {
+ setCollapsed((prev) => ({ ...prev, [side]: value }));
+ try { localStorage.setItem(storageKey(side), value ? "1" : "0"); } catch { /* storage blocked */ }
+ };
+
+ /** Open a folded panel: beside the composer when it fits, over it otherwise. */
+ const open = (side: ComposerSidebarSide) => {
+ const next = { ...collapsed, [side]: false };
+ const fits = composerSidebarModes(
+ width,
+ { present: hasLeft, collapsed: next.left },
+ { present: hasRight, collapsed: next.right },
+ )[side] === "docked";
+ setSideCollapsed(side, false);
+ setOverlayOpen((prev) => ({ ...prev, [side]: !fits }));
+ };
+ const close = (side: ComposerSidebarSide) => {
+ setSideCollapsed(side, true);
+ setOverlayOpen((prev) => ({ ...prev, [side]: false }));
+ };
+
+ return { modes, open, close };
+}
+
+/** One plugin panel of the composer, in the mode useComposerSidebars picked. */
+export function ComposerPluginSidebar({
+ side,
+ mode,
+ onOpen,
+ onClose,
+}: {
+ side: ComposerSidebarSide;
+ mode: ComposerSidebarMode | null;
+ onOpen: () => void;
+ onClose: () => void;
+}) {
+ const t = useTranslations("email_viewer");
+ if (!mode) return null;
+ const border = side === "left" ? "border-e" : "border-s";
+ const OpenIcon = side === "left" ? PanelLeftOpen : PanelRightOpen;
+ const CloseIcon = side === "left" ? PanelLeftClose : PanelRightClose;
+
+ if (mode === "folded") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {side === "left"
+ ?
+ : }
+
+
+ );
+}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index cbd2c9cff..beaae874d 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -61,6 +61,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 { PluginSlot } from "@/components/plugins/plugin-slot";
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { usePluginActions } from "@/components/plugins/plugin-actions";
@@ -2071,6 +2072,9 @@ export function EmailComposer({
// Stable props, so a re-render of the composer doesn't post them to the
// plugin's iframe again.
const pluginToolbarOffers = usePluginSlotOffers('composer-toolbar');
+ // The plugin side panels fold when the composer gets too narrow for them.
+ const [composerRootEl, setComposerRootEl] = useState(null);
+ const pluginSidebars = useComposerSidebars(composerRootEl);
const pluginAttachmentSourceOffers = usePluginSlotOffers('composer-attachment-source');
const hasAboveBodyOffers = usePluginSlotOffers('composer-above-body').length > 0;
const hasBelowBodyOffers = usePluginSlotOffers('composer-below-body').length > 0;
@@ -3222,14 +3226,12 @@ export function EmailComposer({
return (
-
+ pluginSidebars.open('left')} onClose={() => pluginSidebars.close('left')} />
{/* Right-side composer sidebar slot is rendered after the main content div below. */}
)}
+ );
+}
diff --git a/components/settings/__tests__/settings-plugin-page.test.tsx b/components/settings/__tests__/settings-plugin-page.test.tsx
index 40887a75e..622628fe3 100644
--- a/components/settings/__tests__/settings-plugin-page.test.tsx
+++ b/components/settings/__tests__/settings-plugin-page.test.tsx
@@ -94,6 +94,29 @@ describe('Settings plugin pages', () => {
expect(opts.plugin.id).toBe('kanban');
});
+ it('gives a plugin with both a page and a section one entry, with the section on its page', async () => {
+ activate('showcase', 'Showcase', ['settings-section', 'settings-page']);
+ const { container } = render();
+
+ const entries = screen.getAllByRole('button', { name: /Showcase/ });
+ expect(entries).toHaveLength(1);
+
+ act(() => { fireEvent.click(entries[0]); });
+ await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(2));
+ const slots = createSlotInstance.mock.calls.map((call) => (call[0] as { slot: string }).slot);
+ expect(slots.sort()).toEqual(['settings-page', 'settings-section']);
+ const section = container.querySelector('[data-plugin-slot="settings-section"]')!;
+ const page = container.querySelector('[data-plugin-slot="settings-page"]')!;
+ expect(section.compareDocumentPosition(page) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ });
+
+ it('opens the page of such a plugin from an old link to its section', async () => {
+ activate('showcase', 'Showcase', ['settings-section', 'settings-page']);
+ const { container } = render();
+
+ await waitFor(() => expect(container.querySelector('[data-plugin-slot="settings-page"]')).not.toBeNull());
+ });
+
it('opens the page from a /settings/plugin-page: link', async () => {
activate('kanban', 'Kanban', ['settings-page']);
render();
diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx
index 42c26226e..be232a5e9 100644
--- a/components/settings/settings-app.tsx
+++ b/components/settings/settings-app.tsx
@@ -254,6 +254,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
() => pluginOffersForSlot('settings-page'),
);
const tPluginPages = useTranslations('plugin_pages');
+ const pluginPageIds = useMemo(() => new Set(pluginPageOffers.map((offer) => offer.pluginId)), [pluginPageOffers]);
+ const pluginSectionIds = useMemo(() => new Set(pluginSettingsOffers.map((offer) => offer.pluginId)), [pluginSettingsOffers]);
// Consume the one-shot deep-link key so a section gear only steers this one
// open, never the persisted default for future Settings-button clicks.
useEffect(() => {
@@ -584,7 +586,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
// Plugin-contributed settings pages: one entry per active plugin that
// offers a `settings-section` slot (e.g. S/MIME key & certificate manager).
- ...pluginSettingsOffers.map((offer): TabDef => ({
+ // A plugin that also has a page of its own gets that entry only, with the
+ // section on its page, instead of two entries with the same name.
+ ...pluginSettingsOffers.filter((offer) => !pluginPageIds.has(offer.pluginId)).map((offer): TabDef => ({
id: `plugin:${offer.pluginId}` as PluginTabId,
label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId,
icon: Puzzle,
@@ -657,9 +661,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
// If active tab is not in the visible list (e.g., feature disabled, or scoped
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
// otherwise the usual 'appearance' default.
- const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
+ // An old link or remembered tab to the section of a plugin that now has a
+ // page of its own opens that page, where the section is.
+ const sectionPluginId = activeTab.startsWith('plugin:') ? activeTab.slice('plugin:'.length) : null;
+ const requestedTab: SettingsTabId = sectionPluginId && pluginPageIds.has(sectionPluginId)
+ ? (`${PLUGIN_PAGE_PREFIX}${sectionPluginId}` as PluginPageTabId)
+ : activeTab;
+ const isActiveVisible = visibleTabs.some((tab) => tab.id === requestedTab);
const effectiveActiveTab: SettingsTabId = isActiveVisible
- ? activeTab
+ ? requestedTab
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
const handleTabSelect = (tabId: SettingsTabId) => {
@@ -736,13 +746,26 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
/>
)}
{effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) && (
-
-
-
+ <>
+ {/* The plugin's settings-section, on its page rather than as a
+ second entry with the same name. */}
+ {pluginSectionIds.has(effectiveActiveTab.slice(PLUGIN_PAGE_PREFIX.length)) && (
+
+
+
+ )}
+
+
+
+ >
)}
>
);
diff --git a/components/ui/__tests__/context-menu-fit.test.tsx b/components/ui/__tests__/context-menu-fit.test.tsx
new file mode 100644
index 000000000..c9c95da28
--- /dev/null
+++ b/components/ui/__tests__/context-menu-fit.test.tsx
@@ -0,0 +1,59 @@
+import { act, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ContextMenu, ContextMenuItem } from '../context-menu';
+
+// A menu that grows after it opened (a plugin frame in it reports its
+// height late) must be moved up again so it stays inside the window.
+
+function rect(left: number, top: number, width: number, height: number): DOMRect {
+ return { left, top, width, height, right: left + width, bottom: top + height, x: left, y: top, toJSON: () => ({}) } as DOMRect;
+}
+
+let resize: (() => void) | null = null;
+class ManualResizeObserver {
+ constructor(cb: () => void) { resize = cb; }
+ observe() {}
+ unobserve() {}
+ disconnect() { resize = null; }
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ resize = null;
+});
+
+describe('ContextMenu fit', () => {
+ it('moves up when its content grows past the bottom of the window', () => {
+ vi.stubGlobal('ResizeObserver', ManualResizeObserver);
+ Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
+ Object.defineProperty(window, 'innerWidth', { value: 1280, configurable: true });
+ let height = 200;
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+ return this.getAttribute('role') === 'menu' ? rect(0, 0, 220, height) : rect(0, 0, 0, 0);
+ });
+
+ render(
+ {}}>
+ {}} />
+ ,
+ );
+ const menu = screen.getByRole('menu');
+ expect(menu.style.top).toBe('500px');
+
+ height = 624;
+ act(() => resize?.());
+ expect(menu.style.top).toBe(`${800 - 624 - 10}px`);
+ });
+
+ it('scrolls inside instead of running off a short window', () => {
+ render(
+ {}}>
+ {}} />
+ ,
+ );
+ const inner = screen.getByRole('menu').firstElementChild as HTMLElement;
+ expect(inner.className).toContain('overflow-y-auto');
+ expect(inner.style.maxHeight).toMatch(/100vh/);
+ });
+});
diff --git a/components/ui/context-menu.tsx b/components/ui/context-menu.tsx
index dbe9b0b25..a8dc532a8 100644
--- a/components/ui/context-menu.tsx
+++ b/components/ui/context-menu.tsx
@@ -35,6 +35,9 @@ export const ContextMenu = forwardRef(
// `mounted` must be a dependency: on the very first open the menu isn't
// in the DOM yet (mounted is still false), so this effect has to re-run
// after the mount flip or the menu stays visibility:hidden.
+ //
+ // The menu can grow after it opened (a plugin frame in it reports its
+ // height later), so it is clamped again whenever its size changes.
useLayoutEffect(() => {
if (!isOpen) {
setAdjustedPosition(null);
@@ -43,23 +46,30 @@ export const ContextMenu = forwardRef(
const node = localRef.current;
if (!node) return;
- const rect = node.getBoundingClientRect();
- const vw = window.innerWidth;
- const vh = window.innerHeight;
+ const clamp = () => {
+ const rect = node.getBoundingClientRect();
+ const vw = window.innerWidth;
+ const vh = window.innerHeight;
- let x = position.x;
- let y = position.y;
+ let x = position.x;
+ let y = position.y;
- if (x + rect.width > vw - VIEWPORT_MARGIN) {
- x = vw - rect.width - VIEWPORT_MARGIN;
- }
- if (y + rect.height > vh - VIEWPORT_MARGIN) {
- y = vh - rect.height - VIEWPORT_MARGIN;
- }
- x = Math.max(VIEWPORT_MARGIN, x);
- y = Math.max(VIEWPORT_MARGIN, y);
+ if (x + rect.width > vw - VIEWPORT_MARGIN) {
+ x = vw - rect.width - VIEWPORT_MARGIN;
+ }
+ if (y + rect.height > vh - VIEWPORT_MARGIN) {
+ y = vh - rect.height - VIEWPORT_MARGIN;
+ }
+ x = Math.max(VIEWPORT_MARGIN, x);
+ y = Math.max(VIEWPORT_MARGIN, y);
- setAdjustedPosition({ x, y });
+ setAdjustedPosition((prev) => (prev && prev.x === x && prev.y === y ? prev : { x, y }));
+ };
+ clamp();
+ if (typeof ResizeObserver === "undefined") return;
+ const observer = new ResizeObserver(clamp);
+ observer.observe(node);
+ return () => observer.disconnect();
}, [isOpen, mounted, position.x, position.y]);
const setRefs = (node: HTMLDivElement | null) => {
@@ -90,7 +100,8 @@ export const ContextMenu = forwardRef(
role="menu"
aria-orientation="vertical"
>
-
+ {/* Taller than the window: scroll inside the menu. */}
+
{children}
,
diff --git a/lib/__tests__/plugin-api-gates.test.ts b/lib/__tests__/plugin-api-gates.test.ts
index d33b80ad1..7e5205a4d 100644
--- a/lib/__tests__/plugin-api-gates.test.ts
+++ b/lib/__tests__/plugin-api-gates.test.ts
@@ -317,6 +317,79 @@ describe('jmap.uploadBlob', () => {
expect(await gateError(plugin(), 'jmap.uploadBlob', [new Uint8Array(1), 'a.bin', 'x/y']))
.toMatch(/lacks permission "email:blob-write"/);
});
+
+ describe('checks what it uploads', () => {
+ const uploader = () => plugin({ permissions: ['email:blob-write'], grantedPermissions: ['email:blob-write'] });
+ const uploadBlob = vi.fn(async (file: File) => ({ blobId: 'b1', size: file.size, type: file.type, name: file.name }));
+ let maxSizeUpload = 0;
+ const session = () => useAuthStore.setState({ client: { uploadBlob, getMaxSizeUpload: () => maxSizeUpload } as never });
+
+ afterEach(() => {
+ uploadBlob.mockClear();
+ maxSizeUpload = 0;
+ useAuthStore.setState({ client: null });
+ });
+
+ it('refuses a missing or blank name, a bad media type and content that is not bytes', async () => {
+ session();
+ expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(1), '', 'text/plain'])).toMatch(/name must be 1-255 characters/);
+ expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(1), '\u0000\u0001', 'text/plain'])).toMatch(/name must be 1-255 characters/);
+ expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(1), 'x'.repeat(256), 'text/plain'])).toMatch(/name must be 1-255 characters/);
+ expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(1), 'a.txt', 'text/plain; charset=utf-8'])).toMatch(/type is not a media type/);
+ expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(1), 'a.txt', 42])).toMatch(/type must be a string/);
+ expect(await gateError(uploader(), 'jmap.uploadBlob', ['hello', 'a.txt', 'text/plain'])).toMatch(/content must be a Uint8Array/);
+ expect(uploadBlob).not.toHaveBeenCalled();
+ });
+
+ it('caps the size at the server limit, or 50 MB when the server names none', async () => {
+ session();
+ maxSizeUpload = 10;
+ expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(11), 'a.bin', 'application/octet-stream'])).toMatch(/more than the 10 bytes allowed/);
+ expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(10), 'a.bin', 'application/octet-stream'])).toBe('');
+
+ maxSizeUpload = 0;
+ expect(await gateError(uploader(), 'jmap.uploadBlob', [new Uint8Array(50 * 1024 * 1024 + 1), 'a.bin', 'application/octet-stream']))
+ .toMatch(/more than the 52428800 bytes allowed/);
+ expect(uploadBlob).toHaveBeenCalledTimes(1);
+ });
+
+ it('uploads a clean file under its name and type', async () => {
+ session();
+ await dispatchApiCall(uploader(), 'jmap.uploadBlob', [new Uint8Array([1, 2, 3]), 'scan\u0007.pdf', 'application/pdf']);
+ const file = uploadBlob.mock.calls[0][0];
+ expect(file.name).toBe('scan.pdf');
+ expect(file.type).toBe('application/pdf');
+ expect(file.size).toBe(3);
+ });
+ });
+});
+
+describe('email.setKeyword and email.removeKeyword', () => {
+ const setKeyword = vi.fn(async () => {});
+ const removeKeyword = vi.fn(async () => {});
+ afterEach(() => {
+ setKeyword.mockClear();
+ removeKeyword.mockClear();
+ useAuthStore.setState({ client: null });
+ });
+
+ it('check the email id and account id like jmap.setKeyword does', async () => {
+ useAuthStore.setState({ client: { setKeyword, removeKeyword } as never });
+ const writer = plugin({ permissions: ['email:write'], grantedPermissions: ['email:write'] });
+ for (const method of ['email.setKeyword', 'email.removeKeyword']) {
+ expect(await gateError(writer, method, [undefined, '$label:work'])).toMatch(/emailId is required/);
+ expect(await gateError(writer, method, ['', '$label:work'])).toMatch(/emailId is required/);
+ expect(await gateError(writer, method, [{ id: 'e1' }, '$label:work'])).toMatch(/emailId is required/);
+ expect(await gateError(writer, method, ['e1', '$label:work', 7])).toMatch(/accountId must be a non-empty string/);
+ }
+ expect(setKeyword).not.toHaveBeenCalled();
+ expect(removeKeyword).not.toHaveBeenCalled();
+
+ await dispatchApiCall(writer, 'email.setKeyword', ['e1', '$label:work', 'acc-2']);
+ expect(setKeyword).toHaveBeenCalledWith('e1', '$label:work', 'acc-2');
+ await dispatchApiCall(writer, 'email.removeKeyword', ['e1', '$label:work']);
+ expect(removeKeyword).toHaveBeenCalledWith('e1', '$label:work', undefined);
+ });
});
describe('WebAuthn PRF salt', () => {
diff --git a/lib/plugin-sandbox/host-api-input.ts b/lib/plugin-sandbox/host-api-input.ts
index d76dba55a..eb94c7c35 100644
--- a/lib/plugin-sandbox/host-api-input.ts
+++ b/lib/plugin-sandbox/host-api-input.ts
@@ -127,6 +127,21 @@ export function nameOf(entry: string): string {
const MIME_TYPE_RE = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/i;
+/** A media type such as `application/pdf`, without parameters. */
+export function readMediaType(value: unknown, method: string, field: string): string {
+ const type = readString(value, method, field, 255);
+ if (!MIME_TYPE_RE.test(type)) throw new Error(`${method}: ${field} is not a media type`);
+ return type;
+}
+
+/** A file name of 1-255 characters, control characters removed. */
+export function readFileName(value: unknown, method: string, field: string): string {
+ // eslint-disable-next-line no-control-regex -- control characters in a file name are refused
+ const name = readString(value, method, field, 255, { min: 1 }).replace(/[\u0000-\u001f\u007f]/g, '');
+ if (!name.trim()) throw new Error(`${method}: ${field} must be 1-255 characters`);
+ return name;
+}
+
export interface PluginBlobRef {
blobId: string;
name: string;
@@ -150,12 +165,10 @@ export function readBlobRefs(
return value.map((entry, index) => {
const field = `attachments[${index}]`;
const ref = readObject(entry, method, field, ['blobId', 'name', 'type', 'size']);
- const type = readString(ref.type, method, `${field}.type`, 255);
- if (!MIME_TYPE_RE.test(type)) throw new Error(`${method}: ${field}.type is not a media type`);
+ const type = readMediaType(ref.type, method, `${field}.type`);
return {
blobId: readId(ref.blobId, method, `${field}.blobId`),
- // eslint-disable-next-line no-control-regex -- control characters in a file name are refused
- name: readString(ref.name, method, `${field}.name`, 255, { min: 1 }).replace(/[\u0000-\u001f\u007f]/g, ''),
+ name: readFileName(ref.name, method, `${field}.name`),
type,
size: readInteger(ref.size, method, `${field}.size`, 0, maxSize),
};
diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts
index 5752b7600..c1767e787 100644
--- a/lib/plugin-sandbox/host-api.ts
+++ b/lib/plugin-sandbox/host-api.ts
@@ -49,7 +49,7 @@ import {
import {
doEmailArchive, doEmailGet, doEmailGetAttachment, doEmailGetRaw, doEmailList, doEmailMove, doMailboxesList,
} from './host-api-mail';
-import { MAX_PLUGIN_FETCH_BYTES } from './host-api-input';
+import { MAX_PLUGIN_FETCH_BYTES, readFileName, readMediaType, sessionClient } from './host-api-input';
import { doComposeOpen, doDraftsCreate } from './host-api-compose';
import { doNotificationsShow } from './host-api-notify';
@@ -699,10 +699,30 @@ async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: st
return new Uint8Array(buf);
}
-async function doJmapUploadBlob(content: Uint8Array, name: string, type: string): Promise<{ blobId: string; size: number; type: string; }> {
- const { client } = useAuthStore.getState();
- if (!client) throw new Error('jmap.uploadBlob: no active session');
- const file = new File([content as BlobPart], name, { type });
+/** Largest blob a plugin may upload when the server names no limit of its own. */
+export const MAX_PLUGIN_UPLOAD_BYTES = 50 * 1024 * 1024;
+
+/**
+ * jmap.uploadBlob: bytes from the plugin, checked like the attachment refs a
+ * plugin hands the composer (readBlobRefs): a real file name, a media type,
+ * and no more than the server accepts (its maxSizeUpload, or 50 MB when it
+ * names none).
+ */
+async function doJmapUploadBlob(content: unknown, name: unknown, type: unknown): Promise<{ blobId: string; size: number; type: string; }> {
+ const method = 'jmap.uploadBlob';
+ const bytes = content instanceof Uint8Array ? content
+ : content instanceof ArrayBuffer ? new Uint8Array(content)
+ : null;
+ if (!bytes) throw new Error(`${method}: content must be a Uint8Array or ArrayBuffer`);
+ const fileName = readFileName(name, method, 'name');
+ const mediaType = readMediaType(type, method, 'type');
+ const client = sessionClient(method);
+ const serverLimit = client.getMaxSizeUpload();
+ const limit = serverLimit > 0 ? serverLimit : MAX_PLUGIN_UPLOAD_BYTES;
+ if (bytes.byteLength > limit) {
+ throw new Error(`${method}: content is ${bytes.byteLength} bytes, more than the ${limit} bytes allowed`);
+ }
+ const file = new File([bytes as BlobPart], fileName, { type: mediaType });
return await client.uploadBlob(file);
}
@@ -1720,7 +1740,7 @@ export async function dispatchApiCall(
case 'http.fetch': return doHttpFetch(plugin, args[0] as string, args[1] as PluginFetchInit | undefined);
case 'jmap.fetchBlob': return doJmapFetchBlob(args[0] as string, args[1] as { name?: string; type?: string, rangeHeader?: number } | undefined);
- case 'jmap.uploadBlob': return doJmapUploadBlob(args[0] as Uint8Array, args[1] as string, args[2] as string);
+ case 'jmap.uploadBlob': return doJmapUploadBlob(args[0], args[1], args[2]);
case 'jmap.sendRaw': return doJmapSendRaw(
args[0] as ArrayBuffer | ArrayBufferView,
args[1] as string,
@@ -1891,13 +1911,17 @@ export async function dispatchApiCall(
}
case 'email.setKeyword': {
+ const emailId = assertEmailId(args[0], 'email.setKeyword');
const keyword = assertPluginKeyword(args[1]);
- await requireClient().setKeyword(String(args[0]), keyword, args[2] as string | undefined);
+ const accountId = assertOptionalAccountId(args[2], 'email.setKeyword');
+ await requireClient().setKeyword(emailId, keyword, accountId);
return undefined;
}
case 'email.removeKeyword': {
+ const emailId = assertEmailId(args[0], 'email.removeKeyword');
const keyword = assertPluginKeyword(args[1]);
- await requireClient().removeKeyword(String(args[0]), keyword, args[2] as string | undefined);
+ const accountId = assertOptionalAccountId(args[2], 'email.removeKeyword');
+ await requireClient().removeKeyword(emailId, keyword, accountId);
return undefined;
}
diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts
index 03d1e1213..f6e307bdf 100644
--- a/lib/plugin-slot-catalog.ts
+++ b/lib/plugin-slot-catalog.ts
@@ -68,7 +68,7 @@ export const SLOT_CATALOG = {
'email-banner': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'a banner above the message body'),
'reader-above-quote': S('reader', 'frame', ['ui:email-banner'], { email: 'email:read' }, 'between the new text of a message and the quoted text below it'),
'email-footer': S('reader', 'frame', ['ui:email-footer'], { emailId: 'email:read' }, 'below the message body'),
- 'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read' }, 'above the messages of an open conversation'),
+ 'thread-summary': S('reader', 'frame', ['ui:email-banner'], { threadId: 'email:read', emailIds: 'email:read' }, 'above the messages of an open conversation (the phone conversation view and Mountain View; the default desktop layout opens one message at a time and has no conversation view)'),
'reader-actions': S('reader', 'action', ['ui:toolbar'], { emailId: 'email:read' }, 'buttons in the open message\'s toolbar', true),
'email-detail-sidebar': S('reader', 'frame', ['ui:email-details'], { email: 'email:read' }, 'a side panel next to the open message'),
'email-details-section': S('reader', 'frame', ['ui:email-details'], { email: 'email:read', category: null }, 'a section in the message details'),
@@ -81,7 +81,7 @@ export const SLOT_CATALOG = {
'composer-below-body': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { draft: 'email:send', insertText: 'compose:open', getSelectedText: 'email:send' }, 'below the message text, above the attachments'),
'composer-sidebar': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel left of the composer'),
'composer-sidebar-right': S('composer', 'frame', ['ui:composer-sidebar'], {}, 'a panel right of the composer'),
- 'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null, insertText: 'compose:open' }, 'the composer\'s attach menu, as a source of files', true),
+ 'composer-attachment-source': S('composer', 'frame', ['ui:composer-toolbar', 'ui:composer-sidebar'], { onAttach: null, insertText: 'compose:open' }, 'the composer\'s plugin button row, next to composer-toolbar, as a source of files (for an entry in the Attach menu itself, use composer-attach-menu)', true),
'composer-send-menu': S('composer', 'action', ['ui:composer-toolbar'], { draft: 'email:send' }, 'entries in the menu next to Send'),
'composer-attach-menu': S('composer', 'action', ['ui:composer-toolbar'], {}, 'entries in the attach menu (for example "From Nextcloud")'),
@@ -89,7 +89,7 @@ export const SLOT_CATALOG = {
'calendar-event-actions': S('calendar', 'frame', ['ui:calendar-action'], { eventData: 'calendar:read', setVirtualLocation: null }, 'buttons in the event form', true),
'calendar-event-detail': S('calendar', 'frame', ['ui:calendar-action'], { event: 'calendar:read' }, 'a section in an event\'s details'),
'calendar-event-form': S('calendar', 'frame', ['ui:calendar-action'], { event: 'calendar:read' }, 'a section in the form for a new or edited event'),
- 'calendar-day-header': S('calendar', 'data', ['ui:calendar-action'], { date: null }, 'a short label under a day\'s name in the day and week views (api.ui.setDayLabels)'),
+ 'calendar-day-header': S('calendar', 'data', ['ui:calendar-action'], { date: null }, 'a short label under a day\'s name in the day, week and month views; a dot in the phone month view (api.ui.setDayLabels)'),
'mini-calendar-marks': S('calendar', 'data', ['ui:calendar-action'], { date: null }, 'dots on days in the small month calendar (api.ui.setDayLabels)'),
// ─── Contacts ──────────────────────────────────────────────
@@ -106,7 +106,7 @@ export const SLOT_CATALOG = {
'file-preview-section': S('files', 'frame', ['ui:files'], { file: 'files:read' }, 'a section in the file preview'),
// ─── Settings and admin ────────────────────────────────────
- 'settings-section': S('settings', 'frame', ['ui:settings-section'], {}, 'a section on the plugin\'s own page in Settings'),
+ 'settings-section': S('settings', 'frame', ['ui:settings-section'], {}, 'a section on the plugin\'s own page in Settings (above its settings-page, when it has one: one entry per plugin)'),
'settings-page': S('settings', 'frame', ['ui:settings-section'], {}, 'a page of its own in Settings, listed under the plugin\'s name'),
'admin-plugin-page': S('admin', 'frame', ['ui:admin-page', 'admin:config'], {}, 'the plugin\'s page in the admin dashboard'),
} as const satisfies Record;