diff --git a/.github/workflows/static-lite.yml b/.github/workflows/static-lite.yml
index 149e12036..9def1243f 100644
--- a/.github/workflows/static-lite.yml
+++ b/.github/workflows/static-lite.yml
@@ -81,6 +81,12 @@ jobs:
LITE_DEMO_MODE: ${{ inputs.demo }}
run: npm run build:lite
+ # Every host literal in the bundle must be a placeholder, the configured
+ # JMAP server, a built-in host the no-third-party switch turns off, or a
+ # string listed as never requested (#903).
+ - name: Scan the bundle for outside hosts
+ run: node scripts/lite/scan-hosts.mjs out
+
- name: Package zip
env:
REF_NAME: ${{ github.ref_name }}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7365c1069..cef331103 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,7 +6,10 @@
- **Organisation**: Admins can set an organisation signature with placeholders for each user's name, address, company, job title, department and phone numbers, per domain or group, and lock it so it replaces everyone's own signature. Users fill in their details once under Settings > Account. Lines whose details are empty are left out (#908)
- **Organisation**: Admins can add a legal disclaimer that goes below every message when it is sent, never into drafts, and is shown read-only in the composer
+- **Organisation**: Admins can label mail from outside the organisation with an "External" chip and have users confirm before sending to or inviting external recipients (composer, quick reply and meeting invitations), per domain or group. Partner domains and contacts count as internal. This replaces the External Mail Warning plugin, whose safe domains can be imported. Plugins can read `api.host.features.externalSenderLabel` and `externalRecipientWarning`
- **Organisation**: Admins can sign users out after a time without activity or a fixed time after sign-in, per domain or group. A countdown with "Stay signed in" comes first, open and minimised messages and quick replies are saved as drafts, and the login page says why
+- **Delegates**: Settings > Sharing gives a colleague read, or read and write, access to all mail folders or just the Inbox, calendars and contacts in one step, lists who has access to what, and removes access everywhere at once. Admins can hide it per domain or group and limit every share dialog to read access on Organisation > Sharing
+- **Privacy**: One switch, `NO_THIRD_PARTY_TRAFFIC` or Organisation > Security, stops every connection to services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, sender icons, MyMemory translation, map links, OpenPGP key lookups and remote pictures in mail. Plugins that need network access don't run, and the page's CSP allows only the hosts you configured. Push registrations move to your own relay. The admin page lists what stays reachable (#903)
## 1.18.0 (2026-10-26)
diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index 6d63d4ee0..95bf81641 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -10,6 +10,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la
import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect";
import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler";
import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host";
+import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm";
import { CommandPaletteHost } from "@/components/command-palette/command-palette";
import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog";
import { QuickRuleHost } from "@/components/filters/quick-rule-host";
@@ -67,6 +68,7 @@ export default async function LocaleLayout({
{children}
+
diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx
index a745ac45c..796a1564d 100644
--- a/app/(main)/admin/_pages/telemetry.tsx
+++ b/app/(main)/admin/_pages/telemetry.tsx
@@ -15,7 +15,7 @@ import { cn } from '@/lib/utils';
interface TelemetryStatus {
consent: 'pending' | 'on' | 'off';
- consentSource: 'env' | 'file';
+ consentSource: 'no-third-party' | 'env' | 'file';
endpoint: string;
defaultEndpoint: string;
consentedAt: string | null;
@@ -132,14 +132,16 @@ export function TelemetryPage() {
);
}
- const envOverridden = status.consentSource === 'env';
+ const noThirdParty = status.consentSource === 'no-third-party';
+ const envOverridden = status.consentSource === 'env' || noThirdParty;
const isOn = status.consent === 'on';
const consentDescription = [
status.consent === 'pending' && 'Initialising - no heartbeats sent yet.',
status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!',
status.consent === 'off' && 'Heartbeats are off (default).',
- envOverridden && 'Locked by the BULWARK_TELEMETRY env var.',
+ noThirdParty && 'Locked off: no third-party traffic is on (Organisation > Security).',
+ envOverridden && !noThirdParty && 'Locked by the BULWARK_TELEMETRY env var.',
].filter(Boolean).join(' ');
return (
diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx
index e9dcd6d0c..7a700786a 100644
--- a/app/(main)/admin/_pages/version.tsx
+++ b/app/(main)/admin/_pages/version.tsx
@@ -24,6 +24,7 @@ interface VersionAdminStatus {
endpoint: string;
defaultEndpoint: string;
disabledByEnv: boolean;
+ disabledByNoThirdPartyTraffic?: boolean;
lastCheckedAt: string | null;
lastSuccessAt: string | null;
nextScheduledAt: string | null;
@@ -206,6 +207,11 @@ export function VersionPage() {
{data.disabledByEnv ? 'Yes' : 'No'}
+ {data.disabledByNoThirdPartyTraffic && (
+
+ Yes
+
+ )}
diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts
index 2981b5b8a..33e3cd203 100644
--- a/app/api/admin/config/route.ts
+++ b/app/api/admin/config/route.ts
@@ -10,6 +10,7 @@ import { logger } from '@/lib/logger';
import { hasSessionSecret } from '@/lib/auth/session-secret';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
import { checkExtensionDirectoryUrl } from '@/lib/admin/directory-url';
+import { parseNoThirdPartyEnv } from '@/lib/third-party-mode';
// Strings that count as "no real secret configured" - used so the dashboard
// can warn about a placeholder session secret without us ever returning the
@@ -99,6 +100,17 @@ export async function PATCH(request: NextRequest) {
if (problem) return NextResponse.json({ error: problem }, { status: 400 });
}
+ // The no-third-party switch is fail-closed: an "on" from the environment
+ // can't be undone from the panel (lib/admin/third-party-mode.ts).
+ if ('noThirdPartyTraffic' in updates) {
+ if (typeof updates.noThirdPartyTraffic !== 'boolean') {
+ return NextResponse.json({ error: 'noThirdPartyTraffic must be true or false' }, { status: 400 });
+ }
+ if (!updates.noThirdPartyTraffic && parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) {
+ return NextResponse.json({ error: 'NO_THIRD_PARTY_TRAFFIC is set in the environment; change it there.' }, { status: 409 });
+ }
+ }
+
// Enum keys only accept their declared values; a stray string would
// otherwise be persisted and fail closed (or open) at the read site.
for (const [key, value] of Object.entries(updates)) {
diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts
index 4df6da909..f35fd899b 100644
--- a/app/api/admin/marketplace/[slug]/route.ts
+++ b/app/api/admin/marketplace/[slug]/route.ts
@@ -12,6 +12,8 @@ import {
directoryJson,
getDirectoryChannel,
getDirectoryUrl,
+ directoryOff,
+ DIRECTORY_OFF_MESSAGE,
historyFor,
installedExtensions,
installedStateOf,
@@ -65,6 +67,9 @@ export async function GET(
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ if (await directoryOff()) {
+ return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
+ }
const { slug } = await params;
const requested = request.nextUrl.searchParams.get('version');
diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts
index 50404e882..19cfb739e 100644
--- a/app/api/admin/marketplace/route.ts
+++ b/app/api/admin/marketplace/route.ts
@@ -32,6 +32,8 @@ import {
directoryJson,
getDirectoryChannel,
getDirectoryUrl,
+ directoryOff,
+ DIRECTORY_OFF_MESSAGE,
installedExtensions,
installedStateOf,
readBundleBytes,
@@ -57,6 +59,9 @@ export async function GET(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ if (await directoryOff()) {
+ return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
+ }
const directoryUrl = await getDirectoryUrl();
const query: Record = {};
@@ -105,6 +110,9 @@ export async function POST(request: NextRequest) {
try {
const result = await requireAdminAuth(request);
if ('error' in result) return result.error;
+ if (await directoryOff()) {
+ return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 });
+ }
const ip = getClientIP(request);
const { slug, version, type, force } = await request.json();
diff --git a/app/api/admin/marketplace/updates/route.ts b/app/api/admin/marketplace/updates/route.ts
index 52b14e8cd..7d2643452 100644
--- a/app/api/admin/marketplace/updates/route.ts
+++ b/app/api/admin/marketplace/updates/route.ts
@@ -8,6 +8,8 @@ import {
directoryJson,
getDirectoryChannel,
getDirectoryUrl,
+ directoryOff,
+ DIRECTORY_OFF_MESSAGE,
installedExtensions,
v2Url,
type DirectoryAdvisory,
@@ -43,6 +45,13 @@ export async function GET(request: NextRequest) {
const list = [...installed].map(([id, e]) => ({ id, ...e }));
const ask = list.filter((e) => askDirectoryAbout(e.source)).map((e) => ({ slug: e.id, version: e.version }));
+ // The installed list is still useful with the official directory off (#903).
+ if (await directoryOff()) {
+ return NextResponse.json(
+ { installed: list, ...EMPTY, directoryUrl: null, channel: getDirectoryChannel(), appVersion: APP_VERSION || null, error: DIRECTORY_OFF_MESSAGE },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+ }
const directoryUrl = await getDirectoryUrl();
const channel = getDirectoryChannel();
let answer: UpdatesAnswer = EMPTY;
diff --git a/app/api/admin/outgoing-hosts/route.ts b/app/api/admin/outgoing-hosts/route.ts
new file mode 100644
index 000000000..d7eaeccce
--- /dev/null
+++ b/app/api/admin/outgoing-hosts/route.ts
@@ -0,0 +1,49 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { requireAdminAuth } from '@/lib/admin/session';
+import { configManager } from '@/lib/admin/config-manager';
+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 { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
+import { logger } from '@/lib/logger';
+
+/**
+ * GET /api/admin/outgoing-hosts - the "no third-party traffic" switch with
+ * where its value comes from, the built-in outside hosts it turns off, the
+ * admin-configured hosts that stay reachable, and the installed plugins it
+ * refuses (#903). Organisation > Security renders this; the switch itself is
+ * saved through PATCH /api/admin/config like every other config key.
+ */
+export async function GET(request: NextRequest) {
+ try {
+ const auth = await requireAdminAuth(request);
+ if ('error' in auth) return auth.error;
+
+ await configManager.ensureLoaded();
+ const state = noThirdPartyTrafficState();
+ const policy = configManager.getPolicy();
+ const registry = await getPluginRegistry().catch(() => ({ plugins: [] }));
+
+ return NextResponse.json(
+ {
+ noThirdPartyTraffic: state.on,
+ source: state.source,
+ // The panel can't turn it off when the environment pins it on, and
+ // can't change it at all on a read-only config.
+ 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))
+ .map((plugin) => ({ id: plugin.id, name: plugin.name })),
+ },
+ { headers: { 'Cache-Control': 'no-store' } },
+ );
+ } catch (error) {
+ logger.error('Outgoing hosts read error', { error: error instanceof Error ? error.message : 'Unknown error' });
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
+ }
+}
diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts
index 192e64b31..daa5c19f8 100644
--- a/app/api/admin/telemetry/route.ts
+++ b/app/api/admin/telemetry/route.ts
@@ -81,6 +81,12 @@ export async function POST(request: NextRequest) {
{ status: 409 },
);
}
+ if (source === 'no-third-party') {
+ return NextResponse.json(
+ { error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' },
+ { status: 409 },
+ );
+ }
if (body.consent !== 'on' && body.consent !== 'off') {
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
}
diff --git a/app/api/admin/version/route.ts b/app/api/admin/version/route.ts
index 2c63a7b33..004db83d2 100644
--- a/app/api/admin/version/route.ts
+++ b/app/api/admin/version/route.ts
@@ -9,6 +9,7 @@ import {
disabledByEnv,
DEFAULT_VERSION_ENDPOINT,
} from '@/lib/version-check';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
/**
* GET /api/admin/version
@@ -27,6 +28,7 @@ export async function GET(request: NextRequest) {
endpoint: effectiveEndpoint(state),
defaultEndpoint: DEFAULT_VERSION_ENDPOINT,
disabledByEnv: disabledByEnv(),
+ disabledByNoThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
lastCheckedAt: state.lastCheckedAt,
lastSuccessAt: state.lastSuccessAt,
nextScheduledAt: state.nextScheduledAt,
diff --git a/app/api/config/route.ts b/app/api/config/route.ts
index 1faf393c3..d2293318d 100644
--- a/app/api/config/route.ts
+++ b/app/api/config/route.ts
@@ -11,6 +11,7 @@ import {
pickRequestHost,
type BrandingOverrideKey,
} from '@/lib/admin/domain-branding';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
/**
* Runtime configuration endpoint
@@ -98,6 +99,8 @@ export async function GET(request: NextRequest) {
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
parentOrigin: configManager.get('parentOrigin', ''),
sourceCodeUrl: httpUrlOrEmpty(configManager.get('sourceCodeUrl', '')),
+ // Browser-side switches of lib/outgoing-hosts.ts read this (#903).
+ noThirdPartyTraffic: await isNoThirdPartyTrafficOn(),
},
{
// Runtime settings can change without new asset URLs. Prevent stale
diff --git a/app/api/connector/capabilities/route.ts b/app/api/connector/capabilities/route.ts
index 4ff6fd1a1..2235298b9 100644
--- a/app/api/connector/capabilities/route.ts
+++ b/app/api/connector/capabilities/route.ts
@@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { configManager } from '@/lib/admin/config-manager';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
import { TARGET_NAMES } from '@/lib/connector/registry';
/**
@@ -28,7 +29,7 @@ import { TARGET_NAMES } from '@/lib/connector/registry';
export async function GET() {
await configManager.ensureLoaded();
- if (!configManager.get('connectorEnabled', true)) {
+ if (!configManager.get('connectorEnabled', true) || await isNoThirdPartyTrafficOn()) {
return new NextResponse(null, { status: 404 });
}
diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts
index 7b8128fdb..d437585ce 100644
--- a/app/api/dev-jmap/[...path]/route.ts
+++ b/app/api/dev-jmap/[...path]/route.ts
@@ -4,6 +4,7 @@ import { createDemoFileNodes } from '@/lib/demo/fixtures/files';
import { generateDemoId } from '@/lib/demo/demo-utils';
import type { FileNode } from '@/lib/jmap/types';
import JSZip from 'jszip';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
/**
* Mock JMAP server for local development.
@@ -2035,9 +2036,14 @@ function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodRes
return ['VacationResponse/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'vacation-1', isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null }], notFound: [] }, callId];
}
+// The sample photos come from randomuser.me: none while third-party traffic
+// is off (#903). Set per request in POST, which can await the config.
+let samplePhotos = true;
+
function handleContactCardGet(args: MethodArgs, callId: string): MethodResult {
const ids = args.ids as string[] | undefined;
- const list = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts;
+ const found = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts;
+ const list = samplePhotos ? found : found.map(({ media: _media, ...rest }) => rest);
return ['ContactCard/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId];
}
@@ -2826,6 +2832,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{
// JMAP API endpoint
if (joined === 'api') {
+ samplePhotos = !(await isNoThirdPartyTrafficOn());
try {
const body = await request.json();
const methodCalls = body.methodCalls as Array<[string, MethodArgs, string]>;
diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts
index 7dc48fab8..311757fbe 100644
--- a/app/api/favicon/route.ts
+++ b/app/api/favicon/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
+import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
// In-memory LRU cache: domain -> { data, contentType, fetchedAt }
const CACHE_MAX_SIZE = 1000;
@@ -439,6 +440,10 @@ function evictOldest() {
}
export async function GET(request: NextRequest) {
+ // Sender icons come from DuckDuckGo: off, before anything else, while the
+ // server must not contact outside services (#903).
+ if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('Sender icons');
+
const domain = request.nextUrl.searchParams.get('domain');
if (!domain || !isValidDomain(domain)) {
diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts
index 5da089c90..2715160e7 100644
--- a/app/api/plugin-proxy/route.ts
+++ b/app/api/plugin-proxy/route.ts
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
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';
@@ -41,6 +42,8 @@ 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 creds = await getStalwartCredentials(request);
if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 });
diff --git a/app/api/plugin-secrets/__tests__/route.test.ts b/app/api/plugin-secrets/__tests__/route.test.ts
index 60b85a064..7f33684cb 100644
--- a/app/api/plugin-secrets/__tests__/route.test.ts
+++ b/app/api/plugin-secrets/__tests__/route.test.ts
@@ -13,7 +13,7 @@ vi.mock('@/lib/stalwart/credentials', () => ({
getStalwartCredentials: async () => (signedIn ? { ...claimed, serverUrl: 'https://mail.example.org', authHeader: 'x' } : null),
}));
vi.mock('@/lib/admin/config-manager', () => ({
- configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features: { pluginsEnabled } }) },
+ configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled } }) },
}));
vi.mock('@/lib/security/same-origin', async () => {
const { NextResponse } = await import('next/server');
diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts
index 238ecc0e7..b5652688a 100644
--- a/app/api/plugins/route.ts
+++ b/app/api/plugins/route.ts
@@ -4,6 +4,8 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry
import { listDevPlugins } from '@/lib/admin/plugin-dev';
import { configManager } from '@/lib/admin/config-manager';
import { logger } from '@/lib/logger';
+import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
+import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts';
/**
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
@@ -32,11 +34,14 @@ export async function GET(request: NextRequest) {
// plugin without uninstalling it first.
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.
+ const noThirdParty = await isNoThirdPartyTrafficOn();
const plugins = [
...devEntries.map(e => ({ ...e.plugin, dev: true })),
...installedEnabled.map(p => ({ ...p, dev: false })),
- ].map(p => ({
+ ].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({
id: p.id,
name: p.name,
version: p.version,
diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts
index cdf729636..7507303f6 100644
--- a/app/api/translate/route.ts
+++ b/app/api/translate/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { readFileEnv } from "@/lib/read-file-env";
import { getStalwartCredentials } from '@/lib/stalwart/credentials';
+import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode';
// Host-side proxy backing the "Translate" plugin (manifest apiPostPaths:
// ["/api/translate"]). The plugin slot iframe POSTs { text, target, source,
@@ -284,6 +285,10 @@ export async function POST(request: NextRequest) {
const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en';
const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto';
const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory';
+ // MyMemory is a public service; the admin's own LibreTranslate is not (#903).
+ if (provider === 'mymemory' && await isNoThirdPartyTrafficOn()) {
+ return thirdPartyRefusal('Translation through MyMemory');
+ }
if (!text) {
return NextResponse.json({ error: 'No text to translate' }, { status: 400 });
diff --git a/components/admin/__tests__/third-party-section.test.tsx b/components/admin/__tests__/third-party-section.test.tsx
new file mode 100644
index 000000000..8ecadf4ac
--- /dev/null
+++ b/components/admin/__tests__/third-party-section.test.tsx
@@ -0,0 +1,129 @@
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { listReachableHosts } from '@/lib/admin/reachable-hosts';
+import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
+import type { OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft';
+
+const apiFetch = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/browser-navigation', async (orig) => ({
+ ...(await orig()),
+ apiFetch: (...args: unknown[]) => apiFetch(...args),
+}));
+
+const { ThirdPartySection } = await import('@/components/admin/org/sections/third-party-section');
+
+const setMessage = vi.fn();
+const draft = { setMessage } as unknown as OrgPolicyDraft;
+
+function state(overrides: Record = {}) {
+ return {
+ noThirdPartyTraffic: false,
+ source: 'default',
+ readOnly: false,
+ envLocked: false,
+ builtIn: OUTGOING_HOSTS,
+ reachable: [{ kind: 'JMAP server', host: 'mail.corp.example', setIn: 'JMAP_SERVER_URL', side: 'both' }],
+ networkPlugins: [],
+ ...overrides,
+ };
+}
+
+function switchOf() {
+ return within(screen.getByTestId('third-party-switch')).getByRole('switch');
+}
+
+beforeEach(() => {
+ apiFetch.mockReset();
+ setMessage.mockReset();
+});
+
+describe('ThirdPartySection', () => {
+ it('saves the switch as config through /api/admin/config', async () => {
+ let current = state();
+ apiFetch.mockImplementation(async (url: string, init?: RequestInit) => {
+ if (url === '/api/admin/config' && init?.method === 'PATCH') {
+ current = state({ noThirdPartyTraffic: true, source: 'admin' });
+ return Response.json({ ok: true });
+ }
+ return Response.json(current);
+ });
+ render();
+ await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'false'));
+ expect(screen.getByTestId('third-party-scope-note').textContent).toMatch(/whole server, not per domain or group/);
+ expect(screen.getByTestId('third-party-reachable').textContent).toContain('mail.corp.example');
+
+ await act(async () => { fireEvent.click(switchOf()); });
+ expect(apiFetch).toHaveBeenCalledWith('/api/admin/config', expect.objectContaining({
+ method: 'PATCH', body: JSON.stringify({ noThirdPartyTraffic: true }),
+ }));
+ await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'true'));
+ expect(screen.getByText('config.json')).toBeInTheDocument();
+ // No relay of their own: push needs one now.
+ expect(screen.getByText(/Push notifications now need a relay you run yourself/)).toBeInTheDocument();
+ });
+
+ it('is read-only when the environment sets it', async () => {
+ apiFetch.mockImplementation(async () => Response.json(state({ noThirdPartyTraffic: true, source: 'env', envLocked: true })));
+ render();
+ await waitFor(() => expect(switchOf()).toBeDisabled());
+ expect(screen.getByText('NO_THIRD_PARTY_TRAFFIC')).toBeInTheDocument();
+ expect(screen.getByText(/Set by NO_THIRD_PARTY_TRAFFIC in the environment/)).toBeInTheDocument();
+ });
+
+ it('is read-only on a read-only config', async () => {
+ apiFetch.mockImplementation(async () => Response.json(state({ readOnly: true })));
+ render();
+ await waitFor(() => expect(switchOf()).toBeDisabled());
+ expect(screen.getByText(/The configuration is read-only/)).toBeInTheDocument();
+ });
+});
+
+describe('listReachableHosts', () => {
+ it('lists the hosts the deployment configured and leaves the built-in ones out', () => {
+ const config: Record = {
+ jmapServerUrl: 'https://mail.corp.example',
+ jmapServers: [{ id: 'b', label: 'Branch', url: 'https://jmap.branch.example', oauth: { issuerUrl: 'https://sso.corp.example' } }],
+ oauthEnabled: false,
+ oauthIssuerUrl: 'https://ignored.example',
+ extensionDirectoryUrl: 'https://extensions.bulwarkmail.org',
+ wopiClientUrl: 'https://office.corp.example',
+ loginLogoLightUrl: '/branding/Bulwark_Logo_Color.svg',
+ appLogoLightUrl: 'https://cdn.corp.example/logo.svg',
+ };
+ const hosts = listReachableHosts({
+ get: (key) => config[key],
+ policy: {
+ pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }, { label: 'Built-in', url: 'https://notifications.relay.bulwarkmail.org' }],
+ pushRelayUrl: '',
+ defaultSidebarApps: [{ id: 'w', name: 'Wiki', url: 'https://wiki.corp.example', icon: 'Globe', openMode: 'inline', showOnMobile: true }],
+ },
+ env: { LIBRETRANSLATE_URL: 'https://translate.corp.example' },
+ });
+ expect(hosts.map((h) => `${h.kind}: ${h.host}`)).toEqual([
+ 'JMAP server: mail.corp.example',
+ 'JMAP server: jmap.branch.example',
+ 'OAuth issuer: sso.corp.example',
+ 'Push relay: push.corp.example',
+ 'Sidebar app: wiki.corp.example',
+ 'Document editor (WOPI): office.corp.example',
+ 'Translation (LibreTranslate): translate.corp.example',
+ 'Branding image: cdn.corp.example',
+ 'Sidebar apps users add: any address your users enter',
+ 'Calendar subscriptions: any address your users enter',
+ ]);
+ });
+
+ it('names the relay browsers were built with, flags a different one in the environment, and lists what users enter', () => {
+ const hosts = listReachableHosts({
+ get: (key) => ({ allowCustomJmapEndpoint: true } as Record)[key],
+ policy: { pushRelays: [], pushRelayUrl: '', defaultSidebarApps: [], features: { calendarEnabled: false, sidebarAppsEnabled: false } },
+ env: { NEXT_PUBLIC_PUSH_RELAY_URL: 'https://push.runtime.example' },
+ buildRelayUrl: 'https://push.build.example',
+ });
+ expect(hosts.map((h) => [h.host, h.setIn, !!h.note])).toEqual([
+ ['push.build.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (build)', false],
+ ['push.runtime.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (environment)', true],
+ ['any address your users enter', 'ALLOW_CUSTOM_JMAP_ENDPOINT', true],
+ ]);
+ });
+});
diff --git a/components/admin/org/__tests__/external-mail-section.test.tsx b/components/admin/org/__tests__/external-mail-section.test.tsx
new file mode 100644
index 000000000..29a1ff1b3
--- /dev/null
+++ b/components/admin/org/__tests__/external-mail-section.test.tsx
@@ -0,0 +1,128 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+
+// Organisation > Security > External mail: the switches and the domain list
+// go into the draft at the chosen scope, and the External Mail Warning
+// plugin's safe domains can be copied in as an unsaved change.
+
+const puts: Array> = [];
+let stored: Record = {};
+let plugins: Array> = [];
+let pluginConfig: Record = {};
+vi.mock('@/lib/browser-navigation', () => ({
+ apiFetch: vi.fn(async (url: string, init?: RequestInit) => {
+ if (url === '/api/admin/plugins') return new Response(JSON.stringify(plugins));
+ if (url === '/api/admin/plugins/external-mail-warning/config') return new Response(JSON.stringify(pluginConfig));
+ if (init?.method === 'PUT') {
+ const body = JSON.parse(String(init.body));
+ puts.push(body);
+ stored = body;
+ return new Response('{"ok":true}');
+ }
+ return new Response(JSON.stringify(stored));
+ }),
+ withBasePath: (p: string) => p,
+ getPathPrefix: () => '',
+}));
+
+import { ExternalMailSection } from '../sections/external-mail-section';
+import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft';
+
+const POLICY = {
+ restrictions: {}, features: {}, defaults: {}, themePolicy: {},
+ forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [],
+ organisation: { externalMail: { domains: ['partner.com'] } },
+ scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }],
+};
+
+let latest: ReturnType | null = null;
+function Harness() {
+ const draft = useOrgPolicyDraft('test');
+ latest = draft;
+ if (draft.loading) return null;
+ return ;
+}
+
+async function renderSection() {
+ render();
+ await waitFor(() => expect(screen.getByTestId('org-external-sender-label')).toBeInTheDocument());
+}
+
+beforeEach(() => {
+ puts.length = 0;
+ stored = JSON.parse(JSON.stringify(POLICY));
+ plugins = [];
+ pluginConfig = {};
+ latest = null;
+ useOrgScopeStore.setState({ scopeId: EVERYONE });
+});
+
+describe('External mail section', () => {
+ it('starts with both switches off and saves what the admin turns on', async () => {
+ await renderSection();
+ expect(screen.getByTestId('org-external-sender-label').getAttribute('aria-checked')).toBe('false');
+ expect(screen.getByTestId('org-external-recipient-warning').getAttribute('aria-checked')).toBe('false');
+ fireEvent.click(screen.getByTestId('org-external-sender-label'));
+ fireEvent.click(screen.getByTestId('org-external-recipient-warning'));
+ await act(async () => { await latest!.save(); });
+ expect((puts[0].organisation as { externalMail: unknown }).externalMail).toMatchObject({ senderLabel: true, recipientWarning: true, domains: ['partner.com'] });
+ });
+
+ it('writes a scope override only into that scope', async () => {
+ await renderSection();
+ act(() => latest!.setScopeId('two'));
+ fireEvent.click(screen.getByTestId('org-external-sender-label'));
+ expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ externalMail: { senderLabel: true } });
+ expect(latest!.policy?.organisation?.externalMail.senderLabel).toBe(false);
+ });
+
+ it('takes the typed domain list apart when the field is left', async () => {
+ await renderSection();
+ const field = screen.getByTestId('org-external-domains');
+ fireEvent.change(field, { target: { value: 'partner.com, Sister.example\n@third.test' } });
+ fireEvent.blur(field);
+ expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'sister.example', 'third.test']);
+ expect(screen.queryByTestId('org-external-domains-rejected')).toBeNull();
+ });
+
+ it('stores an IDN in ASCII, shows it in Unicode and names entries that are not domains', async () => {
+ await renderSection();
+ const field = screen.getByTestId('org-external-domains') as HTMLTextAreaElement;
+ fireEvent.change(field, { target: { value: 'bücher.de\nnot_a_domain, x' } });
+ fireEvent.blur(field);
+ expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['xn--bcher-kva.de']);
+ expect(screen.getByTestId('org-external-domains-rejected').textContent).toContain('not_a_domain, x');
+ await waitFor(() => expect(field.value).toBe('bücher.de'), { timeout: 5000 });
+ });
+
+ it('offers no import and no note without the plugin', async () => {
+ await renderSection();
+ fireEvent.click(screen.getByTestId('org-external-sender-label'));
+ expect(screen.queryByTestId('org-external-import-plugin')).toBeNull();
+ expect(screen.queryByTestId('org-external-plugin-note')).toBeNull();
+ });
+
+ it('asks to update an old plugin while the warnings are on and imports its safe domains as a draft change', async () => {
+ plugins = [{ id: 'external-mail-warning', version: '1.5.1' }];
+ pluginConfig = { safeDomains: 'partner.com, acme.example\nsub.acme.example' };
+ await renderSection();
+ await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument());
+ expect(screen.queryByTestId('org-external-plugin-note')).toBeNull();
+
+ fireEvent.click(screen.getByTestId('org-external-recipient-warning'));
+ expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('Update or remove the External Mail Warning plugin');
+
+ await act(async () => { fireEvent.click(screen.getByTestId('org-external-import-plugin')); });
+ await waitFor(() => expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'acme.example', 'sub.acme.example']));
+ expect(latest!.dirty).toBe(true);
+ expect(puts).toHaveLength(0);
+ });
+
+ it('only suggests removing a plugin that steps back by itself', async () => {
+ plugins = [{ id: 'external-mail-warning', version: '1.6.0' }];
+ await renderSection();
+ await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument());
+ fireEvent.click(screen.getByTestId('org-external-sender-label'));
+ expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('You can remove the External Mail Warning plugin');
+ });
+});
diff --git a/components/admin/org/sections/delegates-section.tsx b/components/admin/org/sections/delegates-section.tsx
index d6af70217..1fecb99c5 100644
--- a/components/admin/org/sections/delegates-section.tsx
+++ b/components/admin/org/sections/delegates-section.tsx
@@ -1,14 +1,34 @@
'use client';
import { SettingsSection } from '@/components/settings/settings-section';
+import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys';
+import { RestrictionRow } from '../org-page';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
-/** Organisation feature 7; filled in by its own step (local-data/org-features-plan.md). */
+const DELEGATION = RESTRICTION_KEYS.find((def) => def.key === 'delegation')!;
+const SHARING_RIGHTS = RESTRICTION_KEYS.find((def) => def.key === 'sharingRights')!;
+
+/** Organisation > Sharing > Delegates: whether users may delegate, and which rights any share dialog offers. */
export function DelegatesSection({ draft }: { draft: OrgPolicyDraft }) {
- void draft;
+ const rights = draft.restriction(SHARING_RIGHTS.key).value;
+ const noneTicked = Array.isArray(rights?.allowedValues) && rights.allowedValues.length === 0;
return (
-
-
Nothing to set here yet.
+
+
+
+ {noneTicked && (
+
+ With neither ticked nothing is limited, so users can give both. Tick "read" to allow read access only.
+
+ )}
+
+ Both limit what Bulwark offers. Stalwart doesn't enforce them, so users can still share through other clients. Shared
+ mailboxes of groups are yours to manage in Stalwart.
+
);
}
diff --git a/components/admin/org/sections/external-mail-section.tsx b/components/admin/org/sections/external-mail-section.tsx
index 44c7a8a5a..e77c38efc 100644
--- a/components/admin/org/sections/external-mail-section.tsx
+++ b/components/admin/org/sections/external-mail-section.tsx
@@ -1,14 +1,184 @@
'use client';
-import { SettingsSection } from '@/components/settings/settings-section';
+import { useEffect, useState } from 'react';
+import { Download, Info } from '@/components/icons';
+import { Button } from '@/components/ui/button';
+import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
+import { AdminLink } from '@/components/admin/admin-link';
+import { parseDomainList } from '@/lib/admin/organisation-policy';
+import { toUnicodeDomain } from '@/lib/idn';
+import { apiFetch } from '@/lib/browser-navigation';
+import { EXTERNAL_MAIL_WARNING_PLUGIN_ID } from '@/lib/plugin-sandbox/host-features';
+import { InheritNote } from '../org-page';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
-/** Organisation feature 2; filled in by its own step (local-data/org-features-plan.md). */
+/** The first release of the plugin core took over from that steps back on its own. */
+export const PLUGIN_STEPS_BACK_FROM = '1.6.0';
+
+function older(version: string, than: string): boolean {
+ const a = version.split(/[-+]/)[0].split('.').map(Number);
+ const b = than.split(/[-+]/)[0].split('.').map(Number);
+ for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0);
+ return false;
+}
+
+type InstalledPlugin = { version: string };
+
+/** The External Mail Warning plugin when the admin installed it, else null. */
+function useInstalledPlugin(): InstalledPlugin | null {
+ const [plugin, setPlugin] = useState(null);
+ useEffect(() => {
+ let cancelled = false;
+ void (async () => {
+ try {
+ const res = await apiFetch('/api/admin/plugins');
+ const list = res.ok ? ((await res.json()) as Array<{ id: string; version?: string }>) : [];
+ const found = Array.isArray(list) ? list.find((p) => p.id === EXTERNAL_MAIL_WARNING_PLUGIN_ID) : undefined;
+ if (!cancelled) setPlugin(found ? { version: found.version ?? '' } : null);
+ } catch {
+ if (!cancelled) setPlugin(null);
+ }
+ })();
+ return () => { cancelled = true; };
+ }, []);
+ return plugin;
+}
+
+const onOff = (v: boolean) => (v ? 'on' : 'off');
+
+/**
+ * Organisation > Security > External mail: the "External" sender label,
+ * the confirm before sending outside, which domains are internal, and the
+ * hand-over from the External Mail Warning plugin.
+ */
export function ExternalMailSection({ draft }: { draft: OrgPolicyDraft }) {
- void draft;
+ const senderLabel = draft.orgValue('externalMail', 'senderLabel');
+ const recipientWarning = draft.orgValue('externalMail', 'recipientWarning');
+ const domains = draft.orgValue('externalMail', 'domains');
+ const trustContacts = draft.orgValue('externalMail', 'trustContacts');
+ const plugin = useInstalledPlugin();
+ const anyOn = senderLabel.value || recipientWarning.value;
+
+ // The list is typed as text and taken apart when the field is left, so a
+ // half-typed domain isn't thrown away on every key. It is stored in ASCII
+ // (xn--) form and shown in Unicode; entries that aren't domains are named.
+ const stored = domains.value.join('\n');
+ const joined = domains.value.map(toUnicodeDomain).join('\n');
+ const [text, setText] = useState(joined);
+ const [rejected, setRejected] = useState([]);
+ useEffect(() => { setText(joined); }, [joined]);
+ const commitDomains = (raw: string) => {
+ const parsed = parseDomainList(raw) ?? { domains: [], rejected: [] };
+ setRejected(parsed.rejected);
+ if (parsed.domains.join('\n') !== stored) draft.setOrgValue('externalMail', 'domains', parsed.domains);
+ else setText(joined);
+ };
+
+ const [importing, setImporting] = useState(false);
+ const importFromPlugin = async () => {
+ setImporting(true);
+ try {
+ const res = await apiFetch(`/api/admin/plugins/${EXTERNAL_MAIL_WARNING_PLUGIN_ID}/config`);
+ if (!res.ok) throw new Error(String(res.status));
+ const config = (await res.json()) as Record;
+ const parsed = parseDomainList(config.safeDomains) ?? { domains: [], rejected: [] };
+ const fromPlugin = parsed.domains;
+ setRejected(parsed.rejected);
+ const added = fromPlugin.filter((d) => !domains.value.includes(d));
+ if (added.length > 0) draft.setOrgValue('externalMail', 'domains', [...domains.value, ...added]);
+ draft.setMessage({
+ type: 'success',
+ text: added.length > 0
+ ? `Added ${added.length} ${added.length === 1 ? 'domain' : 'domains'} from the plugin. Save to apply them.`
+ : fromPlugin.length > 0 ? 'The list already has every domain the plugin had.' : 'The plugin has no safe domains set.',
+ });
+ } catch {
+ draft.setMessage({ type: 'error', text: 'Could not read the plugin settings' });
+ } finally {
+ setImporting(false);
+ }
+ };
+
+ const outdated = !!plugin && older(plugin.version, PLUGIN_STEPS_BACK_FROM);
+
return (
-
-
+ Your users' own domains always count as internal. Add partner or sister-company domains here, one per line or separated by commas. A domain covers its subdomains: example.com also covers eu.example.com.
+
+
+
+ {plugin && anyOn && (
+
+
+
+
+ {outdated ? 'Update or remove the External Mail Warning plugin' : 'You can remove the External Mail Warning plugin'}
+
+
+ {outdated
+ ? `Version ${plugin.version || 'unknown'} is installed. For each switch that is on, Bulwark skips the matching part of the plugin (its send confirm, or its banner and new-mail toast) so nobody is warned twice; version ${PLUGIN_STEPS_BACK_FROM} and later step back by themselves.`
+ : 'It turns off its own send confirm, banner and toast for each switch that is on here.'}{' '}
+ “Import from the plugin” copies its safe domains into the list above.{' '}
+ Open the plugin
+
+
+
+ )}
);
}
diff --git a/components/admin/org/sections/third-party-section.tsx b/components/admin/org/sections/third-party-section.tsx
index 2736dc26b..9280d8b48 100644
--- a/components/admin/org/sections/third-party-section.tsx
+++ b/components/admin/org/sections/third-party-section.tsx
@@ -1,14 +1,190 @@
'use client';
-import { SettingsSection } from '@/components/settings/settings-section';
+import { useCallback, useEffect, useState } from 'react';
+import { Loader2 } from '@/components/icons';
+import { SettingsSection, SettingItem, ToggleSwitch } from '@/components/settings/settings-section';
+import { ConfigNote, RevertButton, SourceBadge } from '@/components/admin/config-items';
+import { AdminLink } from '@/components/admin/admin-link';
+import { apiFetch } from '@/lib/browser-navigation';
+import type { OutgoingHost } from '@/lib/admin/outgoing-hosts-registry';
+import type { ReachableHost } from '@/lib/admin/reachable-hosts';
import type { OrgPolicyDraft } from '../use-org-policy-draft';
-/** Organisation feature 5; filled in by its own step (local-data/org-features-plan.md). */
+interface OutgoingHostsState {
+ noThirdPartyTraffic: boolean;
+ source: 'env' | 'admin' | 'default';
+ readOnly: boolean;
+ envLocked: boolean;
+ builtIn: OutgoingHost[];
+ reachable: ReachableHost[];
+ networkPlugins: { id: string; name: string }[];
+}
+
+const SIDE_LABEL: Record = {
+ server: 'this server',
+ browser: 'the browser',
+ both: 'server and browser',
+};
+
+/**
+ * Organisation > Security > Third-party traffic (#903). The switch is config,
+ * not policy: it is saved through /api/admin/config like the Server page's
+ * keys, applies to the whole server whatever scope the scope bar shows, and
+ * is read-only when the environment or a read-only config sets it.
+ */
export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) {
- void draft;
+ const [state, setState] = useState(null);
+ const [failed, setFailed] = useState(false);
+ const [saving, setSaving] = useState(false);
+ const { setMessage } = draft;
+
+ const load = useCallback(async () => {
+ try {
+ const res = await apiFetch('/api/admin/outgoing-hosts');
+ if (!res.ok) throw new Error();
+ setState(await res.json());
+ setFailed(false);
+ } catch {
+ setFailed(true);
+ }
+ }, []);
+
+ useEffect(() => { void load(); }, [load]);
+
+ async function save(on: boolean) {
+ setSaving(true);
+ try {
+ const res = await apiFetch('/api/admin/config', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ noThirdPartyTraffic: on }),
+ });
+ if (res.ok) {
+ setMessage({
+ type: 'success',
+ text: on
+ ? 'Third-party traffic is off. The server stops at once; browsers follow the next time Bulwark loads.'
+ : 'Third-party traffic is allowed again.',
+ });
+ } else {
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not save the setting' });
+ }
+ } catch {
+ setMessage({ type: 'error', text: 'Could not save the setting' });
+ } finally {
+ await load();
+ setSaving(false);
+ }
+ }
+
+ async function revert() {
+ setSaving(true);
+ try {
+ const res = await apiFetch('/api/admin/config', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ key: 'noThirdPartyTraffic' }),
+ });
+ if (!res.ok) {
+ const data = await res.json().catch(() => ({}));
+ setMessage({ type: 'error', text: data.error || 'Could not revert the setting' });
+ }
+ } catch {
+ setMessage({ type: 'error', text: 'Could not revert the setting' });
+ } finally {
+ await load();
+ setSaving(false);
+ }
+ }
+
+ const on = !!state?.noThirdPartyTraffic;
+ const locked = !!state && (state.readOnly || state.envLocked);
+ const relays = state?.reachable.filter((h) => h.kind === 'Push relay') ?? [];
+
return (
-
Nothing to set here yet.
+
+ This switch is for the whole server, not per domain or group: most of what it stops, such as telemetry and the version check, runs for no particular user.
+
+
+
+ {state.envLocked && (
+ Set by NO_THIRD_PARTY_TRAFFIC in the environment. Change it there and restart Bulwark.
+ )}
+ {state.readOnly && !state.envLocked && (
+ The configuration is read-only. Set noThirdPartyTraffic in config.json, or NO_THIRD_PARTY_TRAFFIC in the environment.
+ )}
+ {on && relays.length === 0 && (
+
+ Push notifications now need a relay you run yourself. Add one under{' '}
+ User policy > Push relays;
+ until then users get new mail only while Bulwark is open.
+
+ )}
+ {on && state.networkPlugins.length > 0 && (
+
+ Not running because they declare network access: {state.networkPlugins.map((p) => p.name).join(', ')}.
+
+ )}
+
+
+
{on ? 'Still reachable' : 'Stays reachable with the switch on'}
+
Hosts you configured yourself, and what users enter themselves.
+ )}
)}
diff --git a/components/email/external-recipient-confirm.tsx b/components/email/external-recipient-confirm.tsx
new file mode 100644
index 000000000..0a3130a28
--- /dev/null
+++ b/components/email/external-recipient-confirm.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import { useEffect, useId, type KeyboardEvent } from 'react';
+import { useTranslations } from 'next-intl';
+import { Building } from '@/components/icons';
+import { Button } from '@/components/ui/button';
+import { useFocusTrap } from '@/hooks/use-focus-trap';
+import { registerExternalRecipientCheck } from '@/lib/outgoing/external-recipients';
+import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
+
+/**
+ * "Send outside your organisation?", asked once per send with every
+ * external recipient listed. Mounted once in the app layout; while it is
+ * mounted the pre-send check is registered, so the composer and quick reply
+ * both ask through it. Sits above the composer like the plugin dialogs.
+ */
+export function ExternalRecipientConfirmHost() {
+ const t = useTranslations('external_mail');
+ const pending = useExternalRecipientConfirmStore((s) => s.pending);
+ const answer = useExternalRecipientConfirmStore((s) => s.answer);
+ const id = useId();
+
+ useEffect(() => registerExternalRecipientCheck(), []);
+
+ // A send can't be waiting on a dialog that is gone.
+ useEffect(() => () => useExternalRecipientConfirmStore.getState().cancelAll(), []);
+
+ // Escape is handled below with the other keys, not by the trap: one key, one answer.
+ const dialogRef = useFocusTrap({ isActive: !!pending, restoreFocus: true });
+
+ // Keys stay in the dialog: Escape must not also close the message behind
+ // it, j/k must not move the list, Enter must not open a message. Enter and
+ // Space press the focused button here, whatever a page listener did.
+ const onKeyDown = (e: KeyboardEvent) => {
+ e.stopPropagation();
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ answer(false);
+ return;
+ }
+ if ((e.key === 'Enter' || e.key === ' ') && e.target instanceof HTMLButtonElement) {
+ e.preventDefault();
+ e.target.click();
+ }
+ };
+
+ if (!pending) return null;
+ const { recipients } = pending;
+ const field = { to: t('field_to'), cc: t('field_cc'), bcc: t('field_bcc') };
+
+ return (
+