From ab878ebf4fbc84de1ddf46e8c63a5ecee2bf3015 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:10:10 +0100 Subject: [PATCH] feat: tell plugins when Bulwark's own external mail warnings are on and skip the retired plugin's --- .../__tests__/plugin-slot-handover.test.tsx | 47 ++++++ components/plugins/plugin-slot.tsx | 12 +- .../plugin-external-mail-handover.test.ts | 105 +++++++++++++ lib/__tests__/plugin-host-features.test.ts | 143 ++++++++++++++++++ lib/plugin-sandbox/host-bridge.ts | 23 ++- lib/plugin-sandbox/host-features.ts | 65 ++++++++ lib/plugin-sandbox/loader.ts | 8 +- lib/plugin-sandbox/protocol.ts | 10 +- lib/plugin-sandbox/runtime.tsx | 34 ++++- lib/plugin-types.ts | 14 ++ 10 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 components/plugins/__tests__/plugin-slot-handover.test.tsx create mode 100644 lib/__tests__/plugin-external-mail-handover.test.ts create mode 100644 lib/__tests__/plugin-host-features.test.ts create mode 100644 lib/plugin-sandbox/host-features.ts diff --git a/components/plugins/__tests__/plugin-slot-handover.test.tsx b/components/plugins/__tests__/plugin-slot-handover.test.tsx new file mode 100644 index 000000000..24cb78e67 --- /dev/null +++ b/components/plugins/__tests__/plugin-slot-handover.test.tsx @@ -0,0 +1,47 @@ +import { render, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { clearSlotOffers, offerSlots } from './fake-slot-offer'; + +vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer')); + +const { PluginSlot } = await import('../plugin-slot'); + +// The retired external-mail-warning plugin's banner stays away while the +// organisation's own External label or recipient warning is on, so the +// reader doesn't show both; other plugins' banners stay. + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +const frames = (container: HTMLElement) => + [...container.querySelectorAll('[data-testid="plugin-frame-email-banner"]')].map((el) => el.getAttribute('data-plugin-id')); + +beforeEach(() => { + setExternalMail({}); + offerSlots('external-mail-warning', ['email-banner']); + offerSlots('spam-score', ['email-banner']); +}); +afterEach(() => { + cleanup(); + clearSlotOffers(); +}); + +describe('email-banner slot while core external warnings are on', () => { + it('shows the plugin banner while they are off and hides it as soon as one is on', () => { + const { container } = render(); + expect(frames(container).sort()).toEqual(['external-mail-warning', 'spam-score']); + act(() => setExternalMail({ senderLabel: true })); + expect(frames(container)).toEqual(['spam-score']); + act(() => setExternalMail({})); + expect(frames(container).sort()).toEqual(['external-mail-warning', 'spam-score']); + }); +}); diff --git a/components/plugins/plugin-slot.tsx b/components/plugins/plugin-slot.tsx index ad7e96dbd..90e87d560 100644 --- a/components/plugins/plugin-slot.tsx +++ b/components/plugins/plugin-slot.tsx @@ -1,10 +1,12 @@ 'use client'; -import React, { useSyncExternalStore } from 'react'; +import React, { useMemo, useSyncExternalStore } from 'react'; import type { SlotName } from '@/lib/plugin-types'; import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry'; import { PluginIframeSlot } from './plugin-iframe-slot'; import { isInlineSlot } from '@/lib/plugin-sandbox/slot-layout'; +import { externalWarningsOn, isSlotHandedOver } from '@/lib/plugin-sandbox/host-features'; +import { usePolicyStore } from '@/stores/policy-store'; import { cn } from '@/lib/utils'; interface PluginSlotProps { @@ -15,7 +17,13 @@ interface PluginSlotProps { export function PluginSlot({ name, className, extraProps }: PluginSlotProps) { const getSnapshot = () => offersForSlot(name); - const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const allOffers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + // A retired plugin's banner stays away while Bulwark shows its own (host-features.ts). + const externalMailWarnings = usePolicyStore((s) => externalWarningsOn(s.policy.organisation?.externalMail)); + const offers = useMemo( + () => allOffers.filter((offer) => !isSlotHandedOver(offer.pluginId, name, { externalMailWarnings })), + [allOffers, name, externalMailWarnings], + ); if (offers.length === 0) return null; diff --git a/lib/__tests__/plugin-external-mail-handover.test.ts b/lib/__tests__/plugin-external-mail-handover.test.ts new file mode 100644 index 000000000..37c50dea9 --- /dev/null +++ b/lib/__tests__/plugin-external-mail-handover.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import type { InstalledPlugin } from '../plugin-types'; + +// Copies of the external-mail-warning plugin older than the release that +// steps back on its own keep their send check. While the organisation's +// own warnings are on, the host skips that plugin's hooks by id, so the +// user isn't asked twice; other plugins keep theirs. + +const mocks = vi.hoisted(() => ({ + hooks: [] as string[], + invokeHook: null as null | ReturnType, +})); + +const CODE = 'export default { activate() {} }'; +const HASH = createHash('sha256').update(CODE, 'utf-8').digest('hex'); + +vi.mock('../plugin-storage', () => ({ + pluginStorage: { getCode: async () => CODE, saveCode: async () => {}, deleteCode: vi.fn() }, +})); +vi.mock('../plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: vi.fn() })); +vi.mock('../plugin-sandbox/host-bridge', () => ({ + createBackgroundInstance: () => { + // The old plugin's check: "Send to external recipients?" answered with Cancel. + const invokeHook = vi.fn(async () => false); + mocks.invokeHook = invokeHook; + return { + initPromise: Promise.resolve({ hooks: mocks.hooks, slots: [], shortcuts: [] }), + invokeHook, + destroy: vi.fn(), + }; + }, + snapshotRegionalSettings: vi.fn((locale: string) => ({ locale, dateLocale: locale, hour12: false, timeZone: 'UTC', firstDayOfWeek: 1 })), + forEachInstance: vi.fn(), + SandboxInstance: class {}, +})); +vi.mock('../plugin-sandbox/host-api', () => ({ cancelPluginDialogs: vi.fn() })); +vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: () => () => {} })); + +import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader'; +import { emailHooks } from '../plugin-hooks'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; + +function plugin(id: string): InstalledPlugin { + return { + id, name: id, version: '1.5.1', author: 'someone', description: '', type: 'hook', + permissions: ['email:send', 'email:read'], entrypoint: 'index.js', enabled: true, status: 'enabled', + settings: {}, managed: true, adminApproved: true, bundleHash: HASH, + }; +} + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +const OUTGOING = { to: ['dave@org-two.test'], cc: [], bcc: [], subject: 'Hi' }; +let loaded: string | null = null; + +beforeEach(() => { + setSandboxStoreAccessor({ setPluginStatus: () => {} }); + mocks.hooks = ['onBeforeEmailSend', 'onNewEmailReceived']; + setExternalMail({}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); +}); +afterEach(() => { + if (loaded) unloadSandboxedPlugin(loaded); + loaded = null; + vi.restoreAllMocks(); +}); + +describe('the retired external-mail-warning plugin', () => { + it('asks as before while the core warnings are off', async () => { + loaded = 'external-mail-warning'; + await loadSandboxedPlugin(plugin(loaded)); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed'); + expect(mocks.invokeHook).toHaveBeenCalledWith('onBeforeEmailSend', [OUTGOING]); + }); + + it('is skipped while the core warnings are on, and asked again once they are off', async () => { + loaded = 'external-mail-warning'; + await loadSandboxedPlugin(plugin(loaded)); + setExternalMail({ senderLabel: true }); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('allowed'); + await emailHooks.onNewEmailReceived.emit({ from: [{ email: 'dave@org-two.test' }] } as never); + expect(mocks.invokeHook).not.toHaveBeenCalled(); + + setExternalMail({}); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed'); + }); + + it('leaves the send checks of other plugins alone', async () => { + loaded = 'reply-all-guardrail'; + await loadSandboxedPlugin(plugin(loaded)); + setExternalMail({ recipientWarning: true }); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed'); + }); +}); diff --git a/lib/__tests__/plugin-host-features.test.ts b/lib/__tests__/plugin-host-features.test.ts new file mode 100644 index 000000000..ea665ba73 --- /dev/null +++ b/lib/__tests__/plugin-host-features.test.ts @@ -0,0 +1,143 @@ +/** + * api.host.features tells plugins which of Bulwark's own features replace + * them (externalMailWarnings: the organisation's External label or + * recipient warning). The host sends it with init and again on every + * change; the runtime keeps one object up to date. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { + EXTERNAL_MAIL_WARNING_PLUGIN_ID, + isHookHandedOver, + isSlotHandedOver, + snapshotHostFeatures, +} from '@/lib/plugin-sandbox/host-features'; + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +beforeEach(() => setExternalMail({})); + +describe('snapshotHostFeatures', () => { + it('says externalMailWarnings while either switch is on', () => { + expect(snapshotHostFeatures()).toEqual({ externalMailWarnings: false }); + setExternalMail({ senderLabel: true }); + expect(snapshotHostFeatures()).toEqual({ externalMailWarnings: true }); + setExternalMail({ recipientWarning: true }); + expect(snapshotHostFeatures()).toEqual({ externalMailWarnings: true }); + }); +}); + +describe('hand-over of the external-mail-warning plugin', () => { + it('skips its send check, toast and banner only while core warnings are on', () => { + const id = EXTERNAL_MAIL_WARNING_PLUGIN_ID; + expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(false); + expect(isSlotHandedOver(id, 'email-banner')).toBe(false); + setExternalMail({ recipientWarning: true }); + expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(true); + expect(isHookHandedOver(id, 'onNewEmailReceived')).toBe(true); + expect(isSlotHandedOver(id, 'email-banner')).toBe(true); + // Its other hooks, and every other plugin, keep running. + expect(isHookHandedOver(id, 'onIdentitiesLoaded')).toBe(false); + expect(isHookHandedOver('reply-all-guardrail', 'onBeforeEmailSend')).toBe(false); + expect(isSlotHandedOver('spam-score', 'email-banner')).toBe(false); + }); +}); + +// ─── Host bridge: init payload and pushes ──────────────────── + +describe('host bridge', () => { + afterEach(() => { document.body.innerHTML = ''; }); + + it('puts the features into the init payload and pushes changes to running frames', async () => { + const { createBackgroundInstance } = await import('@/lib/plugin-sandbox/host-bridge'); + setExternalMail({ senderLabel: true }); + 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({ externalMailWarnings: true }); + + // Before init has gone out a change lands in the payload ... + setExternalMail({}); + expect(pending?.hostFeatures).toEqual({ externalMailWarnings: false }); + + // ... 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: { externalMailWarnings: true } }]); + instance.destroy(); + }, 30_000); +}); + +// ─── Sandbox runtime: api.host.features ────────────────────── + +const HOST_ORIGIN = window.location.origin; + +function post(source: unknown, origin: string, data: unknown): void { + const ev = new MessageEvent('message', { data, origin }); + Object.defineProperty(ev, 'source', { value: source }); + window.dispatchEvent(ev); +} + +describe('api.host in the sandbox runtime', () => { + let hostWindow: { postMessage: ReturnType }; + const realParent = Object.getOwnPropertyDescriptor(window, 'parent'); + const g = globalThis as { + __pluginApi?: { host: { features: { externalMailWarnings: boolean }; onFeaturesChange(fn: (f: unknown) => void): () => void } }; + __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('has the value from init in activate and follows host-change', async () => { + const bundle = `module.exports = { activate: function (api) { + globalThis.__seenAtActivate = api.host.features.externalMailWarnings; + globalThis.__pluginApi = api; + } };`; + const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime'); + render(React.createElement(SandboxRuntime)); + post(hostWindow, HOST_ORIGIN, { + type: 'init', + payload: { + mode: 'background', pluginId: 'x', tier: 'privileged', locale: 'en', + manifest: { id: 'x', version: '1.0.0', permissions: [], settings: {} }, + code: bundle, + hostFeatures: { externalMailWarnings: true }, + }, + }); + await waitFor(() => expect(g.__pluginApi).toBeDefined()); + expect(g.__seenAtActivate).toBe(true); + + const api = g.__pluginApi!; + const kept = api.host.features; + const changes: unknown[] = []; + api.host.onFeaturesChange((f) => changes.push({ ...(f as object) })); + post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalMailWarnings: false } }); + expect(kept.externalMailWarnings).toBe(false); + expect(changes).toEqual([{ externalMailWarnings: false }]); + }, 30_000); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index db5a39018..1d0e7d67c 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -7,12 +7,13 @@ // `event.source === iframe.contentWindow`. The iframe's runtime pins the // parent on the first inbound message. -import type { InstalledPlugin, SlotName, PluginTier } from '../plugin-types'; +import type { HostFeaturesView, InstalledPlugin, SlotName, PluginTier } from '../plugin-types'; import { dispatchApiCall } from './host-api'; import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH, type RegionalSnapshot } from './protocol'; export type { RegionalSnapshot }; import { withBasePath } from '../browser-navigation'; import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; +import { snapshotHostFeatures, watchHostFeatures } from './host-features'; import { effectiveSettings } from '../plugin-settings'; import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import { resolveDateLocale } from '../utils'; @@ -252,6 +253,13 @@ function pushThemeToFrames(): void { } } +/** Every frame hears about a Bulwark feature that replaces plugins turning on or off. */ +function pushHostFeaturesToFrames(features: HostFeaturesView): void { + for (const instance of liveInstances) { + try { instance.setHostFeatures(features); } catch { /* one broken iframe must not stop the rest */ } + } +} + function ensureThemeWatcher(): void { if (themeWatcher || typeof MutationObserver === 'undefined' || typeof document === 'undefined') return; let scheduled = false; @@ -414,6 +422,7 @@ export class SandboxInstance { hostContainer.appendChild(this.iframe); liveInstances.add(this); if (this.mode === 'slot') ensureThemeWatcher(); + watchHostFeatures(pushHostFeaturesToFrames); // Send init after the iframe runtime signals it's ready. this.readyPromise.then(() => { @@ -637,6 +646,16 @@ export class SandboxInstance { this.send({ type: 'theme-change', theme }); } + /** Push what api.host.features says now; before init it goes into the init payload. */ + setHostFeatures(features: HostFeaturesView): void { + if (this.destroyed) return; + if (this.pendingInit) { + this.pendingInit.hostFeatures = features; + return; + } + this.send({ type: 'host-change', features }); + } + /** Push the plugin's current settings (see effectiveSettings). */ setSettings(settings: Record): void { if (this.destroyed) return; @@ -735,6 +754,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan code: opts.code, locale: opts.locale, regional: opts.regional ?? snapshotRegionalSettings(opts.locale), + hostFeatures: snapshotHostFeatures(), }; return new SandboxInstance( opts.plugin, @@ -764,6 +784,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance { locale: opts.locale, regional: opts.regional ?? snapshotRegionalSettings(opts.locale), theme: snapshotHostTheme(), + hostFeatures: snapshotHostFeatures(), }; return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown); } diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts new file mode 100644 index 000000000..f9721ba19 --- /dev/null +++ b/lib/plugin-sandbox/host-features.ts @@ -0,0 +1,65 @@ +// What the host tells plugins about its own features (`api.host.features`), +// so a plugin can step back where Bulwark now does the job itself. Sent with +// every frame's init and again whenever it changes (the admin's policy, an +// account switch to another scope). +// +// And the other half of that hand-over: copies of a plugin core replaced +// that are older than the release which knows the flag keep running their +// hooks. While core does the job, the host skips those hooks by plugin id. + +import { usePolicyStore } from '@/stores/policy-store'; +import type { HostFeaturesView } from '../plugin-types'; +import type { OrganisationPolicy } from '@/lib/admin/organisation-policy'; + +/** + * Either external-mail switch is on. The same rule as coreExternalWarningsOn + * (lib/external-mail.ts), kept apart so the sandbox doesn't pull in the + * account and contact stores. + */ +export function externalWarningsOn(externalMail: OrganisationPolicy['externalMail'] | undefined): boolean { + return !!externalMail && (externalMail.senderLabel || externalMail.recipientWarning); +} + +export function snapshotHostFeatures(): HostFeaturesView { + return { externalMailWarnings: externalWarningsOn(usePolicyStore.getState().getOrganisation().externalMail) }; +} + +/** The external-mail-warning plugin, retired in favour of organisation.externalMail. */ +export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning'; + +/** Its send check and its new-mail toast; its banner is the email-banner slot. */ +const EXTERNAL_MAIL_WARNING_HOOKS = new Set(['onBeforeEmailSend', 'onNewEmailReceived']); +const EXTERNAL_MAIL_WARNING_SLOTS = new Set(['email-banner']); + +/** True when core does this plugin hook's job right now, so the host skips it. */ +export function isHookHandedOver(pluginId: string, hookName: string): boolean { + return pluginId === EXTERNAL_MAIL_WARNING_PLUGIN_ID + && EXTERNAL_MAIL_WARNING_HOOKS.has(hookName) + && snapshotHostFeatures().externalMailWarnings; +} + +/** True when core does this plugin slot's job right now, so the host doesn't show it. */ +export function isSlotHandedOver(pluginId: string, slot: string, features: HostFeaturesView = snapshotHostFeatures()): boolean { + return pluginId === EXTERNAL_MAIL_WARNING_PLUGIN_ID + && EXTERNAL_MAIL_WARNING_SLOTS.has(slot) + && features.externalMailWarnings; +} + +// ─── Change watch ──────────────────────────────────────────── + +let watching = false; +let lastKey = ''; + +/** Calls `onChange` with the new features whenever they change; set up once. */ +export function watchHostFeatures(onChange: (features: HostFeaturesView) => void): void { + if (watching) return; + watching = true; + lastKey = JSON.stringify(snapshotHostFeatures()); + usePolicyStore.subscribe(() => { + const features = snapshotHostFeatures(); + const key = JSON.stringify(features); + if (key === lastKey) return; + lastKey = key; + onChange(features); + }); +} diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index a95b2bc47..bbf79ef99 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -31,6 +31,7 @@ import { clearPluginPages } from './pages'; import { clearSectionTitle } from './section-titles'; import { cancelPluginProxyRequests } from './proxy-client'; import { cancelConnectFor } from './connect-dialog'; +import { isHookHandedOver } from './host-features'; import type { SlotName } from '../plugin-types'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -280,7 +281,12 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise { } // The bus records a failure once; recording it here too made the // three-strikes breaker trip on the second error. - const proxy = (...args: unknown[]) => bg.invokeHook(hookName, args); + // While Bulwark does a retired plugin's job itself, that plugin's + // hook is skipped: copies older than the release that steps back on + // its own would ask twice (host-features.ts). Asked per call, so + // turning the core switch off gives the plugin its hook back. + const pluginId = plugin.id; + const proxy = (...args: unknown[]) => (isHookHandedOver(pluginId, hookName) ? undefined : bg.invokeHook(hookName, args)); hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown)); if (hookName === 'onEmailOpen') openHandler = proxy; } diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 0dda74f34..c947991a9 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -8,7 +8,7 @@ // the boundary must be structured-cloneable: no functions, no DOM nodes, no // class instances. -import type { SlotName, PluginTier } from '../plugin-types'; +import type { SlotName, PluginTier, HostFeaturesView } from '../plugin-types'; import type { ThemeSnapshot } from './host-theme'; /** Resolved presentation/regional settings mirrored into plugin runtimes. */ @@ -50,6 +50,8 @@ export interface BackgroundInit { locale: string; /** Resolved presentation/regional preferences (time format, date locale, time zone). */ regional?: RegionalSnapshot; + /** Bulwark features that replace plugins (api.host.features); host pushes updates via 'host-change'. */ + hostFeatures?: HostFeaturesView; } /** Initialisation payload for a slot-instance iframe (one per slot mount). */ @@ -97,6 +99,8 @@ export interface SlotInit { * injected CSS + a `.dark` class. Host pushes updates via 'theme-change'. */ theme: ThemeSnapshot; + /** Mirrors `BackgroundInit.hostFeatures`. */ + hostFeatures?: HostFeaturesView; } export type InitPayload = BackgroundInit | SlotInit; @@ -265,6 +269,9 @@ export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; } /** Host → sandbox: the user changed this plugin's settings. */ export interface SettingsChangeMsg { type: 'settings-change'; settings: Record; } +/** Host → sandbox: a Bulwark feature that replaces plugins was turned on or off. */ +export interface HostChangeMsg { type: 'host-change'; features: HostFeaturesView; } + export interface PropsUpdateMsg { type: 'props-update'; props: Record; } export interface SlotShouldShowMsg { @@ -309,6 +316,7 @@ export type HostToSandbox = | LocaleChangeMsg | ThemeChangeMsg | SettingsChangeMsg + | HostChangeMsg | PropsUpdateMsg | SlotShouldShowMsg | FloatingDropdownSelectMsg diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 3f8f46405..748de7916 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -33,7 +33,7 @@ import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui- import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n'; import { isFillableSlot, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import type { - AppInfoView, AppSettingsView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, + AppInfoView, AppSettingsView, HostFeaturesView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, EmailSummary, IdentityPatch, IdentityView, MailboxView, NotificationInput, SlotName, TaskInput, TaskView, TemplateInput, TemplateView, VacationInput, VacationView, } from '../plugin-types'; @@ -148,6 +148,18 @@ function applySettings(next: Record): void { try { listener(settingsState); } catch (err) { console.error('[plugin] settings listener failed', err); } } } +// What api.host.features says: one object for the iframe's lifetime, +// updated in place on 'host-change' like the settings. +const hostFeaturesState: HostFeaturesView = { externalMailWarnings: false }; +const hostFeatureListeners = new Set<(features: HostFeaturesView) => void>(); + +function applyHostFeatures(next: HostFeaturesView | undefined): void { + if (!next) return; + hostFeaturesState.externalMailWarnings = next.externalMailWarnings === true; + for (const listener of hostFeatureListeners) { + try { listener(hostFeaturesState); } catch (err) { console.error('[plugin] host feature listener failed', err); } + } +} const pendingCallbacks = new Map void; reject: (err: Error) => void }>(); const hookHandlers: Record unknown> = {}; @@ -375,6 +387,18 @@ function buildPluginApi(manifest: PluginManifest) { return () => { settingsListeners.delete(listener); }; }, }, + /** + * Bulwark features that replace what plugins did, so a plugin can step + * back while Bulwark does its job. Updated in place; no permission needed. + */ + host: { + features: hostFeaturesState, + /** Called with the new features whenever one turns on or off. Returns an unsubscribe function. */ + onFeaturesChange: (listener: (features: HostFeaturesView) => void) => { + hostFeatureListeners.add(listener); + return () => { hostFeatureListeners.delete(listener); }; + }, + }, /** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */ app: { getInfo: () => callApi('app.getInfo', []) as Promise, @@ -1143,9 +1167,11 @@ async function bootSlot(payload: SlotInit): Promise { const bump = () => setSettingsVersion((v) => v + 1); settingsListeners.add(bump); localeListeners.add(bump); + hostFeatureListeners.add(bump); return () => { settingsListeners.delete(bump); localeListeners.delete(bump); + hostFeatureListeners.delete(bump); }; }, []); React.useEffect(() => { @@ -1193,6 +1219,8 @@ async function handleInit(payload: InitPayload): Promise { // Make the active locale available to plugin code (api.i18n) right away - // not only after the first 'locale-change' push. applyLocale(payload.locale, payload.regional); + // Before the bundle runs, so activate() and shouldShow already see it. + applyHostFeatures(payload.hostFeatures); try { if (payload.mode === 'background') { await bootBackground(payload); @@ -1289,6 +1317,10 @@ function handleHostMessage(ev: MessageEvent): void { applySettings(msg.settings ?? {}); break; + case 'host-change': + applyHostFeatures(msg.features); + break; + case 'props-update': if (slotPropsUpdater) slotPropsUpdater(msg.props ?? {}); else if (mode === 'slot') propsBeforeBoot = msg.props ?? {}; diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 9fc9b4959..3660d9d5c 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -837,6 +837,20 @@ export interface AppInfoView { demoMode: boolean; } +/** + * Features of Bulwark itself that replace what a plugin did, as + * `api.host.features` tells the plugin (no permission needed). A plugin + * that does the same job steps back while one is true. Missing on hosts + * older than 1.19.0, so read it as `api.host?.features?.x === true`. + */ +export interface HostFeaturesView { + /** + * The admin turned on the "External" sender label or the external + * recipient warning (policy organisation.externalMail) for this user. + */ + externalMailWarnings: boolean; +} + /** One setting `api.app.searchSettings` found, in the user's language. */ export interface SettingsSearchResult { /** The settings page's id: account, appearance, composing, vacation, ... */