mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: tell plugins when Bulwark's own external mail warnings are on and skip the retired plugin's
This commit is contained in:
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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 ?? {};
|
||||
|
||||
@@ -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, ... */
|
||||
|
||||
Reference in New Issue
Block a user