feat: tell plugins when Bulwark's own external mail warnings are on and skip the retired plugin's

This commit is contained in:
Linus Rath
2026-10-28 02:10:10 +01:00
parent 2855ca0010
commit ab878ebf4f
10 changed files with 455 additions and 6 deletions
@@ -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<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
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(<PluginSlot name="email-banner" />);
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']);
});
});
+10 -2
View File
@@ -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;
@@ -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<typeof vi.fn>,
}));
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<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
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');
});
});
+143
View File
@@ -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<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
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<typeof vi.fn> };
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);
});
+22 -1
View File
@@ -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<string, unknown>): 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);
}
+65
View File
@@ -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);
});
}
+7 -1
View File
@@ -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<void> {
}
// 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;
}
+9 -1
View File
@@ -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<string, unknown>; }
/** 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<string, unknown>; }
export interface SlotShouldShowMsg {
@@ -309,6 +316,7 @@ export type HostToSandbox =
| LocaleChangeMsg
| ThemeChangeMsg
| SettingsChangeMsg
| HostChangeMsg
| PropsUpdateMsg
| SlotShouldShowMsg
| FloatingDropdownSelectMsg
+33 -1
View File
@@ -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<string, unknown>): 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<string, { resolve: (v: unknown) => void; reject: (err: Error) => void }>();
const hookHandlers: Record<string, (...args: unknown[]) => 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<AppInfoView>,
@@ -1143,9 +1167,11 @@ async function bootSlot(payload: SlotInit): Promise<void> {
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<void> {
// 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 ?? {};
+14
View File
@@ -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, ... */