Merge branch 'feat/enterprise-pack-e'

This commit is contained in:
Linus Rath
2026-11-01 10:42:46 +01:00
81 changed files with 3238 additions and 112 deletions
+20 -4
View File
@@ -5,7 +5,9 @@ import { isConfigReadOnly } from '@/lib/admin/paths';
import { getPluginRegistry } from '@/lib/admin/plugin-registry';
import { noThirdPartyTrafficState } from '@/lib/admin/third-party-mode';
import { listReachableHosts } from '@/lib/admin/reachable-hosts';
import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
import { pluginAllowedInMode, pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
import { adminEnteredOrigins } from '@/lib/plugin-secrets/proxy';
import type { CredentialSpec } from '@/lib/plugin-secrets/credentials';
import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
import { logger } from '@/lib/logger';
@@ -25,6 +27,17 @@ export async function GET(request: NextRequest) {
const state = noThirdPartyTrafficState();
const policy = configManager.getPolicy();
const registry = await getPluginRegistry().catch(() => ({ plugins: [] }));
const enabled = registry.plugins.filter((plugin) => plugin.enabled);
// Plugins that run in the mode with the servers the admin entered for
// them; their manifest-fixed servers (Google Safe Browsing) stay off.
const modePlugins = enabled.filter((plugin) => pluginDeclaresNetwork(plugin) && pluginAllowedInMode(plugin));
const pluginHosts = await Promise.all(modePlugins.map(async (plugin) => ({
id: plugin.id,
name: plugin.name,
origins: await adminEnteredOrigins(plugin.id).catch(() => [] as string[]),
fixed: [...new Set(Object.values((plugin as { credentials?: Record<string, CredentialSpec> }).credentials ?? {})
.flatMap((spec) => (spec.origin ? [new URL(spec.origin).host] : [])))],
})));
return NextResponse.json(
{
@@ -35,10 +48,13 @@ export async function GET(request: NextRequest) {
readOnly: isConfigReadOnly(),
envLocked: state.source === 'env',
builtIn: OUTGOING_HOSTS,
reachable: listReachableHosts({ get: (key) => configManager.get(key), policy, env: process.env }),
networkPlugins: registry.plugins
.filter((plugin) => plugin.enabled && pluginDeclaresNetwork(plugin))
reachable: listReachableHosts({ get: (key) => configManager.get(key), policy, env: process.env, plugins: pluginHosts }),
networkPlugins: enabled
.filter((plugin) => pluginDeclaresNetwork(plugin) && !pluginAllowedInMode(plugin))
.map((plugin) => ({ id: plugin.id, name: plugin.name })),
partlyOffPlugins: pluginHosts
.filter((plugin) => plugin.fixed.length > 0)
.map(({ id, name, fixed }) => ({ id, name, hosts: fixed })),
},
{ headers: { 'Cache-Control': 'no-store' } },
);
+16 -4
View File
@@ -3,8 +3,11 @@ import { configManager } from '@/lib/admin/config-manager';
import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials';
import { ProxyError, proxyRequest, type CaptureSpec } from '@/lib/plugin-secrets/proxy';
import { CREDENTIAL_ID_RE, type CredentialSpec } from '@/lib/plugin-secrets/credentials';
import { credentialAllowedInMode } from '@/lib/outgoing-hosts';
import { getPlugin } from '@/lib/admin/plugin-registry';
import { ProxyError, adminEnteredOrigin, proxyRequest, type CaptureSpec } from '@/lib/plugin-secrets/proxy';
import { getPluginConfig } from '@/lib/admin/plugin-config';
import { bootGateResponse } from '@/lib/shared-store/boot-gate';
const PLUGIN_ID_RE = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/;
@@ -46,8 +49,7 @@ export async function POST(request: NextRequest) {
if (!configManager.getPolicy().features.pluginsEnabled) {
return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 });
}
// Every proxied request goes to a host outside this deployment (#903).
if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('The plugin proxy');
const thirdPartyOff = await isNoThirdPartyTrafficOn();
const creds = await getStalwartCredentials(request);
if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 });
@@ -59,6 +61,16 @@ export async function POST(request: NextRequest) {
if (!PLUGIN_ID_RE.test(pluginId) || !CREDENTIAL_ID_RE.test(credentialId) || !url) {
return NextResponse.json({ error: 'plugin, credential and url are required', code: 'bad_request' }, { status: 400 });
}
// With third-party traffic off only a server the admin typed on the
// plugin's admin page stays reachable (a company link check); servers
// fixed in a manifest or brought as a field's default, and accounts users
// connect, are outside hosts (#903).
if (thirdPartyOff) {
const plugin = await getPlugin(pluginId);
const spec = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials?.[credentialId];
if (!credentialAllowedInMode(spec)) return thirdPartyRefusal('The plugin proxy');
if (!adminEnteredOrigin(spec, await getPluginConfig(pluginId), plugin?.configSchema)) return thirdPartyRefusal('The plugin proxy');
}
const capture = parseCapture(request.headers.get('x-bulwark-capture'));
if (capture === null) {
return NextResponse.json({ error: 'Invalid X-Bulwark-Capture header', code: 'bad_capture' }, { status: 400 });
+4 -3
View File
@@ -6,7 +6,7 @@ import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response';
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
import { pluginAllowedInMode } from '@/lib/outgoing-hosts';
/**
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
@@ -36,13 +36,14 @@ export async function GET(request: NextRequest) {
const devIds = new Set(devEntries.map(e => e.plugin.id));
const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id));
// Plugins that declare network access never reach a browser while the
// server must stay off outside hosts (#903); the rest run as usual.
// server must stay off outside hosts (#903); the rest run as usual, and
// so do those that reach only servers the admin entered for them.
const noThirdParty = await isNoThirdPartyTrafficOn();
const plugins = [
...devEntries.map(e => ({ ...e.plugin, dev: true })),
...installedEnabled.map(p => ({ ...p, dev: false })),
].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({
].filter(p => !noThirdParty || pluginAllowedInMode(p)).map(p => ({
id: p.id,
name: p.name,
version: p.version,
@@ -0,0 +1,174 @@
// Links outside message bodies opened without the plugins' link check
// (onBeforeExternalLink): the reader view, event descriptions, locations
// and meeting links, contact addresses, notification links, the
// List-Unsubscribe page, a middle-click, and other plugins'
// ui.openExternalUrl. A link check that warned in the reader was no check.
import { afterEach, describe, expect, it, vi } from 'vitest';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { clearAllHooks, pluginErrorTracker, uiHooks } from '@/lib/plugin-hooks';
import { gateLinkClick, linkText, openExternalLink, resolveExternalLink } from '@/lib/plugin-hook-result';
import { LinkifiedText } from '@/components/ui/linkified-text';
import { ReaderViewDialog } from '@/components/email/reader-view-dialog';
import type { ExternalLinkContext } from '@/lib/plugin-types';
vi.mock('@/hooks/use-focus-trap', () => ({ useFocusTrap: () => ({ current: null }) }));
afterEach(() => {
cleanup();
clearAllHooks();
pluginErrorTracker.resetAll();
vi.restoreAllMocks();
});
function watchLinks(answer: (ctx: ExternalLinkContext) => unknown = () => undefined) {
const seen: ExternalLinkContext[] = [];
uiHooks.onBeforeExternalLink.register('link-check', (ctx: ExternalLinkContext) => { seen.push(ctx); return answer(ctx); });
const open = vi.spyOn(window, 'open').mockReturnValue(null);
return { seen, open };
}
describe('gateLinkClick', () => {
it('sends a click on a web link through the check with the link text, then opens it', async () => {
const { seen, open } = watchLinks();
render(<div onClick={gateLinkClick}><a href="https://evil.example/x">https://bank.example</a></div>);
expect(fireEvent.click(screen.getByRole('link'))).toBe(false);
await waitFor(() => expect(open).toHaveBeenCalledWith('https://evil.example/x', '_blank', 'noopener,noreferrer'));
expect(seen).toEqual([{ href: 'https://evil.example/x', target: '_blank', text: 'https://bank.example' }]);
});
it('opens nothing when the check says no', async () => {
const { seen, open } = watchLinks(() => false);
render(<div onClick={gateLinkClick}><a href="https://evil.example/x">x</a></div>);
fireEvent.click(screen.getByRole('link'));
await waitFor(() => expect(seen).toHaveLength(1));
await new Promise((r) => setTimeout(r, 10));
expect(open).not.toHaveBeenCalled();
});
it('gates a middle-click, and leaves right-clicks, mailto: and tel: to the browser', async () => {
const { seen } = watchLinks(() => false);
render(
<div onClick={gateLinkClick} onAuxClick={gateLinkClick}>
<a href="https://evil.example/m">m</a>
<a href="mailto:a@example.org">mail</a>
<a href="tel:+491">call</a>
</div>,
);
const [web, mail, tel] = screen.getAllByRole('link');
expect(fireEvent(web, new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 }))).toBe(false);
expect(fireEvent(web, new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 2 }))).toBe(true);
expect(fireEvent.click(mail)).toBe(true);
expect(fireEvent.click(tel)).toBe(true);
await waitFor(() => expect(seen.map((c) => c.href)).toEqual(['https://evil.example/m']));
});
it('names an image link by its alt text', () => {
const a = document.createElement('a');
a.innerHTML = '<img alt=" Sign in to PayPal ">';
expect(linkText(a)).toBe('Sign in to PayPal');
a.textContent = 'x'.repeat(300);
expect(linkText(a)).toHaveLength(200);
});
});
describe('resolveExternalLink and openExternalLink', () => {
it("doesn't ask the plugin that opens the link itself", async () => {
const own = vi.fn(() => false);
const other = vi.fn(() => undefined);
uiHooks.onBeforeExternalLink.register('link-check', own);
uiHooks.onBeforeExternalLink.register('other', other);
expect(await resolveExternalLink({ href: 'https://a.example/' }, { skipPluginId: 'link-check' })).toBe('https://a.example/');
expect(own).not.toHaveBeenCalled();
expect(other).toHaveBeenCalledTimes(1);
expect(await resolveExternalLink({ href: 'https://a.example/' })).toBeNull();
});
it('opens only web addresses', async () => {
const { open } = watchLinks();
expect(await openExternalLink({ href: 'javascript:alert(1)' })).toBe(false);
expect(await openExternalLink({ href: 'https://a.example/' })).toBe(true);
expect(open).toHaveBeenCalledTimes(1);
});
});
describe('surfaces', () => {
it('event descriptions (LinkifiedText)', async () => {
const { seen, open } = watchLinks(() => false);
render(<LinkifiedText text="Join: https://meet.example/abc" />);
expect(fireEvent.click(screen.getByRole('link'))).toBe(false);
await waitFor(() => expect(seen.map((c) => c.href)).toEqual(['https://meet.example/abc']));
expect(open).not.toHaveBeenCalled();
});
it('the reader view', async () => {
const { seen } = watchLinks(() => false);
render(
<ReaderViewDialog isOpen onClose={() => {}} title="t" html={'<a href="https://evil.example/r" target="_blank">click</a>'} />,
);
const link = screen.getByTestId('reader-view-body').querySelector('a')!;
expect(fireEvent.click(link)).toBe(false);
await waitFor(() => expect(seen).toEqual([expect.objectContaining({ href: 'https://evil.example/r', text: 'click' })]));
});
});
describe("other plugins' ui.openExternalUrl", () => {
it('goes through the link check, except for the plugin asking', async () => {
const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
const { seen, open } = watchLinks(() => false);
const caller = (id: string) => ({
id, name: id, version: '1', author: '', description: '', type: 'hook', permissions: [], entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
}) as never;
await dispatchApiCall(caller('other'), 'ui.openExternalUrl', ['https://evil.example/p']);
expect(seen.map((c) => c.href)).toEqual(['https://evil.example/p']);
expect(open).not.toHaveBeenCalled();
// The link check opening the link after its own dialog.
await dispatchApiCall(caller('link-check'), 'ui.openExternalUrl', ['https://evil.example/p']);
expect(seen).toHaveLength(1);
expect(open).toHaveBeenCalledWith('https://evil.example/p', '_blank', 'noopener,noreferrer');
});
});
// What is checked is what opens: the host hands plugins the address as the
// browser parses it, and opens exactly that.
describe('one address end to end', () => {
it('checks and opens the address the browser really goes to', async () => {
const { seen, open } = watchLinks();
render(<div onClick={gateLinkClick}><a href={'http://evil.example\\@good.example/login'}>login</a></div>);
fireEvent.click(screen.getByRole('link'));
await waitFor(() => expect(open).toHaveBeenCalledWith('http://evil.example/@good.example/login', '_blank', 'noopener,noreferrer'));
expect(seen[0].href).toBe('http://evil.example/@good.example/login');
});
it('hands over international names in their punycode form', async () => {
const { seen } = watchLinks(() => false);
expect(await resolveExternalLink({ href: 'https://b\u00fccher.example/a' })).toBeNull();
expect(seen[0].href).toBe('https://xn--bcher-kva.example/a');
});
});
// Without a link check listening, clicks stay exactly as they were.
describe('no link check listening', () => {
it('leaves clicks and middle-clicks to the browser', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
render(<div onClick={gateLinkClick} onAuxClick={gateLinkClick}><a href="https://a.example/">a</a></div>);
expect(fireEvent.click(screen.getByRole('link'))).toBe(true);
expect(fireEvent(screen.getByRole('link'), new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 }))).toBe(true);
expect(open).not.toHaveBeenCalled();
});
it('opens at once, inside the click, from code that opens a link itself', () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
void openExternalLink({ href: 'https://a.example/' });
expect(open).toHaveBeenCalledWith('https://a.example/', '_blank', 'noopener,noreferrer');
});
it("opens a plugin's own link at once when only that plugin listens", async () => {
const { dispatchApiCall } = await import('@/lib/plugin-sandbox/host-api');
uiHooks.onBeforeExternalLink.register('link-check', () => false);
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const call = dispatchApiCall({ id: 'link-check', name: 'x', version: '1', author: '', description: '', type: 'hook', permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {} } as never, 'ui.openExternalUrl', ['https://a.example/']);
expect(open).toHaveBeenCalledTimes(1);
await call;
});
});
@@ -18,6 +18,8 @@ interface OutgoingHostsState {
builtIn: OutgoingHost[];
reachable: ReachableHost[];
networkPlugins: { id: string; name: string }[];
/** Running in the mode with the servers entered on their admin page; these manifest servers stay off. */
partlyOffPlugins?: { id: string; name: string; hosts: string[] }[];
}
const SIDE_LABEL: Record<ReachableHost['side'], string> = {
@@ -146,6 +148,11 @@ export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) {
Not running because they declare network access: {state.networkPlugins.map((p) => p.name).join(', ')}.
</ConfigNote>
)}
{on && (state.partlyOffPlugins?.length ?? 0) > 0 && (
<ConfigNote>
Running with only the servers entered on their admin page: {state.partlyOffPlugins!.map((p) => `${p.name} (${p.hosts.join(', ')} stays off)`).join('; ')}.
</ConfigNote>
)}
<div data-testid="third-party-reachable">
<h4 className="text-sm font-medium text-foreground">{on ? 'Still reachable' : 'Stays reachable with the switch on'}</h4>
@@ -34,6 +34,7 @@ import type { AnswerTarget } from "@/lib/calendar-delegate";
import { isEditableEventTarget } from "@/lib/keyboard";
import { isSingleKeyShortcutOff } from "@/lib/single-key-shortcuts";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { gateLinkClick } from "@/lib/plugin-hook-result";
interface EventDetailPopoverProps {
event: CalendarEvent;
@@ -524,6 +525,8 @@ export function EventDetailPopover({
href={locationHref ?? undefined}
target="_blank"
rel="noreferrer"
onClick={gateLinkClick}
onAuxClick={gateLinkClick}
className={cn("text-sm min-w-0 break-words", locationHref ? "text-primary hover:underline" : "text-foreground")}
dir="auto"
title={locationName}
@@ -554,6 +557,8 @@ export function EventDetailPopover({
href={virtualLocation}
target="_blank"
rel="noreferrer"
onClick={gateLinkClick}
onAuxClick={gateLinkClick}
className="text-sm text-primary hover:underline truncate"
title={virtualLocation}
>
+3 -2
View File
@@ -53,6 +53,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useCvdColor } from "@/hooks/use-cvd-color";
import { gateLinkClick } from "@/lib/plugin-hook-result";
export interface PendingEventPreview {
start: Date;
@@ -1128,7 +1129,7 @@ export function EventModal({
<div className="flex items-start gap-2.5">
<MapPin className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{/* A URL opens as a link, a postal address in the maps, as on the card. */}
<a href={locationHref ?? undefined} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline min-w-0 break-words" title={locationName}>
<a href={locationHref ?? undefined} target="_blank" rel="noreferrer" onClick={gateLinkClick} onAuxClick={gateLinkClick} className="text-sm text-primary hover:underline min-w-0 break-words" title={locationName}>
{/^https?:\/\//i.test(locationName)
? (() => { try { return new URL(locationName).hostname; } catch { return locationName; } })()
: locationName}
@@ -1140,7 +1141,7 @@ export function EventModal({
{virtualLoc && (
<div className="flex items-start gap-2.5">
<Video className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<a href={virtualLoc} target="_blank" rel="noreferrer" className="text-sm text-primary hover:underline truncate" title={virtualLoc}>
<a href={virtualLoc} target="_blank" rel="noreferrer" onClick={gateLinkClick} onAuxClick={gateLinkClick} className="text-sm text-primary hover:underline truncate" title={virtualLoc}>
{(() => { try { return new URL(virtualLoc).hostname; } catch { return virtualLoc; } })()}
</a>
</div>
+6 -5
View File
@@ -19,6 +19,7 @@ import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { contactHooks } from "@/lib/plugin-hooks";
import { contactToView } from "@/lib/plugin-projection";
import { gateLinkClick } from "@/lib/plugin-hook-result";
interface ContactDetailProps {
contact: ContactCard | null;
@@ -283,7 +284,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
>
<div className="flex items-center gap-2 group">
{typeof svc.uri === 'string' && svc.uri.startsWith("http") ? (
<a href={svc.uri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
<a href={svc.uri} target="_blank" rel="noopener noreferrer" onClick={gateLinkClick} onAuxClick={gateLinkClick} className="text-sm text-primary hover:underline break-all">
{svc.user || svc.uri}
</a>
) : (
@@ -419,7 +420,7 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
<div className="flex items-start gap-2 text-sm break-all">
<KeyRound className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
{typeof key.uri === 'string' && key.uri.startsWith("http") ? (
<a href={key.uri} target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
<a href={key.uri} target="_blank" rel="noopener noreferrer" onClick={gateLinkClick} onAuxClick={gateLinkClick} className="text-primary hover:underline">
{key.uri}
</a>
) : (
@@ -437,21 +438,21 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
<div className="space-y-3">
{contact.calendarUri && (
<FieldRow icon={Calendar} label={t("detail.calendar_uri")}>
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
<a href={contact.calendarUri} target="_blank" rel="noopener noreferrer" onClick={gateLinkClick} onAuxClick={gateLinkClick} className="text-sm text-primary hover:underline break-all">
{contact.calendarUri}
</a>
</FieldRow>
)}
{contact.schedulingUri && (
<FieldRow icon={Calendar} label={t("detail.scheduling_uri")}>
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
<a href={contact.schedulingUri} target="_blank" rel="noopener noreferrer" onClick={gateLinkClick} onAuxClick={gateLinkClick} className="text-sm text-primary hover:underline break-all">
{contact.schedulingUri}
</a>
</FieldRow>
)}
{contact.freeBusyUri && (
<FieldRow icon={Calendar} label={t("detail.freebusy_uri")}>
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" className="text-sm text-primary hover:underline break-all">
<a href={contact.freeBusyUri} target="_blank" rel="noopener noreferrer" onClick={gateLinkClick} onAuxClick={gateLinkClick} className="text-sm text-primary hover:underline break-all">
{contact.freeBusyUri}
</a>
</FieldRow>
@@ -0,0 +1,429 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { usePolicyStore } from '@/stores/policy-store';
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import type { InstalledPlugin, OutgoingEmail } from '@/lib/plugin-types';
import { setSendGuardSource } from '@/lib/plugin-send-guards';
// ─── Heavy component mocks (mirrors composer-reply-signature.test.tsx) ─
// A stand-in for the TipTap editor: it hands the composer an editor object
// whose content is whatever the composer last rendered into it, the way the
// real editor reports itself ready from an effect after mounting.
const editorState = vi.hoisted(() => ({ html: '' }));
// A plugin that rewrites signatures (onSignatureRender), off unless a test turns it on.
const signaturePlugin = vi.hoisted(() => ({ on: false }));
// Send plugins: a footer added by onTransformOutgoingEmail, and a crypto
// plugin taking the send over (onComposeSend).
// What a send check plugin (onBeforeEmailSend) was handed.
const sendChecks = vi.hoisted(() => ({ seen: [] as unknown[], size: 1, answers: [] as string[], uploads: [] as unknown[] }));
const sendPlugins = vi.hoisted(() => ({
footer: false,
addBcc: '' as string,
takeOver: null as null | ((request: { htmlBody: string; textBody: string }) => void),
}));
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ content, onChange, onEditorReady }: {
content: string;
onChange: (html: string) => void;
onEditorReady?: (editor: unknown) => void;
}) => {
editorState.html = content;
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
React.useEffect(() => {
onEditorReady?.({
isDestroyed: false,
commands: {
focus: () => {},
setContent: (html: string) => {
editorState.html = html;
onChangeRef.current(html);
},
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-html': content });
},
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [
{ id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: '<b>Alice</b>', textSignature: 'Alice' },
{ id: 'id-team', email: 'team@example.com', name: 'Team', htmlSignature: '', textSignature: '' },
] as Array<Record<string, unknown>>,
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook, notifyIdentitySelected: () => {} };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: false,
signatureDetails: { title: '', department: '', phone: '', mobile: '' },
sendDelaySeconds: 0,
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
externalContentPolicy: 'block',
isSenderTrusted: () => false,
trustedSendersAddressBook: null,
updateSetting: () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
const toasts = vi.hoisted(() => ({ error: [] as string[] }));
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: (message: string) => { toasts.error.push(message); }, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: {
get size() { return signaturePlugin.on ? 1 : 0; },
transform: async (value: { html: string; text: string }) => ({ html: `${value.html}<p>PLUGIN</p>`, text: `${value.text} PLUGIN` }),
},
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: {
get size() { return sendChecks.size; },
intercept: async () => true,
interceptOutcome: async (email: unknown) => { sendChecks.seen.push(email); return sendChecks.answers.shift() ?? 'allowed'; },
},
onBeforeAttachmentUpload: {
size: 1,
intercept: async (info: unknown) => { sendChecks.uploads.push(info); return false; },
},
onComposeSend: {
takeOver: async (request: { htmlBody: string; textBody: string }) => {
if (!sendPlugins.takeOver) return 'default';
sendPlugins.takeOver(request);
return 'handled';
},
},
onTransformOutgoingEmail: {
transform: async (email: { htmlBody: string; textBody: string; bcc: string[] }) => {
const withBcc = sendPlugins.addBcc ? { ...email, bcc: [...email.bcc, sendPlugins.addBcc] } : email;
return sendPlugins.footer
? { ...withBcc, htmlBody: `${withBcc.htmlBody}<p>Plugin footer</p>`, textBody: `${withBcc.textBody}\n\nPlugin footer` }
: withBcc;
},
},
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeSignatureHtmlForDisplay: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
sanitizePluginBodyHtml: (v: string) => v,
escapeHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: (html: string) => `<div data-quoted-html="">${html}</div>`,
serializeEditorContent: () => editorState.html,
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
/**
* A send check plugin gets what it needs to judge a message without the
* quoted original and without the policy: the user's own text, the
* recipients outside the organisation, where the send comes from.
*/
const DRAFT = {
to: 'bob@example.com, Carol <carol@partner.test>',
cc: '',
bcc: 'mallory@evil.test',
subject: 'Re: Invoice',
body: '<p>Pay to DE89 3704 0044 0532 0130 00 please.</p><blockquote>Quoted: DE44 5001 0517 5407 3249 31</blockquote>',
showCc: false,
showBcc: true,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: 'draft-1',
};
function setPolicy(domains: string[]) {
const policy: SettingsPolicy = {
...DEFAULT_POLICY,
organisation: {
...DEFAULT_ORGANISATION_POLICY,
externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, recipientWarning: false, domains },
},
};
usePolicyStore.setState({ policy, rawPolicy: policy, subject: null });
}
describe('composer: what a send check plugin gets', () => {
beforeEach(() => {
resetSendPipeline();
sendChecks.seen = [];
setPolicy([]);
});
afterEach(() => {
resetSendPipeline();
vi.clearAllMocks();
});
it('the authored text without the quote, the external recipients and the surface', async () => {
const onSend = vi.fn();
render(<EmailComposer mode="reply" initialData={{ ...DRAFT, mode: 'reply' as const }} onSend={onSend} />);
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(sendChecks.seen).toHaveLength(1);
const email = sendChecks.seen[0] as OutgoingEmail;
expect(email.surface).toBe('composer');
expect(email.mode).toBe('reply');
expect(email.authoredText).toContain('DE89 3704 0044 0532 0130 00');
expect(email.authoredText).not.toContain('DE44');
expect(email.htmlBody).toContain('DE44');
// Internal: the identity's own domain. The admin's warning is off.
expect(email.externalRecipients).toEqual(['carol@partner.test', 'mallory@evil.test']);
});
it("counts the admin's organisation domains as internal", async () => {
setPolicy(['partner.test']);
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT} onSend={onSend} />);
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect((sendChecks.seen[0] as OutgoingEmail).externalRecipients).toEqual(['mallory@evil.test']);
});
});
// A send check the admin requires that isn't running holds the send.
describe('composer: a required send check that is down', () => {
afterEach(() => {
setSendGuardSource(null);
toasts.error = [];
vi.clearAllMocks();
});
it('sends nothing and says why', async () => {
const down = {
id: 'send-checks', name: 'Send checks', version: '1.0.0', permissions: ['email:send'],
status: 'error', error: 'auto-disabled', enabled: false, forceEnabled: true, managed: true,
} as unknown as InstalledPlugin;
setSendGuardSource({ snapshot: () => ({ plugins: [down], forced: () => true }), subscribe: () => () => {} });
sendChecks.seen = [];
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT} onSend={onSend} />);
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
// Turned off after errors: waiting doesn't help, reloading may.
await waitFor(() => expect(toasts.error).toContain('stopped_working'));
expect(onSend).not.toHaveBeenCalled();
expect(sendChecks.seen).toEqual([]);
});
it('gives a check that was still starting at the click the whole message once it runs', async () => {
let status = 'enabled';
const listeners = new Set<() => void>();
const guard = () => ({
id: 'send-checks', name: 'Send checks', version: '1.0.0', permissions: ['email:send'],
status, enabled: true, forceEnabled: true, managed: true,
}) as unknown as InstalledPlugin;
setSendGuardSource({ snapshot: () => ({ plugins: [guard()], forced: () => true }), subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); } });
setPolicy([]);
// Nothing listens at the click; the check registers when it has started.
sendChecks.size = 0;
sendChecks.seen = [];
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT} onSend={onSend} />);
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await new Promise((r) => setTimeout(r, 50));
expect(onSend).not.toHaveBeenCalled();
sendChecks.size = 1;
status = 'running';
listeners.forEach((l) => l());
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect((sendChecks.seen[0] as OutgoingEmail).externalRecipients).toEqual(['carol@partner.test', 'mallory@evil.test']);
});
});
// A plugin's onTransformOutgoingEmail ran after the check: an automatic Bcc
// to an outside address went out after an "outside only" rule had passed.
describe('composer: recipients a plugin adds after the check', () => {
beforeEach(() => {
sendChecks.size = 1;
sendChecks.seen = [];
sendChecks.answers = [];
setPolicy([]);
});
afterEach(() => {
sendPlugins.addBcc = '';
vi.clearAllMocks();
});
it('are checked again, and a no then stops the send', async () => {
sendPlugins.addBcc = 'archive@outside.test';
sendChecks.answers = ['allowed', 'vetoed'];
const onSend = vi.fn();
render(<EmailComposer initialData={{ ...DRAFT, to: 'bob@example.com', bcc: '' }} onSend={onSend} />);
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(sendChecks.seen).toHaveLength(2));
expect((sendChecks.seen[0] as OutgoingEmail).externalRecipients).toEqual([]);
expect((sendChecks.seen[1] as OutgoingEmail).externalRecipients).toEqual(['archive@outside.test']);
await new Promise((r) => setTimeout(r, 50));
expect(onSend).not.toHaveBeenCalled();
});
it('are not asked about twice when nobody changed the recipients', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={{ ...DRAFT, to: 'bob@example.com', bcc: '' }} onSend={onSend} />);
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(sendChecks.seen).toHaveLength(1);
});
});
// A renamed program is refused before it is uploaded: the plugin learns
// what the file's first bytes say, not only its name.
describe('composer: attaching a file from this device', () => {
it('tells onBeforeAttachmentUpload what the file really is', async () => {
const { useAuthStore } = await import('@/stores/auth-store');
// Any client method answers "nothing": only the hook matters here.
useAuthStore.setState({ client: new Proxy({}, { get: () => () => undefined }) as never });
try {
sendChecks.uploads = [];
render(<EmailComposer initialData={DRAFT} onSend={vi.fn()} />);
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const program = new File([new Uint8Array([0x4d, 0x5a, 0x90, 0, 3, 0])], 'invoice.pdf', { type: 'application/pdf' });
fireEvent.change(input, { target: { files: [program] } });
await waitFor(() => expect(sendChecks.uploads).toHaveLength(1));
expect(sendChecks.uploads[0]).toEqual({ name: 'invoice.pdf', type: 'application/pdf', size: 6, sniffedType: 'application/x-msdownload' });
} finally {
useAuthStore.setState({ client: null } as never);
}
});
});
@@ -26,8 +26,14 @@ const { clearAllHooks, uiHooks } = await import('@/lib/plugin-hooks');
const BODY = '<p>hi</p><a id="web" href="https://site.example/a">web</a><a id="app" href="/settings/accounts">app</a>';
function renderBody() {
const email = {
function renderBody(plain?: string) {
const email = plain ? {
id: 'e1', threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true },
from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }],
subject: 's', receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hi', hasAttachment: false,
htmlBody: [{ partId: '1', type: 'text/plain' }], textBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
bodyValues: { '1': { value: plain } },
} : {
id: 'e1', threadId: 't1', mailboxIds: { a: true }, keywords: { $seen: true },
from: [{ name: 'Sender', email: 'sender@t.example' }], to: [{ email: 'me@example.org' }],
subject: 's', receivedAt: '2026-09-26T10:00:00Z', size: 1, preview: 'hi', hasAttachment: false,
@@ -41,6 +47,7 @@ function renderBody() {
onBack={() => {}}
/>,
);
if (plain) return container as unknown as Document;
const iframe = container.querySelector('iframe')!;
// jsdom does not load srcdoc: put the sanitised body in by hand.
const srcdoc = iframe.getAttribute('srcdoc') ?? '';
@@ -75,6 +82,19 @@ describe('conversation view link gate', () => {
expect(hook).toHaveBeenCalledTimes(1);
}, 30_000);
it('sends a middle-click through the check too, with the link text', async () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const hook = vi.fn(() => false);
uiHooks.onBeforeExternalLink.register('p1', hook);
const doc = renderBody();
const ev = new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 });
doc.getElementById('web')!.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(true);
await waitFor(() => expect(hook).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://site.example/a', text: 'web' })));
expect(open).not.toHaveBeenCalled();
}, 30_000);
it('opens nothing when a plugin stops the link', async () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const hook = vi.fn(() => false);
@@ -87,3 +107,25 @@ describe('conversation view link gate', () => {
expect(open).not.toHaveBeenCalled();
}, 30_000);
});
describe('conversation view link gate, more paths', () => {
it('sends a link in a plain-text message through the check', async () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const hook = vi.fn(() => false);
uiHooks.onBeforeExternalLink.register('p1', hook);
const root = renderBody('Pay at https://pay.example/x now');
const link = root.querySelector('[data-plugin-source="email-body"] a')!;
expect(click(link)).toBe(true);
await waitFor(() => expect(hook).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://pay.example/x', emailId: 'e1' })));
expect(open).not.toHaveBeenCalled();
}, 30_000);
it('leaves a middle-click to the browser while no link check listens', async () => {
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const doc = renderBody();
const ev = new MouseEvent('auxclick', { bubbles: true, cancelable: true, button: 1 });
doc.getElementById('web')!.dispatchEvent(ev);
expect(ev.defaultPrevented).toBe(false);
expect(open).not.toHaveBeenCalled();
}, 30_000);
});
@@ -50,3 +50,33 @@ describe('UnsubscribeBanner mailto', () => {
expect(container.textContent).toBe('');
});
});
// The https unsubscribe page is a link from the sender like any other in
// the mail: it opened without the plugins' link check.
describe('UnsubscribeBanner https', () => {
it('opens the page through the link check and stays put when the check says no', async () => {
const { uiHooks, clearAllHooks } = await import('@/lib/plugin-hooks');
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const check = vi.fn(() => false);
uiHooks.onBeforeExternalLink.register('link-check', check);
try {
render(
<UnsubscribeBanner
listUnsubscribe={{ http: 'https://evil.example/unsub?u=1', preferred: 'http' }}
senderEmail="news@evil.example"
onSendMailtoUnsubscribe={vi.fn()}
onDismiss={() => {}}
/>,
);
fireEvent.click(screen.getByText('email_viewer.unsubscribe_banner.button'));
fireEvent.click(screen.getByText('email_viewer.unsubscribe_banner.confirm_button'));
await waitFor(() => expect(check).toHaveBeenCalledWith(expect.objectContaining({ href: 'https://evil.example/unsub?u=1' })));
await new Promise((r) => setTimeout(r, 10));
expect(open).not.toHaveBeenCalled();
expect(screen.queryByText('email_viewer.unsubscribe_banner.success')).toBeNull();
} finally {
clearAllHooks();
open.mockRestore();
}
});
});
@@ -0,0 +1,84 @@
// The reader's link check: links in a plain-text mail render in the page,
// not in the body iframe, and opened without the check; a middle-click in
// the iframe opened the link in a new tab without it too. With no link
// check listening, a click stays the browser's.
import { render, act, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { clearAllHooks, pluginErrorTracker, uiHooks } from '@/lib/plugin-hooks';
import type { ExternalLinkContext } from '@/lib/plugin-types';
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const message = (type: 'text/plain' | 'text/html', value: string) => ({
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Sender', email: 'sender@other.test' }], to: [{ email: 'me@example.org' }],
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
textBody: [{ partId: '1', type }], htmlBody: [{ partId: '1', type }], attachments: [],
bodyValues: { '1': { value } },
}) as unknown as Email;
async function open(email: Email) {
const view = render(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
return view;
}
function watch(answer: unknown) {
const seen: ExternalLinkContext[] = [];
uiHooks.onBeforeExternalLink.register('link-check', (ctx: ExternalLinkContext) => { seen.push(ctx); return answer; });
return seen;
}
let open$: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
clearAllHooks();
pluginErrorTracker.resetAll();
open$ = vi.spyOn(window, 'open').mockReturnValue(null);
});
afterEach(() => vi.restoreAllMocks());
describe('plain-text mail', () => {
it('sends a link click through the link check, with the message and the address the browser opens', async () => {
const seen = watch(false);
const { container } = await open(message('text/plain', 'Log in at http://evil.example\\@good.example/login today'));
const link = container.querySelector('[data-plugin-source="email-body"] a') as HTMLAnchorElement;
expect(link).not.toBeNull();
expect(fireEvent.click(link)).toBe(false);
await waitFor(() => expect(seen).toHaveLength(1));
expect(seen[0]).toMatchObject({ href: 'http://evil.example/@good.example/login', emailId: 'm1' });
expect(open$).not.toHaveBeenCalled();
});
it('leaves the click to the browser while no link check listens', async () => {
const { container } = await open(message('text/plain', 'See https://site.example/a'));
const link = container.querySelector('[data-plugin-source="email-body"] a') as HTMLAnchorElement;
expect(fireEvent.click(link)).toBe(true);
expect(open$).not.toHaveBeenCalled();
});
});
// The body iframe's middle-click (jsdom doesn't load srcdoc, so the viewer
// never wires it here) is covered by the conversation view's test of the
// same handler and by integration/tests/22-ent-plugins.spec.ts.
+58 -12
View File
@@ -113,6 +113,8 @@ import { wantsSignatureSeparator } from "@/lib/signature-delimiter";
import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline";
import { classifyOutgoing, isLabelRequiredError, labelMissing, labelsPolicyFor, resolveLabel, type OriginalMessage } from "@/lib/outgoing/classification";
import { beginLabelTakeover, endLabelTakeover } from "@/lib/outgoing/classification-raw";
import { recipientsChanged, runPluginSendCheck, sendGuardMessageKey } from "@/lib/outgoing/plugin-send-check";
import { externalRecipientsFor, inspectAttachments, sniffFile } from "@/lib/outgoing/send-check-payload";
import { whenPolicySettled } from "@/stores/policy-store";
import { ClassificationLabelPicker, type ClassificationPickerHandle } from "./classification-label-picker";
import { useComposerClassification } from "./use-composer-classification";
@@ -487,6 +489,7 @@ export function EmailComposer({
const tQuote = useTranslations('quote_header');
const tViewer = useTranslations('email_viewer');
const tClassification = useTranslations('classification');
const tSendGuards = useTranslations('send_guards');
const timeFormat = useSettingsStore((state) => state.timeFormat);
const plainTextModeDefault = useSettingsStore((state) => state.plainTextMode);
// Per-message format (#1022). Seeded from the "plain text only" setting -
@@ -2220,10 +2223,12 @@ export function EmailComposer({
// Let plugins veto each upload before it's queued.
const allowedFiles: File[] = [];
for (const file of files) {
const sniffed = emailHooks.onBeforeAttachmentUpload.size > 0 ? await sniffFile(file) : {};
const ok = await emailHooks.onBeforeAttachmentUpload.intercept({
name: file.name,
type: file.type || 'application/octet-stream',
size: file.size,
...sniffed,
});
if (ok) allowedFiles.push(file);
}
@@ -3608,23 +3613,50 @@ export function EmailComposer({
try {
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
// Let plugins veto the send (external-mail warning, mistyped-domain
// guards, etc.). Returning false from any handler aborts before either
// the S/MIME or standard JMAP path runs.
const sendablePreview: OutgoingEmail = {
// guards, send checks). Returning false from any handler aborts before
// either the S/MIME or standard JMAP path runs. What a send check needs
// beyond the bodies (lib/outgoing/send-check-payload.ts) is worked out
// after waiting for required checks that are still starting, and only
// while a plugin listens.
const sendClient = composerClientRef.current ?? client;
const readBlob = (blobId: string, name: string, type: string, bytes: number) => (sendClient
? sendClient.fetchBlobArrayBuffer(blobId, name, type, undefined, bytes)
: Promise.reject(new Error('No client')));
// What is checked is what goes: a file added while the check waits or
// asks isn't sent with this message.
const sentAttachments = attachmentsRef.current.filter(att => att.blobId && !att.uploading && !att.error);
const recipientsAtCheck = {
to: toAddresses.map(r => formatRecipient(r.name, r.email)),
cc: ccAddresses.map(r => formatRecipient(r.name, r.email)),
bcc: bccAddresses.map(r => formatRecipient(r.name, r.email)),
};
const buildCheck = async (recipients: { to: string[]; cc: string[]; bcc: string[] }): Promise<OutgoingEmail> => ({
...recipients,
subject,
htmlBody: finalHtmlBody || '',
textBody: finalBody,
identityId: currentIdentity?.id || '',
fromEmail,
attachments: attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size })),
attachments: await inspectAttachments([
...sentAttachments.map(a => ({
name: a.name, type: a.type, size: a.size, blobId: a.blobId, file: a.file,
load: (bytes: number) => readBlob(a.blobId!, a.name, a.type, bytes),
})),
...inlineAttachments.map(a => ({
name: a.name, type: a.type, size: a.size, blobId: a.blobId, inline: true,
load: (bytes: number) => readBlob(a.blobId, a.name, a.type, bytes),
})),
]),
inReplyTo: threadingHeaders?.inReplyTo?.[0],
};
const sendCheck = await emailHooks.onBeforeEmailSend.interceptOutcome(sendablePreview);
surface: 'composer',
mode,
authoredText: extractUserAuthoredText(
plainTextMode ? plainTextBodyWithoutSignature(body, signatureIdentity) : stripEmbeddedSignatureHtml(body),
{ plainTextMode, forwardedSeparator: tQuote('forwarded_separator') },
),
externalRecipients: await externalRecipientsFor({ ...outgoingContext, ...recipients }),
});
const sendCheck = await runPluginSendCheck(() => buildCheck(recipientsAtCheck));
if (sendCheck !== 'allowed') {
// A plugin vetoed the send (it is expected to show its own UI).
// Leave a trace so a silent no-op send is diagnosable (#592).
@@ -3632,6 +3664,8 @@ export function EmailComposer({
// A guard that crashed or never answered showed nothing; say why
// the message is still here.
if (sendCheck === 'failed') toast.error(t('send_guard_failed'));
const guardKey = sendGuardMessageKey(sendCheck);
if (guardKey) toast.error(tSendGuards(guardKey));
return;
}
@@ -3667,8 +3701,7 @@ export function EmailComposer({
references: threadingHeaders?.references,
delayedUntil: effectiveDelayedUntil,
attachments: [
...attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
...sentAttachments
.map(a => ({ name: a.name, type: a.type || 'application/octet-stream', size: a.size, blobId: a.blobId, cid: a.cid })),
...inlineAttachments.map(a => ({ name: a.name, type: a.type, size: a.size, blobId: a.blobId, cid: a.cid })),
],
@@ -3700,8 +3733,7 @@ export function EmailComposer({
} else {
// Standard JMAP send path
// Collect uploaded attachment blobIds for the send request
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = attachmentsRef.current
.filter(att => att.blobId && !att.uploading && !att.error)
const uploadedAttachments: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }> = sentAttachments
.map(att => ({
blobId: att.blobId!,
name: att.name,
@@ -3729,6 +3761,20 @@ export function EmailComposer({
inReplyTo: threadingHeaders?.inReplyTo?.[0],
};
const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput);
// A plugin that adds or changes recipients after the send checks
// (an automatic Bcc) could take the message outside after an
// "only outside the organisation" rule passed: check it again for
// the recipients it really goes to.
if (recipientsChanged(recipientsAtCheck, outgoing)) {
const recheck = await runPluginSendCheck(() => buildCheck({ to: outgoing.to ?? [], cc: outgoing.cc ?? [], bcc: outgoing.bcc ?? [] }));
if (recheck !== 'allowed') {
debug.log('email', `Send stopped on the second check after a plugin changed the recipients (${recheck})`);
if (recheck === 'failed') toast.error(t('send_guard_failed'));
const recheckKey = sendGuardMessageKey(recheck);
if (recheckKey) toast.error(tSendGuards(recheckKey));
return;
}
}
// Below whatever the plugins added (a footer), the `last` finalisers.
const sealedOutgoing = lastStage(outgoing.htmlBody, outgoing.textBody);
// The label after the plugins had their say, so none can drop it.
+11 -2
View File
@@ -169,7 +169,7 @@ import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
import { ResizeHandle } from "@/components/layout/resize-handle";
import { emailHooks, renderHooks } from "@/lib/plugin-hooks";
import { emitThreadExpand } from "@/lib/plugin-thread-expand";
import { resolveExternalLink } from "@/lib/plugin-hook-result";
import { gateLinkClick, linkCheckActive, linkText, resolveExternalLink } from "@/lib/plugin-hook-result";
import type { AttachmentInfo, AttachmentPreview } from "@/lib/plugin-types";
import { useAttachmentDrag, isDragOutSupported, type AttachmentDragSource } from "@/hooks/use-attachment-drag";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
@@ -3521,7 +3521,10 @@ export function EmailViewer({
// Plugin intercept: let plugins cancel or rewrite external links inside
// the email body before navigation happens. Bound on the iframe doc so
// it survives later DOM mutations.
const onLinkClick = async (ev: Event) => {
const onLinkClick = async (ev: MouseEvent) => {
// A middle-click opens a link too, through the link check while one
// listens; other buttons are left alone.
if (ev.type === 'auxclick' && (ev.button !== 1 || !linkCheckActive())) return;
const targetEl = (ev.target as Element | null)?.closest?.('a[href], area[href]') as HTMLAnchorElement | HTMLAreaElement | null;
if (!targetEl) return;
const href = targetEl.getAttribute('href') || '';
@@ -3567,6 +3570,7 @@ export function EmailViewer({
href,
target: targetEl.getAttribute('target') ?? undefined,
emailId: email?.id,
text: linkText(targetEl),
};
// A plugin returns false to stop the link or { href } to send it
// somewhere else (a web address only).
@@ -3575,6 +3579,7 @@ export function EmailViewer({
window.open(target, '_blank', 'noopener,noreferrer');
};
doc.addEventListener('click', onLinkClick, true);
doc.addEventListener('auxclick', onLinkClick, true);
// A press in the body closes a mention's card. The page never sees
// it: the focus is already inside the iframe, so there is no blur.
doc.addEventListener('mousedown', () => setBodyMention(null), true);
@@ -5798,6 +5803,10 @@ export function EmailViewer({
data-plugin-source="email-body"
data-email-id={email?.id}
onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email?.id)}
// A plain-text body renders in the page, not the iframe: its
// links go through the link check here.
onClick={(e) => gateLinkClick(e, { emailId: email?.id })}
onAuxClick={(e) => gateLinkClick(e, { emailId: email?.id })}
dangerouslySetInnerHTML={plainBodyHtml}
style={{
...(plainTextFont === 'mono' && { fontFamily: 'ui-monospace, "SF Mono", Consolas, monospace' }),
+4
View File
@@ -9,6 +9,7 @@ import { useMailReading } from "@/hooks/use-mail-reading";
import { useReadAloud } from "@/hooks/use-read-aloud";
import { sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { ReadAloudBar } from "./read-aloud-bar";
import { gateLinkClick } from "@/lib/plugin-hook-result";
interface ReaderViewDialogProps {
isOpen: boolean;
@@ -85,6 +86,9 @@ export function ReaderViewDialog({ isOpen, onClose, title, description, html, la
...(reading.comfortableWidth && { maxWidth: '80ch' }),
}}
data-testid="reader-view-body"
// Links open through the plugins' link check, as in the message.
onClick={gateLinkClick}
onAuxClick={gateLinkClick}
dangerouslySetInnerHTML={body}
/>
</div>
@@ -3,7 +3,7 @@
import type { ComponentType, ReactNode } from "react";
import { useLocale, useTranslations } from "next-intl";
import { ExternalLink } from "@/components/icons";
import { resolveExternalLink } from "@/lib/plugin-hook-result";
import { openExternalLink } from "@/lib/plugin-hook-result";
import { formatMoment, formatMomentTime, isOtherZone, isWebLink, type FormatOptions } from "@/lib/structured-mail/format";
import type { Link, Moment, ReservationStatus } from "@/lib/structured-mail/types";
import { resolveTimeZone } from "@/lib/timezone";
@@ -89,12 +89,16 @@ export function CardLink({ link, label, icon: Icon = ExternalLink, emailId }: {
rel="noopener noreferrer"
className={cn(ACTION_CLASS, "flex-wrap gap-y-0")}
data-testid="structured-mail-link"
onClick={async (e) => {
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const target = await resolveExternalLink({ href: link.url, target: "_blank", emailId });
if (!target) return;
window.open(target, "_blank", "noopener,noreferrer");
void openExternalLink({ href: link.url, target: "_blank", emailId, text: label });
}}
onAuxClick={(e) => {
if (e.button !== 1) return;
e.preventDefault();
e.stopPropagation();
void openExternalLink({ href: link.url, target: "_blank", emailId, text: label });
}}
>
<Icon aria-hidden="true" className="w-3.5 h-3.5 flex-shrink-0" />
+13 -4
View File
@@ -4,7 +4,7 @@ import React, { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallba
import DOMPurify from "dompurify";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { EMAIL_SANITIZE_CONFIG, TRANSPARENT_BLOCKED_PIXEL, blockExternalResourcesOnNode, collapseBlockedImageContainers, emailIframeCsp, isOpenableLinkHref, plainTextToSafeHtml, restrictDataUriResourcesOnNode, sanitizeEmailBodyForIframe, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { resolveExternalLink } from "@/lib/plugin-hook-result";
import { gateLinkClick, linkCheckActive, linkText, resolveExternalLink } from "@/lib/plugin-hook-result";
import { stripBidiControls } from "@/lib/bidi";
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
import { emailDisplayDate } from "@/lib/email-date";
@@ -879,7 +879,10 @@ export function ConversationEmailBody({
// through onBeforeExternalLink, and nothing resolving inside our own
// origin (a relative path, data:) is opened. mailto: is written here
// when the mail page takes it; the blob: URLs of cid: parts are inert.
doc.addEventListener('click', (ev) => {
const onLinkClick = (ev: MouseEvent) => {
// A middle-click opens a link too, through the link check while one
// listens; other buttons are left alone.
if (ev.type === 'auxclick' && (ev.button !== 1 || !linkCheckActive())) return;
const link = (ev.target as Element | null)?.closest?.('a[href], area[href]');
if (!link) return;
const href = link.getAttribute('href') || '';
@@ -891,9 +894,11 @@ export function ConversationEmailBody({
ev.preventDefault();
ev.stopPropagation();
if (!isOpenableLinkHref(href)) return;
void resolveExternalLink({ href, target: link.getAttribute('target') ?? undefined, emailId: email.id })
void resolveExternalLink({ href, target: link.getAttribute('target') ?? undefined, emailId: email.id, text: linkText(link) })
.then((target) => { if (target) window.open(target, '_blank', 'noopener,noreferrer'); });
}, true);
};
doc.addEventListener('click', onLinkClick, true);
doc.addEventListener('auxclick', onLinkClick, true);
// Selected text: the onTextSelectionChange hook and the right-click
// menu plugins may add entries to.
watchBodySelection(doc, iframe, {
@@ -996,6 +1001,10 @@ export function ConversationEmailBody({
data-plugin-source="email-body"
data-email-id={email.id}
onContextMenu={(e) => selectionMenu.onElementContextMenu(e, email.id)}
// A plain-text body renders in the page, not the iframe: its
// links go through the link check here.
onClick={(e) => gateLinkClick(e, { emailId: email.id })}
onAuxClick={(e) => gateLinkClick(e, { emailId: email.id })}
dangerouslySetInnerHTML={plainBodyHtml}
/>
)}
+8 -1
View File
@@ -6,6 +6,7 @@ import { useTranslations } from 'next-intl';
import { isValidUnsubscribeUrl, parseUnsubscribeMailto } from '@/lib/validation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useIsDesktop } from '@/hooks/use-media-query';
import { openExternalLink } from '@/lib/plugin-hook-result';
interface UnsubscribeBannerProps {
listUnsubscribe: {
@@ -83,7 +84,13 @@ export function UnsubscribeBanner({
try {
if (unsubMethod === 'http') {
window.open(unsubUrl, '_blank', 'noopener,noreferrer');
// Through the plugins' link check like any other link in the mail;
// a check that stops it leaves the banner as it was.
if (!(await openExternalLink({ href: unsubUrl, target: '_blank' }))) {
setProcessing(false);
setShowConfirm(false);
return;
}
setSuccess(true);
setProcessing(false);
setShowConfirm(false);
+15 -9
View File
@@ -4099,6 +4099,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
});
}
};
// RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234).
const threading = computeReplyThreadingHeaders({
messageId: selectedEmail.messageId,
references: selectedEmail.references,
});
const replySubject = buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply'));
const prepared = await prepareQuickReply({
body,
signatureIdentity,
@@ -4107,6 +4113,14 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
to: recipients,
fromEmail: headerFromEmail,
identityId: sendingIdentity?.id,
subject: replySubject,
inReplyTo: threading?.inReplyTo?.[0],
// A plugin that said no told the user; one that crashed didn't.
onSendCheckStopped: (outcome) => {
if (outcome === 'failed') toast.error(t('email_composer.send_guard_failed'));
if (outcome === 'guard-loading') toast.error(t('send_guards.not_running'));
if (outcome === 'guard-failed') toast.error(t('send_guards.stopped_working'));
},
questions: {
// The fill-in words of the UI language and of English, as the composer.
placeholderWords: settings.placeholderWarningEnabled
@@ -4137,16 +4151,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}
// RFC 5322 §3.6.4 threading - keep the conversation stitched together (#234).
const threading = computeReplyThreadingHeaders({
messageId: selectedEmail.messageId,
references: selectedEmail.references,
});
// The label goes on last, as in the composer.
const labelled = classifyOutgoing(quickLabel.policy, quickLabel.label, {
subject: buildReplySubject(selectedEmail.subject || t('email_viewer.no_subject'), t('email_composer.prefix.reply')),
});
const labelled = classifyOutgoing(quickLabel.policy, quickLabel.label, { subject: replySubject });
// Send reply with just the body text
const result = await sendEmail(
@@ -8,6 +8,7 @@ import { useConfig } from "@/hooks/use-config";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { isDocumentRTL } from "@/i18n/direction";
import { toastHooks } from "@/lib/plugin-hooks";
import { gateLinkClick } from "@/lib/plugin-hook-result";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
@@ -125,7 +126,8 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo
href={entry.action.url}
target="_blank"
rel="noopener noreferrer"
onClick={() => markRead(entry.id)}
onClick={(event) => { markRead(entry.id); gateLinkClick(event); }}
onAuxClick={(event) => { markRead(entry.id); gateLinkClick(event); }}
className="-mt-1 mb-1 ms-[38px] inline-flex items-center gap-1 rounded text-xs font-medium text-primary hover:underline focus-visible:outline-hidden"
>
{entry.action.label}
+5 -2
View File
@@ -2,6 +2,7 @@
import { useMemo } from "react";
import { plainTextToSafeHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { gateLinkClick } from "@/lib/plugin-hook-result";
interface LinkifiedTextProps {
/** Plain text, as stored on the object (no markup is interpreted). */
@@ -21,7 +22,9 @@ interface LinkifiedTextProps {
* whole string and emits nothing but `<a href="http(s)://...">`, then
* `sanitizePlainTextRenderedHtml` re-checks the result. That second pass is
* defense in depth - unlike message bodies this renders into the main
* document, not the sandboxed iframe.
* document, not the sandboxed iframe. A click or middle-click on a link goes
* through the plugins' link check (onBeforeExternalLink) like a link in a
* message.
*/
export function LinkifiedText({ text, className }: LinkifiedTextProps) {
const html = useMemo(
@@ -29,5 +32,5 @@ export function LinkifiedText({ text, className }: LinkifiedTextProps) {
[text],
);
return <span className={className} dangerouslySetInnerHTML={{ __html: html }} />;
return <span className={className} onClick={gateLinkClick} onAuxClick={gateLinkClick} dangerouslySetInnerHTML={{ __html: html }} />;
}
+675
View File
@@ -0,0 +1,675 @@
import { test, expect, type Browser, type BrowserContext, type Page } from '@playwright/test';
import { createServer, type Server } from 'node:http';
import { existsSync, readFileSync } from 'node:fs';
import {
ENT,
adminContext,
deliver,
getPolicy,
jmapAs,
putPolicy,
requireEntEnv,
shot,
userPage,
} from './helpers/ent';
/**
* Enterprise pack, package E (official plugins and the host API they need),
* through the real UI on a VM 106 test Stalwart with the ent-* accounts:
* - item 4: external-link-warning 2.0.0 asks a company link-check service
* (a mock started by this spec) at click time: an "unsafe" verdict warns,
* a "safe" link opens, the dialog names the real target domain, a service
* that is down follows the admin's fail-open / fail-closed choice, and the
* service stays reachable with "no third-party traffic" on;
* - item 5: send-checks 1.0.0 warns about an IBAN or a card number, blocks a
* blocked rule, ignores internal recipients for an external-only rule
* (with an organisation domain set in a group scope only), refuses a
* disallowed attachment type, and runs on quick reply too;
* (an unsafe verdict also blocks when the admin says so);
* - item 5 also: sending is held while the required check isn't running, and
* a rule the admin changes on the plugin's admin page applies;
* - both dialogs at 390 px.
*
* Both plugins are installed from the local builds of the plugins worktree
* (IT_PLUGINS_DIR) through the admin API and removed at the end. No request
* goes to Google: the lookup mode is "endpoint", the Safe Browsing key is a
* fake, so the Google verdict dialog is covered by the plugin's unit tests.
*
* The rig, from one shell (the spec checks both variables and stops early):
*
* export PLUGIN_PROXY_PRIVATE_ORIGINS=http://127.0.0.1:3195 # the mock is plain HTTP on this PC
* export NEXT_PUBLIC_APP_VERSION=1.20.0 # both plugins need 1.20.0; VERSION says 1.19.1
* BUILD=1 bash enterprise-rig/start-prod.sh <worktree> 3105 <jmap-url> e-1.0
* bash enterprise-rig/run-spec.sh <worktree> 3105 <jmap-url> 1.0 integration/tests/22-ent-plugins.spec.ts
*/
test.describe.configure({ mode: 'serial' });
test.setTimeout(240_000);
const PLUGINS_DIR = process.env.IT_PLUGINS_DIR ?? 'C:/Users/info/worktrees/plugins-enterprise';
const LINK_ZIP = `${PLUGINS_DIR}/external-link-warning/external-link-warning.zip`;
const SEND_ZIP = `${PLUGINS_DIR}/send-checks/send-checks.zip`;
const LINK = 'external-link-warning';
const SEND = 'send-checks';
const MOCK_PORT = 3195;
const TOKEN = 'it-link-check-token';
const RUN = Date.now().toString(36);
const LINK_HOST = 'ent-link.example.org';
let original: Record<string, unknown>;
/** The policy as it was before this spec, put back untouched at the end. */
let untouched: Record<string, unknown> | undefined;
/** No third-party traffic as it was before this spec. */
let modeBefore: boolean | undefined;
let mock: Server;
const endpoint = { up: true, requests: [] as { url: string; host: string; auth: string }[] };
// ─── Setup ───────────────────────────────────────────────────
async function installPlugin(admin: BrowserContext, id: string, zip: string): Promise<void> {
await admin.request.delete('/api/admin/plugins', { data: { id } });
const installed = await admin.request.post('/api/admin/plugins', {
multipart: { file: { name: `${id}.zip`, mimeType: 'application/zip', buffer: readFileSync(zip) } },
});
const text = await installed.text();
if (/Needs Bulwark 1\.20\.0/.test(text)) {
throw new Error(`${id} needs Bulwark 1.20.0: build and start the server with NEXT_PUBLIC_APP_VERSION=1.20.0 (see the header of this spec)`);
}
expect(installed.ok(), text).toBe(true);
}
/** The rig variables the server needs; the same shell starts the server and this spec. */
function requireRigEnv(): void {
const origins = process.env.PLUGIN_PROXY_PRIVATE_ORIGINS ?? '';
const missing = [
...(origins.split(',').map((o) => o.trim()).includes(`http://127.0.0.1:${MOCK_PORT}`) ? [] : [`PLUGIN_PROXY_PRIVATE_ORIGINS=http://127.0.0.1:${MOCK_PORT}`]),
...(process.env.NEXT_PUBLIC_APP_VERSION === '1.20.0' ? [] : ['NEXT_PUBLIC_APP_VERSION=1.20.0']),
];
if (missing.length) {
throw new Error(`Set ${missing.join(' and ')} in the shell that builds and starts the server and runs this spec (see the header of this spec)`);
}
}
async function setConfig(browser: Browser, id: string, values: Record<string, unknown>): Promise<void> {
const admin = await adminContext(browser);
for (const [key, value] of Object.entries(values)) {
const res = await admin.request.put(`/api/admin/plugins/${id}/config`, { data: { key, value } });
expect(res.ok(), `${id}.${key}: ${await res.text()}`).toBe(true);
}
await admin.close();
}
async function setPolicy(browser: Browser, patch: Record<string, unknown>): Promise<void> {
const admin = await adminContext(browser);
await putPolicy(admin, { ...original, ...patch });
await admin.close();
}
function startMock(): Promise<Server> {
return new Promise((resolve) => {
const server = createServer((req, res) => {
let body = '';
req.on('data', (chunk) => { body += chunk; });
req.on('end', () => {
if (!endpoint.up) {
res.writeHead(503, { 'content-type': 'application/json' });
res.end('{"error":"down"}');
return;
}
let parsed: { url?: string; host?: string } = {};
try { parsed = JSON.parse(body); } catch { /* bad request below */ }
endpoint.requests.push({ url: parsed.url ?? '', host: parsed.host ?? '', auth: String(req.headers.authorization ?? '') });
const unsafe = (parsed.url ?? '').includes('/unsafe');
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify(unsafe
? { verdict: 'unsafe', reason: 'Known phishing page', cacheSeconds: 0 }
: { verdict: 'safe', cacheSeconds: 0 }));
});
});
server.listen(MOCK_PORT, '127.0.0.1', () => resolve(server));
});
}
test.beforeAll(async ({ browser }) => {
test.setTimeout(300_000);
requireEntEnv();
requireRigEnv();
test.skip(!existsSync(LINK_ZIP) || !existsSync(SEND_ZIP), `build the plugins first: ${LINK_ZIP}, ${SEND_ZIP}`);
mock = await startMock();
const admin = await adminContext(browser);
original = await getPolicy(admin);
untouched = structuredClone(original);
modeBefore = (await (await admin.request.get('/api/admin/outgoing-hosts')).json()).noThirdPartyTraffic === true;
await admin.request.get('/plugin-sandbox', { timeout: 120_000 });
await installPlugin(admin, LINK, LINK_ZIP);
await installPlugin(admin, SEND, SEND_ZIP);
const features = { ...(original.features as Record<string, unknown>), pluginsEnabled: true };
const forced = [...new Set([...((original.forceEnabledPlugins as string[] | undefined) ?? []), LINK, SEND])];
original = { ...original, features, forceEnabledPlugins: forced };
await putPolicy(admin, original);
await admin.close();
await setConfig(browser, LINK, {
trustedDomains: '',
lookup: 'endpoint',
endpointUrl: `http://127.0.0.1:${MOCK_PORT}/check`,
endpointToken: TOKEN,
// A fake: the lookup mode never asks Google.
safeBrowsingKey: 'it-fake-safe-browsing-key',
onFailure: 'open',
onUnsafe: 'warn',
skipTrusted: true,
});
await setConfig(browser, SEND, {
ibanAction: 'warn', ibanExternalOnly: false,
cardAction: 'warn', cardExternalOnly: false,
keywords: `TOPSECRET-${RUN}`, keywordAction: 'block', keywordExternalOnly: false,
patterns: '', patternAction: 'warn', patternExternalOnly: false,
blockedTypes: 'exe, bat, application/x-msdownload', allowedTypes: '', maxSizeMb: 0,
attachmentAction: 'block', attachmentExternalOnly: false,
});
});
test.afterAll(async ({ browser }) => {
test.setTimeout(120_000);
mock?.close();
// Our messages go, in every ent account the spec wrote to.
for (const account of [ENT.alice, ENT.bob, ENT.carol]) {
const client = await jmapAs(account);
const r = await client.request([['Email/query', { accountId: client.accountId, filter: { text: RUN }, limit: 100 }, '0']]);
const ids = r.methodResponses?.[0]?.[1]?.ids ?? [];
if (ids.length) await client.request([['Email/set', { accountId: client.accountId, destroy: ids }, '0']]).catch(() => {});
}
if (!untouched) return;
const admin = await adminContext(browser);
await putPolicy(admin, untouched);
if (modeBefore !== undefined) await admin.request.patch('/api/admin/config', { data: { noThirdPartyTraffic: modeBefore } }).catch(() => {});
await admin.request.delete('/api/admin/plugins', { data: { id: LINK } });
await admin.request.delete('/api/admin/plugins', { data: { id: SEND } });
await admin.close();
});
// ─── Helpers ─────────────────────────────────────────────────
async function messages(account: string, needle: string): Promise<{ id: string; subject: string }[]> {
const client = await jmapAs(account);
const r = await client.request([
['Email/query', { accountId: client.accountId, filter: { text: needle }, limit: 50 }, '0'],
['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['id', 'subject'] }, '1'],
]);
return r.methodResponses[1][1].list;
}
async function waitForMessage(account: string, subject: string, timeoutMs = 60_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!(await messages(account, RUN)).some((m) => m.subject === subject)) {
if (Date.now() > deadline) throw new Error(`${account}: no "${subject}"`);
await new Promise((r) => setTimeout(r, 1000));
}
}
/**
* Exactly one message with `subject` arrived: the send that was stopped
* earlier went nowhere, and the one let through after it did. The later
* send is the fence, so nothing waits on a clock.
*/
async function expectSentOnce(account: string, subject: string): Promise<void> {
await waitForMessage(account, subject);
expect((await messages(account, RUN)).filter((m) => m.subject === subject)).toHaveLength(1);
}
/**
* Both plugins started (the host notes the hooks of every plugin once it
* has started it) and the message list is there, so the start-up requests
* are done before the test sends anything.
*/
async function pluginsReady(page: Page): Promise<void> {
await page.waitForFunction((ids) => ids.every((id) => localStorage.getItem(`plugin-declared-hooks:${id}`)), [LINK, SEND], { timeout: 60_000 });
await expect(page.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 });
}
async function clearBody(page: Page): Promise<void> {
const editor = page.locator('[data-testid="email-composer"] [contenteditable="true"]').first();
await editor.click();
await page.keyboard.press('Control+A');
await page.keyboard.press('Delete');
}
async function openMessage(page: Page, subject: string): Promise<void> {
const row = page.locator('[data-testid="email-list-item"], [data-thread-keys]', { hasText: subject }).first();
await expect(row).toBeVisible({ timeout: 60_000 });
await row.click();
await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 20_000 });
}
const linkDialog = (page: Page) => page.locator(`[data-plugin-dialog="${LINK}"]`);
const sendDialog = (page: Page) => page.locator(`[data-plugin-dialog="${SEND}"]`);
// The message body: the reader's iframe carries the mail in srcdoc (plugin frames load a URL).
const body = (page: Page) => page.frameLocator('iframe[srcdoc]:visible').first();
/** Answers every request to the test link host, so an opened tab lands somewhere. */
async function routeLinks(context: BrowserContext): Promise<void> {
await context.route(`https://*.${LINK_HOST}/**`, (route) => route.fulfill({ status: 200, contentType: 'text/html', body: '<h1>Target</h1>' }));
}
/** A mail to ent-alice with the three links. */
async function deliverLinks(subject: string): Promise<void> {
await deliver({
from: ENT.bob,
to: [ENT.alice],
subject,
text: 'Links',
html: [
`<p><a href="https://bad.${LINK_HOST}/unsafe/${RUN}">https://bank.example.com/login</a></p>`,
`<p><a href="https://good.${LINK_HOST}/safe/${RUN}">Safe page</a></p>`,
].join(''),
});
await waitForMessage(ENT.alice, subject);
}
async function compose(page: Page, to: string[], subject: string, text: string): Promise<void> {
await page.keyboard.press('c');
const composer = page.locator('[data-testid="email-composer"]');
await composer.waitFor({ state: 'visible', timeout: 15_000 });
const input = page.locator('[data-testid="composer-to"] input').first();
for (const address of to) {
await input.click();
await input.fill(address);
await input.press('Enter');
}
await page.locator('[data-testid="composer-subject"]').first().fill(subject);
await composer.locator('[contenteditable="true"]').first().click();
await page.keyboard.type(text);
}
async function pressSend(page: Page): Promise<void> {
// A send still winding down after a "Go back" ignores another one.
await expect(page.getByTestId('composer-send').first()).toBeEnabled({ timeout: 20_000 });
await page.locator('[data-testid="composer-subject"]').first().focus();
await page.keyboard.press('Control+Enter');
}
/** "Go back" in the send check's warning: it closes and the composer is ready again. */
async function goBack(page: Page): Promise<void> {
const dialog = page.locator(`[data-plugin-dialog="${SEND}"]`);
await dialog.getByRole('button', { name: 'Go back' }).click();
await expect(dialog).toBeHidden();
await expect(page.getByText('Message not sent').first()).toBeVisible({ timeout: 20_000 });
}
const IBAN = 'DE89 3704 0044 0532 0130 00';
const CARD = '4242 4242 4242 4242';
// ─── Item 4: link check ──────────────────────────────────────
test('item 4: an unsafe verdict warns, with the real target domain and what the link text claims', async ({ browser }) => {
const subject = `Links unsafe ${RUN}`;
await deliverLinks(subject);
const alice = await userPage(browser, ENT.alice);
await routeLinks(alice.context());
await pluginsReady(alice);
await openMessage(alice, subject);
endpoint.requests = [];
await body(alice).getByRole('link', { name: 'https://bank.example.com/login' }).click();
const dialog = linkDialog(alice);
await expect(dialog).toBeVisible({ timeout: 20_000 });
await expect(dialog).toContainText('This link may be dangerous');
await expect(dialog).toContainText(`bad.${LINK_HOST}`);
await expect(dialog).toContainText('Known phishing page');
await expect(dialog).toContainText(`The link text says bank.example.com, but the link goes to bad.${LINK_HOST}.`);
// The service got the full URL, with the admin's token.
expect(endpoint.requests).toEqual([{ url: `https://bad.${LINK_HOST}/unsafe/${RUN}`, host: `bad.${LINK_HOST}`, auth: `Bearer ${TOKEN}` }]);
await shot(alice, 'e-link-unsafe');
await dialog.getByRole('button', { name: 'Go back' }).click();
await expect(dialog).toBeHidden();
expect(alice.context().pages()).toHaveLength(1);
await alice.context().close();
});
test('item 4: a safe link opens after the domain warning, and a middle-click goes through the check too', async ({ browser }) => {
const subject = `Links safe ${RUN}`;
await deliverLinks(subject);
const alice = await userPage(browser, ENT.alice);
await routeLinks(alice.context());
await pluginsReady(alice);
await openMessage(alice, subject);
await body(alice).getByRole('link', { name: 'Safe page' }).click();
const dialog = linkDialog(alice);
await expect(dialog).toContainText('Open external link?', { timeout: 20_000 });
await expect(dialog).toContainText(`good.${LINK_HOST}`);
await expect(dialog).toContainText('Checked: no known threats.');
const opened = alice.context().waitForEvent('page');
await dialog.getByRole('button', { name: 'Continue' }).click();
// "Always trust" comes next; this once only.
const trust = linkDialog(alice).getByRole('button', { name: 'Just this once' });
await trust.click({ timeout: 10_000 }).catch(() => {});
const tab = await opened;
expect(tab.url()).toBe(`https://good.${LINK_HOST}/safe/${RUN}`);
await tab.close();
// A middle-click used to open the link in a new tab with no check.
await body(alice).getByRole('link', { name: 'https://bank.example.com/login' }).click({ button: 'middle' });
await expect(linkDialog(alice)).toContainText('This link may be dangerous', { timeout: 20_000 });
await linkDialog(alice).getByRole('button', { name: 'Go back' }).click();
expect(alice.context().pages()).toHaveLength(1);
await alice.context().close();
});
test('item 4: with the service down, fail open lets the user go on and fail closed does not', async ({ browser }) => {
const subject = `Links down ${RUN}`;
await deliverLinks(subject);
endpoint.up = false;
try {
const alice = await userPage(browser, ENT.alice);
await routeLinks(alice.context());
await pluginsReady(alice);
await openMessage(alice, subject);
await body(alice).getByRole('link', { name: 'Safe page' }).click();
const dialog = linkDialog(alice);
await expect(dialog).toContainText("Couldn't check this link.", { timeout: 20_000 });
await expect(dialog).toContainText(`good.${LINK_HOST}`);
await expect(dialog.getByRole('button', { name: 'Continue' })).toBeEnabled();
await shot(alice, 'e-link-fail-open');
await dialog.getByRole('button', { name: 'Cancel' }).click();
await alice.context().close();
await setConfig(browser, LINK, { onFailure: 'closed' });
const again = await userPage(browser, ENT.alice);
await routeLinks(again.context());
await pluginsReady(again);
await openMessage(again, subject);
await body(again).getByRole('link', { name: 'Safe page' }).click();
const closed = linkDialog(again);
await expect(closed).toContainText("Link couldn't be checked", { timeout: 20_000 });
await expect(closed).toContainText(`good.${LINK_HOST}`);
await expect(closed.getByRole('button', { name: 'Continue' })).toHaveCount(0);
await shot(again, 'e-link-fail-closed');
await closed.getByRole('button', { name: 'Close' }).click();
expect(again.context().pages()).toHaveLength(1);
await again.context().close();
} finally {
endpoint.up = true;
await setConfig(browser, LINK, { onFailure: 'open' });
}
});
test('item 4: with no third-party traffic the company service still answers, and Security lists it', async ({ browser }) => {
const subject = `Links mode ${RUN}`;
await deliverLinks(subject);
const admin = await adminContext(browser);
const on = await admin.request.patch('/api/admin/config', { data: { noThirdPartyTraffic: true } });
expect(on.ok(), await on.text()).toBe(true);
try {
const page = await admin.newPage();
await page.goto('/admin/org-security');
const reachable = page.getByTestId('third-party-reachable');
await expect(reachable).toContainText(`127.0.0.1:${MOCK_PORT}`, { timeout: 20_000 });
await expect(reachable).toContainText('Plugin External Link Warning');
await expect(page.locator('[data-settings-section="third-party"]')).toContainText('safebrowsing.googleapis.com stays off');
await shot(page, 'e-link-admin-security');
const alice = await userPage(browser, ENT.alice);
await routeLinks(alice.context());
await pluginsReady(alice);
await openMessage(alice, subject);
endpoint.requests = [];
await body(alice).getByRole('link', { name: 'https://bank.example.com/login' }).click();
await expect(linkDialog(alice)).toContainText('This link may be dangerous', { timeout: 20_000 });
expect(endpoint.requests).toHaveLength(1);
await linkDialog(alice).getByRole('button', { name: 'Go back' }).click();
await alice.context().close();
} finally {
await admin.request.patch('/api/admin/config', { data: { noThirdPartyTraffic: false } });
await admin.close();
}
});
test('item 4: the link dialog fits at 390 px', async ({ browser }) => {
const subject = `Links phone ${RUN}`;
await deliverLinks(subject);
const alice = await userPage(browser, ENT.alice, true);
await routeLinks(alice.context());
await pluginsReady(alice);
await openMessage(alice, subject);
await body(alice).getByRole('link', { name: 'https://bank.example.com/login' }).click();
const dialog = linkDialog(alice);
await expect(dialog).toContainText('This link may be dangerous', { timeout: 20_000 });
const box = await dialog.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
expect(await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await shot(alice, 'e-link-phone');
await alice.keyboard.press('Escape');
await expect(dialog).toBeHidden();
await alice.context().close();
});
// ─── Item 5: send checks ─────────────────────────────────────
test('item 5: an IBAN and a card number warn, masked; Go back keeps the mail, Send anyway sends it', async ({ browser }) => {
const subject = `Send IBAN ${RUN}`;
const alice = await userPage(browser, ENT.alice);
await pluginsReady(alice);
await compose(alice, [ENT.bob], subject, `Please pay to ${IBAN}, card ${CARD}.`);
await pressSend(alice);
const dialog = sendDialog(alice);
await expect(dialog).toContainText('Check before sending', { timeout: 20_000 });
await expect(dialog).toContainText('IBAN DE89');
await expect(dialog).toContainText('Card number ending 4242');
await expect(dialog).not.toContainText('0532 0130');
await expect(dialog).not.toContainText(CARD);
await shot(alice, 'e-send-warn');
await goBack(alice);
await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible();
await pressSend(alice);
await expect(dialog).toContainText('Check before sending', { timeout: 20_000 });
await dialog.getByRole('button', { name: 'Send anyway' }).click();
// One message: Go back sent nothing.
await expectSentOnce(ENT.bob, subject);
await alice.context().close();
});
test('item 5: a block rule stops the send', async ({ browser }) => {
const subject = `Send block ${RUN}`;
const alice = await userPage(browser, ENT.alice);
await pluginsReady(alice);
await compose(alice, [ENT.bob], subject, `This is TOPSECRET-${RUN} material.`);
await pressSend(alice);
const dialog = sendDialog(alice);
await expect(dialog).toContainText("This message can't be sent", { timeout: 20_000 });
await expect(dialog).toContainText(`TOPSECRET-${RUN}`);
await expect(dialog.getByRole('button', { name: 'Send anyway' })).toHaveCount(0);
await shot(alice, 'e-send-block');
await alice.keyboard.press('Escape');
await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible();
// Without the word it goes, once: the blocked send went nowhere.
await clearBody(alice);
await alice.keyboard.type('Nothing secret here.');
await pressSend(alice);
await expectSentOnce(ENT.bob, subject);
await alice.context().close();
});
test('item 4: an unsafe verdict blocks the link when the admin says so', async ({ browser }) => {
const subject = `Links block ${RUN}`;
await deliverLinks(subject);
await setConfig(browser, LINK, { onUnsafe: 'block' });
try {
const alice = await userPage(browser, ENT.alice);
await routeLinks(alice.context());
await pluginsReady(alice);
await openMessage(alice, subject);
await body(alice).getByRole('link', { name: 'https://bank.example.com/login' }).click();
const dialog = linkDialog(alice);
await expect(dialog).toContainText('Link blocked', { timeout: 20_000 });
await expect(dialog).toContainText(`bad.${LINK_HOST}`);
await expect(dialog.getByRole('button', { name: 'Open anyway' })).toHaveCount(0);
await shot(alice, 'e-link-blocked');
await dialog.getByRole('button', { name: 'Close' }).click();
expect(alice.context().pages()).toHaveLength(1);
await alice.context().close();
} finally {
await setConfig(browser, LINK, { onUnsafe: 'warn' });
}
});
test('item 5: an external-only rule ignores internal recipients, with the domain from a group scope for its members only', async ({ browser }) => {
await setConfig(browser, SEND, { ibanExternalOnly: true });
// partner.invalid is internal for ent-team (ent-alice) only. An .invalid
// domain: the copy the server tries to deliver there goes nowhere real.
await setPolicy(browser, {
scopes: [{ id: 'ent-e-partner', label: 'Partner', match: { groups: [ENT.team] }, policy: { organisation: { externalMail: { domains: ['partner.invalid'] } } } }],
});
try {
const internal = `Send internal ${RUN}`;
const alice = await userPage(browser, ENT.alice);
await pluginsReady(alice);
await compose(alice, [ENT.bob, 'someone@partner.invalid'], internal, `IBAN ${IBAN}`);
await pressSend(alice);
await waitForMessage(ENT.bob, internal);
await expect(sendDialog(alice)).toHaveCount(0);
await alice.context().close();
// ent-carol is in no group: partner.invalid is outside for her.
const outside = `Send outside ${RUN}`;
const carol = await userPage(browser, ENT.carol);
await pluginsReady(carol);
await compose(carol, [ENT.bob, 'someone@partner.invalid'], outside, `IBAN ${IBAN}`);
await pressSend(carol);
const dialog = sendDialog(carol);
await expect(dialog).toContainText('IBAN DE89', { timeout: 20_000 });
await expect(dialog).toContainText('goes outside your organisation');
await goBack(carol);
await pressSend(carol);
await sendDialog(carol).getByRole('button', { name: 'Send anyway' }).click();
await expectSentOnce(ENT.bob, outside);
await carol.context().close();
} finally {
await setConfig(browser, SEND, { ibanExternalOnly: false });
await setPolicy(browser, { scopes: (original.scopes as unknown[] | undefined) ?? [] });
}
});
test('item 5: a program is refused as an attachment, also renamed to .pdf', async ({ browser }) => {
const alice = await userPage(browser, ENT.alice);
await pluginsReady(alice);
await compose(alice, [ENT.bob], `Send attachment ${RUN}`, 'See the file.');
const program = Buffer.concat([Buffer.from('MZ'), Buffer.from([0x90, 0, 3, 0, 0, 0, 4, 0]), Buffer.alloc(200, 0)]);
const input = alice.locator('[data-testid="email-composer"] input[type="file"]').first();
await input.setInputFiles({ name: 'tool.exe', mimeType: 'application/octet-stream', buffer: program });
const dialog = sendDialog(alice);
await expect(dialog).toContainText("This file can't be attached", { timeout: 20_000 });
await expect(dialog).toContainText('tool.exe');
await dialog.getByRole('button', { name: 'OK' }).click();
await expect(dialog).toBeHidden();
await input.setInputFiles({ name: 'invoice.pdf', mimeType: 'application/pdf', buffer: program });
await expect(dialog).toContainText('invoice.pdf', { timeout: 20_000 });
await shot(alice, 'e-send-attachment');
await dialog.getByRole('button', { name: 'OK' }).click();
await expect(dialog).toBeHidden();
await expect(alice.locator('[data-testid="email-composer"]').getByText('invoice.pdf')).toHaveCount(0);
await expect(alice.locator('[data-testid="email-composer"]').getByText('tool.exe')).toHaveCount(0);
await alice.context().close();
});
test('item 5: a quick reply is checked too', async ({ browser }) => {
const subject = `Quick ${RUN}`;
await deliver({ from: ENT.bob, to: [ENT.alice], subject, text: 'Where do I pay?' });
await waitForMessage(ENT.alice, subject);
const alice = await userPage(browser, ENT.alice);
await pluginsReady(alice);
await openMessage(alice, subject);
const box = alice.getByPlaceholder('Write a quick reply...');
await box.fill(`To ${IBAN} please`);
await box.press('Control+Enter');
const dialog = sendDialog(alice);
await expect(dialog).toContainText('IBAN DE89', { timeout: 20_000 });
await goBack(alice);
// The typed text stays.
await expect(box).toHaveValue(`To ${IBAN} please`);
await box.fill('I will pay by card in person.');
await box.press('Control+Enter');
await expect(box).toHaveValue('', { timeout: 30_000 });
// One reply: the stopped one went nowhere.
await expectSentOnce(ENT.bob, `Re: ${subject}`);
await alice.context().close();
});
test('item 5: the send dialog fits at 390 px', async ({ browser }) => {
const alice = await userPage(browser, ENT.alice, true);
await pluginsReady(alice);
await alice.getByRole('button', { name: /^(Compose|New message|Write)/ }).first().click().catch(() => alice.keyboard.press('c'));
const composer = alice.locator('[data-testid="email-composer"]');
await composer.waitFor({ state: 'visible', timeout: 15_000 });
const input = alice.locator('[data-testid="composer-to"] input').first();
await input.fill(ENT.bob);
await input.press('Enter');
await alice.locator('[data-testid="composer-subject"]').first().fill(`Send phone ${RUN}`);
await composer.locator('[contenteditable="true"]').first().click();
await alice.keyboard.type(`Card ${CARD}`);
await alice.getByTestId('composer-send').first().click();
const dialog = sendDialog(alice);
await expect(dialog).toContainText('Card number ending 4242', { timeout: 20_000 });
const box = await dialog.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(390);
expect(await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)).toBeLessThanOrEqual(0);
await shot(alice, 'e-send-phone');
await goBack(alice);
await alice.context().close();
});
test('item 5: a keyword the admin adds on the plugin page warns at the next send', async ({ browser }) => {
const word = `Projekt-${RUN}`;
const admin = await adminContext(browser);
const page = await admin.newPage();
await page.goto(`/admin/plugins/${SEND}`);
await expect(page.getByText('IBANs', { exact: true }).first()).toBeVisible({ timeout: 30_000 });
const keywords = page.getByRole('textbox', { name: 'Keywords', exact: true });
await keywords.fill(`TOPSECRET-${RUN}\n${word}`);
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect.poll(async () => {
const res = await admin.request.get(`/api/admin/plugins/${SEND}/config`);
return String((await res.json()).keywords ?? '');
}, { timeout: 20_000 }).toContain(word);
await shot(page, 'e-send-admin');
await admin.close();
const subject = `Send admin keyword ${RUN}`;
const alice = await userPage(browser, ENT.alice);
await pluginsReady(alice);
await compose(alice, [ENT.bob], subject, `About ${word}.`);
await pressSend(alice);
// TOPSECRET is a block rule; the new word is on the same list, so it blocks too.
await expect(sendDialog(alice)).toContainText(word, { timeout: 20_000 });
await alice.keyboard.press('Escape');
await alice.context().close();
await setConfig(browser, SEND, { keywords: `TOPSECRET-${RUN}` });
});
// A required send check that never started used to let every message through.
test("item 5: sending is held while the required check isn't running", async ({ browser }) => {
const subject = `Send held ${RUN}`;
const alice = await userPage(browser, ENT.alice);
await pluginsReady(alice);
// From now on the plugin frames can't load: the next start of the plugins fails.
await alice.context().route('**/plugin-sandbox**', (route) => route.abort());
await alice.reload();
await expect(alice.locator('[data-testid="email-list-item"]').first()).toBeVisible({ timeout: 60_000 });
await compose(alice, [ENT.bob], subject, 'Nothing to check here.');
await pressSend(alice);
// First it waits for the check, then says it isn't running; once the start
// has failed for good it says it stopped working.
await expect(alice.getByText(/A check your organisation requires (isn't running|has stopped working)/).first()).toBeVisible({ timeout: 45_000 });
await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible();
await shot(alice, 'e-send-held');
// With the plugins running again the same message goes, once: the held
// send went nowhere.
await alice.context().unroute('**/plugin-sandbox**');
await alice.reload();
await pluginsReady(alice);
await compose(alice, [ENT.bob], subject, 'Nothing to check here.');
await pressSend(alice);
await expectSentOnce(ENT.bob, subject);
await alice.context().close();
});
+14 -1
View File
@@ -4,7 +4,7 @@ import { resyncWebPush, revokePushDevice, type PushDevice } from '@/lib/web-push
import { doGetPublicKeyFromWKD } from '@/lib/plugin-sandbox/host-api';
import { applyLiteConfig } from '@/lib/lite-config';
import { setNoThirdPartyTraffic, noThirdPartyTraffic, parseNoThirdPartyEnv } from '@/lib/third-party-mode';
import { isBuiltInHost, pluginAllowed, pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
import { credentialAllowedInMode, isBuiltInHost, pluginAllowed, pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
import { isPlaceholderHost } from '@/lib/admin/outgoing-hosts-registry';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
@@ -116,6 +116,19 @@ describe('plugins', () => {
});
describe('config', () => {
it('lets a plugin run that reaches only servers the admin entered, and no other', () => {
setNoThirdPartyTraffic(true);
const admin = (extra: Record<string, unknown>) => ({ label: 'x', scope: 'admin', secretKey: 's', ...extra });
const linkCheck = { permissions: ['http:proxy'], credentials: { company: admin({ originKey: 'url' }), google: admin({ origin: 'https://safebrowsing.googleapis.com' }) } };
const fixedOnly = { permissions: ['http:proxy'], credentials: { google: admin({ origin: 'https://safebrowsing.googleapis.com' }) } };
const userAccount = { permissions: ['http:proxy'], credentials: { company: admin({ originKey: 'url' }), own: { label: 'x', scope: 'user' } } };
const alsoFetches = { permissions: ['http:proxy', 'http:fetch'], httpOrigins: ['https://api.vendor.example'], credentials: { company: admin({ originKey: 'url' }) } };
expect([linkCheck, fixedOnly, userAccount, alsoFetches].map(pluginAllowed)).toEqual([true, false, false, false]);
expect(credentialAllowedInMode(linkCheck.credentials.company)).toBe(true);
expect(credentialAllowedInMode(linkCheck.credentials.google)).toBe(false);
setNoThirdPartyTraffic(false);
});
it('reads noThirdPartyTraffic from the Lite config.json', () => {
expect(applyLiteConfig({}).noThirdPartyTraffic).toBe(false);
expect(applyLiteConfig({ noThirdPartyTraffic: true }).noThirdPartyTraffic).toBe(true);
+82 -1
View File
@@ -26,7 +26,7 @@ const registryPlugins: Record<string, unknown>[] = [];
vi.mock('@/lib/admin/plugin-registry', () => ({
getPluginRegistry: vi.fn(async () => ({ plugins: registryPlugins })),
getThemeRegistry: vi.fn(async () => ({ themes: [] })),
getPlugin: vi.fn(async () => null),
getPlugin: vi.fn(async (id: string) => registryPlugins.find((p) => p.id === id) ?? null),
getTheme: vi.fn(async () => null),
}));
vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) }));
@@ -358,3 +358,84 @@ describe('review follow-ups', () => {
}
});
});
// A company link check is a server the admin entered on the plugin's admin
// page: it stays reachable with the switch on, like the JMAP server. The
// plugin used to be refused completely, and its domain warning with it.
describe('plugins reaching only servers the admin entered', () => {
const linkCheck = {
id: 'link-check', name: 'Link Check', version: '2.0.0', enabled: true, permissions: ['http:proxy'],
configSchema: { endpointUrl: { type: 'string', label: 'URL' }, token: { type: 'secret', label: 'Token' }, key: { type: 'secret', label: 'Key' } },
credentials: {
company: { label: 'Company', scope: 'admin', inject: { type: 'none' }, secretKey: 'token', originKey: 'endpointUrl' },
google: { label: 'Google', scope: 'admin', inject: { type: 'header', name: 'X-Goog-Api-Key' }, secretKey: 'key', origin: 'https://safebrowsing.googleapis.com' },
},
};
const googleOnly = {
id: 'google-only', name: 'Google Only', version: '1.0.0', enabled: true, permissions: ['http:proxy'],
configSchema: { key: { type: 'secret', label: 'Key' } },
credentials: { google: { label: 'Google', scope: 'admin', inject: { type: 'bearer' }, secretKey: 'key', origin: 'https://safebrowsing.googleapis.com' } },
};
it('reach the browser in the mode, unlike one with only fixed servers', async () => {
registryPlugins.push(linkCheck, googleOnly);
const { GET } = await import('@/app/api/plugins/route');
await setMode(true);
const on = await (await GET(new NextRequest('https://webmail.example/api/plugins'))).json();
expect(on.plugins.map((p: { id: string }) => p.id)).toEqual(['link-check']);
});
it('get the proxy for the admin-entered server only', async () => {
registryPlugins.push(linkCheck);
const { setPluginConfig } = await import('@/lib/admin/plugin-config');
await setPluginConfig('link-check', 'endpointUrl', 'https://linkcheck.corp.example/check');
fetchMock.mockResolvedValueOnce(new Response('{"verdict":"safe"}', { headers: { 'content-type': 'application/json' } }));
const { POST } = await import('@/app/api/plugin-proxy/route');
await setMode(true, { features: { pluginsEnabled: true } });
const company = await POST(new NextRequest('https://webmail.example/api/plugin-proxy?plugin=link-check&credential=company&url=https%3A%2F%2Flinkcheck.corp.example%2Fcheck', { method: 'POST' }));
expect(company.status).toBe(200);
expect(await company.json()).toEqual({ verdict: 'safe' });
expect(calledHosts()).toEqual(['linkcheck.corp.example']);
const google = await POST(new NextRequest('https://webmail.example/api/plugin-proxy?plugin=link-check&credential=google&url=https%3A%2F%2Fsafebrowsing.googleapis.com%2Fv5%2Fhashes%3Asearch', { method: 'POST' }));
expect(google.status).toBe(403);
expect((await google.json()).code).toBe('no-third-party-traffic');
expect(calledHosts()).not.toContain('safebrowsing.googleapis.com');
});
// The admin page saves a field's default with the first Save: a plugin
// could bring its own collector as the default and reach it in the mode.
it("don't count a server the plugin brought as the field's default", async () => {
const shipped = {
...linkCheck, id: 'shipped',
configSchema: { ...linkCheck.configSchema, endpointUrl: { type: 'string', label: 'URL', default: 'https://collector.example/in' } },
};
registryPlugins.push(shipped);
const { setPluginConfig } = await import('@/lib/admin/plugin-config');
await setPluginConfig('shipped', 'endpointUrl', 'https://collector.example/in');
const { POST } = await import('@/app/api/plugin-proxy/route');
await setMode(true, { features: { pluginsEnabled: true } });
const res = await POST(new NextRequest('https://webmail.example/api/plugin-proxy?plugin=shipped&credential=company&url=https%3A%2F%2Fcollector.example%2Fin', { method: 'POST' }));
expect(res.status).toBe(403);
expect((await res.json()).code).toBe('no-third-party-traffic');
expect(fetchMock).not.toHaveBeenCalled();
const { adminEnteredOrigins } = await import('@/lib/plugin-secrets/proxy');
expect(await adminEnteredOrigins('shipped')).toEqual([]);
// The admin types another address: that one is theirs.
await setPluginConfig('shipped', 'endpointUrl', 'https://linkcheck.corp.example/check');
expect(await adminEnteredOrigins('shipped')).toEqual(['https://linkcheck.corp.example']);
});
it('are listed on the Security page with the server the admin entered and the one that stays off', async () => {
registryPlugins.push(linkCheck, googleOnly);
const { setPluginConfig } = await import('@/lib/admin/plugin-config');
await setPluginConfig('link-check', 'endpointUrl', 'https://linkcheck.corp.example/check');
const { GET } = await import('@/app/api/admin/outgoing-hosts/route');
await setMode(true);
const body = await (await GET(new NextRequest('https://webmail.example/api/admin/outgoing-hosts'))).json();
expect(body.reachable).toEqual(expect.arrayContaining([
expect.objectContaining({ kind: 'Plugin Link Check', host: 'linkcheck.corp.example', side: 'server' }),
]));
expect(body.networkPlugins).toEqual([{ id: 'google-only', name: 'Google Only' }]);
expect(body.partlyOffPlugins).toEqual([{ id: 'link-check', name: 'Link Check', hosts: ['safebrowsing.googleapis.com'] }]);
});
});
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { dispatchApiCall } from '@/lib/plugin-sandbox/host-api';
import type { InstalledPlugin } from '@/lib/plugin-types';
// api.admin.getConfig asked the config route without ?public=1. The route
// hands an admin session every field, secrets included, so a plugin running
// for a user who was also signed in to the admin console got its own API
// keys (the Safe Browsing key of the link check). Secrets are for the server
// proxy only.
const plugin = {
id: 'external-link-warning', name: 'Link check', version: '2.0.0', author: '', description: '', type: 'hook',
permissions: ['admin:config'], grantedPermissions: ['admin:config'], managed: true, entrypoint: 'index.js',
enabled: true, status: 'running', settings: {},
} as unknown as InstalledPlugin;
afterEach(() => vi.unstubAllGlobals());
describe('api.admin config reads', () => {
it('ask for the fields without secrets', async () => {
const urls: string[] = [];
vi.stubGlobal('fetch', vi.fn(async (url: RequestInfo | URL) => {
urls.push(String(url));
return new Response(JSON.stringify({ trustedDomains: 'example.com' }), { headers: { 'content-type': 'application/json' } });
}));
expect(await dispatchApiCall(plugin, 'admin.getAllConfig', [])).toEqual({ trustedDomains: 'example.com' });
expect(await dispatchApiCall(plugin, 'admin.getConfig', ['trustedDomains'])).toBe('example.com');
expect(urls).toHaveLength(2);
for (const url of urls) expect(url).toMatch(/\/api\/admin\/plugins\/external-link-warning\/config\?public=1$/);
});
});
// The server a plugin's credential reaches (originKey) is what stays
// reachable with no third-party traffic: the plugin may not write it.
describe('api.admin config writes', () => {
it("refuse the plugin's own server address fields", async () => {
const fetchSpy = vi.fn(async () => new Response('{}'));
vi.stubGlobal('fetch', fetchSpy);
const withServer = {
...plugin,
credentials: { linkcheck: { label: 'x', scope: 'admin', inject: { type: 'bearer' }, secretKey: 'endpointToken', originKey: 'endpointUrl' } },
} as unknown as InstalledPlugin;
await expect(dispatchApiCall(withServer, 'admin.setConfig', ['endpointUrl', 'https://collector.example/'])).rejects.toThrow(/only an administrator/);
await expect(dispatchApiCall(withServer, 'admin.deleteConfig', ['endpointUrl'])).rejects.toThrow(/only an administrator/);
expect(fetchSpy).not.toHaveBeenCalled();
await dispatchApiCall(withServer, 'admin.setConfig', ['trustedDomains', 'example.com']);
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
@@ -19,6 +19,7 @@ vi.mock('@/stores/toast-store', () => ({
}));
import { dispatchApiCall } from '../plugin-sandbox/host-api';
import type { OutgoingEmail } from '../plugin-types';
function plugin(perms: string[], id = 'e-invoice'): InstalledPlugin {
return {
@@ -186,6 +187,43 @@ describe('api.email.forward', () => {
}
});
it("gives send checks the note, the recipients outside and what the attachments really are", async () => {
const seen: OutgoingEmail[] = [];
client.fetchBlobArrayBuffer = vi.fn(async (blobId: string) => (blobId === 'pdf-1' ? new Uint8Array([0x4d, 0x5a, 0x90, 0, 3]) : new TextEncoder().encode('<Invoice/>')).buffer);
const off = emailHooks.onBeforeEmailSend.register('send-checks', (outgoing: OutgoingEmail) => { seen.push(outgoing); return false; });
try {
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@outside.test', 'ann2@example.com'], text: 'Please book this.' });
await answerDialog(true);
expect(await pending).toEqual({ sent: false, reason: 'stopped' });
expect(seen[0]).toMatchObject({
surface: 'plugin-forward',
mode: 'forward',
authoredText: 'Please book this.',
externalRecipients: ['books@outside.test'],
});
expect(seen[0].attachments).toEqual([
{ name: 'invoice.pdf', type: 'application/pdf', size: 5000, blobId: 'pdf-1', sniffedType: 'application/x-msdownload' },
{ name: 'xrechnung.xml', type: 'application/xml', size: 3000, blobId: 'xml-1', sniffedType: 'text/plain', textExcerpt: '<Invoice/>' },
]);
} finally {
off.dispose();
}
});
it('is held while a send check the admin requires is down, also with no plugin listening', async () => {
const { setSendGuardSource } = await import('../plugin-send-guards');
const down = { id: 'send-checks', name: 'Send checks', version: '1', permissions: ['email:send'], status: 'error', error: 'auto-disabled', enabled: false, forceEnabled: true } as unknown as InstalledPlugin;
setSendGuardSource({ snapshot: () => ({ plugins: [down], forced: () => true }), subscribe: () => () => {} });
try {
const pending = call(FORWARDER, 'email.forward', 'E1', { to: ['books@example.com'] });
await answerDialog(true);
expect(await pending).toEqual({ sent: false, reason: 'stopped' });
expect(sendEmail).not.toHaveBeenCalled();
} finally {
setSendGuardSource(null);
}
});
it('needs email:forward and sound input', async () => {
await expect(call(plugin(['email:read', 'email:send']), 'email.forward', 'E1', { to: ['a@example.com'] }))
.rejects.toThrow(/lacks permission "email:forward"/);
+25 -8
View File
@@ -10,6 +10,7 @@ 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 { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
import {
EXTERNAL_MAIL_WARNING_PLUGIN_ID,
isHookHandedOver,
@@ -30,11 +31,22 @@ beforeEach(() => setExternalMail({}));
describe('snapshotHostFeatures', () => {
it('has one flag per switch', () => {
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: false });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: false });
setExternalMail({ senderLabel: true });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: true, externalRecipientWarning: false });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false });
setExternalMail({ recipientWarning: true });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: true });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false });
});
// A link check switches Google Safe Browsing off in the mode instead of
// taking the refused requests for a service that is down.
it('says when third-party traffic is off, and while that is not known yet', () => {
setNoThirdPartyTraffic(true);
expect(snapshotHostFeatures().noThirdPartyTraffic).toBe(true);
setNoThirdPartyTraffic('unknown');
expect(snapshotHostFeatures().noThirdPartyTraffic).toBe(true);
setNoThirdPartyTraffic(false);
expect(snapshotHostFeatures().noThirdPartyTraffic).toBe(false);
});
});
@@ -69,18 +81,22 @@ describe('host bridge', () => {
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({ externalSenderLabel: true, externalRecipientWarning: false });
expect(pending?.hostFeatures).toEqual({ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: false });
// Before init has gone out a change lands in the payload ...
setExternalMail({});
expect(pending?.hostFeatures).toEqual({ externalSenderLabel: false, externalRecipientWarning: false });
expect(pending?.hostFeatures).toEqual({ externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: 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: { externalSenderLabel: false, externalRecipientWarning: true } }]);
expect(sent).toEqual([{ type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: false } }]);
// The admin turns third-party traffic off: running plugins hear it.
setNoThirdPartyTraffic(true);
expect(sent[1]).toEqual({ type: 'host-change', features: { externalSenderLabel: false, externalRecipientWarning: true, noThirdPartyTraffic: true } });
setNoThirdPartyTraffic(false);
instance.destroy();
}, 30_000);
});
@@ -140,9 +156,10 @@ describe('api.host in the sandbox runtime', () => {
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: { externalSenderLabel: true, externalRecipientWarning: false } });
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true } });
expect(kept.externalRecipientWarning).toBe(false);
expect(kept.externalSenderLabel).toBe(true);
expect(changes).toEqual([{ externalSenderLabel: true, externalRecipientWarning: false }]);
expect((kept as { noThirdPartyTraffic?: boolean }).noThirdPartyTraffic).toBe(true);
expect(changes).toEqual([{ externalSenderLabel: true, externalRecipientWarning: false, noThirdPartyTraffic: true }]);
}, 30_000);
});
+186
View File
@@ -0,0 +1,186 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
awaitSendGuards,
downSendGuards,
getSendGuardSource,
guardsSending,
rememberDeclaredHooks,
setSendGuardSource,
} from '@/lib/plugin-send-guards';
import { runPluginSendCheck } from '@/lib/outgoing/plugin-send-check';
import { emailHooks } from '@/lib/plugin-hooks';
import type { InstalledPlugin, OutgoingEmail } from '@/lib/plugin-types';
import { usePluginStore } from '@/stores/plugin-store';
import { usePolicyStore } from '@/stores/policy-store';
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
// A send check the admin force-enabled that is starting, failed to start or
// was turned off after errors used to let every message through.
const guard = (patch: Partial<InstalledPlugin> = {}): InstalledPlugin => ({
id: 'send-checks', name: 'Send checks', version: '1.0.0', author: '', description: '', type: 'hook',
permissions: ['email:send', 'email:read'], entrypoint: 'index.js', enabled: true, status: 'running',
settings: {}, managed: true, forceEnabled: true, bundleHash: 'abc', ...patch,
});
const email: OutgoingEmail = { to: ['x@example.org'], cc: [], bcc: [], subject: 's', htmlBody: '', textBody: 't', identityId: 'i', attachments: [] };
beforeEach(() => {
localStorage.clear();
// Plugins are on (the default policy has them off).
const state = usePolicyStore.getState();
usePolicyStore.setState({ policy: { ...state.policy, features: { ...state.policy.features, pluginsEnabled: true } } });
});
afterEach(() => {
usePluginStore.setState({ plugins: [], initialized: false, serverPluginsSynced: false });
emailHooks.onBeforeEmailSend.clear();
vi.useRealTimers();
});
describe('downSendGuards', () => {
it('counts a required guard that is starting, failed to start or was turned off after errors', () => {
usePluginStore.setState({
plugins: [
guard({ id: 'a', status: 'enabled' }),
guard({ id: 'b', status: 'error', error: 'Bundle integrity mismatch: expected x, got y' }),
guard({ id: 'c', status: 'error', error: 'auto-disabled', enabled: false }),
guard({ id: 'd', status: 'running' }),
],
});
expect(downSendGuards().map((g) => [g.id, g.state])).toEqual([['a', 'loading'], ['b', 'failed'], ['c', 'failed']]);
});
it("doesn't count a plugin Bulwark refused on purpose, nor one the user turned on", () => {
usePluginStore.setState({
plugins: [
guard({ id: 'a', status: 'error', error: 'third-party-off' }),
guard({ id: 'b', status: 'error', error: 'approval-denied' }),
guard({ id: 'c', status: 'error', error: 'Privileged tier requires the "crypto:full" permission' }),
guard({ id: 'd', status: 'enabled', forceEnabled: false, managed: false }),
],
});
expect(downSendGuards()).toEqual([]);
});
it('counts a plugin the policy forces on', () => {
const spy = vi.spyOn(usePolicyStore.getState(), 'isPluginForceEnabled').mockImplementation((id: string) => id === 'p');
usePluginStore.setState({ plugins: [guard({ id: 'p', forceEnabled: false, status: 'enabled' })] });
expect(downSendGuards().map((g) => g.id)).toEqual(['p']);
spy.mockRestore();
});
it('knows a guard by the hooks of its last start, else by the email:send permission', () => {
expect(guardsSending(guard())).toBe(true);
expect(guardsSending(guard({ permissions: ['email:read'] }))).toBe(false);
rememberDeclaredHooks(guard(), 'abc', ['onComposeSend']);
expect(guardsSending(guard())).toBe(false);
// Another bundle: what the old one did no longer says anything.
expect(guardsSending(guard({ bundleHash: '"DEF"' }))).toBe(true);
rememberDeclaredHooks(guard({ bundleHash: 'def' }), 'def', ['onBeforeEmailSend']);
expect(guardsSending(guard({ bundleHash: '"DEF"' }))).toBe(true);
});
});
describe('awaitSendGuards', () => {
it('waits for a guard that is starting and goes on once it runs', async () => {
usePluginStore.setState({ plugins: [guard({ status: 'enabled' })] });
const waiting = awaitSendGuards(5000);
usePluginStore.setState({ plugins: [guard({ status: 'running' })] });
expect(await waiting).toEqual([]);
});
it('gives up after the wait and reports it down', async () => {
vi.useFakeTimers();
usePluginStore.setState({ plugins: [guard({ status: 'enabled' })] });
const waiting = awaitSendGuards(5000);
await vi.advanceTimersByTimeAsync(5000);
expect(await waiting).toEqual([{ id: 'send-checks', name: 'Send checks', state: 'loading' }]);
});
it('sees nothing without a source (no plugin store, as in Lite)', async () => {
const storeSource = getSendGuardSource();
setSendGuardSource(null);
try {
usePluginStore.setState({ plugins: [guard({ status: 'error', error: 'auto-disabled' })] });
expect(await awaitSendGuards(10)).toEqual([]);
} finally {
setSendGuardSource(storeSource);
}
});
});
describe('runPluginSendCheck', () => {
it('holds the send while a required guard is down, without asking the others', async () => {
const handler = vi.fn(() => undefined);
emailHooks.onBeforeEmailSend.register('other', handler);
usePluginStore.setState({ plugins: [guard({ status: 'error', error: 'auto-disabled', enabled: false })] });
expect(await runPluginSendCheck(() => email)).toBe('guard-failed');
expect(handler).not.toHaveBeenCalled();
usePluginStore.setState({ plugins: [guard()] });
expect(await runPluginSendCheck(() => email)).toBe('allowed');
expect(handler).toHaveBeenCalledTimes(1);
});
});
// A plugin the admin forces on that hasn't reached this browser (a new
// device, a failed plugin sync) used to count for nothing: mail went out
// unchecked until it arrived.
describe('a forced plugin this browser has not got yet', () => {
const forceInPolicy = (ids: string[]) => {
const state = usePolicyStore.getState();
usePolicyStore.setState({ policy: { ...state.policy, forceEnabledPlugins: ids } });
};
afterEach(() => forceInPolicy([]));
it('counts as starting until the admin list arrived in full, then not', () => {
forceInPolicy(['send-checks']);
usePluginStore.setState({ plugins: [], initialized: true, serverPluginsSynced: false });
expect(downSendGuards()).toEqual([{ id: 'send-checks', name: 'send-checks', state: 'loading' }]);
usePluginStore.setState({ initialized: false, serverPluginsSynced: true });
expect(downSendGuards().map((g) => g.id)).toEqual(['send-checks']);
// The server left it out on purpose (refused in the no-third-party mode, removed).
usePluginStore.setState({ initialized: true, serverPluginsSynced: true });
expect(downSendGuards()).toEqual([]);
});
it('counts nothing while the admin turned plugins off', () => {
forceInPolicy(['send-checks']);
const state = usePolicyStore.getState();
usePolicyStore.setState({ policy: { ...state.policy, features: { ...state.policy.features, pluginsEnabled: false } } });
try {
usePluginStore.setState({ plugins: [guard({ status: 'error', error: 'auto-disabled' })], initialized: false });
expect(downSendGuards()).toEqual([]);
} finally {
usePolicyStore.setState({ policy: { ...state.policy } });
}
});
});
// A send right after sign-in, while the check was still starting, gave it a
// message built for nobody: no external recipients, no file types.
describe('runPluginSendCheck builds the message after the wait', () => {
it('asks a check that finished starting with the message built then', async () => {
usePluginStore.setState({ plugins: [guard({ status: 'enabled' })] });
const seen: unknown[] = [];
let built = 0;
const pending = runPluginSendCheck(() => { built++; return { ...email, externalRecipients: ['x@outside.test'] }; });
expect(built).toBe(0);
emailHooks.onBeforeEmailSend.register('send-checks', (m: OutgoingEmail) => { seen.push(m); return false; });
usePluginStore.setState({ plugins: [guard({ status: 'running' })] });
expect(await pending).toBe('vetoed');
expect(built).toBe(1);
expect(seen).toEqual([expect.objectContaining({ externalRecipients: ['x@outside.test'] })]);
});
it('says whether a guard is starting or has stopped working', async () => {
vi.useFakeTimers();
usePluginStore.setState({ plugins: [guard({ status: 'enabled' })] });
const waiting = runPluginSendCheck(() => email);
await vi.advanceTimersByTimeAsync(8000);
expect(await waiting).toBe('guard-loading');
vi.useRealTimers();
usePluginStore.setState({ plugins: [guard({ status: 'error', error: 'Bundle integrity mismatch' })] });
expect(await runPluginSendCheck(() => email)).toBe('guard-failed');
});
});
@@ -181,6 +181,17 @@ describe('frame-ancestors of the mail UI', () => {
else process.env.ALLOWED_FRAME_ANCESTORS = original;
});
// Send checks run the admin's patterns in a worker they can stop; only
// plugin frames get it, the app keeps its script-src fallback.
it('lets plugin frames, and only them, start workers from blobs', async () => {
vi.mocked(configManager.getPolicy).mockReturnValueOnce({ features: { pluginsEnabled: true } } as never);
const frame = await proxy(new NextRequest('http://localhost:3000/plugin-sandbox', {
headers: { 'sec-fetch-dest': 'iframe', 'sec-fetch-mode': 'navigate', 'sec-fetch-site': 'same-origin' },
}));
expect(frame.headers.get('content-security-policy')).toContain("worker-src 'self' blob:");
expect((await load('/en/mail/folder/inbox')).headers.get('content-security-policy')).not.toContain('worker-src');
});
it('allows only the app itself by default', async () => {
delete process.env.ALLOWED_FRAME_ANCESTORS;
const res = await load('/en/mail/folder/inbox');
+11 -1
View File
@@ -44,6 +44,11 @@ export interface ReachableInput {
env: Record<string, string | undefined>;
/** The relay the browser bundle was built with (NEXT_PUBLIC_PUSH_RELAY_URL inlined at build time). */
buildRelayUrl?: string;
/**
* Plugins that run in the mode and the servers the admin entered on their
* admin page (credentials with an originKey; lib/outgoing-hosts.ts).
*/
plugins?: { name: string; origins: string[] }[];
}
const BRANDING_KEYS = [
@@ -51,7 +56,7 @@ const BRANDING_KEYS = [
'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', 'loginLogoDarkUrl',
] as const;
export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_RELAY_BASE_URL }: ReachableInput): ReachableHost[] {
export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_RELAY_BASE_URL, plugins = [] }: ReachableInput): ReachableHost[] {
const out: ReachableHost[] = [];
const seen = new Set<string>();
const add = (kind: string, url: unknown, setIn: string, side: ReachableHost['side'], note?: string) => {
@@ -92,6 +97,11 @@ export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_R
// Company calendars of Everyone and of every scope: this server fetches
// them (app/api/fetch-ical by feed id), whatever the switch says.
for (const feed of allFeeds(policy)) add('Company calendar', feed.url, `Organisation > Calendars (${feed.name})`, 'server');
// A plugin's server the admin entered (a company link check): this server
// proxies its requests (app/api/plugin-proxy).
for (const plugin of plugins) {
for (const origin of plugin.origins) add(`Plugin ${plugin.name}`, origin, "The plugin's admin page", 'server');
}
for (const app of policy.defaultSidebarApps ?? []) add('Sidebar app', app?.url, `User policy > Sidebar apps (${app?.name ?? ''})`, 'browser');
add('Document editor (WOPI)', get('wopiClientUrl'), 'WOPI_CLIENT_URL', 'both');
add('Translation (LibreTranslate)', env.LIBRETRANSLATE_URL, 'LIBRETRANSLATE_URL', 'server');
+39 -1
View File
@@ -80,10 +80,43 @@ export function avatarUrlAllowed(url: string | null | undefined, origin?: string
}
}
/** The parts of a manifest credential (lib/plugin-secrets/credentials.ts) the mode looks at. */
export interface ModeCredential {
scope?: string;
origin?: string;
originKey?: string;
}
export interface NetworkDeclaringPlugin {
permissions?: readonly string[];
httpOrigins?: readonly string[];
frameOrigins?: readonly string[];
credentials?: Readonly<Record<string, ModeCredential>>;
}
/**
* A proxied credential stays usable in the no-third-party mode when the
* admin entered its server on the plugin's admin page (`originKey`): a host
* the deployment configured itself, like the JMAP server. A server fixed in
* the manifest (Google Safe Browsing) and the accounts users connect are
* outside hosts and stay off.
*/
export function credentialAllowedInMode(spec: ModeCredential | undefined): boolean {
return !!spec && spec.scope === 'admin' && typeof spec.originKey === 'string' && !spec.origin;
}
/**
* A plugin whose only network access is the credential proxy with admin
* credentials, at least one of them on a server the admin entered. It runs
* in the mode; the proxy refuses its other credentials there.
*/
export function pluginReachesOnlyAdminHosts(plugin: NetworkDeclaringPlugin): boolean {
const permissions = plugin.permissions ?? [];
if (permissions.includes('http:fetch') || (plugin.httpOrigins?.length ?? 0) > 0 || (plugin.frameOrigins?.length ?? 0) > 0) return false;
const specs = Object.values(plugin.credentials ?? {});
return specs.length > 0
&& specs.every((spec) => spec?.scope === 'admin')
&& specs.some(credentialAllowedInMode);
}
/**
@@ -102,7 +135,12 @@ export function pluginDeclaresNetwork(plugin: NetworkDeclaringPlugin): boolean {
/** Error code a refused plugin carries (shown translated in Settings > Plugins). */
export const PLUGIN_REFUSED_ERROR = 'third-party-off';
/** Whether a plugin may run while the mode is on (see pluginReachesOnlyAdminHosts). */
export function pluginAllowedInMode(plugin: NetworkDeclaringPlugin): boolean {
return !pluginDeclaresNetwork(plugin) || pluginReachesOnlyAdminHosts(plugin);
}
/** Whether a plugin may run in this browser. */
export function pluginAllowed(plugin: NetworkDeclaringPlugin): boolean {
return !(noThirdPartyTraffic() && pluginDeclaresNetwork(plugin));
return !noThirdPartyTraffic() || pluginAllowedInMode(plugin);
}
+51 -1
View File
@@ -2,6 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { prepareQuickReply, type QuickReplyQuestion, type QuickReplyQuestions } from '@/lib/outgoing/quick-reply';
import { registerOutgoingFinalizer, registerPreSendCheck, resetSendPipeline } from '@/lib/outgoing/send-pipeline';
import type { Identity } from '@/lib/jmap/types';
import { emailHooks, pluginErrorTracker } from '@/lib/plugin-hooks';
import type { OutgoingEmail } from '@/lib/plugin-types';
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
@@ -14,7 +16,11 @@ const input = (signatureIdentity: Identity) => ({
body: 'Thanks <3', signatureIdentity, separator: true, to: ['bob@example.com'], fromEmail: 'me@example.com', identityId: 'i',
});
afterEach(() => resetSendPipeline());
afterEach(() => {
resetSendPipeline();
emailHooks.onBeforeEmailSend.clear();
pluginErrorTracker.resetAll();
});
describe('prepareQuickReply', () => {
it('builds the text and, for an HTML signature, an escaped HTML body', async () => {
@@ -107,3 +113,47 @@ describe('prepareQuickReply warnings', () => {
expect(asked).toEqual([]);
});
});
// Plugins' send checks (onBeforeEmailSend) ran for the composer and a
// plugin's forward, never for a quick reply: a way around a send guard.
describe('prepareQuickReply and plugin send checks', () => {
it('asks the plugins with the reply and stops when one says no', async () => {
const seen: OutgoingEmail[] = [];
emailHooks.onBeforeEmailSend.register('guard', (email: OutgoingEmail) => { seen.push(email); return false; });
const stopped: string[] = [];
const out = await prepareQuickReply({
...input(text), body: 'IBAN DE89370400440532013000', subject: 'Re: Invoice', inReplyTo: '<m1@example.com>',
onSendCheckStopped: (outcome) => stopped.push(outcome),
});
expect(out).toBeNull();
expect(stopped).toEqual(['vetoed']);
expect(seen).toEqual([expect.objectContaining({
surface: 'quick-reply', mode: 'reply', to: ['bob@example.com'], subject: 'Re: Invoice',
inReplyTo: '<m1@example.com>', identityId: 'i', fromEmail: 'me@example.com',
})]);
expect(seen[0].textBody).toContain('IBAN DE89370400440532013000');
});
it('hands over the typed text and the recipients outside the organisation', async () => {
const seen: OutgoingEmail[] = [];
emailHooks.onBeforeEmailSend.register('guard', (email: OutgoingEmail) => { seen.push(email); return undefined; });
expect(await prepareQuickReply({ ...input(text), body: 'Card 4242 4242 4242 4242', to: ['bob@example.com', 'Eve@Other.test'] })).not.toBeNull();
expect(seen[0].authoredText).toBe('Card 4242 4242 4242 4242');
expect(seen[0].externalRecipients).toEqual(['eve@other.test']);
});
it('fails closed when a check crashes', async () => {
emailHooks.onBeforeEmailSend.register('guard', () => { throw new Error('boom'); });
const stopped: string[] = [];
expect(await prepareQuickReply({ ...input(text), onSendCheckStopped: (o) => stopped.push(o) })).toBeNull();
expect(stopped).toEqual(['failed']);
});
it('sends when the plugins allow it, with the disclaimer stage after them', async () => {
const order: string[] = [];
emailHooks.onBeforeEmailSend.register('guard', () => { order.push('plugin'); return undefined; });
registerOutgoingFinalizer('disclaimer', 10, (bodies) => { order.push('last'); return bodies; }, 'last');
expect(await prepareQuickReply(input(text))).not.toBeNull();
expect(order).toEqual(['plugin', 'last']);
});
});
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { externalRecipientsFor, inspectAttachments, sniffFile, TEXT_EXCERPT_BYTES } from '../send-check-payload';
import { isTextAttachment, sniffType, SNIFF_BYTES } from '../attachment-sniff';
import type { OutgoingContext } from '../send-pipeline';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import type { Identity } from '@/lib/jmap/types';
import { hookArgsFor } from '@/lib/plugin-sandbox/hook-payload';
import type { InstalledPlugin, OutgoingEmail } from '@/lib/plugin-types';
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
const bytes = (...parts: (number[] | string)[]) => new Uint8Array(parts.flatMap((p) => (typeof p === 'string' ? Array.from(new TextEncoder().encode(p)) : p)));
describe('sniffType', () => {
it('names programs and archives by their first bytes, whatever the name says', () => {
expect(sniffType(bytes('MZ', [0x90, 0, 3]))).toBe('application/x-msdownload');
expect(sniffType(bytes([0x7f], 'ELF', [2, 1]))).toBe('application/x-executable');
expect(sniffType(bytes('PK', [3, 4, 20, 0]))).toBe('application/zip');
expect(sniffType(bytes([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1, 0]))).toBe('application/x-ole-storage');
expect(sniffType(bytes('%PDF-1.7\n'))).toBe('application/pdf');
expect(sniffType(bytes([0x89], 'PNG', [0x0d, 0x0a, 0x1a, 0x0a]))).toBe('image/png');
expect(sniffType(bytes('RIFF', [0, 0, 0, 0], 'WEBPVP8 '))).toBe('image/webp');
expect(sniffType(bytes('#!/bin/sh\nrm -rf /\n'))).toBe('text/x-shellscript');
});
it('takes a text file that starts with "MZ" for text, not a program', () => {
expect(sniffType(bytes('MZ Steuerberatung, Rechnung 2026\n'))).toBe('text/plain');
});
it('tells Java and Android apps from other ZIP files', () => {
expect(sniffType(bytes('PK', [3, 4, 20, 0], '....META-INF/MANIFEST.MF'))).toBe('application/java-archive');
expect(sniffType(bytes('PK', [3, 4, 20, 0], '....AndroidManifest.xml'))).toBe('application/vnd.android.package-archive');
expect(sniffType(bytes('PK', [3, 4, 20, 0], '....[Content_Types].xml'))).toBe('application/zip');
// An OpenDocument file has a META-INF/manifest.xml, not MANIFEST.MF.
expect(sniffType(bytes('PK', [3, 4, 20, 0], 'mimetypeapplication/vnd.oasis.opendocument.textMETA-INF/manifest.xml'))).toBe('application/zip');
});
it('tells HTML and plain text from binary data', () => {
expect(sniffType(bytes('\uFEFF <!DOCTYPE html><html>'))).toBe('text/html');
expect(sniffType(bytes('IBAN;Name\nDE89370400440532013000;Alice\n'))).toBe('text/plain');
// A character cut off at the end of the head is still text.
expect(sniffType(bytes('Grüße', [0xc3]))).toBe('text/plain');
expect(sniffType(bytes('abc', [0], 'def'))).toBeUndefined();
expect(sniffType(bytes([0xff, 0xfe, 0x41]))).toBeUndefined();
expect(sniffType(new Uint8Array())).toBeUndefined();
});
it('knows text attachments by type, else by extension', () => {
expect(isTextAttachment('a.bin', 'text/csv')).toBe(true);
expect(isTextAttachment('export.csv', 'application/octet-stream')).toBe(true);
expect(isTextAttachment('data.json', '')).toBe(true);
expect(isTextAttachment('photo.jpg', 'image/jpeg')).toBe(false);
});
});
describe('inspectAttachments', () => {
it('reads a local text file: its type and the text', async () => {
const file = new Blob(['IBAN DE89 3704 0044 0532 0130 00']);
const [out] = await inspectAttachments([{ name: 'list.csv', type: 'text/csv', size: file.size, blobId: 'b1', file }]);
expect(out).toEqual({
name: 'list.csv', type: 'text/csv', size: file.size, blobId: 'b1',
sniffedType: 'text/plain', textExcerpt: 'IBAN DE89 3704 0044 0532 0130 00',
});
});
it('reads a server blob: a megabyte of a text file, only the head of anything else', async () => {
const asked: number[] = [];
const load = (content: string) => async (n: number) => { asked.push(n); return new TextEncoder().encode(content).buffer as ArrayBuffer; };
const out = await inspectAttachments([
{ name: 'notes.txt', type: 'text/plain', size: 10, blobId: 'b1', load: load('hello') },
{ name: 'invoice.pdf', type: 'application/pdf', size: 9_000_000, blobId: 'b2', load: load('MZ\u0090\u0000\u0003') },
]);
expect(asked).toEqual([TEXT_EXCERPT_BYTES, SNIFF_BYTES]);
expect(out[0]).toMatchObject({ sniffedType: 'text/plain', textExcerpt: 'hello' });
// A renamed program: the name and type say PDF, the bytes don't.
expect(out[1]).toEqual({ name: 'invoice.pdf', type: 'application/pdf', size: 9_000_000, blobId: 'b2', sniffedType: 'application/x-msdownload' });
});
it("doesn't read a program named .txt as text, nor inline images", async () => {
const out = await inspectAttachments([
{ name: 'readme.txt', type: 'text/plain', size: 3, file: new Blob([bytes('MZ', [0x90, 0, 3])]) },
{ name: 'pasted.png', type: 'image/png', size: 8, blobId: 'b3', inline: true, file: new Blob([bytes([0x89], 'PNG', [0x0d, 0x0a, 0x1a, 0x0a])]) },
]);
expect(out[0]).toEqual({ name: 'readme.txt', type: 'text/plain', size: 3, sniffedType: 'application/x-msdownload' });
expect(out[1]).toEqual({ name: 'pasted.png', type: 'image/png', size: 8, blobId: 'b3', inline: true, sniffedType: 'image/png' });
});
it('marks a file it cannot read, so a check does not take it for a clean one', async () => {
const [out, none] = await inspectAttachments([
{ name: 'a.txt', type: '', size: 1, blobId: 'b', load: async () => { throw new Error('offline'); } },
{ name: 'b.bin', type: '', size: 1, blobId: 'c' },
]);
expect(out).toEqual({ name: 'a.txt', type: 'application/octet-stream', size: 1, blobId: 'b', unreadable: true });
expect(none.unreadable).toBe(true);
});
it('sniffs a file before upload, and says when it cannot', async () => {
expect(await sniffFile(new Blob([bytes('MZ', [0x90, 0, 3])]))).toEqual({ sniffedType: 'application/x-msdownload' });
const broken = { slice: () => ({ arrayBuffer: async () => { throw new Error('gone'); } }) } as unknown as Blob;
expect(await sniffFile(broken)).toEqual({ unreadable: true });
});
});
describe('externalRecipientsFor', () => {
const context = (to: string[], bcc: string[] = []): OutgoingContext => ({
surface: 'composer', to, cc: [], bcc, fromEmail: 'alice@example.org', identityId: 'i1', mode: 'compose', plainText: false,
sender: { name: 'Alice', email: 'alice@example.org' },
});
beforeEach(() => {
useAuthStore.setState({ client: null, identities: [{ id: 'i1', name: 'Alice', email: 'alice@example.org' } as unknown as Identity] });
});
it("lists recipients outside the organisation's domains also while the admin's warning is off", async () => {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: {
...DEFAULT_ORGANISATION_POLICY,
externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, recipientWarning: false, domains: ['partner.example'] },
},
},
});
expect(await externalRecipientsFor(context(['bob@example.org', 'Carol@Partner.Example', 'eve@other.test'], ['x@sub.example.org', 'Mallory@Evil.test'])))
.toEqual(['eve@other.test', 'mallory@evil.test']);
expect(await externalRecipientsFor(context(['bob@example.org']))).toEqual([]);
});
});
describe('hookArgsFor', () => {
const email = {
to: [], cc: [], bcc: [], subject: '', htmlBody: '', textBody: '', identityId: '',
attachments: [{ name: 'a.txt', type: 'text/plain', size: 1, textExcerpt: 'secret' }],
} as OutgoingEmail;
const plugin = (permissions: string[]) => ({ id: 'p', permissions, managed: true } as unknown as InstalledPlugin);
it('gives the text of attachments only to a plugin that may read files', () => {
expect((hookArgsFor(plugin(['email:send']), 'onBeforeEmailSend', [email])[0] as OutgoingEmail).attachments[0]).toEqual({ name: 'a.txt', type: 'text/plain', size: 1 });
expect((hookArgsFor(plugin(['email:send', 'email:blob-read']), 'onBeforeEmailSend', [email])[0] as OutgoingEmail).attachments[0].textExcerpt).toBe('secret');
expect(email.attachments[0].textExcerpt).toBe('secret');
});
});
+110
View File
@@ -0,0 +1,110 @@
/**
* What a file really is, read from its first bytes: a renamed program
* (invoice.pdf that starts with "MZ") is still a program. Used for the
* attachment information plugins get before an upload and before a send
* (AttachmentInfo.sniffedType, OutgoingEmail.attachments[].sniffedType).
*
* Only well-known signatures; anything else is undefined, never a guess
* from the name.
*/
/** Bytes a sniff needs; read at most this much for a binary file. */
export const SNIFF_BYTES = 4096;
const startsWith = (bytes: Uint8Array, sig: readonly number[], offset = 0): boolean =>
bytes.length >= offset + sig.length && sig.every((b, i) => bytes[offset + i] === b);
const ascii = (text: string): number[] => Array.from(text, (c) => c.charCodeAt(0));
const SIGNATURES: readonly { sig: readonly number[]; offset?: number; type: string }[] = [
{ sig: [0x7f, 0x45, 0x4c, 0x46], type: 'application/x-executable' },
{ sig: [0xcf, 0xfa, 0xed, 0xfe], type: 'application/x-mach-binary' },
{ sig: [0xce, 0xfa, 0xed, 0xfe], type: 'application/x-mach-binary' },
{ sig: [0xfe, 0xed, 0xfa, 0xcf], type: 'application/x-mach-binary' },
{ sig: [0xfe, 0xed, 0xfa, 0xce], type: 'application/x-mach-binary' },
{ sig: [0xca, 0xfe, 0xba, 0xbe], type: 'application/x-mach-binary' },
{ sig: [0x4c, 0x00, 0x00, 0x00, 0x01, 0x14, 0x02, 0x00], type: 'application/x-ms-shortcut' },
{ sig: ascii('MSCF'), type: 'application/vnd.ms-cab-compressed' },
{ sig: [0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1], type: 'application/x-ole-storage' },
{ sig: [0x50, 0x4b, 0x03, 0x04], type: 'application/zip' },
{ sig: [0x50, 0x4b, 0x05, 0x06], type: 'application/zip' },
{ sig: [0x50, 0x4b, 0x07, 0x08], type: 'application/zip' },
{ sig: ascii('Rar!\x1a\x07'), type: 'application/x-rar-compressed' },
{ sig: [0x37, 0x7a, 0xbc, 0xaf, 0x27, 0x1c], type: 'application/x-7z-compressed' },
{ sig: [0x1f, 0x8b], type: 'application/gzip' },
{ sig: ascii('%PDF-'), type: 'application/pdf' },
{ sig: ascii('{\\rtf'), type: 'application/rtf' },
{ sig: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], type: 'image/png' },
{ sig: [0xff, 0xd8, 0xff], type: 'image/jpeg' },
{ sig: ascii('GIF87a'), type: 'image/gif' },
{ sig: ascii('GIF89a'), type: 'image/gif' },
{ sig: ascii('#!'), type: 'text/x-shellscript' },
];
/** Whether `needle` occurs in the bytes. */
function contains(bytes: Uint8Array, needle: string): boolean {
const sig = ascii(needle);
outer: for (let i = 0; i + sig.length <= bytes.length; i++) {
for (let j = 0; j < sig.length; j++) if (bytes[i + j] !== sig[j]) continue outer;
return true;
}
return false;
}
/** The MIME type the bytes start with, or undefined when none is known. */
export function sniffType(bytes: Uint8Array): string | undefined {
// "MZ" starts every Windows and DOS program, whose header holds zero
// bytes; a text file that happens to start with "MZ" has none.
if (startsWith(bytes, ascii('MZ')) && bytes.subarray(0, SNIFF_BYTES).includes(0)) return 'application/x-msdownload';
for (const { sig, offset, type } of SIGNATURES) {
if (!startsWith(bytes, sig, offset)) continue;
// Java and Android apps are ZIP files; their entry names give them away.
if (type === 'application/zip') {
if (contains(bytes, 'AndroidManifest.xml')) return 'application/vnd.android.package-archive';
if (contains(bytes, 'META-INF/MANIFEST.MF')) return 'application/java-archive';
}
return type;
}
if (startsWith(bytes, ascii('RIFF')) && startsWith(bytes, ascii('WEBP'), 8)) return 'image/webp';
return sniffText(bytes);
}
/** text/html or text/plain for text without NUL bytes that decodes as UTF-8. */
function sniffText(bytes: Uint8Array): string | undefined {
if (bytes.length === 0) return undefined;
const head = bytes.subarray(0, Math.min(bytes.length, SNIFF_BYTES));
if (head.includes(0)) return undefined;
let text: string;
try {
// A multi-byte character cut at the end of the head is fine.
text = new TextDecoder('utf-8', { fatal: true }).decode(head.subarray(0, trimPartialUtf8(head)));
} catch {
return undefined;
}
const start = text.replace(/^\uFEFF/, '').trimStart().slice(0, 64).toLowerCase();
if (start.startsWith('<!doctype html') || start.startsWith('<html')) return 'text/html';
return 'text/plain';
}
/** Length of `bytes` without a UTF-8 sequence cut off at its end. */
function trimPartialUtf8(bytes: Uint8Array): number {
let end = bytes.length;
for (let back = 1; back <= 3 && end - back >= 0; back++) {
const b = bytes[end - back];
if ((b & 0xc0) === 0x80) continue; // continuation byte
const need = b >= 0xf0 ? 4 : b >= 0xe0 ? 3 : b >= 0xc0 ? 2 : 1;
return need > back ? end - back : end;
}
return end;
}
const TEXT_EXTENSIONS = new Set(['txt', 'csv', 'tsv', 'json', 'xml', 'ics', 'vcf', 'md', 'log', 'htm', 'html', 'yaml', 'yml', 'ini', 'conf']);
const TEXT_TYPES = new Set(['application/json', 'application/xml', 'application/csv', 'application/x-yaml', 'application/yaml']);
/** Whether a file is text a send check may read: by type, else by extension. */
export function isTextAttachment(name: string, type: string): boolean {
const t = (type || '').toLowerCase().split(';')[0].trim();
if (t.startsWith('text/') || TEXT_TYPES.has(t)) return true;
const ext = name.toLowerCase().match(/\.([a-z0-9]+)$/)?.[1];
return !!ext && TEXT_EXTENSIONS.has(ext);
}
+65
View File
@@ -0,0 +1,65 @@
/**
* Plugins' say before a user's mail goes out (onBeforeEmailSend), the same
* for every surface that sends it: the composer (inline, pop-out, Pro tab),
* quick reply and a plugin's forward (api.email.forward). The bus fails
* closed: a handler that throws or runs out of time stops the send.
*
* Not run for mail Bulwark sends on its own (calendar invitations and RSVP
* replies, read receipts, List-Unsubscribe mails, phishing reports) nor for
* a crypto plugin's raw send, which happens after the composer's check.
*/
import { emailHooks } from '@/lib/plugin-hooks';
import type { OutgoingEmail } from '@/lib/plugin-types';
import { awaitSendGuards } from '@/lib/plugin-send-guards';
import { debug } from '@/lib/debug';
/**
* allowed: send. vetoed: a plugin said no and told the user. failed: a
* handler threw or ran out of time. guard-loading / guard-failed: a send
* check the admin requires is still starting, or failed to start or was
* turned off after errors (lib/plugin-send-guards.ts). Nobody told the user
* about the last three yet.
*/
export type PluginSendCheckOutcome = 'allowed' | 'vetoed' | 'failed' | 'guard-loading' | 'guard-failed';
/**
* Waits for the send checks the admin requires, then asks the plugins.
* `build` makes the message they get; it runs only after that wait and only
* while a plugin listens, so a check that was still starting at the click
* gets the whole message (external recipients, attachment types), never the
* cut-down one built for nobody.
*/
export async function runPluginSendCheck(build: () => OutgoingEmail | Promise<OutgoingEmail>): Promise<PluginSendCheckOutcome> {
const down = await awaitSendGuards();
if (down.length > 0) {
debug.warn('email', `Send held: required send checks not running: ${down.map((g) => `${g.id} (${g.state})`).join(', ')}`);
return down.some((g) => g.state === 'failed') ? 'guard-failed' : 'guard-loading';
}
if (emailHooks.onBeforeEmailSend.size === 0) return 'allowed';
return emailHooks.onBeforeEmailSend.interceptOutcome(await build());
}
/** The message key in the `send_guards` namespace for a held send, or null. */
export function sendGuardMessageKey(outcome: PluginSendCheckOutcome): 'not_running' | 'stopped_working' | null {
if (outcome === 'guard-loading') return 'not_running';
if (outcome === 'guard-failed') return 'stopped_working';
return null;
}
/** Bare lowercase addresses of a recipient list ("Name <a@b>" or "a@b"). */
function addressSet(list: readonly string[]): string {
return list
.map((entry) => (/<([^>]+)>\s*$/.exec(entry)?.[1] ?? entry).trim().toLowerCase())
.filter(Boolean)
.sort()
.join(',');
}
/** Whether a plugin's onTransformOutgoingEmail changed who the message goes to. */
export function recipientsChanged(
before: Pick<OutgoingEmail, 'to' | 'cc' | 'bcc'>,
after: Pick<OutgoingEmail, 'to' | 'cc' | 'bcc'>,
): boolean {
return addressSet([...before.to, ...before.cc, ...before.bcc]) !== addressSet([...(after.to ?? []), ...(after.cc ?? []), ...(after.bcc ?? [])]);
}
+34 -3
View File
@@ -3,6 +3,8 @@ import { appendHtmlSignature, appendPlainTextSignature } from '@/lib/signature-u
import { currentSignatureIdentity } from '@/lib/org/signature-state';
import { findUnfilledPlaceholders, type PlaceholderWords } from '@/lib/placeholder-check';
import { finalizeOutgoing, runPreSendChecks, type OutgoingBodies } from './send-pipeline';
import { runPluginSendCheck, type PluginSendCheckOutcome } from './plugin-send-check';
import { externalRecipientsFor } from './send-check-payload';
// Registers the organisation signature check and the disclaimer.
import '@/lib/org/outgoing';
@@ -29,7 +31,10 @@ export interface QuickReplyQuestions {
* checks and finalisers as the composer's send. Null when one stopped the
* send. The organisation signature applies as in the composer
* (`signatureIdentity` is what the reply would sign with on its own,
* `sendingIdentity` the identity it goes out from).
* `sendingIdentity` the identity it goes out from). Plugins' send checks
* (onBeforeEmailSend) run on it too, after the organisation signature and
* before the disclaimer, as in the composer; `onSendCheckStopped` hears
* why one stopped it.
*/
export async function prepareQuickReply(input: {
body: string;
@@ -39,7 +44,11 @@ export async function prepareQuickReply(input: {
to: string[];
fromEmail?: string;
identityId?: string;
/** The reply's subject (without a classification prefix) and the original's Message-ID, for plugins. */
subject?: string;
inReplyTo?: string;
questions?: QuickReplyQuestions;
onSendCheckStopped?: (outcome: Exclude<PluginSendCheckOutcome, 'allowed'>) => void;
}): Promise<OutgoingBodies | null> {
const { body, separator, questions } = input;
@@ -88,6 +97,28 @@ export async function prepareQuickReply(input: {
if (!(await runPreSendChecks(context))) return null;
// Asked last, as in the composer, so an answered warning doesn't ask twice.
if (questions?.confirmSend && !(await questions.ask({ kind: 'send' }))) return null;
// Quick reply has no plugin step in between: both stages at once.
return finalizeOutgoing(finalizeOutgoing({ html, text }, context), context, 'last');
const bodies = finalizeOutgoing({ html, text }, context);
// A quick reply used to skip plugins' send checks: a way around them.
const outcome = await runPluginSendCheck(async () => ({
to: input.to,
cc: [],
bcc: [],
subject: input.subject ?? '',
htmlBody: bodies.html ?? '',
textBody: bodies.text,
identityId: input.identityId ?? '',
fromEmail: input.fromEmail,
attachments: [],
inReplyTo: input.inReplyTo,
surface: 'quick-reply',
mode: 'reply',
// The quote isn't part of a quick reply: the typed text is all the user wrote.
authoredText: body,
externalRecipients: await externalRecipientsFor(context),
}));
if (outcome !== 'allowed') {
input.onSendCheckStopped?.(outcome);
return null;
}
return finalizeOutgoing(bodies, context, 'last');
}
+104
View File
@@ -0,0 +1,104 @@
/**
* What plugins' send checks (onBeforeEmailSend) get beyond the message
* itself, so a rule such as "warn about an IBAN, only when the mail leaves
* the organisation" works without exposing the policy:
*
* - `externalRecipients`: the recipients outside the organisation of the
* account the mail goes out from (its identity domains plus the admin's
* organisation domains), filled whether or not the admin's recipient
* warning is on;
* - per attachment the blobId, whether it is inline, the type its first
* bytes say (sniffedType) and, for text files, the first megabyte as text
* (textExcerpt; only plugins with `email:blob-read` see it, see
* lib/plugin-sandbox/hook-payload.ts).
*
* Attachments are read only while a plugin listens, and a file that can't
* be read is passed on without the two fields.
*/
import type { OutgoingAttachment } from '@/lib/plugin-types';
import { addressDomain, addressKey, contextInternalDomains, externalMailContextFor, externalRecipients } from '@/lib/external-mail';
import { debug } from '@/lib/debug';
import { isTextAttachment, sniffType, SNIFF_BYTES } from './attachment-sniff';
import type { OutgoingContext } from './send-pipeline';
/** Most of a text attachment a plugin gets as text. */
export const TEXT_EXCERPT_BYTES = 1024 * 1024;
/** The recipients outside the sending account's organisation, as bare lowercase addresses. */
export async function externalRecipientsFor(context: OutgoingContext): Promise<string[]> {
const account = await externalMailContextFor(context.sender?.accountId);
const internal = contextInternalDomains(account);
// As the recipient warning: the identity's own domain is internal, a From
// typed over it in the composer is not.
const identityEmail = context.sender?.email ?? (context.surface === 'composer' ? undefined : context.fromEmail);
const identityDomain = addressDomain(identityEmail);
if (identityDomain && !internal.includes(identityDomain)) internal.push(identityDomain);
return externalRecipients(context, internal).map((r) => addressKey(r.address));
}
export interface SendCheckAttachmentSource {
name: string;
type: string;
size: number;
blobId?: string;
inline?: boolean;
/** The bytes, when the browser still has them (a file the user picked). */
file?: Blob;
/** Reads up to `bytes` bytes of the blob from the server otherwise. */
load?: (bytes: number) => Promise<ArrayBuffer>;
}
/** The first `bytes` bytes, or null when they couldn't be read (or there is nothing to read them from). */
async function headOf(source: SendCheckAttachmentSource, bytes: number): Promise<Uint8Array | null> {
try {
if (source.file) return new Uint8Array(await source.file.slice(0, bytes).arrayBuffer());
if (source.load) return new Uint8Array((await source.load(bytes)).slice(0, bytes));
} catch (error) {
debug.warn('email', `Could not read ${source.name} for the send checks:`, error);
}
return null;
}
function decodeExcerpt(bytes: Uint8Array): string {
return new TextDecoder('utf-8').decode(bytes).replace(/\uFFFD+$/, '');
}
/** The attachment list for onBeforeEmailSend, with sniffedType and text excerpts. */
export async function inspectAttachments(sources: readonly SendCheckAttachmentSource[]): Promise<OutgoingAttachment[]> {
const out: OutgoingAttachment[] = [];
// One at a time: a send rarely has many, and downloads share the
// server's request slots with everything else.
for (const source of sources) {
const entry: OutgoingAttachment = {
name: source.name,
type: source.type || 'application/octet-stream',
size: source.size,
...(source.blobId ? { blobId: source.blobId } : {}),
...(source.inline ? { inline: true } : {}),
};
const text = !source.inline && isTextAttachment(source.name, source.type);
const head = await headOf(source, text ? TEXT_EXCERPT_BYTES : SNIFF_BYTES);
if (head) {
const sniffed = sniffType(head);
if (sniffed) entry.sniffedType = sniffed;
// A "text" file whose bytes say it is a program is not read as text.
if (text && (!sniffed || sniffed.startsWith('text/'))) entry.textExcerpt = decodeExcerpt(head);
} else {
// Said out loud, so a check doesn't take it for a clean file.
entry.unreadable = true;
}
out.push(entry);
}
return out;
}
/** sniffedType of a file about to be attached (onBeforeAttachmentUpload), or unreadable. */
export async function sniffFile(file: Blob): Promise<{ sniffedType?: string; unreadable?: true }> {
try {
const sniffedType = sniffType(new Uint8Array(await file.slice(0, SNIFF_BYTES).arrayBuffer()));
return sniffedType ? { sniffedType } : {};
} catch {
return { unreadable: true };
}
}
+1 -1
View File
@@ -57,7 +57,7 @@ export const HOOK_CATALOG = {
onBeforeReply: H('mail-read', 'intercept', 'email:read', 'ReplyContext', 'when Reply is chosen, before the composer opens'),
onBeforeReplyAll: H('mail-read', 'intercept', 'email:read', 'ReplyContext', 'when Reply all is chosen, before the composer opens'),
onBeforeForward: H('mail-read', 'intercept', 'email:read', 'ReplyContext', 'when Forward is chosen, before the composer opens'),
onBeforeExternalLink: H('mail-read', 'intercept', 'ui:observe', 'ExternalLinkContext', 'when a link in a message is clicked; return false to stop it or { href } to change it'),
onBeforeExternalLink: H('mail-read', 'intercept', 'ui:observe', 'ExternalLinkContext', 'when a link is opened (mail, reader view, events, contacts, notifications, the unsubscribe link, links other plugins open); return false to stop it or { href } to change it'),
onAvatarResolve: H('mail-read', 'transform', 'email:read', 'string | null (avatar URL), { email, name }', 'when a sender avatar is needed'),
// ─── Mail: composing ───────────────────────────────────────
+82 -8
View File
@@ -45,14 +45,88 @@ export function acceptLinkRewrite<T extends { href: string }>(result: unknown, c
}
/**
* Ask onBeforeExternalLink about a click on a link in a message. Resolves to
* the address to open: the original, the one a plugin rewrote it to, or null
* when a plugin stopped it.
* A web address the way the browser will open it (`new URL(href).href`):
* a link check has to judge the host the browser really goes to, and
* `http://evil.example\@good.example/` goes to evil.example. Other schemes
* come back unchanged.
*/
export async function resolveExternalLink(ctx: ExternalLinkContext): Promise<string | null> {
const outcome = uiHooks.onBeforeExternalLink.size > 0
? await uiHooks.onBeforeExternalLink.interceptWithValue(ctx, acceptLinkRewrite)
: { allowed: true as const, value: ctx };
export function normaliseLinkHref(href: string): string {
const trimmed = href.trim();
if (!/^https?:/i.test(trimmed)) return trimmed;
try {
return new URL(trimmed).href;
} catch {
return trimmed;
}
}
/** Whether a link check would be asked about a link (some plugin listens, other than `skipPluginId`). */
export function linkCheckActive(skipPluginId?: string): boolean {
return uiHooks.onBeforeExternalLink.hasHandlersBesides(skipPluginId);
}
/**
* Ask onBeforeExternalLink about a link the user opens. Plugins get the
* address as the browser parses it (normaliseLinkHref), and that same
* string is what opens. Resolves to the address to open: that one, the one
* a plugin rewrote it to, or null when a plugin stopped it.
* `skipPluginId`: a plugin opening a link itself (ui.openExternalUrl) isn't
* asked about it again, so a link check can open the link after its own
* dialog.
*/
export async function resolveExternalLink(ctx: ExternalLinkContext, opts: { skipPluginId?: string } = {}): Promise<string | null> {
const normalised = { ...ctx, href: normaliseLinkHref(ctx.href) };
const outcome = linkCheckActive(opts.skipPluginId)
? await uiHooks.onBeforeExternalLink.interceptWithValue(normalised, acceptLinkRewrite, { skipPluginId: opts.skipPluginId })
: { allowed: true as const, value: normalised };
if (!outcome.allowed || !isOpenableLinkHref(outcome.value.href)) return null;
return outcome.value.href;
return normaliseLinkHref(outcome.value.href);
}
/** Longest link text handed to plugins. */
const MAX_LINK_TEXT = 200;
/** What a link says, for ExternalLinkContext.text: its text, else an image's alt text. */
export function linkText(link: Element): string | undefined {
const own = (link.textContent ?? '').replace(/\s+/g, ' ').trim();
const text = own || (link.querySelector('img[alt]')?.getAttribute('alt') ?? '').replace(/\s+/g, ' ').trim();
return text ? text.slice(0, MAX_LINK_TEXT) : undefined;
}
/**
* Open an outside link the way every link in the app opens: through
* onBeforeExternalLink, then in a new tab. Resolves true when it opened.
* With no link check listening it opens right away, inside the click.
*/
export async function openExternalLink(ctx: ExternalLinkContext, opts: { skipPluginId?: string } = {}): Promise<boolean> {
if (!isOpenableLinkHref(ctx.href)) return false;
if (!linkCheckActive(opts.skipPluginId)) {
window.open(normaliseLinkHref(ctx.href), '_blank', 'noopener,noreferrer');
return true;
}
const target = await resolveExternalLink(ctx, opts);
if (!target) return false;
window.open(target, '_blank', 'noopener,noreferrer');
return true;
}
/**
* A click or middle-click on a web link inside `event.currentTarget`
* (rendered text with links, an anchor of our own): while a link check
* listens, the browser's own navigation is stopped and the link goes
* through openExternalLink. Without one the browser handles the click as it
* always did. Other links (mailto:, tel:, in-app paths) and other buttons
* are left alone. "Copy link address" and the browser's context menu stay
* out of reach.
*/
export function gateLinkClick(event: { target: EventTarget | null; button: number; type: string; preventDefault(): void; stopPropagation(): void }, extra: Partial<ExternalLinkContext> = {}): void {
if (!linkCheckActive()) return;
if (event.type === 'auxclick' ? event.button !== 1 : event.button !== 0) return;
const link = (event.target as Element | null)?.closest?.('a[href]') as HTMLAnchorElement | null;
if (!link) return;
const href = link.getAttribute('href') ?? '';
if (!/^https?:/i.test(href.trim())) return;
event.preventDefault();
event.stopPropagation();
void openExternalLink({ href: href.trim(), target: link.getAttribute('target') ?? '_blank', text: linkText(link), ...extra });
}
+14 -6
View File
@@ -197,6 +197,11 @@ export class HookBus<T extends (...args: any[]) => any> {
return this.handlers.length;
}
/** Whether a plugin other than `pluginId` has a handler here. */
hasHandlersBesides(pluginId: string | undefined): boolean {
return this.handlers.some((h) => h.pluginId !== pluginId);
}
/** Fire all handlers (observer pattern - no return values used) */
async emit(...args: Parameters<T>): Promise<void> {
for (const { pluginId, handler } of this.handlers) {
@@ -285,16 +290,18 @@ export class HookBus<T extends (...args: any[]) => any> {
* with the intercept budget; `accept` turns a return value into the next
* value, or returns undefined to ignore it. A handler that throws or runs
* out of time is skipped (fail open), and counts as a failure like any
* other hook error.
* other hook error. `skipPluginId` leaves one plugin's handlers out (a
* plugin opening a link itself isn't asked about it again).
*/
async interceptWithValue<V>(
initial: V,
accept: (result: unknown, current: V) => V | undefined,
opts: { timeoutMs?: number } = {},
opts: { timeoutMs?: number; skipPluginId?: string } = {},
): Promise<{ allowed: false } | { allowed: true; value: V }> {
const budget = opts.timeoutMs ?? Math.max(this.timeoutMs, INTERCEPT_TIMEOUT_MS);
let value = initial;
for (const { pluginId, handler } of this.handlers) {
if (pluginId === opts.skipPluginId) continue;
if (pluginErrorTracker.isDisabled(pluginId)) continue;
let result: unknown;
try {
@@ -737,10 +744,11 @@ export const uiHooks = {
onDeviceTypeChange: new HookBus(),
onColumnResize: new HookBus(),
onMobileBack: new HookBus(),
// Intercept hook - fires when the user clicks an external link inside the
// app (typically inside an email body iframe). Handler receives
// ExternalLinkContext; return false to cancel the navigation. Mutate
// `href` in place to rewrite (e.g. strip UTM params, route via a proxy).
// Intercept hook - fires when the user opens an outside link anywhere in
// the app (message bodies, the reader view, events, contacts,
// notifications, unsubscribe links, other plugins' ui.openExternalUrl;
// lib/plugin-hook-result.ts). Handler receives ExternalLinkContext; return
// false to cancel the navigation or { href } to send it elsewhere.
onBeforeExternalLink: new HookBus(),
// Observer (debounced) fired when the user changes the active text
// selection inside an app surface. Receives SelectionContext.
+26
View File
@@ -0,0 +1,26 @@
// What one plugin gets of a hook's arguments. The bus hands every handler
// the same value; a part that needs a permission beyond the hook's own is
// cut here, per plugin, before it crosses into the sandbox.
import type { InstalledPlugin, OutgoingEmail } from '../plugin-types';
import { pluginHasPermission } from './permissions';
/** onBeforeEmailSend without the text of attachments, for a plugin that may not read files. */
function withoutTextExcerpts(email: OutgoingEmail): OutgoingEmail {
if (!Array.isArray(email?.attachments) || !email.attachments.some((a) => a && 'textExcerpt' in a)) return email;
return {
...email,
attachments: email.attachments.map((attachment) => {
const { textExcerpt: _omit, ...rest } = attachment;
void _omit;
return rest;
}),
};
}
export function hookArgsFor(plugin: InstalledPlugin, hookName: string, args: unknown[]): unknown[] {
if (hookName === 'onBeforeEmailSend' && !pluginHasPermission(plugin, 'email:blob-read') && args[0]) {
return [withoutTextExcerpts(args[0] as OutgoingEmail), ...args.slice(1)];
}
return args;
}
+23 -6
View File
@@ -22,10 +22,11 @@ import { useEmailStore } from '@/stores/email-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useLocaleStore } from '@/stores/locale-store';
import { detectBrowserLocale } from '@/i18n/detect-locale';
import { emailHooks } from '../plugin-hooks';
import { announceSent } from '../plugin-after-send';
import { buildForwardSubject } from '../subject-prefix';
import { finalizeOutgoing, runPreSendChecks } from '../outgoing/send-pipeline';
import { runPluginSendCheck, sendGuardMessageKey } from '../outgoing/plugin-send-check';
import { externalRecipientsFor, inspectAttachments } from '../outgoing/send-check-payload';
import { automaticLabel, classifyOutgoing } from '../outgoing/classification';
import { whenPolicySettled } from '@/stores/policy-store';
import { toast } from '@/stores/toast-store';
@@ -186,16 +187,32 @@ export async function doEmailForward(
// No plugin step in between, as for a quick reply: both stages at once.
const body = finalizeOutgoing(finalizeOutgoing({ text: forwardBody(email, note, messages, locale) }, context), context, 'last').text;
if (emailHooks.onBeforeEmailSend.size > 0) {
const outgoing: OutgoingEmail = {
// Always: a required send check that isn't running holds the forward too,
// also when no plugin listens right now.
{
const outcome = await runPluginSendCheck(async (): Promise<OutgoingEmail> => ({
to, cc: [], bcc: [], subject,
htmlBody: '',
textBody: body,
identityId: identity.id,
fromEmail: identity.email,
attachments: attachments.map((a) => ({ name: a.name || 'attachment', type: a.type || 'application/octet-stream', size: a.size })),
};
if ((await emailHooks.onBeforeEmailSend.interceptOutcome(outgoing)) !== 'allowed') return { sent: false, reason: 'stopped' };
// The original's parts, read where they are (the message's own account).
attachments: await inspectAttachments(attachments.map((a) => ({
name: a.name || 'attachment',
type: a.type || 'application/octet-stream',
size: a.size,
blobId: a.blobId,
load: (bytes: number) => client.fetchBlobArrayBuffer(a.blobId, a.name || undefined, a.type || undefined, sourceAccountId, bytes),
}))),
surface: 'plugin-forward',
mode: 'forward',
// The plugin's note is the only text written for this forward.
authoredText: note,
externalRecipients: await externalRecipientsFor(context),
}));
const guardKey = sendGuardMessageKey(outcome);
if (guardKey) toast.error(text(messages, `send_guards.${guardKey}`));
if (outcome !== 'allowed') return { sent: false, reason: 'stopped' };
}
// The attachments are blobs of the account the original is in; a message
+32 -8
View File
@@ -67,7 +67,9 @@ import { requestPanel } from './panel-requests';
import { requestPageNavigation } from './page-navigation';
import { requestOpenEmail } from './open-email';
import { doComposerAttach, doComposerGetBody, doComposerGetSelection, doComposerReplaceBody, doComposerReplaceSelection } from './host-api-composer';
import { pluginAllowed, wkdAllowed } from '../outgoing-hosts';
import { wkdAllowed } from '../outgoing-hosts';
import { noThirdPartyTraffic } from '../third-party-mode';
import { openExternalLink } from '../plugin-hook-result';
/**
* Methods only callable from the privileged (same-origin) tier. These expose
@@ -697,8 +699,9 @@ async function readCapped(res: Response, limit: number): Promise<ArrayBuffer> {
async function doHttpFetch(plugin: InstalledPlugin, rawUrl: string, init?: PluginFetchInit) {
// Such a plugin isn't started in the no-third-party mode; this is the
// second lock in case one runs anyway (#903).
if (!pluginAllowed(plugin)) throw new Error('Outside requests are off: this server does not contact outside services');
// second lock in case one runs anyway (#903). A plugin the mode lets run
// (admin-entered servers through the proxy) has no outside fetch either.
if (noThirdPartyTraffic()) throw new Error('Outside requests are off: this server does not contact outside services');
if (typeof rawUrl !== 'string') throw new Error('url must be a string');
let url: URL;
try { url = new URL(rawUrl); } catch { throw new Error('url must be absolute https://'); }
@@ -1891,8 +1894,14 @@ async function doSieveRegenerate(): Promise<void> {
// ─── admin config (same as before) ────────────────────────────
/**
* The plugin's admin settings without its secrets, also when this browser
* holds an admin session: the route hands an admin every field, and a
* plugin read the Safe Browsing key that way. Secrets go only to the server
* proxy (api.proxy), never into a plugin frame.
*/
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config`);
const res = await apiFetch(`/api/admin/plugins/${encodeURIComponent(pluginId)}/config?public=1`);
if (!res.ok) return {};
return res.json();
}
@@ -1935,6 +1944,18 @@ async function adminSet(pluginId: string, key: string, value: unknown): Promise<
}));
}
/**
* A server address an admin types for a credential (`originKey`) is what
* stays reachable with no third-party traffic: the plugin itself may not
* write it, or it could point its proxy at a host of its choosing.
*/
function refuseServerField(plugin: InstalledPlugin, key: unknown, method: string): void {
const serverFields = Object.values(plugin.credentials ?? {}).flatMap((spec) => (spec.scope === 'admin' && spec.originKey ? [spec.originKey] : []));
if (typeof key === 'string' && serverFields.includes(key)) {
throw new Error(`${method}: "${key}" is a server address only an administrator can set, on the plugin's admin page`);
}
}
/** A failed admin config write used to look like success to the plugin. */
async function expectOk(method: string, request: Promise<Response>): Promise<void> {
const res = await request;
@@ -2122,8 +2143,8 @@ export async function dispatchApiCall(
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
case 'admin.getAllConfig': return adminGetAll(plugin.id);
case 'plugin.getConfig': return publicConfig(plugin.id);
case 'admin.setConfig': await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
case 'admin.deleteConfig': await adminDelete(plugin.id, args[0] as string); return undefined;
case 'admin.setConfig': refuseServerField(plugin, args[0], 'admin.setConfig'); await adminSet(plugin.id, args[0] as string, args[1]); return undefined;
case 'admin.deleteConfig': refuseServerField(plugin, args[0], 'admin.deleteConfig'); await adminDelete(plugin.id, args[0] as string); return undefined;
case 'ui.confirm': {
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean };
@@ -2211,9 +2232,12 @@ export async function dispatchApiCall(
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error(`ui.openExternalUrl: ${parsed.protocol} not allowed`);
}
// Always open in a new tab; plugins must not be able to navigate the
// Other plugins' link checks (onBeforeExternalLink) see it like a
// click in a message; the plugin's own handler isn't asked again, so a
// link check opens the link after its dialog.
// Always opens in a new tab; plugins must not be able to navigate the
// host window (_self/_top/_parent) to an attacker-controlled origin.
window.open(parsed.toString(), '_blank', 'noopener,noreferrer');
await openExternalLink({ href: parsed.toString(), target: '_blank' }, { skipPluginId: plugin.id });
return undefined;
}
case 'ui.print': return doUiPrint(plugin, args[0]);
+12 -7
View File
@@ -9,22 +9,24 @@
// plugin id, one switch at a time.
import { usePolicyStore } from '@/stores/policy-store';
import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
import type { HostFeaturesView } from '../plugin-types';
import type { OrganisationPolicy } from '@/lib/admin/organisation-policy';
/** The external-mail-warning plugin, retired in favour of organisation.externalMail. */
export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning';
/** The flags for an externalMail policy section (missing = both off). */
export function hostFeaturesFor(externalMail: OrganisationPolicy['externalMail'] | undefined): HostFeaturesView {
/** The flags for an externalMail policy section (missing = both off) and the third-party mode. */
export function hostFeaturesFor(externalMail: OrganisationPolicy['externalMail'] | undefined, thirdPartyOff = false): HostFeaturesView {
return {
externalSenderLabel: externalMail?.senderLabel === true,
externalRecipientWarning: externalMail?.recipientWarning === true,
noThirdPartyTraffic: thirdPartyOff,
};
}
export function snapshotHostFeatures(): HostFeaturesView {
return hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail);
return hostFeaturesFor(usePolicyStore.getState().getOrganisation().externalMail, noThirdPartyTraffic());
}
/**
@@ -32,11 +34,11 @@ export function snapshotHostFeatures(): HostFeaturesView {
* with the recipient warning, the banner and the new-mail toast (both about
* incoming mail from outside) with the sender label.
*/
const HOOK_FLAGS: Record<string, keyof HostFeaturesView> = {
const HOOK_FLAGS: Record<string, 'externalSenderLabel' | 'externalRecipientWarning'> = {
onBeforeEmailSend: 'externalRecipientWarning',
onNewEmailReceived: 'externalSenderLabel',
};
const SLOT_FLAGS: Record<string, keyof HostFeaturesView> = {
const SLOT_FLAGS: Record<string, 'externalSenderLabel' | 'externalRecipientWarning'> = {
'email-banner': 'externalSenderLabel',
};
@@ -62,11 +64,14 @@ export function watchHostFeatures(onChange: (features: HostFeaturesView) => void
if (watching) return;
watching = true;
lastKey = JSON.stringify(snapshotHostFeatures());
usePolicyStore.subscribe(() => {
const check = () => {
const features = snapshotHostFeatures();
const key = JSON.stringify(features);
if (key === lastKey) return;
lastKey = key;
onChange(features);
});
};
usePolicyStore.subscribe(check);
// The mode is known once the config has been read, and an admin may turn it on later.
subscribeThirdPartyMode(check);
}
+7 -1
View File
@@ -33,6 +33,8 @@ import { clearSectionTitle } from './section-titles';
import { cancelPluginProxyRequests } from './proxy-client';
import { cancelConnectFor } from './connect-dialog';
import { isHookHandedOver } from './host-features';
import { hookArgsFor } from './hook-payload';
import { rememberDeclaredHooks } from '../plugin-send-guards';
import type { SlotName } from '../plugin-types';
import { PLUGIN_REFUSED_ERROR, pluginAllowed } from '../outgoing-hosts';
@@ -294,8 +296,10 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
// 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.
// Parts of the arguments that need another permission are cut per
// plugin (hook-payload.ts).
const pluginId = plugin.id;
const proxy = (...args: unknown[]) => (isHookHandedOver(pluginId, hookName) ? undefined : bg.invokeHook(hookName, args));
const proxy = (...args: unknown[]) => (isHookHandedOver(pluginId, hookName) ? undefined : bg.invokeHook(hookName, hookArgsFor(plugin, hookName, args)));
hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown));
if (hookName === 'onEmailOpen') openHandler = proxy;
}
@@ -313,6 +317,8 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
const slotOffers = permittedOffers(plugin, info.slots, true);
writeSlotOffers(plugin.id, hash, info.slots);
// Whether it checks mail before sending, for the next start (plugin-send-guards.ts).
rememberDeclaredHooks(plugin, hash, info.hooks);
registerActive({
plugin,
+4 -1
View File
@@ -150,13 +150,16 @@ function applySettings(next: Record<string, unknown>): void {
}
// What api.host.features says: one object for the iframe's lifetime,
// updated in place on 'host-change' like the settings.
const hostFeaturesState: HostFeaturesView = { externalSenderLabel: false, externalRecipientWarning: false };
// noThirdPartyTraffic starts on, as the host's own mode does until it knows.
const hostFeaturesState: HostFeaturesView = { externalSenderLabel: false, externalRecipientWarning: false, noThirdPartyTraffic: true };
const hostFeatureListeners = new Set<(features: HostFeaturesView) => void>();
function applyHostFeatures(next: HostFeaturesView | undefined): void {
if (!next) return;
hostFeaturesState.externalSenderLabel = next.externalSenderLabel === true;
hostFeaturesState.externalRecipientWarning = next.externalRecipientWarning === true;
// A host older than 1.20 doesn't send it, and never let such a plugin run in the mode.
hostFeaturesState.noThirdPartyTraffic = next.noThirdPartyTraffic === true;
for (const listener of hostFeatureListeners) {
try { listener(hostFeaturesState); } catch (err) { console.error('[plugin] host feature listener failed', err); }
}
+39
View File
@@ -13,6 +13,7 @@ import { authHeaderFor, credentialOrigin, privateProxyOrigins, type CredentialIn
import { getUserCredential, setUserCredentialSecret, userKey, type VaultUser } from './vault';
import { declaredUserCredential } from './known-plugins';
import { isStoredDataUnavailable } from '@/lib/stored-data-errors';
import { credentialAllowedInMode } from '@/lib/outgoing-hosts';
/** Largest request body the proxy forwards (one upload chunk). */
export const MAX_PROXY_REQUEST_BYTES = 100 * 1024 * 1024;
@@ -119,6 +120,44 @@ export async function adminCredentialOrigins(pluginId: string): Promise<string[]
return (await adminCredentials(pluginId)).flatMap(({ origin }) => (origin ? [origin] : []));
}
/**
* The servers the admin entered for a plugin's credentials (originKey), set
* or not yet set up with a secret: what stays reachable for it with
* third-party traffic off (Organisation > Security).
*/
export async function adminEnteredOrigins(pluginId: string): Promise<string[]> {
const plugin = await getPlugin(pluginId);
const specs = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials;
if (!plugin || !plugin.enabled || !specs) return [];
const config = await getPluginConfig(pluginId);
const out = new Set<string>();
for (const spec of Object.values(specs)) {
const origin = adminEnteredOrigin(spec, config, plugin.configSchema);
if (origin) out.add(origin);
}
return [...out];
}
/**
* The server an admin typed for a credential (`originKey`), or null: not
* an originKey credential, nothing set, or the value the plugin's manifest
* brings as the field's default. That one the plugin chose, not the admin
* (the admin page saves defaults with the first Save), so with no
* third-party traffic it stays off like any server fixed in a manifest.
*/
export function adminEnteredOrigin(
spec: CredentialSpec | undefined,
config: Record<string, unknown>,
schema: Record<string, { default?: unknown }> | undefined,
): string | null {
if (!spec || spec.scope !== 'admin' || !credentialAllowedInMode(spec) || !spec.originKey) return null;
const value = config[spec.originKey];
const origin = credentialOrigin(value);
if (!origin) return null;
const fallback = credentialOrigin(schema?.[spec.originKey]?.default);
return fallback && fallback === origin ? null : origin;
}
async function adminCredentials(pluginId: string): Promise<{ credentialId: string; origin: string | null }[]> {
const plugin = await getPlugin(pluginId);
const specs = (plugin as { credentials?: Record<string, CredentialSpec> } | null)?.credentials;
+172
View File
@@ -0,0 +1,172 @@
/**
* Send guards the admin requires that aren't running (enterprise pack R8).
*
* A plugin's onBeforeEmailSend can only stop mail while it runs. One the
* admin force-enabled that is still starting, failed to start or was turned
* off after errors (the hook bus's three-strikes breaker) used to let every
* message through. While such a guard is down, sending waits a few seconds
* for it and then stays blocked.
*
* Doesn't count: a plugin that never runs on purpose, because Bulwark
* refused it (the no-third-party mode, the admin's approval or user-plugin
* rules, a tier it may not have). The Security page lists those for the
* admin. And a plugin the user turned on themselves: that is their choice.
*
* Which plugins guard sending: those the last start saw registering
* onBeforeEmailSend (remembered per bundle), and before any start a plugin
* with the `email:send` permission, to be safe.
*
* The plugin store hands in its state through setSendGuardSource, so the
* send paths need no import of it (lib/outgoing/plugin-send-check.ts).
*/
import type { InstalledPlugin } from './plugin-types';
export interface SendGuardSource {
snapshot(): {
/** The installed plugins. */
plugins: readonly InstalledPlugin[];
/** Whether the admin forces a plugin on. */
forced: (plugin: InstalledPlugin) => boolean;
/** The admin turned plugins off: none runs, none guards. */
pluginsOff?: boolean;
/**
* The plugins started and the admin's list arrived in full. Until then a
* plugin the admin forces on can be missing from `plugins` only because
* it hasn't reached this browser yet (a new device, a failed sync).
*/
complete?: boolean;
/** The plugin ids the policy forces on. */
forcedIds?: readonly string[];
};
subscribe(listener: () => void): () => void;
}
export type SendGuardState = 'loading' | 'failed';
export interface DownSendGuard {
id: string;
name: string;
state: SendGuardState;
}
/** Errors that mean Bulwark refused to start the plugin, not that it broke. */
const REFUSALS = new Set([
'third-party-off',
'user-plugins-off',
'approval-unavailable',
'approval-denied',
'approval-pending',
]);
const TIER_REFUSAL = /^Privileged tier requires /;
/** How long a send waits for guards that are still starting. */
export const SEND_GUARD_WAIT_MS = 8000;
let source: SendGuardSource | null = null;
export function setSendGuardSource(next: SendGuardSource | null): void {
source = next;
}
export function getSendGuardSource(): SendGuardSource | null {
return source;
}
// ─── Hooks seen at the last start ─────────────────────────────
const HOOKS_KEY_PREFIX = 'plugin-declared-hooks:';
const normalHash = (hash: string | null | undefined) => (hash ?? '').replace(/^"|"$/g, '').trim().toLowerCase();
/** Called by the loader once a plugin has started: the hooks it registered. */
export function rememberDeclaredHooks(plugin: Pick<InstalledPlugin, 'id' | 'version'>, hash: string, hooks: readonly string[]): void {
try {
localStorage.setItem(HOOKS_KEY_PREFIX + plugin.id, JSON.stringify({ hash: normalHash(hash), version: plugin.version, hooks }));
} catch {
// Storage blocked or full: the permission decides until next time.
}
}
export function forgetDeclaredHooks(pluginId: string): void {
try { localStorage.removeItem(HOOKS_KEY_PREFIX + pluginId); } catch { /* ignore */ }
}
function rememberedHooks(plugin: InstalledPlugin): string[] | null {
try {
const raw = localStorage.getItem(HOOKS_KEY_PREFIX + plugin.id);
if (!raw) return null;
const rec = JSON.parse(raw) as { hash?: unknown; version?: unknown; hooks?: unknown };
const same = plugin.bundleHash ? rec.hash === normalHash(plugin.bundleHash) : rec.version === plugin.version;
if (!same || !Array.isArray(rec.hooks)) return null;
return rec.hooks.filter((h): h is string => typeof h === 'string');
} catch {
return null;
}
}
/** Whether a plugin checks mail before it is sent. */
export function guardsSending(plugin: InstalledPlugin): boolean {
if (!plugin.permissions?.includes('email:send')) return false;
const hooks = rememberedHooks(plugin);
return hooks === null || hooks.includes('onBeforeEmailSend');
}
/** The state of one required guard, or null when it runs or doesn't count. */
export function guardState(plugin: InstalledPlugin): SendGuardState | null {
switch (plugin.status) {
case 'running':
return null;
case 'error': {
const error = plugin.error ?? '';
if (REFUSALS.has(error) || TIER_REFUSAL.test(error)) return null;
return 'failed';
}
default:
// Enabled and not running yet, or waiting for the server sync to turn it on.
return 'loading';
}
}
/** The force-enabled send guards that aren't running now. */
export function downSendGuards(): DownSendGuard[] {
if (!source) return [];
const { plugins, forced, pluginsOff, complete = true, forcedIds = [] } = source.snapshot();
if (pluginsOff) return [];
const out: DownSendGuard[] = [];
for (const plugin of plugins) {
if (!forced(plugin) || !guardsSending(plugin)) continue;
const state = guardState(plugin);
if (state) out.push({ id: plugin.id, name: plugin.name, state });
}
// A forced plugin this browser doesn't have yet may be a send check:
// it counts as starting until the admin's list has arrived in full.
if (!complete) {
for (const id of forcedIds) {
if (!plugins.some((plugin) => plugin.id === id)) out.push({ id, name: id, state: 'loading' });
}
}
return out;
}
/**
* The required guards that are still down after waiting up to `waitMs` for
* those that are starting. Empty: sending may go on.
*/
export async function awaitSendGuards(waitMs: number = SEND_GUARD_WAIT_MS): Promise<DownSendGuard[]> {
const down = downSendGuards();
if (!source || !down.some((guard) => guard.state === 'loading')) return down;
const current = source;
return new Promise((resolve) => {
let unsubscribe = () => {};
const timer = setTimeout(() => finish(), waitMs);
const finish = () => {
clearTimeout(timer);
unsubscribe();
resolve(downSendGuards());
};
unsubscribe = current.subscribe(() => {
if (!downSendGuards().some((guard) => guard.state === 'loading')) finish();
});
});
}
+77 -1
View File
@@ -876,6 +876,14 @@ export interface HostFeaturesView {
externalSenderLabel: boolean;
/** The admin turned on the confirm before sending outside the organisation (organisation.externalMail.recipientWarning). */
externalRecipientWarning: boolean;
/**
* Bulwark 1.20 and later: "no third-party traffic" is on (or not known
* yet). Outside services are off: the proxy refuses servers fixed in a
* manifest and only reaches those the admin entered on the plugin's admin
* page. A plugin switches such a service off instead of treating it as
* down.
*/
noThirdPartyTraffic: boolean;
}
/** One setting `api.app.searchSettings` found, in the user's language. */
@@ -1076,6 +1084,35 @@ export interface MailtoContext {
* copy. Use to inject signatures, rewrite links, strip tracking pixels from
* forwards, encrypt the body, etc. Return undefined to pass through unchanged.
*/
/** An attachment of an OutgoingEmail. */
export interface OutgoingAttachment {
name: string;
/** The type the file was attached with (often the browser's guess from the extension). */
type: string;
size: number;
/** onBeforeEmailSend, 1.20 and later: the uploaded blob. */
blobId?: string;
/** onBeforeEmailSend, 1.20 and later: an image shown in the body (pasted or quoted). */
inline?: boolean;
/**
* onBeforeEmailSend, 1.20 and later: the type the file's first bytes say
* it is (application/x-msdownload for a program, application/zip, ...);
* absent when Bulwark knows no signature for them or couldn't read them.
*/
sniffedType?: string;
/**
* onBeforeEmailSend, 1.20 and later, plugins with `email:blob-read` only:
* up to the first megabyte of a text attachment (text/*, CSV, JSON, XML,
* calendar and contact files) as text.
*/
textExcerpt?: string;
/**
* onBeforeEmailSend, 1.20 and later: Bulwark couldn't read the file, so
* there is no sniffedType or textExcerpt. Treat it as not checked.
*/
unreadable?: boolean;
}
export interface OutgoingEmail {
to: string[];
cc: string[];
@@ -1086,11 +1123,33 @@ export interface OutgoingEmail {
identityId: string;
/** Sender email derived from the active identity (incl. sub-address tag, when set) */
fromEmail?: string;
attachments: { name: string; type: string; size: number }[];
attachments: OutgoingAttachment[];
/** Original message id when this is a reply or forward */
inReplyTo?: string;
/** Free-form custom headers added by the composer or earlier handlers */
headers?: Record<string, string>;
/**
* Where the send comes from (onBeforeEmailSend, Bulwark 1.20 and later):
* the composer, a quick reply under a message, or a plugin's forward.
*/
surface?: 'composer' | 'quick-reply' | 'plugin-forward';
/** compose, reply, replyAll or forward (onBeforeEmailSend, 1.20 and later). */
mode?: 'compose' | 'reply' | 'replyAll' | 'forward';
/**
* onBeforeEmailSend, 1.20 and later: what the user wrote, as plain text,
* without the quoted original of a reply or forward and without the
* signature. Check this rather than the bodies, or a number in the quoted
* mail warns on every reply.
*/
authoredText?: string;
/**
* onBeforeEmailSend, 1.20 and later: the recipients (To, Cc and Bcc)
* outside the organisation of the account the mail goes out from, as bare
* lowercase addresses. The host works it out from the account's identity
* domains and the admin's organisation domains, also when the admin's
* recipient warning is off. Empty when every recipient is internal.
*/
externalRecipients?: string[];
}
/**
* Passed to onBeforeDraftAutoSave handlers as a transform value.
@@ -1177,6 +1236,13 @@ export interface AttachmentInfo {
blobId?: string;
/** The email this attachment belongs to (download / preview) */
emailId?: string;
/**
* onBeforeAttachmentUpload, 1.20 and later, for a file from this device:
* the type its first bytes say it is (see OutgoingAttachment.sniffedType).
*/
sniffedType?: string;
/** onBeforeAttachmentUpload, 1.20 and later: the file's bytes couldn't be read. */
unreadable?: boolean;
}
/**
@@ -1211,11 +1277,21 @@ export interface AttachmentPreview {
* Return false to cancel the navigation. Mutate `href` to rewrite it.
*/
export interface ExternalLinkContext {
/**
* The address as the browser parses it (`new URL(href).href`, Bulwark
* 1.20 and later); exactly this string opens.
*/
href: string;
/** Anchor target ('_blank', '_self', etc.) when set */
target?: string;
/** Email currently in view, when the click came from an email body */
emailId?: string;
/**
* What the link says (its text, else an image's alt text), trimmed to 200
* characters, when the host knows it (Bulwark 1.20 and later). A link
* whose text names another address than its target is a phishing sign.
*/
text?: string;
}
/**
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "تعذّر الحفظ: {error}"
}
},
"send_guards": {
"not_running": "لم تُرسل. هناك فحص تشترطه مؤسستك لا يعمل حاليًا. حاول مرة أخرى بعد قليل.",
"stopped_working": "لم تُرسل. توقف فحص تشترطه مؤسستك عن العمل. أعد تحميل الصفحة. إذا لم يساعد ذلك، أبلغ المسؤول."
},
"plugin_list": {
"filters": "المرشحات",
"remove_filter": "إزالة المرشح {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "No s'ha pogut desar: {error}"
}
},
"send_guards": {
"not_running": "No s'ha enviat. Una comprovació que exigeix la vostra organització no s'està executant. Torneu-ho a provar d'aquí a un moment.",
"stopped_working": "No s'ha enviat. Una comprovació que exigeix la vostra organització ha deixat de funcionar. Torneu a carregar la pàgina. Si no ho soluciona, aviseu l'administrador."
},
"plugin_list": {
"filters": "Filtres",
"remove_filter": "Treu el filtre {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Uložení se nezdařilo: {error}"
}
},
"send_guards": {
"not_running": "Neodesláno. Kontrola, kterou vaše organizace vyžaduje, právě neběží. Zkuste to za chvíli znovu.",
"stopped_working": "Neodesláno. Kontrola, kterou vaše organizace vyžaduje, přestala fungovat. Načtěte stránku znovu. Pokud to nepomůže, obraťte se na správce."
},
"plugin_list": {
"filters": "Filtry",
"remove_filter": "Odebrat filtr {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Kunne ikke gemme: {error}"
}
},
"send_guards": {
"not_running": "Ikke sendt. En kontrol, som din organisation kræver, kører ikke. Prøv igen om et øjeblik.",
"stopped_working": "Ikke sendt. En kontrol, som din organisation kræver, virker ikke længere. Genindlæs siden. Hvis det ikke hjælper, så kontakt din administrator."
},
"plugin_list": {
"filters": "Filtre",
"remove_filter": "Fjern filteret {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Speichern fehlgeschlagen: {error}"
}
},
"send_guards": {
"not_running": "Nicht gesendet. Eine Prüfung, die Ihre Organisation vorschreibt, läuft gerade nicht. Versuchen Sie es gleich noch einmal.",
"stopped_working": "Nicht gesendet. Eine Prüfung, die Ihre Organisation vorschreibt, funktioniert nicht mehr. Laden Sie die Seite neu. Wenn das nicht hilft, wenden Sie sich an Ihre Administration."
},
"plugin_list": {
"filters": "Filter",
"remove_filter": "Filter {label} entfernen"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Could not save: {error}"
}
},
"send_guards": {
"not_running": "Not sent. A check your organisation requires isn't running. Try again in a moment.",
"stopped_working": "Not sent. A check your organisation requires has stopped working. Reload the page. If that doesn't help, tell your administrator."
},
"plugin_list": {
"filters": "Filters",
"remove_filter": "Remove filter {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "No se pudo guardar: {error}"
}
},
"send_guards": {
"not_running": "No enviado. Una comprobación que exige tu organización no está en marcha. Vuelve a intentarlo en un momento.",
"stopped_working": "No enviado. Una comprobación que exige tu organización ha dejado de funcionar. Vuelve a cargar la página. Si no se soluciona, avisa al administrador."
},
"plugin_list": {
"filters": "Filtros",
"remove_filter": "Quitar el filtro {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Salvestamine ebaõnnestus: {error}"
}
},
"send_guards": {
"not_running": "Ei saadetud. Teie organisatsiooni nõutud kontroll praegu ei tööta. Proovige hetke pärast uuesti.",
"stopped_working": "Ei saadetud. Teie organisatsiooni nõutud kontroll ei tööta enam. Laadige leht uuesti. Kui see ei aita, teatage administraatorile."
},
"plugin_list": {
"filters": "Filtrid",
"remove_filter": "Eemalda filter {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "ذخیره نشد: {error}"
}
},
"send_guards": {
"not_running": "ارسال نشد. بررسی‌ای که سازمان شما الزامی کرده در حال اجرا نیست. چند لحظه دیگر دوباره تلاش کنید.",
"stopped_working": "ارسال نشد. بررسی‌ای که سازمان شما الزامی کرده دیگر کار نمی‌کند. صفحه را دوباره بارگیری کنید. اگر کمکی نکرد، به مدیر سیستم خبر دهید."
},
"plugin_list": {
"filters": "فیلترها",
"remove_filter": "حذف فیلتر {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Impossible d'enregistrer : {error}"
}
},
"send_guards": {
"not_running": "Non envoyé. Une vérification exigée par votre organisation ne fonctionne pas. Réessayez dans un instant.",
"stopped_working": "Non envoyé. Une vérification exigée par votre organisation ne fonctionne plus. Rechargez la page. Si cela ne suffit pas, prévenez votre administrateur."
},
"plugin_list": {
"filters": "Filtres",
"remove_filter": "Retirer le filtre {label}"
+4
View File
@@ -6002,6 +6002,10 @@
"error_save": "השמירה נכשלה: {error}"
}
},
"send_guards": {
"not_running": "לא נשלח. בדיקה שהארגון שלך מחייב לא פועלת כרגע. נסו שוב בעוד רגע.",
"stopped_working": "לא נשלח. בדיקה שהארגון שלך מחייב הפסיקה לפעול. טענו את הדף מחדש. אם זה לא עוזר, פנו למנהל המערכת."
},
"plugin_list": {
"filters": "מסננים",
"remove_filter": "הסר את המסנן {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Nem sikerült menteni: {error}"
}
},
"send_guards": {
"not_running": "Nincs elküldve. Egy, a szervezete által előírt ellenőrzés most nem fut. Próbálja újra egy pillanat múlva.",
"stopped_working": "Nincs elküldve. Egy, a szervezete által előírt ellenőrzés leállt. Töltse be újra az oldalt. Ha ez nem segít, szóljon a rendszergazdának."
},
"plugin_list": {
"filters": "Szűrők",
"remove_filter": "{label} szűrő eltávolítása"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Impossibile salvare: {error}"
}
},
"send_guards": {
"not_running": "Non inviato. Un controllo richiesto dalla tua organizzazione non è attivo. Riprova tra un momento.",
"stopped_working": "Non inviato. Un controllo richiesto dalla tua organizzazione ha smesso di funzionare. Ricarica la pagina. Se non basta, avvisa l'amministratore."
},
"plugin_list": {
"filters": "Filtri",
"remove_filter": "Rimuovi il filtro {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "保存できませんでした: {error}"
}
},
"send_guards": {
"not_running": "送信されませんでした。組織で必須のチェックが動作していません。しばらくしてからもう一度お試しください。",
"stopped_working": "送信されませんでした。組織で必須のチェックが動作しなくなりました。ページを再読み込みしてください。解決しない場合は管理者に連絡してください。"
},
"plugin_list": {
"filters": "フィルター",
"remove_filter": "フィルター「{label}」を解除"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "저장하지 못했어요: {error}"
}
},
"send_guards": {
"not_running": "보내지 않았습니다. 조직에서 요구하는 검사가 실행되고 있지 않습니다. 잠시 후 다시 시도하세요.",
"stopped_working": "보내지 않았습니다. 조직에서 요구하는 검사가 작동을 멈췄습니다. 페이지를 새로 고치세요. 그래도 안 되면 관리자에게 알리세요."
},
"plugin_list": {
"filters": "필터",
"remove_filter": "{label} 필터 제거"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Neizdevās saglabāt: {error}"
}
},
"send_guards": {
"not_running": "Nav nosūtīts. Jūsu organizācijas pieprasītā pārbaude pašlaik nedarbojas. Mēģiniet vēlreiz pēc brīža.",
"stopped_working": "Nav nosūtīts. Jūsu organizācijas pieprasītā pārbaude vairs nedarbojas. Pārlādējiet lapu. Ja tas nepalīdz, sazinieties ar administratoru."
},
"plugin_list": {
"filters": "Filtri",
"remove_filter": "Noņemt filtru {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Хадгалж чадсангүй: {error}"
}
},
"send_guards": {
"not_running": "Илгээгдсэнгүй. Танай байгууллагын шаарддаг шалгалт одоогоор ажиллахгүй байна. Хэсэг хугацааны дараа дахин оролдоно уу.",
"stopped_working": "Илгээгдсэнгүй. Танай байгууллагын шаарддаг шалгалт ажиллахаа больсон. Хуудсыг дахин ачаална уу. Тус болохгүй бол админдаа мэдэгдээрэй."
},
"plugin_list": {
"filters": "Шүүлтүүр",
"remove_filter": "{label} шүүлтүүрийг хасах"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Kunne ikke lagre: {error}"
}
},
"send_guards": {
"not_running": "Ikke sendt. En kontroll organisasjonen din krever, kjører ikke. Prøv igjen om et øyeblikk.",
"stopped_working": "Ikke sendt. En kontroll organisasjonen din krever, har sluttet å virke. Last inn siden på nytt. Hvis det ikke hjelper, si fra til administratoren."
},
"plugin_list": {
"filters": "Filtre",
"remove_filter": "Fjern filteret {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Opslaan mislukt: {error}"
}
},
"send_guards": {
"not_running": "Niet verzonden. Een controle die je organisatie verplicht stelt, draait niet. Probeer het zo meteen opnieuw.",
"stopped_working": "Niet verzonden. Een controle die je organisatie verplicht stelt, werkt niet meer. Laad de pagina opnieuw. Helpt dat niet, meld het dan bij je beheerder."
},
"plugin_list": {
"filters": "Filters",
"remove_filter": "Filter {label} verwijderen"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Nie udało się zapisać: {error}"
}
},
"send_guards": {
"not_running": "Nie wysłano. Kontrola wymagana przez Twoją organizację nie działa. Spróbuj ponownie za chwilę.",
"stopped_working": "Nie wysłano. Kontrola wymagana przez Twoją organizację przestała działać. Odśwież stronę. Jeśli to nie pomoże, zgłoś to administratorowi."
},
"plugin_list": {
"filters": "Filtry",
"remove_filter": "Usuń filtr {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Não foi possível salvar: {error}"
}
},
"send_guards": {
"not_running": "Não enviado. Uma verificação exigida pela sua organização não está em execução. Tente novamente daqui a pouco.",
"stopped_working": "Não enviado. Uma verificação exigida pela sua organização deixou de funcionar. Recarregue a página. Se não resolver, avise o administrador."
},
"plugin_list": {
"filters": "Filtros",
"remove_filter": "Remover o filtro {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Nu s-a putut salva: {error}"
}
},
"send_guards": {
"not_running": "Nu a fost trimis. O verificare cerută de organizația ta nu rulează. Încearcă din nou peste o clipă.",
"stopped_working": "Nu a fost trimis. O verificare cerută de organizația ta nu mai funcționează. Reîncarcă pagina. Dacă nu ajută, anunță administratorul."
},
"plugin_list": {
"filters": "Filtre",
"remove_filter": "Eliminați filtrul {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Не удалось сохранить: {error}"
}
},
"send_guards": {
"not_running": "Не отправлено. Проверка, которую требует ваша организация, сейчас не работает. Повторите попытку через минуту.",
"stopped_working": "Не отправлено. Проверка, которую требует ваша организация, перестала работать. Перезагрузите страницу. Если это не поможет, сообщите администратору."
},
"plugin_list": {
"filters": "Фильтры",
"remove_filter": "Убрать фильтр {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Uloženie zlyhalo: {error}"
}
},
"send_guards": {
"not_running": "Neodoslané. Kontrola, ktorú vyžaduje vaša organizácia, práve nebeží. Skúste to o chvíľu znova.",
"stopped_working": "Neodoslané. Kontrola, ktorú vyžaduje vaša organizácia, prestala fungovať. Načítajte stránku znova. Ak to nepomôže, obráťte sa na správcu."
},
"plugin_list": {
"filters": "Filtre",
"remove_filter": "Odstrániť filter {label}"
+4
View File
@@ -2945,6 +2945,10 @@
"error_save": "บันทึกไม่สำเร็จ: {error}"
}
},
"send_guards": {
"not_running": "ยังไม่ได้ส่ง การตรวจสอบที่องค์กรของคุณกำหนดไม่ได้ทำงานอยู่ ลองอีกครั้งในอีกสักครู่",
"stopped_working": "ยังไม่ได้ส่ง การตรวจสอบที่องค์กรของคุณกำหนดหยุดทำงาน โหลดหน้านี้ใหม่ หากยังไม่ได้ผล โปรดแจ้งผู้ดูแลระบบ"
},
"plugin_list": {
"filters": "ตัวกรอง",
"remove_filter": "นำตัวกรอง {label} ออก"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Kaydedilemedi: {error}"
}
},
"send_guards": {
"not_running": "Gönderilmedi. Kuruluşunuzun zorunlu tuttuğu bir denetim şu anda çalışmıyor. Biraz sonra yeniden deneyin.",
"stopped_working": "Gönderilmedi. Kuruluşunuzun zorunlu tuttuğu bir denetim çalışmayı durdurdu. Sayfayı yeniden yükleyin. İşe yaramazsa yöneticinize bildirin."
},
"plugin_list": {
"filters": "Filtreler",
"remove_filter": "{label} filtresini kaldır"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "Не вдалося зберегти: {error}"
}
},
"send_guards": {
"not_running": "Не надіслано. Перевірка, якої вимагає ваша організація, зараз не працює. Спробуйте ще раз за мить.",
"stopped_working": "Не надіслано. Перевірка, якої вимагає ваша організація, перестала працювати. Перезавантажте сторінку. Якщо це не допоможе, повідомте адміністратора."
},
"plugin_list": {
"filters": "Фільтри",
"remove_filter": "Прибрати фільтр {label}"
+4
View File
@@ -6013,6 +6013,10 @@
"error_save": "Không thể lưu: {error}"
}
},
"send_guards": {
"not_running": "Chưa gửi. Một bước kiểm tra mà tổ chức của bạn yêu cầu hiện không chạy. Hãy thử lại sau giây lát.",
"stopped_working": "Chưa gửi. Một bước kiểm tra mà tổ chức của bạn yêu cầu đã ngừng hoạt động. Hãy tải lại trang. Nếu vẫn không được, hãy báo cho quản trị viên."
},
"plugin_list": {
"filters": "Bộ lọc",
"remove_filter": "Xóa bộ lọc {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "無法儲存:{error}"
}
},
"send_guards": {
"not_running": "未傳送。貴組織要求的一項檢查目前未執行。請稍後再試。",
"stopped_working": "未傳送。貴組織要求的一項檢查已停止運作。請重新載入頁面。如果仍無法解決,請告知管理員。"
},
"plugin_list": {
"filters": "篩選",
"remove_filter": "移除篩選條件 {label}"
+4
View File
@@ -6001,6 +6001,10 @@
"error_save": "无法保存:{error}"
}
},
"send_guards": {
"not_running": "未发送。你的组织要求的一项检查当前未运行。请稍后再试。",
"stopped_working": "未发送。你的组织要求的一项检查已停止工作。请重新加载页面。如果仍不行,请告诉管理员。"
},
"plugin_list": {
"filters": "筛选",
"remove_filter": "移除筛选条件 {label}"
+4
View File
@@ -420,6 +420,10 @@ export async function proxy(request: NextRequest) {
isSandboxPath ? `form-action 'none'` : `form-action ${["'self'", ...wopiOrigins].join(" ")}`,
`frame-ancestors ${frameAncestors}`,
`media-src ${mediaSrc}`,
// A plugin frame may run work it has to stop at a deadline (send checks'
// admin patterns) in a worker made from a blob; it gets the frame's own
// connect-src, so it reaches nothing the frame can't.
...(isSandboxPath ? [`worker-src 'self' blob:`] : []),
].join("; ");
// Skip intl middleware for routes outside the localized app tree.
+39 -1
View File
@@ -27,6 +27,7 @@ import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types';
import type { Permission } from '@/lib/plugin-types';
import { IS_LITE } from '@/lib/lite';
import { onFullSignOut } from '@/lib/sign-out-cleanup';
import { forgetDeclaredHooks, setSendGuardSource } from '@/lib/plugin-send-guards';
let pluginInitializationPromise: Promise<void> | null = null;
// One-time guard so we attach the locale and regional subscriptions only once.
@@ -49,6 +50,12 @@ export interface InstallResult {
interface PluginStoreState {
plugins: InstalledPlugin[];
initialized: boolean;
/**
* The admin's plugin list arrived in full this session (every bundle
* fetched). Until then a plugin the admin forces on may be missing here
* only because it hasn't reached this browser (plugin-send-guards.ts).
*/
serverPluginsSynced: boolean;
// Management
installPlugin: (file: File) => Promise<InstallResult>;
@@ -131,6 +138,7 @@ export const usePluginStore = create<PluginStoreState>()(
(set, get) => ({
plugins: [],
initialized: false,
serverPluginsSynced: false,
installPlugin: async (file: File) => {
const policy = usePolicyStore.getState();
@@ -427,6 +435,7 @@ function removePluginData(id: string): void {
deactivatePlugin(id);
removeAllPluginHooks(id);
forgetSlotOffers(id);
forgetDeclaredHooks(id);
void pluginStorage.deleteCode(id).catch(() => {});
void pluginStorage.deletePreview(id).catch(() => {});
// Accounts the user connected for it live on the server (this mail account's).
@@ -578,7 +587,10 @@ async function syncServerPlugins(
try {
const res = await fetchServerPluginList();
if (res.status === 401) return 'signed-out';
if (!res.ok) return;
if (!res.ok) {
set({ serverPluginsSynced: false });
return;
}
const data = res.data as { plugins: ServerPluginInfo[] };
if (!data?.plugins || !Array.isArray(data.plugins)) return;
@@ -692,9 +704,12 @@ async function syncServerPlugins(
// Persist current server plugin IDs for future cleanup
setServerManagedPluginIds(serverPluginIds);
// A bundle that couldn't be fetched leaves its plugin out: not in full.
set({ serverPluginsSynced: outcomes.every((outcome) => outcome !== null) });
} catch {
// Sync failure is non-fatal - client continues with local plugins
console.warn('[plugin-store] Server plugin sync failed, using local plugins only');
set({ serverPluginsSynced: false });
}
}
@@ -838,3 +853,26 @@ onFullSignOut(() => {
}),
});
});
// Send guards the admin requires hold sends while they aren't running
// (lib/plugin-send-guards.ts). No plugins run in Lite.
if (!IS_LITE) {
setSendGuardSource({
snapshot: () => {
const state = usePluginStore.getState();
const policy = usePolicyStore.getState();
return {
plugins: state.plugins,
forced: (plugin) => !!plugin.forceEnabled || policy.isPluginForceEnabled(plugin.id),
pluginsOff: !policy.isFeatureEnabled('pluginsEnabled'),
complete: state.initialized && state.serverPluginsSynced,
forcedIds: policy.policy.forceEnabledPlugins ?? [],
};
},
subscribe: (listener) => {
const offPlugins = usePluginStore.subscribe(listener);
const offPolicy = usePolicyStore.subscribe(listener);
return () => { offPlugins(); offPolicy(); };
},
});
}