diff --git a/components/email/__tests__/composer-plugin-surfaces.test.tsx b/components/email/__tests__/composer-plugin-surfaces.test.tsx
index 34b0ad4d9..73dacdb35 100644
--- a/components/email/__tests__/composer-plugin-surfaces.test.tsx
+++ b/components/email/__tests__/composer-plugin-surfaces.test.tsx
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
-import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
+import { checkActions, resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
import { clearOffers, lastSlotProps, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
import { useSettingsStore } from '@/stores/settings-store';
import { SLOT_CATALOG } from '@/lib/plugin-slot-catalog';
@@ -334,6 +334,50 @@ describe('composer-send-menu', () => {
});
});
+describe('composer-options-menu and composer-toolbar-actions', () => {
+ it('lists plugin options in Options, ticked ones as check items counted in the badge', async () => {
+ act(() => setPluginActions('smime', 'S/MIME', 'composer-options-menu', [
+ { id: 'sign', label: 'Sign', checked: true, description: 'With your certificate' },
+ { id: 'encrypt', label: 'Encrypt', checked: false },
+ { id: 'zones', label: 'Insert time in other zones' },
+ ]));
+ render();
+
+ const options = screen.getByTestId('composer-options-menu');
+ expect(options).toHaveTextContent('1');
+ fireEvent.click(options);
+ const sign = screen.getByRole('menuitemcheckbox', { name: /Sign/ });
+ expect(sign).toHaveAttribute('aria-checked', 'true');
+ expect(sign).toHaveTextContent('With your certificate');
+ expect(screen.getByRole('menuitemcheckbox', { name: 'Encrypt' })).toHaveAttribute('aria-checked', 'false');
+
+ await act(async () => { fireEvent.click(screen.getByRole('menuitemcheckbox', { name: 'Encrypt' })); });
+ expect(dispatch).toHaveBeenCalledWith('smime', 'composer-options-menu', 'encrypt', { composerId: expect.any(String) });
+ // A check item leaves the menu open, so several options can be set.
+ expect(screen.getByRole('menuitemcheckbox', { name: /Sign/ })).toBeInTheDocument();
+
+ await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Insert time in other zones' })); });
+ expect(dispatch).toHaveBeenCalledWith('smime', 'composer-options-menu', 'zones', { composerId: expect.any(String) });
+ });
+
+ it('draws plugin buttons in the bottom bar like Attach, with the shortcut in the tooltip', async () => {
+ act(() => setPluginActions('ai', 'Assistant', 'composer-toolbar-actions', [{ id: 'ask', label: 'Assistant', icon: 'sparkles', shortcut: 'Alt+A' }]));
+ render();
+ const button = screen.getByRole('button', { name: 'Assistant' });
+ expect(button).toHaveAttribute('title', 'Assistant (Alt+A)');
+ expect(button.className).toContain('h-9');
+ // Not in the plugin frame row above the bar.
+ expect(button.closest('[data-composer-plugin-row]')).toBeNull();
+ await act(async () => { fireEvent.click(button); });
+ expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) });
+ });
+
+ it('refuses odd values for checked and description', () => {
+ expect(() => setPluginActions('p', 'P', 'composer-options-menu', checkActions([{ id: 'a', label: 'A', checked: 'yes' }]))).toThrow(/checked must be true or false/);
+ expect(() => checkActions([{ id: 'a', label: 'A', description: 'x'.repeat(121) }])).toThrow(/description/);
+ });
+});
+
describe('composer-attach-menu', () => {
it('turns Attach into a menu for plugin entries', async () => {
act(() => setPluginActions('p', 'Cloud', 'composer-attach-menu', [{ id: 'cloud', label: 'From the cloud' }]));
diff --git a/components/email/composer-toolbar-menu.tsx b/components/email/composer-toolbar-menu.tsx
index 7b09d5f13..0c7c26c34 100644
--- a/components/email/composer-toolbar-menu.tsx
+++ b/components/email/composer-toolbar-menu.tsx
@@ -195,6 +195,20 @@ export function ComposerPluginMenuItems({ surface, context, onSelect, separator
{separator &&
}
{actions.map((action) => {
const Icon = iconForName(action.icon) ?? Puzzle;
+ if (action.checked !== undefined) {
+ // An option like the menu's own (plain text, read receipt): stays
+ // open, so several can be set in one go.
+ return (
+ { void runAction(action, surface, typeof context === "function" ? context() : context); }}
+ >
+ {action.label}
+
+ );
+ }
return (
);
})}
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index df1e789c1..ceb8a8a59 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -2086,6 +2086,8 @@ export function EmailComposer({
// itself has nothing else to put there.
const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0;
+ // Ticked plugin options count in the Options button's badge like the app's own.
+ const pluginOptionsOn = usePluginActions('composer-options-menu').filter((a) => a.checked).length;
// Files from the Files app are already blobs on the server, so they are
// attached by blobId with no download or upload (#1179). Stalwart resolves a
@@ -4075,7 +4077,7 @@ export function EmailComposer({
{(close) => <>
diff --git a/lib/plugin-sandbox/actions.ts b/lib/plugin-sandbox/actions.ts
index ef5f81b9e..e69da38da 100644
--- a/lib/plugin-sandbox/actions.ts
+++ b/lib/plugin-sandbox/actions.ts
@@ -25,6 +25,13 @@ export interface PluginAction {
* Ctrl/Cmd+Shift or Ctrl/Cmd+Alt, like plugin shortcuts.
*/
shortcut?: string;
+ /**
+ * On menu surfaces (the composer's Options and Send menus): draw the entry
+ * as a check item, ticked or not. The plugin updates it with setActions.
+ */
+ checked?: boolean;
+ /** A second line under the label, on menu surfaces. Up to 120 characters. */
+ description?: string;
}
export interface RegisteredAction extends PluginAction {
@@ -77,6 +84,14 @@ export function checkActions(value: unknown): PluginAction[] {
out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase());
}
if (a.danger !== undefined) out.danger = a.danger === true;
+ if (a.checked !== undefined) {
+ if (typeof a.checked !== 'boolean') throw new Error(`actions[${i}].checked must be true or false`);
+ out.checked = a.checked;
+ }
+ if (a.description !== undefined) {
+ if (typeof a.description !== 'string' || a.description.length > 120) throw new Error(`actions[${i}].description must be a string of up to 120 characters`);
+ if (a.description.trim()) out.description = a.description.trim();
+ }
if (a.shortcut !== undefined) {
if (typeof a.shortcut !== 'string' || a.shortcut.length > 30 || !isAllowedPluginCombo(a.shortcut)) {
throw new Error(`actions[${i}].shortcut must use Alt, Ctrl+Shift or Ctrl+Alt`);