Merge branch 'feat/org-features' into feat/org-templates

# Conflicts:
#	lib/admin/restriction-keys.ts
This commit is contained in:
Linus Rath
2026-10-28 07:10:25 +01:00
136 changed files with 9359 additions and 266 deletions
+6
View File
@@ -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 }}
+3
View File
@@ -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)
+2
View File
@@ -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({
<AccountTemplateSync />
{children}
<PluginDialogHost />
<ExternalRecipientConfirmHost />
<CommandPaletteHost />
<PluginConnectDialog />
<QuickRuleHost />
+5 -3
View File
@@ -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 (
+6
View File
@@ -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'}
</span>
</SettingItem>
{data.disabledByNoThirdPartyTraffic && (
<SettingItem label="Off: no third-party traffic" description="NO_THIRD_PARTY_TRAFFIC is on, so this server never asks the version endpoint. See Organisation > Security.">
<span className="text-sm font-medium text-amber-600 dark:text-amber-400">Yes</span>
</SettingItem>
)}
</SettingsSection>
</div>
</div>
+12
View File
@@ -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)) {
@@ -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');
+8
View File
@@ -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<string, string | undefined> = {};
@@ -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();
@@ -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;
+49
View File
@@ -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 });
}
}
+6
View File
@@ -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 });
}
+2
View File
@@ -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,
+3
View File
@@ -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<string>('parentOrigin', ''),
sourceCodeUrl: httpUrlOrEmpty(configManager.get<string>('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
+2 -1
View File
@@ -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<boolean>('connectorEnabled', true)) {
if (!configManager.get<boolean>('connectorEnabled', true) || await isNoThirdPartyTrafficOn()) {
return new NextResponse(null, { status: 404 });
}
+8 -1
View File
@@ -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]>;
+5
View File
@@ -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)) {
+3
View File
@@ -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 });
@@ -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');
+6 -1
View File
@@ -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,
+5
View File
@@ -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 });
@@ -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<typeof import('@/lib/browser-navigation')>()),
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<string, unknown> = {}) {
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(<ThirdPartySection draft={draft} />);
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(<ThirdPartySection draft={draft} />);
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(<ThirdPartySection draft={draft} />);
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<string, unknown> = {
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<string, unknown>)[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],
]);
});
});
@@ -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<Record<string, unknown>> = [];
let stored: Record<string, unknown> = {};
let plugins: Array<Record<string, unknown>> = [];
let pluginConfig: Record<string, unknown> = {};
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<typeof useOrgPolicyDraft> | null = null;
function Harness() {
const draft = useOrgPolicyDraft('test');
latest = draft;
if (draft.loading) return null;
return <ExternalMailSection draft={draft} />;
}
async function renderSection() {
render(<Harness />);
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');
});
});
@@ -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 (
<SettingsSection id="delegates" title="Delegates" description="Whether users may give a colleague access to their mail, calendars and contacts.">
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
<SettingsSection
id="delegates"
title="Delegates"
description="Users can give a colleague read or read and write access to their mail folders, calendars and address books in one step, under Settings > Sharing. Stalwart doesn't let delegates send as the user; for an address several people send from, set up a shared mailbox."
>
<RestrictionRow draft={draft} def={DELEGATION} />
<RestrictionRow draft={draft} def={SHARING_RIGHTS} />
{noneTicked && (
<p className="text-xs text-destructive" role="status">
With neither ticked nothing is limited, so users can give both. Tick &quot;read&quot; to allow read access only.
</p>
)}
<p className="text-xs text-muted-foreground">
Both limit what Bulwark offers. Stalwart doesn&apos;t enforce them, so users can still share through other clients. Shared
mailboxes of groups are yours to manage in Stalwart.
</p>
</SettingsSection>
);
}
@@ -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<InstalledPlugin | null>(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<string[]>([]);
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<string, unknown>;
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 (
<SettingsSection id="external" title="External mail" description="Label mail from senders outside the organisation and confirm before sending outside it.">
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
<SettingsSection
id="external"
title="External mail"
description="Label mail from senders outside the organisation and confirm before sending outside it. Both are off until you turn them on."
>
<SettingItem label="Label external senders" description="Shows an “External” chip next to the sender of mail from outside the organisation.">
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={senderLabel.value} onChange={(v) => draft.setOrgValue('externalMail', 'senderLabel', v)} testId="org-external-sender-label" />
<InheritNote draft={draft} own={senderLabel.own} everyone={onOff(senderLabel.everyone)} onReset={() => draft.clearOrgValue('externalMail', 'senderLabel')} />
</div>
</SettingItem>
<SettingItem label="Confirm external recipients" description="Before a message goes out, lists the recipients outside the organisation and asks “Send anyway” or “Back”. Quick replies too.">
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={recipientWarning.value} onChange={(v) => draft.setOrgValue('externalMail', 'recipientWarning', v)} testId="org-external-recipient-warning" />
<InheritNote draft={draft} own={recipientWarning.own} everyone={onOff(recipientWarning.everyone)} onReset={() => draft.clearOrgValue('externalMail', 'recipientWarning')} />
</div>
</SettingItem>
<SettingItem label="Trust contacts" description="Recipients in the user's contacts are sent to without asking.">
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={trustContacts.value} onChange={(v) => draft.setOrgValue('externalMail', 'trustContacts', v)} testId="org-external-trust-contacts" />
<InheritNote draft={draft} own={trustContacts.own} everyone={onOff(trustContacts.everyone)} onReset={() => draft.clearOrgValue('externalMail', 'trustContacts')} />
</div>
</SettingItem>
<div className="space-y-2 py-3">
<label htmlFor="org-external-domains" className="text-sm font-medium text-foreground">More organisation domains</label>
<p className="text-xs text-muted-foreground">
Your users&apos; 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.
</p>
<textarea
id="org-external-domains"
data-testid="org-external-domains"
rows={4}
value={text}
onChange={(e) => setText(e.target.value)}
onBlur={(e) => commitDomains(e.target.value)}
placeholder="partner.com"
spellCheck={false}
className="w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
{rejected.length > 0 && (
<p className="text-xs text-destructive" role="status" data-testid="org-external-domains-rejected">
Left out, not a domain: {rejected.join(', ')}
</p>
)}
<InheritNote
draft={draft}
own={domains.own}
everyone={domains.everyone.length ? domains.everyone.map(toUnicodeDomain).join(', ') : 'none'}
onReset={() => draft.clearOrgValue('externalMail', 'domains')}
/>
{plugin && (
<Button type="button" variant="outline" size="sm" onClick={() => void importFromPlugin()} disabled={importing} className="gap-1.5" data-testid="org-external-import-plugin">
<Download className="h-4 w-4" /> Import from the plugin
</Button>
)}
</div>
{plugin && anyOn && (
<div className="flex items-start gap-2 rounded-md border border-border bg-muted/40 p-3 text-sm" data-testid="org-external-plugin-note">
<Info className="mt-0.5 h-4 w-4 flex-shrink-0 text-muted-foreground" />
<div className="min-w-0 space-y-1">
<p className="font-medium text-foreground">
{outdated ? 'Update or remove the External Mail Warning plugin' : 'You can remove the External Mail Warning plugin'}
</p>
<p className="text-muted-foreground">
{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.'}{' '}
&ldquo;Import from the plugin&rdquo; copies its safe domains into the list above.{' '}
<AdminLink href={`/admin/plugins/${EXTERNAL_MAIL_WARNING_PLUGIN_ID}`} className="underline underline-offset-2 hover:text-foreground">Open the plugin</AdminLink>
</p>
</div>
</div>
)}
</SettingsSection>
);
}
@@ -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<ReachableHost['side'], string> = {
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<OutgoingHostsState | null>(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 (
<SettingsSection id="third-party" title="Third-party traffic" description="Whether Bulwark connects to anything outside your own servers.">
<p className="text-sm text-muted-foreground">Nothing to set here yet.</p>
<p className="text-sm text-muted-foreground" data-testid="third-party-scope-note">
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.
</p>
{!state && !failed && (
<div className="flex items-center py-3 text-sm text-muted-foreground">
<Loader2 className="me-2 h-4 w-4 animate-spin" /> Loading
</div>
)}
{failed && <p className="text-sm text-destructive">Could not load the outgoing hosts.</p>}
{state && (
<>
<SettingItem
label="No third-party traffic"
description="Bulwark and your users' browsers stop contacting services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, the connector, sender icons, MyMemory translation, map links and OpenPGP key lookups at other domains. Plugins that declare network access don't run; the others do. The page's security policy then allows only the hosts listed below."
>
<div className="flex items-center gap-1.5" data-testid="third-party-switch">
<SourceBadge source={state.source} label={state.source === 'env' ? 'NO_THIRD_PARTY_TRAFFIC' : 'config.json'} />
<ToggleSwitch checked={on} disabled={saving || locked} onChange={(checked) => void save(checked)} />
{state.source === 'admin' && !locked && <RevertButton onClick={() => void revert()} />}
</div>
</SettingItem>
{state.envLocked && (
<ConfigNote>Set by NO_THIRD_PARTY_TRAFFIC in the environment. Change it there and restart Bulwark.</ConfigNote>
)}
{state.readOnly && !state.envLocked && (
<ConfigNote>The configuration is read-only. Set noThirdPartyTraffic in config.json, or NO_THIRD_PARTY_TRAFFIC in the environment.</ConfigNote>
)}
{on && relays.length === 0 && (
<ConfigNote>
Push notifications now need a relay you run yourself. Add one under{' '}
<AdminLink href="/admin/policy" className="underline underline-offset-2">User policy &gt; Push relays</AdminLink>;
until then users get new mail only while Bulwark is open.
</ConfigNote>
)}
{on && state.networkPlugins.length > 0 && (
<ConfigNote>
Not running because they declare network access: {state.networkPlugins.map((p) => p.name).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>
<p className="mt-1 text-xs text-muted-foreground">Hosts you configured yourself, and what users enter themselves.</p>
{state.reachable.length === 0 ? (
<p className="mt-2 text-sm text-muted-foreground">None configured.</p>
) : (
<ul className="mt-2 divide-y divide-border rounded-md border border-border">
{state.reachable.map((h) => (
<li key={`${h.kind}|${h.host}|${h.setIn}`} className="flex flex-col gap-0.5 px-3 py-2 text-sm sm:flex-row sm:flex-wrap sm:items-baseline sm:gap-x-3">
<span className="font-medium text-foreground sm:w-44 sm:shrink-0">{h.kind}</span>
<span className={h.origin ? 'min-w-0 break-all font-mono text-xs text-foreground' : 'min-w-0 text-xs italic text-foreground'}>{h.host}</span>
<span className="text-xs text-muted-foreground sm:ms-auto sm:text-end">{h.setIn} · {SIDE_LABEL[h.side]}</span>
{h.note && <span className="text-xs text-amber-700 dark:text-amber-400 sm:basis-full">{h.note}</span>}
</li>
))}
</ul>
)}
</div>
<details data-testid="third-party-builtin">
<summary className="cursor-pointer text-sm font-medium text-foreground">
Built-in outside hosts ({state.builtIn.length}){on ? ', all off' : ''}
</summary>
<ul className="mt-2 divide-y divide-border rounded-md border border-border">
{state.builtIn.map((h) => (
<li key={h.id} className="space-y-0.5 px-3 py-2 text-sm">
<div className="flex flex-wrap items-baseline gap-x-2">
<span className="break-all font-mono text-xs text-foreground">{h.host}</span>
<span className="text-xs text-muted-foreground">{h.side === 'server' ? 'this server' : 'the browser'}</span>
</div>
<p className="text-xs text-muted-foreground">{h.usedBy}</p>
<p className="text-xs text-muted-foreground">{on ? h.inMode : `Without the switch: ${h.off}`}</p>
</li>
))}
</ul>
</details>
</>
)}
</SettingsSection>
);
}
@@ -0,0 +1,46 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, it, expect, vi } from 'vitest';
import { EventDetailPopover } from '../event-detail-popover';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
import type { Calendar, CalendarEvent } from '@/lib/jmap/types';
// #903: an address opens Google Maps only while third-party traffic is allowed.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
useFormatter: () => ({ dateTime: (d: Date | string) => String(d), relativeTime: (d: Date | string) => String(d), number: (n: number) => String(n) }),
}));
const calendar = { id: 'cal-1', name: 'Work', myRights: { mayWriteAll: true, mayWriteOwn: true } } as unknown as Calendar;
function renderPopover() {
const event = {
id: 'evt-1', calendarIds: { 'cal-1': true }, title: 'Visit', start: '2026-11-20T10:00:00', duration: 'PT1H',
timeZone: 'Europe/Berlin', showWithoutTime: false, participants: null, alerts: null, useDefaultAlerts: false,
locations: { l1: { name: 'Hauptstrasse 1, Berlin' } },
} as unknown as CalendarEvent;
render(
<EventDetailPopover event={event} calendar={calendar} anchorRect={new DOMRect(100, 100, 50, 20)}
onEdit={vi.fn()} onDelete={vi.fn()} onDuplicate={vi.fn()} onClose={vi.fn()} onSaveNote={vi.fn()} />,
);
return screen.getByTitle('Hauptstrasse 1, Berlin');
}
afterEach(() => setNoThirdPartyTraffic(false));
describe('event address link', () => {
it('opens Google Maps with third-party traffic allowed', () => {
expect(renderPopover().getAttribute('href')).toMatch(/^https:\/\/www\.google\.com\/maps\//);
});
it('is plain text with no third-party traffic', () => {
setNoThirdPartyTraffic(true);
expect(renderPopover().getAttribute('href')).toBeNull();
});
it('is plain text while the config is still unknown', () => {
setNoThirdPartyTraffic('unknown');
expect(renderPopover().getAttribute('href')).toBeNull();
});
});
@@ -0,0 +1,105 @@
import { render, screen, fireEvent, waitFor, cleanup, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventModal } from '../event-modal';
import { ExternalRecipientConfirmHost } from '@/components/email/external-recipient-confirm';
import type { Calendar, CalendarEvent, Identity } from '@/lib/jmap/types';
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 { useContactStore } from '@/stores/contact-store';
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
// Saving a meeting sends its invitations (the server does, on save), so a
// newly invited person outside the organisation gets the same confirm as a
// message. People already invited aren't asked about again.
vi.mock('../participant-availability', () => ({ ParticipantAvailability: () => null }));
const calendars = [{
id: 'cal', name: 'Personal', color: '#123456', isDefault: true,
myRights: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
},
}] as unknown as Calendar[];
const party = (address: string, owner: boolean) => ({
'@type': 'Participant', calendarAddress: `mailto:${address}`, email: address,
roles: owner ? { owner: true } : { attendee: true },
participationStatus: owner ? 'accepted' : 'needs-action', scheduleAgent: 'server', expectReply: !owner, kind: 'individual',
});
// The user's meeting, already sent to a partner outside.
const meeting = {
id: 'e1', uid: 'u1', '@type': 'Event', title: 'Planning', description: '', start: '2026-10-12T10:00:00',
duration: 'PT1H', timeZone: 'Europe/Berlin', showWithoutTime: false, calendarIds: { cal: true },
organizerCalendarAddress: 'mailto:me@example.com', alerts: null, recurrenceRules: null,
participants: { org: party('me@example.com', true), partner: party('partner@partner.example', false) },
} as unknown as CalendarEvent;
function setWarning(on: boolean) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, recipientWarning: on } },
},
});
}
beforeEach(() => {
setWarning(true);
useAuthStore.setState({ identities: [{ id: 'i1', name: 'Me', email: 'me@example.com' } as unknown as Identity] });
useContactStore.setState({ contacts: [], contactsLoaded: true });
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
});
afterEach(() => {
cleanup();
resetSendPipeline();
});
function editWithNewInvitee(onSave: ReturnType<typeof vi.fn>, invitee: string) {
render(
<>
<EventModal event={meeting} calendars={calendars} onSave={onSave as never} onClose={vi.fn()} currentUserEmails={["me@example.com"]} />
<ExternalRecipientConfirmHost />
</>,
);
fireEvent.click(screen.getByText('events.edit'));
fireEvent.change(screen.getByPlaceholderText('email_placeholder'), { target: { value: invitee } });
fireEvent.click(screen.getByText('form.save'));
}
describe('invitations to people outside the organisation', () => {
it('asks about the new external invitee only, and Back saves nothing', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
editWithNewInvitee(onSave, 'org-dave@org-two.test');
await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
expect(useExternalRecipientConfirmStore.getState().pending?.recipients.map((r) => r.address)).toEqual(['org-dave@org-two.test']);
act(() => useExternalRecipientConfirmStore.getState().answer(false));
await new Promise((r) => setTimeout(r, 20));
expect(onSave).not.toHaveBeenCalled();
});
it('saves and sends after Send anyway', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
editWithNewInvitee(onSave, 'org-dave@org-two.test');
await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
act(() => useExternalRecipientConfirmStore.getState().answer(true));
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
expect(onSave.mock.calls[0][1]).toBe(true);
});
it('saves without asking for an internal invitee or with the warning off', async () => {
const onSave = vi.fn().mockResolvedValue(undefined);
editWithNewInvitee(onSave, 'colleague@example.com');
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
cleanup();
setWarning(false);
const second = vi.fn().mockResolvedValue(undefined);
editWithNewInvitee(second, 'org-dave@org-two.test');
await waitFor(() => expect(second).toHaveBeenCalledTimes(1));
expect(useExternalRecipientConfirmStore.getState().pending).toBeNull();
});
});
+8 -3
View File
@@ -25,6 +25,7 @@ import { canUserRsvp, getEventEditability, type EditabilityContext } from "@/lib
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { findMeetingLink, locationAction, mapsUrl, primaryLocationName } from "@/lib/event-links";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { eventToView } from "@/lib/plugin-projection";
@@ -148,6 +149,8 @@ export function EventDetailPopover({
const isMultiDay = !isSameDay(startDate, displayEndDate);
const locationName = useMemo(() => primaryLocationName(event) ?? null, [event]);
// Re-computes the location link once the config is known (#903).
const noThirdParty = useNoThirdPartyTraffic();
// Invitations (Teams above all) leave virtualLocations empty and bury the
// join URL in the description; surface it as the meeting link anyway.
@@ -160,8 +163,10 @@ export function EventDetailPopover({
const locationHref = useMemo(() => {
if (!locationName) return null;
const action = locationAction(locationName, meeting);
return action.kind === "url" ? action.uri : mapsUrl(action.query);
}, [locationName, meeting]);
if (action.kind === "url") return action.uri;
// No Google Maps link while third-party traffic is off (#903).
return noThirdParty ? null : mapsUrl(action.query);
}, [locationName, meeting, noThirdParty]);
const [locationCopied, setLocationCopied] = useState(false);
const copiedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => () => {
@@ -493,7 +498,7 @@ export function EventDetailPopover({
href={locationHref ?? undefined}
target="_blank"
rel="noreferrer"
className="text-sm text-primary hover:underline min-w-0 break-words"
className={cn("text-sm min-w-0 break-words", locationHref ? "text-primary hover:underline" : "text-foreground")}
title={locationName}
>
{/^https?:\/\//i.test(locationName)
+9
View File
@@ -36,6 +36,7 @@ import { buildDuplicateEventData } from "@/lib/calendar-duplicate";
import { useFormatEventDate } from "@/hooks/use-format-event-date";
import { useIsPaneScoped } from "@/hooks/use-pane-context";
import { useContactNameResolver } from "@/hooks/use-contact-name-resolver";
import { confirmInvitationRecipients } from "@/lib/outgoing/external-recipients";
import {
calendarFormHooks,
calendarHooks,
@@ -836,6 +837,14 @@ export function EventModal({
}
const shouldSendScheduling = userIsOrganizer && effectiveAttendees.length > 0 && sendInvitations;
// Invitations are mail too: newly invited people outside the
// organisation get the same confirm as a message (Back keeps the
// editor open, nothing saved).
if (shouldSendScheduling) {
const known = new Set(initialAttendees.map((a) => a.email.trim().toLowerCase()));
const invited = effectiveAttendees.map((a) => a.email).filter((email) => email && !known.has(email.trim().toLowerCase()));
if (!(await confirmInvitationRecipients(invited))) return;
}
await onSave(data, shouldSendScheduling, {
initialAttendees,
attendees: effectiveAttendees,
@@ -0,0 +1,308 @@
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
import { ExternalRecipientConfirmHost } from '../external-recipient-confirm';
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { useAuthStore } from '@/stores/auth-store';
import { useIdentityStore } from '@/stores/identity-store';
// ─── Heavy component mocks (mirrors composer-reopened-draft-signature.test.tsx) ─
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
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' },
{ id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
] 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 };
});
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: true,
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: () => {},
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: [],
addressBooks: [],
trustedSendersBookId: null,
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 ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
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: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
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: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
// The external-recipient confirm in the real composer: the check is
// registered by the dialog host, asks only about the recipients outside the
// organisation, and the composer sends or not by the user's answer.
const DRAFT = {
to: 'bob@example.com, dave@org-two.test',
cc: 'carol@example.com',
bcc: '',
subject: 'Hello',
body: '<p>Hi</p>',
showCc: true,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
function clickSend() {
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
}
function renderComposer(onSend: () => void) {
return render(
<>
<EmailComposer initialData={DRAFT} onSend={onSend} />
<ExternalRecipientConfirmHost />
</>,
);
}
describe('composer: confirm before sending outside the organisation', () => {
beforeEach(() => {
resetSendPipeline();
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
setExternalMail({ recipientWarning: true });
// The user's identities, as the auth store holds them after sign-in:
// example.com is internal because of them.
const identities = [
{ id: 'id-me', email: 'me@example.com', name: 'Me' },
{ id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
];
useAuthStore.setState({ identities } as never);
useIdentityStore.setState({ identities } as never);
});
afterEach(() => {
cleanup();
resetSendPipeline();
vi.clearAllMocks();
});
it('lists only the external recipient and sends nothing on Back', async () => {
const onSend = vi.fn();
const { container } = renderComposer(onSend);
clickSend();
const list = await waitFor(() => {
const el = container.querySelector('[data-external-recipients]');
expect(el).not.toBeNull();
return el!;
});
expect(list.textContent).toContain('dave@org-two.test');
expect(list.textContent).not.toContain('bob@example.com');
expect(list.textContent).not.toContain('carol@example.com');
fireEvent.click(container.querySelector('[data-external-recipient-back]')!);
await waitFor(() => expect(container.querySelector('[data-external-recipient-confirm]')).toBeNull());
expect(onSend).not.toHaveBeenCalled();
});
it('sends after Send anyway', async () => {
const onSend = vi.fn();
const { container } = renderComposer(onSend);
clickSend();
await waitFor(() => expect(container.querySelector('[data-external-recipient-send]')).not.toBeNull());
fireEvent.click(container.querySelector('[data-external-recipient-send]')!);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
});
it('leaves the identity\'s automatic Bcc out of the question', async () => {
useIdentityStore.setState({
identities: [
{ id: 'id-me', email: 'me@example.com', name: 'Me', bcc: [{ name: '', email: 'archive@archive-service.example' }] },
{ id: 'id-sales', email: 'sales@example.com', name: 'Sales' },
],
} as never);
const { container } = renderComposer(vi.fn());
clickSend();
const list = await waitFor(() => {
const el = container.querySelector('[data-external-recipients]');
expect(el).not.toBeNull();
return el!;
});
expect(list.textContent).toContain('dave@org-two.test');
expect(list.textContent).not.toContain('archive@archive-service.example');
});
it('sends without asking while the warning is off', async () => {
setExternalMail({ recipientWarning: false, senderLabel: true });
const onSend = vi.fn();
const { container } = renderComposer(onSend);
clickSend();
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(container.querySelector('[data-external-recipient-confirm]')).toBeNull();
});
});
@@ -0,0 +1,64 @@
import { render, act, fireEvent, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExternalRecipientConfirmHost } from '../external-recipient-confirm';
import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store';
import { resetSendPipeline } from '@/lib/outgoing/send-pipeline';
// The confirm dialog keeps its keys: the mail view's shortcuts listen on the
// window, and must not see Escape (close the message), j/k (move the list)
// or Enter (open a message) while the question is open. Real focus trap.
const RECIPIENTS = [{ address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' as const }];
const pageKeys = vi.fn();
beforeEach(() => {
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
window.addEventListener('keydown', pageKeys);
});
afterEach(() => {
window.removeEventListener('keydown', pageKeys);
pageKeys.mockReset();
cleanup();
resetSendPipeline();
});
function open() {
const view = render(<ExternalRecipientConfirmHost />);
let answer!: Promise<boolean>;
act(() => { answer = useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
const back = view.container.querySelector('[data-external-recipient-back]') as HTMLButtonElement;
const send = view.container.querySelector('[data-external-recipient-send]') as HTMLButtonElement;
return { answer, back, send };
}
describe('keys in the external recipient confirm', () => {
it('puts the focus on Back and keeps shortcut keys from the page', () => {
const { back } = open();
expect(document.activeElement).toBe(back);
for (const key of ['j', 'k', 'Enter', 'Escape']) {
act(() => { fireEvent.keyDown(document.activeElement!, { key }); });
// Re-open after Escape and Enter answered it.
if (!useExternalRecipientConfirmStore.getState().pending) act(() => { void useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
}
expect(pageKeys).not.toHaveBeenCalled();
});
it('answers Back on Escape', async () => {
const { answer } = open();
act(() => { fireEvent.keyDown(document.activeElement!, { key: 'Escape' }); });
expect(await answer).toBe(false);
});
it('presses the focused button on Enter and on Space', async () => {
const first = open();
first.send.focus();
act(() => { fireEvent.keyDown(first.send, { key: 'Enter' }); });
expect(await first.answer).toBe(true);
let second!: Promise<boolean>;
act(() => { second = useExternalRecipientConfirmStore.getState().ask(RECIPIENTS); });
act(() => { fireEvent.keyDown(document.activeElement!, { key: ' ' }); });
// The focus went back to the first button, Back.
expect(await second).toBe(false);
});
});
@@ -0,0 +1,106 @@
import { render, fireEvent, cleanup, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type { Identity } from '@/lib/jmap/types';
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';
// Strings with their values, so the domain can be seen in names and text.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, vars?: Record<string, unknown>) =>
vars ? `${key}(${Object.values(vars).join(',')})` : key,
useLocale: () => 'en',
}));
const { ExternalSenderChip } = await import('../external-sender-chip');
function setLabel(on: boolean) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: on } },
},
});
}
beforeEach(() => {
setLabel(true);
useAuthStore.setState({ identities: [{ id: 'i1', name: 'Alice', email: 'alice@example.org' } as unknown as Identity] });
});
afterEach(() => cleanup());
describe('External chip on phones and keyboards', () => {
it('is a button whose name says the domain, and a tap shows it', () => {
render(<ExternalSenderChip from={[{ email: 'dave@org-two.test' }]} />);
const chip = screen.getByRole('button', { name: /org-two\.test/ });
expect(chip.getAttribute('aria-expanded')).toBe('false');
fireEvent.click(chip);
expect(chip.getAttribute('aria-expanded')).toBe('true');
expect(document.querySelector('[data-external-sender-detail]')?.textContent).toBe('sender_tooltip(org-two.test)');
// Escape and a tap elsewhere close it.
fireEvent.keyDown(chip, { key: 'Escape' });
expect(document.querySelector('[data-external-sender-detail]')).toBeNull();
fireEvent.click(chip);
fireEvent.pointerDown(document.body);
expect(document.querySelector('[data-external-sender-detail]')).toBeNull();
});
it('shows for any external one of several From addresses', () => {
render(<ExternalSenderChip from={[{ email: 'bob@example.org' }, { email: 'eve@evil.example' }]} />);
expect(screen.getByRole('button', { name: /evil\.example/ })).toBeTruthy();
});
it('shows for a From without a domain', () => {
render(<ExternalSenderChip from={[{ email: 'ceo' }]} />);
expect(screen.getByRole('button', { name: /sender_tooltip_no_domain/ })).toBeTruthy();
});
it('stays a plain label inside another button', () => {
const { container } = render(<button type="button"><ExternalSenderChip from={[{ email: 'dave@org-two.test' }]} interactive={false} /></button>);
expect(container.querySelectorAll('button')).toHaveLength(1);
expect(container.querySelector('[data-external-sender-chip]')?.getAttribute('title')).toBe('sender_tooltip(org-two.test)');
});
});
describe('a message of another signed-in account (unified inbox)', () => {
function signInDave(daveScope: Record<string, unknown> | null) {
usePolicyStore.setState({
rawPolicy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: true } },
scopes: daveScope ? [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: daveScope } } }] : [],
},
});
const daveClient = {
getUsername: () => 'org-dave@org-two.test',
getSharedAccounts: () => [],
getAccountInfo: () => null,
getPrincipalTypes: async () => ({}),
getIdentities: async () => [{ id: 'd1', name: 'Dave', email: 'org-dave@org-two.test' }],
};
useAuthStore.setState({
activeAccountId: 'acc-alice',
getClientForAccount: (id: string) => (id === 'acc-dave' ? daveClient : undefined),
} as never);
}
it("is judged with that account's identities", async () => {
signInDave(null);
// For dave, a colleague at org-two.test is internal and alice is not.
const colleague = render(<ExternalSenderChip from={[{ email: 'colleague@org-two.test' }]} accountId="acc-dave" />);
const alice = render(<ExternalSenderChip from={[{ email: 'alice@example.org' }]} accountId="acc-dave" />);
await waitFor(() => expect(alice.container.querySelector('[data-external-sender-chip]')).not.toBeNull());
expect(colleague.container.querySelector('[data-external-sender-chip]')).toBeNull();
});
it("follows that account's scope, not the active one's", async () => {
signInDave({ senderLabel: false });
const { container } = render(<ExternalSenderChip from={[{ email: 'someone@elsewhere.example' }]} accountId="acc-dave" />);
// The active account has the label on; dave's scope turns it off.
await new Promise((r) => setTimeout(r, 20));
expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
const active = render(<ExternalSenderChip from={[{ email: 'someone@elsewhere.example' }]} />);
expect(active.container.querySelector('[data-external-sender-chip]')).not.toBeNull();
});
});
@@ -0,0 +1,97 @@
import { render, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Email, Identity, Mailbox } from '@/lib/jmap/types';
import { clearAllHooks } from '@/lib/plugin-hooks';
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';
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 { ExternalSenderChip } = await import('../external-sender-chip');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const message = (from: string) => ({
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Sender', email: from }], to: [{ name: 'Org Alice', email: 'org-alice@example.org' }],
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
textBody: [{ partId: '1', type: 'text/html' }], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
bodyValues: { '1': { value: '<p>Body</p>' } },
}) as unknown as Email;
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
async function openMessage(from: string) {
const view = render(<EmailViewer email={message(from)} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
return view;
}
beforeEach(() => {
clearAllHooks();
setExternalMail({});
useAuthStore.setState({ identities: [{ id: 'i1', name: 'Org Alice', email: 'org-alice@example.org' } as unknown as Identity] });
});
describe('External sender chip', () => {
it('names the sender domain in its tooltip when the label is on', () => {
setExternalMail({ senderLabel: true });
const { container } = render(<ExternalSenderChip from={[{ email: "org-dave@org-two.test" }]} />);
const chip = container.querySelector('[data-external-sender-chip]');
expect(chip?.textContent).toContain('sender_label');
expect(chip?.getAttribute('title')).toBe('sender_tooltip');
});
it('shows in the reader header for a sender outside the organisation', async () => {
setExternalMail({ senderLabel: true });
const { container } = await openMessage('org-dave@org-two.test');
expect(container.querySelectorAll('[data-external-sender-chip]').length).toBeGreaterThan(0);
});
it('stays away for a sender in an identity domain or its subdomain', async () => {
setExternalMail({ senderLabel: true });
const { container } = await openMessage('org-bob@example.org');
expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
const sub = render(<ExternalSenderChip from={[{ email: "someone@eu.example.org" }]} />);
expect(sub.container.querySelector('[data-external-sender-chip]')).toBeNull();
});
it('stays away for a domain the admin listed', async () => {
setExternalMail({ senderLabel: true, domains: ['org-two.test'] });
const { container } = await openMessage('org-dave@org-two.test');
expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
});
it('stays away while the label is off, even with the recipient warning on', async () => {
setExternalMail({ senderLabel: false, recipientWarning: true });
const { container } = await openMessage('org-dave@org-two.test');
expect(container.querySelector('[data-external-sender-chip]')).toBeNull();
});
});
@@ -0,0 +1,90 @@
import { act, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
import { useSettingsStore } from '@/stores/settings-store';
// #903: with no third-party traffic, remote content in mail never loads, so
// nothing offers to load it or to trust the sender; the notice says the
// organisation blocks it. Without the mode the controls are as before.
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: () => [] }));
vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => ({ enabled: false }), canWopiOpen: () => false }));
vi.mock('@/hooks/use-own-domain-address', () => ({ useOwnDomainAddress: () => () => false }));
vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
const { EmailViewer } = await import('../email-viewer');
const { ThreadConversationView } = await import('../thread-conversation-view');
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
const email = {
id: 'e1', blobId: 'b-e1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'News', email: 'news@shop.example' }], to: [{ email: 'me@example.org' }],
subject: 'Offer', receivedAt: '2026-10-01T10:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
textBody: [], htmlBody: [{ partId: '1', type: 'text/html' }], attachments: [],
bodyValues: { '1': { value: '<p>Hello</p><img src="https://track.shop.example/pixel.gif">' } },
} as unknown as Email;
afterEach(() => {
setNoThirdPartyTraffic(false);
useSettingsStore.setState({ externalContentPolicy: 'ask' });
});
async function renderViewer() {
render(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
return screen.findByTestId('external-content-notice');
}
async function renderConversation() {
const view = render(
<ThreadConversationView thread={{ threadId: 't1', emails: [email], latestEmail: email } as never} emails={[email] as never} onBack={() => {}} />,
);
await act(async () => { await new Promise((r) => setTimeout(r, 30)); });
return view;
}
describe('reading pane', () => {
it('offers to load remote content without the mode', async () => {
const notice = await renderViewer();
expect(notice.textContent).toContain('external_content_warning');
expect(screen.queryByText('load_external_content')).not.toBeNull();
expect(screen.queryByText('trust_sender')).not.toBeNull();
});
it('says the organisation blocks it, with no load or trust button, even when the policy allows', async () => {
setNoThirdPartyTraffic(true);
useSettingsStore.setState({ externalContentPolicy: 'allow' });
const notice = await renderViewer();
expect(notice.textContent).toContain('remote_content_blocked');
expect(screen.queryByText('load_external_content')).toBeNull();
expect(screen.queryByText('trust_sender')).toBeNull();
});
});
describe('conversation view', () => {
it('offers to load remote content without the mode', async () => {
await renderConversation();
expect(screen.getByTestId('external-content-notice').textContent).toContain('email_viewer.external_content_warning');
expect(screen.queryByText('email_viewer.load_external_content')).not.toBeNull();
});
it('says the organisation blocks it, keeps it blocked and offers nothing in the mode', async () => {
setNoThirdPartyTraffic(true);
useSettingsStore.setState({ externalContentPolicy: 'allow' });
const { container } = await renderConversation();
expect(screen.getByTestId('external-content-notice').textContent).toContain('third_party.remote_content_blocked');
expect(screen.queryByText('email_viewer.load_external_content')).toBeNull();
expect(screen.queryByText('email_viewer.trust_sender')).toBeNull();
const srcdoc = container.querySelector('iframe')?.getAttribute('srcdoc') ?? '';
expect(srcdoc).not.toMatch(/\ssrc="https:\/\/track\.shop\.example/);
});
});
@@ -0,0 +1,92 @@
import { render, act, fireEvent, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { clearAllHooks } from '@/lib/plugin-hooks';
// A quick reply that a pre-send question stopped (Back on the external
// recipient confirm) must keep the typed text; one that went out clears it.
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 = {
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Dave', email: 'org-dave@org-two.test' }], to: [{ name: 'Alice', email: 'org-alice@example.org' }],
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
bodyValues: { '1': { value: 'Plain body' } },
} as unknown as Email;
beforeEach(() => clearAllHooks());
async function typeAndSend(onQuickReply: (body: string) => Promise<boolean | void>) {
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={onQuickReply} />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement;
fireEvent.focus(box);
fireEvent.change(box, { target: { value: 'My answer' } });
fireEvent.click(screen.getByRole('button', { name: 'send' }));
await waitFor(() => expect(onQuickReply).toHaveBeenCalledWith('My answer'));
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
return box;
}
describe('quick reply', () => {
it('keeps the text when nothing was sent', async () => {
const box = await typeAndSend(vi.fn(async () => false));
expect(box.value).toBe('My answer');
});
it('clears the text after a send', async () => {
const box = await typeAndSend(vi.fn(async () => true));
expect(box.value).toBe('');
});
});
describe('automatic sign-out while the external recipient question is open', () => {
it('answers Back and keeps the typed reply as a draft, without asking again', async () => {
const { useExternalRecipientConfirmStore } = await import('@/stores/external-recipient-confirm-store');
const { flushComposers, resetComposeFlushRegistry } = await import('@/lib/compose-flush-registry');
resetComposeFlushRegistry();
useExternalRecipientConfirmStore.setState({ pending: null, queue: [] });
const onQuickReply = vi.fn((body: string) => {
void body;
return useExternalRecipientConfirmStore.getState().ask([{ address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' }]);
});
const onSaveQuickReplyDraft = vi.fn(async () => {});
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
onQuickReply={onQuickReply} onSaveQuickReplyDraft={onSaveQuickReplyDraft} />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement;
fireEvent.focus(box);
fireEvent.change(box, { target: { value: 'My answer' } });
fireEvent.click(screen.getByRole('button', { name: 'send' }));
await waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull());
await act(async () => { await flushComposers(2000); });
expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('My answer');
expect(useExternalRecipientConfirmStore.getState().pending).toBeNull();
expect(box.value).toBe('My answer');
expect(onQuickReply).toHaveBeenCalledTimes(1);
});
});
+3 -1
View File
@@ -3356,7 +3356,9 @@ export function EmailComposer({
surface: 'composer',
to: toAddresses.map(r => r.email),
cc: ccAddresses.map(r => r.email),
bcc: bccAddresses.map(r => r.email),
// The identity's automatic Bcc (Identity.bcc) is the user's own setup,
// not a recipient they picked: checks see only the typed Bcc.
bcc: expandRecipients(withInput(bcc, bccInput)).map(r => r.email),
fromEmail: (fromOverrideEnabled && fromOverrideEmail.trim()) || currentIdentity?.email || undefined,
identityId: currentIdentity?.id,
mode,
+36 -11
View File
@@ -103,6 +103,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { useExternalRecipientConfirmStore } from "@/stores/external-recipient-confirm-store";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
@@ -115,6 +116,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useEmailStore, resolveEmailBlobContext, emailWriteSource, emailLinkSource, type EmailBlobContext } from "@/stores/email-store";
import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
import { ExternalSenderChip } from "./external-sender-chip";
import { UnsubscribeBanner } from "./unsubscribe-banner";
import { CalendarInvitationBanner } from "./calendar-invitation-banner";
import { ReadReceiptBanner } from "./read-receipt-banner";
@@ -149,6 +151,7 @@ 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";
import { useArchiveActionEnabled } from "@/lib/archive-gate";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
/** The More menu's two drill-downs: a folder list and a tag list. */
type MoreMenuSub = 'move' | 'tag' | 'rules';
@@ -175,7 +178,8 @@ interface EmailViewerProps {
onMarkAsRead?: (emailId: string, read: boolean, opts?: { automatic?: boolean }) => void;
onSetTag?: (emailId: string, tagId: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean, source?: EmailBlobContext) => void;
onQuickReply?: (body: string) => Promise<void>;
/** Resolves false when nothing was sent (the user went back), so the text stays. */
onQuickReply?: (body: string) => Promise<boolean | void>;
/** Keeps the typed quick reply as a draft before an automatic sign-out (lib/compose-flush-registry.ts). */
onSaveQuickReplyDraft?: (body: string) => Promise<void>;
onMarkAsSpam?: () => void;
@@ -857,6 +861,7 @@ export function MvExpandedConversationCard({
{!compact && sender?.name && sender.email && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} className="self-center" />
<span className="ms-auto ps-2 text-xs text-muted-foreground whitespace-nowrap">
{compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)}
</span>
@@ -1090,6 +1095,10 @@ export function EmailViewer({
const tWelcome = useTranslations('welcome');
const quickReplyEnabled = useSettingsStore((state) => state.quickReplyEnabled);
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
// No third-party traffic (#903): remote content never loads, whatever the
// policy or trust says, and nothing offers to load it.
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const tThirdParty = useTranslations('third_party');
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
const plainTextFont = useSettingsStore((state) => state.plainTextFont);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
@@ -1374,7 +1383,9 @@ export function EmailViewer({
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
// An automatic sign-out keeps typed quick-reply text as a draft; text on
// its way out as a reply needs none.
// its way out as a reply needs none. A reply still waiting on the
// "Send outside your organisation?" question hasn't gone out: the question
// is answered with Back and the text kept as a draft.
const quickReplyDraftRef = useRef({ text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft });
quickReplyDraftRef.current = { text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft };
const hasQuickReplyDraft = quickReplyText.trim().length > 0 && !!onSaveQuickReplyDraft;
@@ -1382,7 +1393,12 @@ export function EmailViewer({
if (!hasQuickReplyDraft) return;
return registerComposeFlush(async () => {
const { text, sending, save } = quickReplyDraftRef.current;
if (sending || !text.trim() || !save) return;
if (!text.trim() || !save) return;
if (sending) {
const confirm = useExternalRecipientConfirmStore.getState();
if (!confirm.pending) return;
confirm.cancelAll();
}
await save(text);
});
}, [hasQuickReplyDraft]);
@@ -1390,9 +1406,12 @@ export function EmailViewer({
if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return;
setIsSendingQuickReply(true);
try {
await onQuickReply(quickReplyText);
setQuickReplyText("");
setIsQuickReplyFocused(false);
const sent = await onQuickReply(quickReplyText);
// Back on a pre-send question keeps what the user typed.
if (sent !== false) {
setQuickReplyText("");
setIsQuickReplyFocused(false);
}
} catch (error) {
console.error("Failed to send quick reply:", error);
// The text stays in the box, so say why it did not go out.
@@ -2484,6 +2503,7 @@ export function EmailViewer({
// 'ask' = block until the user allows this message or trusts the sender.
const shouldBlockExternal = useMemo(() => {
if (!email) return false;
if (remoteBlockedByOrg) return true;
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
@@ -2496,7 +2516,7 @@ export function EmailViewer({
// permission changes, so they're deliberately omitted from deps (matches
// the srcDoc rebuild contract described on `emailContent`).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, allowExternalContent]);
}, [email, externalContentPolicy, allowExternalContent, remoteBlockedByOrg]);
// Sanitize and prepare email HTML content
const emailContent = useMemo(() => {
@@ -5062,6 +5082,7 @@ export function EmailViewer({
{mvViewer && sender?.email && sender?.name && (
<span className="text-xs text-muted-foreground truncate">&lt;{sender.email}&gt;</span>
)}
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -5351,6 +5372,7 @@ export function EmailViewer({
) : (
<span className="text-sm font-semibold text-foreground">{t('unknown_sender')}</span>
)}
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
<EmailIdentityBadge email={email} identities={identities} />
{shouldShowUnsubBanner && listHeaders?.listUnsubscribe && (
<UnsubscribeBanner
@@ -5538,6 +5560,7 @@ export function EmailViewer({
onViewContact={handleViewContactSidebar}
className="text-sm text-start"
/>
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} />
{senderVerification && (
<span
className={cn(
@@ -5856,7 +5879,7 @@ export function EmailViewer({
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
{(senderVerification ||
(hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') ||
(hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
hasCalendarInvitation ||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
<div className="border-b border-border bg-muted/30 isolate">
@@ -5886,8 +5909,8 @@ export function EmailViewer({
)}
{/* External Content Controls */}
{hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow' && (
<div className="flex items-start gap-3 py-1">
{hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
<div className="flex items-start gap-3 py-1" data-testid="external-content-notice">
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<Image className="w-5 h-5" />
</div>
@@ -5897,9 +5920,10 @@ export function EmailViewer({
External Content
</div>
<div className="text-sm font-medium text-foreground break-words">
{t('external_content_warning')}
{remoteBlockedByOrg ? tThirdParty('remote_content_blocked') : t('external_content_warning')}
</div>
</div>
{!remoteBlockedByOrg && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{externalContentPolicy === 'ask' && (
<button
@@ -5932,6 +5956,7 @@ export function EmailViewer({
</button>
)}
</div>
)}
</div>
</div>
)}
@@ -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<HTMLDivElement>) => {
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 (
<div
className="fixed inset-0 z-[100000] flex items-center justify-center bg-black/50 p-4 backdrop-blur-[1px] animate-in fade-in duration-150"
onMouseDown={(e) => {
if (e.target === e.currentTarget) answer(false);
}}
onKeyDown={onKeyDown}
>
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby={`${id}-title`}
aria-describedby={`${id}-message`}
data-external-recipient-confirm=""
className="flex max-h-[90vh] w-full max-w-md flex-col overflow-hidden rounded-lg border border-border bg-background text-foreground shadow-xl animate-in zoom-in-95 duration-200"
>
<div className="flex min-h-0 flex-1 flex-col px-5 pt-5 sm:px-6 sm:pt-6">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full bg-warning/15 text-warning">
<Building className="h-5 w-5" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<h2 id={`${id}-title`} className="text-lg font-semibold text-foreground">{t('confirm_title')}</h2>
<p id={`${id}-message`} className="mt-1 text-sm text-muted-foreground">
{t('confirm_message', { count: recipients.length })}
</p>
</div>
</div>
<ul className="mt-3 min-h-0 flex-1 space-y-1 overflow-y-auto rounded-md border border-border bg-muted/30 p-2 text-sm" data-external-recipients="">
{recipients.map((recipient) => (
<li key={recipient.address} className="flex items-baseline gap-2">
<span className="w-8 flex-shrink-0 text-xs text-muted-foreground">{field[recipient.field]}</span>
<span className="min-w-0 [overflow-wrap:anywhere]">{recipient.address}</span>
</li>
))}
</ul>
</div>
<div className="flex flex-wrap items-center justify-end gap-2 px-5 py-4 sm:px-6">
{/* Back comes first, so the focus trap puts the focus on it: sending outside takes a deliberate click. */}
<Button variant="outline" onClick={() => answer(false)} data-external-recipient-back="">
{t('back')}
</Button>
<Button onClick={() => answer(true)} data-external-recipient-send="">
{t('send_anyway')}
</Button>
</div>
</div>
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
'use client';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Building } from '@/components/icons';
import { cn } from '@/lib/utils';
import {
externalMailContextFor,
externalSenders,
internalDomains,
isOtherAccount,
type ExternalMailContext,
type ExternalSender,
} from '@/lib/external-mail';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
type FromList = readonly { email?: string | null }[] | null | undefined;
/**
* The context of the account a message belongs to: the active one from the
* stores, another signed-in one (the unified inbox) resolved for that
* account, so its own scopes and identities decide.
*/
function useAccountContext(accountId: string | null | undefined): ExternalMailContext | null {
const externalMail = usePolicyStore((s) => s.policy.organisation?.externalMail);
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const identities = useAuthStore((s) => s.identities);
const other = useAuthStore(() => isOtherAccount(accountId));
const [otherContext, setOtherContext] = useState<{ accountId: string; context: ExternalMailContext } | null>(null);
useEffect(() => {
if (!other || !accountId) return;
let cancelled = false;
void externalMailContextFor(accountId).then((context) => {
if (!cancelled) setOtherContext({ accountId, context });
});
return () => { cancelled = true; };
}, [other, accountId, rawPolicy]);
return useMemo(() => {
if (other) return otherContext && otherContext.accountId === accountId ? otherContext.context : null;
if (!externalMail) return null;
return { policy: externalMail, identityEmails: (identities ?? []).map((identity) => identity.email) };
}, [other, otherContext, accountId, externalMail, identities]);
}
/**
* The From addresses outside the organisation when the admin turned the
* "External" label on for the message's account (lib/external-mail.ts);
* empty otherwise.
*/
export function useExternalSenders(from: FromList, accountId?: string | null): ExternalSender[] {
const context = useAccountContext(accountId);
return useMemo(() => {
if (!context?.policy.senderLabel) return [];
return externalSenders(from, internalDomains(context.identityEmails, context.policy.domains));
}, [context, from]);
}
/**
* "External" next to the sender's name. A chip and not a banner, so it sits
* beside the unverified-sender warning without adding a second banner, and
* it keeps to one short word so the narrow phone header doesn't wrap more.
*
* It is a button: a tap (phones have no hover) or Enter opens a line naming
* the domain. Inside another button (a conversation card's header) it can't
* be one, so `interactive={false}` keeps it a label with a tooltip.
*/
export function ExternalSenderChip({ from, accountId, className, interactive = true }: {
from: FromList;
/** The signed-in account the message belongs to (Email.sourceClientAccountId); the active one when not set. */
accountId?: string | null;
className?: string;
interactive?: boolean;
}) {
const t = useTranslations('external_mail');
const senders = useExternalSenders(from, accountId);
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLSpanElement>(null);
const popoverId = useId();
useEffect(() => {
if (!open) return;
const close = (e: Event) => {
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('pointerdown', close);
document.addEventListener('focusin', close);
return () => {
document.removeEventListener('pointerdown', close);
document.removeEventListener('focusin', close);
};
}, [open]);
if (senders.length === 0) return null;
const domains = [...new Set(senders.map((s) => s.domain))];
const named = domains.filter(Boolean).join(', ');
const description = named
? t('sender_tooltip', { domain: named })
: t('sender_tooltip_no_domain');
const chipClass = cn(
'inline-flex flex-shrink-0 items-center gap-1 whitespace-nowrap rounded-md border px-1.5 py-0.5 text-xs font-medium leading-none',
'border-warning/30 bg-warning/10 text-warning',
);
const content = (
<>
<Building className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
{t('sender_label')}
</>
);
if (!interactive) {
return (
<span data-external-sender-chip="" title={description} className={cn(chipClass, 'cursor-help', className)}>
{content}
<span className="sr-only">{` (${description})`}</span>
</span>
);
}
return (
<span ref={wrapRef} className={cn('relative inline-flex flex-shrink-0', className)}>
<button
type="button"
data-external-sender-chip=""
title={description}
aria-label={`${t('sender_label')}: ${description}`}
aria-expanded={open}
aria-controls={open ? popoverId : undefined}
onClick={(e) => {
// The header around it folds or opens things on a click.
e.stopPropagation();
setOpen((v) => !v);
}}
onKeyDown={(e) => {
if (e.key === 'Escape' && open) {
e.stopPropagation();
setOpen(false);
}
}}
className={cn(chipClass, 'cursor-pointer hover:bg-warning/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring')}
>
{content}
</button>
{open && (
<span
id={popoverId}
role="status"
data-external-sender-detail=""
className="absolute start-0 top-full z-30 mt-1 w-max max-w-[16rem] whitespace-normal rounded-md border border-border bg-popover px-2.5 py-1.5 text-xs text-popover-foreground shadow-md"
>
{description}
</span>
)}
</span>
);
}
+12 -3
View File
@@ -11,6 +11,7 @@ import { emailDisplayDate } from "@/lib/email-date";
import { getRenderableHtmlBody } from "@/lib/email-body-selection";
import { findVerificationCode, verificationCodeBodyText } from "@/lib/verification-code";
import { VerificationCodeChip } from "./verification-code-chip";
import { ExternalSenderChip } from "./external-sender-chip";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { fitEmailBodyWidth } from "@/lib/email-fit-width";
@@ -56,6 +57,7 @@ import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePluginRenderedBody } from "@/hooks/use-plugin-rendered-body";
import { emailToReadView } from "@/lib/plugin-projection";
import { AttachmentPluginMenu, ReaderAboveQuoteSlot } from "./reader-plugin-surfaces";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
export type ConversationAttachmentHandler = (
blobId: string, name: string, type: string | undefined, source: EmailBlobContext,
@@ -113,6 +115,8 @@ export function useConversationExternalContent() {
const addToTrustedSendersBook = useContactStore((state) => state.addToTrustedSendersBook);
const { client } = useAuthStore(useShallow((state) => ({ client: state.client })));
const [allowedIds, setAllowedIds] = useState<Set<string>>(new Set());
// No third-party traffic (#903): nothing remote loads and nothing offers to.
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const allow = useCallback((emailId: string) => {
setAllowedIds(prev => new Set(prev).add(emailId));
@@ -123,12 +127,13 @@ export function useConversationExternalContent() {
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
: false;
if (remoteBlockedByOrg) return false;
return externalContentPolicy === 'allow' || !!senderIsTrusted || allowedIds.has(email.id);
};
const trustSender = (email: Email) => {
const senderEmail = email.from?.[0]?.email?.toLowerCase();
if (!senderEmail) return undefined;
if (!senderEmail || remoteBlockedByOrg) return undefined;
return () => {
if (trustedSendersAddressBook && client) {
addToTrustedSendersBook(client, senderEmail).catch(console.error);
@@ -396,6 +401,7 @@ function EmailCard({
)}>
{sender?.name || (sender?.email && formatAddress(sender.email)) || "Unknown"}
</span>
<ExternalSenderChip from={email.from} accountId={email.sourceClientAccountId} interactive={false} />
{isStarred && (
<Star className="w-4 h-4 fill-amber-400 text-amber-400 flex-shrink-0" />
)}
@@ -523,6 +529,7 @@ export function ConversationEmailBody({
bodyStyle,
className,
}: ConversationEmailBodyProps) {
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const t = useTranslations();
const resolvedTheme = useThemeStore((state) => state.resolvedTheme);
const mailAttachmentAction = useSettingsStore((state) => state.mailAttachmentAction);
@@ -832,10 +839,11 @@ export function ConversationEmailBody({
<div className={className}>
{/* External content warning */}
{hasBlockedContent && !allowExternal && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm">
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm" data-testid="external-content-notice">
<span className="text-muted-foreground">
{t("email_viewer.external_content_warning")}
{remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")}
</span>
{!remoteBlockedByOrg && (
<div className="flex items-center gap-2">
<Button
variant="ghost"
@@ -860,6 +868,7 @@ export function ConversationEmailBody({
</Button>
)}
</div>
)}
</div>
)}
+4 -1
View File
@@ -6,6 +6,7 @@ import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitiza
import { getEffectiveTimeZone } from "@/lib/timezone";
import { stripBidiControls } from "@/lib/bidi";
import { useSettingsStore } from "@/stores/settings-store";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
// A parsed message/rfc822 (.eml), as produced by postal-mime. Only the fields
// this preview renders are typed.
@@ -43,7 +44,9 @@ function escapeHtml(s: string): string {
// (FilePreviewModal); this is pure presentation.
export function EmlPreview({ message }: { message: ParsedEml }) {
const t = useTranslations("email_viewer");
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow";
// Always blocked with no third-party traffic (#903).
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
// dir="auto" on a wrapper we control lets the first strong character pick
// the direction, matching the main viewer.
+4 -1
View File
@@ -14,6 +14,7 @@ import { canOpenUrlInNewWindow } from "@/lib/standalone-display";
import { emailIframeCsp, sanitizeEmailBodyForIframe } from "@/lib/email-sanitization";
import { useSettingsStore } from "@/stores/settings-store";
import type { FileResourceView } from "@/lib/plugin-types";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
/**
* An HTML attachment as a document for a fully locked frame (sandbox="",
@@ -161,7 +162,9 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent, fi
// open-in-new-tab fallback use.
const [pdfBlob, setPdfBlob] = useState<Blob | null>(null);
const [htmlDoc, setHtmlDoc] = useState<string | null>(null);
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow";
// Always blocked with no third-party traffic (#903).
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const blockRemote = useSettingsStore((state) => state.externalContentPolicy) !== "allow" || remoteBlockedByOrg;
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
+8 -5
View File
@@ -3858,8 +3858,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
await saveQuickReplyDraft({ client, email: selectedEmail, identities, body, replyPrefix: t('email_composer.prefix.reply') });
};
const handleQuickReply = async (body: string) => {
if (!client || !selectedEmail) return;
// Resolves true once the reply went out (or was scheduled), false when it
// stopped before sending, so the quick-reply box keeps the text.
const handleQuickReply = async (body: string): Promise<boolean> => {
if (!client || !selectedEmail) return false;
// Quick reply follows the composer's addressing rules and sends from the
// address the message was delivered to (lib/outgoing/quick-reply-envelope.ts).
@@ -3888,7 +3890,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
fromEmail: headerFromEmail,
identityId: sendingIdentity?.id,
});
if (!prepared) return;
if (!prepared) return false;
const { html: finalHtmlBody, text: finalBody } = prepared;
const originalEmailId = selectedEmail.id;
@@ -3898,7 +3900,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (sendDelaySeconds > 0) {
if (!client.hasDelayedSend()) {
const confirmed = window.confirm(t('email_composer.send_delay_unsupported_confirm'));
if (!confirmed) return;
if (!confirmed) return false;
} else {
delayedUntil = new Date(Date.now() + sendDelaySeconds * 1000).toISOString();
}
@@ -3948,7 +3950,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (result.scheduled) {
await refreshScheduledMetadata(client);
return;
return true;
}
if (!refused) toast.success(t('notifications.email_sent'));
@@ -3990,6 +3992,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} catch (error) {
console.error('Quick reply sent, but refreshing the view failed:', error);
}
return true;
};
// Mountain View reads a message inside its conversation, as Gmail does: load
@@ -0,0 +1,63 @@
import { render, renderHook, act, cleanup } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { usePolicyStore } from '@/stores/policy-store';
import { clearSlotOffers, offerSlots } from './fake-slot-offer';
vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer'));
const { PluginSlot } = await import('../plugin-slot');
// The retired external-mail-warning plugin's banner stays away while the
// organisation's own External label or recipient warning is on, so the
// reader doesn't show both; other plugins' banners stay.
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
const frames = (container: HTMLElement) =>
[...container.querySelectorAll('[data-testid="plugin-frame-email-banner"]')].map((el) => el.getAttribute('data-plugin-id'));
beforeEach(() => {
setExternalMail({});
offerSlots('external-mail-warning', ['email-banner']);
offerSlots('spam-score', ['email-banner']);
});
afterEach(() => {
cleanup();
clearSlotOffers();
});
describe('email-banner slot while core external warnings are on', () => {
it('shows the plugin banner until the sender label is on, whatever the recipient warning', () => {
const { container } = render(<PluginSlot name="email-banner" />);
expect(frames(container).sort()).toEqual(['external-mail-warning', 'spam-score']);
act(() => setExternalMail({ recipientWarning: true }));
expect(frames(container).sort()).toEqual(['external-mail-warning', 'spam-score']);
act(() => setExternalMail({ senderLabel: true }));
expect(frames(container)).toEqual(['spam-score']);
act(() => setExternalMail({}));
expect(frames(container).sort()).toEqual(['external-mail-warning', 'spam-score']);
});
});
describe('usePluginSlotOffers', () => {
// The reader and the conversation view build the plugin's view of a message
// only when some offer is left, so a hidden banner costs nothing.
it('leaves out the hidden banner offer, so nothing is built for it', async () => {
const { usePluginSlotOffers } = await import('@/hooks/use-plugin-slot-offers');
clearSlotOffers();
offerSlots('external-mail-warning', ['email-banner']);
const { result } = renderHook(() => usePluginSlotOffers('email-banner'));
expect(result.current.map((o) => o.pluginId)).toEqual(['external-mail-warning']);
act(() => setExternalMail({ senderLabel: true }));
expect(result.current).toEqual([]);
});
});
+4 -4
View File
@@ -1,10 +1,10 @@
'use client';
import React, { useSyncExternalStore } from 'react';
import React from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
import { PluginIframeSlot } from './plugin-iframe-slot';
import { isInlineSlot } from '@/lib/plugin-sandbox/slot-layout';
import { usePluginSlotOffers } from '@/hooks/use-plugin-slot-offers';
import { cn } from '@/lib/utils';
interface PluginSlotProps {
@@ -14,8 +14,8 @@ interface PluginSlotProps {
}
export function PluginSlot({ name, className, extraProps }: PluginSlotProps) {
const getSnapshot = () => offersForSlot(name);
const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
// Without a retired plugin's offer while Bulwark does its job (host-features.ts).
const offers = usePluginSlotOffers(name);
if (offers.length === 0) return null;
+3
View File
@@ -121,6 +121,7 @@ export function PushNotificationPrompt() {
);
const accountId = client?.getAccountId() ?? null;
// Empty when no relay may be used (third-party traffic off, no own relay).
const relayBaseUrl = resolveActiveRelayUrl(policy, userPushRelayUrl);
const dismissForSession = useCallback((id: string) => {
@@ -252,6 +253,7 @@ export function PushNotificationPrompt() {
|| wasDismissed(accountId)
|| !isWebPushSupported()
|| Notification.permission === "denied"
|| !relayBaseUrl
) {
return;
}
@@ -284,6 +286,7 @@ export function PushNotificationPrompt() {
pathname,
policyLoaded,
pwaPromptVisible,
relayBaseUrl,
sessionDismissedAccountIds,
]);
@@ -0,0 +1,115 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, it, expect, vi, beforeEach } from 'vitest';
import { MessageListSettings } from '../message-list-settings';
import { NotificationSettings } from '../notification-settings';
import { PluginsSettings } from '../plugins-settings';
import { ReadingSettings } from '../reading-settings';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useSettingsStore } from '@/stores/settings-store';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
import type { InstalledPlugin } from '@/lib/plugin-types';
// #903: the settings pages hide or explain what the no-third-party mode turns off.
vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key, useLocale: () => 'en' }));
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ demoMode: false }) }));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@/lib/web-push', () => ({
WebPushUnsupportedError: class WebPushUnsupportedError extends Error {},
isWebPushSupported: () => true,
isWebPushEnabled: vi.fn(async () => false),
enableWebPush: vi.fn(async () => ({ subscriptionId: 'sub-1' })),
disableWebPush: vi.fn(async () => undefined),
listPushDevices: vi.fn(async () => []),
mailAccountsWithoutPush: vi.fn(() => []),
revokePushDevice: vi.fn(async () => undefined),
serverSupportsEmailPush: vi.fn(() => true),
}));
beforeEach(() => {
useAuthStore.setState({ client: { getAccountId: () => 'acct-1' } as never, username: 'me@example.com' });
usePolicyStore.setState({
...usePolicyStore.getState(),
loaded: true,
policy: {},
isSettingLocked: () => false,
isSettingHidden: () => false,
isFeatureEnabled: () => true,
isPluginForceEnabled: () => false,
} as never);
useSettingsStore.setState({ senderFavicons: true, emailNotificationsEnabled: true });
});
afterEach(() => setNoThirdPartyTraffic(false));
describe('Settings > Message list', () => {
it('offers sender favicons only while third-party traffic is allowed', () => {
const { unmount } = render(<MessageListSettings />);
expect(screen.getByText('sender_favicons.label')).toBeInTheDocument();
unmount();
setNoThirdPartyTraffic(true);
render(<MessageListSettings />);
expect(screen.queryByText('sender_favicons.label')).toBeNull();
});
});
describe('Settings > Notifications', () => {
function pushToggle() {
const row = screen.getByText('push.enable').closest('[data-search-label]') as HTMLElement;
return row.querySelector('[role="switch"]') as HTMLButtonElement;
}
it('says push needs an organisation relay and disables the switch when there is none', async () => {
setNoThirdPartyTraffic(true);
render(<NotificationSettings />);
await waitFor(() => expect(screen.getByText('push_needs_relay')).toBeInTheDocument());
expect(pushToggle()).toBeDisabled();
});
it('keeps the switch with the built-in relay when the mode is off', async () => {
render(<NotificationSettings />);
await waitFor(() => expect(screen.getByText('push.status_inactive')).toBeInTheDocument());
expect(pushToggle()).not.toBeDisabled();
expect(screen.queryByText('push_needs_relay')).toBeNull();
});
});
describe('Settings > Plugins', () => {
it('says why a plugin that declares network access was not started', () => {
const plugin = {
id: 'p', name: 'Fetcher', version: '1.0.0', author: 'a', description: 'd', type: 'hook', permissions: ['http:fetch'],
entrypoint: 'index.js', enabled: true, status: 'error', error: 'third-party-off', managed: true, settings: {},
} as unknown as InstalledPlugin;
usePluginStore.setState({ plugins: [plugin], initialized: true, initializePlugins: vi.fn() } as never);
render(<PluginsSettings />);
fireEvent.click(screen.getByText('Fetcher'));
expect(screen.getByText('plugin_refused')).toBeInTheDocument();
});
});
describe('Settings > Reading', () => {
function externalContentRow() {
return screen.getByText('external_content.label').closest('[data-search-label]') as HTMLElement;
}
it('lets the user pick the external content policy without the mode', () => {
useSettingsStore.setState({ externalContentPolicy: 'ask' });
render(<ReadingSettings />);
expect(externalContentRow().textContent).not.toContain('org_locked');
expect(externalContentRow().querySelector('select')?.value).toBe('ask');
});
it('shows the policy as blocked and set by the organisation in the mode', () => {
setNoThirdPartyTraffic(true);
useSettingsStore.setState({ externalContentPolicy: 'allow' });
render(<ReadingSettings />);
const row = externalContentRow();
expect(row.textContent).toContain('org_locked');
expect(row.querySelector('select')?.value).toBe('block');
expect(row.querySelector('[inert]')).not.toBeNull();
});
});
@@ -0,0 +1,85 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ShareCollectionDialog } from '../share-collection-dialog';
import { MailboxShareDialog } from '@/components/layout/mailbox-share-dialog';
import { usePolicyStore } from '@/stores/policy-store';
import { presetRights, type AnyRights, type ShareKind } from '@/lib/jmap/share-rights';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Mailbox } from '@/lib/jmap/types';
// The admin's `sharingRights` without "write": every share dialog offers
// the read presets only, and a grant that already gives more stays named but
// can't be picked again.
const initialPolicy = usePolicyStore.getState();
afterEach(() => usePolicyStore.setState(initialPolicy, true));
function readOnly() {
usePolicyStore.setState((state) => ({ policy: { ...state.policy, restrictions: { ...state.policy.restrictions, sharingRights: { allowedValues: ['read'] } } } }));
}
const client = {
getAccountId: () => 'me',
listPrincipals: vi.fn(async () => ({ principals: [], status: 'ok' as const })),
getPrincipals: vi.fn(async () => []),
} as unknown as IJMAPClient;
function renderKind(kind: ShareKind, shareWith: Record<string, AnyRights>) {
const onShare = vi.fn().mockResolvedValue(undefined);
render(<ShareCollectionDialog client={client} kind={kind} collectionName="X" shareWith={shareWith} ownAccountId="me" onShare={onShare} onClose={vi.fn()} />);
return onShare;
}
const offered = (select: HTMLElement) => within(select).getAllByRole('option')
.filter((o) => !(o as HTMLOptionElement).disabled)
.map((o) => (o as HTMLOptionElement).value);
describe('sharingRights in the share dialogs', () => {
it.each([
['mailbox', ['read', 'readWrite', 'manager']],
['calendar', ['freeBusy', 'read', 'readWrite', 'manager']],
['addressBook', ['read', 'readWrite', 'manager']],
['file', ['read', 'readWrite', 'manager']],
] as const)('offers every preset for a %s without a restriction', (kind, all) => {
renderKind(kind, { anna: presetRights(kind, 'read')! });
expect(offered(screen.getByRole('combobox'))).toEqual(all);
expect(screen.queryByText('org_locked')).toBeNull();
});
it.each([
['mailbox', ['read']],
['calendar', ['freeBusy', 'read']],
['addressBook', ['read']],
['file', ['read']],
] as const)('offers only read presets for a %s without write, and says why', (kind, readOnlyPresets) => {
readOnly();
renderKind(kind, { anna: presetRights(kind, 'read')! });
expect(offered(screen.getByRole('combobox'))).toEqual(readOnlyPresets);
expect(screen.getByText('org_locked')).toBeInTheDocument();
});
it('keeps an existing write grant named but not pickable, and refuses to give write', async () => {
readOnly();
const onShare = renderKind('mailbox', { anna: presetRights('mailbox', 'readWrite')! });
const select = screen.getByRole('combobox') as HTMLSelectElement;
expect(select.value).toBe('readWrite');
expect((within(select).getByRole('option', { name: 'preset.readWrite' }) as HTMLOptionElement).disabled).toBe(true);
fireEvent.change(select, { target: { value: 'manager' } });
await act(async () => {});
expect(onShare).not.toHaveBeenCalled();
fireEvent.change(select, { target: { value: 'read' } });
await waitFor(() => expect(onShare).toHaveBeenCalledWith('anna', presetRights('mailbox', 'read')));
});
it('applies to the folder share dialog', async () => {
readOnly();
const mailboxClient = {
...client,
getMailboxShareWith: vi.fn(async () => ({ anna: presetRights('mailbox', 'read') })),
setMailboxShare: vi.fn(async () => {}),
} as unknown as IJMAPClient;
render(<MailboxShareDialog client={mailboxClient} mailbox={{ id: 'a', name: 'Inbox', role: 'inbox' } as Mailbox} onClose={vi.fn()} />);
const select = await screen.findByRole('combobox');
expect(offered(select)).toEqual(['read']);
});
});
@@ -0,0 +1,361 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DelegatesSettings } from '../delegates-settings';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import type { ShareChange, ShareChangeResult, ShareTarget } from '@/lib/jmap/share-batch';
import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '@/lib/jmap/share-rights';
import type { Principal, PrincipalListing } from '@/lib/jmap/types';
const ALICE: Principal = { id: 'ci', type: 'individual', name: 'org-alice@example.org', email: 'org-alice@example.org' };
const TEAM: Principal = { id: 'ch', type: 'group', name: 'org-team@example.org', email: 'org-team@example.org' };
const CAROL: Principal = { id: 'ck', type: 'individual', name: 'org-carol@example.org', email: 'org-carol@example.org' };
// Stalwart 1.0 with directory queries off: only the principals alice knows.
const DIRECTORY_OFF: PrincipalListing = { principals: [ALICE, TEAM, CAROL], status: 'restricted', directoryQueriesOff: true };
function targets(withCarol = false): ShareTarget[] {
const carol = (rights: object) => (withCarol ? { ck: rights } : {});
return [
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: carol({ ...MAILBOX_PRESETS.read, maySetKeywords: true }) as never },
{ kind: 'mailbox', accountId: 'ci', id: 'b', name: 'Sent Items', role: 'sent', shareWith: {} },
{ kind: 'calendar', accountId: 'ci', id: 'c', name: 'Calendar', isDefault: true, shareWith: carol(CALENDAR_PRESETS.read) as never },
{ kind: 'addressBook', accountId: 'ci', id: 'd', name: 'Contacts', isDefault: true, shareWith: {} },
...(withCarol ? [{ kind: 'file' as const, accountId: 'ci', id: 'n', name: 'plan.pdf', shareWith: { ck: FILE_PRESETS.read } }] : []),
];
}
const ok = (changes: ShareChange[]): ShareChangeResult[] => changes.map((change) => ({ change, ok: true }));
function makeClient(options: {
withCarol?: boolean;
directory?: PrincipalListing;
apply?: (c: ShareChange[]) => ShareChangeResult[];
sharedAccounts?: Array<{ id: string; name: string }>;
targets?: ShareTarget[];
failed?: string[];
seenSetsKeywords?: boolean;
} = {}) {
return {
getAccountId: () => 'ci',
getUsername: () => 'org-alice@example.org',
getSharedAccounts: () => options.sharedAccounts ?? [],
supportsPrincipals: () => true,
sharesSeenWithKeywords: () => options.seenSetsKeywords ?? false,
getPrincipals: vi.fn(async () => (options.directory ?? DIRECTORY_OFF).principals),
listPrincipals: vi.fn(async () => options.directory ?? DIRECTORY_OFF),
getShareTargets: vi.fn(async (_include?: object) => ({ targets: options.targets ?? targets(options.withCarol), failed: (options.failed ?? []) as never[] })),
applyShareChanges: vi.fn(async (changes: ShareChange[]) => (options.apply ?? ok)(changes)),
findPrincipalByEmail: vi.fn(async () => ({ principal: null, status: 'forbidden' as const, directoryQueriesOff: true })),
};
}
const initialPolicy = usePolicyStore.getState();
const initialAuth = useAuthStore.getState();
function restrict(restrictions: Record<string, object>) {
usePolicyStore.setState((state) => ({ policy: { ...state.policy, restrictions: { ...state.policy.restrictions, ...restrictions } } }));
}
function renderWith(client: ReturnType<typeof makeClient>) {
useAuthStore.setState({ client: client as never, activeAccountId: 'acc-alice' });
return render(<DelegatesSettings />);
}
beforeEach(() => {
try { localStorage.clear(); } catch { /* ignore */ }
});
afterEach(() => {
cleanup();
usePolicyStore.setState(initialPolicy, true);
useAuthStore.setState(initialAuth, true);
});
describe('DelegatesSettings', () => {
it('shows nothing and reads nothing when the admin hides delegation', async () => {
restrict({ delegation: { hidden: true } });
const client = makeClient();
const { container } = renderWith(client);
await act(async () => {});
expect(container).toBeEmptyDOMElement();
expect(client.getShareTargets).not.toHaveBeenCalled();
});
it('appears once a late policy stops hiding it', async () => {
restrict({ delegation: { hidden: true } });
const client = makeClient();
renderWith(client);
await act(async () => {});
act(() => usePolicyStore.setState((state) => ({ policy: { ...state.policy, restrictions: {} } })));
expect(await screen.findByText('give_access')).toBeInTheDocument();
expect(client.getShareTargets).toHaveBeenCalled();
});
it('lists everyone with access, grouped by person, with what they have', async () => {
renderWith(makeClient({ withCarol: true }));
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByText('org-carol@example.org')).toBeInTheDocument();
expect(within(row).getByText('summary.mail_inbox · summary.calendars · summary.files · level.read')).toBeInTheDocument();
expect(screen.getAllByTestId('delegate-row')).toHaveLength(1);
});
it('names someone the directory does not list from the accounts they share with the user', async () => {
renderWith(makeClient({
withCarol: true,
directory: { principals: [ALICE], status: 'restricted', directoryQueriesOff: true },
sharedAccounts: [{ id: 'ck', name: 'org-carol@example.org' }],
}));
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByText('org-carol@example.org')).toBeInTheDocument();
expect(within(row).queryByText('unknown_person')).toBeNull();
});
it('marks a group named only by the session as a group', async () => {
const list = targets();
list[0].shareWith.ch = MAILBOX_PRESETS.read;
renderWith(makeClient({
targets: list,
directory: { principals: [], status: 'error' },
sharedAccounts: [{ id: 'ch', name: 'org-team@example.org' }],
}));
// The auth store sets the policy subject once the session is in; it knows the user's groups.
act(() => usePolicyStore.setState({ subject: { email: ALICE.email!, groups: ['org-team@example.org'] } }));
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByText('org-team@example.org')).toBeInTheDocument();
expect(await within(row).findByText('group')).toBeInTheDocument();
});
it('asks only for the types the admin left on', async () => {
usePolicyStore.setState((state) => ({ policy: { ...state.policy, features: { ...state.policy.features, calendarEnabled: false, filesEnabled: false } } }));
const client = makeClient();
renderWith(client);
await screen.findByText('nobody');
expect(client.getShareTargets).toHaveBeenLastCalledWith({ calendars: false, contacts: true, files: false });
});
it('holds Change and Remove while a type is unread, and reads again on Try again', async () => {
const client = makeClient({ withCarol: true, failed: ['calendar'] });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
expect(within(row).getByRole('button', { name: 'change' })).toBeDisabled();
expect(within(row).getByRole('button', { name: 'remove' })).toBeDisabled();
const notice = screen.getByTestId('delegates-read-failed');
expect(notice).toHaveTextContent('read_failed');
client.getShareTargets.mockResolvedValue({ targets: targets(true), failed: [] });
fireEvent.click(within(notice).getByRole('button', { name: 'retry' }));
await waitFor(() => expect(within(screen.getByTestId('delegate-row')).getByRole('button', { name: 'remove' })).toBeEnabled());
expect(client.getShareTargets).toHaveBeenCalledTimes(2);
});
it('shows the answer of the current account, not a slow one of the account before', async () => {
let releaseFirst: (v: { targets: ShareTarget[]; failed: never[] }) => void = () => {};
const first = makeClient({ withCarol: true });
first.getShareTargets.mockImplementation(() => new Promise((resolve) => { releaseFirst = resolve; }));
renderWith(first);
await act(async () => {});
const second = makeClient();
act(() => useAuthStore.setState({ client: second as never, activeAccountId: 'acc-other' }));
expect(await screen.findByText('nobody')).toBeInTheDocument();
await act(async () => { releaseFirst({ targets: targets(true), failed: [] }); });
expect(screen.queryByTestId('delegate-row')).toBeNull();
expect(screen.getByText('nobody')).toBeInTheDocument();
});
it('remembers the names of people with access, never the rest of the directory', async () => {
renderWith(makeClient({ withCarol: true }));
await screen.findByTestId('delegate-row');
await waitFor(() => expect(localStorage.getItem('bulwark-delegate-names')).toContain('ck'));
const stored = JSON.parse(localStorage.getItem('bulwark-delegate-names')!);
expect(Object.keys(stored['acc-alice'])).toEqual(['ck']);
});
it('removes a person from every item, files included', async () => {
const client = makeClient({ withCarol: true });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
fireEvent.click(within(row).getByRole('button', { name: 'remove' }));
const confirm = await screen.findByRole('alertdialog');
fireEvent.click(within(confirm).getByRole('button', { name: 'remove' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(1));
const changes = client.applyShareChanges.mock.calls[0][0];
expect(changes.map((c) => [c.kind, c.id, c.principalId, c.rights])).toEqual([
['mailbox', 'a', 'ck', null],
['calendar', 'c', 'ck', null],
['file', 'n', 'ck', null],
]);
// The overview is read again afterwards.
await waitFor(() => expect(client.getShareTargets).toHaveBeenCalledTimes(2));
});
});
function candidate(dialog: HTMLElement, text: string): HTMLElement {
return within(within(dialog).getByTestId('delegate-candidates')).getAllByRole('button').find((b) => b.textContent?.includes(text))!;
}
describe('DelegateDialog', () => {
async function openDialog(client: ReturnType<typeof makeClient>) {
renderWith(client);
fireEvent.click(await screen.findByText('give_access'));
return screen.findByRole('dialog');
}
it('starts in the search field', async () => {
const dialog = await openDialog(makeClient());
await waitFor(() => expect(within(dialog).getByRole('textbox')).toHaveFocus());
});
it("says \"That's you\" for the user's own address", async () => {
const client = makeClient({ directory: { principals: [ALICE], status: 'ok' } });
const dialog = await openDialog(client);
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'ORG-alice@example.org' } });
fireEvent.click(within(dialog).getByText('use_address'));
expect(await within(dialog).findByText('is_you')).toBeInTheDocument();
expect(client.findPrincipalByEmail).not.toHaveBeenCalled();
});
it('remembers the person just picked', async () => {
const client = makeClient({ directory: { principals: [ALICE, CAROL, TEAM], status: 'ok' } });
const dialog = await openDialog(client);
fireEvent.click(candidate(dialog, 'org-carol'));
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_add' }));
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
const stored = JSON.parse(localStorage.getItem('bulwark-delegate-names')!);
expect(Object.keys(stored['acc-alice'])).toEqual(['ck']);
});
it('says read delegates can flag mail where the server ties the two rights', async () => {
const dialog = await openDialog(makeClient({ seenSetsKeywords: true }));
expect(within(dialog).getByText('rights_read_hint_flags')).toBeInTheDocument();
expect(within(dialog).queryByText('rights_read_hint')).toBeNull();
});
it('starts Change of manager rights at "Keep as it is" and has nothing to save', async () => {
const list = targets();
for (const t of list) if (t.kind === 'mailbox') t.shareWith.ck = MAILBOX_PRESETS.manager;
const client = makeClient({ targets: list });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
fireEvent.click(within(row).getByRole('button', { name: 'change' }));
const dialog = await screen.findByRole('dialog');
const checked = within(dialog).getAllByRole('radio').filter((r) => (r as HTMLInputElement).checked).map((r) => (r as HTMLInputElement).value);
expect(checked).toEqual(['keep', 'none', 'none', 'keep']);
expect(within(dialog).getByRole('button', { name: 'apply_change' })).toBeDisabled();
expect(within(dialog).getByText('nothing_to_change')).toBeInTheDocument();
});
it('says why only known people are listed when directory queries are off', async () => {
const dialog = await openDialog(makeClient());
expect(within(dialog).getByTestId('delegate-directory-off')).toHaveTextContent('directory_off_note');
// The user's own principal isn't offered; their group and carol are.
const options = within(within(dialog).getByTestId('delegate-candidates')).getAllByRole('button').map((o) => o.textContent);
expect(options.some((o) => o?.includes('org-alice'))).toBe(false);
expect(options.some((o) => o?.includes('org-carol'))).toBe(true);
});
it('shows the note for a typed address the server cannot look up', async () => {
// A listing refused for another reason: the note comes from the lookup.
const client = makeClient({ directory: { principals: [ALICE], status: 'restricted' } });
const dialog = await openDialog(client);
expect(within(dialog).queryByTestId('delegate-directory-off')).toBeNull();
fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'org-carol@example.org' } });
fireEvent.click(within(dialog).getByText('use_address'));
await waitFor(() => expect(client.findPrincipalByEmail).toHaveBeenCalledWith('org-carol@example.org'));
expect(within(dialog).getByTestId('delegate-directory-off')).toHaveTextContent('directory_off_note');
});
it('takes a typed address the server resolves', async () => {
const client = makeClient({ directory: { principals: [ALICE], status: 'ok' } });
client.findPrincipalByEmail.mockResolvedValue({ principal: CAROL, status: 'ok' } as never);
const dialog = await openDialog(client);
const input = within(dialog).getByRole('textbox');
fireEvent.change(input, { target: { value: 'org-carol@example.org' } });
fireEvent.keyDown(input, { key: 'Enter' });
expect(await within(dialog).findByText('org-carol@example.org')).toBeInTheDocument();
expect(within(dialog).queryByRole('textbox')).toBeNull();
});
it('gives read access to all folders and the default calendar and address book in one batch', async () => {
const client = makeClient();
const dialog = await openDialog(client);
fireEvent.click(candidate(dialog, 'org-carol'));
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_add' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(1));
const changes = client.applyShareChanges.mock.calls[0][0];
expect(changes.map((c) => [c.kind, c.id, c.principalId])).toEqual([
['mailbox', 'a', 'ck'], ['mailbox', 'b', 'ck'], ['calendar', 'c', 'ck'], ['addressBook', 'd', 'ck'],
]);
expect(changes[0].rights).toEqual(MAILBOX_PRESETS.read);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('reports the items that failed and tries only those again', async () => {
let calls = 0;
const client = makeClient({
apply: (changes) => {
calls += 1;
return changes.map((change) => ({ change, ok: calls > 1 || change.kind !== 'calendar', error: calls > 1 ? undefined : 'You may not share this calendar' }));
},
});
const dialog = await openDialog(client);
fireEvent.click(candidate(dialog, 'org-carol'));
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_add' }));
const report = await within(dialog).findByRole('alert');
expect(report).toHaveTextContent('partial_title');
expect(report).toHaveTextContent('Calendar');
expect(report).toHaveTextContent('You may not share this calendar');
expect(report).not.toHaveTextContent('Inbox');
fireEvent.click(within(dialog).getByRole('button', { name: 'retry' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(2));
expect(client.applyShareChanges.mock.calls[1][0].map((c) => c.id)).toEqual(['c']);
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
});
it('offers only read when the admin allows only read', async () => {
restrict({ sharingRights: { allowedValues: ['read'] } });
const dialog = await openDialog(makeClient());
const rights = within(dialog).getByTestId('delegate-rights');
expect(within(rights).getAllByRole('radio').map((r) => (r as HTMLInputElement).value)).toEqual(['read']);
expect(within(rights).getByText('org_locked')).toBeInTheDocument();
});
it('offers both without a restriction, and no lock note', async () => {
const dialog = await openDialog(makeClient());
const rights = within(dialog).getByTestId('delegate-rights');
expect(within(rights).getAllByRole('radio').map((r) => (r as HTMLInputElement).value)).toEqual(['read', 'write']);
expect(within(rights).queryByText('org_locked')).toBeNull();
});
it('keeps keys from the shortcuts behind it and closes on Escape', async () => {
const onWindowKey = vi.fn();
window.addEventListener('keydown', onWindowKey);
try {
const dialog = await openDialog(makeClient());
fireEvent.keyDown(within(dialog).getByRole('textbox'), { key: 'e' });
expect(onWindowKey).not.toHaveBeenCalled();
fireEvent.keyDown(within(dialog).getByRole('textbox'), { key: 'Escape' });
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
expect(onWindowKey).not.toHaveBeenCalled();
} finally {
window.removeEventListener('keydown', onWindowKey);
}
});
it('starts "Change" from what the person has, and replaces it', async () => {
const client = makeClient({ withCarol: true });
renderWith(client);
const row = await screen.findByTestId('delegate-row');
fireEvent.click(within(row).getByRole('button', { name: 'change' }));
const dialog = await screen.findByRole('dialog');
const checked = within(dialog).getAllByRole('radio').filter((r) => (r as HTMLInputElement).checked).map((r) => (r as HTMLInputElement).value);
expect(checked).toEqual(['inbox', 'default', 'none', 'read']);
fireEvent.click(within(dialog).getAllByRole('radio').find((r) => (r as HTMLInputElement).value === 'write')!);
fireEvent.click(within(dialog).getByRole('button', { name: 'apply_change' }));
await waitFor(() => expect(client.applyShareChanges).toHaveBeenCalledTimes(1));
const changes = client.applyShareChanges.mock.calls[0][0];
expect(changes.map((c) => [c.id, (c.rights as Record<string, boolean> | null)?.mayAddItems ?? (c.rights as Record<string, boolean> | null)?.mayWriteAll])).toEqual([
['a', true], ['c', true],
]);
});
});
@@ -0,0 +1,430 @@
"use client";
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { Info, Loader2, X } from "@/components/icons";
import { OrgLockedNote } from "@/components/settings/org-locked-note";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, PrincipalListing } from "@/lib/jmap/types";
import type { ShareChange, ShareChangeResult, ShareTarget } from "@/lib/jmap/share-batch";
import type { AccessLevel } from "@/lib/jmap/share-rights";
import {
buildGrantChanges,
DEFAULT_DELEGATE_SCOPE,
failedChanges,
scopeIsEmpty,
type CollectionScope,
type DelegateScope,
type LevelChoice,
type MailScope,
} from "@/lib/delegation/delegation";
import { principalDisplay } from "./principal-display";
import { cn } from "@/lib/utils";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
type Lookup =
| { state: "idle" }
| { state: "busy" }
| { state: "notFound"; address: string }
| { state: "directoryOff" }
| { state: "self" }
| { state: "failed" };
export interface DelegateDialogProps {
client: IJMAPClient;
targets: ShareTarget[];
directory: PrincipalListing | null;
/** The user's own principal ids, never offered. */
ownIds: string[];
/** The user's own addresses: typing one says "That's you". */
ownEmails: string[];
/** Ids that already have access to something. */
existingIds: Set<string>;
/** Set for "Change": the person, and what they have now. */
initial?: { principal: Principal; scope: DelegateScope; level: LevelChoice };
levels: AccessLevel[];
/** The admin limited the rights (sharingRights). */
levelsRestricted: boolean;
/**
* The server keeps "mark as read" and flags as one right on folders
* (Stalwart 1.0), so read delegates can flag too; the read hint says so.
*/
seenSetsKeywords?: boolean;
onApplied: (principal: Principal, results: ShareChangeResult[]) => void;
onClose: () => void;
}
/**
* "Give access" and "Change": one person, what they get, and with which
* rights, applied to every folder, calendar and address book in one batch.
* Full screen on a phone.
*/
export function DelegateDialog({
client, targets, directory, ownIds, ownEmails, existingIds, initial, levels, levelsRestricted, seenSetsKeywords, onApplied, onClose,
}: DelegateDialogProps) {
const t = useTranslations("delegates");
const tCommon = useTranslations("common");
const titleId = useId();
const changing = !!initial;
const [principal, setPrincipal] = useState<Principal | null>(initial?.principal ?? null);
const [query, setQuery] = useState("");
const [lookup, setLookup] = useState<Lookup>({ state: "idle" });
const [scope, setScope] = useState<DelegateScope>(initial?.scope ?? DEFAULT_DELEGATE_SCOPE);
const [level, setLevel] = useState<LevelChoice>(() => {
const wanted = initial?.level ?? "read";
return wanted === "keep" || levels.includes(wanted) ? wanted : levels[0];
});
// "Keep as it is" is offered where Change started with it, and stays offered.
const keep = {
mail: initial?.scope.mail === "keep",
calendars: initial?.scope.calendars === "keep",
addressBooks: initial?.scope.addressBooks === "keep",
level: initial?.level === "keep",
};
const [saving, setSaving] = useState(false);
const [failures, setFailures] = useState<ShareChangeResult[] | null>(null);
const dialogRef = useFocusTrap({ isActive: true, onEscape: () => { if (!saving) onClose(); }, restoreFocus: true });
// The trap focuses the first control, the close button; picking a person
// starts in the search field. Declared after the trap, so it runs after it.
const searchRef = useRef<HTMLInputElement>(null);
useEffect(() => {
searchRef.current?.focus();
}, []);
const has = (kind: ShareTarget["kind"]) => targets.some((target) => target.kind === kind);
const directoryOff = directory?.status === "restricted" && !!directory.directoryQueriesOff;
const candidates = useMemo(() => {
const own = new Set(ownIds);
const q = query.trim().toLowerCase();
return (directory?.principals ?? [])
.filter((p) => !own.has(p.id))
.filter((p) => !q || p.name.toLowerCase().includes(q) || p.email?.toLowerCase().includes(q) || p.description?.toLowerCase().includes(q));
}, [directory, ownIds, query]);
const typedAddress = query.trim();
const canLookUp = EMAIL_RE.test(typedAddress)
&& !candidates.some((p) => p.email?.toLowerCase() === typedAddress.toLowerCase());
const lookUp = async () => {
if (!canLookUp || lookup.state === "busy") return;
if (ownEmails.some((email) => email.toLowerCase() === typedAddress.toLowerCase())) {
setLookup({ state: "self" });
return;
}
if (!client.findPrincipalByEmail) {
setLookup({ state: "failed" });
return;
}
setLookup({ state: "busy" });
const found = await client.findPrincipalByEmail(typedAddress);
if (found.principal && ownIds.includes(found.principal.id)) {
setLookup({ state: "self" });
} else if (found.principal) {
setPrincipal(found.principal);
setLookup({ state: "idle" });
} else if (found.status === "forbidden") {
setLookup({ state: "directoryOff" });
} else if (found.status === "ok") {
setLookup({ state: "notFound", address: typedAddress });
} else {
setLookup({ state: "failed" });
}
};
const apply = async (changes: ShareChange[]) => {
if (!principal || !client.applyShareChanges) return;
setSaving(true);
try {
const results = await client.applyShareChanges(changes);
const failed = failedChanges(results);
onApplied(principal, results);
if (failed.length === 0) onClose();
else setFailures(failed);
} finally {
setSaving(false);
}
};
const changes = useMemo(
() => (principal ? buildGrantChanges(targets, principal.id, scope, level, { replace: changing }) : []),
[targets, principal, scope, level, changing],
);
const nothingToDo = changing ? changes.length === 0 : scopeIsEmpty(scope);
const submit = () => {
if (!principal || nothingToDo) return;
void apply(changes);
};
const retry = () => {
if (failures) void apply(failures.map((f) => f.change));
};
if (typeof document === "undefined") return null;
return createPortal(
<div
className="fixed inset-0 z-[100000] flex items-stretch justify-center bg-black/50 backdrop-blur-[1px] sm:items-center sm:p-4"
onMouseDown={(e) => { if (e.target === e.currentTarget && !saving) onClose(); }}
// Keys typed here are the dialog's: the mail shortcuts behind it must not see them.
onKeyDown={(e) => e.stopPropagation()}
data-testid="delegate-dialog"
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="relative flex h-full w-full flex-col bg-background shadow-xl sm:h-auto sm:max-h-[85vh] sm:max-w-lg sm:rounded-lg sm:border sm:border-border"
>
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-3 sm:px-6 sm:py-4">
<h2 id={titleId} className="truncate text-lg font-semibold">
{changing && principal ? t("dialog_title_change", { name: principalDisplay(principal).name }) : t("dialog_title_add")}
</h2>
<button
type="button"
onClick={onClose}
disabled={saving}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
aria-label={tCommon("close")}
>
<X className="h-5 w-5" />
</button>
</div>
{failures ? (
<div className="flex-1 space-y-3 overflow-y-auto px-4 py-4 sm:px-6" role="alert">
<p className="text-sm font-medium">{t("partial_title")}</p>
<ul className="divide-y divide-border rounded-md border border-border text-sm">
{failures.map((f) => (
<li key={`${f.change.kind}:${f.change.id}`} className="px-3 py-2">
<span className="font-medium">{f.change.name}</span>
<span className="text-muted-foreground"> · {t(`kind.${f.change.kind}`)}</span>
{f.error && <div className="text-xs text-muted-foreground">{f.error}</div>}
</li>
))}
</ul>
</div>
) : (
<div className="flex-1 space-y-5 overflow-y-auto px-4 py-4 sm:px-6">
<Field label={t("person")}>
{principal ? (
<div className="flex items-center gap-3 rounded-md border border-border px-3 py-2">
<Avatar name={principal.name} email={principal.email ?? undefined} size="sm" className="shrink-0" />
<PrincipalText principal={principal} />
{!changing && (
<Button variant="ghost" size="sm" onClick={() => { setPrincipal(null); setLookup({ state: "idle" }); }}>
{t("change_person")}
</Button>
)}
</div>
) : (
<div className="space-y-2">
<input
type="text"
value={query}
onChange={(e) => { setQuery(e.target.value); setLookup({ state: "idle" }); }}
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); void lookUp(); } }}
placeholder={t("search_placeholder")}
aria-label={t("person")}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
ref={searchRef}
/>
{directory === null ? (
<p className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3 w-3 animate-spin" />{t("loading")}</p>
) : (
<ul className="max-h-48 overflow-y-auto rounded-md border border-border" aria-label={t("person")} data-testid="delegate-candidates">
{candidates.length === 0 && !canLookUp && (
<li className="px-3 py-2 text-xs text-muted-foreground">{t("no_match")}</li>
)}
{candidates.map((p) => (
<li key={p.id}>
<button
type="button"
onClick={() => setPrincipal(p)}
className="flex w-full items-center gap-2 px-3 py-2 text-start hover:bg-muted focus:bg-muted focus:outline-none"
>
<Avatar name={p.name} email={p.email ?? undefined} size="sm" className="shrink-0" />
<PrincipalText principal={p} />
{existingIds.has(p.id) && <span className="shrink-0 text-[10px] text-muted-foreground">{t("has_access")}</span>}
</button>
</li>
))}
{canLookUp && (
<li>
<button
type="button"
onClick={() => void lookUp()}
disabled={lookup.state === "busy"}
className="flex w-full items-center gap-2 px-3 py-2 text-start text-sm hover:bg-muted focus:bg-muted focus:outline-none disabled:opacity-50"
>
{lookup.state === "busy" && <Loader2 className="h-4 w-4 animate-spin" />}
{lookup.state === "busy" ? t("looking_up") : t("use_address", { address: typedAddress })}
</button>
</li>
)}
</ul>
)}
{lookup.state === "notFound" && <p className="text-xs text-destructive" role="status">{t("not_found", { address: lookup.address })}</p>}
{lookup.state === "failed" && <p className="text-xs text-destructive" role="status">{t("lookup_failed")}</p>}
{lookup.state === "self" && <p className="text-xs text-destructive" role="status">{t("is_you")}</p>}
{(directoryOff || lookup.state === "directoryOff") && (
<p className="text-xs text-muted-foreground" role="status" data-testid="delegate-directory-off">
{directoryOff ? `${t("known_only")} ` : ""}{t("directory_off_note")}
</p>
)}
{directory?.status === "error" && <p className="text-xs text-muted-foreground">{t("directory_failed")}</p>}
</div>
)}
</Field>
<fieldset className="space-y-3">
<legend className="mb-1 text-sm font-medium">{t("what")}</legend>
{has("mailbox") && (
<Choice<MailScope>
label={t("mail")}
value={scope.mail}
onChange={(mail) => setScope({ ...scope, mail })}
options={[...keepOption<MailScope>(keep.mail, t("keep")), ["all", t("mail_all")], ["inbox", t("mail_inbox")], ["none", t("none")]]}
/>
)}
{has("calendar") && (
<Choice<CollectionScope>
label={t("calendars")}
value={scope.calendars}
onChange={(calendars) => setScope({ ...scope, calendars })}
options={[...keepOption<CollectionScope>(keep.calendars, t("keep")), ["default", t("calendars_default")], ["all", t("calendars_all")], ["none", t("none")]]}
/>
)}
{has("addressBook") && (
<Choice<CollectionScope>
label={t("address_books")}
value={scope.addressBooks}
onChange={(addressBooks) => setScope({ ...scope, addressBooks })}
options={[...keepOption<CollectionScope>(keep.addressBooks, t("keep")), ["default", t("address_books_default")], ["all", t("address_books_all")], ["none", t("none")]]}
/>
)}
{!changing && scopeIsEmpty(scope) && <p className="text-xs text-destructive">{t("empty_scope")}</p>}
{changing && <p className="text-xs text-muted-foreground">{t("change_note")}</p>}
</fieldset>
<fieldset className="space-y-2" data-testid="delegate-rights">
<legend className="mb-1 text-sm font-medium">{t("rights")}</legend>
{[...(keep.level ? ["keep" as const] : []), ...levels].map((option) => (
<label key={option} className="flex cursor-pointer items-start gap-2 text-sm">
<input
type="radio"
name="delegate-level"
value={option}
checked={level === option}
onChange={() => setLevel(option)}
className="mt-1"
/>
<span>
<span className="block">{t(`rights_${option}`)}</span>
<span className="block text-xs text-muted-foreground">
{t(option === "read" && seenSetsKeywords ? "rights_read_hint_flags" : `rights_${option}_hint`)}
</span>
</span>
</label>
))}
{levelsRestricted && <OrgLockedNote />}
</fieldset>
{changing && nothingToDo && <p className="text-xs text-muted-foreground" role="status">{t("nothing_to_change")}</p>}
<p className="flex gap-2 rounded-md bg-muted/60 px-3 py-2 text-xs text-muted-foreground">
<Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
<span>{t("send_note")}</span>
</p>
</div>
)}
<div className="flex items-center justify-end gap-2 border-t border-border px-4 py-3 sm:px-6 sm:py-4">
{failures ? (
<>
<Button variant="outline" onClick={onClose} disabled={saving}>{tCommon("close")}</Button>
<Button onClick={retry} disabled={saving}>
{saving && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t("retry")}
</Button>
</>
) : (
<>
<Button variant="outline" onClick={onClose} disabled={saving}>{tCommon("cancel")}</Button>
<Button onClick={submit} disabled={saving || !principal || nothingToDo}>
{saving && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{changing ? t("apply_change") : t("apply_add")}
</Button>
</>
)}
</div>
</div>
</div>,
document.body,
);
}
function Field({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="space-y-1.5">
<div className="text-sm font-medium">{label}</div>
{children}
</div>
);
}
function PrincipalText({ principal }: { principal: Principal }) {
const t = useTranslations("delegates");
const { name, detail } = principalDisplay(principal);
return (
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 truncate text-sm font-medium">
<span className="truncate">{name}</span>
{principal.type === "group" && (
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-normal uppercase text-muted-foreground">{t("group")}</span>
)}
</div>
{detail && <div className="truncate text-xs text-muted-foreground">{detail}</div>}
</div>
);
}
function keepOption<T extends string>(offered: boolean, label: string): [T, string][] {
return offered ? [["keep" as T, label]] : [];
}
function Choice<T extends string>({ label, value, onChange, options }: {
label: string;
value: T;
onChange: (value: T) => void;
options: [T, string][];
}) {
const name = useId();
return (
<div role="radiogroup" aria-label={label} className="flex flex-col gap-1.5 sm:flex-row sm:items-center sm:gap-3">
<span className="w-28 shrink-0 text-sm text-muted-foreground">{label}</span>
<div className="flex flex-wrap gap-1.5">
{options.map(([option, text]) => (
<label
key={option}
className={cn(
"cursor-pointer rounded-md border px-2.5 py-1 text-xs transition-colors has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
value === option ? "border-primary bg-primary/10 text-foreground" : "border-border text-muted-foreground hover:bg-muted",
)}
>
<input type="radio" name={name} value={option} checked={value === option} onChange={() => onChange(option)} className="sr-only" />
{text}
</label>
))}
</div>
</div>
);
}
@@ -0,0 +1,267 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Loader2, UserPlus } from "@/components/icons";
import { SettingsSection } from "@/components/settings/settings-section";
import { useAuthStore } from "@/stores/auth-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
import { useRestriction } from "@/hooks/use-restriction";
import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory";
import type { Principal, PrincipalListing } from "@/lib/jmap/types";
import type { ShareChangeResult, ShareTarget } from "@/lib/jmap/share-batch";
import type { ShareKind } from "@/lib/jmap/share-rights";
import {
allowedLevels,
buildRevokeChanges,
changeStartFor,
failedChanges,
summarizeDelegates,
type DelegateSummary,
} from "@/lib/delegation/delegation";
import { rememberPrincipals, rememberedPrincipals } from "@/lib/delegation/known-principals";
import { DelegateDialog, type DelegateDialogProps } from "./delegate-dialog";
import { principalDisplay } from "./principal-display";
type DialogState = { mode: "add" } | { mode: "change"; summary: DelegateSummary } | null;
/**
* Settings > Sharing > Delegates: give someone access to mail, calendars
* and contacts in one step, and see and take back who has access to what.
* Hidden when the admin hides `delegation`; `sharingRights` limits the
* rights offered. Calendars, contacts and files follow their feature gates.
*/
export function DelegatesSettings() {
const t = useTranslations("delegates");
const client = useAuthStore((s) => s.client);
const accountKey = useAuthStore((s) => s.activeAccountId) ?? "";
const delegation = useRestriction("delegation");
const rights = useRestriction("sharingRights");
const levels = useMemo(() => allowedLevels(rights.allowedValues), [rights.allowedValues]);
const calendars = usePolicyStore((s) => s.isFeatureEnabled("calendarEnabled"));
const contacts = usePolicyStore((s) => s.isFeatureEnabled("contactsEnabled"));
const files = usePolicyStore((s) => s.isFeatureEnabled("filesEnabled"));
const groupAddresses = usePolicyStore((s) => s.subject?.groups);
const [targets, setTargets] = useState<ShareTarget[] | null>(null);
const [failedKinds, setFailedKinds] = useState<ShareKind[]>([]);
const [directory, setDirectory] = useState<PrincipalListing | null>(null);
const [remembered, setRemembered] = useState<Map<string, Principal>>(() => new Map());
const [dialog, setDialog] = useState<DialogState>(null);
const [confirmRemove, setConfirmRemove] = useState<DelegateSummary | null>(null);
const [removingId, setRemovingId] = useState<string | null>(null);
// Each read is numbered; an answer that arrives after a newer read started
// (another account, a changed gate) is dropped instead of shown.
const readSeq = useRef(0);
const reload = useCallback(async () => {
if (!client?.getShareTargets) return;
const seq = ++readSeq.current;
const result = await client.getShareTargets({ calendars, contacts, files });
if (seq !== readSeq.current) return;
setTargets(result.targets);
setFailedKinds(result.failed);
}, [client, calendars, contacts, files]);
useEffect(() => {
if (!client || delegation.hidden) return;
let cancelled = false;
const reads = readSeq;
setTargets(null);
setFailedKinds([]);
setDirectory(null);
setRemembered(rememberedPrincipals(accountKey));
void reload();
void loadPrincipalDirectory(client).then((listing) => {
if (!cancelled) setDirectory(listing);
});
return () => {
cancelled = true;
reads.current++;
};
}, [client, accountKey, delegation.hidden, reload]);
const ownIds = useMemo(() => (client ? [client.getAccountId()] : []), [client]);
const ownEmails = useMemo(() => {
const emails = new Set<string>();
const username = client?.getUsername();
if (username?.includes("@")) emails.add(username);
for (const p of directory?.principals ?? []) if (ownIds.includes(p.id) && p.email) emails.add(p.email);
return [...emails];
}, [client, directory, ownIds]);
const summaries = useMemo(() => (targets ? summarizeDelegates(targets, ownIds) : []), [targets, ownIds]);
// Stalwart names a principal by its account id, and an account shared with
// the user is in the session under the owner's address: a last way to name
// someone the directory won't (Stalwart 1.0 with directory queries off).
// The policy subject knows which of those accounts are groups.
const sessionAccounts = useMemo(() => new Map((client?.getSharedAccounts() ?? []).map((a) => [a.id, a.name])), [client]);
const principalFor = useCallback((id: string): Principal | null => {
const known = directory?.principals.find((p) => p.id === id) ?? remembered.get(id);
if (known) return known;
const accountName = sessionAccounts.get(id);
if (!accountName) return null;
const isGroup = (groupAddresses ?? []).some((g) => g.toLowerCase() === accountName.toLowerCase());
return { id, name: accountName, email: accountName.includes("@") ? accountName : null, type: isGroup ? "group" : "individual" };
}, [directory, remembered, sessionAccounts, groupAddresses]);
// Remember the names of the people who have access, so the list can name
// them when the directory no longer does. Never the whole directory.
useEffect(() => {
if (!accountKey || summaries.length === 0 || !directory) return;
const named = summaries
.map((s) => directory.principals.find((p) => p.id === s.principalId))
.filter((p): p is Principal => !!p);
if (named.length > 0) rememberPrincipals(accountKey, named);
}, [accountKey, summaries, directory]);
if (delegation.hidden || !client) return null;
// Change and Remove act on everything the person has; with a type unread,
// that isn't known, so they wait until a read succeeds.
const incomplete = failedKinds.length > 0;
const onApplied: DelegateDialogProps["onApplied"] = (principal, results) => {
rememberPrincipals(accountKey, [principal]);
setRemembered(rememberedPrincipals(accountKey));
const failed = failedChanges(results);
if (failed.length === 0) {
toast.success(dialog?.mode === "change" ? t("updated") : t("applied", { name: principalDisplay(principal).name }));
}
void reload();
};
const remove = async (summary: DelegateSummary) => {
if (!targets || !client.applyShareChanges || incomplete) return;
setRemovingId(summary.principalId);
try {
const results: ShareChangeResult[] = await client.applyShareChanges(buildRevokeChanges(targets, summary.principalId));
const failed = failedChanges(results);
if (failed.length === 0) toast.success(t("removed"));
else toast.error(t("remove_partial", { items: failed.map((f) => f.change.name).join(", ") }));
await reload();
} finally {
setRemovingId(null);
}
};
const nameOf = (id: string) => {
const principal = principalFor(id);
return principal ? principalDisplay(principal).name : t("unknown_person");
};
return (
<SettingsSection id="delegates" title={t("title")} description={t("description")}>
<div>
<Button onClick={() => setDialog({ mode: "add" })} disabled={!targets} data-testid="delegates-give-access">
<UserPlus className="me-2 h-4 w-4" />
{t("give_access")}
</Button>
</div>
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("who_has_access")}</h4>
{incomplete && (
<div className="flex flex-wrap items-center gap-2 text-xs text-destructive" role="status" data-testid="delegates-read-failed">
<span>{t("read_failed")}</span>
<Button variant="outline" size="sm" onClick={() => void reload()}>{t("retry")}</Button>
</div>
)}
{targets === null ? (
<p className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("loading")}</p>
) : summaries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("nobody")}</p>
) : (
<ul className="divide-y divide-border rounded-md border border-border" data-testid="delegates-list">
{summaries.map((summary) => {
const principal = principalFor(summary.principalId);
const display = principal ? principalDisplay(principal) : { name: t("unknown_person"), detail: summary.principalId };
const busy = removingId === summary.principalId;
return (
<li key={summary.principalId} className="flex flex-col gap-2 px-3 py-3 sm:flex-row sm:items-center" data-testid="delegate-row">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Avatar name={principal?.name} email={principal?.email ?? undefined} size="sm" className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 truncate text-sm font-medium">
<span className="truncate">{display.name}</span>
{principal?.type === "group" && (
<span className="rounded bg-muted px-1 py-0.5 text-[10px] font-normal uppercase text-muted-foreground">{t("group")}</span>
)}
</div>
{display.detail && <div className="truncate text-xs text-muted-foreground">{display.detail}</div>}
<div className="text-xs text-muted-foreground">{describeSummary(summary, t)}</div>
</div>
</div>
<div className="flex shrink-0 gap-2 ps-11 sm:ps-0">
<Button variant="outline" size="sm" onClick={() => setDialog({ mode: "change", summary })} disabled={busy || incomplete}>
{t("change")}
</Button>
<Button variant="outline" size="sm" onClick={() => setConfirmRemove(summary)} disabled={busy || incomplete}>
{busy && <Loader2 className="me-2 h-4 w-4 animate-spin" />}
{t("remove")}
</Button>
</div>
</li>
);
})}
</ul>
)}
</div>
{dialog && targets && (
<DelegateDialog
client={client}
targets={targets}
directory={directory}
ownIds={ownIds}
ownEmails={ownEmails}
existingIds={new Set(summaries.map((s) => s.principalId))}
initial={dialog.mode === "change"
? {
principal: principalFor(dialog.summary.principalId)
?? { id: dialog.summary.principalId, name: t("unknown_person"), type: "individual" },
...changeStartFor(targets, dialog.summary.principalId, levels),
}
: undefined}
levels={levels}
levelsRestricted={rights.allowedValues !== null && levels.length < 2}
seenSetsKeywords={client.sharesSeenWithKeywords?.() ?? false}
onApplied={onApplied}
onClose={() => setDialog(null)}
/>
)}
<ConfirmDialog
isOpen={!!confirmRemove}
onClose={() => setConfirmRemove(null)}
onConfirm={() => { if (confirmRemove) void remove(confirmRemove); }}
title={confirmRemove ? t("remove_title", { name: nameOf(confirmRemove.principalId) }) : ""}
message={confirmRemove ? t("remove_message", { name: nameOf(confirmRemove.principalId) }) : ""}
confirmText={t("remove")}
variant="destructive"
/>
</SettingsSection>
);
}
type Translate = ReturnType<typeof useTranslations<"delegates">>;
/** "All mail folders · 1 calendar · Read", in the order of the dialog. */
export function describeSummary(summary: DelegateSummary, t: Translate): string {
const parts: string[] = [];
const { mail, calendars, addressBooks, files } = summary;
if (mail.shared > 0) {
parts.push(mail.shared === mail.total
? t("summary.mail_all")
: mail.shared === 1 && mail.inbox ? t("summary.mail_inbox") : t("summary.mail_some", { count: mail.shared }));
}
if (calendars.shared > 0) parts.push(t("summary.calendars", { count: calendars.shared }));
if (addressBooks.shared > 0) parts.push(t("summary.address_books", { count: addressBooks.shared }));
if (files > 0) parts.push(t("summary.files", { count: files }));
parts.push(t(`level.${summary.level ?? "none"}`));
return parts.join(" · ");
}
@@ -0,0 +1,10 @@
import type { Principal } from "@/lib/jmap/types";
/** The line to show for a principal, and the smaller one under it. */
export function principalDisplay(principal: Pick<Principal, "id" | "name" | "email" | "description">): { name: string; detail: string | null } {
const name = principal.name || principal.email || principal.id;
const detail = principal.email && principal.email !== name
? principal.email
: principal.description && principal.description !== name ? principal.description : null;
return { name, detail };
}
@@ -13,6 +13,7 @@ import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store';
import { toast } from '@/stores/toast-store';
import { cn } from '@/lib/utils';
import { IS_LITE } from '@/lib/lite';
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
import { MessageListOrderSettings } from './message-list-order-settings';
import { Button } from '@/components/ui/button';
@@ -28,6 +29,8 @@ import type { Mailbox } from '@/lib/jmap/types';
*/
export function MessageListSettings() {
const t = useTranslations('settings.email_behavior');
// Sender icons come from an outside service: no setting for them in the mode (#903).
const faviconsAllowed = !useNoThirdPartyTraffic();
const tPage = useTranslations('settings.message_list');
const tAppearance = useTranslations('settings.appearance');
const tAdvanced = useTranslations('settings.advanced');
@@ -216,13 +219,13 @@ export function MessageListSettings() {
{/* Sender favicons come from the /api/favicon proxy, absent in the static Lite build (components/ui/avatar.tsx). */}
<SettingsSection id="senders" title={tPage('sections.senders')}>
{!IS_LITE && (
{!IS_LITE && faviconsAllowed && (
<SettingItem label={tAdvanced('sender_favicons.label')} description={tAdvanced('sender_favicons.description')}>
<ToggleSwitch checked={senderFavicons} onChange={(checked) => updateSetting('senderFavicons', checked)} />
</SettingItem>
)}
{!IS_LITE && senderFavicons && (
{!IS_LITE && faviconsAllowed && senderFavicons && (
<SettingItem nested label={tAdvanced('sender_favicons.refresh_label')} description={tAdvanced('sender_favicons.refresh_description')}>
<Button
variant="outline"
+11 -3
View File
@@ -29,6 +29,7 @@ import {
} from '@/lib/web-push';
import type { PushDevice } from '@/lib/web-push';
import { IS_LITE } from '@/lib/lite';
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
import {
resolveActiveRelayUrl,
resolvePushRelayOptions,
@@ -50,6 +51,9 @@ type DevicesState =
export function NotificationSettings() {
const t = useTranslations('settings.notifications');
const tAppearance = useTranslations('settings.appearance');
const tThirdParty = useTranslations('third_party');
// The relay list depends on it; re-render once the config is known (#903).
const noThirdParty = useNoThirdPartyTraffic();
const {
emailNotificationsEnabled,
emailNotificationSound,
@@ -83,6 +87,8 @@ export function NotificationSettings() {
const relayOptions = resolvePushRelayOptions(policy);
const activeRelayUrl = resolveActiveRelayUrl(policy, pushRelayUrl);
const relayChoiceFixed = pushRelayLocked || relayOptions.length < 2;
// Third-party traffic off and no relay of the organisation's own (#903).
const noRelay = !activeRelayUrl;
const activeRelayLabel =
relayOptions.find((option) => option.url === activeRelayUrl)?.label ?? activeRelayUrl;
@@ -140,6 +146,8 @@ export function NotificationSettings() {
// components/service-worker-registration.tsx), not a browser limitation.
const statusDescription = pushStatus.kind === 'unsupported'
? (IS_LITE ? t('push.status_lite') : `${t('push.status_unsupported')} ${t('push.ios_hint')}`)
: noRelay && !pushEnabled
? tThirdParty('push_needs_relay')
: busy
? t('push.status_busy')
: pushEnabled
@@ -420,7 +428,7 @@ export function NotificationSettings() {
<ToggleSwitch
checked={pushEnabled}
onChange={(checked) => void (checked ? handleEnablePush() : handleDisablePush())}
disabled={busy || pushStatus.kind === 'unsupported' || !client}
disabled={busy || pushStatus.kind === 'unsupported' || !client || (noRelay && !pushEnabled)}
/>
</div>
</SettingItem>
@@ -432,11 +440,11 @@ export function NotificationSettings() {
<SettingItem
advanced
label={t('push.relay_label')}
description={pushRelayLocked ? t('push.relay_locked_desc') : t('push.relay_desc')}
description={pushRelayLocked ? t('push.relay_locked_desc') : noThirdParty ? tThirdParty('relay_desc') : t('push.relay_desc')}
locked={pushRelayLocked}
>
{relayChoiceFixed ? (
<span className="text-sm text-muted-foreground">{activeRelayLabel}</span>
<span className="text-sm text-muted-foreground">{noRelay ? tThirdParty('relay_none') : activeRelayLabel}</span>
) : (
<Select
value={activeRelayUrl}
+4 -1
View File
@@ -14,6 +14,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { PluginPermissionList } from '@/components/plugins/plugin-permission-list';
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
import { effectiveSettings } from '@/lib/plugin-settings';
import { PLUGIN_REFUSED_ERROR } from '@/lib/outgoing-hosts';
import { lookup } from '@/lib/plugin-sandbox/plugin-i18n';
import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog';
@@ -213,12 +214,14 @@ interface PluginCardProps {
function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onToggleExpand, onToggle, onRemove, onUpdateSettings }: PluginCardProps) {
const t = useTranslations('plugins.settings');
const tThirdParty = useTranslations('third_party');
const locale = useLocale();
const isManaged = Boolean(plugin.managed);
const awaiting = !isManaged && plugin.approvalStatus === 'pending';
const denied = !isManaged && plugin.approvalStatus === 'denied';
const errorText = plugin.error
? ERROR_CODES.has(plugin.error) ? t(`error_${plugin.error.replace(/-/g, '_')}`) : plugin.error
? plugin.error === PLUGIN_REFUSED_ERROR ? tThirdParty('plugin_refused')
: ERROR_CODES.has(plugin.error) ? t(`error_${plugin.error.replace(/-/g, '_')}`) : plugin.error
: null;
return (
+6 -2
View File
@@ -10,6 +10,7 @@ import { ChevronRight } from '@/components/icons';
import { usePolicyStore } from '@/stores/policy-store';
import { useContactStore } from '@/stores/contact-store';
import { useAuthStore } from '@/stores/auth-store';
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
/**
* The Reading page: the open message. Opening it, how its body is drawn,
@@ -18,6 +19,9 @@ import { useAuthStore } from '@/stores/auth-store';
* tab. List, delete and swipe rows live on the Message list page.
*/
export function ReadingSettings() {
// No third-party traffic (#903) blocks remote content outright: the
// setting shows as the organisation's, set to block.
const remoteBlockedByOrg = useNoThirdPartyTraffic();
const t = useTranslations('settings.email_behavior');
const tPage = useTranslations('settings.reading_page');
const [showTrustedModal, setShowTrustedModal] = useState(false);
@@ -119,9 +123,9 @@ export function ReadingSettings() {
<SettingsSection id="images" title={tPage('sections.images')} description={tPage('sections.images_description')}>
{isFeatureEnabled('externalContentEnabled') && !isSettingHidden('externalContentPolicy') && (
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy')}>
<SettingItem label={t('external_content.label')} description={t('external_content.description')} locked={isSettingLocked('externalContentPolicy') || remoteBlockedByOrg}>
<Select
value={externalContentPolicy}
value={remoteBlockedByOrg ? 'block' : externalContentPolicy}
onChange={(value) =>
updateSetting('externalContentPolicy', value as 'ask' | 'block' | 'allow')
}
+13
View File
@@ -24,6 +24,7 @@ import {
PenLine,
LayoutList,
Database,
Users,
X,
type AppIcon,
} from '@/components/icons';
@@ -48,6 +49,8 @@ import { FilesSettingsComponent } from '@/components/settings/files-settings';
import { ContactsSettings } from '@/components/settings/contacts-settings';
import { AddonsSettings } from '@/components/settings/addons-settings';
import { NotificationSettings } from '@/components/settings/notification-settings';
import { DelegatesSettings } from '@/components/settings/delegates/delegates-settings';
import { useRestriction } from '@/hooks/use-restriction';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { AdvancedSettingsProvider, AdvancedSettingsSwitch, SectionNav, scrollToSettingsSection } from '@/components/settings/settings-section';
@@ -127,6 +130,7 @@ const tabIcons: Record<Tab, AppIcon> = {
filters: Filter,
organize: FolderOpen,
security: Shield,
sharing: Users,
calendar: Calendar,
contacts: BookUser,
files: HardDrive,
@@ -254,6 +258,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
const tDelegates = useTranslations('delegates');
useDocumentTitle(tSidebar('settings'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
@@ -266,6 +271,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const appName = productName(configAppName);
const { isFeatureEnabled } = usePolicyStore();
const archiveActionEnabled = isFeatureEnabled('archiveActionEnabled');
const delegationHidden = useRestriction('delegation').hidden;
// A tab in the URL wins over the persisted one; without it, nothing changes.
const [activeTab, setActiveTab] = useState<SettingsTabId>(
() => targetFromDeepLink(linkSegments)?.tab ?? readPersistedTab(),
@@ -629,11 +635,17 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
const supportsCalendar = client?.supportsCalendars() ?? false;
const supportsSieve = client?.supportsSieve() ?? false;
const supportsFiles = client?.supportsFiles() ?? false;
// Delegates (Settings > Sharing) needs someone to share with and something to share.
const supportsSharing = !delegationHidden && !!client?.getShareTargets && (client?.supportsPrincipals() ?? false)
&& ((client?.supportsMailboxSharing?.() ?? false)
|| (supportsCalendar && isFeatureEnabled('calendarEnabled'))
|| ((client?.supportsContacts() ?? false) && isFeatureEnabled('contactsEnabled')));
const tabs: TabDef[] = [
// You and the app
{ id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'top' },
...(stalwartFeaturesEnabled ? [{ id: 'security' as Tab, label: t('tabs.security'), icon: tabIcons.security, group: 'top' as TabGroup }] : []),
...(supportsSharing ? [{ id: 'sharing' as Tab, label: tDelegates('tab'), icon: tabIcons.sharing, group: 'top' as TabGroup }] : []),
{ id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'top' },
{ id: 'general', label: t('tabs.general'), icon: tabIcons.general, group: 'top' },
{ id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'top' },
@@ -783,6 +795,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
{effectiveActiveTab === 'filters' && <FilterSettings />}
{effectiveActiveTab === 'organize' && <OrganizeSettings />}
{effectiveActiveTab === 'security' && <AccountSecuritySettings />}
{effectiveActiveTab === 'sharing' && <DelegatesSettings />}
{effectiveActiveTab === 'calendar' && (
managedAccountId
? <CalendarManagementSettings />
+20 -126
View File
@@ -6,129 +6,13 @@ import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { X, Loader2, UserPlus, Trash2, Users, ChevronDown } from "@/components/icons";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import type { Principal, PrincipalDirectoryStatus, CalendarRights, AddressBookRights, FileNodeRights, MailboxRights } from "@/lib/jmap/types";
import type { Principal, PrincipalDirectoryStatus } from "@/lib/jmap/types";
import { detectPreset, presetRights, type AnyRights, type RolePreset, type ShareKind } from "@/lib/jmap/share-rights";
import { loadPrincipalDirectory } from "@/lib/jmap/principal-directory";
import { toast } from "@/stores/toast-store";
type ShareKind = "calendar" | "addressBook" | "file" | "mailbox";
type AnyRights = CalendarRights | AddressBookRights | FileNodeRights | MailboxRights;
type RolePreset = "freeBusy" | "read" | "readWrite" | "manager" | "custom";
const CALENDAR_PRESETS: Record<Exclude<RolePreset, "custom">, CalendarRights> = {
freeBusy: {
mayReadFreeBusy: true, mayReadItems: false, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
read: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: false, mayWriteOwn: false,
mayUpdatePrivate: false, mayRSVP: false, mayShare: false, mayDelete: false,
},
readWrite: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: false, mayDelete: false,
},
manager: {
mayReadFreeBusy: true, mayReadItems: true, mayWriteAll: true, mayWriteOwn: true,
mayUpdatePrivate: true, mayRSVP: true, mayShare: true, mayDelete: true,
},
};
const ADDRESS_BOOK_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights> = {
read: { mayRead: true, mayWrite: false, mayShare: false, mayDelete: false },
readWrite: { mayRead: true, mayWrite: true, mayShare: false, mayDelete: false },
manager: { mayRead: true, mayWrite: true, mayShare: true, mayDelete: true },
};
const FILE_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, FileNodeRights> = {
read: {
mayRead: true, mayAddChildren: false, mayRename: false,
mayDelete: false, mayModifyContent: false, mayShare: false,
},
readWrite: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: false,
},
manager: {
mayRead: true, mayAddChildren: true, mayRename: true,
mayDelete: true, mayModifyContent: true, mayShare: true,
},
};
// Mail folder rights (RFC 8621 §2 + mail:share). "Read & write" lets the
// grantee file, flag and remove mail; "Manager" additionally lets them
// rename/delete the folder, create subfolders, send as it and re-share.
const MAILBOX_PRESETS: Record<Exclude<RolePreset, "custom" | "freeBusy">, MailboxRights> = {
read: {
mayReadItems: true, mayAddItems: false, mayRemoveItems: false, maySetSeen: true,
maySetKeywords: false, mayCreateChild: false, mayRename: false, mayDelete: false,
maySubmit: false, mayShare: false,
},
readWrite: {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
maySetKeywords: true, mayCreateChild: false, mayRename: false, mayDelete: false,
maySubmit: false, mayShare: false,
},
manager: {
mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetSeen: true,
maySetKeywords: true, mayCreateChild: true, mayRename: true, mayDelete: true,
maySubmit: true, mayShare: true,
},
};
function detectMailboxPreset(r: MailboxRights): RolePreset {
for (const [name, preset] of Object.entries(MAILBOX_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, MailboxRights][]) {
const keys = Object.keys(preset) as (keyof MailboxRights)[];
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
return name;
}
}
return "custom";
}
function detectCalendarPreset(r: CalendarRights): RolePreset {
for (const [name, preset] of Object.entries(CALENDAR_PRESETS) as [Exclude<RolePreset, "custom">, CalendarRights][]) {
if ((Object.keys(preset) as (keyof CalendarRights)[]).every((k) => preset[k] === r[k])) {
return name;
}
}
return "custom";
}
function detectAddressBookPreset(r: AddressBookRights): RolePreset {
for (const [name, preset] of Object.entries(ADDRESS_BOOK_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, AddressBookRights][]) {
const keys = Object.keys(preset) as (keyof AddressBookRights)[];
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
return name;
}
}
return "custom";
}
function detectFilePreset(r: FileNodeRights): RolePreset {
for (const [name, preset] of Object.entries(FILE_PRESETS) as [Exclude<RolePreset, "custom" | "freeBusy">, FileNodeRights][]) {
const keys = Object.keys(preset) as (keyof FileNodeRights)[];
if (keys.every((k) => preset[k] === (r[k] ?? false))) {
return name;
}
}
return "custom";
}
function presetRights(kind: ShareKind, preset: RolePreset): AnyRights | undefined {
if (preset === "custom") return undefined;
if (kind === "calendar") return CALENDAR_PRESETS[preset as keyof typeof CALENDAR_PRESETS];
if (kind === "file") return FILE_PRESETS[preset as keyof typeof FILE_PRESETS];
if (kind === "mailbox") return MAILBOX_PRESETS[preset as keyof typeof MAILBOX_PRESETS];
return ADDRESS_BOOK_PRESETS[preset as keyof typeof ADDRESS_BOOK_PRESETS];
}
function detectPreset(kind: ShareKind, rights: AnyRights): RolePreset {
if (kind === "calendar") return detectCalendarPreset(rights as CalendarRights);
if (kind === "file") return detectFilePreset(rights as FileNodeRights);
if (kind === "mailbox") return detectMailboxPreset(rights as MailboxRights);
return detectAddressBookPreset(rights as AddressBookRights);
}
import { useRestriction } from "@/hooks/use-restriction";
import { allowedLevels } from "@/lib/delegation/delegation";
import { OrgLockedNote } from "./org-locked-note";
interface ShareCollectionDialogProps {
client: IJMAPClient;
@@ -159,6 +43,9 @@ export function ShareCollectionDialog({
const [search, setSearch] = useState("");
const [savingId, setSavingId] = useState<string | null>(null);
const [showAdd, setShowAdd] = useState(false);
// The admin's `sharingRights`: without write, only the read presets.
const sharingRights = useRestriction("sharingRights");
const writeAllowed = allowedLevels(sharingRights.allowedValues).includes("write");
// Load principals on mount
useEffect(() => {
@@ -198,8 +85,15 @@ export function ShareCollectionDialog({
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
const presetOptions: RolePreset[] = [
...(kind === "calendar" ? ["freeBusy" as const] : []),
"read",
...(writeAllowed ? ["readWrite" as const, "manager" as const] : []),
];
const handleSetRights = async (principalId: string, preset: RolePreset) => {
if (preset === "custom") return; // custom is read-only here
if (!(presetOptions as readonly RolePreset[]).includes(preset)) return;
const rights = presetRights(kind, preset);
if (!rights) return;
setSavingId(principalId);
@@ -255,9 +149,7 @@ export function ShareCollectionDialog({
return Object.entries(shareWith || {}) as [string, AnyRights][];
}, [shareWith]);
const presetOptions = kind === "calendar"
? ["freeBusy", "read", "readWrite", "manager"] as const
: ["read", "readWrite", "manager"] as const;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
@@ -285,6 +177,7 @@ export function ShareCollectionDialog({
<div className="px-6 py-4 space-y-4 overflow-y-auto">
<p className="text-sm text-muted-foreground">{t("description")}</p>
{!writeAllowed && <OrgLockedNote />}
{sharedEntries.length === 0 && !showAdd && (
<div className="text-sm text-muted-foreground italic py-4 text-center">
@@ -331,8 +224,9 @@ export function ShareCollectionDialog({
{presetOptions.map((p) => (
<option key={p} value={p}>{t(`preset.${p}`)}</option>
))}
{preset === "custom" && (
<option value="custom">{t("preset.custom")}</option>
{/* A grant the admin no longer lets users give stays named, but can't be picked. */}
{!presetOptions.includes(preset) && (
<option value={preset} disabled>{t(`preset.${preset}`)}</option>
)}
</select>
<ChevronDown className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-muted-foreground" />
@@ -0,0 +1,61 @@
import { render, waitFor } from '@testing-library/react';
import { afterEach, describe, it, expect, vi } from 'vitest';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
// #903: the mock server's sample photos come from randomuser.me and
// img.freepik.com; none of them while third-party traffic is off.
vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ devMode: true }) }));
import { Avatar } from '../avatar';
import { avatarHooks } from '@/lib/plugin-hooks';
const PEOPLE = ['anna', 'ben', 'carla', 'dirk', 'eva', 'finn', 'greta', 'hans'].map((n) => `${n}@gmail.com`);
function sampleImages(): string[] {
const { container, unmount } = render(
<div>
{PEOPLE.map((email) => <Avatar key={email} name={email} email={email} />)}
<Avatar name="Alice" email="alice@example.com" />
</div>,
);
const srcs = [...container.querySelectorAll('img')].map((img) => img.getAttribute('src') ?? '');
unmount();
return srcs.filter((src) => /randomuser\.me|img\.freepik\.com/.test(src));
}
afterEach(() => setNoThirdPartyTraffic(false));
describe('mock server sample photos', () => {
it('show with third-party traffic allowed', () => {
expect(sampleImages().length).toBeGreaterThan(0);
});
it('are left out with no third-party traffic', () => {
setNoThirdPartyTraffic(true);
expect(sampleImages()).toEqual([]);
});
});
describe('plugin avatars (Gravatar and the like)', () => {
afterEach(() => avatarHooks.onAvatarResolve.clear());
async function renderWithGravatar() {
avatarHooks.onAvatarResolve.register('gravatar', (async () => 'https://www.gravatar.com/avatar/abc?d=404') as never);
const view = render(<Avatar name="Zoe" email="zoe@corp.example.net" />);
await new Promise((r) => setTimeout(r, 20));
return view;
}
it("show the plugin's outside URL with third-party traffic allowed", async () => {
const { container } = await renderWithGravatar();
await waitFor(() => expect(container.querySelector('img')?.getAttribute('src')).toMatch(/gravatar\.com/));
});
it('fall back to initials with no third-party traffic', async () => {
setNoThirdPartyTraffic(true);
const { container } = await renderWithGravatar();
expect(container.querySelector('img')).toBeNull();
expect(container.textContent).toBe('ZO');
});
});
+14
View File
@@ -6,6 +6,7 @@ import { useContactStore } from '@/stores/contact-store';
import { useFaviconRefreshStore } from '@/stores/favicon-refresh-store';
import { clearContactPhotoCache } from '@/lib/contact-photo-cache';
import type { ContactCard } from '@/lib/jmap/types';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
describe('Avatar', () => {
it('renders initials from full name', () => {
@@ -230,4 +231,17 @@ describe('Avatar', () => {
expect(stamp).toBeGreaterThan(0);
expect(img()?.getAttribute('src')).toBe(`/api/favicon?domain=example.cz&v=${stamp}`);
});
// #903: no sender icons, and so no /api/favicon request, while third-party traffic is off.
it('shows initials instead of the sender favicon in the no-third-party mode', () => {
useFaviconRefreshStore.setState({ refreshedAt: 0 });
setNoThirdPartyTraffic(true);
try {
const { container } = render(<Avatar name="Jan" email="jan@example.cz" />);
expect(container.querySelector('img')).toBeNull();
expect(container.textContent).toBe('JA');
} finally {
setNoThirdPartyTraffic(false);
}
});
});
+12 -6
View File
@@ -9,6 +9,8 @@ import { useContactPhotoSrc } from "@/hooks/use-contact-photo";
import { avatarHooks } from "@/lib/plugin-hooks";
import { withBasePath } from "@/lib/browser-navigation";
import { IS_LITE } from "@/lib/lite";
import { avatarUrlAllowed, sampleAvatarsAllowed, senderFaviconsAllowed } from "@/lib/outgoing-hosts";
import { useNoThirdPartyTraffic } from "@/hooks/use-third-party-mode";
import { useFaviconRefreshStore, failedFaviconDomains } from "@/stores/favicon-refresh-store";
const IS_DEV = process.env.NODE_ENV !== "production";
@@ -126,7 +128,7 @@ const CUSTOM_AVATARS: Record<string, string> = IS_DEV ? {
// Mock-server-only: for personal-domain emails, deterministically pick a randomuser.me portrait.
// Returns null for ~30% of addresses so not everyone has a photo.
function getProfilePictureUrl(email: string, domain: string, devMode: boolean, name?: string): string | null {
if (!devMode) return null;
if (!devMode || !sampleAvatarsAllowed()) return null;
if (!PERSONAL_DOMAINS.has(domain)) return null;
const h = emailHash(email);
if (h % 10 < 3) return null; // ~30% get no photo
@@ -165,6 +167,8 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
const faviconRefreshedAt = useFaviconRefreshStore((s) => s.refreshedAt);
const contacts = useContactStore((s) => s.contacts);
const { devMode } = useConfig();
// Re-render when the config arrives: until then nothing outside is asked (#903).
useNoThirdPartyTraffic();
// Ask plugins (e.g. Gravatar) to resolve an avatar URL for this email address.
// Runs whenever email or name changes; resets plugin avatar state on each change.
@@ -187,7 +191,7 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
return getContactPhotoIndex(contacts).get(email.toLowerCase()) ?? null;
}, [disableImages, contactPhoto, email, contacts]);
const { src: contactPhotoSrc, observe } = useContactPhotoSrc(photoRef);
const resolvedContactPhoto = contactPhotoSrc && contactPhotoSrc !== failedContactPhoto ? contactPhotoSrc : undefined;
const resolvedContactPhoto = contactPhotoSrc && contactPhotoSrc !== failedContactPhoto && avatarUrlAllowed(contactPhotoSrc) ? contactPhotoSrc : undefined;
// A refresh of the sender icons gives a failed favicon another chance.
useEffect(() => {
@@ -237,13 +241,15 @@ export function Avatar({ name, email, contactPhoto, size = "md", className, disa
};
const profilePic = email && domain ? getProfilePictureUrl(email, domain, devMode, name) : null;
// Sender favicons come from the /api/favicon proxy, absent in the static build.
// Sender favicons come from the /api/favicon proxy, absent in the static
// build and off while third-party traffic is (#903).
const showFavicon =
!IS_LITE && !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
!IS_LITE && senderFaviconsAllowed() && !disableFavicon && senderFavicons && faviconDomain && !PERSONAL_DOMAINS.has(faviconDomain) && !imgError && !domainFailed;
// Priority: contact photo > plugin avatar (e.g. Gravatar) > custom avatar > profile picture > company favicon > initials
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const customAvatar = devMode && email && sampleAvatarsAllowed() ? CUSTOM_AVATARS[email.toLowerCase()] : null;
// Gravatar and the like are outside hosts: initials instead in the no-third-party mode.
const pluginAvatar = pluginAvatarFailed || !avatarUrlAllowed(pluginAvatarUrl) ? null : pluginAvatarUrl;
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}${faviconRefreshedAt ? `&v=${faviconRefreshedAt}` : ""}`) : null;
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
+11 -2
View File
@@ -7,6 +7,7 @@ import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
import { IS_LITE, IS_LITE_STALWART, LITE_CONFIG_PATH, withLiteBuildId } from '@/lib/lite';
import { applyLiteConfig, liteStalwartDefaults } from '@/lib/lite-config';
import { isConfigData } from '@/lib/config-validation';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
export interface ConfigData {
appName: string;
@@ -46,6 +47,8 @@ export interface ConfigData {
embeddedMode: boolean;
parentOrigin: string;
sourceCodeUrl: string;
/** Stay off every built-in outside host (#903; lib/outgoing-hosts.ts). */
noThirdPartyTraffic: boolean;
}
interface AppConfig extends ConfigData {
@@ -71,8 +74,10 @@ async function fetchLiteConfig(): Promise<ConfigData> {
if (!res.ok) throw new Error(`config.json answered ${res.status}`);
return applyLiteConfig(await res.json(), defaults);
} catch (err) {
console.warn('[lite] config.json missing or invalid, using defaults:', err);
return applyLiteConfig({}, defaults);
// Fail closed (#903): without a readable config.json nobody said outside
// hosts may be used, so the no-third-party switches stay on.
console.warn('[lite] config.json missing or invalid, using defaults with no third-party traffic:', err);
return applyLiteConfig({ noThirdPartyTraffic: true }, defaults);
}
}
@@ -128,6 +133,7 @@ export async function fetchConfig(): Promise<ConfigData> {
configPromise = (IS_LITE ? fetchLiteConfig() : fetchServerConfig())
.then((data) => {
configCache = data;
setNoThirdPartyTraffic(data.noThirdPartyTraffic === true);
// Fetch admin policy alongside config (non-blocking)
usePolicyStore.getState().fetchPolicy();
return data;
@@ -186,6 +192,7 @@ export function useConfig(): AppConfig {
embeddedMode: configCache?.embeddedMode || false,
parentOrigin: configCache?.parentOrigin || '',
sourceCodeUrl: configCache?.sourceCodeUrl || '',
noThirdPartyTraffic: configCache?.noThirdPartyTraffic ?? false,
isLoading: !configCache,
error: null,
});
@@ -231,6 +238,7 @@ export function useConfig(): AppConfig {
embeddedMode: configCache.embeddedMode,
parentOrigin: configCache.parentOrigin,
sourceCodeUrl: configCache.sourceCodeUrl,
noThirdPartyTraffic: configCache.noThirdPartyTraffic,
isLoading: false,
error: null,
});
@@ -277,6 +285,7 @@ export function useConfig(): AppConfig {
embeddedMode: data.embeddedMode,
parentOrigin: data.parentOrigin,
sourceCodeUrl: data.sourceCodeUrl,
noThirdPartyTraffic: data.noThirdPartyTraffic,
isLoading: false,
error: null,
});
+14 -2
View File
@@ -3,12 +3,24 @@
// Reactive accessor for sandboxed plugin slot offers. Components that gate
// layout on the *presence* of a plugin-supplied slot (e.g. a detail
// sidebar) read from here instead of the legacy `usePluginStore.slots` map.
//
// These are the offers the host shows: a retired plugin's offer is left out
// while Bulwark does its job itself (lib/plugin-sandbox/host-features.ts), so
// nothing is built for a slot nobody will see.
import { useSyncExternalStore } from 'react';
import { useMemo, useSyncExternalStore } from 'react';
import type { SlotName } from '@/lib/plugin-types';
import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry';
import { hostFeaturesFor, isSlotHandedOver } from '@/lib/plugin-sandbox/host-features';
import { usePolicyStore } from '@/stores/policy-store';
export function usePluginSlotOffers(slotName: SlotName) {
const getSnapshot = () => offersForSlot(slotName);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const externalMail = usePolicyStore((s) => s.policy.organisation?.externalMail);
return useMemo(() => {
const features = hostFeaturesFor(externalMail);
const shown = offers.filter((offer) => !isSlotHandedOver(offer.pluginId, slotName, features));
return shown.length === offers.length ? offers : shown;
}, [offers, slotName, externalMail]);
}
@@ -21,6 +21,11 @@ const CROSS_ACCOUNT_IDENTITY_DELIMITER = '::';
// still refetches.
const remoteIdentityCache = new Map<string, Identity[]>();
/** One non-active account's identities, if fetched already (raw ids). */
export function cachedIdentitiesForAccount(localAccountId: string): Identity[] | undefined {
return remoteIdentityCache.get(localAccountId);
}
/** Every non-active account's identities fetched so far (raw ids). */
export function cachedRemoteIdentities(): Identity[] {
return [...remoteIdentityCache.values()].flat();
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { useSyncExternalStore } from 'react';
import { noThirdPartyTraffic, subscribeThirdPartyMode } from '@/lib/third-party-mode';
/**
* Whether the browser must stay off built-in outside hosts (#903), as React
* state: components re-render when the config arrives. True while the config
* is still unknown, so nothing reaches out before it says it may.
*/
export function useNoThirdPartyTraffic(): boolean {
return useSyncExternalStore(subscribeThirdPartyMode, noThirdPartyTraffic, () => true);
}
+284
View File
@@ -0,0 +1,284 @@
import { test, expect, type Page } from '@playwright/test';
import {
ORG,
ORG_PASSWORD,
adminContext,
getPolicy,
putPolicy,
requireOrgEnv,
shot,
userPage,
} from './helpers/org';
import { JmapClient } from './helpers/jmap';
import { expandSharedFolders, folderRow } from './helpers/app';
/**
* Delegates (organisation feature 7), through the real UI on a VM 106 test
* server: org-alice gives org-carol read access in one dialog, carol sees
* alice's Inbox and calendar, alice changes it to read and write and then
* removes it everywhere, each step checked over JMAP. The admin hides the
* section for a group and limits the rights for a domain on Organisation >
* Sharing. Every grant made here is taken away again, and the admin's policy
* is put back.
*
* With directory queries off (Stalwart 1.0's default) alice can't look up
* carol, so the typed address shows the note; carol then shares her Drafts
* with alice, which makes her known, and alice picks her from the list.
*/
test.describe.configure({ mode: 'serial' });
const CALENDARS = 'urn:ietf:params:jmap:calendars';
const CONTACTS = 'urn:ietf:params:jmap:contacts';
const MAIL_SHARE = 'urn:ietf:params:jmap:mail:share';
const USING = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail', MAIL_SHARE, CALENDARS, CONTACTS, 'urn:ietf:params:jmap:principals'];
let original: Record<string, unknown>;
let alice: JmapClient;
let carol: JmapClient;
let directoryOff = false;
let carolDraftsId = '';
type Grants = Record<string, Record<string, Record<string, boolean>>>;
/** alice's grants per item ("mailbox:a", "calendar:b", ...) for one principal. */
async function grantsFor(principalId: string): Promise<Grants[string]> {
const r = await alice.request([
['Mailbox/get', { accountId: alice.accountId, ids: null, properties: ['id', 'role', 'shareWith'] }, 'm'],
['Calendar/get', { accountId: alice.accountId, ids: null, properties: ['id', 'shareWith'] }, 'c'],
['AddressBook/get', { accountId: alice.accountId, ids: null, properties: ['id', 'shareWith'] }, 'a'],
], USING);
const out: Grants[string] = {};
const kinds: Record<string, string> = { m: 'mailbox', c: 'calendar', a: 'addressBook' };
for (const [, result, id] of r.methodResponses as [string, { list: Array<{ id: string; role?: string; shareWith?: Grants[string] | null }> }, string][]) {
for (const item of result.list ?? []) {
const rights = item.shareWith?.[principalId];
if (rights) out[`${kinds[id]}:${item.role ?? item.id}`] = rights;
}
}
return out;
}
/** Takes every grant of `principalId` off alice's folders, calendars and address books. */
async function revokeAllOverJmap(principalId: string): Promise<void> {
const r = await alice.request([
['Mailbox/get', { accountId: alice.accountId, ids: null, properties: ['id', 'shareWith'] }, 'm'],
['Calendar/get', { accountId: alice.accountId, ids: null, properties: ['id', 'shareWith'] }, 'c'],
['AddressBook/get', { accountId: alice.accountId, ids: null, properties: ['id', 'shareWith'] }, 'a'],
], USING);
const methods: Record<string, string> = { m: 'Mailbox/set', c: 'Calendar/set', a: 'AddressBook/set' };
const calls: [string, Record<string, unknown>, string][] = [];
for (const [, result, id] of r.methodResponses as [string, { list: Array<{ id: string; shareWith?: Record<string, unknown> | null }> }, string][]) {
const update = Object.fromEntries((result.list ?? [])
.filter((item) => item.shareWith && principalId in item.shareWith)
.map((item) => [item.id, { [`shareWith/${principalId}`]: null }]));
if (Object.keys(update).length) calls.push([methods[id], { accountId: alice.accountId, update }, id]);
}
if (calls.length) await alice.request(calls, USING);
}
async function setCarolDraftsShare(on: boolean): Promise<void> {
await carol.request([
['Mailbox/set', { accountId: carol.accountId, update: { [carolDraftsId]: { [`shareWith/${alice.accountId}`]: on ? { mayReadItems: true } : null } } }, '0'],
], USING);
}
async function openSharing(page: Page, phone = false): Promise<void> {
await page.goto('/settings/sharing');
if (phone) {
await page.getByRole('button', { name: 'Sharing' }).or(page.getByRole('link', { name: 'Sharing' })).first().click();
}
await expect(page.getByRole('heading', { name: 'Delegates' })).toBeVisible({ timeout: 30000 });
}
const dialog = (page: Page) => page.getByRole('dialog');
test.beforeAll(async ({ browser }) => {
requireOrgEnv();
const admin = await adminContext(browser);
original = await getPolicy(admin);
await putPolicy(admin, { ...original, scopes: [], restrictions: {}, features: {} });
await admin.close();
alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
carol = await JmapClient.connect(ORG.carol, ORG_PASSWORD);
carolDraftsId = (await carol.mailboxByRole('drafts'))!.id;
const q = await alice.request([['Principal/query', { accountId: alice.accountId, filter: { email: ORG.carol } }, '0']], USING);
directoryOff = q.methodResponses[0][0] === 'error';
// Leftovers of an earlier run.
await revokeAllOverJmap(carol.accountId);
await setCarolDraftsShare(false);
});
test.afterAll(async ({ browser }) => {
if (alice && carol) {
await revokeAllOverJmap(carol.accountId);
await setCarolDraftsShare(false);
expect(await grantsFor(carol.accountId)).toEqual({});
}
const admin = await adminContext(browser);
await putPolicy(admin, original);
await admin.close();
});
test('a typed address works, or says the administrator has to turn on directory lookups', async ({ browser }) => {
const page = await userPage(browser, ORG.alice);
await openSharing(page);
await page.getByRole('button', { name: 'Give access' }).click();
await dialog(page).getByRole('textbox', { name: 'Person' }).fill(ORG.carol);
await dialog(page).getByRole('button', { name: `Use ${ORG.carol}` }).click();
if (directoryOff) {
await expect(dialog(page).getByTestId('delegate-directory-off'))
.toContainText('Your administrator has to turn on directory lookups in Stalwart before you can add someone new.');
await shot(page, 'delegates-directory-off');
} else {
await expect(dialog(page).getByText(ORG.carol)).toBeVisible();
}
await page.context().close();
});
test('alice gives carol read access to her mail, calendar and contacts in one step', async ({ browser }) => {
if (directoryOff) await setCarolDraftsShare(true);
const page = await userPage(browser, ORG.alice);
await openSharing(page);
await expect(page.getByText('Nobody has access to your mail, calendars, contacts or files.')).toBeVisible();
await page.getByRole('button', { name: 'Give access' }).click();
const d = dialog(page);
await expect(d.getByText("Stalwart doesn't let them send as you or on your behalf.", { exact: false })).toBeVisible();
await d.getByRole('textbox', { name: 'Person' }).fill('org-carol');
await d.getByTestId('delegate-candidates').getByRole('button', { name: /org-carol@example\.org/ }).click();
await expect(d.getByRole('radio', { name: /^Read See/ })).toBeChecked();
await shot(page, 'delegates-dialog');
await d.getByRole('button', { name: 'Give access' }).click();
await expect(d).toBeHidden();
const row = page.getByTestId('delegate-row');
await expect(row).toHaveCount(1);
await expect(row).toContainText(ORG.carol);
await expect(row).toContainText('All mail folders');
await expect(row).toContainText('Read');
await shot(page, 'delegates-overview');
const grants = await grantsFor(carol.accountId);
expect(grants['mailbox:inbox']).toMatchObject({ mayReadItems: true, mayAddItems: false, mayRemoveItems: false, maySubmit: false });
expect(grants['mailbox:sent']).toMatchObject({ mayReadItems: true, mayAddItems: false });
const calendars = Object.entries(grants).filter(([k]) => k.startsWith('calendar:'));
expect(calendars).toHaveLength(1);
expect(calendars[0][1]).toMatchObject({ mayReadItems: true, mayWriteAll: false });
const books = Object.entries(grants).filter(([k]) => k.startsWith('addressBook:'));
expect(books).toHaveLength(1);
expect(books[0][1]).toMatchObject({ mayRead: true, mayWrite: false });
await page.context().close();
});
test("carol sees alice's Inbox and calendar", async ({ browser }) => {
test.setTimeout(180_000);
const page = await userPage(browser, ORG.carol);
await expandSharedFolders(page, ORG.alice);
await expect(folderRow(page, { role: 'inbox', shared: true }).first()).toBeVisible({ timeout: 30000 });
await shot(page, 'delegates-carol-sees-inbox');
await page.goto('/calendar');
await expect(page.getByText(`Stalwart Calendar (${ORG.alice})`).first()).toBeVisible({ timeout: 30000 });
await page.context().close();
});
test('Change gives carol read and write access, and only to the Inbox', async ({ browser }) => {
const page = await userPage(browser, ORG.alice);
await openSharing(page);
await page.getByTestId('delegate-row').getByRole('button', { name: 'Change' }).click();
const d = dialog(page);
await expect(d.getByRole('heading', { name: `Change access for ${ORG.carol}` })).toBeVisible();
// It starts from what carol has: exactly every folder, the default calendar and address book, read.
await expect(d.getByRole('radiogroup', { name: 'Mail' }).getByRole('radio', { name: 'All folders' })).toBeChecked();
await expect(d.getByRole('radiogroup', { name: 'Calendars' }).getByRole('radio', { name: 'Default calendar' })).toBeChecked();
await expect(d.getByRole('radiogroup', { name: 'Contacts' }).getByRole('radio', { name: 'Default address book' })).toBeChecked();
await expect(d.getByRole('radio', { name: /^Read See/ })).toBeChecked();
await d.getByRole('radiogroup', { name: 'Mail' }).getByText('Inbox only').click();
await d.getByRole('radio', { name: /^Read and write/ }).check();
await d.getByRole('button', { name: 'Save' }).click();
await expect(d).toBeHidden();
await expect(page.getByTestId('delegate-row')).toContainText('Read and write');
const grants = await grantsFor(carol.accountId);
expect(grants['mailbox:inbox']).toMatchObject({ mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySubmit: false, mayShare: false });
// Change replaces: the other folders lost their access.
expect(Object.keys(grants).filter((k) => k.startsWith('mailbox:'))).toEqual(['mailbox:inbox']);
const calendars = Object.entries(grants).filter(([k]) => k.startsWith('calendar:'));
expect(calendars[0][1]).toMatchObject({ mayReadItems: true, mayWriteAll: true, mayShare: false });
await page.context().close();
});
test('Remove access takes it away everywhere', async ({ browser }) => {
const page = await userPage(browser, ORG.alice);
await openSharing(page);
await page.getByTestId('delegate-row').getByRole('button', { name: 'Remove access' }).click();
await page.getByRole('alertdialog').getByRole('button', { name: 'Remove access' }).click();
await expect(page.getByText('Nobody has access to your mail, calendars, contacts or files.')).toBeVisible();
expect(await grantsFor(carol.accountId)).toEqual({});
await page.context().close();
});
test('the admin limits delegates to read for one domain and hides them for a group', async ({ browser }) => {
const admin = await adminContext(browser);
const policy = await getPolicy(admin);
await putPolicy(admin, {
...policy,
scopes: [
{ id: 'delegates-team', label: 'Org team', match: { groups: [ORG.team] }, policy: {} },
{ id: 'delegates-example', label: 'Example', match: { domains: ['example.org'] }, policy: {} },
],
});
const page = await admin.newPage();
await page.goto('/admin/org-sharing');
await expect(page.getByRole('heading', { name: 'Delegates' })).toBeVisible();
// Example (a domain scope): untick write.
const scopeBar = page.getByTestId('org-scope-bar');
await scopeBar.getByRole('button', { name: 'Example', exact: true }).click();
const section = page.locator('[data-settings-section="delegates"]');
await section.getByLabel('write', { exact: true }).uncheck();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.')).toBeVisible();
// Org team (a group scope): hide.
await scopeBar.getByRole('button', { name: 'Org team', exact: true }).click();
await section.getByLabel('Hide', { exact: true }).check();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.')).toBeVisible();
await section.scrollIntoViewIfNeeded();
await shot(page, 'delegates-admin');
const saved = await getPolicy(admin);
const scopes = saved.scopes as Array<{ id: string; policy: { restrictions?: Record<string, unknown> } }>;
expect(scopes.find((s) => s.id === 'delegates-example')!.policy.restrictions).toEqual({ sharingRights: { allowedValues: ['read'] } });
expect(scopes.find((s) => s.id === 'delegates-team')!.policy.restrictions).toEqual({ delegation: { hidden: true } });
await page.close();
await admin.close();
});
test('a member of the hidden group gets no Delegates section', async ({ browser }) => {
const page = await userPage(browser, ORG.alice);
await page.goto('/settings/account');
await expect(page.getByRole('heading', { name: 'Account' }).first()).toBeVisible({ timeout: 30000 });
await expect(page.getByRole('button', { name: 'Sharing' }).or(page.getByRole('link', { name: 'Sharing' }))).toHaveCount(0);
await page.goto('/settings/sharing');
await expect(page.getByRole('heading', { name: 'Delegates' })).toHaveCount(0);
await page.context().close();
});
test('the domain limit offers only read, says why, and the dialog fills a phone screen', async ({ browser }) => {
// carol is in example.org but not in the group.
const page = await userPage(browser, ORG.carol, true);
await openSharing(page, true);
await page.getByRole('button', { name: 'Give access' }).click();
const d = dialog(page);
const rights = d.getByTestId('delegate-rights');
await expect(rights.getByRole('radio')).toHaveCount(1);
await expect(rights.getByRole('radio', { name: /^Read See/ })).toBeChecked();
await expect(rights.getByText('Set by your organisation')).toBeVisible();
const box = await d.boundingBox();
expect(box).toMatchObject({ x: 0, y: 0, width: 390 });
await shot(page, 'delegates-dialog-phone');
await d.getByRole('button', { name: 'Cancel' }).click();
await shot(page, 'delegates-overview-phone');
await page.context().close();
});
+360
View File
@@ -0,0 +1,360 @@
import { test, expect, type Page } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { JmapClient } from './helpers/jmap';
import { addRecipient, emailItem, openComposer, setSubject } from './helpers/app';
import {
ORG,
ORG_PASSWORD,
adminContext,
getPolicy,
putPolicy,
requireOrgEnv,
shot,
userPage,
} from './helpers/org';
/**
* External sender label and external recipient warning (organisation
* feature 2), through the real UI on a VM 106 test server. Test mail is
* delivered over JMAP between the org-* accounts (SMTP on the VM doesn't
* work). The admin's policy is put back afterwards.
*/
test.describe.configure({ mode: 'serial' });
const RUN = Date.now().toString(36);
const FROM_DAVE = `External check from dave ${RUN}`;
const FROM_BOB = `Internal check from bob ${RUN}`;
// The quick reply turns FROM_DAVE into a thread whose latest message is
// alice's own; the later checks read this one.
const FROM_DAVE_2 = `Second external check from dave ${RUN}`;
let original: Record<string, unknown>;
/** Sends a plain message from one org account to another, the way a mail app would. */
async function deliver(from: string, to: string, subject: string): Promise<void> {
const client = await JmapClient.connect(from, ORG_PASSWORD);
const [drafts, sent] = await Promise.all([client.mailboxByRole('drafts'), client.mailboxByRole('sent')]);
const identity = (await client.identities()).find((i) => i.email === from);
if (!drafts || !sent || !identity) throw new Error(`${from}: no drafts, sent or identity`);
const r = await client.request([
['Email/set', {
accountId: client.accountId,
create: {
m: {
mailboxIds: { [drafts.id]: true },
keywords: { $draft: true, $seen: true },
from: [{ email: from }],
to: [{ email: to }],
subject,
bodyValues: { b: { value: `Test message for the external mail spec (${RUN}).` } },
textBody: [{ partId: 'b', type: 'text/plain' }],
},
},
}, '0'],
['EmailSubmission/set', {
accountId: client.accountId,
create: { s: { identityId: identity.id, emailId: '#m' } },
onSuccessUpdateEmail: { '#s': { [`mailboxIds/${drafts.id}`]: null, [`mailboxIds/${sent.id}`]: true, 'keywords/$draft': null } },
}, '1'],
]);
const submission = r.methodResponses[1][1];
if (!submission.created?.s) throw new Error(`${from}: submission failed ${JSON.stringify(r.methodResponses)}`);
const receiver = await JmapClient.connect(to, ORG_PASSWORD);
await receiver.waitForEmail(subject, { timeoutMs: 30000 });
}
/** Polls until the account has a message with exactly this subject (the subject filter doesn't find "Re:" subjects). */
async function waitForSubject(account: string, subject: string, timeoutMs = 60000): Promise<void> {
const client = await JmapClient.connect(account, ORG_PASSWORD);
const deadline = Date.now() + timeoutMs;
for (;;) {
const r = await client.request([
['Email/query', { accountId: client.accountId, filter: { text: RUN }, limit: 50 }, '0'],
['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject'] }, '1'],
]);
const subjects = (r.methodResponses[1][1].list as Array<{ subject: string }>).map((e) => e.subject);
if (subjects.includes(subject)) return;
if (Date.now() > deadline) throw new Error(`${account} has no "${subject}" (has: ${subjects.join(' | ')})`);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
}
async function openMessage(page: Page, subject: string): Promise<void> {
const row = emailItem(page, subject).first();
await expect(row).toBeVisible({ timeout: 30000 });
await row.click();
await expect(page.getByRole('heading', { name: subject }).first()).toBeVisible({ timeout: 15000 });
}
const chip = (page: Page) => page.locator('[data-external-sender-chip]:visible');
const confirm = (page: Page) => page.locator('[data-external-recipient-confirm]');
async function composeTo(page: Page, to: string, subject: string): Promise<void> {
await openComposer(page);
await addRecipient(page, to);
await setSubject(page, subject);
// An empty body keeps Send disabled.
await page.locator('[data-testid="email-composer"] [contenteditable="true"]').first().click();
await page.keyboard.type('Hello');
// Send with the shortcut: the recipient autocomplete can cover the button.
await page.locator('[data-testid="composer-subject"]').first().focus();
await page.keyboard.press('Control+Enter');
}
test.beforeAll(async ({ browser }) => {
requireOrgEnv();
const admin = await adminContext(browser);
original = await getPolicy(admin);
// Start from defaults: no scopes, both switches off.
await putPolicy(admin, { ...original, scopes: [], organisation: {} });
await admin.close();
await deliver(ORG.dave, ORG.alice, FROM_DAVE);
await deliver(ORG.bob, ORG.alice, FROM_BOB);
await deliver(ORG.dave, ORG.alice, FROM_DAVE_2);
});
test.afterAll(async ({ browser }) => {
const admin = await adminContext(browser);
await putPolicy(admin, original);
await admin.close();
});
test('with both switches off nothing is labelled or asked', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice);
await openMessage(alice, FROM_DAVE);
await expect(chip(alice)).toHaveCount(0);
await composeTo(alice, ORG.dave, `Off ${RUN}`);
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30000 });
await expect(confirm(alice)).toHaveCount(0);
await alice.context().close();
});
test('the admin turns both on in Organisation > Security', async ({ browser }) => {
const admin = await adminContext(browser);
const page = await admin.newPage();
await page.goto('/admin/org-security');
await expect(page.getByRole('heading', { name: 'External mail' })).toBeVisible();
await page.getByTestId('org-external-sender-label').click();
await page.getByTestId('org-external-recipient-warning').click();
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.')).toBeVisible();
await page.locator('[data-settings-section="external"]').scrollIntoViewIfNeeded();
await shot(page, 'external-admin-section');
const policy = await getPolicy(admin);
expect((policy.organisation as { externalMail: unknown }).externalMail).toMatchObject({ senderLabel: true, recipientWarning: true, trustContacts: true });
await admin.close();
});
test('mail from org-dave is labelled External, mail from org-bob is not', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice);
await openMessage(alice, FROM_DAVE);
await expect(chip(alice).first()).toBeVisible();
await expect(chip(alice).first()).toHaveAttribute('title', /org-two\.test/);
await shot(alice, 'external-sender-chip');
// A click (a tap on phones) names the domain.
await chip(alice).first().click();
await expect(alice.locator('[data-external-sender-detail]')).toContainText('org-two.test');
await openMessage(alice, FROM_BOB);
await expect(chip(alice)).toHaveCount(0);
await alice.context().close();
});
test('sending to org-dave asks first, sending to org-bob does not', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice);
await composeTo(alice, ORG.dave, `Asks ${RUN}`);
await expect(confirm(alice)).toBeVisible();
await expect(confirm(alice).locator('[data-external-recipients]')).toHaveText(/org-dave@org-two\.test/);
await shot(alice, 'external-recipient-confirm');
// Back keeps the message open and unsent.
await confirm(alice).locator('[data-external-recipient-back]').click();
await expect(confirm(alice)).toHaveCount(0);
await expect(alice.locator('[data-testid="email-composer"]')).toBeVisible();
await alice.keyboard.press('Escape');
await alice.getByRole('button', { name: 'Discard', exact: true }).last().click();
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden();
await composeTo(alice, ORG.bob, `Internal ${RUN}`);
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30000 });
await expect(confirm(alice)).toHaveCount(0);
await alice.context().close();
});
test('a quick reply to org-dave asks too: Back keeps the text, Send anyway sends it', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice);
await openMessage(alice, FROM_DAVE);
const box = alice.getByPlaceholder('Write a quick reply...');
await box.fill(`Quick reply ${RUN}`);
await box.press('Control+Enter');
await expect(confirm(alice)).toBeVisible();
await confirm(alice).locator('[data-external-recipient-back]').click();
await expect(confirm(alice)).toHaveCount(0);
await expect(box).toHaveValue(`Quick reply ${RUN}`);
await box.press('Control+Enter');
await expect(confirm(alice)).toBeVisible();
// Keys stay in the dialog: Escape answers Back and leaves the message open.
await alice.keyboard.press('Escape');
await expect(confirm(alice)).toHaveCount(0);
await expect(box).toHaveValue(`Quick reply ${RUN}`);
await box.press('Control+Enter');
await expect(confirm(alice)).toBeVisible();
await confirm(alice).locator('[data-external-recipient-send]').click();
await expect(confirm(alice)).toHaveCount(0);
await waitForSubject(ORG.dave, `Re: ${FROM_DAVE}`);
await alice.context().close();
});
test('a scope for org-two.test with the label off applies to org-dave only', async ({ browser }) => {
const admin = await adminContext(browser);
const policy = await getPolicy(admin);
await putPolicy(admin, {
...policy,
scopes: [{ id: 'org-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: { senderLabel: false } } } }],
});
// The scope shows on the admin page as an override.
const page = await admin.newPage();
await page.goto('/admin/org-security');
await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Org two' }).click();
await expect(page.getByTestId('org-external-sender-label')).toHaveAttribute('aria-checked', 'false');
await expect(page.getByText('Everyone: on').first()).toBeVisible();
await shot(page, 'external-admin-scope');
await admin.close();
// org-dave reads mail from outside org-two.test: no label for that domain.
await deliver(ORG.alice, ORG.dave, `From alice ${RUN}`);
const dave = await userPage(browser, ORG.dave);
await openMessage(dave, `From alice ${RUN}`);
await expect(chip(dave)).toHaveCount(0);
await dave.context().close();
// org-alice keeps it.
const alice = await userPage(browser, ORG.alice);
await openMessage(alice, FROM_DAVE_2);
await expect(chip(alice).first()).toBeVisible();
await alice.context().close();
});
test('the label and the confirm fit a phone', async ({ browser }) => {
const alice = await userPage(browser, ORG.alice, true);
await openMessage(alice, FROM_DAVE_2);
await expect(chip(alice).first()).toBeVisible();
const overflow = await alice.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(0);
const box = await chip(alice).first().boundingBox();
expect(box && box.x + box.width).toBeLessThanOrEqual(390);
await shot(alice, 'external-sender-chip-phone');
await alice.goBack().catch(() => {});
await composeTo(alice, ORG.dave, `Phone ${RUN}`);
await expect(confirm(alice)).toBeVisible();
const dialog = await confirm(alice).boundingBox();
expect(dialog && dialog.x).toBeGreaterThanOrEqual(0);
expect(dialog && dialog.x + dialog.width).toBeLessThanOrEqual(390);
await shot(alice, 'external-recipient-confirm-phone');
await confirm(alice).locator('[data-external-recipient-back]').click();
await alice.context().close();
});
test('Organisation > Security fits a phone', async ({ browser }) => {
const admin = await adminContext(browser, true);
const page = await admin.newPage();
await page.goto('/admin/org-security');
await expect(page.getByTestId('org-external-sender-label')).toBeVisible();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(0);
await page.getByTestId('org-external-domains').scrollIntoViewIfNeeded();
await shot(page, 'external-admin-section-phone');
await admin.close();
});
// ─── The retired External Mail Warning plugin ─────────────────────────────
// Needs the 1.5.1 package (from before the plugin knew api.host): set
// IT_EMW_ZIP to its path. It is installed for this test and removed again.
test.describe('an installed External Mail Warning 1.5.1', () => {
const zip = process.env.IT_EMW_ZIP;
test.skip(!zip, 'set IT_EMW_ZIP to the external-mail-warning 1.5.1 zip');
test.afterAll(async ({ browser }) => {
const admin = await adminContext(browser);
await admin.request.delete('/api/admin/plugins', { data: { id: 'external-mail-warning' } });
await admin.close();
});
test('steps back one switch at a time, and its safe domains import', async ({ browser }) => {
const admin = await adminContext(browser);
// next dev compiles the sandbox page on first use, which can outlast the plugin's init timeout.
await admin.request.get('/plugin-sandbox', { timeout: 120000 });
const installed = await admin.request.post('/api/admin/plugins', {
multipart: { file: { name: 'external-mail-warning.zip', mimeType: 'application/zip', buffer: readFileSync(zip!) } },
});
expect(installed.ok(), await installed.text()).toBe(true);
const config = await admin.request.put('/api/admin/plugins/external-mail-warning/config', { data: { key: 'safeDomains', value: 'partner.example, other-partner.example' } });
expect(config.ok(), await config.text()).toBe(true);
// Core off, plugin on for everyone: the plugin asks, which shows it runs.
const policy = await getPolicy(admin);
const features = { ...(policy.features as Record<string, unknown>), pluginsEnabled: true };
await putPolicy(admin, {
...policy, features, forceEnabledPlugins: ['external-mail-warning'], scopes: [],
organisation: { externalMail: { senderLabel: false, recipientWarning: false } },
});
let alice = await userPage(browser, ORG.alice);
// The plugin starts after the sign-in; its frame is the sign that it runs.
await expect(alice.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 30000 });
await composeTo(alice, ORG.dave, `Plugin asks ${RUN}`);
const pluginDialog = alice.locator('[data-plugin-dialog="external-mail-warning"]');
await expect(pluginDialog).toBeVisible({ timeout: 30000 });
await expect(confirm(alice)).toHaveCount(0);
await pluginDialog.getByRole('button', { name: 'Cancel' }).click();
await alice.context().close();
// The admin page asks to update the plugin and imports its list.
await putPolicy(admin, { ...(await getPolicy(admin)), organisation: { externalMail: { senderLabel: true, recipientWarning: true } } });
const page = await admin.newPage();
await page.goto('/admin/org-security');
await expect(page.getByTestId('org-external-plugin-note')).toContainText('Update or remove the External Mail Warning plugin');
await page.getByTestId('org-external-import-plugin').click();
await expect(page.getByTestId('org-external-domains')).toHaveValue('partner.example\nother-partner.example');
await page.getByTestId('org-external-plugin-note').scrollIntoViewIfNeeded();
await shot(page, 'external-admin-plugin-note');
await page.getByRole('button', { name: 'Save', exact: true }).click();
await expect(page.getByText('Saved.')).toBeVisible();
expect(((await getPolicy(admin)).organisation as { externalMail: { domains: string[] } }).externalMail.domains)
.toEqual(['partner.example', 'other-partner.example']);
await page.close();
// Only the recipient warning on: one question on send, Bulwark's own;
// the plugin keeps its banner.
await putPolicy(admin, { ...(await getPolicy(admin)), organisation: { externalMail: { senderLabel: false, recipientWarning: true } } });
alice = await userPage(browser, ORG.alice);
await expect(alice.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 30000 });
await openMessage(alice, FROM_DAVE_2);
await expect(chip(alice)).toHaveCount(0);
await expect(alice.locator('[data-plugin-slot="email-banner"]')).toHaveCount(1);
await composeTo(alice, ORG.dave, `Core asks ${RUN}`);
await expect(confirm(alice)).toBeVisible();
await confirm(alice).locator('[data-external-recipient-send]').click();
await expect(alice.locator('[data-testid="email-composer"]')).toBeHidden({ timeout: 30000 });
await expect(alice.locator('[data-plugin-dialog="external-mail-warning"]')).toHaveCount(0);
await waitForSubject(ORG.dave, `Core asks ${RUN}`);
await alice.context().close();
// Only the label on: Bulwark's chip instead of the plugin's banner; the
// plugin still asks on send, Bulwark doesn't.
await putPolicy(admin, { ...(await getPolicy(admin)), organisation: { externalMail: { senderLabel: true, recipientWarning: false } } });
alice = await userPage(browser, ORG.alice);
await expect(alice.locator('iframe[src*="/plugin-sandbox"]').first()).toBeAttached({ timeout: 30000 });
await openMessage(alice, FROM_DAVE_2);
await expect(chip(alice).first()).toBeVisible();
await expect(alice.locator('[data-plugin-slot="email-banner"]')).toHaveCount(0);
await composeTo(alice, ORG.dave, `Plugin still asks ${RUN}`);
await expect(alice.locator('[data-plugin-dialog="external-mail-warning"]')).toBeVisible({ timeout: 30000 });
await expect(confirm(alice)).toHaveCount(0);
await alice.locator('[data-plugin-dialog="external-mail-warning"]').getByRole('button', { name: 'Cancel' }).click();
await alice.context().close();
await admin.close();
});
});
+256
View File
@@ -0,0 +1,256 @@
import { test, expect, type BrowserContext, type Page } from '@playwright/test';
import { JMAP_URL } from './helpers/config';
import { neutralizeDevOverlay, accountSwitcher, emailItem, openComposer, closeComposer } from './helpers/app';
import { JmapClient } from './helpers/jmap';
import { readFileSync, existsSync } from 'node:fs';
import { ORG, ORG_PASSWORD, adminContext, contextOptions, getPolicy, putPolicy, requireOrgEnv, shot } from './helpers/org';
/**
* "No third-party traffic" (organisation feature 5, #903) through the real
* UI. The dev server runs with NO_THIRD_PARTY_TRAFFIC=true. Every request the
* browser makes while org-alice signs in, reads mail (from a sender whose
* domain has a favicon), composes and opens settings, and while the admin
* opens the Organisation pages, must go to the webmail itself or the JMAP
* server. The server's own requests (telemetry, version check, favicons,
* directory) are covered by lib/__tests__/no-third-party-server.test.ts; the
* direct calls here check that the routes refuse before reaching out.
*/
test.describe.configure({ mode: 'serial' });
const SUBJECT = `Third-party check ${Date.now()}`;
const REMOTE_SUBJECT = `Remote picture check ${Date.now()}`;
const seededIds: string[] = [];
const allowedHosts = () => new Set([new URL(contextOptions().baseURL).host, new URL(JMAP_URL).host]);
/** Records every http(s) request of a context, service worker included. */
function recordRequests(context: BrowserContext): string[] {
const urls: string[] = [];
context.on('request', (request) => {
const url = request.url();
if (/^https?:/i.test(url)) urls.push(url);
});
return urls;
}
function outside(urls: string[]): string[] {
const allowed = allowedHosts();
return [...new Set(urls.filter((url) => !allowed.has(new URL(url).host)))];
}
async function signIn(page: Page, email: string): Promise<void> {
await neutralizeDevOverlay(page);
await page.goto('/');
await page.fill('#username', email);
await page.fill('#password', ORG_PASSWORD);
await page.click('button[type="submit"]');
await accountSwitcher(page).waitFor({ state: 'visible', timeout: 60000 });
}
test.beforeAll(async ({ browser }) => {
requireOrgEnv();
// The mode must come from the server's config, not from this spec.
const admin = await adminContext(browser);
const res = await admin.request.get('/api/admin/outgoing-hosts');
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.noThirdPartyTraffic, 'start the dev server with NO_THIRD_PARTY_TRAFFIC=true').toBe(true);
await admin.close();
// A message from a sender whose domain has a favicon, straight into the
// Inbox (SMTP on the test servers doesn't work).
const alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
const inbox = await alice.mailboxByRole('inbox');
const r = await alice.request([['Email/set', {
accountId: alice.accountId,
create: {
m: {
mailboxIds: { [inbox!.id]: true },
from: [{ name: 'GitHub', email: 'noreply@github.com' }],
to: [{ name: 'Org Alice', email: ORG.alice }],
subject: SUBJECT,
receivedAt: new Date().toISOString(),
bodyStructure: { partId: 't', type: 'text/plain' },
bodyValues: { t: { value: 'Checking that nothing leaves for an outside host.' } },
},
// A newsletter with a tracking picture on another host.
p: {
mailboxIds: { [inbox!.id]: true },
from: [{ name: 'Shop News', email: 'news@shop.example' }],
to: [{ name: 'Org Alice', email: ORG.alice }],
subject: REMOTE_SUBJECT,
receivedAt: new Date(Date.now() - 60_000).toISOString(),
bodyStructure: { partId: 'h', type: 'text/html' },
bodyValues: { h: { value: '<p>Our offers this week.</p><img src="https://track.shop.example/pixel.gif" width="1" height="1">' } },
},
},
}, '0']]);
const created = r.methodResponses[0][1].created ?? {};
for (const key of ['m', 'p']) if (created[key]?.id) seededIds.push(created[key].id);
expect(seededIds.length, JSON.stringify(r.methodResponses[0][1])).toBe(2);
});
test.afterAll(async () => {
if (seededIds.length === 0) return;
const alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
await alice.request([['Email/set', { accountId: alice.accountId, destroy: seededIds }, '0']]);
});
test('signing in, reading, composing and settings reach no outside host', async ({ browser }) => {
const context = await browser.newContext(contextOptions());
const urls = recordRequests(context);
const page = await context.newPage();
await signIn(page, ORG.alice);
const row = emailItem(page, SUBJECT).first();
await expect(row).toBeVisible({ timeout: 30000 });
await row.click();
await expect(page.locator('[data-plugin-source="email-body"]').first()).toContainText('Checking that nothing leaves', { timeout: 20000 });
// Initials, not GitHub's favicon: the avatars never ask /api/favicon.
await expect(page.locator('img[src*="/api/favicon"]')).toHaveCount(0);
await shot(page, 'thirdparty-reading');
// Remote pictures: the notice says the organisation blocks them, and
// nothing offers to load them or to trust the sender.
await emailItem(page, REMOTE_SUBJECT).first().click();
const notice = page.getByTestId('external-content-notice').first();
await expect(notice).toContainText('Your organisation blocks content from other servers', { timeout: 20000 });
await expect(page.getByRole('button', { name: 'Load images' })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Always trust this sender' })).toHaveCount(0);
await shot(page, 'thirdparty-remote-blocked');
await openComposer(page);
await page.locator('[data-testid="composer-subject"]').fill('Not sent');
await page.waitForTimeout(1500);
await closeComposer(page);
await page.goto('/settings/notifications');
const pushNote = page.getByText(/Push notifications need a push relay run by your organisation/);
await expect(pushNote).toBeVisible({ timeout: 20000 });
await pushNote.scrollIntoViewIfNeeded();
await shot(page, 'thirdparty-push-settings');
await page.goto('/settings/reading');
const externalContent = page.locator('[data-search-label="External Content"], [data-search-label="External content"]').first();
await expect(externalContent).toContainText('Set by your organisation', { timeout: 20000 });
await page.goto('/settings/message_list');
await expect(page.getByText(/Sender/).first()).toBeVisible({ timeout: 20000 });
await expect(page.getByText('Sender Favicons', { exact: true })).toHaveCount(0);
await page.waitForTimeout(2000);
expect(urls.length).toBeGreaterThan(10);
expect(urls.some((url) => url.includes('/api/favicon'))).toBe(false);
expect(outside(urls)).toEqual([]);
// Direct calls are refused before the server reaches out.
expect((await context.request.get('/api/favicon?domain=github.com')).status()).toBe(403);
const translate = await context.request.post('/api/translate', {
data: { text: 'Hallo Welt', target: 'en', source: 'de' },
headers: { Origin: new URL(contextOptions().baseURL).origin },
});
expect(translate.status()).toBe(403);
expect((await context.request.get('/api/connector/capabilities')).status()).toBe(404);
await context.close();
});
test('the admin sees the switch from the environment and what stays reachable', async ({ browser }) => {
const admin = await adminContext(browser);
const urls = recordRequests(admin);
const page = await admin.newPage();
await page.goto('/admin/organisation');
await expect(page.getByRole('heading', { name: 'Domains and groups' }).first()).toBeVisible();
await page.goto('/admin/org-security');
const section = page.locator('[data-settings-section="third-party"]');
await expect(section.getByText('No third-party traffic', { exact: true })).toBeVisible({ timeout: 20000 });
const toggle = page.getByTestId('third-party-switch').getByRole('switch');
await expect(toggle).toHaveAttribute('aria-checked', 'true');
await expect(toggle).toBeDisabled();
await expect(section.getByText('NO_THIRD_PARTY_TRAFFIC', { exact: true })).toBeVisible();
await expect(section.getByText(/whole server, not per domain or group/)).toBeVisible();
await expect(page.getByTestId('third-party-reachable')).toContainText(new URL(JMAP_URL).host);
await section.getByText(/Built-in outside hosts/).click();
await expect(page.getByTestId('third-party-builtin')).toContainText('icons.duckduckgo.com');
await section.scrollIntoViewIfNeeded();
await shot(page, 'thirdparty-admin-security');
await page.goto('/admin/marketplace');
await expect(page.getByText(/official extension directory is off/).first()).toBeVisible({ timeout: 20000 });
await page.waitForTimeout(1500);
expect(outside(urls)).toEqual([]);
await admin.close();
});
test('the admin section and the user settings work at phone width', async ({ browser }) => {
const admin = await adminContext(browser, true);
const page = await admin.newPage();
await page.goto('/admin/org-security');
const section = page.locator('[data-settings-section="third-party"]');
await expect(section.getByText('No third-party traffic', { exact: true })).toBeVisible({ timeout: 20000 });
await section.scrollIntoViewIfNeeded();
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
expect(overflow).toBeLessThanOrEqual(0);
await shot(page, 'thirdparty-admin-security-phone');
await admin.close();
const context = await browser.newContext(contextOptions(true));
const urls = recordRequests(context);
const user = await context.newPage();
await signIn(user, ORG.alice);
await user.goto('/settings/notifications');
await user.getByRole('button', { name: 'Notifications' }).or(user.getByRole('link', { name: 'Notifications' })).first().click();
const pushNote = user.getByText(/Push notifications need a push relay run by your organisation/);
await expect(pushNote).toBeVisible({ timeout: 20000 });
await pushNote.scrollIntoViewIfNeeded();
await shot(user, 'thirdparty-push-settings-phone');
expect(outside(urls)).toEqual([]);
await context.close();
});
/**
* A plugin that only returns image URLs (Gravatar declares no network
* access, so it runs): its avatars must fall back to initials, and the page's
* CSP stops anything that slips through. The zip is built from
* repos/plugins/gravatar into the rig's scratch folder with the fallback
* style set to "identicon"; IT_GRAVATAR_ZIP points at it.
*/
test('the Gravatar plugin runs but nothing reaches gravatar.com', async ({ browser }) => {
const zip = process.env.IT_GRAVATAR_ZIP ?? '';
test.skip(!zip || !existsSync(zip), 'IT_GRAVATAR_ZIP not set');
const admin = await adminContext(browser);
const before = await getPolicy(admin);
const features = (before.features ?? {}) as Record<string, unknown>;
try {
const upload = await admin.request.post('/api/admin/plugins', {
multipart: { file: { name: 'gravatar.zip', mimeType: 'application/zip', buffer: readFileSync(zip) } },
});
expect(upload.ok(), await upload.text()).toBe(true);
await putPolicy(admin, { ...before, features: { ...features, pluginsEnabled: true }, forceEnabledPlugins: ['gravatar'] });
const context = await browser.newContext(contextOptions());
const urls = recordRequests(context);
const page = await context.newPage();
await signIn(page, ORG.alice);
await expect(emailItem(page, SUBJECT).first()).toBeVisible({ timeout: 30000 });
// The plugin is running in this browser. The zip's fallback style is
// "identicon", so it returns a gravatar.com URL for every sender without
// first asking gravatar.com whether a picture exists (a fetch the
// sandbox's own policy stops); that URL is what the avatar must refuse.
await page.goto('/settings/addons');
await expect(page.getByText('Gravatar Avatars').first()).toBeVisible({ timeout: 30000 });
await expect(page.getByText('On', { exact: true }).first()).toBeVisible({ timeout: 60000 });
// Back inside the app, so the running plugin stays loaded.
await page.getByRole('button', { name: 'Back to Mail' }).or(page.getByRole('link', { name: 'Back to Mail' })).first().click();
await emailItem(page, SUBJECT).first().click();
await expect(page.locator('[data-plugin-source="email-body"]').first()).toContainText('Checking that nothing leaves', { timeout: 20000 });
await page.waitForTimeout(3000);
await expect(page.locator('img[src*="gravatar.com"]')).toHaveCount(0);
await shot(page, 'thirdparty-gravatar-initials');
expect(urls.filter((url) => /gravatar\.com/i.test(url))).toEqual([]);
expect(outside(urls)).toEqual([]);
await context.close();
} finally {
await putPolicy(admin, before);
await admin.request.delete('/api/admin/plugins', { data: { id: 'gravatar' } });
await admin.close();
}
});
+216
View File
@@ -0,0 +1,216 @@
import { describe, it, expect, beforeEach } from 'vitest';
import {
addressDomain,
addressKey,
contactAddresses,
activeExternalMailContext,
contextInternalDomains,
externalMailContextFor,
domainMatches,
externalRecipients,
externalSenders,
internalDomains,
isExternalAddress,
} from '../external-mail';
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 { useContactStore } from '@/stores/contact-store';
import type { ContactCard, Identity } from '@/lib/jmap/types';
const identity = (email: string) => ({ id: email, name: '', email }) as unknown as Identity;
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
describe('addressDomain', () => {
it('takes the domain of bare and named addresses, lowercased', () => {
expect(addressDomain('Bob@Example.COM')).toBe('example.com');
expect(addressDomain('Bob <bob@eu.example.com>')).toBe('eu.example.com');
expect(addressDomain('no-at-sign')).toBe('');
expect(addressDomain(undefined)).toBe('');
});
});
describe('domainMatches', () => {
it('matches the domain itself and its subdomains, nothing else', () => {
expect(domainMatches('example.com', ['example.com'])).toBe(true);
expect(domainMatches('eu.example.com', ['example.com'])).toBe(true);
expect(domainMatches('a.b.example.com', ['example.com'])).toBe(true);
// A suffix that isn't a subdomain doesn't count.
expect(domainMatches('badexample.com', ['example.com'])).toBe(false);
expect(domainMatches('example.com.evil.net', ['example.com'])).toBe(false);
// A subdomain in the list doesn't cover its parent.
expect(domainMatches('example.com', ['eu.example.com'])).toBe(false);
});
});
describe('internalDomains', () => {
it('always counts the identity domains and adds the admin list', () => {
expect(internalDomains(['me@example.org'], [])).toEqual(['example.org']);
expect(internalDomains(['me@example.org', 'alias@Example.org'], ['partner.com'])).toEqual(['example.org', 'partner.com']);
});
it('keeps the identity domains when the admin list fills the cap', () => {
const full = Array.from({ length: 500 }, (_, i) => `partner${i}.example`);
const internal = internalDomains(['me@own.example'], full);
expect(internal).toHaveLength(500);
expect(internal[0]).toBe('own.example');
});
});
describe('IDN domains', () => {
it('compares Unicode and xn-- forms as the same domain, on both sides', () => {
// The admin typed it in Unicode, the server delivers xn--.
const internal = internalDomains([], ['bücher.de']);
expect(internal).toEqual(['xn--bcher-kva.de']);
expect(isExternalAddress('anna@xn--bcher-kva.de', internal)).toBe(false);
expect(isExternalAddress('anna@bücher.de', internal)).toBe(false);
expect(isExternalAddress('anna@eu.bücher.de', internal)).toBe(false);
expect(isExternalAddress('anna@buecher.de', internal)).toBe(true);
// An identity on an IDN domain counts in whichever form the address has.
expect(isExternalAddress('x@münchen.example', internalDomains(['me@xn--mnchen-3ya.example'], []))).toBe(false);
});
it('matches a contact written in Unicode against an xn-- recipient', () => {
expect(addressKey('Anna@Bücher.de')).toBe(addressKey('anna@xn--bcher-kva.de'));
});
});
describe('isExternalAddress', () => {
const internal = internalDomains(['me@example.org'], ['partner.com']);
it('is external only outside the identity domains and the list, subdomains included', () => {
expect(isExternalAddress('bob@example.org', internal)).toBe(false);
expect(isExternalAddress('x@eu.partner.com', internal)).toBe(false);
expect(isExternalAddress('dave@org-two.test', internal)).toBe(true);
});
it('calls nobody external when no internal domain is known', () => {
expect(isExternalAddress('dave@org-two.test', [])).toBe(false);
});
});
describe('externalRecipients', () => {
const internal = ['example.org'];
it('lists only the external recipients, each once, with the field it is in', () => {
const result = externalRecipients(
{ to: ['bob@example.org', 'dave@org-two.test'], cc: ['DAVE@org-two.test', 'eve@other.net'], bcc: ['x@sub.example.org'] },
internal,
);
expect(result).toEqual([
{ address: 'dave@org-two.test', domain: 'org-two.test', field: 'to' },
{ address: 'eve@other.net', domain: 'other.net', field: 'cc' },
]);
});
it('leaves out trusted addresses (contacts)', () => {
const trusted = new Set(['dave@org-two.test']);
const result = externalRecipients({ to: ['Dave@org-two.test', 'eve@other.net'], cc: [], bcc: [] }, internal, (a) => trusted.has(a));
expect(result.map((r) => r.address)).toEqual(['eve@other.net']);
});
});
describe('externalSenders', () => {
const internal = ['example.org'];
it('names the external senders and nothing for internal ones', () => {
expect(externalSenders([{ email: 'dave@org-two.test' }], internal)).toEqual([{ address: 'dave@org-two.test', domain: 'org-two.test' }]);
expect(externalSenders([{ email: 'bob@example.org' }], internal)).toEqual([]);
expect(externalSenders(undefined, internal)).toEqual([]);
});
it('counts a message as external when any of several From addresses is', () => {
expect(externalSenders([{ email: 'bob@example.org' }, { email: 'eve@evil.example' }], internal))
.toEqual([{ address: 'eve@evil.example', domain: 'evil.example' }]);
});
it('counts a From without a domain as external', () => {
expect(externalSenders([{ email: 'ceo' }], internal)).toEqual([{ address: 'ceo', domain: '' }]);
});
it('calls nobody external when no internal domain is known', () => {
expect(externalSenders([{ email: 'ceo' }, { email: 'dave@org-two.test' }], [])).toEqual([]);
});
});
describe('the active account', () => {
beforeEach(() => {
setExternalMail({});
useAuthStore.setState({ identities: [], activeAccountId: 'acc-alice' });
useContactStore.setState({ contacts: [], addressBooks: [], trustedSendersBookId: null });
});
it('reads the switches from the effective policy', () => {
expect(activeExternalMailContext().policy.senderLabel).toBe(false);
setExternalMail({ senderLabel: true });
expect(activeExternalMailContext().policy.senderLabel).toBe(true);
});
it('combines the policy domains with the identity domains', () => {
setExternalMail({ domains: ['partner.com'] });
useAuthStore.setState({ identities: [identity('org-alice@example.org')] });
expect(contextInternalDomains(activeExternalMailContext())).toEqual(['example.org', 'partner.com']);
});
it('resolves another signed-in account with its own scopes and identities', async () => {
// The raw policy: the label on for everyone, off for org-two.test.
usePolicyStore.setState({
rawPolicy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, senderLabel: true } },
scopes: [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { organisation: { externalMail: { senderLabel: false } } } }],
},
});
setExternalMail({ senderLabel: true });
const daveClient = {
getUsername: () => 'org-dave@org-two.test',
getSharedAccounts: () => [],
getAccountInfo: () => null,
getPrincipalTypes: async () => ({}),
getIdentities: async () => [identity('org-dave@org-two.test')],
};
useAuthStore.setState({
identities: [identity('org-alice@example.org')],
getClientForAccount: (id: string) => (id === 'acc-dave' ? daveClient : undefined),
} as never);
const dave = await externalMailContextFor('acc-dave');
expect(dave.policy.senderLabel).toBe(false);
expect(dave.identityEmails).toEqual(['org-dave@org-two.test']);
// The active account, and an unknown one, read the stores.
expect((await externalMailContextFor('acc-alice')).identityEmails).toEqual(['org-alice@example.org']);
expect((await externalMailContextFor(undefined)).policy.senderLabel).toBe(true);
});
it('collects the addresses of every contact', () => {
useContactStore.setState({
contacts: [
{ id: 'c1', emails: { e1: { address: 'Dave@org-two.test' }, e2: { address: 'second@x.test' } } },
{ id: 'c2' },
] as unknown as ContactCard[],
});
expect([...contactAddresses()].sort()).toEqual(['dave@org-two.test', 'second@x.test']);
});
it('leaves out cards that are only in the Trusted Senders book', () => {
useContactStore.setState({
addressBooks: [
{ id: 'book-main', name: 'Contacts' },
{ id: 'acc1::book-trusted', name: 'Trusted Senders' },
] as never,
trustedSendersBookId: 'book-trusted-raw',
contacts: [
// Only "always load images" for this sender: not a contact.
{ id: 'c1', addressBookIds: { 'acc1::book-trusted': true }, emails: { e: { address: 'newsletter@shop.example' } } },
{ id: 'c2', addressBookIds: { 'book-trusted-raw': true }, emails: { e: { address: 'promo@shop.example' } } },
// In a real book too: a contact.
{ id: 'c3', addressBookIds: { 'book-main': true, 'acc1::book-trusted': true }, emails: { e: { address: 'dave@org-two.test' } } },
{ id: 'c4', addressBookIds: { 'book-main': true }, emails: { e: { address: 'carol@partner.example' } } },
] as unknown as ContactCard[],
});
expect([...contactAddresses()].sort()).toEqual(['carol@partner.example', 'dave@org-two.test']);
});
});
+3 -3
View File
@@ -30,8 +30,8 @@ describe('favicon route upstream concurrency', () => {
const inFlight = Array.from({ length: 16 }, (_, i) =>
GET(new NextRequest(`https://webmail.example/api/favicon?domain=site${i}.example`)),
);
await new Promise((r) => setTimeout(r, 0));
expect(fetchMock).toHaveBeenCalledTimes(16);
// The route reads the no-third-party switch from config first (#903).
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(16));
const over = await GET(new NextRequest('https://webmail.example/api/favicon?domain=site99.example'));
expect(fetchMock).toHaveBeenCalledTimes(16);
@@ -42,7 +42,7 @@ describe('favicon route upstream concurrency', () => {
for (const release of pending.splice(0)) release();
await Promise.all(inFlight);
const later = GET(new NextRequest('https://webmail.example/api/favicon?domain=site100.example'));
expect(fetchMock).toHaveBeenCalledTimes(17);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(17));
for (const release of pending.splice(0)) release();
expect((await later).status).toBe(200);
});
+1
View File
@@ -40,6 +40,7 @@ export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
embeddedMode: false,
parentOrigin: '',
sourceCodeUrl: '',
noThirdPartyTraffic: false,
...overrides,
};
}
+244
View File
@@ -0,0 +1,244 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { JMAPClient } from '../jmap/client';
import { buildShareSetCalls, parseShareTargets, readShareSetResults, type ShareChange } from '../jmap/share-batch';
import { CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '../jmap/share-rights';
const change = (kind: ShareChange['kind'], id: string, rights: ShareChange['rights'], principalId = 'ck', accountId = 'ci'): ShareChange =>
({ kind, accountId, id, name: id, principalId, rights });
describe('buildShareSetCalls', () => {
it('puts each type in one call with a shareWith patch per item', () => {
const { calls } = buildShareSetCalls([
change('mailbox', 'a', MAILBOX_PRESETS.read),
change('mailbox', 'b', MAILBOX_PRESETS.read),
change('calendar', 'c2', CALENDAR_PRESETS.read),
change('file', 'n1', null),
], { maxObjectsInSet: 500 });
expect(calls).toEqual([
['Mailbox/set', { accountId: 'ci', update: { a: { 'shareWith/ck': MAILBOX_PRESETS.read }, b: { 'shareWith/ck': MAILBOX_PRESETS.read } } }, 's0'],
['Calendar/set', { accountId: 'ci', update: { c2: { 'shareWith/ck': CALENDAR_PRESETS.read } } }, 's1'],
['FileNode/set', { accountId: 'ci', update: { n1: { 'shareWith/ck': null } } }, 's2'],
]);
});
it('splits a type past maxObjectsInSet', () => {
const changes = ['a', 'b', 'c'].map((id) => change('mailbox', id, null));
const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: 2 });
expect(calls.map(([, args]) => Object.keys(args.update as object))).toEqual([['a', 'b'], ['c']]);
expect(changesByCall.get('s1')!.map((c) => c.id)).toEqual(['c']);
});
it('converts rights through wireRights, but never a revoke', () => {
const { calls } = buildShareSetCalls([change('file', 'n1', FILE_PRESETS.read), change('file', 'n2', null)], {
maxObjectsInSet: 500,
wireRights: () => ({ mayRead: true, mayWrite: false, mayShare: false }),
});
expect(calls[0][1].update).toEqual({ n1: { 'shareWith/ck': { mayRead: true, mayWrite: false, mayShare: false } }, n2: { 'shareWith/ck': null } });
});
});
describe('readShareSetResults', () => {
it('reports each item: updated, refused with the reason, or the whole call failing', () => {
const changes = [
change('mailbox', 'a', MAILBOX_PRESETS.read),
change('mailbox', 'b', MAILBOX_PRESETS.read),
change('mailbox', 'z', MAILBOX_PRESETS.read),
change('calendar', 'c2', CALENDAR_PRESETS.read),
];
const { calls, changesByCall } = buildShareSetCalls(changes, { maxObjectsInSet: 500 });
const results = readShareSetResults(calls, changesByCall, [
['Mailbox/set', { updated: { a: null }, notUpdated: { b: { type: 'forbidden', description: 'You may not share this folder' } } }, 's0'],
['error', { type: 'forbidden' }, 's1'],
]);
expect(results.map((r) => [r.change.id, r.ok, r.error])).toEqual([
['a', true, undefined],
['b', false, 'You may not share this folder'],
['z', false, undefined],
['c2', false, 'forbidden'],
]);
});
});
describe('parseShareTargets', () => {
it('reads every type and names the ones whose call failed', () => {
const { targets, failed } = parseShareTargets([
['Mailbox/get', { list: [{ id: 'a', name: 'Inbox', role: 'inbox', parentId: null, shareWith: { ck: MAILBOX_PRESETS.read } }] }, 'm'],
['error', { type: 'serverFail' }, 'c'],
['AddressBook/get', { list: [{ id: 'ab', name: 'Contacts', isDefault: true, shareWith: null }] }, 'a'],
], { mailbox: 'ci', calendar: 'ci', addressBook: 'ci' });
expect(targets).toEqual([
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', parentId: null, shareWith: { ck: MAILBOX_PRESETS.read } },
{ kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} },
]);
expect(failed).toEqual(['calendar']);
});
});
describe('JMAPClient.applyShareChanges', () => {
afterEach(() => vi.restoreAllMocks());
function client(fileCapability: Record<string, unknown> = { forbiddenNameChars: '/' }) {
const c = new JMAPClient('https://jmap.example.com', 'user', 'pass');
Object.assign(c, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'ci',
capabilities: {
'urn:ietf:params:jmap:core': { maxCallsInRequest: 2, maxObjectsInSet: 500 },
'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:calendars': {}, 'urn:ietf:params:jmap:filenode': {},
},
});
const accounts = { ci: { name: 'org-alice@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:filenode': fileCapability } } };
Object.assign(c, { accounts, session: { accounts } });
return c;
}
function respondWith(handler: (body: { using: string[]; methodCalls: [string, Record<string, unknown>, string][] }) => unknown) {
return vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
const body = JSON.parse((init as RequestInit).body as string);
const response = handler(body);
return { ok: true, status: 200, text: async () => JSON.stringify(response), json: async () => response } as Response;
});
}
it('sends one request per maxCallsInRequest calls and reports every item', async () => {
const spy = respondWith((body) => ({
methodResponses: body.methodCalls.map(([method, args, id]) => [method, { updated: Object.fromEntries(Object.keys(args.update as object).map((k) => [k, null])) }, id]),
}));
const results = await client().applyShareChanges([
change('mailbox', 'a', MAILBOX_PRESETS.read),
change('calendar', 'c2', CALENDAR_PRESETS.read),
change('file', 'n1', null),
]);
expect(spy).toHaveBeenCalledTimes(2);
const first = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
expect(first.using).toEqual(expect.arrayContaining(['urn:ietf:params:jmap:mail', 'urn:ietf:params:jmap:mail:share', 'urn:ietf:params:jmap:calendars']));
expect(results.map((r) => [r.change.id, r.ok])).toEqual([['a', true], ['c2', true], ['n1', true]]);
});
it('marks the items of a failed request failed instead of throwing', async () => {
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline'));
const results = await client().applyShareChanges([change('mailbox', 'a', MAILBOX_PRESETS.read)]);
expect(results).toEqual([{ change: expect.objectContaining({ id: 'a' }), ok: false, error: expect.any(String) }]);
});
it('sends the old file rights to a server before 0.16.6', async () => {
const spy = respondWith((body) => ({ methodResponses: [['FileNode/set', { updated: { n1: null } }, body.methodCalls[0][2]]] }));
await client({}).applyShareChanges([change('file', 'n1', FILE_PRESETS.readWrite)]);
const body = JSON.parse((spy.mock.calls[0][1] as RequestInit).body as string);
expect(body.methodCalls[0][1].update).toEqual({ n1: { 'shareWith/ck': { mayRead: true, mayWrite: true, mayShare: false } } });
});
});
describe('JMAPClient.getShareTargets', () => {
afterEach(() => vi.restoreAllMocks());
type Call = [string, Record<string, unknown>, string];
function client(answer: (calls: Call[]) => unknown[]) {
const c = new JMAPClient('https://jmap.example.com', 'user', 'pass');
const accounts = {
ci: {
name: 'org-alice@example.org', isPersonal: true,
accountCapabilities: {
'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:mail:share': {}, 'urn:ietf:params:jmap:calendars': {},
'urn:ietf:params:jmap:contacts': {}, 'urn:ietf:params:jmap:filenode': { forbiddenNameChars: '/' },
},
},
};
Object.assign(c, {
apiUrl: 'https://jmap.example.com/api', accountId: 'ci', accounts,
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {} },
session: { accounts, primaryAccounts: {} },
});
const request = vi.spyOn(c as unknown as { request: (calls: Call[]) => Promise<unknown> }, 'request')
.mockImplementation(async (calls: Call[]) => ({ methodResponses: answer(calls) }));
return { c, request };
}
const lists = (calls: Call[]) => calls.map(([method, , id]) => [method, {
list: method === 'Mailbox/get' ? [{ id: 'a', name: 'Inbox', role: 'inbox', shareWith: { ck: MAILBOX_PRESETS.read } }]
: method === 'Calendar/get' ? [{ id: 'b', name: 'Calendar', isDefault: true, shareWith: {} }]
: [{ id: 'c', name: 'Contacts', isDefault: true, shareWith: {} }],
}, id]);
it('reads folders, calendars and address books in one request, and only the shared files', async () => {
const { c, request } = client(lists);
vi.spyOn(c, 'listAllFileNodes').mockResolvedValue([
{ id: 'n1', name: 'plan.pdf', parentId: null, shareWith: { ck: FILE_PRESETS.read } },
{ id: 'n2', name: 'notes.txt', parentId: null, shareWith: {} },
] as never);
const { targets, failed } = await c.getShareTargets();
expect(request).toHaveBeenCalledTimes(1);
expect(request.mock.calls[0][0].map(([m]) => m)).toEqual(['Mailbox/get', 'Calendar/get', 'AddressBook/get']);
expect(targets.map((t) => `${t.kind}:${t.id}`)).toEqual(['mailbox:a', 'calendar:b', 'addressBook:c', 'file:n1']);
expect(failed).toEqual([]);
});
it('reads the file tree alongside the rest, not after it', async () => {
let releaseCollections: () => void = () => {};
const collectionsHeld = new Promise<void>((resolve) => { releaseCollections = resolve; });
const { c, request } = client(lists);
request.mockImplementation(async (calls: Call[]) => { await collectionsHeld; return { methodResponses: lists(calls) }; });
const files = vi.spyOn(c, 'listAllFileNodes').mockResolvedValue([]);
const pending = c.getShareTargets();
// The collections haven't answered yet, and the file read has already started.
await Promise.resolve();
expect(files).toHaveBeenCalled();
releaseCollections();
expect((await pending).failed).toEqual([]);
});
it('skips the types whose feature is off', async () => {
const { c, request } = client(lists);
const files = vi.spyOn(c, 'listAllFileNodes').mockResolvedValue([]);
await c.getShareTargets({ calendars: false, contacts: false, files: false });
expect(request.mock.calls[0][0].map(([m]) => m)).toEqual(['Mailbox/get']);
expect(files).not.toHaveBeenCalled();
});
it('names the types whose read failed', async () => {
const { c } = client((calls) => calls.map((call) => (call[0] === 'Calendar/get' ? ['error', { type: 'serverFail' }, call[2]] : lists([call])[0])));
vi.spyOn(c, 'listAllFileNodes').mockRejectedValue(new Error('offline'));
vi.spyOn(console, 'error').mockImplementation(() => {});
const { failed } = await c.getShareTargets();
expect(failed.sort()).toEqual(['calendar', 'file']);
});
});
describe('JMAPClient.findPrincipalByEmail', () => {
afterEach(() => vi.restoreAllMocks());
function client(responses: unknown[]) {
const c = new JMAPClient('https://jmap.example.com', 'user', 'pass');
Object.assign(c, {
apiUrl: 'https://jmap.example.com/api', accountId: 'ci',
capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:principals': {} },
});
const request = vi.spyOn(c as unknown as { request: () => Promise<unknown> }, 'request').mockResolvedValue({ methodResponses: responses });
return { c, request };
}
it('finds the principal with that address, whatever its case', async () => {
const { c, request } = client([
['Principal/query', { ids: ['ck'] }, '0'],
['Principal/get', { list: [{ id: 'ck', type: 'individual', name: 'Carol', email: 'org-carol@example.org' }] }, '1'],
]);
const found = await c.findPrincipalByEmail(' Org-Carol@Example.org ');
expect(found).toEqual({ principal: expect.objectContaining({ id: 'ck', accountId: 'ci' }), status: 'ok' });
const [calls] = request.mock.calls[0] as unknown as [[string, Record<string, unknown>][]];
expect(calls[0][1].filter).toEqual({ email: 'org-carol@example.org' });
});
it('says when directory queries are off (Stalwart 1.0)', async () => {
const { c } = client([
['error', { type: 'forbidden', description: 'The administrator has disabled directory queries.' }, '0'],
['error', { type: 'invalidResultReference' }, '1'],
]);
expect(await c.findPrincipalByEmail('org-carol@example.org')).toEqual({ principal: null, status: 'forbidden', directoryQueriesOff: true });
});
it('tells a refusal for another reason apart, and an unknown address', async () => {
expect(await client([['error', { type: 'forbidden', description: 'You are not authorized to perform this action' }, '0']]).c
.findPrincipalByEmail('x@example.org')).toEqual({ principal: null, status: 'forbidden' });
expect(await client([['Principal/query', { ids: [] }, '0'], ['Principal/get', { list: [] }, '1']]).c
.findPrincipalByEmail('x@example.org')).toEqual({ principal: null, status: 'ok' });
});
});
+69
View File
@@ -0,0 +1,69 @@
// @vitest-environment node
import { afterAll, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { BUILT_IN_HOSTNAMES } from '@/lib/outgoing-hosts';
import { isPlaceholderHost as sourcePlaceholder } from '@/lib/admin/outgoing-hosts-registry';
import { LITE_BUILT_IN_HOSTS, isPlaceholderHost, scanBundleHosts } from '../../scripts/lite/scan-hosts.mjs';
// scripts/lite/scan-hosts.mjs runs in the Lite CI job after build:lite (the
// build prunes the test trees, so the scan can't be a vitest file there).
// These tests pin it to the app's host list and show what it catches. With
// LITE_OUT_DIR set it also scans a real build:
//
// LITE_OUT_DIR=/path/to/out npx vitest run lib/__tests__/lite-bundle-hosts.test.ts
const dirs: string[] = [];
function fakeOut(files: Record<string, string>): string {
const dir = mkdtempSync(path.join(tmpdir(), 'lite-out-'));
dirs.push(dir);
for (const [name, text] of Object.entries(files)) {
mkdirSync(path.dirname(path.join(dir, name)), { recursive: true });
writeFileSync(path.join(dir, name), text);
}
return dir;
}
afterAll(() => { for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); });
describe('Lite bundle host scan', () => {
it('knows the same built-in hosts and placeholders as the app', () => {
expect((LITE_BUILT_IN_HOSTS as string[]).filter((h) => !h.endsWith('.')).sort()).toEqual([...BUILT_IN_HOSTNAMES].sort());
for (const host of ['mail.example.com', 'x', '10.0.0.5', '93.184.216.34', 'acme.test', 'github.com', '*.vimeo.com']) {
expect(isPlaceholderHost(host), host).toBe(sourcePlaceholder(host));
}
});
it('passes a bundle with only accounted hosts', () => {
const out = fakeOut({
'config.json': JSON.stringify({ jmapServerUrl: 'https://mail.corp.example.net' }),
'_next/static/chunks/a1.js': 'fetch("https://mail.corp.example.net/jmap");x="https://react.dev/errors/";y="https://icons.duckduckgo.com"',
'en/mail/index.html': '<svg xmlns="http://www.w3.org/2000/svg"></svg>',
});
expect(scanBundleHosts(out)).toEqual([]);
});
it('catches a new host, a ws host, a protocol-relative host, a public IP and a listed host in the wrong file', () => {
const out = fakeOut({
'config.json': '{}',
'_next/static/chunks/b2.js': [
'fetch("https://beacon.fakecdn.io/x")',
'new WebSocket("WSS://Live.FakeCDN.io")',
's.src="//cdn.fakecdn.io/lib.js"',
'fetch("http://93.184.216.34/")',
].join(';'),
'en/mail/index.html': '<img src="https://icons.duckduckgo.com/x.ico">',
});
expect(scanBundleHosts(out).sort()).toEqual([
'_next/static/chunks/b2.js: 93.184.216.34',
'_next/static/chunks/b2.js: beacon.fakecdn.io',
'_next/static/chunks/b2.js: cdn.fakecdn.io',
'_next/static/chunks/b2.js: live.fakecdn.io',
'en/mail/index.html: icons.duckduckgo.com',
]);
});
it.skipIf(!process.env.LITE_OUT_DIR)('finds nothing unaccounted in the build at LITE_OUT_DIR', () => {
expect(scanBundleHosts(process.env.LITE_OUT_DIR)).toEqual([]);
});
});
+13
View File
@@ -194,6 +194,19 @@ describe('lite build helpers', () => {
expect(withScriptHashCsp('<html><script>1</script></html>')).toMatch(/^<html><meta http-equiv="Content-Security-Policy"[^>]*><script>/);
});
it('fences a page built with no third-party traffic to its own origin and the JMAP server (#903)', () => {
const strict = buildScriptHashCsp([], { noThirdParty: true, connectSrc: 'https://mail.example.com' });
expect(strict).toContain("connect-src 'self' https://mail.example.com");
expect(strict).toContain("img-src 'self' data: blob: https://mail.example.com");
expect(strict).toContain("font-src 'self' data:");
expect(strict).not.toMatch(/ https:( |;|$)/);
expect(buildScriptHashCsp([], { noThirdParty: true })).toContain("connect-src 'self' *");
const header = buildHeaders({ basePath: '', connectSrc: 'https://mail.example.com', noThirdParty: true });
expect(header).toContain("img-src 'self' data: blob: https://mail.example.com");
expect(header).not.toMatch(/img-src[^;]* https:( |;)/);
expect(buildHeaders({ basePath: '', connectSrc: '*' })).toContain("img-src 'self' data: blob: https:");
});
it('writes a 404 shim that parks deep links for known locale/surface pairs only', () => {
const html = buildNotFoundShim({ basePath: '/webmail', locales: ['de', 'en'] });
expect(html).toContain('var base = "/webmail"');
+189
View File
@@ -0,0 +1,189 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_RELAY_BASE_URL, resolveActiveRelayUrl, resolveDefaultRelayUrl, resolvePushRelayOptions } from '@/lib/push-relays';
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 { isPlaceholderHost } from '@/lib/admin/outgoing-hosts-registry';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// The browser half of the "no third-party traffic" mode (#903).
const fetchSpy = vi.fn(async () => new Response('{}'));
beforeEach(() => {
fetchSpy.mockClear();
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
setNoThirdPartyTraffic(false);
});
const ownRelay = { label: 'Company', url: 'https://push.company.example' };
describe('push relays', () => {
it('drop the built-in relay in the mode and keep the admin\'s', () => {
setNoThirdPartyTraffic(true);
expect(resolvePushRelayOptions({})).toEqual([]);
expect(resolvePushRelayOptions({ pushRelays: [ownRelay] })).toEqual([ownRelay]);
setNoThirdPartyTraffic(false);
expect(resolvePushRelayOptions({ pushRelays: [ownRelay] }).map((o) => o.url)).toEqual([DEFAULT_RELAY_BASE_URL, ownRelay.url]);
});
it('fall back to the admin\'s relay, or to none, instead of the built-in one', () => {
setNoThirdPartyTraffic(true);
expect(resolveDefaultRelayUrl({ pushRelays: [ownRelay] })).toBe(ownRelay.url);
expect(resolveDefaultRelayUrl({})).toBe('');
// A user's saved pick of the built-in relay is not honoured either.
expect(resolveActiveRelayUrl({}, DEFAULT_RELAY_BASE_URL)).toBe('');
expect(resolveActiveRelayUrl({ pushRelays: [ownRelay] }, DEFAULT_RELAY_BASE_URL)).toBe(ownRelay.url);
// A configured pushRelayUrl pointing at the built-in host doesn't sneak it back.
expect(resolveDefaultRelayUrl({ pushRelayUrl: DEFAULT_RELAY_BASE_URL })).toBe('');
setNoThirdPartyTraffic(false);
expect(resolveDefaultRelayUrl({})).toBe(DEFAULT_RELAY_BASE_URL);
});
it('never get a request to the built-in relay in the mode', async () => {
const client = { destroyPushSubscription: vi.fn(async () => {}) } as unknown as IJMAPClient;
const device: PushDevice = { id: 'p1', deviceClientId: 'abc', expires: null, types: null, isThisDevice: false, relayStatus: 'unknown' };
setNoThirdPartyTraffic(true);
await revokePushDevice({ client, device, relayBaseUrl: DEFAULT_RELAY_BASE_URL });
expect(client.destroyPushSubscription).toHaveBeenCalledWith('p1');
expect(fetchSpy).not.toHaveBeenCalled();
setNoThirdPartyTraffic(false);
await revokePushDevice({ client, device, relayBaseUrl: DEFAULT_RELAY_BASE_URL });
expect(fetchSpy).toHaveBeenCalledWith(`${DEFAULT_RELAY_BASE_URL}/api/push/register/abc`, { method: 'DELETE' });
});
});
describe('push registrations made before the mode', () => {
it('are removed from the mail server when no relay of the organisation is left', async () => {
vi.stubGlobal('PushManager', class {});
vi.stubGlobal('Notification', { permission: 'granted' });
Object.defineProperty(navigator, 'serviceWorker', {
configurable: true,
value: { getRegistration: async () => undefined },
});
localStorage.setItem('bulwark.push.subscriptionId.v1.acc1', 'ps1');
const client = {
getAccountId: () => 'acc1',
destroyPushSubscription: vi.fn(async () => {}),
listPushSubscriptions: vi.fn(async () => []),
} as unknown as IJMAPClient;
try {
setNoThirdPartyTraffic(true);
await resyncWebPush({ client, relayBaseUrl: '', inboxOnly: false });
expect(client.destroyPushSubscription).toHaveBeenCalledWith('ps1');
expect(localStorage.getItem('bulwark.push.subscriptionId.v1.acc1')).toBeNull();
expect(fetchSpy).not.toHaveBeenCalled();
} finally {
delete (navigator as unknown as Record<string, unknown>).serviceWorker;
localStorage.clear();
}
});
});
describe('WKD key lookup', () => {
it('asks the recipient\'s domain only with the mode off', async () => {
setNoThirdPartyTraffic(true);
expect(await doGetPublicKeyFromWKD('joe@vendor.example')).toMatchObject({ status: 'ERROR' });
expect(fetchSpy).not.toHaveBeenCalled();
setNoThirdPartyTraffic(false);
fetchSpy.mockImplementation(async () => new Response('', { status: 404 }));
await doGetPublicKeyFromWKD('joe@vendor.example');
expect(String((fetchSpy.mock.calls[0] as unknown[])[0])).toMatch(/^https:\/\/openpgpkey\.vendor\.example\//);
});
});
describe('plugins', () => {
it('that declare network access are refused in the mode, the rest run', () => {
const offline = { permissions: ['email:read', 'http:post'] };
const fetcher = { permissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] };
const proxied = { permissions: ['http:proxy'] };
const framer = { permissions: [], frameOrigins: ['https://www.youtube-nocookie.com'] };
expect([offline, fetcher, proxied, framer].map(pluginDeclaresNetwork)).toEqual([false, true, true, true]);
setNoThirdPartyTraffic(true);
expect([offline, fetcher, proxied, framer].map(pluginAllowed)).toEqual([true, false, false, false]);
setNoThirdPartyTraffic(false);
expect([offline, fetcher, proxied, framer].map(pluginAllowed)).toEqual([true, true, true, true]);
});
});
describe('config', () => {
it('reads noThirdPartyTraffic from the Lite config.json', () => {
expect(applyLiteConfig({}).noThirdPartyTraffic).toBe(false);
expect(applyLiteConfig({ noThirdPartyTraffic: true }).noThirdPartyTraffic).toBe(true);
// A string as in env counts too (#903 review).
expect(applyLiteConfig({ noThirdPartyTraffic: 'yes' }).noThirdPartyTraffic).toBe(true);
expect(applyLiteConfig({ noThirdPartyTraffic: 'off' }).noThirdPartyTraffic).toBe(false);
});
it('turns the browser switches on once the runtime config is fetched', async () => {
vi.resetModules();
const mode = await import('@/lib/third-party-mode');
const { createConfig } = await import('./fixtures/config');
fetchSpy.mockImplementation(async () => Response.json(createConfig({ noThirdPartyTraffic: true })));
const { fetchConfig } = await import('@/hooks/use-config');
await fetchConfig();
expect(mode.noThirdPartyTraffic()).toBe(true);
});
it('parses the env spellings', () => {
expect(['true', 'TRUE', '1', 'yes', 'on', ' on '].map(parseNoThirdPartyEnv)).toEqual([true, true, true, true, true, true]);
expect(['false', '0', '', 'off', 'nope', undefined].map(parseNoThirdPartyEnv)).toEqual([false, false, false, false, false, false]);
expect(noThirdPartyTraffic()).toBe(false);
});
});
describe('host helpers', () => {
it('know the built-in hosts and the placeholders', () => {
expect(isBuiltInHost('https://notifications.relay.bulwarkmail.org/')).toBe(true);
expect(isBuiltInHost('https://extensions.bulwarkmail.org')).toBe(true);
expect(isBuiltInHost('https://extensions.corp.example')).toBe(false);
expect(isBuiltInHost('not a url')).toBe(false);
expect(['mail.example.com', 'x', 'stalwart.internal:8080', '10.0.0.5', '*.vimeo.com', 'acme.test'].every(isPlaceholderHost)).toBe(true);
expect(['github.com', 'icons.duckduckgo.com', 'api.example.io'].some(isPlaceholderHost)).toBe(false);
});
});
describe('before the config is known (#903 review)', () => {
it('treats the mode as on', async () => {
vi.resetModules();
const mode = await import('@/lib/third-party-mode');
const hosts = await import('@/lib/outgoing-hosts');
expect(mode.thirdPartyModeState()).toBe('unknown');
expect(mode.noThirdPartyTraffic()).toBe(true);
expect(hosts.relayAllowed('https://notifications.relay.bulwarkmail.org')).toBe(false);
expect(hosts.senderFaviconsAllowed()).toBe(false);
expect(hosts.mapsLinksAllowed()).toBe(false);
expect(hosts.pluginAllowed({ permissions: ['http:fetch'] })).toBe(false);
mode.setNoThirdPartyTraffic(false);
expect(hosts.relayAllowed('https://notifications.relay.bulwarkmail.org')).toBe(true);
});
it.each([
['missing', async () => new Response('', { status: 404 })],
['not JSON', async () => new Response('{ this is not json', { status: 200 })],
])('turns the mode on in Lite when config.json is %s', async (_label, answer) => {
vi.resetModules();
vi.doMock('@/lib/lite', async (importOriginal) => ({ ...(await importOriginal<typeof import('@/lib/lite')>()), IS_LITE: true, IS_LITE_STALWART: false }));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
fetchSpy.mockImplementation(answer as never);
try {
const mode = await import('@/lib/third-party-mode');
const { fetchConfig } = await import('@/hooks/use-config');
expect((await fetchConfig()).noThirdPartyTraffic).toBe(true);
expect(mode.thirdPartyModeState()).toBe('on');
expect(String(warn.mock.calls[0]?.[0])).toMatch(/no third-party traffic/);
} finally {
vi.doUnmock('@/lib/lite');
warn.mockRestore();
}
});
});
+360
View File
@@ -0,0 +1,360 @@
// @vitest-environment node
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { NextRequest } from 'next/server';
// The server half of the "no third-party traffic" mode (#903): every route
// and scheduler that talks to a built-in outside host stops before the
// request once the switch is on, and works as before when it is off.
vi.hoisted(() => { process.env.NEXT_PUBLIC_APP_VERSION = '1.18.0'; });
vi.mock('@/lib/stalwart/credentials', () => ({
getStalwartCredentials: vi.fn(async () => ({ serverUrl: 'https://mail.example.com', authHeader: 'Basic x', username: 'u' })),
}));
vi.mock('@/lib/security/same-origin', () => ({ rejectCrossOriginRequest: () => null }));
vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: vi.fn(async () => true) }));
vi.mock('@/lib/admin/session', () => ({
requireAdminAuth: vi.fn(async () => ({ session: { sub: 'admin' } })),
getClientIP: () => '127.0.0.1',
}));
vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn(async () => {}) }));
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),
getTheme: vi.fn(async () => null),
}));
vi.mock('@/lib/admin/plugin-dev', () => ({ listDevPlugins: vi.fn(async () => []) }));
import { configManager } from '@/lib/admin/config-manager';
import { isNoThirdPartyTrafficOn, noThirdPartyTrafficState } from '@/lib/admin/third-party-mode';
let dir: string;
const fetchMock = vi.fn(async (_url: unknown, _init?: unknown) => new Response(new Uint8Array(64).fill(1), { headers: { 'content-type': 'image/png' } }));
function calledHosts(): string[] {
return fetchMock.mock.calls.map(([url]) => {
try { return new URL(String(url)).hostname; } catch { return String(url); }
});
}
/** The switch as an admin sets it: config.json, read again. */
async function setMode(on: boolean, policy: Record<string, unknown> = {}) {
writeFileSync(path.join(dir, 'config.json'), JSON.stringify(on ? { noThirdPartyTraffic: true } : {}));
writeFileSync(path.join(dir, 'policy.json'), JSON.stringify(policy));
await configManager.load();
}
beforeAll(() => {
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-3p-'));
process.env.ADMIN_CONFIG_DIR = dir;
process.env.TELEMETRY_DATA_DIR = path.join(dir, 'telemetry');
process.env.VERSION_CHECK_DATA_DIR = path.join(dir, 'version');
});
afterAll(() => {
rmSync(dir, { recursive: true, force: true });
delete process.env.ADMIN_CONFIG_DIR;
delete process.env.TELEMETRY_DATA_DIR;
delete process.env.VERSION_CHECK_DATA_DIR;
});
beforeEach(() => {
fetchMock.mockClear();
vi.stubGlobal('fetch', fetchMock);
delete process.env.NO_THIRD_PARTY_TRAFFIC;
registryPlugins.length = 0;
});
afterEach(() => {
vi.unstubAllGlobals();
delete process.env.NO_THIRD_PARTY_TRAFFIC;
});
describe('the switch', () => {
it('is off by default and on from config.json', async () => {
await setMode(false);
expect(await isNoThirdPartyTrafficOn()).toBe(false);
expect(noThirdPartyTrafficState()).toEqual({ on: false, source: 'default' });
await setMode(true);
expect(await isNoThirdPartyTrafficOn()).toBe(true);
expect(noThirdPartyTrafficState()).toEqual({ on: true, source: 'admin' });
});
it('takes an env "on" over a config.json false, and any common spelling of on', async () => {
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: false }));
await configManager.load();
for (const value of ['true', '1', 'YES', 'on']) {
process.env.NO_THIRD_PARTY_TRAFFIC = value;
expect(await isNoThirdPartyTrafficOn()).toBe(true);
expect(noThirdPartyTrafficState()).toEqual({ on: true, source: 'env' });
}
process.env.NO_THIRD_PARTY_TRAFFIC = 'false';
expect(await isNoThirdPartyTrafficOn()).toBe(false);
});
it('is in the client config', async () => {
const { GET } = await import('@/app/api/config/route');
await setMode(true);
expect((await (await GET(new NextRequest('https://webmail.example/api/config'))).json()).noThirdPartyTraffic).toBe(true);
await setMode(false);
expect((await (await GET(new NextRequest('https://webmail.example/api/config'))).json()).noThirdPartyTraffic).toBe(false);
});
it('refuses an admin "off" while the environment says on', async () => {
const { PATCH } = await import('@/app/api/admin/config/route');
process.env.NO_THIRD_PARTY_TRAFFIC = 'true';
const res = await PATCH(new NextRequest('https://webmail.example/api/admin/config', {
method: 'PATCH', body: JSON.stringify({ noThirdPartyTraffic: false }),
}));
expect(res.status).toBe(409);
});
});
describe('sender favicons', () => {
it('answer 403 without contacting DuckDuckGo', async () => {
const { GET } = await import('@/app/api/favicon/route');
await setMode(true);
const res = await GET(new NextRequest('https://webmail.example/api/favicon?domain=github.com'));
expect(res.status).toBe(403);
expect(fetchMock).not.toHaveBeenCalled();
});
it('come from DuckDuckGo with the mode off', async () => {
const { GET } = await import('@/app/api/favicon/route');
await setMode(false);
await GET(new NextRequest('https://webmail.example/api/favicon?domain=gitlab.com'));
expect(calledHosts()).toEqual(['icons.duckduckgo.com']);
});
});
describe('translation', () => {
const post = async (body: Record<string, unknown>) => {
const { POST } = await import('@/app/api/translate/route');
return POST(new NextRequest('https://webmail.example/api/translate', { method: 'POST', body: JSON.stringify(body) }));
};
it('refuses MyMemory before any request', async () => {
await setMode(true);
const res = await post({ text: 'Hallo Welt', target: 'en', source: 'de' });
expect(res.status).toBe(403);
expect(fetchMock).not.toHaveBeenCalled();
});
it('still uses MyMemory with the mode off, and the admin\'s LibreTranslate with it on', async () => {
fetchMock.mockImplementation(async () => Response.json({ responseData: { translatedText: 'Hello world' }, translatedText: 'Hello world' }));
await setMode(false);
await post({ text: 'Hallo Welt', target: 'en', source: 'de' });
expect(calledHosts()).toContain('api.mymemory.translated.net');
fetchMock.mockClear();
process.env.LIBRETRANSLATE_URL = 'https://translate.corp.example';
try {
await setMode(true);
await post({ text: 'Hallo Welt', target: 'en', source: 'de', provider: 'libretranslate' });
expect(calledHosts()).toEqual(['translate.corp.example']);
} finally {
delete process.env.LIBRETRANSLATE_URL;
fetchMock.mockImplementation(async () => new Response(new Uint8Array(64).fill(1), { headers: { 'content-type': 'image/png' } }));
}
});
});
describe('connector', () => {
it('answers 404 in the mode', async () => {
const { GET } = await import('@/app/api/connector/capabilities/route');
await setMode(false);
expect((await GET()).status).toBe(200);
await setMode(true);
expect((await GET()).status).toBe(404);
});
});
describe('telemetry', () => {
it('is off whatever the saved consent says, and send-now sends nothing', async () => {
const { effectiveConsent, saveState, loadState, sendOnce } = await import('@/lib/telemetry');
await setMode(false);
await saveState({ ...(await loadState()), consent: 'on' });
expect((await effectiveConsent()).consent).toBe('on');
await setMode(true);
expect(await effectiveConsent()).toMatchObject({ consent: 'off', source: 'no-third-party' });
expect((await sendOnce({ reason: 'test' })).ok).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('version check', () => {
it('asks the version endpoint only with the mode off', async () => {
const { checkOnce } = await import('@/lib/version-check');
fetchMock.mockImplementation(async () => Response.json({ latest: '1.18.0', severity: 'none' }));
try {
await setMode(true);
expect(await checkOnce()).toMatchObject({ ok: false, error: 'off: no third-party traffic' });
expect(fetchMock).not.toHaveBeenCalled();
await setMode(false);
await checkOnce();
expect(calledHosts()).toEqual(['version.telemetry.bulwarkmail.org']);
} finally {
fetchMock.mockImplementation(async () => new Response(new Uint8Array(64).fill(1), { headers: { 'content-type': 'image/png' } }));
}
});
});
describe('extension directory', () => {
it('refuses the official directory in the mode and keeps a self-hosted one', async () => {
const { directoryOff } = await import('@/lib/admin/marketplace-directory');
const { GET } = await import('@/app/api/admin/marketplace/route');
await setMode(true);
expect(await directoryOff()).toBe(true);
const res = await GET(new NextRequest('https://webmail.example/api/admin/marketplace'));
expect(res.status).toBe(403);
expect(fetchMock).not.toHaveBeenCalled();
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: true, extensionDirectoryUrl: 'https://extensions.corp.example' }));
await configManager.load();
expect(await directoryOff()).toBe(false);
await setMode(false);
expect(await directoryOff()).toBe(false);
});
it('keeps the installed list of the updates view', async () => {
const { GET } = await import('@/app/api/admin/marketplace/updates/route');
await setMode(true);
const res = await GET(new NextRequest('https://webmail.example/api/admin/marketplace/updates'));
expect(res.status).toBe(200);
expect((await res.json()).error).toMatch(/official extension directory is off/);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('plugins', () => {
const plugins = [
{ id: 'offline', name: 'Offline', version: '1.0.0', enabled: true, permissions: ['email:read'] },
{ id: 'fetcher', name: 'Fetcher', version: '1.0.0', enabled: true, permissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] },
{ id: 'proxied', name: 'Proxied', version: '1.0.0', enabled: true, permissions: ['http:proxy'] },
{ id: 'framer', name: 'Framer', version: '1.0.0', enabled: true, permissions: [], frameOrigins: ['https://www.youtube-nocookie.com'] },
];
it('that declare network access never reach a browser in the mode', async () => {
registryPlugins.push(...plugins);
const { GET } = await import('@/app/api/plugins/route');
await setMode(false);
const off = await (await GET(new NextRequest('https://webmail.example/api/plugins'))).json();
expect(off.plugins.map((p: { id: string }) => p.id)).toEqual(['offline', 'fetcher', 'proxied', 'framer']);
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(['offline']);
});
it('get no outside frames in the CSP', async () => {
registryPlugins.push(...plugins);
const { getEnabledPluginFrameOrigins, invalidateFrameOriginsCache } = await import('@/lib/admin/csp-frame-origins');
await setMode(false);
invalidateFrameOriginsCache();
expect(await getEnabledPluginFrameOrigins()).toEqual(['https://www.youtube-nocookie.com']);
await setMode(true);
invalidateFrameOriginsCache();
expect(await getEnabledPluginFrameOrigins()).toEqual([]);
});
it('can\'t use the credential proxy', async () => {
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=proxied&credential=main&url=https%3A%2F%2Fapi.vendor.example%2F', { method: 'POST' }));
expect(res.status).toBe(403);
expect((await res.json()).code).toBe('no-third-party-traffic');
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('admin view', () => {
it('reports the switch, its source, what stays reachable and the refused plugins', async () => {
registryPlugins.push(
{ id: 'offline', name: 'Offline', version: '1.0.0', enabled: true, permissions: ['email:read'] },
{ id: 'fetcher', name: 'Fetcher', version: '1.0.0', enabled: true, permissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] },
);
const { GET } = await import('@/app/api/admin/outgoing-hosts/route');
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: true, jmapServerUrl: 'https://mail.corp.example' }));
writeFileSync(path.join(dir, 'policy.json'), JSON.stringify({ pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }] }));
await configManager.load();
const body = await (await GET(new NextRequest('https://webmail.example/api/admin/outgoing-hosts'))).json();
expect(body).toMatchObject({ noThirdPartyTraffic: true, source: 'admin', envLocked: false, readOnly: false });
expect(body.reachable.map((h: { host: string }) => h.host)).toEqual(['mail.corp.example', 'push.corp.example', 'any address your users enter', 'any address your users enter']);
expect(body.networkPlugins).toEqual([{ id: 'fetcher', name: 'Fetcher' }]);
expect(body.builtIn.length).toBeGreaterThan(5);
process.env.NO_THIRD_PARTY_TRAFFIC = 'true';
expect(await (await GET(new NextRequest('https://webmail.example/api/admin/outgoing-hosts'))).json()).toMatchObject({ source: 'env', envLocked: true });
});
});
describe('review follow-ups', () => {
it('counts a string "true", "1", "yes" or "on" in config.json as on', async () => {
for (const value of ['true', '1', 'yes', 'on', 'TRUE']) {
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: value }));
await configManager.load();
expect(await isNoThirdPartyTrafficOn(), value).toBe(true);
expect(noThirdPartyTrafficState(), value).toEqual({ on: true, source: 'admin' });
}
writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ noThirdPartyTraffic: 'no' }));
await configManager.load();
expect(await isNoThirdPartyTrafficOn()).toBe(false);
});
it('keeps the version check scheduled in the mode, so it resumes once the switch is off', async () => {
const { startScheduler, stopScheduler, checkOnce, loadState, saveState } = await import('@/lib/version-check');
fetchMock.mockImplementation(async () => Response.json({ latest: '1.18.0', severity: 'none' }));
try {
await setMode(true);
await saveState({ ...(await loadState()), nextScheduledAt: null });
await startScheduler();
expect((await loadState()).nextScheduledAt).not.toBeNull();
await stopScheduler();
expect((await checkOnce()).ok).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
await setMode(false);
await checkOnce();
expect(calledHosts()).toEqual(['version.telemetry.bulwarkmail.org']);
} finally {
await stopScheduler();
fetchMock.mockImplementation(async () => new Response(new Uint8Array(64).fill(1), { headers: { 'content-type': 'image/png' } }));
}
});
it('makes getDirectoryUrl refuse the official directory in the mode', async () => {
const { getDirectoryUrl, DirectoryError } = await import('@/lib/admin/marketplace-directory');
await setMode(true);
await expect(getDirectoryUrl()).rejects.toBeInstanceOf(DirectoryError);
await expect(getDirectoryUrl()).rejects.toMatchObject({ status: 403 });
await setMode(false);
expect(await getDirectoryUrl()).toBe('https://extensions.bulwarkmail.org');
});
it('leaves the sample contact photos out of the mock server in the mode', async () => {
process.env.DEV_MOCK_JMAP = 'true';
process.env.NEXT_PUBLIC_DEV_MOCK_JMAP = 'true';
try {
const { POST } = await import('@/app/api/dev-jmap/[...path]/route');
const photos = async () => {
const res = await POST(
new NextRequest('http://localhost:3000/api/dev-jmap/api', { method: 'POST', body: JSON.stringify({ using: [], methodCalls: [['ContactCard/get', {}, '0']] }) }),
{ params: Promise.resolve({ path: ['api'] }) },
);
const list = (await res.json()).methodResponses[0][1].list as Array<{ media?: Record<string, { uri?: string }> }>;
return list.flatMap((c) => Object.values(c.media ?? {}).map((m) => m.uri ?? '')).filter((uri) => uri.includes('randomuser.me'));
};
await setMode(false);
expect((await photos()).length).toBeGreaterThan(0);
await setMode(true);
expect(await photos()).toEqual([]);
} finally {
delete process.env.DEV_MOCK_JMAP;
delete process.env.NEXT_PUBLIC_DEV_MOCK_JMAP;
}
});
});
+118
View File
@@ -0,0 +1,118 @@
// @vitest-environment node
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { NextRequest } from 'next/server';
import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry';
import {
isBuiltInHost,
mapsLinksAllowed,
sampleAvatarsAllowed,
senderFaviconsAllowed,
wkdAllowed,
} from '@/lib/outgoing-hosts';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
// Turns "no third-party traffic" on the way an admin does (config.json; the
// browser gets it from /api/config) and checks that every entry of the
// registry is then off, by asking the code that uses each host. A new entry
// without a probe here fails, so the registry can't list a host whose
// switch nobody checked (#903).
vi.hoisted(() => { process.env.NEXT_PUBLIC_APP_VERSION = '1.18.0'; });
vi.mock('@/lib/stalwart/credentials', () => ({
getStalwartCredentials: vi.fn(async () => ({ serverUrl: 'https://mail.example.com', authHeader: 'Basic x', username: 'u' })),
}));
vi.mock('@/lib/security/same-origin', () => ({ rejectCrossOriginRequest: () => null }));
import { configManager } from '@/lib/admin/config-manager';
let dir: string;
const fetchMock = vi.fn(async (_url: unknown, _init?: unknown) => Response.json({ latest: '1.18.0' }));
const fetchedHosts = () => fetchMock.mock.calls.map(([url]) => new URL(String(url)).hostname);
async function setMode(on: boolean) {
writeFileSync(path.join(dir, 'config.json'), JSON.stringify(on ? { noThirdPartyTraffic: true } : {}));
await configManager.load();
// What the browser does with /api/config (hooks/use-config.ts).
const { GET } = await import('@/app/api/config/route');
const config = await (await GET(new NextRequest('https://webmail.example/api/config'))).json();
setNoThirdPartyTraffic(config.noThirdPartyTraffic === true);
}
/** Whether the code behind each registry entry would reach its host right now. */
const probes: Record<string, () => Promise<boolean>> = {
telemetry: async () => {
const { effectiveConsent, loadState, saveState } = await import('@/lib/telemetry');
await saveState({ ...(await loadState()), consent: 'on' });
return (await effectiveConsent()).consent === 'on';
},
'version-check': async () => {
const { checkOnce } = await import('@/lib/version-check');
await checkOnce();
return fetchedHosts().includes('version.telemetry.bulwarkmail.org');
},
'push-relay': async () => {
const { resolvePushRelayOptions, resolveDefaultRelayUrl } = await import('@/lib/push-relays');
return resolvePushRelayOptions({}).some((o) => isBuiltInHost(o.url)) || isBuiltInHost(resolveDefaultRelayUrl({}));
},
'extension-directory': async () => {
const { directoryOff } = await import('@/lib/admin/marketplace-directory');
return !(await directoryOff());
},
connector: async () => {
const { GET } = await import('@/app/api/connector/capabilities/route');
return (await GET()).status === 200;
},
favicons: async () => {
const { GET } = await import('@/app/api/favicon/route');
await GET(new NextRequest(`https://webmail.example/api/favicon?domain=probe${Date.now()}.org`));
return fetchedHosts().includes('icons.duckduckgo.com') || senderFaviconsAllowed();
},
translate: async () => {
const { POST } = await import('@/app/api/translate/route');
await POST(new NextRequest('https://webmail.example/api/translate', {
method: 'POST', body: JSON.stringify({ text: 'Hallo Welt', target: 'en', source: 'de' }),
}));
return fetchedHosts().includes('api.mymemory.translated.net');
},
maps: async () => mapsLinksAllowed(),
wkd: async () => wkdAllowed(),
'dev-avatars': async () => sampleAvatarsAllowed(),
};
beforeAll(() => {
dir = mkdtempSync(path.join(tmpdir(), 'bulwark-3p-mode-'));
process.env.ADMIN_CONFIG_DIR = dir;
process.env.TELEMETRY_DATA_DIR = path.join(dir, 'telemetry');
process.env.VERSION_CHECK_DATA_DIR = path.join(dir, 'version');
delete process.env.NO_THIRD_PARTY_TRAFFIC;
});
afterAll(() => {
setNoThirdPartyTraffic(false);
rmSync(dir, { recursive: true, force: true });
delete process.env.ADMIN_CONFIG_DIR;
delete process.env.TELEMETRY_DATA_DIR;
delete process.env.VERSION_CHECK_DATA_DIR;
});
beforeEach(() => {
fetchMock.mockClear();
vi.stubGlobal('fetch', fetchMock);
});
describe('no third-party traffic mode', () => {
it('has a probe for every registry entry', () => {
expect(Object.keys(probes).sort()).toEqual(OUTGOING_HOSTS.map((h) => h.id).sort());
});
it.each(OUTGOING_HOSTS.map((h) => h.id))('%s is reachable with the mode off', async (id) => {
await setMode(false);
expect(await probes[id]()).toBe(true);
});
it.each(OUTGOING_HOSTS.map((h) => h.id))('%s is off with the mode on', async (id) => {
await setMode(true);
expect(await probes[id]()).toBe(false);
});
});
+163
View File
@@ -0,0 +1,163 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import path from 'node:path';
import {
BUILT_URL_FILES,
NON_REQUEST_HOSTS,
NON_REQUEST_PATHS,
OUTGOING_HOSTS,
isPlaceholderHost,
registeredLiterals,
registryHostnames,
} from '@/lib/admin/outgoing-hosts-registry';
import { BUILT_IN_HOSTNAMES } from '@/lib/outgoing-hosts';
// Every host literal in the shipped source must be a built-in outside host
// in lib/admin/outgoing-hosts-registry.ts (with a switch that turns it off)
// or a known non-request (docs link, namespace, sample text), in the file it
// is listed for. URLs built from a variable must be in a file listed with a
// reason. A new outside host fails here until someone decides which it is
// (#903).
const ROOT = path.resolve(__dirname, '..', '..');
const DIRS = ['app', 'lib', 'stores', 'hooks', 'components', 'contexts', 'i18n'];
const FILES = ['public/sw.js', 'proxy.ts', 'instrumentation.ts', 'instrumentation.node.ts', 'next.config.ts'];
const EXTENSIONS = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
/** scheme://host, for http(s) and ws(s), any case. */
const SCHEME_HOST = /\b(?:https?|wss?):\/\/([a-z0-9.*_-]+)/gi;
/** scheme:// followed by ${...} or the end of the string: a URL built from a variable, or the scheme alone. */
const BUILT_URL = /\b(?:https?|wss?):\/\/(?=\$\{|["'`])/gi;
/** A protocol-relative //host at the start of a string literal. */
const PROTOCOL_RELATIVE = /["'`]\/\/([a-z0-9-]+(?:\.[a-z0-9-]+)+)/gi;
function isTestPath(rel: string): boolean {
return /(^|\/)(__tests__|__mocks__|node_modules)\//.test(rel) || /\.(test|spec)\.[a-z]+$/.test(rel);
}
function walk(rel: string, out: string[]) {
const abs = path.join(ROOT, rel);
for (const name of readdirSync(abs)) {
const child = `${rel}/${name}`;
if (isTestPath(`${child}/`) || name === 'node_modules') continue;
if (statSync(path.join(abs, name)).isDirectory()) walk(child, out);
else if (EXTENSIONS.test(name) && !isTestPath(child)) out.push(child);
}
}
function sourceFiles(): string[] {
const out: string[] = [];
for (const dir of DIRS) walk(dir, out);
for (const file of FILES) {
try { statSync(path.join(ROOT, file)); out.push(file); } catch { /* not in this checkout */ }
}
return out.filter((file) => !NON_REQUEST_PATHS.some((prefix) => file.startsWith(prefix)));
}
type Finding = { kind: 'host'; host: string } | { kind: 'built' };
/** Host literals (lower case) and built URLs in a source text. */
function findings(text: string): Finding[] {
const out: Finding[] = [];
for (const m of text.matchAll(SCHEME_HOST)) out.push({ kind: 'host', host: m[1].toLowerCase() });
for (const m of text.matchAll(PROTOCOL_RELATIVE)) out.push({ kind: 'host', host: m[1].toLowerCase() });
for (const _m of text.matchAll(BUILT_URL)) out.push({ kind: 'built' });
return out;
}
const registered = registeredLiterals();
/** Why a finding in `file` is fine, or null when it isn't accounted for. */
function classify(finding: Finding, file: string): string | null {
if (finding.kind === 'built') return BUILT_URL_FILES.some((b) => b.file === file) ? 'built' : null;
const host = finding.host;
const trimmed = host.replace(/\.{2,}$/, '');
for (const literal of [host, trimmed]) {
if (registered.get(literal)?.has(file)) return 'registry';
if (NON_REQUEST_HOSTS.some((entry) => entry.host === literal && entry.files.includes(file))) return 'non-request';
}
// A registered or allowed host is only fine where it is listed.
if (registered.has(trimmed) || NON_REQUEST_HOSTS.some((entry) => entry.host === trimmed)) return null;
if (isPlaceholderHost(trimmed)) return 'placeholder';
return null;
}
describe('outgoing host literals', () => {
const files = sourceFiles();
const texts = new Map(files.map((file) => [file, readFileSync(path.join(ROOT, file), 'utf8')]));
it('scans the shipped source, service worker and config included, and leaves the tests out', () => {
for (const file of ['public/sw.js', 'next.config.ts', 'app/api/favicon/route.ts', 'app/api/dev-jmap/[...path]/route.ts', 'i18n/request.ts']) {
expect(files).toContain(file);
}
expect(files.some((f) => f.includes('__tests__'))).toBe(false);
expect(files.some((f) => f.startsWith('lib/demo/fixtures/'))).toBe(false);
expect(files.length).toBeGreaterThan(500);
});
it('finds no host or built URL that isn\'t accounted for', () => {
const unknown: string[] = [];
for (const [file, text] of texts) {
for (const finding of findings(text)) {
if (!classify(finding, file)) unknown.push(`${file}: ${finding.kind === 'host' ? finding.host : 'URL built from a variable'}`);
}
}
expect([...new Set(unknown)], 'Add the host to OUTGOING_HOSTS with a switch, or to NON_REQUEST_HOSTS / BUILT_URL_FILES with the reason').toEqual([]);
});
it('lists only hosts and files that are still in the source', () => {
const hostsIn = (file: string) => findings(texts.get(file) ?? '')
.flatMap((f) => (f.kind === 'host' ? [f.host, f.host.replace(/\.{2,}$/, '')] : []));
for (const entry of OUTGOING_HOSTS) {
for (const file of entry.files) {
const found = (entry.literals ?? [entry.host]).some((literal) => hostsIn(file).includes(literal));
expect(found, `${entry.id} in ${file}`).toBe(true);
}
}
for (const entry of NON_REQUEST_HOSTS) {
for (const file of entry.files) expect(hostsIn(file).includes(entry.host), `${entry.host} in ${file}`).toBe(true);
}
for (const { file } of BUILT_URL_FILES) {
expect(findings(texts.get(file) ?? '').some((f) => f.kind === 'built'), `built URL in ${file}`).toBe(true);
}
});
it('keeps the browser\'s host list equal to the registry', () => {
expect([...BUILT_IN_HOSTNAMES].sort()).toEqual(registryHostnames().sort());
});
});
describe('the scan catches', () => {
const unknownIn = (text: string, file = 'lib/new-module.ts') =>
findings(text).filter((f) => !classify(f, file)).map((f) => (f.kind === 'host' ? f.host : 'built'));
it('a new outside host, in any case', () => {
expect(unknownIn(`fetch('https://tracker.fakecdn.io/pixel.gif')`)).toEqual(['tracker.fakecdn.io']);
expect(unknownIn(`fetch("HTTPS://Tracker.FakeCDN.io/p")`)).toEqual(['tracker.fakecdn.io']);
expect(unknownIn(`new WebSocket('wss://live.fakecdn.io/socket')`)).toEqual(['live.fakecdn.io']);
expect(unknownIn(`new WebSocket("ws://live.fakecdn.io")`)).toEqual(['live.fakecdn.io']);
});
it('a protocol-relative host in a string', () => {
expect(unknownIn(`script.src = '//cdn.fakecdn.io/lib.js'`)).toEqual(['cdn.fakecdn.io']);
expect(unknownIn(`// a comment about //cdn.fakecdn.io`)).toEqual([]);
});
it('a URL built from a variable or the scheme alone, outside the listed files', () => {
expect(unknownIn('fetch(`https://${host}/x`)')).toEqual(['built']);
expect(unknownIn(`url.startsWith('https://')`)).toEqual(['built']);
expect(unknownIn('fetch(`https://${host}/x`)', 'lib/validation.ts')).toEqual([]);
});
it('a public IP literal, but not a private or reserved one', () => {
expect(unknownIn(`fetch('http://93.184.216.34/x')`)).toEqual(['93.184.216.34']);
expect(unknownIn(`fetch('http://10.0.0.5:8080'); fetch('http://192.168.1.1'); fetch('http://127.0.0.1')`)).toEqual([]);
});
it('a registered or allowed host in a file it isn\'t listed for', () => {
expect(unknownIn(`fetch('https://icons.duckduckgo.com/ip3/x.ico')`)).toEqual(['icons.duckduckgo.com']);
expect(unknownIn(`<a href="https://github.com/x">`)).toEqual(['github.com']);
expect(unknownIn(`fetch('https://icons.duckduckgo.com/ip3/x.ico')`, 'app/api/favicon/route.ts')).toEqual([]);
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createHash } from 'node:crypto';
import type { InstalledPlugin } from '../plugin-types';
// Copies of the external-mail-warning plugin older than the release that
// steps back on its own keep their send check. While the organisation's
// own warnings are on, the host skips that plugin's hooks by id, so the
// user isn't asked twice; other plugins keep theirs.
const mocks = vi.hoisted(() => ({
hooks: [] as string[],
invokeHook: null as null | ReturnType<typeof vi.fn>,
}));
const CODE = 'export default { activate() {} }';
const HASH = createHash('sha256').update(CODE, 'utf-8').digest('hex');
vi.mock('../plugin-storage', () => ({
pluginStorage: { getCode: async () => CODE, saveCode: async () => {}, deleteCode: vi.fn() },
}));
vi.mock('../plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: vi.fn() }));
vi.mock('../plugin-sandbox/host-bridge', () => ({
createBackgroundInstance: () => {
// The old plugin's check: "Send to external recipients?" answered with Cancel.
const invokeHook = vi.fn(async () => false);
mocks.invokeHook = invokeHook;
return {
initPromise: Promise.resolve({ hooks: mocks.hooks, slots: [], shortcuts: [] }),
invokeHook,
destroy: vi.fn(),
};
},
snapshotRegionalSettings: vi.fn((locale: string) => ({ locale, dateLocale: locale, hour12: false, timeZone: 'UTC', firstDayOfWeek: 1 })),
forEachInstance: vi.fn(),
SandboxInstance: class {},
}));
vi.mock('../plugin-sandbox/host-api', () => ({ cancelPluginDialogs: vi.fn() }));
vi.mock('../plugin-sandbox/shortcuts', () => ({ registerShortcuts: () => () => {} }));
import { loadSandboxedPlugin, unloadSandboxedPlugin, setSandboxStoreAccessor } from '../plugin-sandbox/loader';
import { emailHooks } from '../plugin-hooks';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { usePolicyStore } from '@/stores/policy-store';
function plugin(id: string): InstalledPlugin {
return {
id, name: id, version: '1.5.1', author: 'someone', description: '', type: 'hook',
permissions: ['email:send', 'email:read'], entrypoint: 'index.js', enabled: true, status: 'enabled',
settings: {}, managed: true, adminApproved: true, bundleHash: HASH,
};
}
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
const OUTGOING = { to: ['dave@org-two.test'], cc: [], bcc: [], subject: 'Hi' };
let loaded: string | null = null;
beforeEach(() => {
setSandboxStoreAccessor({ setPluginStatus: () => {} });
mocks.hooks = ['onBeforeEmailSend', 'onNewEmailReceived'];
setExternalMail({});
vi.spyOn(console, 'warn').mockImplementation(() => {});
vi.spyOn(console, 'info').mockImplementation(() => {});
});
afterEach(() => {
if (loaded) unloadSandboxedPlugin(loaded);
loaded = null;
vi.restoreAllMocks();
});
describe('the retired external-mail-warning plugin', () => {
it('asks as before while the core warnings are off', async () => {
loaded = 'external-mail-warning';
await loadSandboxedPlugin(plugin(loaded));
expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed');
expect(mocks.invokeHook).toHaveBeenCalledWith('onBeforeEmailSend', [OUTGOING]);
});
it('loses its send check to the recipient warning, and gets it back once that is off', async () => {
loaded = 'external-mail-warning';
await loadSandboxedPlugin(plugin(loaded));
setExternalMail({ recipientWarning: true });
expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('allowed');
expect(mocks.invokeHook).not.toHaveBeenCalled();
// The toast is about incoming mail: it stays while only the warning is on.
await emailHooks.onNewEmailReceived.emit({ from: [{ email: 'dave@org-two.test' }] } as never);
expect(mocks.invokeHook).toHaveBeenCalledWith('onNewEmailReceived', expect.anything());
setExternalMail({});
expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed');
});
it('loses its new-mail toast to the sender label but keeps its send check', async () => {
loaded = 'external-mail-warning';
await loadSandboxedPlugin(plugin(loaded));
setExternalMail({ senderLabel: true });
await emailHooks.onNewEmailReceived.emit({ from: [{ email: 'dave@org-two.test' }] } as never);
expect(mocks.invokeHook).not.toHaveBeenCalled();
expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed');
});
it('leaves the send checks of other plugins alone', async () => {
loaded = 'reply-all-guardrail';
await loadSandboxedPlugin(plugin(loaded));
setExternalMail({ recipientWarning: true });
expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed');
});
});
@@ -0,0 +1,60 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '../plugin-types';
import { setNoThirdPartyTraffic } from '../third-party-mode';
// #903: the host API's own locks, behind the loader that already refuses
// plugins declaring network access: api.http.fetch and the WKD key lookup
// reach nothing outside while third-party traffic is off.
vi.mock('@/stores/toast-store', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() } }));
import { dispatchApiCall } from '../plugin-sandbox/host-api';
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'net', name: 'Net', version: '1.0.0', author: '', description: '', type: 'hook',
permissions: [], entrypoint: 'index.js', enabled: true, status: 'running', settings: {},
...overrides,
};
}
const fetchSpy = vi.fn(async () => new Response('{}', { headers: { 'content-type': 'application/json' } }));
beforeEach(() => {
fetchSpy.mockClear();
vi.stubGlobal('fetch', fetchSpy);
});
afterEach(() => {
vi.unstubAllGlobals();
setNoThirdPartyTraffic(false);
});
describe('api.http.fetch', () => {
const fetcher = plugin({ permissions: ['http:fetch'], grantedPermissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] });
it('is refused with no third-party traffic, even for a plugin that got started', async () => {
setNoThirdPartyTraffic(true);
await expect(dispatchApiCall(fetcher, 'http.fetch', ['https://api.vendor.example/x'])).rejects.toThrow(/Outside requests are off/);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('reaches the declared origin with the mode off', async () => {
await dispatchApiCall(fetcher, 'http.fetch', ['https://api.vendor.example/x']).catch(() => undefined);
expect(String((fetchSpy.mock.calls[0] as unknown[] | undefined)?.[0])).toMatch(/^https:\/\/api\.vendor\.example\/x/);
});
});
describe('api.crypto.getPublicKeyFromWKD', () => {
const pgp = plugin({ permissions: ['crypto:full'], grantedPermissions: ['crypto:full'] });
it('asks nobody with no third-party traffic', async () => {
setNoThirdPartyTraffic(true);
expect(await dispatchApiCall(pgp, 'crypto.getPublicKeyFromWKD', ['joe@vendor.example'], { privileged: true })).toMatchObject({ status: 'ERROR' });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('asks the recipient\'s domain with the mode off', async () => {
fetchSpy.mockImplementationOnce(async () => new Response('', { status: 404 }));
await dispatchApiCall(pgp, 'crypto.getPublicKeyFromWKD', ['joe@vendor.example'], { privileged: true });
expect(String((fetchSpy.mock.calls[0] as unknown[])[0])).toMatch(/^https:\/\/openpgpkey\.vendor\.example\//);
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* api.host.features tells plugins which of Bulwark's own features replace
* them (externalSenderLabel and externalRecipientWarning: the
* organisation's External label and recipient warning). The host sends it with init and again on every
* change; the runtime keeps one object up to date.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { render, cleanup, waitFor } from '@testing-library/react';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { usePolicyStore } from '@/stores/policy-store';
import {
EXTERNAL_MAIL_WARNING_PLUGIN_ID,
isHookHandedOver,
isSlotHandedOver,
snapshotHostFeatures,
} from '@/lib/plugin-sandbox/host-features';
function setExternalMail(patch: Partial<typeof DEFAULT_ORGANISATION_POLICY.externalMail>) {
usePolicyStore.setState({
policy: {
...DEFAULT_POLICY,
organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } },
},
});
}
beforeEach(() => setExternalMail({}));
describe('snapshotHostFeatures', () => {
it('has one flag per switch', () => {
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: false });
setExternalMail({ senderLabel: true });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: true, externalRecipientWarning: false });
setExternalMail({ recipientWarning: true });
expect(snapshotHostFeatures()).toEqual({ externalSenderLabel: false, externalRecipientWarning: true });
});
});
describe('hand-over of the external-mail-warning plugin', () => {
it('skips its send check with the recipient warning and its toast and banner with the label', () => {
const id = EXTERNAL_MAIL_WARNING_PLUGIN_ID;
expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(false);
expect(isSlotHandedOver(id, 'email-banner')).toBe(false);
setExternalMail({ recipientWarning: true });
expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(true);
expect(isHookHandedOver(id, 'onNewEmailReceived')).toBe(false);
expect(isSlotHandedOver(id, 'email-banner')).toBe(false);
setExternalMail({ senderLabel: true });
expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(false);
expect(isHookHandedOver(id, 'onNewEmailReceived')).toBe(true);
expect(isSlotHandedOver(id, 'email-banner')).toBe(true);
// Its other hooks, and every other plugin, keep running.
expect(isHookHandedOver(id, 'onIdentitiesLoaded')).toBe(false);
expect(isHookHandedOver('reply-all-guardrail', 'onBeforeEmailSend')).toBe(false);
expect(isSlotHandedOver('spam-score', 'email-banner')).toBe(false);
});
});
// ─── Host bridge: init payload and pushes ────────────────────
describe('host bridge', () => {
afterEach(() => { document.body.innerHTML = ''; });
it('puts the features into the init payload and pushes changes to running frames', async () => {
const { createBackgroundInstance } = await import('@/lib/plugin-sandbox/host-bridge');
setExternalMail({ senderLabel: true });
const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never;
const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' });
const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit;
expect(pending?.hostFeatures).toEqual({ externalSenderLabel: true, externalRecipientWarning: false });
// Before init has gone out a change lands in the payload ...
setExternalMail({});
expect(pending?.hostFeatures).toEqual({ externalSenderLabel: false, externalRecipientWarning: 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 } }]);
instance.destroy();
}, 30_000);
});
// ─── Sandbox runtime: api.host.features ──────────────────────
const HOST_ORIGIN = window.location.origin;
function post(source: unknown, origin: string, data: unknown): void {
const ev = new MessageEvent('message', { data, origin });
Object.defineProperty(ev, 'source', { value: source });
window.dispatchEvent(ev);
}
describe('api.host in the sandbox runtime', () => {
let hostWindow: { postMessage: ReturnType<typeof vi.fn> };
const realParent = Object.getOwnPropertyDescriptor(window, 'parent');
const g = globalThis as {
__pluginApi?: { host: { features: { externalSenderLabel: boolean; externalRecipientWarning: boolean }; onFeaturesChange(fn: (f: unknown) => void): () => void } };
__seenAtActivate?: boolean;
};
beforeEach(() => {
vi.resetModules();
hostWindow = { postMessage: vi.fn() };
Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window });
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} });
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
delete g.__pluginApi;
delete g.__seenAtActivate;
if (realParent) Object.defineProperty(window, 'parent', realParent);
});
it('has the value from init in activate and follows host-change', async () => {
const bundle = `module.exports = { activate: function (api) {
globalThis.__seenAtActivate = api.host.features.externalRecipientWarning;
globalThis.__pluginApi = api;
} };`;
const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime');
render(React.createElement(SandboxRuntime));
post(hostWindow, HOST_ORIGIN, {
type: 'init',
payload: {
mode: 'background', pluginId: 'x', tier: 'privileged', locale: 'en',
manifest: { id: 'x', version: '1.0.0', permissions: [], settings: {} },
code: bundle,
hostFeatures: { externalSenderLabel: false, externalRecipientWarning: true },
},
});
await waitFor(() => expect(g.__pluginApi).toBeDefined());
expect(g.__seenAtActivate).toBe(true);
const api = g.__pluginApi!;
const kept = api.host.features;
const changes: unknown[] = [];
api.host.onFeaturesChange((f) => changes.push({ ...(f as object) }));
post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalSenderLabel: true, externalRecipientWarning: false } });
expect(kept.externalRecipientWarning).toBe(false);
expect(kept.externalSenderLabel).toBe(true);
expect(changes).toEqual([{ externalSenderLabel: true, externalRecipientWarning: false }]);
}, 30_000);
});
@@ -102,6 +102,25 @@ function lastStatus() {
return statuses[statuses.length - 1];
}
describe('loadSandboxedPlugin in the no-third-party mode (#903)', () => {
it('refuses a plugin that declares network access and runs one that does not', async () => {
const { setNoThirdPartyTraffic } = await import('../third-party-mode');
mocks.getCode.mockResolvedValue(CODE);
setNoThirdPartyTraffic(true);
try {
await loadSandboxedPlugin(plugin({ permissions: ['http:fetch'], httpOrigins: ['https://api.vendor.example'] }));
expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'error', error: 'third-party-off' });
expect(mocks.createBackgroundInstance).not.toHaveBeenCalled();
expect(getActive('managed-plugin')).toBeUndefined();
await loadSandboxedPlugin(plugin({ permissions: ['email:read'] }));
expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'running' });
} finally {
setNoThirdPartyTraffic(false);
}
});
});
describe('loadSandboxedPlugin bundle sourcing', () => {
it('runs a managed plugin from IndexedDB when the cached copy verifies', async () => {
mocks.getCode.mockResolvedValue(CODE);
@@ -0,0 +1,69 @@
/**
* @vitest-environment node
*
* With no third-party traffic (#903) the page's CSP lists only its own origin
* and the hosts the deployment configured, so anything the in-app switches
* miss (a privileged plugin, a Gravatar URL) is still stopped by the browser.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NextRequest } from 'next/server';
const config: Record<string, unknown> = {};
let policy: Record<string, unknown> = {};
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: vi.fn(async () => {}),
get: vi.fn((key: string, fallback: unknown) => (key in config ? config[key] : fallback)),
getPolicy: vi.fn(() => policy),
},
}));
vi.mock('@/lib/setup/state', () => ({ detectSetupState: vi.fn(() => 'configured') }));
vi.mock('@/lib/admin/csp-frame-origins', () => ({ getEnabledPluginFrameOrigins: vi.fn(async () => []) }));
vi.mock('@/lib/wopi/discovery', () => ({ getWopiEditorOrigins: vi.fn(async () => []) }));
vi.mock('next-intl/middleware', async () => {
const { NextResponse } = await import('next/server');
return { default: () => () => NextResponse.next() };
});
import { proxy } from '@/proxy';
async function csp(): Promise<Record<string, string>> {
const res = await proxy(new NextRequest('http://localhost:3000/en/mail', { headers: { 'sec-fetch-dest': 'document' } }));
const header = res.headers.get('content-security-policy') ?? '';
return Object.fromEntries(header.split('; ').map((d) => [d.split(' ')[0], d.slice(d.indexOf(' ') + 1)]));
}
beforeEach(() => {
for (const key of Object.keys(config)) delete config[key];
policy = { features: { pluginsEnabled: false }, pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }], defaultSidebarApps: [] };
config.jmapServerUrl = 'https://mail.corp.example';
});
describe('page CSP with no third-party traffic', () => {
it('lists only the own origin and the configured hosts', async () => {
config.noThirdPartyTraffic = true;
const d = await csp();
expect(d['connect-src']).toBe("'self' https://mail.corp.example https://push.corp.example");
expect(d['img-src']).toBe("'self' data: blob: https://mail.corp.example https://push.corp.example");
expect(d['font-src']).toBe("'self' data: https://mail.corp.example https://push.corp.example");
expect(d['media-src']).toBe("'self' blob: https://mail.corp.example https://push.corp.example");
expect(d['frame-src']).toContain('https://mail.corp.example');
for (const directive of ['connect-src', 'img-src', 'font-src', 'media-src', 'frame-src']) {
expect(d[directive], directive).not.toMatch(/(^| )https?:( |$)|gravatar|bulwarkmail\.org/);
}
});
it('keeps https: in connect-src only when users may sign in to any server', async () => {
config.noThirdPartyTraffic = true;
config.allowCustomJmapEndpoint = true;
const d = await csp();
expect(d['connect-src']).toMatch(/ https:( |$)/);
expect(d['img-src']).not.toMatch(/ https:( |$)/);
});
it('stays as before with the mode off', async () => {
const d = await csp();
expect(d['img-src']).toBe("'self' data: blob: https:");
expect(d['connect-src']).toContain('https:');
});
});
+1 -1
View File
@@ -17,7 +17,7 @@ describe('purgeSignedOutData', () => {
it("removes the accounts' leftovers and keeps device preferences", () => {
for (const key of [
'email-snapshot', 'pro-tabs', 'search-history-storage', 'calendar-storage',
'files-favorites', 'files-recent-files', 'files-path-stack',
'files-favorites', 'files-recent-files', 'files-path-stack', 'bulwark-delegate-names',
'settings-storage', 'theme-storage', 'locale-storage', 'template-storage', 'files-view-mode',
]) localStorage.setItem(key, 'x');
+49
View File
@@ -16,8 +16,11 @@ import {
revokePushDevice,
waitForWebPushResync,
} from '@/lib/web-push';
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
import { setNoThirdPartyTraffic } from '@/lib/third-party-mode';
const RELAY = 'https://relay.example';
const RELAY_BASE_KEY = `bulwark.push.relayBase.v1.${'acct-1'}`;
const ACCOUNT_ID = 'acct-1';
const THIS_DEVICE = 'aaaaaaaabbbbbbbbccccccccdddddddd';
const OTHER_DEVICE = '11111111222222223333333344444444';
@@ -469,6 +472,52 @@ describe('enableWebPush', () => {
// Registrations made before the client learned about the delivery filter are
// repaired in the background on app start - nobody should have to find the
// settings toggle to stop the spam pushes.
describe('resyncWebPush when the relay changed (#903)', () => {
afterEach(() => setNoThirdPartyTraffic(false));
it('moves a registration made with the built-in relay to the admin relay with no third-party traffic', async () => {
setNoThirdPartyTraffic(true);
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(RELAY_BASE_KEY, DEFAULT_RELAY_BASE_URL);
const client = makeClient([sub('push-old', THIS_DEVICE, { url: `${DEFAULT_RELAY_BASE_URL}/api/push/jmap/${THIS_DEVICE}` })]);
const calls = installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
// A new server record that points at the admin relay replaces the old one.
expect((client.createPushSubscription as ReturnType<typeof vi.fn>).mock.calls[0][0]).toMatchObject({ url: `${RELAY}/api/push/jmap/${THIS_DEVICE}` });
expect(client.destroyed).toContain('push-old');
expect(client.updatePushSubscription).not.toHaveBeenCalled();
expect(localStorage.getItem(RELAY_BASE_KEY)).toBe(RELAY);
expect(calls.some((c) => c.url.startsWith(DEFAULT_RELAY_BASE_URL))).toBe(false);
});
it('treats a registration from before the relay was recorded as the built-in one in that mode', async () => {
setNoThirdPartyTraffic(true);
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
const client = makeClient([sub('push-old', THIS_DEVICE)]);
installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
expect(client.destroyed).toContain('push-old');
});
it('re-registers inside the daily limit when the admin switched relays, and only once', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
localStorage.setItem(SUB_KEY, 'push-old');
localStorage.setItem(RELAY_BASE_KEY, 'https://old-relay.example');
const client = makeClient([sub('push-old', THIS_DEVICE)]);
installFetch({});
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(true);
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
expect(await resyncWebPush({ client, relayBaseUrl: RELAY, inboxOnly: false })).toBe(false);
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
});
});
describe('resyncWebPush', () => {
it('repairs a lost browser subscription for an account that previously opted in', async () => {
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
DEFAULT_ORGANISATION_POLICY,
normalizeDomainList,
parseDomainList,
sanitizeOrganisationPolicy,
} from '@/lib/admin/organisation-policy';
import { applyLitePolicy } from '@/lib/lite-config';
@@ -32,6 +33,14 @@ describe('sanitizeOrganisationPolicy', () => {
expect(normalizeDomainList(['A.org', 'a.org', 'x'])).toEqual(['a.org']);
expect(normalizeDomainList(42)).toBeUndefined();
});
it('takes IDN domains in ASCII form and names what it left out', () => {
expect(parseDomainList('Bücher.de, xn--bcher-kva.de, @partner.example, *.sub.example\nnot a domain, x')).toEqual({
domains: ['xn--bcher-kva.de', 'partner.example', 'sub.example'],
rejected: ['not', 'a', 'domain', 'x'],
});
expect(parseDomainList(['bad_domain.example', 'ok.example'])).toEqual({ domains: ['ok.example'], rejected: ['bad_domain.example'] });
});
});
describe('applyLitePolicy', () => {
+7
View File
@@ -11,6 +11,7 @@
import { getPluginRegistry } from './plugin-registry';
import { sanitizeFrameOrigins } from '../extension-origins';
import { isNoThirdPartyTrafficOn } from './third-party-mode';
export {
isValidFrameOrigin,
@@ -40,6 +41,12 @@ export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
if (now - cachedAt < CACHE_TTL_MS) return cachedOrigins;
try {
// No outside frames at all while the server stays off outside hosts (#903).
if (await isNoThirdPartyTrafficOn()) {
cachedOrigins = [];
cachedAt = now;
return [];
}
const registry = await getPluginRegistry();
// Dev plugins (PLUGIN_DEV_DIR) are served next to the installed ones and
// need their embeds allowed too.
+20 -1
View File
@@ -14,6 +14,8 @@
import { configManager } from './config-manager';
import { getPluginRegistry, getThemeRegistry, type ExtensionSource } from './plugin-registry';
import { listHistory, type HistoryEntry } from './extension-history';
import { isNoThirdPartyTrafficOn } from './third-party-mode';
import { isBuiltInHost } from '@/lib/outgoing-hosts';
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || '';
@@ -21,7 +23,24 @@ export type Channel = 'stable' | 'beta';
export async function getDirectoryUrl(): Promise<string> {
await configManager.ensureLoaded();
return configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
const url = configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
// Defense in depth: the routes ask directoryOff() first and answer 403.
if (isBuiltInHost(url) && await isNoThirdPartyTrafficOn()) throw new DirectoryError(DIRECTORY_OFF_MESSAGE, 403);
return url;
}
export const DIRECTORY_OFF_MESSAGE =
'The official extension directory is off: this server does not contact outside services (NO_THIRD_PARTY_TRAFFIC). Point EXTENSION_DIRECTORY_URL at a self-hosted directory to use the marketplace.';
/**
* Whether the marketplace must not ask its directory: the no-third-party
* mode is on and the directory is the official one (#903). A self-hosted
* directory is the deployment's own and stays reachable.
*/
export async function directoryOff(): Promise<boolean> {
await configManager.ensureLoaded();
const url = configManager.get<string>('extensionDirectoryUrl') || 'https://extensions.bulwarkmail.org';
return isBuiltInHost(url) && await isNoThirdPartyTrafficOn();
}
export function getDirectoryChannel(): Channel {
+30 -7
View File
@@ -11,6 +11,7 @@
*/
import type { EmailTemplate } from '@/lib/template-types';
import { toAsciiDomain } from '@/lib/idn';
/** Details a user fills in once for the organisation signature. */
export const SIGNATURE_FIELDS = ['title', 'department', 'phone', 'mobile'] as const;
@@ -25,7 +26,11 @@ export interface OrganisationPolicy {
senderLabel: boolean;
/** Confirm before sending to recipients outside the organisation domains. */
recipientWarning: boolean;
/** Organisation domains; empty means the domains of the user's identities. Subdomains match. */
/**
* More organisation domains. The domains of the user's own identities
* always count as internal; these are added to them. A domain covers its
* subdomains (example.com covers eu.example.com). See lib/external-mail.ts.
*/
domains: string[];
/** Recipients in the user's contacts don't trigger the warning. */
trustContacts: boolean;
@@ -97,18 +102,36 @@ function count(value: unknown, max: number): number | undefined {
return Math.min(max, Math.max(0, Math.round(value)));
}
/** Lowercased, trimmed, deduplicated domains; "@example.com" and "*.example.com" are taken as example.com. */
export function normalizeDomainList(value: unknown): string[] | undefined {
/**
* A domain list taken apart: the usable domains, lowercased, deduplicated
* and in ASCII form (an IDN like bücher.de becomes xn--bcher-kva.de, the
* form mail servers use), and the entries that aren't domains.
* "@example.com" and "*.example.com" are taken as example.com. Undefined for
* anything that isn't a string or an array.
*/
export function parseDomainList(value: unknown): { domains: string[]; rejected: string[] } | undefined {
const raw = typeof value === 'string' ? value.split(/[\s,;]+/) : Array.isArray(value) ? value : undefined;
if (!raw) return undefined;
const out = new Set<string>();
const rejected: string[] = [];
for (const item of raw) {
if (typeof item !== 'string') continue;
const domain = item.trim().toLowerCase().replace(/^\*\./, '').replace(/^@/, '').replace(/\.+$/, '');
if (domain && DOMAIN_RE.test(domain)) out.add(domain);
if (out.size >= MAX_DOMAINS) break;
const entry = item.trim();
if (!entry) continue;
const bare = entry.toLowerCase().replace(/^\*\./, '').replace(/^@/, '').replace(/\.+$/, '');
const domain = bare ? toAsciiDomain(bare) : null;
if (domain && DOMAIN_RE.test(domain)) {
if (out.size < MAX_DOMAINS) out.add(domain);
} else {
rejected.push(entry);
}
}
return [...out];
return { domains: [...out], rejected };
}
/** The usable domains of a list (see parseDomainList). */
export function normalizeDomainList(value: unknown): string[] | undefined {
return parseDomainList(value)?.domains;
}
function sanitizeLibrary(value: unknown): OrgTemplate[] | undefined {
+241
View File
@@ -0,0 +1,241 @@
/**
* Every outside host Bulwark itself knows about, what talks to it, and what
* turns it off (#903). The admin view (/api/admin/outgoing-hosts) and the
* tests read this; the browser only needs the host names and the switches in
* lib/outgoing-hosts.ts, so none of this prose ends up in client chunks.
*
* lib/__tests__/outgoing-hosts.test.ts scans the source for host literals and
* fails on any that isn't accounted for here, in the file it is listed for.
* lib/__tests__/outgoing-hosts-mode.test.ts turns the mode on and checks
* every entry's switch.
*/
export type OutgoingSide = 'server' | 'browser';
export interface OutgoingHost {
/** Stable id, used by the tests and the admin list. */
id: string;
/** The host as people should read it. */
host: string;
/** How the host appears in the source after the scheme, when it differs from `host`. */
literals?: string[];
/** The files that may name it. */
files: string[];
/** Who opens the connection: this server, or the user's browser. */
side: OutgoingSide;
/** What uses it, in one sentence. */
usedBy: string;
/** How to turn it off without the mode. */
off: string;
/** What the mode does instead. */
inMode: string;
/** Also present in Bulwark Lite. */
lite: boolean;
}
export const OUTGOING_HOSTS: readonly OutgoingHost[] = [
{
id: 'telemetry',
host: 'telemetry.bulwarkmail.org',
files: ['lib/telemetry/types.ts'],
side: 'server',
usedBy: 'Anonymous daily heartbeat, sent only after an admin turned telemetry on.',
off: 'Off by default. BULWARK_TELEMETRY=off, or Admin > Telemetry.',
inMode: 'Never sent, whatever the telemetry setting says.',
lite: false,
},
{
id: 'version-check',
host: 'version.telemetry.bulwarkmail.org',
files: ['lib/version-check/types.ts'],
side: 'server',
usedBy: 'Hourly check for a newer Bulwark release, shown to admins.',
off: 'BULWARK_UPDATE_CHECK=off, or BULWARK_UPDATE_CHECK_URL= (empty).',
inMode: 'Not checked; Admin > Version says why. Checks resume when the switch goes off.',
lite: false,
},
{
id: 'push-relay',
host: 'notifications.relay.bulwarkmail.org',
files: ['lib/push-relays.ts', 'app/api/dev-jmap/[...path]/route.ts'],
side: 'browser',
usedBy: 'Built-in Web Push relay that wakes the browser for new mail.',
off: 'Add your own relays under Admin > User policy > Push relays and lock the choice.',
inMode: 'Not offered. Push works only with a relay the admin configured; existing registrations move to it or are removed.',
lite: false,
},
{
id: 'extension-directory',
host: 'extensions.bulwarkmail.org',
files: ['lib/admin/directory-keys.ts', 'lib/admin/marketplace-directory.ts', 'lib/admin/types.ts'],
side: 'server',
usedBy: 'Marketplace browsing, installs and update checks in the admin panel.',
off: 'EXTENSION_DIRECTORY_URL pointing at a self-hosted directory.',
inMode: 'Off, unless extensionDirectoryUrl points at another host.',
lite: false,
},
{
id: 'connector',
host: 'connector.bulwarkmail.org',
files: ['lib/admin/types.ts', 'lib/connector/registry.ts'],
side: 'server',
usedBy: 'Links in the docs and the directory that open this instance; the connector asks this server what it can open.',
off: 'CONNECTOR_ENABLED=false.',
inMode: 'The capabilities endpoint the connector asks answers 404.',
lite: false,
},
{
id: 'favicons',
host: 'icons.duckduckgo.com',
files: ['app/api/favicon/route.ts'],
side: 'server',
usedBy: 'Sender icons next to messages, fetched by /api/favicon.',
off: 'Users can turn sender icons off in Settings > Message list.',
inMode: 'Avatars show initials; /api/favicon answers 403 before any request.',
lite: false,
},
{
id: 'translate',
host: 'api.mymemory.translated.net',
files: ['app/api/translate/route.ts'],
side: 'server',
usedBy: 'Default provider of /api/translate, which the Translate plugin calls.',
off: 'Set LIBRETRANSLATE_URL to your own LibreTranslate and pick it in the plugin.',
inMode: 'Refused with 403; LibreTranslate at LIBRETRANSLATE_URL still works.',
lite: false,
},
{
id: 'maps',
host: 'www.google.com',
files: ['lib/event-links.ts'],
side: 'browser',
usedBy: 'Tapping the address of a calendar event opens Google Maps.',
off: 'Nothing else turns it off; it is a link the user taps.',
inMode: 'The address is plain text with a copy button.',
lite: true,
},
{
id: 'wkd',
host: 'openpgpkey.<recipient domain>',
literals: ['openpgpkey.'],
files: ['lib/plugin-sandbox/host-api.ts'],
side: 'browser',
usedBy: 'OpenPGP key lookup (WKD) at the recipient\'s domain, for PGP plugins.',
off: 'Don\'t install a PGP plugin that looks keys up.',
inMode: 'The lookup is refused.',
lite: false,
},
{
id: 'dev-avatars',
host: 'randomuser.me',
literals: ['randomuser.me', 'img.freepik.com'],
files: ['components/ui/avatar.tsx', 'app/api/dev-jmap/[...path]/route.ts'],
side: 'browser',
usedBy: 'Sample sender and contact photos, only with the built-in mock server (DEV_MOCK_JMAP=true).',
off: 'Don\'t run the mock server.',
inMode: 'Not shown.',
lite: false,
},
];
/**
* Hosts in the source that Bulwark never connects to on its own: docs links,
* XML namespaces, credits, examples in comments and sample text. Each is
* allowed only in the files named, so the same host turning up somewhere new
* has to be looked at again.
*/
export const NON_REQUEST_HOSTS: readonly { host: string; files: readonly string[]; why: string }[] = [
{ host: 'bulwarkmail.org', why: 'docs links and comments', files: ['app/(main)/admin/_pages/telemetry.tsx', 'instrumentation.node.ts', 'lib/telemetry/state.ts', 'lib/telemetry/types.ts'] },
{ host: 'bulwarkmail.com', why: 'JMAP capability URI', files: ['lib/jmap/client.ts'] },
{ host: 'extensions.bulwarkmail.org', why: 'signed statement type URIs', files: ['lib/admin/directory-keys.ts'] },
{ host: 'www.w3.org', why: 'XML namespaces', files: ['app/(main)/setup/page.tsx', 'components/ui/flag-icons.tsx', 'lib/favicon-badge.ts'] },
{ host: 'creativecommons.org', why: 'licence metadata inside an SVG', files: ['components/ui/flag-icons.tsx'] },
{ host: 'github.com', why: 'source code link (AGPL) and SVG metadata', files: ['components/settings/about-data-settings.tsx', 'components/ui/flag-icons.tsx'] },
{ host: 'www.rfc-editor.org', why: 'comments', files: ['lib/jmap/client.ts'] },
{ host: 'devguide.calconnect.org', why: 'comment', files: ['lib/jmap/client.ts'] },
{ host: 'stalw.art', why: 'comment', files: ['app/api/auth/impersonate/route.ts'] },
{ host: 'tabler.io', why: 'icon credit link', files: ['app/(main)/admin/_pages/policy.tsx'] },
{ host: 'www.youtube-nocookie.com', why: 'example in a comment', files: ['lib/plugin-types.ts'] },
{ host: 'bulwark-webmail.vercel.app', why: 'sample message text', files: ['stores/email-store.ts'] },
{ host: 'randomuser.me', why: 'docs links in a comment of the mock server', files: ['app/api/dev-jmap/[...path]/route.ts'] },
];
/**
* Files that build an http(s) URL from a variable or name the scheme alone
* (for checks and placeholders). The host there comes from configuration,
* user input or the data around it, never from a built-in default; each is
* listed with the reason.
*/
export const BUILT_URL_FILES: readonly { file: string; why: string }[] = [
{ file: 'app/(main)/admin/_pages/marketplace-detail.tsx', why: "link to an extension's source host, from the directory's answer" },
{ file: 'app/(main)/setup/page.tsx', why: "the instance's own origin, and scheme checks of the admin-entered server URL" },
{ file: 'app/api/plugin-secrets/route.ts', why: "scheme check of a credential's server URL" },
{ file: 'components/calendar/ical-subscription-modal.tsx', why: "webcal:// rewritten to https:// for a feed the user enters" },
{ file: 'lib/admin/branding-asset.ts', why: "scheme checks of admin-configured branding URLs" },
{ file: 'lib/auth/pair-bundle.ts', why: "scheme checks of the paired server URL" },
{ file: 'lib/calendar-subscription-sync.ts', why: "webcal:// rewritten to https:// for a feed the user subscribed to" },
{ file: 'lib/email-sanitization.ts', why: "comment about links in mail" },
{ file: 'lib/idn.ts', why: "parses a host name the user typed" },
{ file: 'lib/plugin-sandbox/connect-dialog.ts', why: "adds the scheme to a server address the user enters" },
{ file: 'lib/plugin-sandbox/host-api.ts', why: "WKD lookup at the recipient's domain (registered as wkd), and an error message" },
{ file: 'lib/validation.ts', why: "scheme checks" },
{ file: 'lib/vcard.ts', why: "scheme checks of contact URLs" },
{ file: 'stores/calendar-store.ts', why: "webcal:// rewritten to https:// for a feed the user subscribed to" },
];
/** Folders whose host literals are sample mail content, never requests. */
export const NON_REQUEST_PATHS: readonly string[] = [
'lib/demo/fixtures/',
];
const RESERVED_TLDS = new Set(['example', 'test', 'invalid', 'localhost', 'local', 'internal', 'lan']);
/** IPv4 literals in private, loopback, link-local, shared, documentation and other reserved ranges. */
export function isReservedIPv4(ip: string): boolean {
const parts = ip.split('.').map(Number);
if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return false;
const [a, b, c] = parts;
return a === 0 || a === 10 || a === 127 || a >= 224
|| (a === 100 && b >= 64 && b <= 127)
|| (a === 169 && b === 254)
|| (a === 172 && b >= 16 && b <= 31)
|| (a === 192 && b === 168)
|| (a === 192 && b === 0 && (c === 0 || c === 2))
|| (a === 198 && (b === 18 || b === 19))
|| (a === 198 && b === 51 && c === 100)
|| (a === 203 && b === 0 && c === 113);
}
/**
* Hosts that can't be anyone else's: example and reserved names, single
* labels, private or reserved IP literals and CSP wildcards. Placeholders and
* tests use them. A public IP literal is not one.
*/
export function isPlaceholderHost(raw: string): boolean {
const host = raw.toLowerCase().replace(/:\d+$/, '').replace(/\.+$/, '');
if (/^[\d.]+$/.test(host)) return isReservedIPv4(host);
if (!host.includes('.') || host.includes('*')) return true;
const labels = host.split('.');
const tld = labels[labels.length - 1];
if (RESERVED_TLDS.has(tld)) return true;
return /(^|\.)example\.(com|org|net)$/.test(host);
}
/** Every host literal the registry accounts for, with the files that may name it. */
export function registeredLiterals(): Map<string, Set<string>> {
const out = new Map<string, Set<string>>();
for (const entry of OUTGOING_HOSTS) {
for (const literal of entry.literals ?? [entry.host]) {
const files = out.get(literal) ?? new Set<string>();
for (const file of entry.files) files.add(file);
out.set(literal, files);
}
}
return out;
}
/** The exact host names of the registry, which the browser module repeats. */
export function registryHostnames(): string[] {
return OUTGOING_HOSTS.flatMap((entry) => entry.literals ?? [entry.host]).filter((h) => !h.endsWith('.'));
}
+122
View File
@@ -0,0 +1,122 @@
import type { SettingsPolicy } from './types';
import { parseJmapServers } from './jmap-servers';
import { isBuiltInHost } from '@/lib/outgoing-hosts';
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
/**
* What stays reachable with "no third-party traffic" on: the hosts the
* deployment configured itself, plus the addresses users type in themselves.
* Shown on Organisation > Security so an admin can check the list against
* what they expect, and turned into the page's CSP in the mode (#903).
*/
export interface ReachableHost {
/** What the host is for, e.g. "JMAP server". */
kind: string;
/** The host (and port when it isn't the default), or "any address your users enter". */
host: string;
/** scheme://host[:port], for the CSP; absent for addresses users enter. */
origin?: string;
/** Where it was configured, e.g. "JMAP_SERVER_URL" or "User policy > Push relays". */
setIn: string;
/** Who connects: this server, the browser, or both. */
side: 'server' | 'browser' | 'both';
/** Something the admin should know about this line. */
note?: string;
}
export const USER_ENTERED = 'any address your users enter';
function parse(url: unknown): URL | null {
if (typeof url !== 'string' || !url.trim()) return null;
try {
const parsed = new URL(url.trim());
return parsed.protocol === 'https:' || parsed.protocol === 'http:' ? parsed : null;
} catch {
return null;
}
}
export interface ReachableInput {
get: (key: string) => unknown;
policy: Pick<SettingsPolicy, 'pushRelays' | 'pushRelayUrl' | 'defaultSidebarApps'> & { features?: Partial<SettingsPolicy['features']> };
env: Record<string, string | undefined>;
/** The relay the browser bundle was built with (NEXT_PUBLIC_PUSH_RELAY_URL inlined at build time). */
buildRelayUrl?: string;
}
const BRANDING_KEYS = [
'faviconUrl', 'pwaIconUrl', 'pwaScreenshotMobileUrl', 'pwaScreenshotDesktopUrl',
'appLogoLightUrl', 'appLogoDarkUrl', 'loginLogoLightUrl', 'loginLogoDarkUrl',
] as const;
export function listReachableHosts({ get, policy, env, buildRelayUrl = DEFAULT_RELAY_BASE_URL }: ReachableInput): ReachableHost[] {
const out: ReachableHost[] = [];
const seen = new Set<string>();
const add = (kind: string, url: unknown, setIn: string, side: ReachableHost['side'], note?: string) => {
const parsed = parse(url);
// A built-in host listed here would be off in the mode anyway; leaving
// it out keeps the list to what really stays reachable.
if (!parsed || isBuiltInHost(parsed.href)) return;
const host = parsed.host.toLowerCase();
const key = `${kind}|${host}`;
if (seen.has(key)) return;
seen.add(key);
out.push({ kind, host, origin: parsed.origin, setIn, side, ...(note ? { note } : {}) });
};
add('JMAP server', get('jmapServerUrl'), 'JMAP_SERVER_URL', 'both');
for (const server of parseJmapServers(get('jmapServers'))) {
add('JMAP server', server.url, `JMAP_SERVERS (${server.label})`, 'both');
add('OAuth issuer', server.oauth?.issuerUrl, `JMAP_SERVERS (${server.label})`, 'both');
add('OAuth issuer', server.oauth?.endSessionUrl, `JMAP_SERVERS (${server.label})`, 'browser');
}
if (get('oauthEnabled') === true) {
add('OAuth issuer', get('oauthIssuerUrl'), 'OAUTH_ISSUER_URL', 'both');
add('OAuth issuer', get('oauthAuthorizeUrl'), 'OAUTH_AUTHORIZE_URL', 'browser');
add('OAuth issuer', get('oauthEndSessionUrl'), 'OAUTH_END_SESSION_URL', 'browser');
}
for (const relay of policy.pushRelays ?? []) add('Push relay', relay?.url, 'User policy > Push relays', 'browser');
add('Push relay', policy.pushRelayUrl, 'User policy > Push relays', 'browser');
// The relay browsers offer as the default is the one inlined when the
// bundle was built. A different value in the running environment does
// nothing for them, so it is listed with a warning.
add('Push relay', buildRelayUrl, 'NEXT_PUBLIC_PUSH_RELAY_URL (build)', 'browser');
const runtimeRelay = parse(env.NEXT_PUBLIC_PUSH_RELAY_URL);
if (runtimeRelay && runtimeRelay.origin !== parse(buildRelayUrl)?.origin) {
add('Push relay', runtimeRelay.href, 'NEXT_PUBLIC_PUSH_RELAY_URL (environment)', 'browser',
'Set in the environment only: browsers use the relay this build was made with. Rebuild, or add it under User policy > Push relays.');
}
add('Extension directory', get('extensionDirectoryUrl'), 'EXTENSION_DIRECTORY_URL', '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');
for (const key of BRANDING_KEYS) add('Branding image', get(key), key, 'browser');
// User-directed: what users type in themselves stays allowed.
if (get('allowCustomJmapEndpoint') === true) {
out.push({ kind: 'JMAP server', host: USER_ENTERED, setIn: 'ALLOW_CUSTOM_JMAP_ENDPOINT', side: 'both',
note: 'Users can sign in to any server, so the browser may connect to any https host.' });
}
if (policy.features?.sidebarAppsEnabled !== false) {
out.push({ kind: 'Sidebar apps users add', host: USER_ENTERED, setIn: 'Users, in Settings > Apps', side: 'browser' });
}
if (policy.features?.calendarEnabled !== false) {
out.push({ kind: 'Calendar subscriptions', host: USER_ENTERED, setIn: 'Users, in Calendar', side: 'server' });
}
return out;
}
/**
* The Content-Security-Policy sources of the page in the no-third-party mode:
* 'self' and the origins above. With custom JMAP endpoints allowed, the
* browser must reach whatever server a user signs in to, so connect-src
* keeps https: (the other directives stay narrow).
*/
export function noThirdPartyCspSources(hosts: ReachableHost[], allowCustomJmapEndpoint: boolean): { connect: string[]; other: string[] } {
const origins = [...new Set(hosts.flatMap((h) => (h.origin ? [h.origin] : [])))];
return {
connect: allowCustomJmapEndpoint ? [...origins, 'https:'] : origins,
other: origins,
};
}
+2
View File
@@ -69,6 +69,8 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
page: 'org-templates',
modes: ['locked'],
},
{ key: 'delegation', label: 'Delegates', description: 'Hide removes Settings > Sharing > Delegates, so users can no longer give someone access to mail, calendars and contacts in one step.', category: 'Sharing', type: 'boolean', page: 'org-sharing', modes: ['hidden'] },
{ key: 'sharingRights', label: 'Rights users can give', description: 'Untick "write" and every share dialog in Bulwark (folders, calendars, address books, files, Delegates) offers read access only.', category: 'Sharing', type: 'enum', allowedValues: ['read', 'write'], page: 'org-sharing', modes: ['allowedValues'] },
];
export function restrictionKeysForPage(page: AdminPageId): RestrictionKeyDef[] {
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from 'next/server';
import { configManager } from './config-manager';
import { parseNoThirdPartyEnv, parseNoThirdPartyValue } from '@/lib/third-party-mode';
/**
* The "no third-party traffic" switch (#903) on the server.
*
* It is config, not policy: instance-wide, because most of what it stops
* (telemetry, the version check, the extension directory) runs for no
* particular user. NO_THIRD_PARTY_TRAFFIC in the environment wins over
* config.json in one direction only: an env "on" can't be switched off from
* the admin panel, so a deployment that pins it stays closed.
*/
export type NoThirdPartySource = 'env' | 'admin' | 'default';
export function noThirdPartyTrafficState(): { on: boolean; source: NoThirdPartySource } {
if (parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) return { on: true, source: 'env' };
const entry = configManager.getAllWithSources().noThirdPartyTraffic;
// config.json may hold "true", "1", "yes" or "on" as a string.
const on = parseNoThirdPartyValue(entry?.value);
return { on, source: entry?.source === 'admin' ? 'admin' : on ? 'env' : 'default' };
}
/** Whether every built-in outside host is off. Loads the config first. */
export async function isNoThirdPartyTrafficOn(): Promise<boolean> {
if (parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) return true;
await configManager.ensureLoaded();
return parseNoThirdPartyValue(configManager.get<unknown>('noThirdPartyTraffic', false));
}
/** The answer a route gives instead of contacting an outside host. */
export function thirdPartyRefusal(what: string): NextResponse {
return NextResponse.json(
{ error: `${what} is off: this server doesn't contact outside services (NO_THIRD_PARTY_TRAFFIC).`, code: 'no-third-party-traffic' },
{ status: 403, headers: { 'Cache-Control': 'no-store' } },
);
}
+7
View File
@@ -356,6 +356,13 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
// white-label or self-hosted deployment at its own connector.
connectorEnabled: { envVar: 'CONNECTOR_ENABLED', type: 'boolean', defaultValue: true },
connectorUrl: { envVar: 'CONNECTOR_URL', type: 'url', defaultValue: 'https://connector.bulwarkmail.org' },
// Fail-closed "no third-party traffic" mode (#903): every built-in outside
// host in lib/outgoing-hosts.ts is off (telemetry, version check, the
// built-in push relay, the official extension directory, the connector,
// sender favicons, MyMemory, maps links, WKD) and plugins that declare
// network access don't run. Instance-wide on purpose. An env "on" can't be
// switched off in the admin panel (lib/admin/third-party-mode.ts).
noThirdPartyTraffic: { envVar: 'NO_THIRD_PARTY_TRAFFIC', type: 'boolean', defaultValue: false },
// WOPI document editing (#425). `wopiClientUrl` is the editor's base URL
// (Collabora Online / OnlyOffice / EuroOffice, ...); discovery is fetched
// from `<url>/hosting/discovery` unless the URL already carries a path.
+1
View File
@@ -64,6 +64,7 @@ const configValidators = {
embeddedMode: isBoolean,
parentOrigin: isString,
sourceCodeUrl: isString,
noThirdPartyTraffic: isBoolean,
} satisfies Validators<ConfigData>;
/**
+233
View File
@@ -0,0 +1,233 @@
import { describe, expect, it } from 'vitest';
import {
allowedLevels,
buildGrantChanges,
buildRevokeChanges,
changeStartFor,
selectTargets,
summarizeDelegates,
} from '../delegation';
import type { ShareTarget } from '@/lib/jmap/share-batch';
import { ADDRESS_BOOK_PRESETS, CALENDAR_PRESETS, FILE_PRESETS, MAILBOX_PRESETS } from '@/lib/jmap/share-rights';
const CAROL = 'ck';
const BOB = 'cj';
// A 1.0-style read grant of a folder: keywords come back with seen.
const MAILBOX_READ_STORED = { ...MAILBOX_PRESETS.read, maySetKeywords: true };
function targets(): ShareTarget[] {
return [
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', role: 'inbox', shareWith: {} },
{ kind: 'mailbox', accountId: 'ci', id: 'b', name: 'Deleted Items', role: 'trash', shareWith: {} },
{ kind: 'mailbox', accountId: 'ci', id: 'f', name: 'Projects', role: null, shareWith: { [BOB]: MAILBOX_PRESETS.readWrite } },
{ kind: 'calendar', accountId: 'ci', id: 'c1', name: 'Work', isDefault: false, shareWith: {} },
{ kind: 'calendar', accountId: 'ci', id: 'c2', name: 'Personal', isDefault: true, shareWith: {} },
{ kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', isDefault: true, shareWith: {} },
{ kind: 'addressBook', accountId: 'ci', id: 'ts', name: 'Trusted Senders', isDefault: false, shareWith: {} },
{ kind: 'file', accountId: 'ci', id: 'n1', name: 'report.pdf', shareWith: { [CAROL]: FILE_PRESETS.read } },
];
}
const ids = (list: { kind: string; id: string }[]) => list.map((t) => `${t.kind}:${t.id}`);
describe('selectTargets', () => {
it('takes every folder, the default calendar and the default address book by default', () => {
expect(ids(selectTargets(targets(), { mail: 'all', calendars: 'default', addressBooks: 'default' }))).toEqual([
'mailbox:a', 'mailbox:b', 'mailbox:f', 'calendar:c2', 'addressBook:ab',
]);
});
it('takes only the Inbox, every calendar, and no address book', () => {
expect(ids(selectTargets(targets(), { mail: 'inbox', calendars: 'all', addressBooks: 'none' }))).toEqual([
'mailbox:a', 'calendar:c1', 'calendar:c2',
]);
});
it('leaves out the trusted senders list and files even with "all"', () => {
const picked = ids(selectTargets(targets(), { mail: 'none', calendars: 'none', addressBooks: 'all' }));
expect(picked).toEqual(['addressBook:ab']);
});
it('uses the first calendar when none is marked default', () => {
const list = targets().map((t) => (t.kind === 'calendar' ? { ...t, isDefault: false } : t));
expect(ids(selectTargets(list, { mail: 'none', calendars: 'default', addressBooks: 'none' }))).toEqual(['calendar:c1']);
});
});
describe('buildGrantChanges', () => {
it('builds read patches per type', () => {
const changes = buildGrantChanges(targets(), CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'read');
expect(changes).toEqual([
{ kind: 'mailbox', accountId: 'ci', id: 'a', name: 'Inbox', principalId: CAROL, rights: MAILBOX_PRESETS.read },
{ kind: 'calendar', accountId: 'ci', id: 'c2', name: 'Personal', principalId: CAROL, rights: CALENDAR_PRESETS.read },
{ kind: 'addressBook', accountId: 'ci', id: 'ab', name: 'Contacts', principalId: CAROL, rights: ADDRESS_BOOK_PRESETS.read },
]);
});
it('builds read and write patches that never let a delegate send or re-share', () => {
const changes = buildGrantChanges(targets(), CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'default' }, 'write');
const [mail, calendar, book] = changes.map((c) => c.rights as unknown as Record<string, boolean>);
expect(mail).toMatchObject({ mayReadItems: true, mayAddItems: true, mayRemoveItems: true, maySetKeywords: true, maySubmit: false, mayShare: false });
expect(calendar).toMatchObject({ mayReadItems: true, mayWriteAll: true, mayShare: false });
expect(book).toMatchObject({ mayRead: true, mayWrite: true, mayShare: false });
});
it('takes away what a change leaves out, but never files', () => {
const list = targets();
list[2].shareWith[CAROL] = MAILBOX_READ_STORED; // Projects
list[3].shareWith[CAROL] = CALENDAR_PRESETS.read; // Work
const changes = buildGrantChanges(list, CAROL, { mail: 'inbox', calendars: 'default', addressBooks: 'none' }, 'read', { replace: true });
expect(changes.filter((c) => c.rights === null).map((c) => `${c.kind}:${c.id}`)).toEqual(['mailbox:f', 'calendar:c1']);
expect(changes.some((c) => c.kind === 'file')).toBe(false);
});
it('takes nothing away without replace', () => {
const list = targets();
list[3].shareWith[CAROL] = CALENDAR_PRESETS.read;
const changes = buildGrantChanges(list, CAROL, { mail: 'inbox', calendars: 'none', addressBooks: 'none' }, 'read');
expect(changes.every((c) => c.rights !== null)).toBe(true);
});
});
describe('buildRevokeChanges', () => {
it('removes the person from everything, files included, and leaves others alone', () => {
const list = targets();
list[0].shareWith[CAROL] = MAILBOX_READ_STORED;
list[4].shareWith[CAROL] = CALENDAR_PRESETS.readWrite;
const changes = buildRevokeChanges(list, CAROL);
expect(changes.map((c) => [`${c.kind}:${c.id}`, c.principalId, c.rights])).toEqual([
['mailbox:a', CAROL, null],
['calendar:c2', CAROL, null],
['file:n1', CAROL, null],
]);
});
});
describe('summarizeDelegates', () => {
it('groups grants by person and counts each type', () => {
const list = targets();
list[0].shareWith[CAROL] = MAILBOX_READ_STORED;
list[1].shareWith[CAROL] = MAILBOX_READ_STORED;
list[4].shareWith[CAROL] = CALENDAR_PRESETS.read;
const summary = summarizeDelegates(list);
expect(summary.map((s) => s.principalId)).toEqual([CAROL, BOB]);
const carol = summary[0];
expect(carol.level).toBe('read');
expect(carol.mail).toEqual({ shared: 2, total: 3, inbox: true });
expect(carol.calendars).toEqual({ shared: 1, total: 2 });
expect(carol.addressBooks).toEqual({ shared: 0, total: 1 });
expect(carol.files).toBe(1);
expect(summary[1]).toMatchObject({ level: 'write', mail: { shared: 1, total: 3, inbox: false } });
});
it('says "mixed" when read and write grants meet, and leaves out excluded ids', () => {
const list = targets();
list[0].shareWith[CAROL] = MAILBOX_PRESETS.readWrite;
const summary = summarizeDelegates(list, [BOB]);
expect(summary.map((s) => [s.principalId, s.level])).toEqual([[CAROL, 'mixed']]);
});
it('gives a free/busy-only calendar no level', () => {
const list = targets().filter((t) => t.kind !== 'file' && t.id !== 'f');
list.find((t) => t.id === 'c1')!.shareWith[CAROL] = CALENDAR_PRESETS.freeBusy;
expect(summarizeDelegates(list)[0].level).toBeNull();
});
});
describe('changeStartFor', () => {
const withCarol = (list: ShareTarget[], ids: string[], rights: object) => {
for (const t of list) if (ids.includes(t.id)) t.shareWith[CAROL] = rights as never;
return list;
};
it('starts from exact matches: every folder, the default calendar, read', () => {
const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_READ_STORED);
withCarol(list, ['c2'], CALENDAR_PRESETS.read);
expect(changeStartFor(list, CAROL)).toEqual({ scope: { mail: 'all', calendars: 'default', addressBooks: 'none' }, level: 'read' });
});
it('starts from Inbox only and all calendars, read and write', () => {
const list = withCarol(targets(), ['a'], MAILBOX_PRESETS.readWrite);
withCarol(list, ['c1', 'c2'], CALENDAR_PRESETS.readWrite);
expect(changeStartFor(list, CAROL)).toEqual({ scope: { mail: 'inbox', calendars: 'all', addressBooks: 'none' }, level: 'write' });
});
it('keeps mail as it is when a folder was added after "all", and the patch leaves mail alone', () => {
// Inbox and Trash shared, Projects created later.
const list = withCarol(targets(), ['a', 'b'], MAILBOX_READ_STORED);
const start = changeStartFor(list, CAROL);
expect(start.scope.mail).toBe('keep');
const changes = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'default' }, 'read', { replace: true });
expect(changes.filter((c) => c.kind === 'mailbox')).toEqual([]);
});
it('keeps one shared non-Inbox folder as it is instead of turning it into the Inbox', () => {
const list = withCarol(targets(), ['b'], MAILBOX_READ_STORED);
expect(changeStartFor(list, CAROL).scope.mail).toBe('keep');
});
it('keeps a non-default calendar as it is', () => {
const list = withCarol(targets(), ['c1'], CALENDAR_PRESETS.read);
const start = changeStartFor(list, CAROL);
expect(start.scope.calendars).toBe('keep');
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
});
it('keeps free/busy only as it is, until the user picks a scope', () => {
const list = withCarol(targets(), ['c2'], CALENDAR_PRESETS.freeBusy);
const start = changeStartFor(list, CAROL);
expect(start.scope.calendars).toBe('keep');
expect(start.level).toBe('keep');
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
// Picking "Default calendar" with read replaces it.
const picked = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'default' }, 'read', { replace: true });
expect(picked.map((c) => [c.id, c.rights])).toEqual([['c2', CALENDAR_PRESETS.read]]);
});
it('keeps manager rights untouched', () => {
const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_PRESETS.manager);
const start = changeStartFor(list, CAROL);
expect(start).toEqual({ scope: { mail: 'keep', calendars: 'none', addressBooks: 'none' }, level: 'keep' });
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
});
it('keeps mixed levels per type: grants stay, anything added gets that type\'s level', () => {
const list = withCarol(targets(), ['a', 'b', 'f'], MAILBOX_PRESETS.readWrite);
withCarol(list, ['c2'], CALENDAR_PRESETS.read);
const start = changeStartFor(list, CAROL);
expect(start).toEqual({ scope: { mail: 'all', calendars: 'default', addressBooks: 'none' }, level: 'keep' });
expect(buildGrantChanges(list, CAROL, start.scope, start.level, { replace: true })).toEqual([]);
// Widening calendars to all adds Work at the calendars' level (read), not mail's (write).
const wider = buildGrantChanges(list, CAROL, { ...start.scope, calendars: 'all' }, 'keep', { replace: true });
expect(wider.map((c) => [c.id, c.rights])).toEqual([['c1', CALENDAR_PRESETS.read]]);
});
it('counts a shared trusted senders book as something the dialog cannot express', () => {
const list = withCarol(targets(), ['ab', 'ts'], ADDRESS_BOOK_PRESETS.read);
expect(changeStartFor(list, CAROL).scope.addressBooks).toBe('keep');
});
it('clamps a write delegate to read when the admin allows only read', () => {
const list = withCarol(targets(), ['a'], MAILBOX_PRESETS.readWrite);
expect(changeStartFor(list, CAROL, ['read'])).toEqual({ scope: { mail: 'inbox', calendars: 'none', addressBooks: 'none' }, level: 'read' });
// Mixed with write starts at read too, so nothing keeps write by default.
withCarol(list, ['c2'], CALENDAR_PRESETS.read);
expect(changeStartFor(list, CAROL, ['read']).level).toBe('read');
});
});
describe('allowedLevels', () => {
it('offers both without a restriction', () => {
expect(allowedLevels(null)).toEqual(['read', 'write']);
});
it('offers only what the admin allows', () => {
expect(allowedLevels(['read'])).toEqual(['read']);
expect(allowedLevels(['write'])).toEqual(['write']);
});
it('falls back to read when the list names neither', () => {
expect(allowedLevels(['manager'])).toEqual(['read']);
});
});
@@ -0,0 +1,37 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { forgetPrincipals, rememberPrincipals, rememberedPrincipals } from '../known-principals';
import type { Principal } from '@/lib/jmap/types';
const person = (id: string): Principal => ({ id, type: 'individual', name: `${id}@example.org`, email: `${id}@example.org` });
beforeEach(() => localStorage.clear());
describe('known principals', () => {
it('keeps names per account', () => {
rememberPrincipals('acc-1', [person('ck')]);
rememberPrincipals('acc-2', [person('cj')]);
expect([...rememberedPrincipals('acc-1').keys()]).toEqual(['ck']);
expect([...rememberedPrincipals('acc-2').keys()]).toEqual(['cj']);
});
it('forgets one account and leaves the others', () => {
rememberPrincipals('acc-1', [person('ck')]);
rememberPrincipals('acc-2', [person('cj')]);
forgetPrincipals('acc-1');
expect(rememberedPrincipals('acc-1').size).toBe(0);
expect([...rememberedPrincipals('acc-2').keys()]).toEqual(['cj']);
});
it('leaves no key behind when the last account goes', () => {
rememberPrincipals('acc-1', [person('ck')]);
forgetPrincipals('acc-1');
expect(localStorage.getItem('bulwark-delegate-names')).toBeNull();
});
it('keeps at most 200 per account, dropping the oldest', () => {
rememberPrincipals('acc-1', Array.from({ length: 205 }, (_, i) => person(`p${i}`)));
const ids = [...rememberedPrincipals('acc-1').keys()];
expect(ids).toHaveLength(200);
expect(ids[0]).toBe('p5');
});
});
+257
View File
@@ -0,0 +1,257 @@
/**
* Delegates (Settings > Sharing): give one person access to mail, calendars
* and contacts in one step, list who has access to what, and take it away
* everywhere. Pure functions over the user's shareable items
* (JMAPClient.getShareTargets); JMAPClient.applyShareChanges sends what they
* build.
*
* Stalwart has no "send as" or "send on behalf" for a person's own account,
* so delegates get no submission right; the dialog says so.
*/
import type { ShareChange, ShareChangeResult, ShareTarget } from '@/lib/jmap/share-batch';
import { accessLevelOf, accessRights, detectPreset, type AccessLevel, type ShareKind } from '@/lib/jmap/share-rights';
/** `keep`: "Keep as it is", offered by Change when what the person has fits no other choice. */
export type MailScope = 'all' | 'inbox' | 'none' | 'keep';
export type CollectionScope = 'default' | 'all' | 'none' | 'keep';
export type LevelChoice = AccessLevel | 'keep';
/** What a delegate gets: which folders, calendars and address books. */
export interface DelegateScope {
mail: MailScope;
calendars: CollectionScope;
addressBooks: CollectionScope;
}
type DelegateKind = 'mailbox' | 'calendar' | 'addressBook';
const SCOPE_KEY: Record<DelegateKind, keyof DelegateScope> = { mailbox: 'mail', calendar: 'calendars', addressBook: 'addressBooks' };
const DELEGATE_KINDS: DelegateKind[] = ['mailbox', 'calendar', 'addressBook'];
export const DEFAULT_DELEGATE_SCOPE: DelegateScope = { mail: 'all', calendars: 'default', addressBooks: 'default' };
export const ACCESS_LEVELS: readonly AccessLevel[] = ['read', 'write'];
// Bulwark's own list of senders whose images load (stores/contact-store.ts,
// TRUSTED_SENDERS_BOOK_NAME). It is an address book on the server, but not
// one a delegate needs.
const TRUSTED_SENDERS_BOOK_NAME = 'Trusted Senders';
function ofKind(targets: ShareTarget[], kind: ShareKind): ShareTarget[] {
return targets.filter((t) => t.kind === kind);
}
function defaultOf(collections: ShareTarget[]): ShareTarget[] {
const flagged = collections.find((c) => c.isDefault);
return flagged ? [flagged] : collections.slice(0, 1);
}
function delegableAddressBooks(targets: ShareTarget[]): ShareTarget[] {
return ofKind(targets, 'addressBook').filter((b) => b.name !== TRUSTED_SENDERS_BOOK_NAME || b.isDefault);
}
/** The items of one type the dialog's choices are about. */
function itemsOfType(targets: ShareTarget[], kind: DelegateKind): ShareTarget[] {
return kind === 'addressBook' ? delegableAddressBooks(targets) : ofKind(targets, kind);
}
const isInbox = (t: ShareTarget) => t.role?.toLowerCase() === 'inbox';
/** The items one choice covers; null for "Keep as it is". */
function itemsFor(targets: ShareTarget[], kind: DelegateKind, choice: MailScope | CollectionScope): ShareTarget[] | null {
if (choice === 'keep') return null;
const items = itemsOfType(targets, kind);
if (choice === 'none') return [];
if (choice === 'all') return items;
if (choice === 'inbox') return items.filter(isInbox);
return defaultOf(items);
}
/** The items a scope covers. Files are never part of it, nor types left as they are. */
export function selectTargets(targets: ShareTarget[], scope: DelegateScope): ShareTarget[] {
return DELEGATE_KINDS.flatMap((kind) => itemsFor(targets, kind, scope[SCOPE_KEY[kind]]) ?? []);
}
function change(target: ShareTarget, principalId: string, rights: ShareChange['rights']): ShareChange {
return { kind: target.kind, accountId: target.accountId, id: target.id, name: target.name, principalId, rights };
}
/** read or write when every grant of one type has exactly that preset, otherwise null. */
function uniformLevel(items: ShareTarget[], kind: ShareKind, principalId: string): AccessLevel | null {
const presets = new Set(items.filter((t) => principalId in t.shareWith).map((t) => detectPreset(kind, t.shareWith[principalId])));
if (presets.size !== 1) return null;
const [preset] = [...presets];
return preset === 'read' ? 'read' : preset === 'readWrite' ? 'write' : null;
}
/**
* The changes that give `principalId` access to what `scope` covers at
* `level`. A type left as it is ("keep") gets no change at all. With
* `replace` (the "Change" of someone who already has access), the items of a
* chosen type outside the scope lose their access; shared files stay as they
* are, since the dialog doesn't offer them. With level "keep", items the
* person has keep their rights and items added get the rights the rest of
* that type has, or read.
*/
export function buildGrantChanges(
targets: ShareTarget[],
principalId: string,
scope: DelegateScope,
level: LevelChoice,
options: { replace?: boolean } = {},
): ShareChange[] {
const changes: ShareChange[] = [];
for (const kind of DELEGATE_KINDS) {
const chosen = itemsFor(targets, kind, scope[SCOPE_KEY[kind]]);
if (!chosen) continue;
const all = itemsOfType(targets, kind);
const typeLevel = uniformLevel(all, kind, principalId) ?? 'read';
const ids = new Set(chosen.map((t) => t.id));
for (const t of chosen) {
if (level === 'keep') {
if (principalId in t.shareWith) continue;
changes.push(change(t, principalId, accessRights(kind, typeLevel)));
} else {
changes.push(change(t, principalId, accessRights(kind, level)));
}
}
if (options.replace) {
for (const t of all) {
if (!ids.has(t.id) && principalId in t.shareWith) changes.push(change(t, principalId, null));
}
}
}
return changes;
}
/** The changes that take every grant of `principalId` away, files included. */
export function buildRevokeChanges(targets: ShareTarget[], principalId: string): ShareChange[] {
return targets.filter((t) => principalId in t.shareWith).map((t) => change(t, principalId, null));
}
export interface DelegateItem {
kind: ShareKind;
id: string;
name: string;
/** null: shared without read access (calendar free/busy only). */
level: AccessLevel | null;
}
export interface DelegateSummary {
principalId: string;
/** "mixed" when some items are read only and others writable; null when nothing is readable. */
level: AccessLevel | 'mixed' | null;
mail: { shared: number; total: number; inbox: boolean };
calendars: { shared: number; total: number };
addressBooks: { shared: number; total: number };
files: number;
items: DelegateItem[];
}
/**
* Everyone who has access to something of the user's, one entry per person
* or group, in the order they first appear. `excludeIds` leaves out the
* user's own principal, should a server list it.
*/
export function summarizeDelegates(targets: ShareTarget[], excludeIds: string[] = []): DelegateSummary[] {
const exclude = new Set(excludeIds);
const byPrincipal = new Map<string, DelegateItem[]>();
for (const target of targets) {
for (const [principalId, rights] of Object.entries(target.shareWith)) {
if (exclude.has(principalId) || !rights) continue;
const items = byPrincipal.get(principalId) ?? [];
items.push({ kind: target.kind, id: target.id, name: target.name, level: accessLevelOf(target.kind, rights) });
byPrincipal.set(principalId, items);
}
}
const totals = {
mailbox: ofKind(targets, 'mailbox').length,
calendar: ofKind(targets, 'calendar').length,
addressBook: delegableAddressBooks(targets).length,
};
const inboxIds = new Set(ofKind(targets, 'mailbox').filter((m) => m.role?.toLowerCase() === 'inbox').map((m) => m.id));
return [...byPrincipal.entries()].map(([principalId, items]) => {
const count = (kind: ShareKind) => items.filter((i) => i.kind === kind).length;
const levels = new Set(items.map((i) => i.level).filter((l): l is AccessLevel => l !== null));
return {
principalId,
level: levels.size === 0 ? null : levels.size > 1 ? 'mixed' : [...levels][0],
mail: { shared: count('mailbox'), total: totals.mailbox, inbox: items.some((i) => i.kind === 'mailbox' && inboxIds.has(i.id)) },
calendars: { shared: count('calendar'), total: totals.calendar },
addressBooks: { shared: count('addressBook'), total: totals.addressBook },
files: count('file'),
items,
};
});
}
export interface ChangeStart {
scope: DelegateScope;
level: LevelChoice;
}
/**
* The dialog's starting point for "Change". A type gets a choice only when
* the person's grants match it exactly (every folder for "all", exactly the
* Inbox for "Inbox only", exactly the default calendar, ...), all with the
* read or the read-and-write preset. Anything else (a folder added since,
* another single folder, free/busy only, manager or custom rights) starts
* as "Keep as it is", which leaves that type out of the patch. The level is
* read or write when every grant has that preset, else "keep". Without
* write among `allowed`, a level that would keep or give write starts at
* read: saving then lowers the types the user chose to read.
*/
export function changeStartFor(targets: ShareTarget[], principalId: string, allowed: AccessLevel[] = [...ACCESS_LEVELS]): ChangeStart {
const scopeFor = (kind: DelegateKind): MailScope | CollectionScope => {
// Address books Delegates doesn't offer still count: a grant there isn't one the dialog can express.
const relevant = kind === 'addressBook' ? ofKind(targets, kind) : itemsOfType(targets, kind);
const shared = relevant.filter((t) => principalId in t.shareWith);
if (shared.length === 0) return 'none';
if (shared.some((t) => !['read', 'readWrite'].includes(detectPreset(kind, t.shareWith[principalId])))) return 'keep';
const ids = shared.map((t) => t.id).sort().join('\n');
const exact = (choice: MailScope | CollectionScope) => {
const covered = itemsFor(targets, kind, choice) ?? [];
return covered.length > 0 && covered.map((t) => t.id).sort().join('\n') === ids;
};
if (kind === 'mailbox') return exact('all') ? 'all' : exact('inbox') ? 'inbox' : 'keep';
return exact('default') ? 'default' : exact('all') ? 'all' : 'keep';
};
const scope: DelegateScope = {
mail: scopeFor('mailbox') as MailScope,
calendars: scopeFor('calendar') as CollectionScope,
addressBooks: scopeFor('addressBook') as CollectionScope,
};
const granted = targets.filter((t) => t.kind !== 'file' && principalId in t.shareWith);
const presets = new Set(granted.map((t) => detectPreset(t.kind, t.shareWith[principalId])));
let level: LevelChoice = presets.size === 1 && presets.has('read') ? 'read'
: presets.size === 1 && presets.has('readWrite') ? 'write'
: 'keep';
const hasWrite = granted.some((t) => accessLevelOf(t.kind, t.shareWith[principalId]) === 'write');
if (!allowed.includes('write') && (level === 'write' || (level === 'keep' && hasWrite))) level = 'read';
else if (level !== 'keep' && !allowed.includes(level)) level = 'keep';
return { scope, level };
}
/**
* The levels the admin allows (`sharingRights.allowedValues`). Values
* other than "read" and "write" are ignored; a list with neither leaves
* read only, the safe end.
*/
export function allowedLevels(allowedValues: unknown[] | null): AccessLevel[] {
if (!allowedValues) return [...ACCESS_LEVELS];
const allowed = ACCESS_LEVELS.filter((level) => allowedValues.includes(level));
return allowed.length > 0 ? allowed : ['read'];
}
/** Whether a scope gives access to nothing at all. */
export function scopeIsEmpty(scope: DelegateScope): boolean {
return Object.values(scope).every((choice) => choice === 'none');
}
/** The changes that failed, for the report and for "Try again". */
export function failedChanges(results: ShareChangeResult[]): ShareChangeResult[] {
return results.filter((r) => !r.ok);
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Names of the people and groups the user gave access to, kept in this
* browser. Stalwart 1.0 with directory queries off (its default) won't name
* a principal the user shares with unless that principal also shares
* something back, so the "Who has access" list would otherwise show bare
* ids for the very people the user just picked by name. Only the people
* who have access and the one just picked are kept, never the directory;
* per account, at most MAX entries, gone when the account is removed or
* everyone signs out. A convenience: losing it costs names, not access.
*/
import type { Principal } from '@/lib/jmap/types';
/** Purged when everyone signs out (lib/sign-out-cleanup.ts). */
export const DELEGATE_NAMES_KEY = 'bulwark-delegate-names';
const STORAGE_KEY = DELEGATE_NAMES_KEY;
const MAX = 200;
type Entry = Pick<Principal, 'id' | 'name' | 'email' | 'type'>;
type Store = Record<string, Record<string, Entry>>;
function readStore(): Store {
try {
const raw = localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' ? (parsed as Store) : {};
} catch {
return {};
}
}
/** Remember these principals for `accountKey` (the signed-in account). */
export function rememberPrincipals(accountKey: string, principals: Principal[]): void {
if (!accountKey || principals.length === 0) return;
try {
const store = readStore();
const own = { ...(store[accountKey] ?? {}) };
for (const p of principals) {
delete own[p.id];
own[p.id] = { id: p.id, name: p.name, email: p.email ?? null, type: p.type };
}
const ids = Object.keys(own);
for (const id of ids.slice(0, Math.max(0, ids.length - MAX))) delete own[id];
store[accountKey] = own;
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
// Private window or storage full: the list shows ids instead.
}
}
/** The principals remembered for `accountKey`, by id. */
export function rememberedPrincipals(accountKey: string): Map<string, Principal> {
const own = readStore()[accountKey] ?? {};
return new Map(Object.values(own).map((entry) => [entry.id, entry as Principal]));
}
/** Forget the names kept for one account (it was removed from this browser). */
export function forgetPrincipals(accountKey: string): void {
if (!accountKey) return;
try {
const store = readStore();
if (!(accountKey in store)) return;
delete store[accountKey];
if (Object.keys(store).length === 0) localStorage.removeItem(STORAGE_KEY);
else localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
} catch {
// storage unavailable
}
}
+215
View File
@@ -0,0 +1,215 @@
/**
* Who is outside the organisation, for the "External" sender chip and the
* confirm before sending outside it (policy.json organisation.externalMail).
*
* Internal are the domains of the user's own identities plus the domains
* the admin lists; a listed domain covers its subdomains (example.com
* covers eu.example.com). With no internal domain known at all nobody can
* be told apart, so nothing counts as external.
*
* Domains are compared in ASCII (punycode) form on both sides, so an IDN
* the admin typed in Unicode matches the xn-- form a server delivers.
*
* The pure functions take everything as arguments; the context helpers
* read one signed-in account's effective policy and identities.
*/
import { DEFAULT_ORGANISATION_POLICY, normalizeDomainList, type OrganisationPolicy } from '@/lib/admin/organisation-policy';
import { toAsciiDomain, toAsciiEmail } from '@/lib/idn';
import { usePolicyStore } from '@/stores/policy-store';
import { useAuthStore } from '@/stores/auth-store';
import { TRUSTED_SENDERS_BOOK_NAME, useContactStore } from '@/stores/contact-store';
import { policySubjectForClient } from '@/lib/admin/policy-subject';
import type { PolicySubject } from '@/lib/admin/policy-scopes';
import { cachedIdentitiesForAccount } from '@/hooks/use-pro-multi-account-identities';
export type ExternalMailPolicy = OrganisationPolicy['externalMail'];
/** The bare address of "Name <a@b>" or "a@b". */
function bareAddress(address: string | null | undefined): string {
const raw = String(address ?? '').trim();
const bracket = raw.match(/<([^<>]*)>\s*$/);
return (bracket ? bracket[1] : raw).trim();
}
/** The domain of an address in lowercased ASCII form; '' without a usable one. */
export function addressDomain(address: string | null | undefined): string {
const bare = bareAddress(address);
const at = bare.lastIndexOf('@');
if (at < 0) return '';
const domain = bare.slice(at + 1).trim().replace(/\.+$/, '');
return (domain && toAsciiDomain(domain)) || '';
}
/** The address as a lookup key: the domain in ASCII form, all lowercased. */
export function addressKey(address: string): string {
return toAsciiEmail(bareAddress(address)).toLowerCase();
}
/** True when `domain` (ASCII) is one of `internal` or a subdomain of one. */
export function domainMatches(domain: string, internal: readonly string[]): boolean {
if (!domain) return false;
const d = domain.toLowerCase();
return internal.some((entry) => d === entry || d.endsWith(`.${entry}`));
}
/**
* The domains of the user's identities, then the admin's, in ASCII form and
* deduplicated. The identity domains come first so the list's size cap never
* cuts them.
*/
export function internalDomains(identityEmails: readonly string[], policyDomains: readonly string[]): string[] {
const own = identityEmails.map(addressDomain).filter(Boolean);
return normalizeDomainList([...own, ...policyDomains]) ?? [];
}
/** Whether mail to this address leaves the organisation. An address without a domain isn't sendable and isn't counted. */
export function isExternalAddress(address: string, internal: readonly string[]): boolean {
if (internal.length === 0) return false;
const domain = addressDomain(address);
return !!domain && !domainMatches(domain, internal);
}
export type RecipientField = 'to' | 'cc' | 'bcc';
export interface ExternalRecipient {
address: string;
domain: string;
field: RecipientField;
}
/**
* The recipients outside the organisation, each address once (in the first
* field it appears in). `trusted` is asked for the rest, with the address as
* addressKey makes it: with "trust contacts" on, a contact doesn't count.
*/
export function externalRecipients(
recipients: { to: readonly string[]; cc: readonly string[]; bcc: readonly string[] },
internal: readonly string[],
trusted?: (key: string) => boolean,
): ExternalRecipient[] {
const out: ExternalRecipient[] = [];
const seen = new Set<string>();
for (const field of ['to', 'cc', 'bcc'] as const) {
for (const raw of recipients[field]) {
const address = String(raw ?? '').trim();
const key = addressKey(address);
if (!address || seen.has(key)) continue;
seen.add(key);
if (!isExternalAddress(address, internal)) continue;
if (trusted?.(key)) continue;
out.push({ address, domain: addressDomain(address), field });
}
}
return out;
}
export interface ExternalSender {
address: string;
/** ASCII domain; '' for an address without one. */
domain: string;
}
/**
* The From addresses that are outside the organisation. A message counts as
* external when any of them is, and an address without a domain
* ("CEO <ceo>") counts too: no organisation sends like that. A forged
* internal From is not caught here; the sender check warns about that.
*/
export function externalSenders(
from: readonly { email?: string | null }[] | null | undefined,
internal: readonly string[],
): ExternalSender[] {
if (!from || internal.length === 0) return [];
const out: ExternalSender[] = [];
for (const sender of from) {
const address = String(sender?.email ?? '').trim();
const domain = addressDomain(address);
if (domain && domainMatches(domain, internal)) continue;
out.push({ address, domain });
}
return out;
}
// ─── Per account ─────────────────────────────────────────────
/** What decides who is external for one signed-in account. */
export interface ExternalMailContext {
/** That account's effective externalMail policy (its domain and group scopes applied). */
policy: ExternalMailPolicy;
/** That account's identities' addresses. */
identityEmails: string[];
}
/** The active account: the effective policy and the identities in the stores. */
export function activeExternalMailContext(): ExternalMailContext {
return {
policy: usePolicyStore.getState().getOrganisation().externalMail ?? DEFAULT_ORGANISATION_POLICY.externalMail,
identityEmails: (useAuthStore.getState().identities ?? []).map((identity) => identity.email),
};
}
/** The internal domains of a context. */
export function contextInternalDomains(context: ExternalMailContext): string[] {
return internalDomains(context.identityEmails, context.policy.domains);
}
type AccountClient = NonNullable<ReturnType<ReturnType<typeof useAuthStore.getState>['getClientForAccount']>>;
const subjects = new WeakMap<AccountClient, Promise<PolicySubject | null>>();
const identityLists = new WeakMap<AccountClient, Promise<string[]>>();
/** Whether `localAccountId` names another signed-in account than the active one. */
export function isOtherAccount(localAccountId: string | null | undefined): localAccountId is string {
const auth = useAuthStore.getState();
return !!localAccountId && localAccountId !== auth.activeAccountId && !!auth.getClientForAccount(localAccountId);
}
/**
* The context of a signed-in account by its login id (AccountEntry id): a
* message's own account in the unified inbox, or the account a message is
* sent from. Its policy is resolved for its own subject, so a domain or
* group scope of that account applies, and its own identities count. The
* active account (or none given) reads the stores directly.
*/
export async function externalMailContextFor(localAccountId?: string | null): Promise<ExternalMailContext> {
if (!isOtherAccount(localAccountId)) return activeExternalMailContext();
const client = useAuthStore.getState().getClientForAccount(localAccountId)!;
let subject = subjects.get(client);
if (!subject) {
subject = policySubjectForClient(client).catch(() => null);
subjects.set(client, subject);
}
let identities = identityLists.get(client);
if (!identities) {
const cached = cachedIdentitiesForAccount(localAccountId);
identities = cached
? Promise.resolve(cached.map((identity) => identity.email))
: client.getIdentities().then((list) => list.map((identity) => identity.email)).catch(() => []);
identityLists.set(client, identities);
}
const [resolvedSubject, identityEmails] = await Promise.all([subject, identities]);
const policy = usePolicyStore.getState().resolveFor(resolvedSubject).organisation?.externalMail
?? DEFAULT_ORGANISATION_POLICY.externalMail;
return { policy, identityEmails };
}
/**
* The addresses of the loaded contacts, as addressKey makes them. A card
* that sits only in the "Trusted Senders" book is left out: that book is
* the list of senders whose images load, not people the user writes to.
*/
export function contactAddresses(): Set<string> {
const { contacts, addressBooks, trustedSendersBookId } = useContactStore.getState();
const trustedBooks = new Set(addressBooks.filter((book) => book.name === TRUSTED_SENDERS_BOOK_NAME).map((book) => book.id));
if (trustedSendersBookId) trustedBooks.add(trustedSendersBookId);
const out = new Set<string>();
for (const contact of contacts) {
const books = Object.keys(contact.addressBookIds ?? {}).filter((id) => contact.addressBookIds?.[id]);
if (books.length > 0 && books.every((id) => trustedBooks.has(id))) continue;
for (const entry of Object.values(contact.emails ?? {})) {
const address = entry?.address?.trim();
if (address) out.add(addressKey(address));
}
}
return out;
}

Some files were not shown because too many files have changed in this diff Show More