From 4b69f1307f5fa12c9778db72a83c4082c8846809 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:29:02 +0100 Subject: [PATCH 001/137] feat: let admins scope the user policy per domain or group --- .../__tests__/policy-scopes-route.test.ts | 72 +++++ app/api/admin/policy/route.ts | 23 +- app/api/settings/route.ts | 5 +- lib/__tests__/instance-details-gates.test.ts | 3 +- .../config-manager-organisation.test.ts | 50 +++ .../__tests__/organisation-policy.test.ts | 49 +++ lib/admin/__tests__/policy-scopes.test.ts | 155 ++++++++++ lib/admin/config-manager.ts | 23 ++ lib/admin/organisation-policy.ts | 216 +++++++++++++ lib/admin/policy-scopes.ts | 284 ++++++++++++++++++ lib/admin/policy-subject.ts | 30 ++ lib/admin/types.ts | 11 + lib/auth/instance-details.ts | 27 ++ lib/jmap/client-interface.ts | 2 + lib/jmap/client.ts | 24 ++ lib/lite-config.ts | 26 +- stores/__tests__/policy-store-scopes.test.ts | 106 +++++++ stores/auth-store.ts | 21 ++ stores/policy-store.ts | 53 +++- stores/settings-store.ts | 49 ++- 20 files changed, 1209 insertions(+), 20 deletions(-) create mode 100644 app/api/admin/__tests__/policy-scopes-route.test.ts create mode 100644 lib/admin/__tests__/config-manager-organisation.test.ts create mode 100644 lib/admin/__tests__/organisation-policy.test.ts create mode 100644 lib/admin/__tests__/policy-scopes.test.ts create mode 100644 lib/admin/organisation-policy.ts create mode 100644 lib/admin/policy-scopes.ts create mode 100644 lib/admin/policy-subject.ts create mode 100644 stores/__tests__/policy-store-scopes.test.ts diff --git a/app/api/admin/__tests__/policy-scopes-route.test.ts b/app/api/admin/__tests__/policy-scopes-route.test.ts new file mode 100644 index 000000000..d1348d23b --- /dev/null +++ b/app/api/admin/__tests__/policy-scopes-route.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment node +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +// The policy route hands each browser only the scopes of the domains it is +// signed in with, so one organisation on a shared instance never sees +// another's settings. + +const who = vi.hoisted(() => ({ + signedIn: true, + identity: null as null | { admin: true } | { admin: false; addresses: string[] }, +})); +vi.mock('@/lib/auth/instance-details', () => ({ + canSeeInstanceDetails: async () => who.signedIn, + signedInIdentity: async () => who.identity, +})); + +const SCOPES = [ + { id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { features: { calendarEnabled: false } } }, + { id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: {} }, +]; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + getPolicy: () => ({ + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: { disclaimer: { html: '

Acme GmbH

', text: 'Acme GmbH' } }, + scopes: SCOPES, + }), + }, +})); +vi.mock('@/lib/logger', () => ({ logger: { error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() } })); +vi.mock('@/lib/admin/session', () => ({ requireAdminAuth: vi.fn(), getClientIP: () => '0.0.0.0' })); +vi.mock('@/lib/admin/audit', () => ({ auditLog: vi.fn() })); + +import { GET } from '@/app/api/admin/policy/route'; + +const get = async () => (await GET(new NextRequest('https://mail.example/api/admin/policy'))).json(); + +beforeEach(() => { + who.signedIn = true; + who.identity = null; +}); + +describe('GET /api/admin/policy scopes', () => { + it('gives visitors who are not signed in no scopes and no organisation settings', async () => { + who.signedIn = false; + const body = await get(); + expect(body.scopes).toEqual([]); + expect(body.organisation).toBeUndefined(); + }); + + it('gives a signed-in user only the scopes of their domains', async () => { + who.identity = { admin: false, addresses: ['org-dave@org-two.test'] }; + expect((await get()).scopes.map((s: { id: string }) => s.id)).toEqual(['two']); + who.identity = { admin: false, addresses: ['org-alice@example.org'] }; + const body = await get(); + expect(body.scopes.map((s: { id: string }) => s.id)).toEqual(['team']); + expect(body.organisation.disclaimer.text).toBe('Acme GmbH'); + }); + + it('gives the admin every scope', async () => { + who.identity = { admin: true }; + expect((await get()).scopes).toHaveLength(2); + }); + + it('gives every scope when the server can\'t tell who is asking', async () => { + who.identity = null; + expect((await get()).scopes).toHaveLength(2); + }); +}); diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 6e707577e..8aeb72dbb 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -4,7 +4,8 @@ import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; import { POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types'; -import { canSeeInstanceDetails } from '@/lib/auth/instance-details'; +import { canSeeInstanceDetails, signedInIdentity } from '@/lib/auth/instance-details'; +import { scopesVisibleTo } from '@/lib/admin/policy-scopes'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; /** @@ -28,11 +29,23 @@ export async function GET(request: NextRequest) { pushRelayUrl: '', pushRelayUrlLocked: false, defaultSidebarApps: [], + // Signatures, domain lists and scopes describe the organisation + // and only apply once someone is signed in. + organisation: undefined, + scopes: [], }, { headers: { 'Cache-Control': 'no-store', [POLICY_SCOPE_HEADER]: 'public' } }, ); } - return NextResponse.json(policy, { + // Users get only the scopes for their own domains, so on an instance + // shared by several organisations one doesn't see another's settings. + // Without a session secret the server can't tell who is asking and + // answers with all of them (documented). + const identity = await signedInIdentity(); + const scopes = identity === null || identity.admin + ? policy.scopes ?? [] + : scopesVisibleTo(policy.scopes ?? [], identity.addresses); + return NextResponse.json({ ...policy, scopes }, { headers: { 'Cache-Control': 'no-store' }, }); } catch (error) { @@ -72,6 +85,12 @@ export async function PUT(request: NextRequest) { if (policy.defaultSidebarApps !== undefined && !Array.isArray(policy.defaultSidebarApps)) { return NextResponse.json({ error: 'defaultSidebarApps must be an array' }, { status: 400 }); } + if (policy.scopes !== undefined && !Array.isArray(policy.scopes)) { + return NextResponse.json({ error: 'scopes must be an array' }, { status: 400 }); + } + if (policy.organisation !== undefined && (typeof policy.organisation !== 'object' || policy.organisation === null || Array.isArray(policy.organisation))) { + return NextResponse.json({ error: 'organisation must be an object' }, { status: 400 }); + } await configManager.setPolicy(policy); await auditLog('policy.update', { diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 0ad259273..18080c4b5 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -7,6 +7,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie'; import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; import { saveUserSettings, loadUserSettings, deleteUserSettings } from '@/lib/settings-sync'; import { configManager } from '@/lib/admin/config-manager'; +import { resolvePolicy } from '@/lib/admin/policy-scopes'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { mergeStoredTemplates } from '@/lib/template-merge'; @@ -180,7 +181,9 @@ export async function POST(request: NextRequest) { // Enforce admin policy - strip locked settings so users can't override them await configManager.ensureLoaded(); - const policy = configManager.getPolicy(); + // With the account's domain scopes applied; group scopes need the + // account's session, which only the client has, so the client enforces those. + const policy = resolvePolicy(configManager.getPolicy(), { email: accountName, groups: [] }).policy; const filteredSettings = { ...settings }; for (const key of Object.keys(filteredSettings)) { const restriction = policy.restrictions[key]; diff --git a/lib/__tests__/instance-details-gates.test.ts b/lib/__tests__/instance-details-gates.test.ts index 85b3b96ac..5e2d91c92 100644 --- a/lib/__tests__/instance-details-gates.test.ts +++ b/lib/__tests__/instance-details-gates.test.ts @@ -10,6 +10,7 @@ import { NextRequest } from 'next/server'; const gate = vi.hoisted(() => ({ signedIn: false })); vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: async () => gate.signedIn, + signedInIdentity: async () => null, })); const config = vi.hoisted(() => ({ values: {} as Record })); @@ -121,6 +122,6 @@ describe('endpoints for visitors who are not signed in', () => { gate.signedIn = true; const full = await policy(req('/api/admin/policy')); expect(full.headers.get('x-bulwark-policy-scope')).toBeNull(); - expect(await full.json()).toEqual(POLICY); + expect(await full.json()).toEqual({ ...POLICY, scopes: [] }); }); }); diff --git a/lib/admin/__tests__/config-manager-organisation.test.ts b/lib/admin/__tests__/config-manager-organisation.test.ts new file mode 100644 index 000000000..2d0250689 --- /dev/null +++ b/lib/admin/__tests__/config-manager-organisation.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { configManager } from '../config-manager'; + +let dir: string; + +async function load(policy: Record) { + await writeFile(path.join(dir, 'policy.json'), JSON.stringify(policy), 'utf-8'); + await configManager.reload(); + return configManager.getPolicy(); +} + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-org-policy-')); + process.env.ADMIN_CONFIG_DIR = dir; +}); + +afterEach(async () => { + delete process.env.ADMIN_CONFIG_DIR; + await rm(dir, { recursive: true, force: true }); +}); + +describe('policy.json organisation settings', () => { + it('moves a locked sessionTimeout to the organisation idle timeout', async () => { + const policy = await load({ features: {}, restrictions: { sessionTimeout: { locked: true, value: 30 }, density: { locked: true } } }); + expect(policy.organisation?.session.idleMinutes).toBe(30); + expect(policy.restrictions).toEqual({ density: { locked: true } }); + }); + + it('keeps an idle timeout already set and drops the old key', async () => { + const policy = await load({ + restrictions: { sessionTimeout: { locked: true, value: 30 } }, + organisation: { session: { idleMinutes: 5 } }, + }); + expect(policy.organisation?.session.idleMinutes).toBe(5); + expect(policy.restrictions.sessionTimeout).toBeUndefined(); + }); + + it('sanitises hand-written scopes and organisation settings on load', async () => { + const policy = await load({ + organisation: { externalMail: { senderLabel: 'on' } }, + scopes: [{ id: 'ok', label: 'OK', match: { domains: ['example.org'] }, policy: {} }, { id: 'Bad Id' }], + }); + expect(policy.organisation?.externalMail.senderLabel).toBe(false); + expect(policy.scopes?.map((s) => s.id)).toEqual(['ok']); + }); +}); diff --git a/lib/admin/__tests__/organisation-policy.test.ts b/lib/admin/__tests__/organisation-policy.test.ts new file mode 100644 index 000000000..7d6b8ce25 --- /dev/null +++ b/lib/admin/__tests__/organisation-policy.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_ORGANISATION_POLICY, + normalizeDomainList, + sanitizeOrganisationPolicy, +} from '@/lib/admin/organisation-policy'; +import { applyLitePolicy } from '@/lib/lite-config'; + +describe('sanitizeOrganisationPolicy', () => { + it('gives the defaults for nothing or nonsense', () => { + expect(sanitizeOrganisationPolicy(undefined)).toEqual(DEFAULT_ORGANISATION_POLICY); + expect(sanitizeOrganisationPolicy('x')).toEqual(DEFAULT_ORGANISATION_POLICY); + expect(sanitizeOrganisationPolicy({ session: 'x', signature: [] })).toEqual(DEFAULT_ORGANISATION_POLICY); + }); + + it('keeps valid values, clamps numbers and drops wrong types and unknown keys', () => { + const org = sanitizeOrganisationPolicy({ + externalMail: { senderLabel: true, recipientWarning: 'yes', domains: 'Example.org, @partner.io *.eu.example.org bad_domain' }, + signature: { html: '

{name}

', fields: ['phone', 'shoe size', 'title'] }, + session: { idleMinutes: 15.4, maxHours: -1, warnSeconds: 99999 }, + templates: { library: [{ id: 'a', name: 'A', body: 'x' }, { id: 'a', name: 'Dup' }, { name: 'No id' }] }, + madeUp: { x: 1 }, + }); + expect(org.externalMail).toEqual({ senderLabel: true, recipientWarning: false, domains: ['example.org', 'partner.io', 'eu.example.org'], trustContacts: true }); + expect(org.signature).toEqual({ html: '

{name}

', text: '', fields: ['title', 'phone'] }); + expect(org.session).toEqual({ idleMinutes: 15, maxHours: 0, warnSeconds: 600 }); + expect(org.templates.library).toEqual([{ id: 'a', name: 'A', subject: '', body: 'x', isHTML: false, category: '' }]); + expect(org).not.toHaveProperty('madeUp'); + }); + + it('normalises domain lists from text or arrays', () => { + expect(normalizeDomainList(['A.org', 'a.org', 'x'])).toEqual(['a.org']); + expect(normalizeDomainList(42)).toBeUndefined(); + }); +}); + +describe('applyLitePolicy', () => { + it('keeps scopes and organisation settings, but no scope switches on what Lite lacks', () => { + const policy = applyLitePolicy({ + organisation: { session: { idleMinutes: 10 } }, + scopes: [{ id: 'x', label: 'X', match: { domains: ['example.org'] }, policy: { features: { sidebarAppsEnabled: true, calendarEnabled: false } } }], + }); + expect(policy.organisation?.session.idleMinutes).toBe(10); + expect(policy.scopes?.[0].policy.features).toEqual({ calendarEnabled: false, ...{ + pluginsEnabled: false, pluginsUploadEnabled: false, sidebarAppsEnabled: false, + accountArchiveEnabled: false, accountArchivePasswordsEnabled: false, + } }); + }); +}); diff --git a/lib/admin/__tests__/policy-scopes.test.ts b/lib/admin/__tests__/policy-scopes.test.ts new file mode 100644 index 000000000..a9d020f77 --- /dev/null +++ b/lib/admin/__tests__/policy-scopes.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types'; +import { + domainPatternMatches, + resolvePolicy, + sanitizeScopes, + scopesVisibleTo, + type PolicyScope, +} from '@/lib/admin/policy-scopes'; +import { sanitizeOrganisationPolicy, DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; + +const alice = { email: 'org-alice@example.org', groups: ['org-team@example.org'] }; +const dave = { email: 'org-dave@org-two.test', groups: [] }; +const carol = { email: 'org-carol@example.org', groups: [] }; + +function policyWith(scopes: unknown[], extra: Partial = {}): SettingsPolicy { + return { ...DEFAULT_POLICY, ...extra, scopes: scopes as PolicyScope[] }; +} + +const twoDomain = { + id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, + policy: { features: { calendarEnabled: false } }, +}; +const team = { + id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, + policy: { restrictions: { density: { locked: true, value: 'compact' } }, organisation: { session: { idleMinutes: 15 } } }, +}; + +describe('resolvePolicy', () => { + it('applies a domain scope only to addresses in that domain', () => { + const policy = policyWith([twoDomain]); + expect(resolvePolicy(policy, dave).policy.features.calendarEnabled).toBe(false); + expect(resolvePolicy(policy, alice).policy.features.calendarEnabled).toBe(true); + expect(resolvePolicy(policy, null).policy.features.calendarEnabled).toBe(true); + }); + + it('applies a group scope to members only, case-insensitively', () => { + const policy = policyWith([team]); + const forAlice = resolvePolicy(policy, { ...alice, groups: ['Org-Team@Example.org'] }); + expect(forAlice.policy.restrictions.density).toEqual({ locked: true, value: 'compact' }); + expect(forAlice.policy.organisation?.session.idleMinutes).toBe(15); + expect(forAlice.matched).toEqual(['team']); + const forCarol = resolvePolicy(policy, carol); + expect(forCarol.policy.restrictions.density).toBeUndefined(); + expect(forCarol.policy.organisation?.session.idleMinutes).toBe(0); + }); + + it('applies domain scopes before group scopes, whatever the list order', () => { + const groupFirst = { ...team, policy: { organisation: { session: { idleMinutes: 30 } } } }; + const domainScope = { id: 'main', label: 'Main', match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 60, maxHours: 8 } } } }; + const resolved = resolvePolicy(policyWith([groupFirst, domainScope]), alice); + expect(resolved.matched).toEqual(['main', 'team']); + expect(resolved.policy.organisation?.session).toEqual({ idleMinutes: 30, maxHours: 8, warnSeconds: 60 }); + expect(resolved.sources['organisation.session.idleMinutes']).toBe('team'); + expect(resolved.sources['organisation.session.maxHours']).toBe('main'); + }); + + it('lets the later scope of the same kind win', () => { + const a = { id: 'a', label: 'A', match: { domains: ['example.org'] }, policy: { features: { contactsEnabled: false } } }; + const b = { id: 'b', label: 'B', match: { domains: ['*.org', 'example.org'] }, policy: { features: { contactsEnabled: true } } }; + expect(resolvePolicy(policyWith([a, b]), alice).policy.features.contactsEnabled).toBe(true); + expect(resolvePolicy(policyWith([b, a]), alice).policy.features.contactsEnabled).toBe(false); + }); + + it('adds a scope\'s templates to the inherited library and replaces the same id', () => { + const policy = policyWith( + [{ id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: { organisation: { templates: { library: [ + { id: 'welcome', name: 'Team welcome', subject: 'Hi team', body: 'b' }, + { id: 'faq', name: 'FAQ', subject: '', body: 'b' }, + ] } } } }], + { organisation: sanitizeOrganisationPolicy({ templates: { library: [ + { id: 'welcome', name: 'Welcome', subject: 'Hi', body: 'a' }, + { id: 'legal', name: 'Legal', subject: '', body: 'a' }, + ] } }) }, + ); + const names = resolvePolicy(policy, alice).policy.organisation?.templates.library.map((t) => t.name); + expect(names).toEqual(['Team welcome', 'Legal', 'FAQ']); + expect(resolvePolicy(policy, carol).policy.organisation?.templates.library.map((t) => t.name)).toEqual(['Welcome', 'Legal']); + }); + + it('records what the global policy set, but not values equal to the defaults', () => { + const policy = policyWith([], { + features: { ...DEFAULT_POLICY.features, calendarEnabled: false, contactsEnabled: true }, + organisation: sanitizeOrganisationPolicy({ externalMail: { senderLabel: true, recipientWarning: false } }), + }); + const { sources } = resolvePolicy(policy, alice); + expect(sources['features.calendarEnabled']).toBe('global'); + expect(sources['features.contactsEnabled']).toBeUndefined(); + expect(sources['organisation.externalMail.senderLabel']).toBe('global'); + expect(sources['organisation.externalMail.recipientWarning']).toBeUndefined(); + }); + + it('hands out the effective policy without scopes and with the organisation defaults filled in', () => { + const resolved = resolvePolicy(policyWith([twoDomain]), dave).policy; + expect(resolved.scopes).toBeUndefined(); + expect(resolved.organisation).toEqual(DEFAULT_ORGANISATION_POLICY); + }); +}); + +describe('domainPatternMatches', () => { + it('matches exact domains and subdomains of a wildcard, not the bare wildcard domain', () => { + expect(domainPatternMatches('example.org', 'example.org')).toBe(true); + expect(domainPatternMatches('example.org', 'eu.example.org')).toBe(false); + expect(domainPatternMatches('*.example.org', 'eu.example.org')).toBe(true); + expect(domainPatternMatches('*.example.org', 'example.org')).toBe(false); + expect(domainPatternMatches('*.example.org', 'badexample.org')).toBe(false); + }); +}); + +describe('sanitizeScopes', () => { + it('drops bad ids, duplicates, invalid matches and unknown or server-checked feature gates', () => { + const scopes = sanitizeScopes([ + { id: 'Sales', label: ' Sales ', match: { domains: ['Example.ORG.', 'not a domain', 'http://x.org'], groups: ['Sales@Example.org', 'nope'] }, + policy: { features: { calendarEnabled: false, pluginsEnabled: true, madeUp: true, contactsEnabled: 'no' }, + restrictions: { density: { locked: true, value: 'compact', junk: 1 }, __proto__: { locked: true } }, + defaults: { density: 'compact', list: [1] }, + organisation: { session: { idleMinutes: -5, extra: 1 }, unknown: {} } } }, + { id: 'sales', label: 'Duplicate', match: {}, policy: {} }, + { id: '../x', label: 'Bad', match: {}, policy: {} }, + 'nonsense', + ]); + expect(scopes).toHaveLength(1); + expect(scopes[0]).toEqual({ + id: 'sales', + label: 'Sales', + match: { domains: ['example.org'], groups: ['sales@example.org'] }, + policy: { + features: { calendarEnabled: false }, + restrictions: { density: { locked: true, value: 'compact' } }, + defaults: { density: 'compact' }, + organisation: { session: { idleMinutes: 0 } }, + }, + }); + }); +}); + +describe('scopesVisibleTo', () => { + const scopes = sanitizeScopes([ + twoDomain, + team, + { id: 'wild', label: 'Wild', match: { domains: ['*.example.org'] }, policy: {} }, + ]); + + it('gives a user only the scopes of their own domains', () => { + expect(scopesVisibleTo(scopes, ['org-alice@example.org']).map((s) => s.id)).toEqual(['team']); + expect(scopesVisibleTo(scopes, ['org-dave@org-two.test']).map((s) => s.id)).toEqual(['two']); + expect(scopesVisibleTo(scopes, ['x@eu.example.org']).map((s) => s.id)).toEqual(['wild']); + expect(scopesVisibleTo(scopes, ['org-alice@example.org', 'org-dave@org-two.test']).map((s) => s.id)).toEqual(['two', 'team']); + }); + + it('gives a browser without a known address none', () => { + expect(scopesVisibleTo(scopes, [])).toEqual([]); + expect(scopesVisibleTo(scopes, ['admin'])).toEqual([]); + }); +}); diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts index ea3d22482..087ad5700 100644 --- a/lib/admin/config-manager.ts +++ b/lib/admin/config-manager.ts @@ -6,6 +6,8 @@ import { ensureConfigDir, getConfigPath, assertWritable } from './paths'; import { isValidRelayUrl, normalizeRelayUrl } from '@/lib/push-relays'; import { sanitizeDefaultSidebarApps } from '@/lib/sidebar-apps'; import { sanitizePolicyDefaults } from './policy-defaults'; +import { sanitizeOrganisationPolicy } from './organisation-policy'; +import { sanitizeRestrictions, sanitizeScopes } from './policy-scopes'; function parseEnvValue(value: string, type: string): unknown { switch (type) { @@ -216,9 +218,30 @@ class ConfigManager { policy.defaultSidebarApps = sanitizeDefaultSidebarApps(policy.defaultSidebarApps); // Same for the values users start with (#1248). policy.defaults = sanitizePolicyDefaults(policy.defaults); + ConfigManager.migrateSessionTimeout(policy); + policy.restrictions = sanitizeRestrictions(policy.restrictions); + policy.organisation = sanitizeOrganisationPolicy(policy.organisation); + policy.scopes = sanitizeScopes(policy.scopes); return policy; } + /** + * The user setting `sessionTimeout` was never read by anything; an admin + * who locked it to a number of minutes meant an idle timeout, which is + * organisation.session.idleMinutes now. Moved over once, unless the + * organisation setting is already there. + */ + private static migrateSessionTimeout(policy: SettingsPolicy): void { + const restriction = policy.restrictions?.sessionTimeout; + const minutes = typeof restriction?.value === 'number' ? restriction.value : policy.defaults?.sessionTimeout; + const raw = (policy.organisation ?? {}) as unknown as Record | undefined>; + if (typeof minutes === 'number' && minutes > 0 && typeof raw.session?.idleMinutes !== 'number') { + policy.organisation = { ...raw, session: { ...(raw.session ?? {}), idleMinutes: minutes } } as unknown as SettingsPolicy['organisation']; + } + if (policy.restrictions) delete policy.restrictions.sessionTimeout; + if (policy.defaults) delete policy.defaults.sessionTimeout; + } + /** * Reload config from disk (for manual file edits or multi-instance). */ diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts new file mode 100644 index 000000000..55769a4e3 --- /dev/null +++ b/lib/admin/organisation-policy.ts @@ -0,0 +1,216 @@ +/** + * The organisation part of the user policy (policy.json `organisation`): + * settings an admin makes for everyone, as opposed to the per-setting + * restrictions. Every section has one default that works without + * configuration, and every section can be overridden per domain or group + * through `scopes` (lib/admin/policy-scopes.ts). + * + * policy.json is written by hand and Lite reads it as a static file, so + * sanitizeOrganisationPolicy accepts any input and keeps only known keys + * with the right type, falling back to the default for each. + */ + +import type { EmailTemplate } from '@/lib/template-types'; + +/** Details a user fills in once for the organisation signature. */ +export const SIGNATURE_FIELDS = ['title', 'department', 'phone', 'mobile'] as const; +export type SignatureField = (typeof SIGNATURE_FIELDS)[number]; + +/** A template of the admin library: read-only for users. */ +export type OrgTemplate = Pick; + +export interface OrganisationPolicy { + externalMail: { + /** "External" chip on mail from senders outside the organisation domains. */ + 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. */ + domains: string[]; + /** Recipients in the user's contacts don't trigger the warning. */ + trustContacts: boolean; + }; + signature: { + /** HTML template with placeholders; empty html and text mean no organisation signature. */ + html: string; + text: string; + /** The details users fill in, as the template uses them. */ + fields: SignatureField[]; + }; + disclaimer: { + /** Added below everything when a message is sent; empty means none. */ + html: string; + text: string; + }; + templates: { + library: OrgTemplate[]; + }; + session: { + /** Sign out after this many minutes without activity; 0 is off. */ + idleMinutes: number; + /** Sign out this many hours after signing in; 0 is off. */ + maxHours: number; + /** How long the warning counts down before signing out. */ + warnSeconds: number; + }; + recovery: { + /** How far back "Recover deleted items" lists; 0 is everything the server keeps. */ + days: number; + }; +} + +export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = { + externalMail: { senderLabel: false, recipientWarning: false, domains: [], trustContacts: true }, + signature: { html: '', text: '', fields: [] }, + disclaimer: { html: '', text: '' }, + templates: { library: [] }, + session: { idleMinutes: 0, maxHours: 0, warnSeconds: 60 }, + recovery: { days: 0 }, +}; + +export type OrganisationSection = keyof OrganisationPolicy; + +export type DeepPartial = { + [K in keyof T]?: T[K] extends readonly unknown[] ? T[K] : T[K] extends object ? DeepPartial : T[K]; +}; + +const MAX_TEXT = 64 * 1024; +const MAX_LIBRARY = 200; +const MAX_DOMAINS = 500; +// Same shape as the branding host rule, without the wildcard: a bare domain. +const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function text(value: unknown): string | undefined { + return typeof value === 'string' ? value.slice(0, MAX_TEXT) : undefined; +} + +function bool(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function count(value: unknown, max: number): number | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return 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 { + const raw = typeof value === 'string' ? value.split(/[\s,;]+/) : Array.isArray(value) ? value : undefined; + if (!raw) return undefined; + const out = new Set(); + 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; + } + return [...out]; +} + +function sanitizeLibrary(value: unknown): OrgTemplate[] | undefined { + if (!Array.isArray(value)) return undefined; + const seen = new Set(); + const out: OrgTemplate[] = []; + for (const item of value) { + if (!isRecord(item)) continue; + const id = typeof item.id === 'string' ? item.id.trim().slice(0, 128) : ''; + const name = text(item.name)?.trim(); + if (!id || !name || seen.has(id)) continue; + seen.add(id); + out.push({ + id, + name: name.slice(0, 200), + subject: text(item.subject) ?? '', + body: text(item.body) ?? '', + isHTML: item.isHTML === true, + category: (text(item.category) ?? '').slice(0, 100), + }); + if (out.length >= MAX_LIBRARY) break; + } + return out; +} + +/** + * The parts of `raw` that are valid organisation settings, without + * defaults filled in. Used for scope overrides, where a missing key means + * "inherit", and as the first step of sanitizeOrganisationPolicy. + */ +export function sanitizeOrganisationPartial(raw: unknown): DeepPartial { + if (!isRecord(raw)) return {}; + const out: DeepPartial = {}; + const section = (key: OrganisationSection) => (isRecord(raw[key]) ? (raw[key] as Record) : null); + const assign = (key: S, values: Record) => { + const kept = Object.fromEntries(Object.entries(values).filter(([, v]) => v !== undefined)); + if (Object.keys(kept).length > 0) (out as Record)[key] = kept; + }; + + const ext = section('externalMail'); + if (ext) assign('externalMail', { + senderLabel: bool(ext.senderLabel), + recipientWarning: bool(ext.recipientWarning), + domains: normalizeDomainList(ext.domains), + trustContacts: bool(ext.trustContacts), + }); + + const sig = section('signature'); + if (sig) assign('signature', { + html: text(sig.html), + text: text(sig.text), + fields: Array.isArray(sig.fields) + ? SIGNATURE_FIELDS.filter((field) => (sig.fields as unknown[]).includes(field)) + : undefined, + }); + + const disc = section('disclaimer'); + if (disc) assign('disclaimer', { html: text(disc.html), text: text(disc.text) }); + + const tpl = section('templates'); + if (tpl) assign('templates', { library: sanitizeLibrary(tpl.library) }); + + const ses = section('session'); + if (ses) assign('session', { + idleMinutes: count(ses.idleMinutes, 7 * 24 * 60), + maxHours: count(ses.maxHours, 24 * 365), + warnSeconds: count(ses.warnSeconds, 600), + }); + + const rec = section('recovery'); + if (rec) assign('recovery', { days: count(rec.days, 3650) }); + + return out; +} + +/** Merges `override` into `base` per leaf of plain objects; arrays and values replace. */ +export function mergeOrganisation( + base: OrganisationPolicy, + override: DeepPartial, +): OrganisationPolicy { + const out = { ...base } as Record>; + for (const [sectionKey, values] of Object.entries(override)) { + if (!isRecord(values) || !isRecord(out[sectionKey])) continue; + const merged = { ...out[sectionKey] }; + for (const [key, value] of Object.entries(values)) { + if (value === undefined) continue; + if (sectionKey === 'templates' && key === 'library' && Array.isArray(value)) { + // The one array that adds up: a scope's templates join the ones it + // inherits; the same id replaces the inherited template. + const byId = new Map((merged.library as OrgTemplate[]).map((t) => [t.id, t])); + for (const template of value as OrgTemplate[]) byId.set(template.id, template); + merged.library = [...byId.values()]; + } else { + merged[key] = value; + } + } + out[sectionKey] = merged; + } + return out as unknown as OrganisationPolicy; +} + +/** A complete organisation policy from anything: defaults for whatever is missing or invalid. */ +export function sanitizeOrganisationPolicy(raw: unknown): OrganisationPolicy { + return mergeOrganisation(DEFAULT_ORGANISATION_POLICY, sanitizeOrganisationPartial(raw)); +} diff --git a/lib/admin/policy-scopes.ts b/lib/admin/policy-scopes.ts new file mode 100644 index 000000000..96bdf776b --- /dev/null +++ b/lib/admin/policy-scopes.ts @@ -0,0 +1,284 @@ +/** + * Per-domain and per-group policies (policy.json `scopes`). + * + * A scope matches a signed-in account by the domain of its address or by a + * group account in its JMAP session (the groups the user is a member of; + * groups without a mailbox don't show up there). The effective policy is + * the global one with every matching scope applied on top: domain scopes + * first, then group scopes, each in list order, the later value winning per + * key. Features, restrictions and defaults merge per key; the organisation + * settings per leaf (see mergeOrganisation). + * + * Pure functions: the policy store resolves for the active account, the + * admin panel's effective-policy view and the tests use the same code. + */ + +import type { FeatureGates, SettingRestriction, SettingsPolicy } from './types'; +import { DEFAULT_FEATURE_GATES } from './types'; +import { + DEFAULT_ORGANISATION_POLICY, + mergeOrganisation, + sanitizeOrganisationPartial, + sanitizeOrganisationPolicy, + type DeepPartial, + type OrganisationPolicy, +} from './organisation-policy'; +import { sanitizePolicyDefaults } from './policy-defaults'; + +export interface PolicyScopeOverrides { + features?: Partial; + restrictions?: Record; + defaults?: Record; + organisation?: DeepPartial; +} + +export interface PolicyScope { + /** Issued by the admin panel; lowercase letters, digits and dashes. */ + id: string; + label: string; + match: { + /** Address domains: "example.com" exactly, or "*.example.com" for its subdomains. */ + domains?: string[]; + /** Group account addresses, such as sales@example.com. */ + groups?: string[]; + }; + policy: PolicyScopeOverrides; +} + +/** Who a policy is resolved for. */ +export interface PolicySubject { + /** The account's login address. */ + email: string; + /** Addresses of the group accounts in the account's session. */ + groups: string[]; +} + +export type PolicySource = 'default' | 'global' | string; + +export interface ResolvedPolicy { + /** The policy with every matching scope applied, without `scopes`. */ + policy: SettingsPolicy; + /** Ids of the matching scopes, in the order they were applied. */ + matched: string[]; + /** + * What set each value that differs from the built-in default, by dotted + * key (`features.calendarEnabled`, `restrictions.density`, + * `defaults.density`, `organisation.session.idleMinutes`): `global` or a + * scope id. + */ + sources: Record; +} + +export const MAX_SCOPES = 200; +const SCOPE_ID_RE = /^[a-z0-9][a-z0-9-]{0,63}$/; +const DOMAIN_PATTERN_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; +const GROUP_RE = /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; +/** + * Gates the server checks itself, for requests it can't tie to a scope + * (plugin bundles, plugin secrets and approvals, the account list backup). + * They stay global: a scope that turned one on would show users a feature + * the server then refuses. + */ +export const GLOBAL_ONLY_FEATURES: ReadonlySet = new Set([ + 'pluginsEnabled', + 'pluginsUploadEnabled', + 'requirePluginApproval', + 'accountArchiveEnabled', + 'accountArchivePasswordsEnabled', +]); +const FEATURE_KEYS = new Set(Object.keys(DEFAULT_FEATURE_GATES).filter((key) => !GLOBAL_ONLY_FEATURES.has(key))); + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function isSafeKey(key: string): boolean { + return key !== '__proto__' && key !== 'constructor' && key !== 'prototype'; +} + +function cleanList(value: unknown, re: RegExp): string[] { + if (!Array.isArray(value)) return []; + const out = new Set(); + for (const item of value) { + if (typeof item !== 'string') continue; + const v = item.trim().toLowerCase().replace(/\.+$/, ''); + if (re.test(v)) out.add(v); + } + return [...out]; +} + +function sanitizeRestriction(raw: unknown): SettingRestriction | null { + if (!isRecord(raw)) return null; + const out: SettingRestriction = {}; + if (typeof raw.locked === 'boolean') out.locked = raw.locked; + if (typeof raw.hidden === 'boolean') out.hidden = raw.hidden; + if ('value' in raw) out.value = raw.value; + if (Array.isArray(raw.allowedValues)) out.allowedValues = raw.allowedValues.slice(0, 100); + if (typeof raw.min === 'number' && Number.isFinite(raw.min)) out.min = raw.min; + if (typeof raw.max === 'number' && Number.isFinite(raw.max)) out.max = raw.max; + return out; +} + +export function sanitizeRestrictions(raw: unknown): Record { + if (!isRecord(raw)) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(raw)) { + if (!isSafeKey(key)) continue; + const restriction = sanitizeRestriction(value); + if (restriction) out[key] = restriction; + } + return out; +} + +function sanitizeFeatures(raw: unknown): Partial { + if (!isRecord(raw)) return {}; + const out: Partial = {}; + for (const [key, value] of Object.entries(raw)) { + if (FEATURE_KEYS.has(key) && typeof value === 'boolean') (out as Record)[key] = value; + } + return out; +} + +/** Valid scopes from anything: bad entries are dropped, ids deduplicated, at most MAX_SCOPES. */ +export function sanitizeScopes(raw: unknown): PolicyScope[] { + if (!Array.isArray(raw)) return []; + const seen = new Set(); + const out: PolicyScope[] = []; + for (const item of raw) { + if (!isRecord(item)) continue; + const id = typeof item.id === 'string' ? item.id.trim().toLowerCase() : ''; + if (!SCOPE_ID_RE.test(id) || seen.has(id)) continue; + const match = isRecord(item.match) ? item.match : {}; + const domains = cleanList(match.domains, DOMAIN_PATTERN_RE); + const groups = cleanList(match.groups, GROUP_RE); + seen.add(id); + const policy = isRecord(item.policy) ? item.policy : {}; + out.push({ + id, + label: typeof item.label === 'string' && item.label.trim() ? item.label.trim().slice(0, 100) : id, + match: { ...(domains.length ? { domains } : {}), ...(groups.length ? { groups } : {}) }, + policy: { + features: sanitizeFeatures(policy.features), + restrictions: sanitizeRestrictions(policy.restrictions), + defaults: sanitizePolicyDefaults(policy.defaults), + organisation: sanitizeOrganisationPartial(policy.organisation), + }, + }); + if (out.length >= MAX_SCOPES) break; + } + return out; +} + +function domainOf(email: string): string { + const at = email.lastIndexOf('@'); + return at >= 0 ? email.slice(at + 1).trim().toLowerCase().replace(/\.+$/, '') : ''; +} + +/** Whether a domain pattern ("example.com" or "*.example.com") covers `domain`. */ +export function domainPatternMatches(pattern: string, domain: string): boolean { + if (!domain) return false; + if (pattern.startsWith('*.')) { + const suffix = pattern.slice(1); + return domain.endsWith(suffix) && domain.length > suffix.length; + } + return pattern === domain; +} + +export function scopeMatchesDomain(scope: PolicyScope, domain: string): boolean { + return (scope.match.domains ?? []).some((pattern) => domainPatternMatches(pattern, domain)); +} + +export function scopeMatchesGroups(scope: PolicyScope, groups: string[]): boolean { + const lower = new Set(groups.map((g) => g.trim().toLowerCase())); + return (scope.match.groups ?? []).some((group) => lower.has(group)); +} + +/** Matching scope ids in the order they apply: domain matches first, then group matches. */ +export function matchingScopes(scopes: PolicyScope[], subject: PolicySubject): PolicyScope[] { + const domain = domainOf(subject.email); + const byDomain = scopes.filter((scope) => scopeMatchesDomain(scope, domain)); + const byGroup = scopes.filter((scope) => !byDomain.includes(scope) && scopeMatchesGroups(scope, subject.groups)); + return [...byDomain, ...byGroup]; +} + +function recordSources( + sources: Record, + prefix: string, + values: Record, + source: PolicySource, + isDefault: (key: string, value: unknown) => boolean, +) { + for (const [key, value] of Object.entries(values)) { + const path = `${prefix}.${key}`; + if (isDefault(key, value) && source === 'global') continue; + sources[path] = source; + } +} + +function organisationLeaves(value: DeepPartial): Record { + const out: Record = {}; + for (const [section, values] of Object.entries(value)) { + if (!isRecord(values)) continue; + for (const [key, leaf] of Object.entries(values)) out[`${section}.${key}`] = leaf; + } + return out; +} + +function organisationDefaultLeaf(path: string): unknown { + const [section, key] = path.split('.'); + return (DEFAULT_ORGANISATION_POLICY as unknown as Record>)[section]?.[key]; +} + +/** + * The effective policy for `subject`. With no subject (not signed in, or no + * address known yet) only the global policy applies. + */ +export function resolvePolicy(policy: SettingsPolicy, subject: PolicySubject | null): ResolvedPolicy { + const scopes = sanitizeScopes(policy.scopes); + const organisation = sanitizeOrganisationPolicy(policy.organisation); + const sources: Record = {}; + const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + + recordSources(sources, 'features', { ...(policy.features ?? {}) }, 'global', + (key, value) => (DEFAULT_FEATURE_GATES as unknown as Record)[key] === value); + recordSources(sources, 'restrictions', policy.restrictions ?? {}, 'global', () => false); + recordSources(sources, 'defaults', policy.defaults ?? {}, 'global', () => false); + recordSources(sources, 'organisation', organisationLeaves(organisation), 'global', + (key, value) => sameJson(organisationDefaultLeaf(key), value)); + + const matched = subject ? matchingScopes(scopes, subject) : []; + let features = { ...DEFAULT_FEATURE_GATES, ...(policy.features ?? {}) }; + let restrictions = { ...(policy.restrictions ?? {}) }; + let defaults = { ...(policy.defaults ?? {}) }; + let org = organisation; + for (const scope of matched) { + const o = scope.policy; + features = { ...features, ...(o.features ?? {}) }; + restrictions = { ...restrictions, ...(o.restrictions ?? {}) }; + defaults = { ...defaults, ...(o.defaults ?? {}) }; + org = mergeOrganisation(org, o.organisation ?? {}); + recordSources(sources, 'features', o.features ?? {}, scope.id, () => false); + recordSources(sources, 'restrictions', o.restrictions ?? {}, scope.id, () => false); + recordSources(sources, 'defaults', o.defaults ?? {}, scope.id, () => false); + recordSources(sources, 'organisation', organisationLeaves(o.organisation ?? {}), scope.id, () => false); + } + + const effective: SettingsPolicy = { ...policy, features, restrictions, defaults, organisation: org }; + delete effective.scopes; + return { policy: effective, matched: matched.map((scope) => scope.id), sources }; +} + +/** + * The scopes a browser signed in with these addresses may see: the ones + * whose domain patterns cover one of their domains, and group scopes with a + * group address in one of those domains. Keeps one customer's scopes from + * reaching another customer's users on a shared instance. + */ +export function scopesVisibleTo(scopes: PolicyScope[], emails: string[]): PolicyScope[] { + const domains = new Set(emails.map(domainOf).filter(Boolean)); + if (domains.size === 0) return []; + return scopes.filter((scope) => + [...domains].some((domain) => scopeMatchesDomain(scope, domain)) + || (scope.match.groups ?? []).some((group) => domains.has(domainOf(group))), + ); +} diff --git a/lib/admin/policy-subject.ts b/lib/admin/policy-subject.ts new file mode 100644 index 000000000..1825ad2a4 --- /dev/null +++ b/lib/admin/policy-subject.ts @@ -0,0 +1,30 @@ +import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import type { PolicySubject } from './policy-scopes'; + +type SubjectClient = Pick; + +/** + * Who a signed-in client is for the domain and group scopes: its login + * address and the addresses of the group accounts in its session. Stalwart + * names group accounts by their address (local-data/org-features-facts.md + * §1). A mailbox another user shared is also a non-personal account, so the + * principal type decides; when the server won't say, every non-personal + * account counts, which only matters if an admin put a person's address in a + * group scope. + */ +export async function policySubjectForClient(client: SubjectClient | null): Promise { + if (!client) return null; + const email = client.getUsername()?.trim().toLowerCase() ?? ''; + if (!email) return null; + const shared = client.getSharedAccounts() + .filter((account) => !account.isPrimary && client.getAccountInfo(account.id)?.isPersonal === false) + .filter((account) => account.name.includes('@')); + const types = shared.length > 0 && client.getPrincipalTypes + ? await client.getPrincipalTypes(shared.map((account) => account.id)) + : null; + const groups = shared + .filter((account) => !types || types[account.id] === 'group') + .map((account) => account.name.trim().toLowerCase()) + .sort(); + return { email, groups }; +} diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 3a1bdaae8..54fc77859 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -1,5 +1,8 @@ // Admin dashboard types +import type { OrganisationPolicy } from './organisation-policy'; +import type { PolicyScope } from './policy-scopes'; + /** * Operator-authored admin record. Lives in admin.json under the config dir * and can be mounted read-only after setup. Only the password hash itself @@ -192,6 +195,14 @@ export interface SettingsPolicy { * operator can ship a fixed set while forbidding custom ones. */ defaultSidebarApps?: AdminSidebarApp[]; + /** Organisation settings (signatures, external mail, sessions...); lib/admin/organisation-policy.ts. */ + organisation?: OrganisationPolicy; + /** + * Overrides per domain or group, applied on top of the rest for the + * accounts they match (lib/admin/policy-scopes.ts). The effective policy + * a client works with has none. + */ + scopes?: PolicyScope[]; } /** Set to `public` on a policy answered with the pre-login subset. */ diff --git a/lib/auth/instance-details.ts b/lib/auth/instance-details.ts index 85ff22f2e..ac1f42404 100644 --- a/lib/auth/instance-details.ts +++ b/lib/auth/instance-details.ts @@ -7,6 +7,7 @@ import { sessionCookieName } from '@/lib/auth/session-cookie'; import { verifyCurrentAdminSession } from '@/lib/admin/session'; import { ADMIN_SESSION_COOKIE } from '@/lib/admin/types'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; /** * Whether a request may see details that fingerprint this instance: exact @@ -31,3 +32,29 @@ export async function canSeeInstanceDetails(request: NextRequest): Promise { + if (!hasSessionSecret()) return null; + const jar = await cookies(); + const admin = jar.get(ADMIN_SESSION_COOKIE)?.value; + if (admin && (await verifyCurrentAdminSession(admin)) !== null) return { admin: true }; + + const addresses = new Set(); + for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { + const context = readStalwartAuthContextFromStore(jar, slot); + if (context) { + addresses.add(context.username); + if (context.accountName) addresses.add(context.accountName); + } + const session = jar.get(sessionCookieName(slot))?.value; + const remembered = session ? decryptSession(session) : null; + if (remembered) addresses.add(remembered.username); + } + return { admin: false, addresses: [...addresses].map((a) => a.trim().toLowerCase()).filter(Boolean) }; +} diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 551f32010..b5fbc4e52 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -608,6 +608,8 @@ export interface IJMAPClient { getPrincipals(targetAccountId?: string, options?: { throwOnError?: boolean }): Promise; // The same list with how complete it is. Optional (demo client). listPrincipals?(targetAccountId?: string, options?: { throwOnError?: boolean }): Promise; + /** Principal type by id ("individual", "group"); null when unknown. Optional (demo client). */ + getPrincipalTypes?(ids: string[]): Promise | null>; setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise; setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise; setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 3d76ace05..0fd9efa67 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -7052,6 +7052,30 @@ export class JMAPClient implements IJMAPClient { return calendars?.mayGetAvailability !== false; } + /** + * The principal type ("individual", "group") of each id. Stalwart uses the + * account id as the principal id, and Principal/get answers for accounts + * the user can access even with directory queries off. Null when the + * server has no principals or refuses. + */ + async getPrincipalTypes(ids: string[]): Promise | null> { + if (!this.supportsPrincipals() || ids.length === 0) return ids.length === 0 ? {} : null; + try { + const response = await this.request([ + ["Principal/get", { accountId: this.accountId, ids, properties: ["id", "type"] }, "0"], + ], this.principalsUsing()); + const [method, result] = response.methodResponses?.[0] ?? []; + if (method !== "Principal/get") return null; + const types: Record = {}; + for (const principal of (result as { list?: Principal[] }).list ?? []) { + if (principal.id && typeof principal.type === "string") types[principal.id] = principal.type; + } + return types; + } catch { + return null; + } + } + /** * The busy periods of a principal between `utcStart` and `utcEnd` * (Principal/getAvailability). Event details are never requested: this diff --git a/lib/lite-config.ts b/lib/lite-config.ts index b3f6ed201..17accce2a 100644 --- a/lib/lite-config.ts +++ b/lib/lite-config.ts @@ -2,6 +2,8 @@ import type { ConfigData } from '@/hooks/use-config'; import type { SettingsPolicy } from '@/lib/admin/types'; import { DEFAULT_POLICY } from '@/lib/admin/types'; import { httpUrlOrEmpty } from '@/lib/config-validation'; +import { sanitizeOrganisationPolicy } from '@/lib/admin/organisation-policy'; +import { sanitizeRestrictions, sanitizeScopes } from '@/lib/admin/policy-scopes'; /** * Flags that need the Next.js server (cookies, filesystem, OAuth relay). They @@ -114,22 +116,32 @@ export function applyLitePolicy(raw: unknown): SettingsPolicy { return { ...DEFAULT_POLICY, ...input, - restrictions: { ...(input.restrictions ?? {}) }, + restrictions: sanitizeRestrictions(input.restrictions), defaults: { ...(input.defaults ?? {}) }, themePolicy: { ...DEFAULT_POLICY.themePolicy, ...(input.themePolicy ?? {}) }, features: { ...DEFAULT_POLICY.features, ...(input.features ?? {}), - pluginsEnabled: false, - pluginsUploadEnabled: false, - sidebarAppsEnabled: false, - // Kept by this app's server, which Lite does not have. - accountArchiveEnabled: false, - accountArchivePasswordsEnabled: false, + ...LITE_PINNED_FEATURES, }, forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], defaultSidebarApps: [], + organisation: sanitizeOrganisationPolicy(input.organisation), + // A scope can't switch back on what Lite can't do. + scopes: sanitizeScopes(input.scopes).map((scope) => ({ + ...scope, + policy: { ...scope.policy, features: { ...scope.policy.features, ...LITE_PINNED_FEATURES } }, + })), }; } + +const LITE_PINNED_FEATURES = { + pluginsEnabled: false, + pluginsUploadEnabled: false, + sidebarAppsEnabled: false, + // Kept by this app's server, which Lite does not have. + accountArchiveEnabled: false, + accountArchivePasswordsEnabled: false, +} as const; diff --git a/stores/__tests__/policy-store-scopes.test.ts b/stores/__tests__/policy-store-scopes.test.ts new file mode 100644 index 000000000..0ccd04622 --- /dev/null +++ b/stores/__tests__/policy-store-scopes.test.ts @@ -0,0 +1,106 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// The effective policy follows the active account: its domain and group +// scopes are applied on top of the fetched policy, and switching accounts +// re-resolves it. Settings an earlier account's scope imposed go back to the +// default when the next account has no such scope. + +const fetchMock = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', () => ({ apiFetch: fetchMock })); + +import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore, adminPolicyReleasePatch } from '@/stores/settings-store'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { policySubjectForClient } from '@/lib/admin/policy-subject'; + +const POLICY = { + ...DEFAULT_POLICY, + scopes: [ + { id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: { + features: { calendarEnabled: false }, + restrictions: { density: { locked: true, value: 'compact' } }, + } }, + { id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: { + defaults: { showPreview: false }, + organisation: { session: { idleMinutes: 20 } }, + } }, + ], +}; + +beforeEach(async () => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(new Response(JSON.stringify(POLICY))); + usePolicyStore.getState().setPolicySubject(null); + await usePolicyStore.getState().fetchPolicy(); +}); + +describe('policy store scopes', () => { + it('resolves for the subject and again when it changes', () => { + const store = usePolicyStore.getState(); + expect(store.isFeatureEnabled('calendarEnabled')).toBe(true); + + store.setPolicySubject({ email: 'org-dave@org-two.test', groups: [] }); + expect(usePolicyStore.getState().isFeatureEnabled('calendarEnabled')).toBe(false); + expect(usePolicyStore.getState().isSettingLocked('density')).toBe(true); + expect(usePolicyStore.getState().matchedScopes).toEqual(['two']); + expect(usePolicyStore.getState().getPolicySource('restrictions.density')).toBe('two'); + + store.setPolicySubject({ email: 'org-alice@example.org', groups: ['org-team@example.org'] }); + expect(usePolicyStore.getState().isFeatureEnabled('calendarEnabled')).toBe(true); + expect(usePolicyStore.getState().isSettingLocked('density')).toBe(false); + expect(usePolicyStore.getState().getOrganisation().session.idleMinutes).toBe(20); + }); + + it('keeps the subject across a refetch', async () => { + usePolicyStore.getState().setPolicySubject({ email: 'org-dave@org-two.test', groups: [] }); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().isFeatureEnabled('calendarEnabled')).toBe(false); + }); + + it('resolves another account without switching to it', () => { + usePolicyStore.getState().setPolicySubject({ email: 'org-carol@example.org', groups: [] }); + const other = usePolicyStore.getState().resolveFor({ email: 'org-bob@example.org', groups: ['org-team@example.org'] }); + expect(other.organisation?.session.idleMinutes).toBe(20); + expect(usePolicyStore.getState().getOrganisation().session.idleMinutes).toBe(0); + }); + + it('puts a locked value back to the default once the next account has no lock', () => { + usePolicyStore.getState().setPolicySubject({ email: 'org-dave@org-two.test', groups: [] }); + expect(useSettingsStore.getState().density).toBe('compact'); + usePolicyStore.getState().setPolicySubject({ email: 'org-carol@example.org', groups: [] }); + expect(useSettingsStore.getState().density).toBe('regular'); + }); +}); + +describe('adminPolicyReleasePatch', () => { + it('leaves values the user chose, and values the next policy still imposes', () => { + const previous = { defaults: { showPreview: false, density: 'compact' }, restrictions: {} }; + const state = { showPreview: false, density: 'compact', explicitSettings: ['density'] }; + expect(adminPolicyReleasePatch(state, previous, { defaults: {}, restrictions: {} })).toEqual({ showPreview: true }); + expect(adminPolicyReleasePatch(state, previous, { defaults: { showPreview: false }, restrictions: {} })).toEqual({}); + expect(adminPolicyReleasePatch({ ...state, showPreview: true }, previous, {})).toEqual({}); + }); +}); + +describe('policySubjectForClient', () => { + const client = (types: Record | null) => ({ + getUsername: () => 'Org-Alice@Example.org', + getSharedAccounts: () => [ + { id: 'ci', name: 'org-alice@example.org', isPrimary: true, capabilities: {} }, + { id: 'ch', name: 'org-team@example.org', isPrimary: false, capabilities: {} }, + { id: 'cj', name: 'org-bob@example.org', isPrimary: false, capabilities: {} }, + ] as never, + getAccountInfo: (id: string) => ({ name: id, isPersonal: id === 'ci', isReadOnly: false, accountCapabilities: {} }), + getPrincipalTypes: async () => types, + }); + + it('counts only group principals, not mailboxes another user shared', async () => { + expect(await policySubjectForClient(client({ ch: 'group', cj: 'individual' }))) + .toEqual({ email: 'org-alice@example.org', groups: ['org-team@example.org'] }); + }); + + it('falls back to every shared account when the server won\'t say', async () => { + expect((await policySubjectForClient(client(null)))?.groups) + .toEqual(['org-bob@example.org', 'org-team@example.org']); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 09e5031df..8d3cc772b 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -23,6 +23,7 @@ import { broadcastSignOut, onSignedOutElsewhere, purgeSignedOutData } from '@/li import { useSearchHistoryStore } from './search-history-store'; import { useCalendarNotificationStore } from './calendar-notification-store'; import { usePolicyStore } from './policy-store'; +import { policySubjectForClient } from '@/lib/admin/policy-subject'; import type { Identity } from '@/lib/jmap/types'; import { authHooks } from '@/lib/plugin-hooks'; import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite'; @@ -3160,6 +3161,26 @@ useAuthStore.subscribe((state, prev) => { if (state.isAuthenticated && !prev.isAuthenticated) void usePolicyStore.getState().refreshIfPartial(); }); +// Domain and group scopes follow the active account. setPolicySubject skips +// the work when the account's address and groups stayed the same. +let policySubjectRequest = 0; +useAuthStore.subscribe((state, prev) => { + if ( + state.client === prev.client + && state.activeAccountId === prev.activeAccountId + && state.isAuthenticated === prev.isAuthenticated + && state.connectedAccountsRevision === prev.connectedAccountsRevision + ) return; + const request = ++policySubjectRequest; + if (!state.isAuthenticated || !state.client) { + usePolicyStore.getState().setPolicySubject(null); + return; + } + void policySubjectForClient(state.client).then((subject) => { + if (request === policySubjectRequest) usePolicyStore.getState().setPolicySubject(subject); + }); +}); + // See canEnterLoginInPlace. let signedInSincePageLoad = useAuthStore.getState().isAuthenticated; useAuthStore.subscribe((state) => { diff --git a/stores/policy-store.ts b/stores/policy-store.ts index d0bfb3a04..733d6a04e 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -4,9 +4,24 @@ import { DEFAULT_POLICY, DEFAULT_THEME_POLICY, POLICY_SCOPE_HEADER } from '@/lib import { apiFetch } from '@/lib/browser-navigation'; import { IS_LITE, IS_LITE_STALWART, LITE_POLICY_PATH, withLiteBuildId } from '@/lib/lite'; import { applyLitePolicy } from '@/lib/lite-config'; +import { resolvePolicy, type PolicySource, type PolicySubject } from '@/lib/admin/policy-scopes'; +import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy'; interface PolicyState { + /** + * The effective policy for the active account: the policy as fetched with + * the domain and group scopes that match the account applied. Everything + * that reads the policy reads this. + */ policy: SettingsPolicy; + /** The policy as fetched, scopes included. */ + rawPolicy: SettingsPolicy; + /** The account `policy` was resolved for; null before one is known. */ + subject: PolicySubject | null; + /** Ids of the scopes that apply to `subject`. */ + matchedScopes: string[]; + /** What set each non-default value of `policy` (see ResolvedPolicy.sources). */ + sources: Record; loaded: boolean; /** * The server answered with the public subset (not signed in yet); the @@ -21,6 +36,14 @@ interface PolicyState { demoFeatureOverrides: Partial; setDemoFeatureOverrides: (overrides: Partial) => void; fetchPolicy: () => Promise; + /** Resolve the policy for this account (the active one); null for none. */ + setPolicySubject: (subject: PolicySubject | null) => void; + /** The effective policy of another account, without making it the active one. */ + resolveFor: (subject: PolicySubject | null) => SettingsPolicy; + /** The organisation settings of the effective policy. */ + getOrganisation: () => OrganisationPolicy; + /** `global`, a scope id, or `default` when nothing set it. */ + getPolicySource: (key: string) => PolicySource; /** Fetch the full policy if only the public subset is loaded. */ refreshIfPartial: () => Promise; isSettingLocked: (key: string) => boolean; @@ -45,8 +68,19 @@ let policyRequest = 0; const COLOR_MODES: readonly ColorMode[] = ['light', 'dark', 'system']; +const sameSubject = (a: PolicySubject | null, b: PolicySubject | null) => + a === b || (!!a && !!b && a.email === b.email && a.groups.join(',') === b.groups.join(',')); + +function resolved(rawPolicy: SettingsPolicy, subject: PolicySubject | null) { + const { policy, matched, sources } = resolvePolicy(rawPolicy, subject); + return { rawPolicy, policy, matchedScopes: matched, sources }; +} + +const INITIAL = resolved({ ...DEFAULT_POLICY }, null); + export const usePolicyStore = create()((set, get) => ({ - policy: { ...DEFAULT_POLICY }, + ...INITIAL, + subject: null, loaded: false, partial: false, demoFeatureOverrides: {}, @@ -71,15 +105,26 @@ export const usePolicyStore = create()((set, get) => ({ const data = await res.json(); if (request !== policyRequest) return; const partial = res.headers?.get?.(POLICY_SCOPE_HEADER) === 'public'; - set({ policy: IS_LITE ? applyLitePolicy(data) : data, loaded: true, partial }); + set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, partial }); } else { - set({ ...fallback, loaded: true }); + set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true }); } } catch { - if (request === policyRequest) set({ ...fallback, loaded: true }); + if (request === policyRequest) set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true }); } }, + setPolicySubject: (subject) => { + if (sameSubject(subject, get().subject)) return; + set({ ...resolved(get().rawPolicy, subject), subject }); + }, + + resolveFor: (subject) => resolvePolicy(get().rawPolicy, subject).policy, + + getOrganisation: () => get().policy.organisation ?? DEFAULT_ORGANISATION_POLICY, + + getPolicySource: (key) => get().sources[key] ?? 'default', + refreshIfPartial: async () => { if (IS_LITE || (get().loaded && !get().partial)) return; await get().fetchPolicy(); diff --git a/stores/settings-store.ts b/stores/settings-store.ts index bf50b3338..95b880059 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -746,9 +746,10 @@ interface SettingsState { * Applies the admin policy (#1248): `policy.defaults` to every setting the * user has not chosen, and a locked restriction's value to its setting * whatever the user chose. Runs when the policy loads and after every - * import, reset and server load. + * import, reset and server load. With the `previous` policy, values it + * imposed and the current one no longer does go back to the default. */ - applyAdminPolicy: () => void; + applyAdminPolicy: (previous?: SettingsPolicy) => void; /** `forSync` builds the snapshot pushed to the server; it is not a user export. */ exportSettings: (opts?: { forSync?: boolean; subscriptionOwner?: string }) => string; importSettings: (json: string, opts?: { serverAccountId?: string; keepCrossAccountViews?: boolean; subscriptionOwner?: string }) => boolean; @@ -1095,6 +1096,40 @@ export function adminPolicyPatch( return patch; } +/** + * What goes back to the built-in default when a policy stops applying a + * value: a default or lock that the previous policy had and the next one + * doesn't (another account's scopes, or the admin removed it). Only for + * settings the user hasn't chosen and that still hold the imposed value. + * Exported for tests. + */ +export function adminPolicyReleasePatch( + state: Record, + previous: Partial>, + next: Partial>, +): Record { + const explicit = new Set(Array.isArray(state.explicitSettings) ? (state.explicitSettings as string[]) : []); + const imposed = (policy: typeof previous) => { + const out = new Map(); + const defaults = isPlainRecord(policy.defaults) ? policy.defaults : {}; + const restrictions = isPlainRecord(policy.restrictions) ? (policy.restrictions as Record) : {}; + for (const [key, value] of Object.entries(defaults)) out.set(key, value); + for (const [key, restriction] of Object.entries(restrictions)) { + if (!isPlainRecord(restriction) || restriction.locked !== true) continue; + out.set(key, hasOwn(restriction, 'value') ? restriction.value : hasOwn(defaults, key) ? defaults[key] : undefined); + } + return out; + }; + const before = imposed(previous); + const after = imposed(next); + const patch: Record = {}; + for (const [key, value] of before) { + if (after.has(key) || explicit.has(key) || !isPolicySettingKey(key)) continue; + if (state[key] === value && value !== builtInDefault(key)) patch[key] = builtInDefault(key); + } + return patch; +} + export const useSettingsStore = create()( persist( (set, get) => { @@ -1342,8 +1377,12 @@ export const useSettingsStore = create()( }); }, - applyAdminPolicy: () => { - if (applyAdminPolicyPatch()) applyVisualSettings(); + applyAdminPolicy: (previous) => { + const released = previous + ? adminPolicyReleasePatch(get() as unknown as Record, previous, usePolicyStore.getState().policy) + : {}; + if (Object.keys(released).length > 0) set(released as Partial); + if (applyAdminPolicyPatch() || Object.keys(released).length > 0) applyVisualSettings(); }, resetToDefaults: () => { @@ -2010,7 +2049,7 @@ if (typeof window !== 'undefined') { // full scope once signed in), so its defaults apply whenever it changes. usePolicyStore.subscribe((policyState, prev) => { if (policyState.policy === prev.policy) return; - useSettingsStore.getState().applyAdminPolicy(); + useSettingsStore.getState().applyAdminPolicy(prev.policy); }); const triggerSync = () => { From 0738f6f999a261be7a833c6ebbffdd7e5c8bf104 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:29:03 +0100 Subject: [PATCH 002/137] feat: say "Set by your organisation" next to settings an admin locked --- app/(main)/admin/_pages/policy.tsx | 24 +-------- .../__tests__/setting-item-locked.test.tsx | 30 ++++++++++++ components/settings/org-locked-note.tsx | 21 ++++++++ components/settings/settings-section.tsx | 8 +-- hooks/use-restriction.ts | 32 ++++++++++++ lib/admin/restriction-keys.ts | 49 +++++++++++++++++++ locales/de/common.json | 1 + locales/en/common.json | 1 + 8 files changed, 140 insertions(+), 26 deletions(-) create mode 100644 components/settings/__tests__/setting-item-locked.test.tsx create mode 100644 components/settings/org-locked-note.tsx create mode 100644 hooks/use-restriction.ts create mode 100644 lib/admin/restriction-keys.ts diff --git a/app/(main)/admin/_pages/policy.tsx b/app/(main)/admin/_pages/policy.tsx index e1ae84045..9876f3eec 100644 --- a/app/(main)/admin/_pages/policy.tsx +++ b/app/(main)/admin/_pages/policy.tsx @@ -7,6 +7,7 @@ import type { SettingsPolicy, FeatureGates, PushRelayOption, AdminSidebarApp } f import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; import { adminPageDef } from '@/lib/admin/admin-nav'; +import { restrictionKeysForPage } from '@/lib/admin/restriction-keys'; import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; import { DEFAULT_SIDEBAR_APP_ID_PREFIX, MAX_DEFAULT_SIDEBAR_APPS } from '@/lib/sidebar-apps'; import { generateUUID, cn } from '@/lib/utils'; @@ -54,28 +55,7 @@ const FEATURE_GATE_LABELS: Partial + + , + ); +} + +describe('SettingItem locked', () => { + it('shows "Set by your organisation" and makes the control inert', () => { + renderItem(true); + expect(screen.getByText('org_locked')).toBeTruthy(); + const control = screen.getByRole('button', { name: 'Compact', hidden: true }).parentElement!; + expect(control.hasAttribute('inert')).toBe(true); + }); + + it('shows neither when not locked', () => { + renderItem(false); + expect(screen.queryByText('org_locked')).toBeNull(); + expect(screen.getByRole('button', { name: 'Compact' }).parentElement!.hasAttribute('inert')).toBe(false); + }); +}); diff --git a/components/settings/org-locked-note.tsx b/components/settings/org-locked-note.tsx new file mode 100644 index 000000000..35c73038d --- /dev/null +++ b/components/settings/org-locked-note.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { Lock } from "@/components/icons"; +import { cn } from "@/lib/utils"; + +/** + * "Set by your organisation", shown in place of the explanation-free + * disabled control wherever an admin locked something: next to a + * SettingItem's label, and in editors that aren't one (signature, templates, + * sharing, tags). + */ +export function OrgLockedNote({ className }: { className?: string }) { + const t = useTranslations("settings"); + return ( +

+

+ ); +} diff --git a/components/settings/settings-section.tsx b/components/settings/settings-section.tsx index a3e103c78..495f0f047 100644 --- a/components/settings/settings-section.tsx +++ b/components/settings/settings-section.tsx @@ -2,7 +2,7 @@ import { ReactNode, createContext, useContext, useEffect, useId, useState, useCallback, useMemo, useRef } from 'react'; import { useTranslations } from 'next-intl'; -import { Lock } from '@/components/icons'; +import { OrgLockedNote } from './org-locked-note'; import { cn } from '@/lib/utils'; import { useSettingsStore } from '@/stores/settings-store'; @@ -211,22 +211,22 @@ export function SettingItem({ label, description, children, locked, advanced, ne data-search-label={label} className={cn( "flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4 py-3 border-b border-border last:border-0", - locked && "opacity-60", nested && "ms-4 ps-3 border-s-2 border-s-border border-b-transparent py-2", )} >
- {locked && } {advanced && }
{description && (

{description}

)} + {locked && }
-
{children}
+ {/* The admin's value stays readable; inert keeps mouse and keyboard off it. */} +
{children}
); diff --git a/hooks/use-restriction.ts b/hooks/use-restriction.ts new file mode 100644 index 000000000..9edc49c4d --- /dev/null +++ b/hooks/use-restriction.ts @@ -0,0 +1,32 @@ +import { usePolicyStore } from '@/stores/policy-store'; + +export interface RestrictionView { + /** Not offered to the user at all. */ + hidden: boolean; + /** Shown with the admin's value and "Set by your organisation", not changeable. */ + locked: boolean; + /** Values the user may pick from, when the admin limited them. */ + allowedValues: unknown[] | null; + min: number | null; + max: number | null; + /** The value a lock pins, if it names one. */ + value: unknown; +} + +/** + * The admin's restriction on one user-facing option, from the effective + * policy of the active account (domain and group scopes applied). Every + * option an admin can restrict reads it through here, so locking works the + * same everywhere. + */ +export function useRestriction(key: string): RestrictionView { + const restriction = usePolicyStore((state) => state.policy.restrictions[key]); + return { + hidden: restriction?.hidden === true, + locked: restriction?.locked === true, + allowedValues: Array.isArray(restriction?.allowedValues) && restriction.allowedValues.length > 0 ? restriction.allowedValues : null, + min: typeof restriction?.min === 'number' ? restriction.min : null, + max: typeof restriction?.max === 'number' ? restriction.max : null, + value: restriction?.value, + }; +} diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts new file mode 100644 index 000000000..089a410a4 --- /dev/null +++ b/lib/admin/restriction-keys.ts @@ -0,0 +1,49 @@ +/** + * Every setting an admin can lock, hide or limit (SettingRestriction in + * lib/admin/types.ts), with where the admin panel offers it. The User policy + * page lists the ones with page `policy`; each Organisation page lists its + * own next to the settings they belong to. New user-facing options add their + * key here instead of a lock flag of their own. + */ + +import type { AdminPageId } from './admin-nav'; + +export interface RestrictionKeyDef { + key: string; + label: string; + /** One sentence for the admin: what locking or hiding it does for users. */ + description?: string; + category: string; + type: 'boolean' | 'number' | 'enum'; + allowedValues?: string[]; + /** The admin page that lists it. */ + page: AdminPageId; + /** Which of locked, hidden and allowed values make sense for it. */ + modes?: ReadonlyArray<'locked' | 'hidden' | 'allowedValues'>; +} + +export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [ + { key: 'fontSize', label: 'Font Size', category: 'Appearance', type: 'enum', allowedValues: ['small', 'medium', 'large'], page: 'policy' }, + { key: 'density', label: 'Density', category: 'Appearance', type: 'enum', allowedValues: ['compact', 'regular', 'spacious'], page: 'policy' }, + { key: 'interfaceLayout', label: 'Interface Layout', category: 'Appearance', type: 'enum', allowedValues: ['default', 'mountain-view'], page: 'policy' }, + { key: 'animationsEnabled', label: 'Animations', category: 'Appearance', type: 'boolean', page: 'policy' }, + { key: 'markAsReadDelay', label: 'Mark as Read Delay', category: 'Email', type: 'number', page: 'policy' }, + { key: 'deleteAction', label: 'Delete Action', category: 'Email', type: 'enum', allowedValues: ['trash', 'trash-and-read', 'permanent'], page: 'policy' }, + { key: 'showPreview', label: 'Show Preview', category: 'Email', type: 'boolean', page: 'policy' }, + { key: 'mailLayout', label: 'Mail Layout', category: 'Email', type: 'enum', allowedValues: ['split', 'focus', 'horizontal'], page: 'policy' }, + { key: 'emailsPerPage', label: 'Emails Per Page', category: 'Email', type: 'number', page: 'policy' }, + { key: 'externalContentPolicy', label: 'External Content Policy', category: 'Email', type: 'enum', allowedValues: ['allow', 'block', 'ask'], page: 'policy' }, + { key: 'sendConfirmation', label: 'Send Confirmation', category: 'Composer', type: 'boolean', page: 'policy' }, + { key: 'defaultReplyMode', label: 'Default Reply Mode', category: 'Composer', type: 'enum', allowedValues: ['reply', 'reply-all'], page: 'policy' }, + { key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean', page: 'policy' }, + { key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'], page: 'policy' }, + { key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean', page: 'policy' }, + { key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean', page: 'policy' }, + { key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean', page: 'policy' }, + { key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean', page: 'policy' }, + { key: 'debugMode', label: 'Debug Mode', category: 'Advanced', type: 'boolean', page: 'policy' }, +]; + +export function restrictionKeysForPage(page: AdminPageId): RestrictionKeyDef[] { + return RESTRICTION_KEYS.filter((def) => def.page === page); +} diff --git a/locales/de/common.json b/locales/de/common.json index 9be82dc88..ffdd16235 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1047,6 +1047,7 @@ }, "settings": { "title": "Einstellungen", + "org_locked": "Von Ihrer Organisation festgelegt", "back_to_mail": "Zurück zu E-Mails", "save_success": "Einstellungen erfolgreich gespeichert", "import_success": "Einstellungen erfolgreich importiert", diff --git a/locales/en/common.json b/locales/en/common.json index ef96a3e74..95ec87fa3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1047,6 +1047,7 @@ }, "settings": { "title": "Settings", + "org_locked": "Set by your organisation", "back_to_mail": "Back to Mail", "save_success": "Settings saved successfully", "import_success": "Settings imported successfully", From d19095fafe509806fdfa708c105d356e4259e371 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:35:00 +0100 Subject: [PATCH 003/137] feat: fetch the policy again when an account from another domain is added --- lib/admin/policy-subject.ts | 11 ++++++----- stores/auth-store.ts | 13 +++++++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/lib/admin/policy-subject.ts b/lib/admin/policy-subject.ts index 1825ad2a4..eac0654f0 100644 --- a/lib/admin/policy-subject.ts +++ b/lib/admin/policy-subject.ts @@ -13,14 +13,15 @@ type SubjectClient = Pick { - if (!client) return null; + if (!client || typeof client.getUsername !== 'function') return null; const email = client.getUsername()?.trim().toLowerCase() ?? ''; if (!email) return null; - const shared = client.getSharedAccounts() - .filter((account) => !account.isPrimary && client.getAccountInfo(account.id)?.isPersonal === false) + // A client without a session yet (or a stand-in) has no shared accounts. + const shared = (typeof client.getSharedAccounts === 'function' ? client.getSharedAccounts() : []) + .filter((account) => !account.isPrimary && client.getAccountInfo?.(account.id)?.isPersonal === false) .filter((account) => account.name.includes('@')); - const types = shared.length > 0 && client.getPrincipalTypes - ? await client.getPrincipalTypes(shared.map((account) => account.id)) + const types = shared.length > 0 && typeof client.getPrincipalTypes === 'function' + ? await client.getPrincipalTypes(shared.map((account) => account.id)).catch(() => null) : null; const groups = shared .filter((account) => !types || types[account.id] === 'group') diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 8d3cc772b..d971d992d 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -3164,6 +3164,7 @@ useAuthStore.subscribe((state, prev) => { // Domain and group scopes follow the active account. setPolicySubject skips // the work when the account's address and groups stayed the same. let policySubjectRequest = 0; +const policySubjectsSeen = new Set(); useAuthStore.subscribe((state, prev) => { if ( state.client === prev.client @@ -3176,8 +3177,16 @@ useAuthStore.subscribe((state, prev) => { usePolicyStore.getState().setPolicySubject(null); return; } - void policySubjectForClient(state.client).then((subject) => { - if (request === policySubjectRequest) usePolicyStore.getState().setPolicySubject(subject); + void policySubjectForClient(state.client).then(async (subject) => { + if (request !== policySubjectRequest) return; + // The server sends only the scopes of the domains signed in when the + // policy was fetched; an account added since may have scopes of its own. + if (subject && !policySubjectsSeen.has(subject.email)) { + policySubjectsSeen.add(subject.email); + if (policySubjectsSeen.size > 1) await usePolicyStore.getState().fetchPolicy(); + if (request !== policySubjectRequest) return; + } + usePolicyStore.getState().setPolicySubject(subject); }); }); From e76490580d08be47258545872676fe329df96d94 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:40:56 +0100 Subject: [PATCH 004/137] feat: add an Organisation area to the admin panel with domain and group scopes --- app/(main)/admin/[[...segments]]/page.tsx | 10 + .../__tests__/organisation-page.test.tsx | 97 +++++++ app/(main)/admin/_pages/org-security.tsx | 18 ++ app/(main)/admin/_pages/org-sharing.tsx | 18 ++ app/(main)/admin/_pages/org-signatures.tsx | 14 + app/(main)/admin/_pages/org-templates.tsx | 14 + app/(main)/admin/_pages/organisation.tsx | 265 ++++++++++++++++++ components/admin/admin-shell.tsx | 13 +- components/admin/org/org-page.tsx | 180 ++++++++++++ .../admin/org/sections/delegates-section.tsx | 14 + .../org/sections/external-mail-section.tsx | 14 + .../admin/org/sections/recovery-section.tsx | 14 + .../admin/org/sections/sessions-section.tsx | 14 + .../org/sections/shared-tags-section.tsx | 14 + .../admin/org/sections/signature-section.tsx | 14 + .../admin/org/sections/templates-section.tsx | 14 + .../org/sections/third-party-section.tsx | 14 + components/admin/org/use-org-policy-draft.ts | 237 ++++++++++++++++ lib/admin/admin-nav.ts | 68 ++++- 19 files changed, 1043 insertions(+), 3 deletions(-) create mode 100644 app/(main)/admin/__tests__/organisation-page.test.tsx create mode 100644 app/(main)/admin/_pages/org-security.tsx create mode 100644 app/(main)/admin/_pages/org-sharing.tsx create mode 100644 app/(main)/admin/_pages/org-signatures.tsx create mode 100644 app/(main)/admin/_pages/org-templates.tsx create mode 100644 app/(main)/admin/_pages/organisation.tsx create mode 100644 components/admin/org/org-page.tsx create mode 100644 components/admin/org/sections/delegates-section.tsx create mode 100644 components/admin/org/sections/external-mail-section.tsx create mode 100644 components/admin/org/sections/recovery-section.tsx create mode 100644 components/admin/org/sections/sessions-section.tsx create mode 100644 components/admin/org/sections/shared-tags-section.tsx create mode 100644 components/admin/org/sections/signature-section.tsx create mode 100644 components/admin/org/sections/templates-section.tsx create mode 100644 components/admin/org/sections/third-party-section.tsx create mode 100644 components/admin/org/use-org-policy-draft.ts diff --git a/app/(main)/admin/[[...segments]]/page.tsx b/app/(main)/admin/[[...segments]]/page.tsx index 120e5b6a6..4d71528bc 100644 --- a/app/(main)/admin/[[...segments]]/page.tsx +++ b/app/(main)/admin/[[...segments]]/page.tsx @@ -8,6 +8,11 @@ import { SettingsPage } from '../_pages/settings'; import { BrandingPage } from '../_pages/branding'; import { AuthPage } from '../_pages/auth'; import { PolicyPage } from '../_pages/policy'; +import { OrganisationPage } from '../_pages/organisation'; +import { OrgSignaturesPage } from '../_pages/org-signatures'; +import { OrgTemplatesPage } from '../_pages/org-templates'; +import { OrgSecurityPage } from '../_pages/org-security'; +import { OrgSharingPage } from '../_pages/org-sharing'; import { PluginsPage } from '../_pages/plugins'; import { ThemesPage } from '../_pages/themes'; import { MarketplacePage } from '../_pages/marketplace'; @@ -41,6 +46,11 @@ export default function AdminPage() { case 'branding': return ; case 'auth': return ; case 'policy': return ; + case 'organisation': return ; + case 'org-signatures': return ; + case 'org-templates': return ; + case 'org-security': return ; + case 'org-sharing': return ; case 'plugins': return detail ? : ; case 'themes': return detail ? : ; case 'marketplace': return detail ? : ; diff --git a/app/(main)/admin/__tests__/organisation-page.test.tsx b/app/(main)/admin/__tests__/organisation-page.test.tsx new file mode 100644 index 000000000..8ff9c8868 --- /dev/null +++ b/app/(main)/admin/__tests__/organisation-page.test.tsx @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-library/react'; + +// The Organisation pages edit one scope at a time: Everyone (the global +// policy) or a domain or group scope. A value a scope doesn't set comes from +// Everyone and can be reset to it; saving sends the whole policy. + +const puts: Array> = []; +let stored: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + 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 { OrganisationPage } from '../_pages/organisation'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '@/components/admin/org/use-org-policy-draft'; + +const POLICY = { + restrictions: { density: { locked: true, value: 'compact' } }, + features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: { session: { idleMinutes: 30 } }, + scopes: [{ id: 'team', label: 'Team', match: { groups: ['org-team@example.org'] }, policy: {} }], +}; + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('useOrgPolicyDraft', () => { + it('writes into the selected scope and shows Everyone\'s value until then', async () => { + const { result } = renderHook(() => useOrgPolicyDraft('test')); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.setScopeId('team')); + expect(result.current.orgValue('session', 'idleMinutes')).toEqual({ value: 30, own: false, everyone: 30 }); + + act(() => result.current.setOrgValue('session', 'idleMinutes', 5)); + expect(result.current.orgValue('session', 'idleMinutes')).toEqual({ value: 5, own: true, everyone: 30 }); + expect(result.current.policy?.organisation?.session.idleMinutes).toBe(30); + expect(result.current.dirty).toBe(true); + + act(() => result.current.clearOrgValue('session', 'idleMinutes')); + expect(result.current.orgValue('session', 'idleMinutes').own).toBe(false); + expect(result.current.policy?.scopes?.[0].policy.organisation).toEqual({}); + }); + + it('lets a scope unlock what Everyone locks, and saves the whole policy', async () => { + const { result } = renderHook(() => useOrgPolicyDraft('test')); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.setScopeId('team')); + act(() => result.current.setRestriction('density', { locked: false })); + expect(result.current.policy?.scopes?.[0].policy.restrictions).toEqual({ density: {} }); + expect(result.current.policy?.restrictions.density).toEqual({ locked: true, value: 'compact' }); + + await act(async () => { await result.current.save(); }); + expect(puts).toHaveLength(1); + expect((puts[0].scopes as Array<{ policy: unknown }>)[0].policy).toEqual({ restrictions: { density: {} } }); + expect(puts[0].organisation).toMatchObject({ session: { idleMinutes: 30 } }); + }); + + it('writes to the global policy for Everyone', async () => { + const { result } = renderHook(() => useOrgPolicyDraft('test')); + await waitFor(() => expect(result.current.loading).toBe(false)); + act(() => result.current.setOrgValue('externalMail', 'senderLabel', true)); + expect(result.current.policy?.organisation?.externalMail.senderLabel).toBe(true); + expect(result.current.policy?.scopes?.[0].policy).toEqual({}); + }); +}); + +describe('OrganisationPage', () => { + it('adds a domain scope and shows which entry sets what for a user', async () => { + render(); + await screen.findByTestId('org-scope-team'); + + fireEvent.click(screen.getByRole('button', { name: /Add domains/ })); + const names = screen.getAllByLabelText('Name') as HTMLInputElement[]; + expect(names.map((n) => n.value)).toEqual(['Team', 'New domain']); + + fireEvent.change(screen.getByLabelText('User address'), { target: { value: 'org-alice@example.org' } }); + fireEvent.change(screen.getByLabelText('Groups the user is a member of'), { target: { value: 'org-team@example.org' } }); + const table = await screen.findByTestId('org-effective-policy'); + expect(table.textContent).toContain('Applies, in this order: Everyone, Team.'); + expect(table.textContent).toContain('restrictions.density'); + expect(table.textContent).toContain('organisation.session.idleMinutes'); + }); +}); diff --git a/app/(main)/admin/_pages/org-security.tsx b/app/(main)/admin/_pages/org-security.tsx new file mode 100644 index 000000000..af778d1ee --- /dev/null +++ b/app/(main)/admin/_pages/org-security.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { OrgPage } from '@/components/admin/org/org-page'; +import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; +import { ExternalMailSection } from '@/components/admin/org/sections/external-mail-section'; +import { SessionsSection } from '@/components/admin/org/sections/sessions-section'; +import { ThirdPartySection } from '@/components/admin/org/sections/third-party-section'; + +export function OrgSecurityPage() { + const draft = useOrgPolicyDraft('org-security'); + return ( + + + + + + ); +} diff --git a/app/(main)/admin/_pages/org-sharing.tsx b/app/(main)/admin/_pages/org-sharing.tsx new file mode 100644 index 000000000..2617ccc2e --- /dev/null +++ b/app/(main)/admin/_pages/org-sharing.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { OrgPage } from '@/components/admin/org/org-page'; +import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; +import { SharedTagsSection } from '@/components/admin/org/sections/shared-tags-section'; +import { DelegatesSection } from '@/components/admin/org/sections/delegates-section'; +import { RecoverySection } from '@/components/admin/org/sections/recovery-section'; + +export function OrgSharingPage() { + const draft = useOrgPolicyDraft('org-sharing'); + return ( + + + + + + ); +} diff --git a/app/(main)/admin/_pages/org-signatures.tsx b/app/(main)/admin/_pages/org-signatures.tsx new file mode 100644 index 000000000..7aa0b27c5 --- /dev/null +++ b/app/(main)/admin/_pages/org-signatures.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { OrgPage } from '@/components/admin/org/org-page'; +import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; +import { SignatureSection } from '@/components/admin/org/sections/signature-section'; + +export function OrgSignaturesPage() { + const draft = useOrgPolicyDraft('org-signatures'); + return ( + + + + ); +} diff --git a/app/(main)/admin/_pages/org-templates.tsx b/app/(main)/admin/_pages/org-templates.tsx new file mode 100644 index 000000000..bc63b0578 --- /dev/null +++ b/app/(main)/admin/_pages/org-templates.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { OrgPage } from '@/components/admin/org/org-page'; +import { useOrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; +import { TemplatesSection } from '@/components/admin/org/sections/templates-section'; + +export function OrgTemplatesPage() { + const draft = useOrgPolicyDraft('org-templates'); + return ( + + + + ); +} diff --git a/app/(main)/admin/_pages/organisation.tsx b/app/(main)/admin/_pages/organisation.tsx new file mode 100644 index 000000000..0276ef616 --- /dev/null +++ b/app/(main)/admin/_pages/organisation.tsx @@ -0,0 +1,265 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { ArrowDown, ArrowUp, Plus, Trash2 } from '@/components/icons'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/admin/badge'; +import { inputClass } from '@/components/admin/config-items'; +import { OrgPage, scopeMatchSummary } from '@/components/admin/org/org-page'; +import { EVERYONE, useOrgPolicyDraft, type OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; +import { resolvePolicy, type PolicyScope } from '@/lib/admin/policy-scopes'; +import { cn } from '@/lib/utils'; + +const fieldLabelClass = 'mb-1 block text-[11px] font-medium text-muted-foreground'; +const DOMAIN_RE = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; +const GROUP_RE = /^[^\s@]+@[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; + +function splitList(text: string): string[] { + return [...new Set(text.split(/[\s,;]+/).map((v) => v.trim().toLowerCase()).filter(Boolean))]; +} + +function newScopeId(existing: PolicyScope[]): string { + const taken = new Set(existing.map((s) => s.id)); + for (;;) { + const id = `scope-${Math.random().toString(36).slice(2, 8)}`; + if (!taken.has(id)) return id; + } +} + +/** A comma-separated list edited as text and stored as an array when the field loses focus. */ +function ListField({ label, value, placeholder, valid, onCommit }: { + label: string; + value: string[]; + placeholder: string; + valid: (item: string) => boolean; + onCommit: (items: string[]) => void; +}) { + const [text, setText] = useState(null); + const shown = text ?? value.join(', '); + const invalid = splitList(shown).filter((item) => !valid(item)); + return ( + + ); +} + +function ScopeCard({ draft, scope, index, count }: { draft: OrgPolicyDraft; scope: PolicyScope; index: number; count: number }) { + const move = (delta: number) => draft.update((p) => { + const list = p.scopes ?? []; + const [item] = list.splice(index, 1); + list.splice(index + delta, 0, item); + }); + const overrides = Object.keys(scope.policy.features ?? {}).length + + Object.keys(scope.policy.restrictions ?? {}).length + + Object.keys(scope.policy.defaults ?? {}).length + + Object.values(scope.policy.organisation ?? {}).reduce((n, section) => n + Object.keys(section ?? {}).length, 0); + return ( +
+
+ +
+ + + +
+
+
+ DOMAIN_RE.test(d)} + onCommit={(domains) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, domains }; })} + /> + GROUP_RE.test(g)} + onCommit={(groups) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, groups }; })} + /> +
+
+ {overrides === 0 ? 'No settings of its own yet' : `${overrides} setting${overrides === 1 ? '' : 's'} of its own`} + +
+
+ ); +} + +function sourceLabel(source: string, scopes: PolicyScope[]): string { + if (source === 'global') return 'Everyone'; + return scopes.find((s) => s.id === source)?.label ?? source; +} + +function valueAt(policy: Record, path: string): unknown { + const [root, ...rest] = path.split('.'); + // Keys of features, restrictions and defaults never contain dots; organisation paths are section.key. + let value: unknown = policy[root]; + for (const part of rest) value = (value as Record | undefined)?.[part]; + return value; +} + +function formatValue(value: unknown): string { + if (typeof value === 'string') return value.length > 60 ? `${value.slice(0, 60)}…` : value || '(empty)'; + const json = JSON.stringify(value); + return json && json.length > 80 ? `${json.slice(0, 80)}…` : String(json); +} + +function EffectivePolicy({ draft }: { draft: OrgPolicyDraft }) { + const [email, setEmail] = useState(''); + const [groupsText, setGroupsText] = useState(''); + const subject = email.includes('@') ? { email: email.trim().toLowerCase(), groups: splitList(groupsText) } : null; + const resolved = useMemo( + () => (draft.policy && subject ? resolvePolicy(draft.policy, subject) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [draft.policy, subject?.email, subject?.groups.join(',')], + ); + const rows = resolved + ? Object.entries(resolved.sources).sort(([a], [b]) => a.localeCompare(b)) + : []; + return ( +
+
+ + +
+ {resolved && ( +
+

+ {resolved.matched.length === 0 + ? 'No domain or group applies; this user gets Everyone\'s settings.' + : `Applies, in this order: Everyone, ${resolved.matched.map((id) => sourceLabel(id, draft.scopes)).join(', ')}.`} +

+ {rows.length === 0 ? ( +

Everything is at Bulwark's defaults.

+ ) : ( +
+ + + + + + + + + + {rows.map(([path, source]) => ( + + + + + + ))} + +
SettingValueSet by
{path}{formatValue(valueAt(resolved.policy as unknown as Record, path))}{sourceLabel(source, draft.scopes)}
+
+ )} +

+ Bulwark can only see groups that have a mailbox: a user counts as a member when the group's account is in their session. +

+
+ )} +
+ ); +} + +export function OrganisationPage() { + const draft = useOrgPolicyDraft('organisation'); + const addScope = (kind: 'domain' | 'group') => draft.update((p) => { + const id = newScopeId(p.scopes ?? []); + p.scopes = [...(p.scopes ?? []), { + id, + label: kind === 'domain' ? 'New domain' : 'New group', + match: {}, + policy: {}, + }]; + }); + return ( + + +
+ + +
+ {draft.scopes.length === 0 ? ( +

None yet: every user gets Everyone's settings.

+ ) : ( +
+ {draft.scopes.map((scope, index) => ( + + ))} +
+ )} + {draft.scopeId !== EVERYONE && draft.scope && ( +

+ Selected for editing: {draft.scope.label} ({scopeMatchSummary(draft.scope)}). +

+ )} +
+ + + + +
+ ); +} diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index e78a3ec63..208c9576a 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -6,17 +6,22 @@ import { ArrowLeft, ArrowUpCircle, Activity, + Building2, ChevronRight, KeyRound, LayoutDashboard, + LayoutTemplate, LogOut, Palette, + PenLine, Puzzle, Scale, ScrollText, Search, Server, + Share2, Shield, + ShieldCheck, Store, SwatchBook, X, @@ -42,6 +47,11 @@ const PAGE_ICONS: Record = { auth: Shield, branding: Palette, policy: Scale, + organisation: Building2, + 'org-signatures': PenLine, + 'org-templates': LayoutTemplate, + 'org-security': ShieldCheck, + 'org-sharing': Share2, plugins: Puzzle, themes: SwatchBook, marketplace: Store, @@ -52,6 +62,7 @@ const PAGE_ICONS: Record = { const GROUP_LABELS: Record = { top: null, + organisation: 'Organisation', extensions: 'Extensions', system: 'System', }; @@ -156,7 +167,7 @@ export function AdminShell({ authenticated, isStalwartAutoLogin, configReadOnly, }, [trimmed]); const groups = useMemo(() => ( - (['top', 'extensions', 'system'] as const) + (['top', 'organisation', 'extensions', 'system'] as const) .map((group) => ({ group, label: GROUP_LABELS[group], items: matches.filter((m) => m.page.group === group) })) .filter((g) => g.items.length > 0) ), [matches]); diff --git a/components/admin/org/org-page.tsx b/components/admin/org/org-page.tsx new file mode 100644 index 000000000..3cdad7641 --- /dev/null +++ b/components/admin/org/org-page.tsx @@ -0,0 +1,180 @@ +'use client'; + +import type { ReactNode } from 'react'; +import { Loader2, Lock, RotateCcw } from '@/components/icons'; +import { AdminPageHeader } from '@/components/admin/page-header'; +import { AdminSaveBar } from '@/components/admin/save-bar'; +import { AdminLink } from '@/components/admin/admin-link'; +import { Flash } from '@/components/admin/flash-message'; +import { SectionNav, SettingItem } from '@/components/settings/settings-section'; +import { adminPageDef, type AdminPageId } from '@/lib/admin/admin-nav'; +import type { RestrictionKeyDef } from '@/lib/admin/restriction-keys'; +import type { PolicyScope } from '@/lib/admin/policy-scopes'; +import { cn } from '@/lib/utils'; +import { EVERYONE, type OrgPolicyDraft } from './use-org-policy-draft'; + +export const chipClass = (selected: boolean) => cn( + 'h-8 px-3 rounded-md text-sm font-medium transition-colors max-w-full truncate', + selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground hover:bg-accent hover:text-accent-foreground', +); + +/** "example.com, sales@example.com": what a scope matches, for labels. */ +export function scopeMatchSummary(scope: PolicyScope): string { + const parts = [...(scope.match.domains ?? []), ...(scope.match.groups ?? [])]; + return parts.length > 0 ? parts.join(', ') : 'matches nobody yet'; +} + +/** + * Which part of the organisation an Organisation page edits: Everyone, or + * one domain or group scope. Chips on wide screens; they wrap at phone width. + */ +export function ScopeBar({ draft }: { draft: OrgPolicyDraft }) { + return ( +
+
+ Applies to + + {draft.scopes.map((scope) => ( + + ))} + + Manage domains and groups + +
+ {draft.scope && ( +

+ Changes here apply to {scopeMatchSummary(draft.scope)} only. Settings this scope doesn't change come from Everyone. +

+ )} +
+ ); +} + +/** "From Everyone: 15 minutes" with a reset, under a field a scope inherits or overrides. */ +export function InheritNote({ draft, own, everyone, onReset }: { + draft: OrgPolicyDraft; + own: boolean; + everyone: ReactNode; + onReset: () => void; +}) { + if (!draft.scope) return null; + return ( +

+ {own ? Everyone: {everyone} : From Everyone: {everyone}} + {own && ( + + )} +

+ ); +} + +/** + * Lock, hide and allowed values of one restriction key, at the selected + * scope. Every user-facing option of the Organisation features is + * restricted through this, never through a flag of its own. + */ +export function RestrictionRow({ draft, def }: { draft: OrgPolicyDraft; def: RestrictionKeyDef }) { + const { value, own, everyone } = draft.restriction(def.key); + const current = value ?? {}; + const modes = def.modes ?? ['locked', 'hidden']; + const set = (patch: Partial) => draft.setRestriction(def.key, { ...current, ...patch }); + const describe = (r: typeof current | undefined) => + r?.hidden ? 'hidden' : r?.locked ? 'locked' : r?.allowedValues?.length ? `limited to ${r.allowedValues.join(', ')}` : 'users choose'; + return ( + +
+
+ {modes.includes('locked') && ( + + )} + {modes.includes('hidden') && ( + + )} + {modes.includes('allowedValues') && def.allowedValues?.map((option) => { + const allowed = current.allowedValues as string[] | undefined; + const checked = !allowed || allowed.length === 0 || allowed.includes(option); + return ( + + ); + })} +
+ draft.setRestriction(def.key, undefined)} /> +
+
+ ); +} + +/** + * The frame of an Organisation page: title and the one sentence on what it + * does for users, the scope bar, messages, and the save bar. + */ +export function OrgPage({ pageId, draft, children, scoped = true }: { + pageId: AdminPageId; + draft: OrgPolicyDraft; + children: ReactNode; + /** Pages whose settings can be set per scope show the scope bar. */ + scoped?: boolean; +}) { + const page = adminPageDef(pageId); + return ( +
+ + draft.setMessage(null)} /> + {draft.loading ? ( +
+ Loading +
+ ) : ( +
+ {scoped && } + + {children} +
+ )} + void draft.save()} + onDiscard={draft.discard} + saveLabel="Save" + note="Users get the change the next time Bulwark loads" + /> +
+ ); +} diff --git a/components/admin/org/sections/delegates-section.tsx b/components/admin/org/sections/delegates-section.tsx new file mode 100644 index 000000000..d6af70217 --- /dev/null +++ b/components/admin/org/sections/delegates-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 7; filled in by its own step (local-data/org-features-plan.md). */ +export function DelegatesSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/external-mail-section.tsx b/components/admin/org/sections/external-mail-section.tsx new file mode 100644 index 000000000..44c7a8a5a --- /dev/null +++ b/components/admin/org/sections/external-mail-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 2; filled in by its own step (local-data/org-features-plan.md). */ +export function ExternalMailSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/recovery-section.tsx b/components/admin/org/sections/recovery-section.tsx new file mode 100644 index 000000000..92ed6445b --- /dev/null +++ b/components/admin/org/sections/recovery-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 9; filled in by its own step (local-data/org-features-plan.md). */ +export function RecoverySection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/sessions-section.tsx b/components/admin/org/sections/sessions-section.tsx new file mode 100644 index 000000000..b964d20d4 --- /dev/null +++ b/components/admin/org/sections/sessions-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 4; filled in by its own step (local-data/org-features-plan.md). */ +export function SessionsSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/shared-tags-section.tsx b/components/admin/org/sections/shared-tags-section.tsx new file mode 100644 index 000000000..978c1a56d --- /dev/null +++ b/components/admin/org/sections/shared-tags-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 6; filled in by its own step (local-data/org-features-plan.md). */ +export function SharedTagsSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/signature-section.tsx b/components/admin/org/sections/signature-section.tsx new file mode 100644 index 000000000..b5bc1a84b --- /dev/null +++ b/components/admin/org/sections/signature-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 1; filled in by its own step (local-data/org-features-plan.md). */ +export function SignatureSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/templates-section.tsx b/components/admin/org/sections/templates-section.tsx new file mode 100644 index 000000000..8cf157268 --- /dev/null +++ b/components/admin/org/sections/templates-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 3; filled in by its own step (local-data/org-features-plan.md). */ +export function TemplatesSection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/sections/third-party-section.tsx b/components/admin/org/sections/third-party-section.tsx new file mode 100644 index 000000000..2736dc26b --- /dev/null +++ b/components/admin/org/sections/third-party-section.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** Organisation feature 5; filled in by its own step (local-data/org-features-plan.md). */ +export function ThirdPartySection({ draft }: { draft: OrgPolicyDraft }) { + void draft; + return ( + +

Nothing to set here yet.

+
+ ); +} diff --git a/components/admin/org/use-org-policy-draft.ts b/components/admin/org/use-org-policy-draft.ts new file mode 100644 index 000000000..e8e751731 --- /dev/null +++ b/components/admin/org/use-org-policy-draft.ts @@ -0,0 +1,237 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { create } from 'zustand'; +import type { FeatureGates, SettingRestriction, SettingsPolicy } from '@/lib/admin/types'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { + DEFAULT_ORGANISATION_POLICY, + sanitizeOrganisationPolicy, + type OrganisationPolicy, + type OrganisationSection, +} from '@/lib/admin/organisation-policy'; +import type { PolicyScope } from '@/lib/admin/policy-scopes'; +import { apiFetch } from '@/lib/browser-navigation'; +import { useAdminUnsavedChanges } from '@/stores/admin-unsaved-store'; +import { useFlash } from '@/components/admin/flash-message'; + +/** The scope id the Organisation pages edit; '' is Everyone (the global policy). */ +export const EVERYONE = ''; + +/** The scope picked on one Organisation page stays picked on the others. */ +export const useOrgScopeStore = create<{ scopeId: string; setScopeId: (id: string) => void }>()((set) => ({ + scopeId: EVERYONE, + setScopeId: (scopeId) => set({ scopeId }), +})); + +type SectionValues = OrganisationPolicy[S]; + +export interface OrgValue { + /** What applies at the selected scope: its own value, or the inherited one. */ + value: T; + /** The selected scope sets it itself (always true for Everyone). */ + own: boolean; + /** Everyone's value, shown as "From Everyone" in a scope. */ + everyone: T; +} + +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** + * Loading, editing and saving the user policy for one Organisation page. + * Edits go to the scope chosen in the scope bar (Everyone or one domain or + * group scope); in a scope, a value the scope doesn't set shows Everyone's + * and can be reset to it. Saving writes the whole policy, so the User + * policy page and every Organisation page work on the same policy.json. + */ +export function useOrgPolicyDraft(dirtyId: string) { + const [policy, setPolicy] = useState(null); + const [saved, setSaved] = useState(null); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useFlash(); + const scopeId = useOrgScopeStore((s) => s.scopeId); + const setScopeId = useOrgScopeStore((s) => s.setScopeId); + + const load = useCallback(async () => { + try { + const res = await apiFetch('/api/admin/policy'); + if (!res.ok) throw new Error(); + const data = (await res.json()) as SettingsPolicy; + const normalised = { ...DEFAULT_POLICY, ...data, organisation: sanitizeOrganisationPolicy(data.organisation), scopes: data.scopes ?? [] }; + setPolicy(normalised); + setSaved(clone(normalised)); + } catch { + setMessage({ type: 'error', text: 'Could not load the policy' }); + setPolicy((p) => p ?? { ...DEFAULT_POLICY, organisation: clone(DEFAULT_ORGANISATION_POLICY), scopes: [] }); + } + }, [setMessage]); + + useEffect(() => { void load(); }, [load]); + + const scopes = useMemo(() => policy?.scopes ?? [], [policy]); + const scope = scopes.find((s) => s.id === scopeId) ?? null; + // A scope deleted elsewhere (or on another page) falls back to Everyone. + useEffect(() => { + if (policy && scopeId !== EVERYONE && !scope) setScopeId(EVERYONE); + }, [policy, scopeId, scope, setScopeId]); + + const dirty = !!policy && !!saved && JSON.stringify(policy) !== JSON.stringify(saved); + useAdminUnsavedChanges(dirtyId, dirty); + + const update = useCallback((change: (draft: SettingsPolicy) => void) => { + setPolicy((current) => { + if (!current) return current; + const draft = clone(current); + change(draft); + return draft; + }); + setMessage(null); + }, [setMessage]); + + const updateScope = useCallback((id: string, change: (scope: PolicyScope) => void) => { + update((draft) => { + const target = draft.scopes?.find((s) => s.id === id); + if (target) change(target); + }); + }, [update]); + + const org = policy?.organisation ?? DEFAULT_ORGANISATION_POLICY; + + function orgValue>(section: S, key: K): OrgValue[K]> { + const everyone = org[section][key]; + if (!scope) return { value: everyone, own: true, everyone }; + const override = (scope.policy.organisation?.[section] as Partial> | undefined)?.[key]; + return override === undefined + ? { value: everyone, own: false, everyone } + : { value: override as SectionValues[K], own: true, everyone }; + } + + function setOrgValue>(section: S, key: K, value: SectionValues[K]) { + if (!scope) { + update((draft) => { + const target = (draft.organisation ?? clone(DEFAULT_ORGANISATION_POLICY)) as OrganisationPolicy; + (target[section] as SectionValues)[key] = value; + draft.organisation = target; + }); + return; + } + updateScope(scope.id, (s) => { + const organisation = (s.policy.organisation ??= {}) as Record>; + (organisation[section] ??= {})[key as string] = value; + }); + } + + /** In a scope: use Everyone's value again. */ + function clearOrgValue(section: S, key: keyof SectionValues) { + if (!scope) return; + updateScope(scope.id, (s) => { + const values = s.policy.organisation?.[section] as Record | undefined; + if (!values) return; + delete values[key as string]; + if (Object.keys(values).length === 0) delete (s.policy.organisation as Record)[section]; + }); + } + + function restriction(key: string): OrgValue { + const everyone = policy?.restrictions[key]; + if (!scope) return { value: everyone, own: true, everyone }; + const override = scope.policy.restrictions?.[key]; + return override === undefined ? { value: everyone, own: false, everyone } : { value: override, own: true, everyone }; + } + + /** Sets the restriction at the selected scope; undefined removes it (in a scope: inherit). */ + function setRestriction(key: string, value: SettingRestriction | undefined) { + const empty = !value || (!value.locked && !value.hidden && !value.allowedValues?.length && value.min === undefined && value.max === undefined); + if (!scope) { + update((draft) => { + if (empty) delete draft.restrictions[key]; + else draft.restrictions[key] = value!; + }); + return; + } + updateScope(scope.id, (s) => { + const restrictions = (s.policy.restrictions ??= {}); + // In a scope, "nothing" is a real override: it unlocks what Everyone locks. + if (value === undefined) delete restrictions[key]; + else restrictions[key] = empty ? {} : value; + }); + } + + function feature(key: keyof FeatureGates): OrgValue { + const everyone = policy?.features[key] ?? DEFAULT_POLICY.features[key]; + if (!scope) return { value: everyone, own: true, everyone }; + const override = scope.policy.features?.[key]; + return override === undefined ? { value: everyone, own: false, everyone } : { value: override, own: true, everyone }; + } + + function setFeature(key: keyof FeatureGates, value: boolean | undefined) { + if (!scope) { + update((draft) => { draft.features[key] = value ?? DEFAULT_POLICY.features[key]; }); + return; + } + updateScope(scope.id, (s) => { + const features = (s.policy.features ??= {}); + if (value === undefined) delete features[key]; + else features[key] = value; + }); + } + + async function save() { + if (!policy) return; + setSaving(true); + setMessage(null); + try { + const res = await apiFetch('/api/admin/policy', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(policy), + }); + if (res.ok) { + // The server sanitises; show what it kept. + await load(); + setMessage({ type: 'success', text: 'Saved. Users get the change the next time Bulwark loads.' }); + } else { + const data = await res.json().catch(() => ({})); + setMessage({ type: 'error', text: data.error || 'Could not save the policy' }); + } + } catch { + setMessage({ type: 'error', text: 'Could not save the policy' }); + } finally { + setSaving(false); + } + } + + function discard() { + if (saved) setPolicy(clone(saved)); + setMessage(null); + } + + return { + policy, + loading: policy === null, + scopes, + scope, + scopeId: scope ? scope.id : EVERYONE, + setScopeId, + update, + updateScope, + orgValue, + setOrgValue, + clearOrgValue, + restriction, + setRestriction, + feature, + setFeature, + dirty, + saving, + save, + discard, + message, + setMessage, + reload: load, + }; +} + +export type OrgPolicyDraft = ReturnType; diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts index ccb8e6fb1..f10b80ea7 100644 --- a/lib/admin/admin-nav.ts +++ b/lib/admin/admin-nav.ts @@ -17,6 +17,11 @@ export const ADMIN_PAGE_IDS = [ 'auth', 'branding', 'policy', + 'organisation', + 'org-signatures', + 'org-templates', + 'org-security', + 'org-sharing', 'plugins', 'themes', 'marketplace', @@ -31,8 +36,8 @@ export function isAdminPageId(value: string | null | undefined): value is AdminP return typeof value === 'string' && (ADMIN_PAGE_IDS as readonly string[]).includes(value); } -/** `top` has no heading in the sidebar; the other two do. */ -export type AdminPageGroup = 'top' | 'extensions' | 'system'; +/** `top` has no heading in the sidebar; the others do. */ +export type AdminPageGroup = 'top' | 'organisation' | 'extensions' | 'system'; export interface AdminSectionDef { /** The `id` the page gives its SettingsSection, for jump links and search. */ @@ -118,6 +123,65 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [ { id: 'restrictions', title: 'Locked settings', keywords: 'restrict hide lock font size density layout delete action reply mode session timeout notifications debug' }, ], }, + { + id: 'organisation', + label: 'Domains and groups', + description: 'Give some domains or groups their own settings, see what applies to a user, and keep the policy in one file', + group: 'organisation', + keywords: 'organisation organization scope domain group department customer tenant effective policy export import file json', + sections: [ + { id: 'scopes', title: 'Domains and groups', keywords: 'scope domain group add remove order' }, + { id: 'effective', title: 'Effective policy', keywords: 'user address lookup applies source' }, + { id: 'policy-file', title: 'Policy file', keywords: 'export import download upload json backup git' }, + ], + }, + { + id: 'org-signatures', + label: 'Signatures', + description: 'The signature and legal disclaimer every message carries', + group: 'organisation', + keywords: 'signature disclaimer footer legal imprint placeholder phone title department lock', + sections: [ + { id: 'signature', title: 'Signature', keywords: 'template placeholder name email lock' }, + { id: 'details', title: 'User details', keywords: 'phone mobile title department fields' }, + { id: 'disclaimer', title: 'Disclaimer', keywords: 'footer legal notice confidentiality' }, + ], + }, + { + id: 'org-templates', + label: 'Templates', + description: 'Templates every user can pick, and what users may keep of their own', + group: 'organisation', + keywords: 'templates library snippets canned responses team personal', + sections: [ + { id: 'library', title: 'Organisation library', keywords: 'templates read-only shared add edit' }, + { id: 'user-templates', title: 'Personal and team templates', keywords: 'personal team group lock hide' }, + ], + }, + { + id: 'org-security', + label: 'Security', + description: 'Warnings about mail from and to outside the organisation, sign-out times and outside connections', + group: 'organisation', + keywords: 'security external warning label recipients domains session timeout idle sign out third party traffic privacy', + sections: [ + { id: 'external', title: 'External mail', keywords: 'external sender label recipient warning organisation domains contacts' }, + { id: 'sessions', title: 'Sessions', keywords: 'idle timeout maximum length sign out warning' }, + { id: 'third-party', title: 'Third-party traffic', keywords: 'telemetry favicon relay directory outgoing hosts privacy gdpr' }, + ], + }, + { + id: 'org-sharing', + label: 'Sharing', + description: 'Tags in shared mailboxes, delegates and recovering deleted items', + group: 'organisation', + keywords: 'sharing shared mailbox tags delegate access recover deleted items archive', + sections: [ + { id: 'shared-tags', title: 'Shared tags', keywords: 'tags labels colours group mailbox edit lock' }, + { id: 'delegates', title: 'Delegates', keywords: 'delegate access mail calendar contacts rights read write' }, + { id: 'recovery', title: 'Deleted items', keywords: 'recover restore deleted archive enterprise' }, + ], + }, { id: 'plugins', label: 'Plugins', From 4004b9be7c2ac8c8bbc85c87f7bf6c086dfc2087 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 00:44:31 +0100 Subject: [PATCH 005/137] feat: let admins export and import the whole user policy as one file --- .../__tests__/organisation-page.test.tsx | 33 +++- app/(main)/admin/_pages/organisation.tsx | 3 + .../admin/__tests__/policy-file-route.test.ts | 89 +++++++++++ app/api/admin/policy/file/route.ts | 79 ++++++++++ components/admin/org/policy-file-section.tsx | 144 ++++++++++++++++++ lib/admin/config-manager.ts | 24 ++- lib/admin/policy-diff.ts | 60 ++++++++ 7 files changed, 423 insertions(+), 9 deletions(-) create mode 100644 app/api/admin/__tests__/policy-file-route.test.ts create mode 100644 app/api/admin/policy/file/route.ts create mode 100644 components/admin/org/policy-file-section.tsx create mode 100644 lib/admin/policy-diff.ts diff --git a/app/(main)/admin/__tests__/organisation-page.test.tsx b/app/(main)/admin/__tests__/organisation-page.test.tsx index 8ff9c8868..542c15140 100644 --- a/app/(main)/admin/__tests__/organisation-page.test.tsx +++ b/app/(main)/admin/__tests__/organisation-page.test.tsx @@ -7,8 +7,23 @@ import { act, fireEvent, render, renderHook, screen, waitFor } from '@testing-li const puts: Array> = []; let stored: Record = {}; +const filePosts: string[] = []; vi.mock('@/lib/browser-navigation', () => ({ - apiFetch: vi.fn(async (_url: string, init?: RequestInit) => { + apiFetch: vi.fn(async (url: string, init?: RequestInit) => { + if (url.startsWith('/api/admin/policy/file') && init?.method === 'POST') { + filePosts.push(url); + return new Response(JSON.stringify({ + ok: true, + summary: { + scopes: { added: ['Two (two)'], changed: [], removed: [] }, + organisation: { added: [], changed: ['session'], removed: [] }, + restrictions: { added: [], changed: [], removed: [] }, + features: { added: [], changed: [], removed: [] }, + defaults: { added: [], changed: [], removed: [] }, + other: [], unchanged: false, + }, + })); + } if (init?.method === 'PUT') { const body = JSON.parse(String(init.body)); puts.push(body); @@ -34,6 +49,7 @@ const POLICY = { beforeEach(() => { puts.length = 0; + filePosts.length = 0; stored = JSON.parse(JSON.stringify(POLICY)); useOrgScopeStore.setState({ scopeId: EVERYONE }); }); @@ -94,4 +110,19 @@ describe('OrganisationPage', () => { expect(table.textContent).toContain('restrictions.density'); expect(table.textContent).toContain('organisation.session.idleMinutes'); }); + + it('shows what an imported file changes and replaces the policy only when asked', async () => { + render(); + await screen.findByTestId('org-scope-team'); + const file = new File(['{"scopes":[]}'], 'policy.json', { type: 'application/json' }); + fireEvent.change(screen.getByLabelText('Policy file to import'), { target: { files: [file] } }); + const preview = await screen.findByTestId('policy-import-preview'); + expect(preview.textContent).toContain('Domains and groups: 1 added'); + expect(preview.textContent).toContain('Organisation settings: 1 changed'); + expect(filePosts).toEqual(['/api/admin/policy/file?preview=1']); + + fireEvent.click(screen.getByRole('button', { name: 'Replace the current policy' })); + await waitFor(() => expect(filePosts).toEqual(['/api/admin/policy/file?preview=1', '/api/admin/policy/file'])); + await waitFor(() => expect(screen.queryByTestId('policy-import-preview')).toBeNull()); + }); }); diff --git a/app/(main)/admin/_pages/organisation.tsx b/app/(main)/admin/_pages/organisation.tsx index 0276ef616..7191fd911 100644 --- a/app/(main)/admin/_pages/organisation.tsx +++ b/app/(main)/admin/_pages/organisation.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/admin/badge'; import { inputClass } from '@/components/admin/config-items'; import { OrgPage, scopeMatchSummary } from '@/components/admin/org/org-page'; +import { PolicyFileSection } from '@/components/admin/org/policy-file-section'; import { EVERYONE, useOrgPolicyDraft, type OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; import { resolvePolicy, type PolicyScope } from '@/lib/admin/policy-scopes'; import { cn } from '@/lib/utils'; @@ -260,6 +261,8 @@ export function OrganisationPage() { > + + ); } diff --git a/app/api/admin/__tests__/policy-file-route.test.ts b/app/api/admin/__tests__/policy-file-route.test.ts new file mode 100644 index 000000000..9e7afce38 --- /dev/null +++ b/app/api/admin/__tests__/policy-file-route.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { NextRequest } from 'next/server'; + +// The whole user policy goes out and comes back as one policy.json. An +// import is previewed first and goes through the same sanitising as the +// file on disk. + +const audit = vi.hoisted(() => [] as Array<{ action: string; detail: Record }>); +vi.mock('@/lib/admin/session', () => ({ + requireAdminAuth: async () => ({ payload: { role: 'admin' } }), + getClientIP: () => '127.0.0.1', +})); +vi.mock('@/lib/admin/audit', () => ({ + auditLog: async (action: string, detail: Record) => { audit.push({ action, detail }); }, +})); + +import { GET, POST } from '@/app/api/admin/policy/file/route'; +import { configManager } from '@/lib/admin/config-manager'; + +let dir: string; +const url = (query = '') => `https://mail.example/api/admin/policy/file${query}`; +const post = (body: string, query = '') => POST(new NextRequest(url(query), { method: 'POST', body })); +const stored = async () => JSON.parse(await readFile(path.join(dir, 'policy.json'), 'utf-8')); + +beforeEach(async () => { + dir = await mkdtemp(path.join(tmpdir(), 'bw-policy-file-')); + process.env.ADMIN_CONFIG_DIR = dir; + audit.length = 0; + await writeFile(path.join(dir, 'policy.json'), JSON.stringify({ + restrictions: { density: { locked: true } }, + organisation: { session: { idleMinutes: 30 } }, + scopes: [{ id: 'team', label: 'Team', match: { groups: ['team@example.org'] }, policy: {} }], + })); + await configManager.reload(); +}); + +afterEach(async () => { + delete process.env.ADMIN_CONFIG_DIR; + await rm(dir, { recursive: true, force: true }); +}); + +describe('policy file', () => { + it('exports the stored policy as a dated download and logs it', async () => { + const res = await GET(new NextRequest(url())); + expect(res.headers.get('content-disposition')).toMatch(/^attachment; filename="bulwark-policy-\d{4}-\d{2}-\d{2}\.json"$/); + const body = JSON.parse(await res.text()); + expect(body.scopes.map((s: { id: string }) => s.id)).toEqual(['team']); + expect(body.organisation.session.idleMinutes).toBe(30); + expect(audit.map((a) => a.action)).toEqual(['policy.export']); + }); + + it('previews an import without writing anything', async () => { + const file = JSON.stringify({ + restrictions: {}, + organisation: { session: { idleMinutes: 10 }, signature: { html: '

{name}

' } }, + scopes: [{ id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: {} }], + }); + const res = await post(file, '?preview=1'); + const { summary } = await res.json(); + expect(summary.scopes).toEqual({ added: ['Two (two)'], changed: [], removed: ['Team (team)'] }); + expect(summary.restrictions.removed).toEqual(['density']); + expect(summary.organisation.changed.sort()).toEqual(['session', 'signature']); + expect((await stored()).organisation.session.idleMinutes).toBe(30); + expect(audit).toEqual([]); + }); + + it('imports the sanitised policy and logs it', async () => { + const res = await post(JSON.stringify({ + organisation: { session: { idleMinutes: 'soon' } }, + scopes: [{ id: 'Bad id!' }, { id: 'two', label: 'Two', match: { domains: ['org-two.test'] }, policy: {} }], + })); + expect(res.status).toBe(200); + const policy = await stored(); + expect(policy.scopes.map((s: { id: string }) => s.id)).toEqual(['two']); + expect(policy.organisation.session.idleMinutes).toBe(0); + expect(configManager.getPolicy().scopes?.map((s) => s.id)).toEqual(['two']); + expect(audit.map((a) => a.action)).toEqual(['policy.import']); + }); + + it('refuses what isn\'t a policy object', async () => { + expect((await post('{nope')).status).toBe(400); + expect((await post('[1,2]')).status).toBe(400); + expect((await stored()).scopes).toHaveLength(1); + }); +}); diff --git a/app/api/admin/policy/file/route.ts b/app/api/admin/policy/file/route.ts new file mode 100644 index 000000000..b1448ad0b --- /dev/null +++ b/app/api/admin/policy/file/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { completePolicy, configManager } from '@/lib/admin/config-manager'; +import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; +import { auditLog } from '@/lib/admin/audit'; +import { logger } from '@/lib/logger'; +import { summarizePolicyChange } from '@/lib/admin/policy-diff'; +import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; +import type { SettingsPolicy } from '@/lib/admin/types'; + +/** Largest policy file an import accepts. */ +const MAX_IMPORT_BYTES = 2 * 1024 * 1024; + +/** + * GET /api/admin/policy/file - the whole user policy as a policy.json + * download: the same file the server reads, so it can go into git, into + * another instance's ADMIN_CONFIG_DIR or next to a Lite build. + */ +export async function GET(request: NextRequest) { + const auth = await requireAdminAuth(request); + if ('error' in auth) return auth.error; + await configManager.ensureLoaded(); + const policy = configManager.getPolicy(); + await auditLog('policy.export', { scopeCount: policy.scopes?.length ?? 0 }, getClientIP(request)); + const date = new Date().toISOString().slice(0, 10); + return new NextResponse(`${JSON.stringify(policy, null, 2)}\n`, { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Disposition': `attachment; filename="bulwark-policy-${date}.json"`, + 'Cache-Control': 'no-store', + }, + }); +} + +/** + * POST /api/admin/policy/file - import a policy file. With `?preview=1` it + * only says what would change; otherwise it replaces the policy. The file + * goes through the same defaults and sanitising as policy.json on load, and + * the preview shows the result of that, not the raw file. + */ +export async function POST(request: NextRequest) { + const auth = await requireAdminAuth(request); + if ('error' in auth) return auth.error; + try { + const text = await request.text(); + if (text.length > MAX_IMPORT_BYTES) { + return NextResponse.json({ error: 'The file is larger than 2 MB' }, { status: 413 }); + } + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + return NextResponse.json({ error: 'The file is not valid JSON' }, { status: 400 }); + } + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return NextResponse.json({ error: 'The file does not hold a policy object' }, { status: 400 }); + } + + await configManager.ensureLoaded(); + const next = completePolicy(raw as Partial); + const summary = summarizePolicyChange(configManager.getPolicy(), next); + if (request.nextUrl.searchParams.get('preview') === '1') { + return NextResponse.json({ summary }); + } + + await configManager.setPolicy(next); + await auditLog('policy.import', { + scopeCount: next.scopes?.length ?? 0, + scopesAdded: summary.scopes.added.length, + scopesRemoved: summary.scopes.removed.length, + restrictionsChanged: summary.restrictions.added.length + summary.restrictions.changed.length + summary.restrictions.removed.length, + }, getClientIP(request)); + return NextResponse.json({ ok: true, summary }); + } catch (error) { + const readOnly = configReadOnlyResponse(error); + if (readOnly) return readOnly; + logger.error('Policy import error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/components/admin/org/policy-file-section.tsx b/components/admin/org/policy-file-section.tsx new file mode 100644 index 000000000..bbb7e737a --- /dev/null +++ b/components/admin/org/policy-file-section.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Download, Loader2, Upload } from '@/components/icons'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { KeyChanges, PolicyChangeSummary } from '@/lib/admin/policy-diff'; +import type { OrgPolicyDraft } from './use-org-policy-draft'; + +function describe(label: string, changes: KeyChanges): string | null { + const parts = [ + changes.added.length ? `${changes.added.length} added` : null, + changes.changed.length ? `${changes.changed.length} changed` : null, + changes.removed.length ? `${changes.removed.length} removed` : null, + ].filter(Boolean); + return parts.length ? `${label}: ${parts.join(', ')}` : null; +} + +function summaryLines(summary: PolicyChangeSummary): string[] { + return [ + describe('Domains and groups', summary.scopes), + describe('Organisation settings', summary.organisation), + describe('Locked and hidden settings', summary.restrictions), + describe('Features', summary.features), + describe('Defaults for new users', summary.defaults), + summary.other.length ? `Also: ${summary.other.join(', ')}` : null, + ].filter((line): line is string => !!line); +} + +/** + * Export and import of the whole user policy as policy.json, so the policy + * can live in git, move to another instance or go next to a Lite build. + * An import shows what it changes before it replaces anything. + */ +export function PolicyFileSection({ draft }: { draft: OrgPolicyDraft }) { + const input = useRef(null); + const [pending, setPending] = useState<{ name: string; text: string; summary: PolicyChangeSummary } | null>(null); + const [busy, setBusy] = useState(false); + + async function exportFile() { + const res = await apiFetch('/api/admin/policy/file'); + if (!res.ok) { + draft.setMessage({ type: 'error', text: 'Could not export the policy' }); + return; + } + const blob = await res.blob(); + const name = /filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') ?? '')?.[1] ?? 'bulwark-policy.json'; + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = name; + link.click(); + URL.revokeObjectURL(url); + } + + async function pick(file: File) { + setBusy(true); + try { + const text = await file.text(); + const res = await apiFetch('/api/admin/policy/file?preview=1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: text }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + draft.setMessage({ type: 'error', text: data.error || 'Could not read the file' }); + return; + } + setPending({ name: file.name, text, summary: data.summary }); + } finally { + setBusy(false); + if (input.current) input.current.value = ''; + } + } + + async function apply() { + if (!pending) return; + setBusy(true); + try { + const res = await apiFetch('/api/admin/policy/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: pending.text }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + draft.setMessage({ type: 'error', text: data.error || 'Could not import the file' }); + return; + } + setPending(null); + await draft.reload(); + draft.setMessage({ type: 'success', text: `Imported ${pending.name}.` }); + } finally { + setBusy(false); + } + } + + return ( + +
+ + + { const file = e.target.files?.[0]; if (file) void pick(file); }} + /> +
+ {draft.dirty &&

Save or discard your changes first.

} + {pending && ( +
+

Importing {pending.name}

+ {pending.summary.unchanged ? ( +

It matches the current policy; nothing would change.

+ ) : ( +
    + {summaryLines(pending.summary).map((line) =>
  • {line}
  • )} +
+ )} +
+ + +
+
+ )} +
+ ); +} diff --git a/lib/admin/config-manager.ts b/lib/admin/config-manager.ts index 087ad5700..52e7d5fc6 100644 --- a/lib/admin/config-manager.ts +++ b/lib/admin/config-manager.ts @@ -38,12 +38,7 @@ class ConfigManager { this.adminConfig = await this.readJsonFile('config.json') || {}; const policy = await this.readJsonFile('policy.json'); if (policy) { - this.policyCache = ConfigManager.normalizePolicy({ - ...DEFAULT_POLICY, - ...policy, - features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) }, - themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) }, - }); + this.policyCache = ConfigManager.completePolicy(policy as Partial); } else { this.policyCache = { ...DEFAULT_POLICY }; } @@ -184,13 +179,23 @@ class ConfigManager { */ async setPolicy(policy: SettingsPolicy): Promise { assertWritable('update settings policy'); - this.policyCache = ConfigManager.normalizePolicy({ + this.policyCache = ConfigManager.completePolicy(policy); + await this.writeJsonFile('policy.json', this.policyCache as unknown as Record); + } + + /** + * A policy as it would be stored: defaults filled in, migrated and + * sanitised, exactly as policy.json is on load. For previewing an import. + */ + static completePolicy(policy: Partial): SettingsPolicy { + return ConfigManager.normalizePolicy({ ...DEFAULT_POLICY, ...policy, + restrictions: { ...(policy.restrictions || {}) }, + defaults: { ...(policy.defaults || {}) }, features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) }, themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) }, }); - await this.writeJsonFile('policy.json', this.policyCache as unknown as Record); } /** @@ -281,3 +286,6 @@ type GlobalWithConfig = typeof globalThis & { [SINGLETON_KEY]?: ConfigManager }; const g = globalThis as GlobalWithConfig; export const configManager: ConfigManager = g[SINGLETON_KEY] ?? (g[SINGLETON_KEY] = new ConfigManager()); + +/** See ConfigManager.completePolicy. */ +export const completePolicy = (policy: Partial): SettingsPolicy => ConfigManager.completePolicy(policy); diff --git a/lib/admin/policy-diff.ts b/lib/admin/policy-diff.ts new file mode 100644 index 000000000..4224d3e09 --- /dev/null +++ b/lib/admin/policy-diff.ts @@ -0,0 +1,60 @@ +import type { SettingsPolicy } from './types'; + +export interface KeyChanges { + added: string[]; + changed: string[]; + removed: string[]; +} + +/** What importing a policy file would change, for the preview before applying it. */ +export interface PolicyChangeSummary { + scopes: KeyChanges; + features: KeyChanges; + restrictions: KeyChanges; + defaults: KeyChanges; + /** Organisation sections, by name (signature, session...). */ + organisation: KeyChanges; + /** Other top-level parts (theme policy, plugins, push relays, sidebar apps). */ + other: string[]; + /** Nothing would change. */ + unchanged: boolean; +} + +const same = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); + +function keyChanges(before: Record = {}, after: Record = {}): KeyChanges { + const out: KeyChanges = { added: [], changed: [], removed: [] }; + for (const key of Object.keys(after)) { + if (!(key in before)) out.added.push(key); + else if (!same(before[key], after[key])) out.changed.push(key); + } + for (const key of Object.keys(before)) if (!(key in after)) out.removed.push(key); + return out; +} + +const empty = (c: KeyChanges) => c.added.length + c.changed.length + c.removed.length === 0; + +/** Compares two complete policies (both as configManager stores them). */ +export function summarizePolicyChange(current: SettingsPolicy, next: SettingsPolicy): PolicyChangeSummary { + const byId = (policy: SettingsPolicy) => Object.fromEntries((policy.scopes ?? []).map((s) => [s.label ? `${s.label} (${s.id})` : s.id, s])); + const summary: PolicyChangeSummary = { + scopes: keyChanges(byId(current), byId(next)), + features: keyChanges(current.features as unknown as Record, next.features as unknown as Record), + restrictions: keyChanges(current.restrictions, next.restrictions), + defaults: keyChanges(current.defaults, next.defaults), + organisation: keyChanges( + current.organisation as unknown as Record, + next.organisation as unknown as Record, + ), + other: [], + unchanged: false, + }; + const covered = new Set(['scopes', 'features', 'restrictions', 'defaults', 'organisation']); + for (const key of new Set([...Object.keys(current), ...Object.keys(next)])) { + if (covered.has(key)) continue; + if (!same((current as unknown as Record)[key], (next as unknown as Record)[key])) summary.other.push(key); + } + summary.unchanged = [summary.scopes, summary.features, summary.restrictions, summary.defaults, summary.organisation].every(empty) + && summary.other.length === 0; + return summary; +} From 084cf3cf3aae0db4d3d5c82462d8709633789a70 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:08:56 +0100 Subject: [PATCH 006/137] fix: fetch the policy again only for an account of a domain it doesn't cover --- .../__tests__/policy-scopes-route.test.ts | 8 +++++++ app/api/admin/policy/route.ts | 16 +++++++++----- lib/admin/types.ts | 6 ++++++ stores/__tests__/policy-store-scopes.test.ts | 8 +++++++ stores/auth-store.ts | 8 +++---- stores/policy-store.ts | 21 +++++++++++++++++-- 6 files changed, 55 insertions(+), 12 deletions(-) diff --git a/app/api/admin/__tests__/policy-scopes-route.test.ts b/app/api/admin/__tests__/policy-scopes-route.test.ts index d1348d23b..3c27c1320 100644 --- a/app/api/admin/__tests__/policy-scopes-route.test.ts +++ b/app/api/admin/__tests__/policy-scopes-route.test.ts @@ -60,6 +60,14 @@ describe('GET /api/admin/policy scopes', () => { expect(body.organisation.disclaimer.text).toBe('Acme GmbH'); }); + it('says which domains the scopes were picked for, and nothing when all are sent', async () => { + who.identity = { admin: false, addresses: ['org-alice@example.org', 'org-dave@org-two.test'] }; + const res = await GET(new NextRequest('https://mail.example/api/admin/policy')); + expect(res.headers.get('x-bulwark-policy-domains')).toBe('example.org,org-two.test'); + who.identity = { admin: true }; + expect((await GET(new NextRequest('https://mail.example/api/admin/policy'))).headers.get('x-bulwark-policy-domains')).toBeNull(); + }); + it('gives the admin every scope', async () => { who.identity = { admin: true }; expect((await get()).scopes).toHaveLength(2); diff --git a/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 8aeb72dbb..145260bd2 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -3,7 +3,7 @@ import { configManager } from '@/lib/admin/config-manager'; import { requireAdminAuth, getClientIP } from '@/lib/admin/session'; import { auditLog } from '@/lib/admin/audit'; import { logger } from '@/lib/logger'; -import { POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types'; +import { POLICY_DOMAINS_HEADER, POLICY_SCOPE_HEADER, type SettingsPolicy } from '@/lib/admin/types'; import { canSeeInstanceDetails, signedInIdentity } from '@/lib/auth/instance-details'; import { scopesVisibleTo } from '@/lib/admin/policy-scopes'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; @@ -42,11 +42,17 @@ export async function GET(request: NextRequest) { // Without a session secret the server can't tell who is asking and // answers with all of them (documented). const identity = await signedInIdentity(); - const scopes = identity === null || identity.admin - ? policy.scopes ?? [] - : scopesVisibleTo(policy.scopes ?? [], identity.addresses); + const filtered = identity !== null && !identity.admin; + const scopes = filtered + ? scopesVisibleTo(policy.scopes ?? [], identity.addresses) + : policy.scopes ?? []; + // Which domains the scopes were picked for, so a browser that signs in + // to an account of another domain knows to fetch again. + const domains = filtered + ? [...new Set(identity.addresses.map((a) => a.slice(a.lastIndexOf('@') + 1)).filter(Boolean))].join(',') + : null; return NextResponse.json({ ...policy, scopes }, { - headers: { 'Cache-Control': 'no-store' }, + headers: { 'Cache-Control': 'no-store', ...(domains !== null ? { [POLICY_DOMAINS_HEADER]: domains } : {}) }, }); } catch (error) { logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' }); diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 54fc77859..0c779bef2 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -208,6 +208,12 @@ export interface SettingsPolicy { /** Set to `public` on a policy answered with the pre-login subset. */ export const POLICY_SCOPE_HEADER = 'X-Bulwark-Policy-Scope'; +/** + * The domains a policy's scopes were picked for (comma-separated); absent + * when it holds every scope (admin, or a server that can't tell users apart). + */ +export const POLICY_DOMAINS_HEADER = 'X-Bulwark-Policy-Domains'; + export const DEFAULT_POLICY: SettingsPolicy = { restrictions: {}, features: { ...DEFAULT_FEATURE_GATES }, diff --git a/stores/__tests__/policy-store-scopes.test.ts b/stores/__tests__/policy-store-scopes.test.ts index 0ccd04622..1662e5bac 100644 --- a/stores/__tests__/policy-store-scopes.test.ts +++ b/stores/__tests__/policy-store-scopes.test.ts @@ -57,6 +57,14 @@ describe('policy store scopes', () => { expect(usePolicyStore.getState().isFeatureEnabled('calendarEnabled')).toBe(false); }); + it('knows which domains the fetched scopes cover', async () => { + expect(usePolicyStore.getState().coversAddress('x@anywhere.test')).toBe(true); + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify(POLICY), { headers: { 'X-Bulwark-Policy-Domains': 'example.org' } })); + await usePolicyStore.getState().fetchPolicy(); + expect(usePolicyStore.getState().coversAddress('org-alice@Example.org')).toBe(true); + expect(usePolicyStore.getState().coversAddress('org-dave@org-two.test')).toBe(false); + }); + it('resolves another account without switching to it', () => { usePolicyStore.getState().setPolicySubject({ email: 'org-carol@example.org', groups: [] }); const other = usePolicyStore.getState().resolveFor({ email: 'org-bob@example.org', groups: ['org-team@example.org'] }); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index d971d992d..6a7421f7e 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -3164,7 +3164,6 @@ useAuthStore.subscribe((state, prev) => { // Domain and group scopes follow the active account. setPolicySubject skips // the work when the account's address and groups stayed the same. let policySubjectRequest = 0; -const policySubjectsSeen = new Set(); useAuthStore.subscribe((state, prev) => { if ( state.client === prev.client @@ -3180,10 +3179,9 @@ useAuthStore.subscribe((state, prev) => { void policySubjectForClient(state.client).then(async (subject) => { if (request !== policySubjectRequest) return; // The server sends only the scopes of the domains signed in when the - // policy was fetched; an account added since may have scopes of its own. - if (subject && !policySubjectsSeen.has(subject.email)) { - policySubjectsSeen.add(subject.email); - if (policySubjectsSeen.size > 1) await usePolicyStore.getState().fetchPolicy(); + // policy was fetched; an account of another domain may have its own. + if (subject && !usePolicyStore.getState().coversAddress(subject.email)) { + await usePolicyStore.getState().fetchPolicy(); if (request !== policySubjectRequest) return; } usePolicyStore.getState().setPolicySubject(subject); diff --git a/stores/policy-store.ts b/stores/policy-store.ts index 733d6a04e..6c0a6945a 100644 --- a/stores/policy-store.ts +++ b/stores/policy-store.ts @@ -1,6 +1,6 @@ import { create } from 'zustand'; import type { SettingsPolicy, FeatureGates, SettingRestriction, ThemePolicy, ColorMode } from '@/lib/admin/types'; -import { DEFAULT_POLICY, DEFAULT_THEME_POLICY, POLICY_SCOPE_HEADER } from '@/lib/admin/types'; +import { DEFAULT_POLICY, DEFAULT_THEME_POLICY, POLICY_DOMAINS_HEADER, POLICY_SCOPE_HEADER } from '@/lib/admin/types'; import { apiFetch } from '@/lib/browser-navigation'; import { IS_LITE, IS_LITE_STALWART, LITE_POLICY_PATH, withLiteBuildId } from '@/lib/lite'; import { applyLitePolicy } from '@/lib/lite-config'; @@ -22,6 +22,13 @@ interface PolicyState { matchedScopes: string[]; /** What set each non-default value of `policy` (see ResolvedPolicy.sources). */ sources: Record; + /** + * The domains the server picked the scopes for, or null when it sent all + * of them. An account of another domain needs a fresh fetch. + */ + scopeDomains: string[] | null; + /** Whether the fetched scopes cover this address's domain. */ + coversAddress: (email: string) => boolean; loaded: boolean; /** * The server answered with the public subset (not signed in yet); the @@ -81,6 +88,7 @@ const INITIAL = resolved({ ...DEFAULT_POLICY }, null); export const usePolicyStore = create()((set, get) => ({ ...INITIAL, subject: null, + scopeDomains: null, loaded: false, partial: false, demoFeatureOverrides: {}, @@ -105,7 +113,9 @@ export const usePolicyStore = create()((set, get) => ({ const data = await res.json(); if (request !== policyRequest) return; const partial = res.headers?.get?.(POLICY_SCOPE_HEADER) === 'public'; - set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, partial }); + const domainsHeader = res.headers?.get?.(POLICY_DOMAINS_HEADER); + const scopeDomains = typeof domainsHeader === 'string' ? domainsHeader.split(',').filter(Boolean) : null; + set({ ...resolved(IS_LITE ? applyLitePolicy(data) : data, get().subject), loaded: true, partial, scopeDomains }); } else { set({ ...(fallback.policy ? resolved(fallback.policy, get().subject) : {}), loaded: true }); } @@ -121,6 +131,13 @@ export const usePolicyStore = create()((set, get) => ({ resolveFor: (subject) => resolvePolicy(get().rawPolicy, subject).policy, + coversAddress: (email) => { + const domains = get().scopeDomains; + if (!domains) return true; + const domain = email.slice(email.lastIndexOf('@') + 1).toLowerCase(); + return domains.includes(domain); + }, + getOrganisation: () => get().policy.organisation ?? DEFAULT_ORGANISATION_POLICY, getPolicySource: (key) => get().sources[key] ?? 'default', From 42f52ab066ef1e491cd6dd469dfd7f93afaff617 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:08:56 +0100 Subject: [PATCH 007/137] refactor: send composer and quick reply mail through one send path --- .../__tests__/composer-send-pipeline.test.tsx | 248 ++++++++++++++++++ components/email/email-composer.tsx | 37 ++- components/mail/mail-app.tsx | 29 +- lib/outgoing/__tests__/quick-reply.test.ts | 45 ++++ lib/outgoing/__tests__/send-pipeline.test.ts | 57 ++++ lib/outgoing/quick-reply.ts | 46 ++++ lib/outgoing/send-pipeline.ts | 115 ++++++++ 7 files changed, 557 insertions(+), 20 deletions(-) create mode 100644 components/email/__tests__/composer-send-pipeline.test.tsx create mode 100644 lib/outgoing/__tests__/quick-reply.test.ts create mode 100644 lib/outgoing/__tests__/send-pipeline.test.ts create mode 100644 lib/outgoing/quick-reply.ts create mode 100644 lib/outgoing/send-pipeline.ts diff --git a/components/email/__tests__/composer-send-pipeline.test.tsx b/components/email/__tests__/composer-send-pipeline.test.tsx new file mode 100644 index 000000000..075421c0e --- /dev/null +++ b/components/email/__tests__/composer-send-pipeline.test.tsx @@ -0,0 +1,248 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { EmailComposer } from '../email-composer'; +import { registerOutgoingFinalizer, registerPreSendCheck, resetSendPipeline } from '@/lib/outgoing/send-pipeline'; + +// ─── 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) => 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>, + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => 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) => 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) => 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) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => 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) => 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 ──────────────────────────────────────────────────────────────────── + +// Features add to every send through lib/outgoing/send-pipeline.ts: a check +// can stop it, a finaliser changes what goes out. Both run in the composer's +// send, after its own checks. + +const DRAFT = { + to: 'bob@example.com', + cc: 'carol@example.com', + bcc: '', + subject: 'Hello', + body: '

Hi

', + showCc: true, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +function clickSend() { + fireEvent.click(screen.getAllByTestId('composer-send')[0]); +} + +describe('composer send pipeline', () => { + beforeEach(() => resetSendPipeline()); + afterEach(() => { + resetSendPipeline(); + vi.clearAllMocks(); + }); + + it('sends what the finalisers return, with the recipients in the context', async () => { + const seen: unknown[] = []; + registerOutgoingFinalizer('footer', 10, (bodies, context) => { + seen.push(context); + return { html: `${bodies.html}

Footer

`, text: `${bodies.text}\n\nFooter` }; + }); + const onSend = vi.fn(); + render(); + clickSend(); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + const sent = onSend.mock.calls[0][0] as { htmlBody?: string; body: string }; + expect(sent.htmlBody).toMatch(/

Footer<\/p>$/); + expect(sent.body).toMatch(/\n\nFooter$/); + expect(seen).toEqual([expect.objectContaining({ + surface: 'composer', to: ['bob@example.com'], cc: ['carol@example.com'], fromEmail: 'me@example.com', mode: 'compose', plainText: false, + })]); + }); + + it('sends nothing when a check says no, and sends after it says yes', async () => { + let allow = false; + const check = vi.fn(() => allow); + registerPreSendCheck('ask', 10, check); + const onSend = vi.fn(); + render(); + clickSend(); + await waitFor(() => expect(check).toHaveBeenCalledTimes(1)); + expect(onSend).not.toHaveBeenCalled(); + + allow = true; + clickSend(); + await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index d594c091b..01ac2feb7 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -96,6 +96,7 @@ import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils"; import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; +import { finalizeOutgoing, runPreSendChecks, type OutgoingContext } from "@/lib/outgoing/send-pipeline"; import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent, ownReplyAddresses } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading"; @@ -3091,6 +3092,8 @@ export function EmailComposer({ // second click in the same tick - and isSending drives button disabling. const [isSending, setIsSending] = useState(false); const isSendingRef = useRef(false); + // A pre-send check is waiting for the user (send-pipeline). + const preSendCheckingRef = useRef(false); // Attachments still uploading when Send is clicked used to be silently // dropped from the outgoing message (the filters below exclude anything @@ -3275,6 +3278,29 @@ export function EmailComposer({ } } + // Checks other features register (lib/outgoing/send-pipeline.ts), such + // as the external-recipient confirm. They may ask the user, so a second + // click meanwhile does nothing. + if (preSendCheckingRef.current) return; + const outgoingContext: OutgoingContext = { + surface: 'composer', + to: toAddresses.map(r => r.email), + cc: ccAddresses.map(r => r.email), + bcc: bccAddresses.map(r => r.email), + fromEmail: (fromOverrideEnabled && fromOverrideEmail.trim()) || currentIdentity?.email || undefined, + identityId: currentIdentity?.id, + mode, + plainText: plainTextMode, + }; + preSendCheckingRef.current = true; + let checksPassed = false; + try { + checksPassed = await runPreSendChecks(outgoingContext); + } finally { + preSendCheckingRef.current = false; + } + if (!checksPassed) return; + // Past every "don't send" early return - mark the send in flight so a // second click is a no-op until this resolves (reset in the finally below). isSendingRef.current = true; @@ -3347,7 +3373,7 @@ export function EmailComposer({ // In plain text mode, send text/plain only (no HTML body) const signatureOpts = { separator: signatureSeparatorEnabled }; - const finalBody = plainTextMode + const builtTextBody = plainTextMode ? (signatureAlreadyInBody ? body : appendPlainTextSignature(body, signatureIdentity, signatureOpts)) : (signatureAlreadyInBody ? htmlToPlainText(body) : appendPlainTextSignature(htmlToPlainText(body), signatureIdentity, signatureOpts)); @@ -3367,9 +3393,12 @@ export function EmailComposer({ } const rewritten = plainTextMode ? null : rewriteInlineImages(body); - const finalHtmlBody = plainTextMode - ? undefined - : `

${rewritten!.html}
${buildSignatureHtml()}`; + // What the send-pipeline finalisers add (disclaimer, organisation + // signature) goes in here, on the send only: drafts never get it. + const { html: finalHtmlBody, text: finalBody } = finalizeOutgoing({ + html: plainTextMode ? undefined : `
${rewritten!.html}
${buildSignatureHtml()}`, + text: builtTextBody, + }, outgoingContext); const inlineAttachments = rewritten?.attachments ?? []; try { diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index bbd5971b7..e5bfaf3c9 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -112,7 +112,7 @@ import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { WopiEditor } from "@/components/files/wopi-editor"; import { useWopiStatus, canWopiOpen } from "@/hooks/use-wopi-status"; import { isFilePreviewable } from "@/lib/file-preview"; -import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils"; +import { prepareQuickReply } from "@/lib/outgoing/quick-reply"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { findDraftIdentityId, findReplyIdentityId, resolveComposeAccountEmail } from "@/lib/reply-identity"; @@ -3900,21 +3900,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { const signatureIdentity = (sendingIdentity?.htmlSignature || sendingIdentity?.textSignature) ? sendingIdentity : primaryIdentity; - const separator = useSettingsStore.getState().signatureSeparatorEnabled; - const finalBody = appendPlainTextSignature(body, signatureIdentity, { separator }); - - // When the identity has an HTML signature, send a matching HTML body so the - // signature keeps its formatting; appendPlainTextSignature would otherwise - // flatten it to plain text. Text-only identities keep the plain-text-only - // behavior (htmlBody stays undefined). - const escapedBody = body - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\n/g, '
'); - const finalHtmlBody = signatureIdentity?.htmlSignature?.trim() - ? appendHtmlSignature(`
${escapedBody}
`, signatureIdentity, { separator }) - : undefined; + // The same checks and finalisers as the composer's send + // (lib/outgoing/send-pipeline.ts): a quick reply is no way around them. + const prepared = await prepareQuickReply({ + body, + signatureIdentity, + separator: useSettingsStore.getState().signatureSeparatorEnabled, + to: recipients, + fromEmail: headerFromEmail, + identityId: sendingIdentity?.id, + }); + if (!prepared) return; + const { html: finalHtmlBody, text: finalBody } = prepared; const originalEmailId = selectedEmail.id; const originalSource = emailWriteSource(selectedEmail, client); diff --git a/lib/outgoing/__tests__/quick-reply.test.ts b/lib/outgoing/__tests__/quick-reply.test.ts new file mode 100644 index 000000000..834082a6b --- /dev/null +++ b/lib/outgoing/__tests__/quick-reply.test.ts @@ -0,0 +1,45 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { prepareQuickReply } from '@/lib/outgoing/quick-reply'; +import { registerOutgoingFinalizer, registerPreSendCheck, resetSendPipeline } from '@/lib/outgoing/send-pipeline'; +import type { Identity } from '@/lib/jmap/types'; + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); + +// Quick reply goes through the same checks and finalisers as the composer, +// so the disclaimer and the external-recipient confirm reach it too. + +const html = { id: 'i', name: 'Me', email: 'me@example.com', htmlSignature: 'Me', textSignature: '' } as Identity; +const text = { id: 'i', name: 'Me', email: 'me@example.com', htmlSignature: '', textSignature: 'Me' } as Identity; +const input = (signatureIdentity: Identity) => ({ + body: 'Thanks <3', signatureIdentity, separator: true, to: ['bob@example.com'], fromEmail: 'me@example.com', identityId: 'i', +}); + +afterEach(() => resetSendPipeline()); + +describe('prepareQuickReply', () => { + it('builds the text and, for an HTML signature, an escaped HTML body', async () => { + const withHtml = await prepareQuickReply(input(html)); + expect(withHtml?.html).toContain('
Thanks <3
'); + expect(withHtml?.html).toContain('Me'); + expect(withHtml?.text).toContain('Thanks <3'); + const plain = await prepareQuickReply(input(text)); + expect(plain?.html).toBeUndefined(); + expect(plain?.text).toMatch(/Thanks <3[\s\S]*-- \nMe$/); + }); + + it('runs the finalisers with a reply context', async () => { + const contexts: unknown[] = []; + registerOutgoingFinalizer('f', 10, (bodies, context) => { + contexts.push(context); + return { html: bodies.html, text: `${bodies.text}\nFooter` }; + }); + const out = await prepareQuickReply(input(text)); + expect(out?.text).toMatch(/\nFooter$/); + expect(contexts).toEqual([expect.objectContaining({ surface: 'quick-reply', mode: 'reply', to: ['bob@example.com'], plainText: true })]); + }); + + it('sends nothing when a check says no', async () => { + registerPreSendCheck('no', 10, () => false); + expect(await prepareQuickReply(input(text))).toBeNull(); + }); +}); diff --git a/lib/outgoing/__tests__/send-pipeline.test.ts b/lib/outgoing/__tests__/send-pipeline.test.ts new file mode 100644 index 000000000..9492a1f39 --- /dev/null +++ b/lib/outgoing/__tests__/send-pipeline.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + finalizeOutgoing, + registerOutgoingFinalizer, + registerPreSendCheck, + resetSendPipeline, + runPreSendChecks, + type OutgoingContext, +} from '@/lib/outgoing/send-pipeline'; + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); + +const CONTEXT: OutgoingContext = { + surface: 'composer', to: ['a@example.com'], cc: [], bcc: [], mode: 'compose', plainText: false, +}; + +afterEach(() => resetSendPipeline()); + +describe('finalizeOutgoing', () => { + it('runs finalisers in order and leaves the bodies alone without any', () => { + expect(finalizeOutgoing({ html: '

x

', text: 'x' }, CONTEXT)).toEqual({ html: '

x

', text: 'x' }); + registerOutgoingFinalizer('second', 20, (b) => ({ ...b, text: `${b.text}2` })); + registerOutgoingFinalizer('first', 10, (b) => ({ ...b, text: `${b.text}1` })); + expect(finalizeOutgoing({ text: 'x' }, CONTEXT).text).toBe('x12'); + }); + + it('replaces a finaliser registered again under the same id, and unregisters', () => { + registerOutgoingFinalizer('f', 10, (b) => ({ ...b, text: 'old' })); + const off = registerOutgoingFinalizer('f', 10, (b) => ({ ...b, text: 'new' })); + expect(finalizeOutgoing({ text: 'x' }, CONTEXT).text).toBe('new'); + off(); + expect(finalizeOutgoing({ text: 'x' }, CONTEXT).text).toBe('x'); + }); + + it('skips a finaliser that throws', () => { + registerOutgoingFinalizer('broken', 10, () => { throw new Error('boom'); }); + registerOutgoingFinalizer('ok', 20, (b) => ({ ...b, text: `${b.text}!` })); + expect(finalizeOutgoing({ text: 'x' }, CONTEXT).text).toBe('x!'); + }); +}); + +describe('runPreSendChecks', () => { + it('stops at the first check that says no', async () => { + const later = vi.fn(() => true); + registerPreSendCheck('no', 10, async () => false); + registerPreSendCheck('later', 20, later); + expect(await runPreSendChecks(CONTEXT)).toBe(false); + expect(later).not.toHaveBeenCalled(); + }); + + it('sends when every check agrees or there are none, and skips one that throws', async () => { + expect(await runPreSendChecks(CONTEXT)).toBe(true); + registerPreSendCheck('broken', 10, () => { throw new Error('boom'); }); + registerPreSendCheck('yes', 20, () => true); + expect(await runPreSendChecks(CONTEXT)).toBe(true); + }); +}); diff --git a/lib/outgoing/quick-reply.ts b/lib/outgoing/quick-reply.ts new file mode 100644 index 000000000..ce696325a --- /dev/null +++ b/lib/outgoing/quick-reply.ts @@ -0,0 +1,46 @@ +import type { Identity } from '@/lib/jmap/types'; +import { appendHtmlSignature, appendPlainTextSignature } from '@/lib/signature-utils'; +import { finalizeOutgoing, runPreSendChecks, type OutgoingBodies } from './send-pipeline'; + +/** + * The bodies a quick reply sends: the typed text with the signature, as + * HTML too when the signature is HTML, after the same pre-send checks and + * finalisers as the composer's send. Null when a check stopped the send. + */ +export async function prepareQuickReply(input: { + body: string; + signatureIdentity: Identity | null | undefined; + separator: boolean; + to: string[]; + fromEmail?: string; + identityId?: string; +}): Promise { + const { body, signatureIdentity, separator } = input; + const text = appendPlainTextSignature(body, signatureIdentity, { separator }); + + // When the identity has an HTML signature, send a matching HTML body so the + // signature keeps its formatting; appendPlainTextSignature would otherwise + // flatten it to plain text. Text-only identities keep the plain-text-only + // behavior (no HTML part). + const escapedBody = body + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + const html = signatureIdentity?.htmlSignature?.trim() + ? appendHtmlSignature(`
${escapedBody}
`, signatureIdentity, { separator }) + : undefined; + + const context = { + surface: 'quick-reply' as const, + to: input.to, + cc: [], + bcc: [], + fromEmail: input.fromEmail, + identityId: input.identityId, + mode: 'reply' as const, + plainText: !html, + }; + if (!(await runPreSendChecks(context))) return null; + return finalizeOutgoing({ html, text }, context); +} diff --git a/lib/outgoing/send-pipeline.ts b/lib/outgoing/send-pipeline.ts new file mode 100644 index 000000000..bfe97765c --- /dev/null +++ b/lib/outgoing/send-pipeline.ts @@ -0,0 +1,115 @@ +/** + * One path for everything Bulwark does to a message on its way out, for + * every surface that sends: the composer and quick reply. Features register + * here instead of patching each send function: + * + * - pre-send checks run before anything is sent and may ask the user + * (the external-recipient confirm); + * - finalisers change the bodies that go out (the organisation signature + * check, the legal disclaimer). They run on sends only, never on drafts, + * so a re-opened draft never carries what they add. + * + * With nothing registered both are no-ops. + */ + +import { debug } from '@/lib/debug'; + +export type SendSurface = 'composer' | 'quick-reply'; + +export interface OutgoingContext { + surface: SendSurface; + /** Recipient addresses, without names. */ + to: string[]; + cc: string[]; + bcc: string[]; + fromEmail?: string; + identityId?: string; + /** compose, reply, replyAll or forward; quick reply is a reply. */ + mode: 'compose' | 'reply' | 'replyAll' | 'forward'; + /** Plain-text-only message (no HTML part). */ + plainText: boolean; +} + +export interface OutgoingBodies { + /** The HTML part; undefined for a plain-text-only message. */ + html?: string; + text: string; +} + +/** Changes the bodies; must return them unchanged when it has nothing to do. */ +export type OutgoingFinalizer = (bodies: OutgoingBodies, context: OutgoingContext) => OutgoingBodies; + +/** Resolves false to stop the send (after telling the user why, or asking them). */ +export type PreSendCheck = (context: OutgoingContext) => boolean | Promise; + +interface Entry { + id: string; + order: number; + run: T; +} + +const finalizers: Entry[] = []; +const checks: Entry[] = []; + +function register(list: Entry[], id: string, order: number, run: T): () => void { + const existing = list.findIndex((entry) => entry.id === id); + if (existing >= 0) list.splice(existing, 1); + list.push({ id, order, run }); + list.sort((a, b) => a.order - b.order); + return () => { + const index = list.findIndex((entry) => entry.id === id && entry.run === run); + if (index >= 0) list.splice(index, 1); + }; +} + +/** + * Adds a finaliser; lower `order` runs first. Registering the same id again + * replaces it. Returns the unregister function. + */ +export function registerOutgoingFinalizer(id: string, order: number, run: OutgoingFinalizer): () => void { + return register(finalizers, id, order, run); +} + +/** Adds a pre-send check; lower `order` asks first. Returns the unregister function. */ +export function registerPreSendCheck(id: string, order: number, run: PreSendCheck): () => void { + return register(checks, id, order, run); +} + +/** The bodies as they go out, after every finaliser. */ +export function finalizeOutgoing(bodies: OutgoingBodies, context: OutgoingContext): OutgoingBodies { + let current = bodies; + for (const entry of finalizers) { + try { + current = entry.run(current, context); + } catch (error) { + // A broken finaliser must not stop the mail; it goes out without its change. + debug.error(`Outgoing finaliser ${entry.id} failed:`, error); + } + } + return current; +} + +/** + * Runs the checks in order and stops at the first that says no. A check + * that throws is skipped: the checks warn, they don't guard, and a bug in + * one must not make sending impossible. + */ +export async function runPreSendChecks(context: OutgoingContext): Promise { + for (const entry of [...checks]) { + try { + if (!(await entry.run(context))) { + debug.log('email', `Send stopped by the pre-send check ${entry.id}`); + return false; + } + } catch (error) { + debug.error(`Pre-send check ${entry.id} failed:`, error); + } + } + return true; +} + +/** For tests: forget every registration. */ +export function resetSendPipeline(): void { + finalizers.length = 0; + checks.length = 0; +} From 7ea0a7d58d169970f9191f175243d99f780db74c Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:14:30 +0100 Subject: [PATCH 008/137] feat: read and write account metadata for shared Bulwark data --- lib/__tests__/jmap-bulwark-metadata.test.ts | 138 ++++++++++++++++++++ lib/jmap/bulwark-metadata.ts | 76 +++++++++++ lib/jmap/client-interface.ts | 4 + lib/jmap/client.ts | 79 +++++++++++ 4 files changed, 297 insertions(+) create mode 100644 lib/__tests__/jmap-bulwark-metadata.test.ts create mode 100644 lib/jmap/bulwark-metadata.ts diff --git a/lib/__tests__/jmap-bulwark-metadata.test.ts b/lib/__tests__/jmap-bulwark-metadata.test.ts new file mode 100644 index 000000000..2e1db844a --- /dev/null +++ b/lib/__tests__/jmap-bulwark-metadata.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { JMAPClient } from '../jmap/client'; +import { MetadataTooLargeError, MetadataUnsupportedError, bulwarkEntries, isValidMetadataKey } from '../jmap/bulwark-metadata'; + +// Bulwark keeps shared tags and templates in the Inbox metadata of an +// account, one namespace per item (each namespace holds at most 8 KB on +// Stalwart v1.0.0). Servers without metadata, and the pre-release 1.0 build +// that drops the connection on these calls, count as "not supported". + +const METADATA_CAP = { + 'urn:ietf:params:jmap:metadata': { + dataTypes: { Mailbox: { namespaces: [], supportsVendorNamespaces: true, supportsPrivate: true, maxDepth: 8 } }, + }, +}; + +function session() { + return { + capabilities: { 'urn:ietf:params:jmap:core': {}, 'urn:ietf:params:jmap:mail': {}, 'urn:ietf:params:jmap:metadata': {} }, + accounts: { + me: { name: 'me@example.org', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } }, + team: { name: 'team@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {}, ...METADATA_CAP } }, + old: { name: 'old@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } }, + }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'me' }, + apiUrl: 'https://mail.example.com/jmap/api', + downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}', + uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/', + eventSourceUrl: '', + }; +} + +const json = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + +let fetchSpy: ReturnType; +let client: JMAPClient; +let requests: Array<{ using: string[]; methodCalls: Array<[string, Record, string]> }>; +let answer: (body: (typeof requests)[number]) => Response | Promise; + +beforeEach(async () => { + vi.useRealTimers(); + fetchSpy = vi.spyOn(globalThis, 'fetch'); + fetchSpy.mockResolvedValueOnce(json(session())); + client = new JMAPClient('https://mail.example.com', 'me@example.org', 'pw'); + await client.connect(); + fetchSpy.mockReset(); + requests = []; + fetchSpy.mockImplementation(async (_url: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body ?? '{}')); + requests.push(body); + return answer(body); + }); + vi.spyOn(console, 'error').mockImplementation(() => {}); +}); + +afterEach(() => vi.restoreAllMocks()); + +const inboxAnswer = (metadata: unknown, property = 'metadata') => json({ + methodResponses: [ + ['Mailbox/query', { ids: ['inbox-1'] }, 'q'], + ['Mailbox/get', { list: [{ id: 'inbox-1', [property]: metadata }] }, 'g'], + ], +}); + +describe('getBulwarkMetadata', () => { + it('reads Bulwark\'s namespaces of the Inbox in a request of its own', async () => { + answer = () => inboxAnswer({ 'org.bulwarkmail.tags': [{ id: 'urgent' }], 'org.bulwarkmail.template.a': { name: 'A' }, 'other.example': 1 }); + expect(await client.getBulwarkMetadata('team')).toEqual({ tags: [{ id: 'urgent' }], 'template.a': { name: 'A' } }); + expect(requests).toHaveLength(1); + expect(requests[0].using).toContain('urn:ietf:params:jmap:metadata'); + expect(requests[0].methodCalls.map((c) => c[0])).toEqual(['Mailbox/query', 'Mailbox/get']); + expect(requests[0].methodCalls[1][1].properties).toEqual(['id', 'metadata']); + }); + + it('reads private metadata when asked', async () => { + answer = () => inboxAnswer({ 'org.bulwarkmail.templates': { x: 1 } }, 'privateMetadata'); + expect(await client.getBulwarkMetadata('me', { private: true })).toEqual({ templates: { x: 1 } }); + expect(requests[0].methodCalls[1][1].properties).toEqual(['id', 'privateMetadata']); + }); + + it('asks nothing of an account without the capability', async () => { + answer = () => { throw new Error('should not be asked'); }; + expect(client.supportsBulwarkMetadata('old')).toBe(false); + expect(await client.getBulwarkMetadata('old')).toBeNull(); + expect(requests).toHaveLength(0); + }); + + it('treats a dropped connection as unsupported and leaves the account alone afterwards', async () => { + answer = () => { throw new TypeError('Failed to fetch'); }; + expect(await client.getBulwarkMetadata('team')).toBeNull(); + const asked = requests.length; + expect(client.supportsBulwarkMetadata('team')).toBe(false); + expect(await client.getBulwarkMetadata('team')).toBeNull(); + expect(requests).toHaveLength(asked); + // Other accounts are not affected. + expect(client.supportsBulwarkMetadata('me')).toBe(true); + }); +}); + +describe('setBulwarkMetadata', () => { + it('patches one namespace of the Inbox and nothing else', async () => { + answer = (body) => body.methodCalls[0][0] === 'Mailbox/set' + ? json({ methodResponses: [['Mailbox/set', { updated: { 'inbox-1': null } }, '0']] }) + : inboxAnswer({}); + await client.setBulwarkMetadata('team', 'tags', [{ id: 'urgent', color: '#f00' }]); + const set = requests.find((r) => r.methodCalls[0][0] === 'Mailbox/set')!; + expect(set.methodCalls[0][1]).toEqual({ + accountId: 'team', + update: { 'inbox-1': { 'metadata/org.bulwarkmail.tags': [{ id: 'urgent', color: '#f00' }] } }, + }); + }); + + it('refuses an entry over 8 KB before asking, and passes on the server\'s size refusal', async () => { + answer = (body) => body.methodCalls[0][0] === 'Mailbox/set' + ? json({ methodResponses: [['Mailbox/set', { notUpdated: { 'inbox-1': { type: 'invalidProperties', description: 'Metadata size 70413 exceeds the maximum of 65536 bytes.' } } }, '0']] }) + : inboxAnswer({}); + await expect(client.setBulwarkMetadata('team', 'template.big', { body: 'x'.repeat(9000) })).rejects.toBeInstanceOf(MetadataTooLargeError); + expect(requests.some((r) => r.methodCalls[0][0] === 'Mailbox/set')).toBe(false); + await expect(client.setBulwarkMetadata('team', 'template.ok', { body: 'small' })).rejects.toBeInstanceOf(MetadataTooLargeError); + }); + + it('says unsupported for an account without metadata', async () => { + await expect(client.setBulwarkMetadata('old', 'tags', [])).rejects.toBeInstanceOf(MetadataUnsupportedError); + }); +}); + +describe('helpers', () => { + it('accepts lowercase dotted keys only', () => { + expect(isValidMetadataKey('template.abc-1')).toBe(true); + expect(isValidMetadataKey('Tags')).toBe(false); + expect(isValidMetadataKey('a/b')).toBe(false); + expect(isValidMetadataKey('.x')).toBe(false); + }); + + it('ignores other vendors and non-objects', () => { + expect(bulwarkEntries(null)).toEqual({}); + expect(bulwarkEntries({ 'org.bulwarkmail': 1, 'org.bulwarkmailx.a': 2, 'org.bulwarkmail.a': 3 })).toEqual({ a: 3 }); + }); +}); diff --git a/lib/jmap/bulwark-metadata.ts b/lib/jmap/bulwark-metadata.ts new file mode 100644 index 000000000..57f39f7ee --- /dev/null +++ b/lib/jmap/bulwark-metadata.ts @@ -0,0 +1,76 @@ +/** + * Bulwark's own data in JMAP metadata (urn:ietf:params:jmap:metadata) of an + * account's Inbox: shared tag definitions and team templates in a group + * account, personal templates in the user's own account. + * + * What Stalwart v1.0.0 does (local-data/org-features-facts.md): + * - vendor namespaces need a dot ("org.bulwarkmail" works, "bulwark" is + * refused); each namespace holds at most 8 KB, one mailbox at most 64 KB + * of shared and 16 KB of private metadata; + * - `metadata/` patches one namespace and leaves the others; + * - every member of a group account sees its shared metadata; private + * metadata is per user. + * + * So every item gets a namespace of its own, `org.bulwarkmail.`. 0.16 + * has no metadata, and a pre-release 1.0 build advertises it but drops the + * connection on Mailbox/get with `metadata`; the calls go in a request of + * their own and any failure means "not supported" for that account. + */ + +export const METADATA_CAPABILITY = 'urn:ietf:params:jmap:metadata'; +export const BULWARK_NAMESPACE = 'org.bulwarkmail'; +/** Largest value Stalwart's default config takes in one namespace. */ +export const MAX_METADATA_ENTRY_BYTES = 8192; + +const KEY_RE = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/; + +export class MetadataTooLargeError extends Error { + constructor(message: string) { + super(message); + this.name = 'MetadataTooLargeError'; + } +} + +export class MetadataUnsupportedError extends Error { + constructor(message = 'This server keeps no metadata for Bulwark') { + super(message); + this.name = 'MetadataUnsupportedError'; + } +} + +/** `tags`, `template.`: lowercase, digits, dashes, dot-separated. */ +export function isValidMetadataKey(key: string): boolean { + return key.length <= 120 && KEY_RE.test(key); +} + +export function namespaceFor(key: string): string { + if (!isValidMetadataKey(key)) throw new Error(`Invalid metadata key: ${key}`); + return `${BULWARK_NAMESPACE}.${key}`; +} + +/** + * Whether the account's capabilities offer vendor-namespaced metadata on + * mailboxes. Says nothing about whether the server really implements it. + */ +export function advertisesMailboxMetadata(accountCapabilities: Record | undefined): boolean { + const cap = accountCapabilities?.[METADATA_CAPABILITY] as + | { dataTypes?: Record } + | undefined; + return cap?.dataTypes?.Mailbox?.supportsVendorNamespaces === true; +} + +/** Bulwark's entries of a `metadata` or `privateMetadata` object, by key. */ +export function bulwarkEntries(metadata: unknown): Record { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return {}; + const prefix = `${BULWARK_NAMESPACE}.`; + const out: Record = {}; + for (const [namespace, value] of Object.entries(metadata as Record)) { + if (namespace.startsWith(prefix)) out[namespace.slice(prefix.length)] = value; + } + return out; +} + +/** The size Stalwart counts for a value: its JSON in UTF-8. */ +export function metadataValueBytes(value: unknown): number { + return new TextEncoder().encode(JSON.stringify(value)).length; +} diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index b5fbc4e52..32c3fff43 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -610,6 +610,10 @@ export interface IJMAPClient { listPrincipals?(targetAccountId?: string, options?: { throwOnError?: boolean }): Promise; /** Principal type by id ("individual", "group"); null when unknown. Optional (demo client). */ getPrincipalTypes?(ids: string[]): Promise | null>; + // Bulwark data in Inbox metadata (lib/jmap/bulwark-metadata.ts). Optional (demo client). + supportsBulwarkMetadata?(accountId?: string): boolean; + getBulwarkMetadata?(accountId?: string, options?: { private?: boolean }): Promise | null>; + setBulwarkMetadata?(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise; setCalendarShare(calendarId: string, principalId: string, rights: CalendarRights | null, targetAccountId?: string): Promise; setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise; setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise; diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 0fd9efa67..3032b4477 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,3 +1,4 @@ +import { advertisesMailboxMetadata, bulwarkEntries, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata"; import { IncompleteDeleteError } from "./delete-error"; import { ArchiveEmailsError } from "./archive-error"; import { generateUUID } from '@/lib/utils'; @@ -7076,6 +7077,84 @@ export class JMAPClient implements IJMAPClient { } } + /** + * Accounts whose metadata request failed, with when: not asked again for + * ten minutes. A dropped connection (the pre-release 1.0 build) looks the + * same as a network blip, so the pause is not for the whole session. + */ + private metadataUnsupported = new Map(); + + /** Whether Bulwark may keep data in this account's Inbox metadata (lib/jmap/bulwark-metadata.ts). */ + supportsBulwarkMetadata(accountId?: string): boolean { + const id = accountId || this.accountId; + const failedAt = this.metadataUnsupported.get(id); + if (failedAt !== undefined && Date.now() - failedAt < 10 * 60_000) return false; + return advertisesMailboxMetadata(this.accounts[id]?.accountCapabilities as Record | undefined); + } + + /** + * Bulwark's metadata entries of the account's Inbox, by key, shared or the + * user's private ones. Null when the server has no usable metadata. + */ + async getBulwarkMetadata(accountId?: string, options?: { private?: boolean }): Promise | null> { + const id = accountId || this.accountId; + if (!this.supportsBulwarkMetadata(id)) return null; + const property = options?.private ? "privateMetadata" : "metadata"; + try { + // A request of its own: a server that can't answer it must not take + // other calls down with it. + const response = await this.request([ + ["Mailbox/query", { accountId: id, filter: { role: "inbox" } }, "q"], + ["Mailbox/get", { accountId: id, "#ids": { resultOf: "q", name: "Mailbox/query", path: "/ids" }, properties: ["id", property] }, "g"], + ], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]); + const get = response.methodResponses?.find((r) => r[0] === "Mailbox/get"); + const inbox = (get?.[1] as { list?: Array> } | undefined)?.list?.[0]; + if (!inbox) throw new MetadataUnsupportedError("No Inbox metadata in the answer"); + this.metadataInboxIds.set(id, inbox.id as string); + this.metadataUnsupported.delete(id); + return bulwarkEntries(inbox[property]); + } catch (error) { + debug.warn('jmap', `Metadata unavailable for account ${id}:`, error); + this.metadataUnsupported.set(id, Date.now()); + return null; + } + } + + private metadataInboxIds = new Map(); + + /** + * Writes one Bulwark metadata entry of the account's Inbox (null removes + * it), leaving every other entry as it is. Throws MetadataTooLargeError + * when the server refuses the size, MetadataUnsupportedError when the + * account has no metadata. + */ + async setBulwarkMetadata(accountId: string | undefined, key: string, value: unknown, options?: { private?: boolean }): Promise { + const id = accountId || this.accountId; + if (!this.supportsBulwarkMetadata(id)) throw new MetadataUnsupportedError(); + const property = options?.private ? "privateMetadata" : "metadata"; + if (value !== null && metadataValueBytes(value) > MAX_METADATA_ENTRY_BYTES) { + throw new MetadataTooLargeError(`The entry is larger than ${MAX_METADATA_ENTRY_BYTES} bytes`); + } + let inboxId = this.metadataInboxIds.get(id); + if (!inboxId) { + await this.getBulwarkMetadata(id, options); + inboxId = this.metadataInboxIds.get(id); + if (!inboxId) throw new MetadataUnsupportedError(); + } + const response = await this.request([ + ["Mailbox/set", { accountId: id, update: { [inboxId]: { [`${property}/${namespaceFor(key)}`]: value } } }, "0"], + ], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", METADATA_CAPABILITY]); + const [method, result] = response.methodResponses?.[0] ?? []; + if (method !== "Mailbox/set") { + throw jmapError((result as JmapErrorInfo | undefined)?.description || "Failed to save metadata", result as JmapErrorInfo | undefined); + } + const notUpdated = (result as { notUpdated?: Record }).notUpdated?.[inboxId]; + if (notUpdated) { + if (/exceeds the maximum/i.test(notUpdated.description ?? "")) throw new MetadataTooLargeError(notUpdated.description!); + throw new Error(notUpdated.description || `Failed to save metadata (${notUpdated.type ?? "unknown"})`); + } + } + /** * The busy periods of a principal between `utcStart` and `utcEnd` * (Principal/getAvailability). Event details are never requested: this From 90e6e1046478b71cc61d20c11e210ab905e9af6e Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:33:12 +0100 Subject: [PATCH 009/137] test: cover domain and group policies against the test servers --- .../tests/19-org-policy-scopes.spec.ts | 169 ++++++++++++++++++ integration/tests/helpers/org.ts | 104 +++++++++++ 2 files changed, 273 insertions(+) create mode 100644 integration/tests/19-org-policy-scopes.spec.ts create mode 100644 integration/tests/helpers/org.ts diff --git a/integration/tests/19-org-policy-scopes.spec.ts b/integration/tests/19-org-policy-scopes.spec.ts new file mode 100644 index 000000000..dedc01ff3 --- /dev/null +++ b/integration/tests/19-org-policy-scopes.spec.ts @@ -0,0 +1,169 @@ +import { test, expect } from '@playwright/test'; +import { readFile, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { + ORG, + adminContext, + getPolicy, + putPolicy, + requireOrgEnv, + shot, + userPage, +} from './helpers/org'; + +/** + * Domain and group scopes (organisation feature 8), through the real UI on a + * VM 106 test server: the admin creates a domain scope and a group scope on + * Organisation > Domains and groups, and each applies only where it should. + * The admin's policy is put back afterwards. + */ + +test.describe.configure({ mode: 'serial' }); + +let original: Record; + +test.beforeAll(async ({ browser }) => { + requireOrgEnv(); + const admin = await adminContext(browser); + original = await getPolicy(admin); + await putPolicy(admin, { ...original, scopes: [], restrictions: {}, features: {} }); + await admin.close(); +}); + +test.afterAll(async ({ browser }) => { + const admin = await adminContext(browser); + await putPolicy(admin, original); + await admin.close(); +}); + +test('the admin adds a domain and a group on the Domains and groups page', async ({ browser }) => { + const admin = await adminContext(browser); + const page = await admin.newPage(); + await page.goto('/admin/organisation'); + await expect(page.getByRole('heading', { name: 'Domains and groups' }).first()).toBeVisible(); + + await page.getByRole('button', { name: 'Add domains' }).click(); + await page.getByLabel('Name', { exact: true }).last().fill('Org two'); + await page.getByLabel('Domains', { exact: true }).last().fill('org-two.test'); + await page.getByLabel('Domains', { exact: true }).last().blur(); + + await page.getByRole('button', { name: 'Add groups' }).click(); + await page.getByLabel('Name', { exact: true }).last().fill('Org team'); + await page.getByLabel('Groups', { exact: true }).last().fill(ORG.team); + await page.getByLabel('Groups', { exact: true }).last().blur(); + + await page.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(page.getByText('Saved.')).toBeVisible(); + await shot(page, 'admin-domains-and-groups'); + + const policy = await getPolicy(admin); + const scopes = policy.scopes as Array<{ id: string; label: string; match: Record }>; + expect(scopes.map((s) => [s.label, s.match])).toEqual([ + ['Org two', { domains: ['org-two.test'] }], + ['Org team', { groups: [ORG.team] }], + ]); + + // The settings of each scope come with the Organisation pages of the + // features; here they are set through the same policy the pages write. + const [two, team] = scopes; + await putPolicy(admin, { + ...policy, + scopes: [ + { ...two, policy: { features: { calendarEnabled: false } } }, + { ...team, policy: { restrictions: { density: { locked: true, value: 'compact' } } } }, + ], + }); + + // The effective policy for org-alice names the group scope. + await page.reload(); + await page.getByLabel('User address').fill(ORG.alice); + await page.getByLabel('Groups the user is a member of').fill(ORG.team); + const effective = page.getByTestId('org-effective-policy'); + await expect(effective).toContainText('Applies, in this order: Everyone, Org team.'); + await expect(effective).toContainText('restrictions.density'); + await shot(page, 'admin-effective-policy'); + await admin.close(); +}); + +test('a domain scope applies to that domain only', async ({ browser }) => { + const dave = await userPage(browser, ORG.dave); + await expect(dave.locator('[data-tour="nav-mail"]')).toBeVisible(); + await expect(dave.locator('[data-tour="nav-calendar"]')).toHaveCount(0); + await dave.context().close(); + + const carol = await userPage(browser, ORG.carol); + await expect(carol.locator('[data-tour="nav-calendar"]')).toBeVisible(); + await carol.goto('/settings/appearance'); + await expect(carol.getByText('Set by your organisation')).toHaveCount(0); + await carol.context().close(); +}); + +test('a group scope locks a setting for members only, with the reason in place', async ({ browser }) => { + const alice = await userPage(browser, ORG.alice); + await expect(alice.locator('[data-tour="nav-calendar"]')).toBeVisible(); + await alice.goto('/settings/appearance'); + const note = alice.getByText('Set by your organisation'); + await expect(note).toHaveCount(1); + await note.scrollIntoViewIfNeeded(); + await shot(alice, 'user-locked-setting'); + // The locked control can't be reached. + const row = alice.locator('[data-search-label]').filter({ has: note }); + await expect(row.locator('[inert]')).toHaveCount(1); + await alice.context().close(); +}); + +test('the lock shows at phone width too', async ({ browser }) => { + const alice = await userPage(browser, ORG.alice, true); + // A phone opens the settings on their list. + await alice.goto('/settings/appearance'); + await alice.getByRole('button', { name: 'Appearance' }).or(alice.getByRole('link', { name: 'Appearance' })).first().click(); + const note = alice.getByText('Set by your organisation'); + await expect(note).toBeVisible(); + await note.scrollIntoViewIfNeeded(); + await shot(alice, 'user-locked-setting-phone'); + await alice.context().close(); +}); + +test('the whole policy exports to one file and imports back', async ({ browser }, testInfo) => { + const admin = await adminContext(browser); + const page = await admin.newPage(); + await page.goto('/admin/organisation'); + const [download] = await Promise.all([ + page.waitForEvent('download'), + page.getByRole('button', { name: 'Export policy.json' }).click(), + ]); + const exported = testInfo.outputPath('exported-policy.json'); + await download.saveAs(exported); + const file = JSON.parse(await readFile(exported, 'utf-8')); + expect(file.scopes.map((s: { label: string }) => s.label)).toEqual(['Org two', 'Org team']); + + // Change it outside Bulwark, as someone keeping it in git would. + file.scopes = file.scopes.filter((s: { label: string }) => s.label !== 'Org two'); + const edited = path.join(path.dirname(exported), 'edited-policy.json'); + await writeFile(edited, JSON.stringify(file)); + await page.getByLabel('Policy file to import').setInputFiles(edited); + const preview = page.getByTestId('policy-import-preview'); + await expect(preview).toContainText('Domains and groups: 1 removed'); + await shot(page, 'admin-policy-import-preview'); + await page.getByRole('button', { name: 'Replace the current policy' }).click(); + await expect(preview).toHaveCount(0); + const after = await getPolicy(admin); + expect((after.scopes as Array<{ label: string }>).map((s) => s.label)).toEqual(['Org team']); + await admin.close(); + + // org-dave has the calendar again. + const dave = await userPage(browser, ORG.dave); + await expect(dave.locator('[data-tour="nav-calendar"]')).toBeVisible(); + await dave.context().close(); +}); + +test('the Organisation pages work at phone width', async ({ browser }) => { + const admin = await adminContext(browser, true); + const page = await admin.newPage(); + await page.goto('/admin/organisation'); + await expect(page.getByRole('button', { name: 'Add domains' })).toBeVisible(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(0); + await shot(page, 'admin-domains-and-groups-phone'); + await admin.close(); +}); diff --git a/integration/tests/helpers/org.ts b/integration/tests/helpers/org.ts new file mode 100644 index 000000000..3c14996fe --- /dev/null +++ b/integration/tests/helpers/org.ts @@ -0,0 +1,104 @@ +/** + * Helpers for the organisation feature specs (19-org-*.spec.ts). They run + * against a local dev server on a VM 106 test Stalwart with the throwaway + * org-* accounts, never the suite's alice/bob/carol, and never reset an + * account. + * + * Env: + * IT_WEBMAIL_URL the dev server (scratch ADMIN_CONFIG_DIR) + * IT_ORG_PASSWORD password of the org-* accounts (vault "Stalwart test – org-* throwaway accounts") + * IT_ADMIN_PASSWORD the dev server's admin password (vault "Bulwark dev rig – org features admin") + * IT_ORG_SERVER label for screenshots and logs: 0.16, 1.0, 1.0.0 + */ +import { type Browser, type BrowserContext, type Page, expect } from '@playwright/test'; +import { existsSync, mkdirSync } from 'node:fs'; +import path from 'node:path'; +import { WEBMAIL_URL } from './config'; +import { accountSwitcher, neutralizeDevOverlay } from './app'; + +export const ORG_PASSWORD = process.env.IT_ORG_PASSWORD ?? ''; +export const ADMIN_PASSWORD = process.env.IT_ADMIN_PASSWORD ?? ''; +export const ORG_SERVER = process.env.IT_ORG_SERVER ?? 'unknown'; + +export const ORG = { + alice: 'org-alice@example.org', + bob: 'org-bob@example.org', + carol: 'org-carol@example.org', + dave: 'org-dave@org-two.test', + team: 'org-team@example.org', +} as const; + +export function requireOrgEnv(): void { + if (!ORG_PASSWORD || !ADMIN_PASSWORD) { + throw new Error('Set IT_ORG_PASSWORD and IT_ADMIN_PASSWORD (vw exec --env ...) to run the org specs'); + } +} + +/** English UI (Playwright's default locale gives German) at desktop or phone width. */ +export function contextOptions(phone = false) { + return { + baseURL: WEBMAIL_URL, + locale: 'en-US', + viewport: phone ? { width: 390, height: 844 } : { width: 1440, height: 900 }, + ...(phone ? { isMobile: true, hasTouch: true } : {}), + }; +} + +const stateDir = path.join(process.cwd(), 'integration', 'test-results'); +const adminStatePath = () => path.join(stateDir, `org-admin-${new URL(WEBMAIL_URL).port || '80'}.json`); + +/** + * A browser context signed in to /admin. Admin login is rate limited (5 per + * 15 minutes), so the session is stored once and reused across specs. + */ +export async function adminContext(browser: Browser, phone = false): Promise { + if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true }); + if (existsSync(adminStatePath())) { + const context = await browser.newContext({ ...contextOptions(phone), storageState: adminStatePath() }); + const res = await context.request.get('/api/admin/auth'); + if (res.ok() && (await res.json().catch(() => ({})))?.authenticated !== false) return context; + await context.close(); + } + const context = await browser.newContext(contextOptions(phone)); + const page = await context.newPage(); + await page.goto('/admin/login'); + await page.fill('#password', ADMIN_PASSWORD); + await page.click('button[type="submit"]'); + await page.waitForURL((url) => !url.pathname.endsWith('/admin/login'), { timeout: 30000 }); + await context.storageState({ path: adminStatePath() }); + await page.close(); + return context; +} + +/** The policy as the admin sees it. */ +export async function getPolicy(admin: BrowserContext): Promise> { + const res = await admin.request.get('/api/admin/policy'); + expect(res.ok()).toBe(true); + return res.json(); +} + +export async function putPolicy(admin: BrowserContext, policy: Record): Promise { + const res = await admin.request.put('/api/admin/policy', { data: policy }); + expect(res.ok(), await res.text()).toBe(true); +} + +/** Sign in a fresh context as one of the org-* accounts. */ +export async function userPage(browser: Browser, email: string, phone = false): Promise { + const context = await browser.newContext(contextOptions(phone)); + const page = await context.newPage(); + 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 }); + return page; +} + +/** Screenshots for local-data/org-features-screenshots/, named by feature and server. */ +export async function shot(page: Page, name: string): Promise { + const dir = process.env.IT_ORG_SCREENSHOTS; + if (!dir) return; + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + await page.screenshot({ path: path.join(dir, `${name}-${ORG_SERVER}.png`), fullPage: false }); +} From b504e39b63f05ff061423f5b8ffdaf7fcfbaba09 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:53:39 +0100 Subject: [PATCH 010/137] feat: label mail from outside the organisation --- .../__tests__/external-sender-chip.test.tsx | 97 +++++++++++++ components/email/email-viewer.tsx | 5 + components/email/external-sender-chip.tsx | 50 +++++++ components/email/thread-conversation-view.tsx | 2 + lib/__tests__/external-mail.test.ts | 129 ++++++++++++++++++ lib/admin/organisation-policy.ts | 6 +- lib/external-mail.ts | 119 ++++++++++++++++ locales/de/common.json | 4 + locales/en/common.json | 4 + 9 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 components/email/__tests__/external-sender-chip.test.tsx create mode 100644 components/email/external-sender-chip.tsx create mode 100644 lib/__tests__/external-mail.test.ts create mode 100644 lib/external-mail.ts diff --git a/components/email/__tests__/external-sender-chip.test.tsx b/components/email/__tests__/external-sender-chip.test.tsx new file mode 100644 index 000000000..a14269288 --- /dev/null +++ b/components/email/__tests__/external-sender-chip.test.tsx @@ -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: '

Body

' } }, +}) as unknown as Email; + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +async function openMessage(from: string) { + const view = render(); + 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(); + 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(); + 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(); + }); +}); diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 54a8409d6..573d48422 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -114,6 +114,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"; @@ -854,6 +855,7 @@ export function MvExpandedConversationCard({ {!compact && sender?.name && sender.email && ( <{sender.email}> )} + {compact ? formatDate(emailDisplayDate(email)) : mvSenderDate(emailDisplayDate(email), timeFormat)} @@ -5045,6 +5047,7 @@ export function EmailViewer({ {mvViewer && sender?.email && sender?.name && ( <{sender.email}> )} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( {t('unknown_sender')} )} + {shouldShowUnsubBanner && listHeaders?.listUnsubscribe && ( + {senderVerification && ( s.policy.organisation?.externalMail); + const identities = useAuthStore((s) => s.identities); + return useMemo(() => { + if (!externalMail?.senderLabel) return null; + const internal = internalDomains(externalMail.domains, (identities ?? []).map((identity) => identity.email)); + return externalSenderDomain(fromEmail, internal); + }, [externalMail, identities, fromEmail]); +} + +/** + * "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. + */ +export function ExternalSenderChip({ fromEmail, className }: { fromEmail: string | null | undefined; className?: string }) { + const t = useTranslations('external_mail'); + const domain = useExternalSenderDomain(fromEmail); + if (!domain) return null; + const tooltip = t('sender_tooltip', { domain }); + return ( + + + ); +} diff --git a/components/email/thread-conversation-view.tsx b/components/email/thread-conversation-view.tsx index 4cde880b6..26c19ad7f 100644 --- a/components/email/thread-conversation-view.tsx +++ b/components/email/thread-conversation-view.tsx @@ -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"; @@ -396,6 +397,7 @@ function EmailCard({ )}> {sender?.name || (sender?.email && formatAddress(sender.email)) || "Unknown"} + {isStarred && ( )} diff --git a/lib/__tests__/external-mail.test.ts b/lib/__tests__/external-mail.test.ts new file mode 100644 index 000000000..5f07e76ca --- /dev/null +++ b/lib/__tests__/external-mail.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { + addressDomain, + contactAddresses, + coreExternalWarningsOn, + currentInternalDomains, + domainMatches, + externalRecipients, + externalSenderDomain, + 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) { + 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 ')).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(['partner.com'], ['me@example.org', 'alias@Example.org'])).toEqual(['partner.com', 'example.org']); + }); +}); + +describe('isExternalAddress', () => { + const internal = internalDomains(['partner.com'], ['me@example.org']); + 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('externalSenderDomain', () => { + it('names the domain of an external sender and nothing for internal ones', () => { + expect(externalSenderDomain('dave@org-two.test', ['example.org'])).toBe('org-two.test'); + expect(externalSenderDomain('bob@example.org', ['example.org'])).toBeNull(); + expect(externalSenderDomain(undefined, ['example.org'])).toBeNull(); + }); +}); + +describe('the active account', () => { + beforeEach(() => { + setExternalMail({}); + useAuthStore.setState({ identities: [] }); + useContactStore.setState({ contacts: [] }); + }); + + it('reads the switches from the effective policy', () => { + expect(coreExternalWarningsOn()).toBe(false); + setExternalMail({ senderLabel: true }); + expect(coreExternalWarningsOn()).toBe(true); + setExternalMail({ recipientWarning: true }); + expect(coreExternalWarningsOn()).toBe(true); + }); + + it('combines the policy domains with the identity domains', () => { + setExternalMail({ domains: ['partner.com'] }); + useAuthStore.setState({ identities: [identity('org-alice@example.org')] }); + expect(currentInternalDomains()).toEqual(['partner.com', 'example.org']); + }); + + 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']); + }); +}); diff --git a/lib/admin/organisation-policy.ts b/lib/admin/organisation-policy.ts index 55769a4e3..77562da19 100644 --- a/lib/admin/organisation-policy.ts +++ b/lib/admin/organisation-policy.ts @@ -25,7 +25,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; diff --git a/lib/external-mail.ts b/lib/external-mail.ts new file mode 100644 index 000000000..d81add2b0 --- /dev/null +++ b/lib/external-mail.ts @@ -0,0 +1,119 @@ +/** + * 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. + * + * The pure functions take everything as arguments; the `current*` helpers + * read the active account's effective policy and identities. + */ + +import { DEFAULT_ORGANISATION_POLICY, normalizeDomainList, type OrganisationPolicy } from '@/lib/admin/organisation-policy'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; + +export type ExternalMailPolicy = OrganisationPolicy['externalMail']; + +/** The lowercased domain of an address ("Name " or "a@b"); '' without one. */ +export function addressDomain(address: string | null | undefined): string { + const raw = String(address ?? '').trim(); + const bracket = raw.match(/<([^<>]*)>\s*$/); + const bare = (bracket ? bracket[1] : raw).trim(); + const at = bare.lastIndexOf('@'); + if (at < 0) return ''; + return bare.slice(at + 1).trim().toLowerCase().replace(/\.+$/, ''); +} + +/** True when `domain` 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 admin's domains plus the domains of the user's identities, normalised and deduplicated. */ +export function internalDomains(policyDomains: readonly string[], identityEmails: readonly string[]): string[] { + const own = identityEmails.map(addressDomain).filter(Boolean); + return normalizeDomainList([...policyDomains, ...own]) ?? []; +} + +/** Whether mail from or to this address leaves the organisation. */ +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 "trust + * contacts" on, an address in the user's contacts doesn't count. + */ +export function externalRecipients( + recipients: { to: readonly string[]; cc: readonly string[]; bcc: readonly string[] }, + internal: readonly string[], + trusted?: (address: string) => boolean, +): ExternalRecipient[] { + const out: ExternalRecipient[] = []; + const seen = new Set(); + for (const field of ['to', 'cc', 'bcc'] as const) { + for (const raw of recipients[field]) { + const address = String(raw ?? '').trim(); + const key = address.toLowerCase(); + 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; +} + +/** The sender's domain when it is outside the organisation, else null. */ +export function externalSenderDomain(fromEmail: string | null | undefined, internal: readonly string[]): string | null { + if (!fromEmail || !isExternalAddress(fromEmail, internal)) return null; + return addressDomain(fromEmail); +} + +// ─── The active account ────────────────────────────────────── + +/** The externalMail settings of the active account's effective policy. */ +export function currentExternalMailPolicy(): ExternalMailPolicy { + return usePolicyStore.getState().getOrganisation().externalMail ?? DEFAULT_ORGANISATION_POLICY.externalMail; +} + +/** Either core switch is on: the label or the recipient warning. */ +export function coreExternalWarningsOn(policy: ExternalMailPolicy = currentExternalMailPolicy()): boolean { + return policy.senderLabel || policy.recipientWarning; +} + +/** The internal domains for the active account. */ +export function currentInternalDomains(policy: ExternalMailPolicy = currentExternalMailPolicy()): string[] { + const identities = useAuthStore.getState().identities ?? []; + return internalDomains(policy.domains, identities.map((identity) => identity.email)); +} + +/** Lowercased addresses of every loaded contact (all address books). */ +export function contactAddresses(): Set { + const out = new Set(); + for (const contact of useContactStore.getState().contacts) { + for (const entry of Object.values(contact.emails ?? {})) { + const address = entry?.address?.trim().toLowerCase(); + if (address) out.add(address); + } + } + return out; +} diff --git a/locales/de/common.json b/locales/de/common.json index ffdd16235..73f54a823 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -659,6 +659,10 @@ "plugin_panel_hide": "Plugin-Bereich ausblenden", "copy": "Kopieren" }, + "external_mail": { + "sender_label": "Extern", + "sender_tooltip": "Von außerhalb Ihrer Organisation: {domain}" + }, "email_composer": { "select_from_directory": "Aus Verzeichnis auswählen", "minimize": "Minimieren", diff --git a/locales/en/common.json b/locales/en/common.json index 95ec87fa3..60734aa9d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -659,6 +659,10 @@ "plugin_panel_show": "Show plugin panel", "plugin_panel_hide": "Hide plugin panel" }, + "external_mail": { + "sender_label": "External", + "sender_tooltip": "From outside your organisation: {domain}" + }, "email_composer": { "select_from_directory": "Select from directory", "minimize": "Minimize", From 79c697a93553c9da66868a1d5d302a1ccf3d8e13 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:55:15 +0100 Subject: [PATCH 011/137] feat: let an automatic sign-out save open and minimised drafts first --- .../composer-sign-out-flush.test.tsx | 265 ++++++++++++++++++ components/email/email-composer.tsx | 23 +- components/mail/mail-app.tsx | 17 ++ lib/__tests__/compose-flush-registry.test.ts | 69 +++++ lib/compose-flush-registry.ts | 82 ++++++ 5 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 components/email/__tests__/composer-sign-out-flush.test.tsx create mode 100644 lib/__tests__/compose-flush-registry.test.ts create mode 100644 lib/compose-flush-registry.ts diff --git a/components/email/__tests__/composer-sign-out-flush.test.tsx b/components/email/__tests__/composer-sign-out-flush.test.tsx new file mode 100644 index 000000000..50c4a1e17 --- /dev/null +++ b/components/email/__tests__/composer-sign-out-flush.test.tsx @@ -0,0 +1,265 @@ +import { render, screen, fireEvent, act } from '@testing-library/react'; +import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { EmailComposer, type ComposerDraftData } from '../email-composer'; +import { useAuthStore } from '@/stores/auth-store'; +import { flushComposers, resetComposeFlushRegistry } from '@/lib/compose-flush-registry'; + +// ─── Heavy component mocks (mirrors reply-addressing.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) => Object.assign(state, p); + return { useAuthStore: hook }; +}); + +vi.mock('@/stores/identity-store', () => { + const state = { + identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }], + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => 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) => 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) => Object.assign(state, p); + return { useEmailStore: hook }; +}); + +const updateSetting = vi.fn(); + +vi.mock('@/stores/settings-store', () => { + const state = { + timeFormat: '24h', + plainTextMode: false, + subAddressDelimiter: '+', + autoSelectReplyIdentity: true, + attachmentReminderEnabled: false, + attachmentReminderKeywords: [], + emptySubjectWarningEnabled: true, + sendDelaySeconds: 0, + signaturePosition: 'above_quote', + signatureSeparatorEnabled: false, + requestReadReceiptDefault: false, + addTrustedSender: () => {}, + trustedSendersAddressBook: null, + updateSetting: (...args: unknown[]) => updateSetting(...args), + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => 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) => 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, + sanitizeEmailHtml: (v: string) => v, + parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), +})); + +vi.mock('@/lib/email-threading', () => ({ + computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), +})); +vi.mock('@/lib/signature-utils', () => ({ + appendPlainTextSignature: (body: string) => body, + getPlainTextSignature: () => '', +})); +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 ──────────────────────────────────────────────────────────────────── + +/** + * Before an automatic sign-out (organisation idle timeout or maximum session + * length) every composer saves what the server doesn't have yet, through the + * compose flush registry. + */ + +const DRAFT: ComposerDraftData = { + to: 'bob@example.com', + cc: '', + bcc: '', + subject: 'Half-written', + body: '

Still typing

', + showCc: false, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose', + draftId: null, +}; + +let createDraft: ReturnType; + +beforeEach(() => { + resetComposeFlushRegistry(); + createDraft = vi.fn().mockResolvedValue('draft-1'); + useAuthStore.setState({ + client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never, + }); +}); + +afterEach(() => { + useAuthStore.setState({ client: null }); +}); + +describe('composer flush before an automatic sign-out', () => { + it('saves edits the autosave has not stored yet', async () => { + render(); + fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } }); + + let result: Awaited> | undefined; + await act(async () => { result = await flushComposers(5000); }); + + expect(result).toEqual({ total: 1, settled: 1, timedOut: false }); + expect(createDraft).toHaveBeenCalledTimes(1); + expect(createDraft.mock.calls[0][1]).toBe('Half-written more'); + }); + + it('leaves a composer without changes alone', async () => { + render(); + + await act(async () => { await flushComposers(5000); }); + + expect(createDraft).not.toHaveBeenCalled(); + }); + + it('saves a minimised composer opened again with its stashed edits', async () => { + render(); + + await act(async () => { await flushComposers(5000); }); + + expect(createDraft).toHaveBeenCalledTimes(1); + expect(createDraft.mock.calls[0][1]).toBe('Half-written'); + }); + + it('marks the state it hands to the host on unmount as unsaved', () => { + const onSaveState = vi.fn(); + const view = render(); + fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } }); + view.unmount(); + + expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: true })); + }); + + it('unregisters when it unmounts', async () => { + const view = render(); + view.unmount(); + + await expect(flushComposers(1000)).resolves.toMatchObject({ total: 0 }); + }); +}); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 01ac2feb7..11f303fd3 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -133,6 +133,7 @@ import type { Editor } from "@tiptap/react"; import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text"; import { fileStorage } from "@/lib/plugin-storage"; import { usePolicyStore } from "@/stores/policy-store"; +import { registerComposeFlush } from "@/lib/compose-flush-registry"; import { toUnicodeEmail } from "@/lib/idn"; /** @@ -231,6 +232,12 @@ export interface ComposerDraftData { references?: string[]; /** Priority of a re-opened draft, read back from its headers (#970). */ priority?: MessagePriority; + /** + * Handed to the host when the composer went away with edits the server may + * not have: the composer opened again with it counts them as unsaved, so + * an automatic sign-out saves them (lib/compose-flush-registry.ts). + */ + unsaved?: boolean; } interface EmailComposerProps { @@ -1574,7 +1581,8 @@ export function EmailComposer({ // Bumped on every edit and compared with the edit the last successful save // covered. Closing a message autosave already stored asked about "unsaved // changes" that were not there. - const changeVersionRef = useRef(0); + // Stashed edits handed back by the host start out unsaved. + const changeVersionRef = useRef(initialData?.unsaved ? 1 : 0); const savedVersionRef = useRef(0); const firstChangeRunRef = useRef(true); useEffect(() => { @@ -1648,6 +1656,7 @@ export function EmailComposer({ mode, replyTo, priority: priorityRef.current, + unsaved: true, }); } }; @@ -2699,6 +2708,18 @@ export function EmailComposer({ // Keep saveDraftRef pointing to latest saveDraft saveDraftRef.current = saveDraft; + // An automatic sign-out saves what the server doesn't have yet, with the + // same save the close and send paths use. + useEffect(() => registerComposeFlush(async () => { + const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current; + if (!unsaved) return; + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + saveTimeoutRef.current = null; + } + await saveDraftRef.current(); + }), []); + // The draft as plugins see it: onDraftChange, the frames above and below // the body, and clicks on plugin entries in the Send menu. const draftSnapshot = () => ({ diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx index e5bfaf3c9..49581ae72 100644 --- a/components/mail/mail-app.tsx +++ b/components/mail/mail-app.tsx @@ -159,6 +159,7 @@ import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-lin import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect"; import type { QuoteHeader } from "@/lib/plugin-types"; import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments"; +import { registerComposeFlush, waitForComposeFlush } from "@/lib/compose-flush-registry"; export interface MailAppProps { /** @@ -198,6 +199,9 @@ function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise = {}; @@ -2266,6 +2270,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) { setPendingDraft(null); }, []); + // A minimised composer's edits live only in pendingDraft. Before an + // automatic sign-out it is opened again and saves them itself. + const minimisedUnsaved = !showComposer && pendingDraft?.unsaved === true; + useEffect(() => { + if (!minimisedUnsaved) return; + return registerComposeFlush(async () => { + const reopened = waitForComposeFlush(MINIMISED_COMPOSER_REOPEN_MS); + setShowComposer(true); + const flush = await reopened; + await flush?.(); + }); + }, [minimisedUnsaved]); + const handleReply = async (draftText?: string) => { if (selectedEmail) { const formerSelected = { ...selectedEmail }; diff --git a/lib/__tests__/compose-flush-registry.test.ts b/lib/__tests__/compose-flush-registry.test.ts new file mode 100644 index 000000000..3858bb358 --- /dev/null +++ b/lib/__tests__/compose-flush-registry.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + flushComposers, + registerComposeFlush, + resetComposeFlushRegistry, + waitForComposeFlush, +} from '../compose-flush-registry'; + +describe('compose flush registry', () => { + beforeEach(() => { + resetComposeFlushRegistry(); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('runs every registered flush and waits for them', async () => { + const order: string[] = []; + registerComposeFlush(async () => { + await new Promise((r) => setTimeout(r, 100)); + order.push('a'); + }); + registerComposeFlush(async () => { order.push('b'); }); + + const done = flushComposers(5000); + await vi.advanceTimersByTimeAsync(100); + await expect(done).resolves.toEqual({ total: 2, settled: 2, timedOut: false }); + expect(order.sort()).toEqual(['a', 'b']); + }); + + it('stops waiting for a hanging flush at the timeout', async () => { + const quick = vi.fn(async () => {}); + registerComposeFlush(() => new Promise(() => {})); + registerComposeFlush(quick); + + let result: Awaited> | null = null; + void flushComposers(3000).then((r) => { result = r; }); + await vi.advanceTimersByTimeAsync(2999); + expect(result).toBeNull(); + await vi.advanceTimersByTimeAsync(1); + expect(result).toEqual({ total: 2, settled: 1, timedOut: true }); + expect(quick).toHaveBeenCalledTimes(1); + }); + + it('goes on when a flush fails', async () => { + registerComposeFlush(async () => { throw new Error('save refused'); }); + await expect(flushComposers(1000)).resolves.toEqual({ total: 1, settled: 1, timedOut: false }); + }); + + it('forgets a composer that unregistered', async () => { + const flush = vi.fn(async () => {}); + const unregister = registerComposeFlush(flush); + unregister(); + await expect(flushComposers(1000)).resolves.toEqual({ total: 0, settled: 0, timedOut: false }); + expect(flush).not.toHaveBeenCalled(); + }); + + it('hands the next registration to a waiting host, or null after the timeout', async () => { + const waiting = waitForComposeFlush(500); + const flush = vi.fn(async () => {}); + registerComposeFlush(flush); + await expect(waiting).resolves.toBe(flush); + + const nobody = waitForComposeFlush(500); + await vi.advanceTimersByTimeAsync(500); + await expect(nobody).resolves.toBeNull(); + }); +}); diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts new file mode 100644 index 000000000..8af821e9e --- /dev/null +++ b/lib/compose-flush-registry.ts @@ -0,0 +1,82 @@ +/** + * Composers that hold text the server may not have yet. An automatic + * sign-out (the organisation's idle timeout or maximum session length) saves + * them as drafts before the credentials go: each open composer registers a + * `flush` that runs its own draft save, and the host of a minimised composer + * (its state lives only in the host's memory) registers one that reopens it + * so the composer can save itself. + * + * A flush that hangs must not keep the user signed in, so flushComposers + * waits at most `timeoutMs` for all of them together. + */ + +export type ComposeFlush = () => Promise; + +let nextId = 1; +const flushes = new Map(); +const waiters = new Set<(flush: ComposeFlush) => void>(); + +/** Register a composer's flush; returns the unregister function. */ +export function registerComposeFlush(flush: ComposeFlush): () => void { + const id = nextId++; + flushes.set(id, flush); + for (const waiter of [...waiters]) waiter(flush); + return () => { + flushes.delete(id); + }; +} + +/** + * The flush of the next composer to register, or null after `timeoutMs`. + * A minimised composer's host reopens it and waits for it here. + */ +export function waitForComposeFlush(timeoutMs: number): Promise { + return new Promise((resolve) => { + const waiter = (flush: ComposeFlush) => { + clearTimeout(timer); + waiters.delete(waiter); + resolve(flush); + }; + const timer = setTimeout(() => { + waiters.delete(waiter); + resolve(null); + }, timeoutMs); + waiters.add(waiter); + }); +} + +export interface FlushResult { + /** Flushes that were registered when the sign-out started. */ + total: number; + /** Flushes that finished (or failed) in time. */ + settled: number; + timedOut: boolean; +} + +/** Run every registered flush and wait for them, but no longer than `timeoutMs`. */ +export async function flushComposers(timeoutMs: number): Promise { + const pending = [...flushes.values()]; + if (pending.length === 0) return { total: 0, settled: 0, timedOut: false }; + let settled = 0; + const all = Promise.all(pending.map(async (flush) => { + try { + await flush(); + } catch { + // A failed save is the composer's to report; the sign-out goes on. + } + settled += 1; + })); + let timer: ReturnType | undefined; + const timedOut = await Promise.race([ + all.then(() => false), + new Promise((resolve) => { timer = setTimeout(() => resolve(true), timeoutMs); }), + ]); + clearTimeout(timer); + return { total: pending.length, settled, timedOut }; +} + +/** Test helper: forget every registration. */ +export function resetComposeFlushRegistry(): void { + flushes.clear(); + waiters.clear(); +} From 2855ca00104847d96efec089083d2d7dda201d35 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:59:13 +0100 Subject: [PATCH 012/137] feat: confirm before sending to recipients outside the organisation --- app/(main)/[locale]/layout.tsx | 2 + .../composer-external-recipients.test.tsx | 278 ++++++++++++++++++ .../email/external-recipient-confirm.tsx | 83 ++++++ .../__tests__/external-recipients.test.ts | 112 +++++++ lib/outgoing/external-recipients.ts | 74 +++++ locales/de/common.json | 9 +- locales/en/common.json | 9 +- stores/external-recipient-confirm-store.ts | 31 ++ 8 files changed, 596 insertions(+), 2 deletions(-) create mode 100644 components/email/__tests__/composer-external-recipients.test.tsx create mode 100644 components/email/external-recipient-confirm.tsx create mode 100644 lib/outgoing/__tests__/external-recipients.test.ts create mode 100644 lib/outgoing/external-recipients.ts create mode 100644 stores/external-recipient-confirm-store.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 76f9396ad..eab9e2482 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -10,6 +10,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; +import { ExternalRecipientConfirmHost } from "@/components/email/external-recipient-confirm"; import { CommandPaletteHost } from "@/components/command-palette/command-palette"; import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; import { QuickRuleHost } from "@/components/filters/quick-rule-host"; @@ -63,6 +64,7 @@ export default async function LocaleLayout({ {children} + diff --git a/components/email/__tests__/composer-external-recipients.test.tsx b/components/email/__tests__/composer-external-recipients.test.tsx new file mode 100644 index 000000000..c3a5ea328 --- /dev/null +++ b/components/email/__tests__/composer-external-recipients.test.tsx @@ -0,0 +1,278 @@ +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'; + +// ─── 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) => 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>, + defaultIdentityId: 'id-me', + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => 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) => 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) => 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) => Object.assign(state, p); + return { useSettingsStore: hook }; +}); + +vi.mock('@/stores/contact-store', () => { + const state = { + contacts: [], + getAutocomplete: async () => [], + addToTrustedSendersBook: async () => {}, + }; + const hook = (sel?: (s: typeof state) => unknown) => + typeof sel === 'function' ? sel(state) : state; + hook.getState = () => state; + hook.setState = (p: Partial) => 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) => 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: '

Hi

', + showCc: true, + showBcc: false, + selectedIdentityId: 'id-me', + subAddressTag: '', + mode: 'compose' as const, + draftId: null, +}; + +function setExternalMail(patch: Partial) { + 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( + <> + + + , + ); +} + +describe('composer: confirm before sending outside the organisation', () => { + beforeEach(() => { + resetSendPipeline(); + useExternalRecipientConfirmStore.setState({ pending: null }); + setExternalMail({ recipientWarning: true }); + }); + 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('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(); + }); +}); diff --git a/components/email/external-recipient-confirm.tsx b/components/email/external-recipient-confirm.tsx new file mode 100644 index 000000000..ee6e0b85a --- /dev/null +++ b/components/email/external-recipient-confirm.tsx @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useId } 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().answer(false), []); + + const dialogRef = useFocusTrap({ isActive: !!pending, onEscape: () => answer(false), restoreFocus: true }); + + if (!pending) return null; + const { recipients } = pending; + const field = { to: t('field_to'), cc: t('field_cc'), bcc: t('field_bcc') }; + + return ( +
{ + if (e.target === e.currentTarget) answer(false); + }} + > +
+
+
+
+
+
+

{t('confirm_title')}

+

+ {t('confirm_message', { count: recipients.length })} +

+
+
+
    + {recipients.map((recipient) => ( +
  • + {field[recipient.field]} + {recipient.address} +
  • + ))} +
+
+
+ {/* Back comes first, so the focus trap puts the focus on it: sending outside takes a deliberate click. */} + + +
+
+
+ ); +} diff --git a/lib/outgoing/__tests__/external-recipients.test.ts b/lib/outgoing/__tests__/external-recipients.test.ts new file mode 100644 index 000000000..c9982315f --- /dev/null +++ b/lib/outgoing/__tests__/external-recipients.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { externalRecipientCheck, registerExternalRecipientCheck } from '../external-recipients'; +import { resetSendPipeline, type OutgoingContext } from '../send-pipeline'; +import { prepareQuickReply } from '../quick-reply'; +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 type { ContactCard, Identity } from '@/lib/jmap/types'; + +vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } })); + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +const context = (to: string[], cc: string[] = [], bcc: string[] = []): OutgoingContext => ({ + surface: 'composer', to, cc, bcc, fromEmail: 'org-alice@example.org', identityId: 'i1', mode: 'compose', plainText: false, +}); + +beforeEach(() => { + setExternalMail({ recipientWarning: true }); + useAuthStore.setState({ client: null, identities: [{ id: 'i1', name: 'Org Alice', email: 'org-alice@example.org' } as unknown as Identity] }); + useContactStore.setState({ contacts: [], contactsLoaded: true }); + useExternalRecipientConfirmStore.setState({ pending: null }); +}); +afterEach(() => resetSendPipeline()); + +describe('externalRecipientCheck', () => { + it('does not ask while the warning is off', async () => { + setExternalMail({ recipientWarning: false, senderLabel: true }); + const ask = vi.fn(async () => false); + expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(true); + expect(ask).not.toHaveBeenCalled(); + }); + + it('does not ask when every recipient is internal, subdomains included', async () => { + const ask = vi.fn(async () => false); + expect(await externalRecipientCheck(context(['org-bob@example.org'], ['x@eu.example.org']), ask)).toBe(true); + expect(ask).not.toHaveBeenCalled(); + }); + + it('asks about the external recipients only and returns the answer', async () => { + const ask = vi.fn(async () => false); + const recipients = context(['org-bob@example.org', 'org-dave@org-two.test'], [], ['eve@other.net']); + expect(await externalRecipientCheck(recipients, ask)).toBe(false); + expect(ask).toHaveBeenCalledWith([ + { address: 'org-dave@org-two.test', domain: 'org-two.test', field: 'to' }, + { address: 'eve@other.net', domain: 'other.net', field: 'bcc' }, + ]); + ask.mockResolvedValueOnce(true); + expect(await externalRecipientCheck(recipients, ask)).toBe(true); + }); + + it('counts the admin domains as internal', async () => { + setExternalMail({ recipientWarning: true, domains: ['org-two.test'] }); + const ask = vi.fn(async () => false); + expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(true); + expect(ask).not.toHaveBeenCalled(); + }); + + it('trusts contacts only while trustContacts is on', async () => { + useContactStore.setState({ + contacts: [{ id: 'c1', emails: { e: { address: 'Org-Dave@org-two.test' } } }] as unknown as ContactCard[], + }); + const ask = vi.fn(async () => false); + expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(true); + expect(ask).not.toHaveBeenCalled(); + + setExternalMail({ recipientWarning: true, trustContacts: false }); + expect(await externalRecipientCheck(context(['org-dave@org-two.test']), ask)).toBe(false); + expect(ask).toHaveBeenCalledTimes(1); + }); +}); + +describe('quick reply goes through the same confirm', () => { + const input = { body: 'Thanks', signatureIdentity: null, separator: true, to: ['org-dave@org-two.test'], fromEmail: 'org-alice@example.org', identityId: 'i1' }; + + async function answerWhenAsked(send: boolean) { + await vi.waitFor(() => expect(useExternalRecipientConfirmStore.getState().pending).not.toBeNull()); + expect(useExternalRecipientConfirmStore.getState().pending?.recipients.map((r) => r.address)).toEqual(['org-dave@org-two.test']); + useExternalRecipientConfirmStore.getState().answer(send); + } + + it('sends nothing on Back', async () => { + registerExternalRecipientCheck(); + const result = prepareQuickReply(input); + await answerWhenAsked(false); + expect(await result).toBeNull(); + }); + + it('returns the bodies on Send anyway', async () => { + registerExternalRecipientCheck(); + const result = prepareQuickReply(input); + await answerWhenAsked(true); + expect((await result)?.text).toContain('Thanks'); + }); + + it('does not ask for a reply to an internal sender', async () => { + registerExternalRecipientCheck(); + const result = await prepareQuickReply({ ...input, to: ['org-bob@example.org'] }); + expect(result?.text).toContain('Thanks'); + expect(useExternalRecipientConfirmStore.getState().pending).toBeNull(); + }); +}); diff --git a/lib/outgoing/external-recipients.ts b/lib/outgoing/external-recipients.ts new file mode 100644 index 000000000..e7f2b4cb4 --- /dev/null +++ b/lib/outgoing/external-recipients.ts @@ -0,0 +1,74 @@ +/** + * The pre-send check behind "Send outside your organisation?": when the + * admin turned the recipient warning on, recipients outside the + * organisation (and, with trustContacts, not in the user's contacts) are + * listed in one confirm. Runs for every surface that sends through the + * pipeline: the composer and quick reply. + */ + +import { + addressDomain, + contactAddresses, + currentExternalMailPolicy, + currentInternalDomains, + externalRecipients, + type ExternalRecipient, +} from '@/lib/external-mail'; +import { useAuthStore } from '@/stores/auth-store'; +import { useContactStore } from '@/stores/contact-store'; +import { useExternalRecipientConfirmStore } from '@/stores/external-recipient-confirm-store'; +import { registerPreSendCheck, type OutgoingContext } from './send-pipeline'; +import { debug } from '@/lib/debug'; + +export const EXTERNAL_RECIPIENT_CHECK_ID = 'external-recipients'; +/** After the composer's own checks (attachment, subject, placeholders), before anything later. */ +const ORDER = 40; +/** Longest the send waits for the contacts to load the first time. */ +const CONTACTS_WAIT_MS = 3000; + +async function ensureContactsLoaded(): Promise { + const contacts = useContactStore.getState(); + if (contacts.contactsLoaded) return; + const client = useAuthStore.getState().client; + if (!client) return; + try { + await Promise.race([ + contacts.fetchContacts(client), + new Promise((resolve) => setTimeout(resolve, CONTACTS_WAIT_MS)), + ]); + } catch (error) { + debug.error('Could not load contacts for the external-recipient check:', error); + } +} + +/** The recipients the user is asked about; empty when the check doesn't apply. */ +export async function recipientsToConfirm(context: OutgoingContext): Promise { + const policy = currentExternalMailPolicy(); + if (!policy.recipientWarning) return []; + // The From address is one of the user's own, even an override typed in the composer. + const fromDomain = addressDomain(context.fromEmail); + const internal = currentInternalDomains(policy); + if (fromDomain && !internal.includes(fromDomain)) internal.push(fromDomain); + let trusted: ((address: string) => boolean) | undefined; + if (policy.trustContacts) { + await ensureContactsLoaded(); + const known = contactAddresses(); + trusted = (address) => known.has(address); + } + return externalRecipients(context, internal, trusted); +} + +/** True to send: nobody external, or the user chose "Send anyway". */ +export async function externalRecipientCheck( + context: OutgoingContext, + ask: (recipients: ExternalRecipient[]) => Promise = (recipients) => useExternalRecipientConfirmStore.getState().ask(recipients), +): Promise { + const recipients = await recipientsToConfirm(context); + if (recipients.length === 0) return true; + return ask(recipients); +} + +/** Called by the dialog host once it is mounted; returns the unregister function. */ +export function registerExternalRecipientCheck(): () => void { + return registerPreSendCheck(EXTERNAL_RECIPIENT_CHECK_ID, ORDER, (context) => externalRecipientCheck(context)); +} diff --git a/locales/de/common.json b/locales/de/common.json index 73f54a823..729bb80ca 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -661,7 +661,14 @@ }, "external_mail": { "sender_label": "Extern", - "sender_tooltip": "Von außerhalb Ihrer Organisation: {domain}" + "sender_tooltip": "Von außerhalb Ihrer Organisation: {domain}", + "confirm_title": "Außerhalb Ihrer Organisation senden?", + "confirm_message": "{count, plural, one {Dieser Empfänger ist} other {Diese # Empfänger sind}} außerhalb Ihrer Organisation. Senden Sie nur, wenn das so gewollt ist.", + "field_to": "An", + "field_cc": "Cc", + "field_bcc": "Bcc", + "send_anyway": "Trotzdem senden", + "back": "Zurück" }, "email_composer": { "select_from_directory": "Aus Verzeichnis auswählen", diff --git a/locales/en/common.json b/locales/en/common.json index 60734aa9d..b8c4ecb2c 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -661,7 +661,14 @@ }, "external_mail": { "sender_label": "External", - "sender_tooltip": "From outside your organisation: {domain}" + "sender_tooltip": "From outside your organisation: {domain}", + "confirm_title": "Send outside your organisation?", + "confirm_message": "{count, plural, one {This recipient is} other {These # recipients are}} outside your organisation. Send only if you mean to.", + "field_to": "To", + "field_cc": "Cc", + "field_bcc": "Bcc", + "send_anyway": "Send anyway", + "back": "Back" }, "email_composer": { "select_from_directory": "Select from directory", diff --git a/stores/external-recipient-confirm-store.ts b/stores/external-recipient-confirm-store.ts new file mode 100644 index 000000000..45e3304e5 --- /dev/null +++ b/stores/external-recipient-confirm-store.ts @@ -0,0 +1,31 @@ +import { create } from 'zustand'; +import type { ExternalRecipient } from '@/lib/external-mail'; + +/** + * The one open "Send outside your organisation?" question. The pre-send + * check asks, the dialog (components/email/external-recipient-confirm.tsx) + * shows it and answers. Asking again while a question is open answers the + * old one with "Back", so no send waits forever. + */ +interface ExternalRecipientConfirmState { + pending: { recipients: ExternalRecipient[]; resolve: (send: boolean) => void } | null; + /** Resolves true for "Send anyway", false for "Back". */ + ask: (recipients: ExternalRecipient[]) => Promise; + answer: (send: boolean) => void; +} + +export const useExternalRecipientConfirmStore = create()((set, get) => ({ + pending: null, + ask: (recipients) => { + get().pending?.resolve(false); + return new Promise((resolve) => { + set({ pending: { recipients, resolve } }); + }); + }, + answer: (send) => { + const pending = get().pending; + if (!pending) return; + set({ pending: null }); + pending.resolve(send); + }, +})); From ab878ebf4fbc84de1ddf46e8c63a5ecee2bf3015 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:10:10 +0100 Subject: [PATCH 013/137] feat: tell plugins when Bulwark's own external mail warnings are on and skip the retired plugin's --- .../__tests__/plugin-slot-handover.test.tsx | 47 ++++++ components/plugins/plugin-slot.tsx | 12 +- .../plugin-external-mail-handover.test.ts | 105 +++++++++++++ lib/__tests__/plugin-host-features.test.ts | 143 ++++++++++++++++++ lib/plugin-sandbox/host-bridge.ts | 23 ++- lib/plugin-sandbox/host-features.ts | 65 ++++++++ lib/plugin-sandbox/loader.ts | 8 +- lib/plugin-sandbox/protocol.ts | 10 +- lib/plugin-sandbox/runtime.tsx | 34 ++++- lib/plugin-types.ts | 14 ++ 10 files changed, 455 insertions(+), 6 deletions(-) create mode 100644 components/plugins/__tests__/plugin-slot-handover.test.tsx create mode 100644 lib/__tests__/plugin-external-mail-handover.test.ts create mode 100644 lib/__tests__/plugin-host-features.test.ts create mode 100644 lib/plugin-sandbox/host-features.ts diff --git a/components/plugins/__tests__/plugin-slot-handover.test.tsx b/components/plugins/__tests__/plugin-slot-handover.test.tsx new file mode 100644 index 000000000..24cb78e67 --- /dev/null +++ b/components/plugins/__tests__/plugin-slot-handover.test.tsx @@ -0,0 +1,47 @@ +import { render, act, cleanup } from '@testing-library/react'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { clearSlotOffers, offerSlots } from './fake-slot-offer'; + +vi.mock('@/components/plugins/plugin-iframe-slot', () => import('@/components/plugins/__tests__/fake-slot-offer')); + +const { PluginSlot } = await import('../plugin-slot'); + +// The retired external-mail-warning plugin's banner stays away while the +// organisation's own External label or recipient warning is on, so the +// reader doesn't show both; other plugins' banners stay. + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +const frames = (container: HTMLElement) => + [...container.querySelectorAll('[data-testid="plugin-frame-email-banner"]')].map((el) => el.getAttribute('data-plugin-id')); + +beforeEach(() => { + setExternalMail({}); + offerSlots('external-mail-warning', ['email-banner']); + offerSlots('spam-score', ['email-banner']); +}); +afterEach(() => { + cleanup(); + clearSlotOffers(); +}); + +describe('email-banner slot while core external warnings are on', () => { + it('shows the plugin banner while they are off and hides it as soon as one is on', () => { + const { container } = render(); + 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']); + }); +}); diff --git a/components/plugins/plugin-slot.tsx b/components/plugins/plugin-slot.tsx index ad7e96dbd..90e87d560 100644 --- a/components/plugins/plugin-slot.tsx +++ b/components/plugins/plugin-slot.tsx @@ -1,10 +1,12 @@ 'use client'; -import React, { useSyncExternalStore } from 'react'; +import React, { useMemo, useSyncExternalStore } from 'react'; import type { SlotName } from '@/lib/plugin-types'; import { offersForSlot, subscribe } from '@/lib/plugin-sandbox/registry'; import { PluginIframeSlot } from './plugin-iframe-slot'; import { isInlineSlot } from '@/lib/plugin-sandbox/slot-layout'; +import { externalWarningsOn, isSlotHandedOver } from '@/lib/plugin-sandbox/host-features'; +import { usePolicyStore } from '@/stores/policy-store'; import { cn } from '@/lib/utils'; interface PluginSlotProps { @@ -15,7 +17,13 @@ interface PluginSlotProps { export function PluginSlot({ name, className, extraProps }: PluginSlotProps) { const getSnapshot = () => offersForSlot(name); - const offers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + const allOffers = useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + // A retired plugin's banner stays away while Bulwark shows its own (host-features.ts). + const externalMailWarnings = usePolicyStore((s) => externalWarningsOn(s.policy.organisation?.externalMail)); + const offers = useMemo( + () => allOffers.filter((offer) => !isSlotHandedOver(offer.pluginId, name, { externalMailWarnings })), + [allOffers, name, externalMailWarnings], + ); if (offers.length === 0) return null; diff --git a/lib/__tests__/plugin-external-mail-handover.test.ts b/lib/__tests__/plugin-external-mail-handover.test.ts new file mode 100644 index 000000000..37c50dea9 --- /dev/null +++ b/lib/__tests__/plugin-external-mail-handover.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createHash } from 'node:crypto'; +import type { InstalledPlugin } from '../plugin-types'; + +// Copies of the external-mail-warning plugin older than the release that +// steps back on its own keep their send check. While the organisation's +// own warnings are on, the host skips that plugin's hooks by id, so the +// user isn't asked twice; other plugins keep theirs. + +const mocks = vi.hoisted(() => ({ + hooks: [] as string[], + invokeHook: null as null | ReturnType, +})); + +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) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +const OUTGOING = { to: ['dave@org-two.test'], cc: [], bcc: [], subject: 'Hi' }; +let loaded: string | null = null; + +beforeEach(() => { + setSandboxStoreAccessor({ setPluginStatus: () => {} }); + mocks.hooks = ['onBeforeEmailSend', 'onNewEmailReceived']; + setExternalMail({}); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + vi.spyOn(console, 'info').mockImplementation(() => {}); +}); +afterEach(() => { + if (loaded) unloadSandboxedPlugin(loaded); + loaded = null; + vi.restoreAllMocks(); +}); + +describe('the retired external-mail-warning plugin', () => { + it('asks as before while the core warnings are off', async () => { + loaded = 'external-mail-warning'; + await loadSandboxedPlugin(plugin(loaded)); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed'); + expect(mocks.invokeHook).toHaveBeenCalledWith('onBeforeEmailSend', [OUTGOING]); + }); + + it('is skipped while the core warnings are on, and asked again once they are off', async () => { + loaded = 'external-mail-warning'; + await loadSandboxedPlugin(plugin(loaded)); + setExternalMail({ senderLabel: true }); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('allowed'); + await emailHooks.onNewEmailReceived.emit({ from: [{ email: 'dave@org-two.test' }] } as never); + expect(mocks.invokeHook).not.toHaveBeenCalled(); + + setExternalMail({}); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed'); + }); + + it('leaves the send checks of other plugins alone', async () => { + loaded = 'reply-all-guardrail'; + await loadSandboxedPlugin(plugin(loaded)); + setExternalMail({ recipientWarning: true }); + expect(await emailHooks.onBeforeEmailSend.interceptOutcome(OUTGOING as never)).toBe('vetoed'); + }); +}); diff --git a/lib/__tests__/plugin-host-features.test.ts b/lib/__tests__/plugin-host-features.test.ts new file mode 100644 index 000000000..ea665ba73 --- /dev/null +++ b/lib/__tests__/plugin-host-features.test.ts @@ -0,0 +1,143 @@ +/** + * api.host.features tells plugins which of Bulwark's own features replace + * them (externalMailWarnings: the organisation's External label or + * recipient warning). The host sends it with init and again on every + * change; the runtime keeps one object up to date. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import React from 'react'; +import { render, cleanup, waitFor } from '@testing-library/react'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { usePolicyStore } from '@/stores/policy-store'; +import { + EXTERNAL_MAIL_WARNING_PLUGIN_ID, + isHookHandedOver, + isSlotHandedOver, + snapshotHostFeatures, +} from '@/lib/plugin-sandbox/host-features'; + +function setExternalMail(patch: Partial) { + usePolicyStore.setState({ + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, externalMail: { ...DEFAULT_ORGANISATION_POLICY.externalMail, ...patch } }, + }, + }); +} + +beforeEach(() => setExternalMail({})); + +describe('snapshotHostFeatures', () => { + it('says externalMailWarnings while either switch is on', () => { + expect(snapshotHostFeatures()).toEqual({ externalMailWarnings: false }); + setExternalMail({ senderLabel: true }); + expect(snapshotHostFeatures()).toEqual({ externalMailWarnings: true }); + setExternalMail({ recipientWarning: true }); + expect(snapshotHostFeatures()).toEqual({ externalMailWarnings: true }); + }); +}); + +describe('hand-over of the external-mail-warning plugin', () => { + it('skips its send check, toast and banner only while core warnings are on', () => { + const id = EXTERNAL_MAIL_WARNING_PLUGIN_ID; + expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(false); + expect(isSlotHandedOver(id, 'email-banner')).toBe(false); + setExternalMail({ recipientWarning: true }); + expect(isHookHandedOver(id, 'onBeforeEmailSend')).toBe(true); + expect(isHookHandedOver(id, 'onNewEmailReceived')).toBe(true); + expect(isSlotHandedOver(id, 'email-banner')).toBe(true); + // Its other hooks, and every other plugin, keep running. + expect(isHookHandedOver(id, 'onIdentitiesLoaded')).toBe(false); + expect(isHookHandedOver('reply-all-guardrail', 'onBeforeEmailSend')).toBe(false); + expect(isSlotHandedOver('spam-score', 'email-banner')).toBe(false); + }); +}); + +// ─── Host bridge: init payload and pushes ──────────────────── + +describe('host bridge', () => { + afterEach(() => { document.body.innerHTML = ''; }); + + it('puts the features into the init payload and pushes changes to running frames', async () => { + const { createBackgroundInstance } = await import('@/lib/plugin-sandbox/host-bridge'); + setExternalMail({ senderLabel: true }); + const plugin = { id: 'p', name: 'P', version: '1.0.0', permissions: [], settings: {}, settingsSchema: {} } as never; + const instance = createBackgroundInstance({ plugin, tier: 'privileged', code: '', locale: 'en' }); + const pending = (instance as unknown as { pendingInit: { hostFeatures?: unknown } | null }).pendingInit; + expect(pending?.hostFeatures).toEqual({ externalMailWarnings: true }); + + // Before init has gone out a change lands in the payload ... + setExternalMail({}); + expect(pending?.hostFeatures).toEqual({ externalMailWarnings: false }); + + // ... after it, as a host-change message. + const sent: unknown[] = []; + (instance as unknown as { pendingInit: unknown }).pendingInit = null; + (instance as unknown as { send: (m: unknown) => void }).send = (m) => sent.push(m); + setExternalMail({ recipientWarning: true }); + expect(sent).toEqual([{ type: 'host-change', features: { externalMailWarnings: true } }]); + instance.destroy(); + }, 30_000); +}); + +// ─── Sandbox runtime: api.host.features ────────────────────── + +const HOST_ORIGIN = window.location.origin; + +function post(source: unknown, origin: string, data: unknown): void { + const ev = new MessageEvent('message', { data, origin }); + Object.defineProperty(ev, 'source', { value: source }); + window.dispatchEvent(ev); +} + +describe('api.host in the sandbox runtime', () => { + let hostWindow: { postMessage: ReturnType }; + const realParent = Object.getOwnPropertyDescriptor(window, 'parent'); + const g = globalThis as { + __pluginApi?: { host: { features: { externalMailWarnings: boolean }; onFeaturesChange(fn: (f: unknown) => void): () => void } }; + __seenAtActivate?: boolean; + }; + + beforeEach(() => { + vi.resetModules(); + hostWindow = { postMessage: vi.fn() }; + Object.defineProperty(window, 'parent', { configurable: true, get: () => hostWindow as unknown as Window }); + vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} }); + }); + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + delete g.__pluginApi; + delete g.__seenAtActivate; + if (realParent) Object.defineProperty(window, 'parent', realParent); + }); + + it('has the value from init in activate and follows host-change', async () => { + const bundle = `module.exports = { activate: function (api) { + globalThis.__seenAtActivate = api.host.features.externalMailWarnings; + globalThis.__pluginApi = api; + } };`; + const { SandboxRuntime } = await import('@/lib/plugin-sandbox/runtime'); + render(React.createElement(SandboxRuntime)); + post(hostWindow, HOST_ORIGIN, { + type: 'init', + payload: { + mode: 'background', pluginId: 'x', tier: 'privileged', locale: 'en', + manifest: { id: 'x', version: '1.0.0', permissions: [], settings: {} }, + code: bundle, + hostFeatures: { externalMailWarnings: true }, + }, + }); + await waitFor(() => expect(g.__pluginApi).toBeDefined()); + expect(g.__seenAtActivate).toBe(true); + + const api = g.__pluginApi!; + const kept = api.host.features; + const changes: unknown[] = []; + api.host.onFeaturesChange((f) => changes.push({ ...(f as object) })); + post(hostWindow, HOST_ORIGIN, { type: 'host-change', features: { externalMailWarnings: false } }); + expect(kept.externalMailWarnings).toBe(false); + expect(changes).toEqual([{ externalMailWarnings: false }]); + }, 30_000); +}); diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index db5a39018..1d0e7d67c 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -7,12 +7,13 @@ // `event.source === iframe.contentWindow`. The iframe's runtime pins the // parent on the first inbound message. -import type { InstalledPlugin, SlotName, PluginTier } from '../plugin-types'; +import type { HostFeaturesView, InstalledPlugin, SlotName, PluginTier } from '../plugin-types'; import { dispatchApiCall } from './host-api'; import { SANDBOX_PATH, SANDBOX_PRIVILEGED_PATH, type RegionalSnapshot } from './protocol'; export type { RegionalSnapshot }; import { withBasePath } from '../browser-navigation'; import { snapshotHostTheme, type ThemeSnapshot } from './host-theme'; +import { snapshotHostFeatures, watchHostFeatures } from './host-features'; import { effectiveSettings } from '../plugin-settings'; import { clampSlotHeight, clampSlotWidth, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import { resolveDateLocale } from '../utils'; @@ -252,6 +253,13 @@ function pushThemeToFrames(): void { } } +/** Every frame hears about a Bulwark feature that replaces plugins turning on or off. */ +function pushHostFeaturesToFrames(features: HostFeaturesView): void { + for (const instance of liveInstances) { + try { instance.setHostFeatures(features); } catch { /* one broken iframe must not stop the rest */ } + } +} + function ensureThemeWatcher(): void { if (themeWatcher || typeof MutationObserver === 'undefined' || typeof document === 'undefined') return; let scheduled = false; @@ -414,6 +422,7 @@ export class SandboxInstance { hostContainer.appendChild(this.iframe); liveInstances.add(this); if (this.mode === 'slot') ensureThemeWatcher(); + watchHostFeatures(pushHostFeaturesToFrames); // Send init after the iframe runtime signals it's ready. this.readyPromise.then(() => { @@ -637,6 +646,16 @@ export class SandboxInstance { this.send({ type: 'theme-change', theme }); } + /** Push what api.host.features says now; before init it goes into the init payload. */ + setHostFeatures(features: HostFeaturesView): void { + if (this.destroyed) return; + if (this.pendingInit) { + this.pendingInit.hostFeatures = features; + return; + } + this.send({ type: 'host-change', features }); + } + /** Push the plugin's current settings (see effectiveSettings). */ setSettings(settings: Record): void { if (this.destroyed) return; @@ -735,6 +754,7 @@ export function createBackgroundInstance(opts: BackgroundOptions): SandboxInstan code: opts.code, locale: opts.locale, regional: opts.regional ?? snapshotRegionalSettings(opts.locale), + hostFeatures: snapshotHostFeatures(), }; return new SandboxInstance( opts.plugin, @@ -764,6 +784,7 @@ export function createSlotInstance(opts: SlotOptions): SandboxInstance { locale: opts.locale, regional: opts.regional ?? snapshotRegionalSettings(opts.locale), theme: snapshotHostTheme(), + hostFeatures: snapshotHostFeatures(), }; return new SandboxInstance(opts.plugin, payload, opts.hostContainer, opts.onResize, opts.onFloatingDropdown); } diff --git a/lib/plugin-sandbox/host-features.ts b/lib/plugin-sandbox/host-features.ts new file mode 100644 index 000000000..f9721ba19 --- /dev/null +++ b/lib/plugin-sandbox/host-features.ts @@ -0,0 +1,65 @@ +// What the host tells plugins about its own features (`api.host.features`), +// so a plugin can step back where Bulwark now does the job itself. Sent with +// every frame's init and again whenever it changes (the admin's policy, an +// account switch to another scope). +// +// And the other half of that hand-over: copies of a plugin core replaced +// that are older than the release which knows the flag keep running their +// hooks. While core does the job, the host skips those hooks by plugin id. + +import { usePolicyStore } from '@/stores/policy-store'; +import type { HostFeaturesView } from '../plugin-types'; +import type { OrganisationPolicy } from '@/lib/admin/organisation-policy'; + +/** + * Either external-mail switch is on. The same rule as coreExternalWarningsOn + * (lib/external-mail.ts), kept apart so the sandbox doesn't pull in the + * account and contact stores. + */ +export function externalWarningsOn(externalMail: OrganisationPolicy['externalMail'] | undefined): boolean { + return !!externalMail && (externalMail.senderLabel || externalMail.recipientWarning); +} + +export function snapshotHostFeatures(): HostFeaturesView { + return { externalMailWarnings: externalWarningsOn(usePolicyStore.getState().getOrganisation().externalMail) }; +} + +/** The external-mail-warning plugin, retired in favour of organisation.externalMail. */ +export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning'; + +/** Its send check and its new-mail toast; its banner is the email-banner slot. */ +const EXTERNAL_MAIL_WARNING_HOOKS = new Set(['onBeforeEmailSend', 'onNewEmailReceived']); +const EXTERNAL_MAIL_WARNING_SLOTS = new Set(['email-banner']); + +/** True when core does this plugin hook's job right now, so the host skips it. */ +export function isHookHandedOver(pluginId: string, hookName: string): boolean { + return pluginId === EXTERNAL_MAIL_WARNING_PLUGIN_ID + && EXTERNAL_MAIL_WARNING_HOOKS.has(hookName) + && snapshotHostFeatures().externalMailWarnings; +} + +/** True when core does this plugin slot's job right now, so the host doesn't show it. */ +export function isSlotHandedOver(pluginId: string, slot: string, features: HostFeaturesView = snapshotHostFeatures()): boolean { + return pluginId === EXTERNAL_MAIL_WARNING_PLUGIN_ID + && EXTERNAL_MAIL_WARNING_SLOTS.has(slot) + && features.externalMailWarnings; +} + +// ─── Change watch ──────────────────────────────────────────── + +let watching = false; +let lastKey = ''; + +/** Calls `onChange` with the new features whenever they change; set up once. */ +export function watchHostFeatures(onChange: (features: HostFeaturesView) => void): void { + if (watching) return; + watching = true; + lastKey = JSON.stringify(snapshotHostFeatures()); + usePolicyStore.subscribe(() => { + const features = snapshotHostFeatures(); + const key = JSON.stringify(features); + if (key === lastKey) return; + lastKey = key; + onChange(features); + }); +} diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index a95b2bc47..bbf79ef99 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -31,6 +31,7 @@ import { clearPluginPages } from './pages'; import { clearSectionTitle } from './section-titles'; import { cancelPluginProxyRequests } from './proxy-client'; import { cancelConnectFor } from './connect-dialog'; +import { isHookHandedOver } from './host-features'; import type { SlotName } from '../plugin-types'; // ─── Hook-bus lookup (one flat map for name → bus) ──────────── @@ -280,7 +281,12 @@ async function startSandboxedPlugin(plugin: InstalledPlugin): Promise { } // The bus records a failure once; recording it here too made the // three-strikes breaker trip on the second error. - const proxy = (...args: unknown[]) => bg.invokeHook(hookName, args); + // While Bulwark does a retired plugin's job itself, that plugin's + // hook is skipped: copies older than the release that steps back on + // its own would ask twice (host-features.ts). Asked per call, so + // turning the core switch off gives the plugin its hook back. + const pluginId = plugin.id; + const proxy = (...args: unknown[]) => (isHookHandedOver(pluginId, hookName) ? undefined : bg.invokeHook(hookName, args)); hookDisposables.push(bus.register(plugin.id, proxy as (...a: unknown[]) => unknown)); if (hookName === 'onEmailOpen') openHandler = proxy; } diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 0dda74f34..c947991a9 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -8,7 +8,7 @@ // the boundary must be structured-cloneable: no functions, no DOM nodes, no // class instances. -import type { SlotName, PluginTier } from '../plugin-types'; +import type { SlotName, PluginTier, HostFeaturesView } from '../plugin-types'; import type { ThemeSnapshot } from './host-theme'; /** Resolved presentation/regional settings mirrored into plugin runtimes. */ @@ -50,6 +50,8 @@ export interface BackgroundInit { locale: string; /** Resolved presentation/regional preferences (time format, date locale, time zone). */ regional?: RegionalSnapshot; + /** Bulwark features that replace plugins (api.host.features); host pushes updates via 'host-change'. */ + hostFeatures?: HostFeaturesView; } /** Initialisation payload for a slot-instance iframe (one per slot mount). */ @@ -97,6 +99,8 @@ export interface SlotInit { * injected CSS + a `.dark` class. Host pushes updates via 'theme-change'. */ theme: ThemeSnapshot; + /** Mirrors `BackgroundInit.hostFeatures`. */ + hostFeatures?: HostFeaturesView; } export type InitPayload = BackgroundInit | SlotInit; @@ -265,6 +269,9 @@ export interface ThemeChangeMsg { type: 'theme-change'; theme: ThemeSnapshot; } /** Host → sandbox: the user changed this plugin's settings. */ export interface SettingsChangeMsg { type: 'settings-change'; settings: Record; } +/** Host → sandbox: a Bulwark feature that replaces plugins was turned on or off. */ +export interface HostChangeMsg { type: 'host-change'; features: HostFeaturesView; } + export interface PropsUpdateMsg { type: 'props-update'; props: Record; } export interface SlotShouldShowMsg { @@ -309,6 +316,7 @@ export type HostToSandbox = | LocaleChangeMsg | ThemeChangeMsg | SettingsChangeMsg + | HostChangeMsg | PropsUpdateMsg | SlotShouldShowMsg | FloatingDropdownSelectMsg diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index 3f8f46405..748de7916 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -33,7 +33,7 @@ import { initFloatingDropdownBridge, dispatchFloatingDropdownEvent } from './ui- import { interpolate, lookup, textDirection, type LocaleTables } from './plugin-i18n'; import { isFillableSlot, isInlineSlot, MAX_INLINE_SLOT_WIDTH } from './slot-layout'; import type { - AppInfoView, AppSettingsView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, + AppInfoView, AppSettingsView, HostFeaturesView, SettingsSearchResult, CalendarEventInput, CalendarEventView, CalendarView, ComposeInput, DraftInput, EmailReadView, EmailSummary, IdentityPatch, IdentityView, MailboxView, NotificationInput, SlotName, TaskInput, TaskView, TemplateInput, TemplateView, VacationInput, VacationView, } from '../plugin-types'; @@ -148,6 +148,18 @@ function applySettings(next: Record): void { try { listener(settingsState); } catch (err) { console.error('[plugin] settings listener failed', err); } } } +// What api.host.features says: one object for the iframe's lifetime, +// updated in place on 'host-change' like the settings. +const hostFeaturesState: HostFeaturesView = { externalMailWarnings: false }; +const hostFeatureListeners = new Set<(features: HostFeaturesView) => void>(); + +function applyHostFeatures(next: HostFeaturesView | undefined): void { + if (!next) return; + hostFeaturesState.externalMailWarnings = next.externalMailWarnings === true; + for (const listener of hostFeatureListeners) { + try { listener(hostFeaturesState); } catch (err) { console.error('[plugin] host feature listener failed', err); } + } +} const pendingCallbacks = new Map void; reject: (err: Error) => void }>(); const hookHandlers: Record unknown> = {}; @@ -375,6 +387,18 @@ function buildPluginApi(manifest: PluginManifest) { return () => { settingsListeners.delete(listener); }; }, }, + /** + * Bulwark features that replace what plugins did, so a plugin can step + * back while Bulwark does its job. Updated in place; no permission needed. + */ + host: { + features: hostFeaturesState, + /** Called with the new features whenever one turns on or off. Returns an unsubscribe function. */ + onFeaturesChange: (listener: (features: HostFeaturesView) => void) => { + hostFeatureListeners.add(listener); + return () => { hostFeatureListeners.delete(listener); }; + }, + }, /** The app the plugin runs in: product, the administrator's name for it, version, edition. No permission needed. */ app: { getInfo: () => callApi('app.getInfo', []) as Promise, @@ -1143,9 +1167,11 @@ async function bootSlot(payload: SlotInit): Promise { const bump = () => setSettingsVersion((v) => v + 1); settingsListeners.add(bump); localeListeners.add(bump); + hostFeatureListeners.add(bump); return () => { settingsListeners.delete(bump); localeListeners.delete(bump); + hostFeatureListeners.delete(bump); }; }, []); React.useEffect(() => { @@ -1193,6 +1219,8 @@ async function handleInit(payload: InitPayload): Promise { // Make the active locale available to plugin code (api.i18n) right away - // not only after the first 'locale-change' push. applyLocale(payload.locale, payload.regional); + // Before the bundle runs, so activate() and shouldShow already see it. + applyHostFeatures(payload.hostFeatures); try { if (payload.mode === 'background') { await bootBackground(payload); @@ -1289,6 +1317,10 @@ function handleHostMessage(ev: MessageEvent): void { applySettings(msg.settings ?? {}); break; + case 'host-change': + applyHostFeatures(msg.features); + break; + case 'props-update': if (slotPropsUpdater) slotPropsUpdater(msg.props ?? {}); else if (mode === 'slot') propsBeforeBoot = msg.props ?? {}; diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 9fc9b4959..3660d9d5c 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -837,6 +837,20 @@ export interface AppInfoView { demoMode: boolean; } +/** + * Features of Bulwark itself that replace what a plugin did, as + * `api.host.features` tells the plugin (no permission needed). A plugin + * that does the same job steps back while one is true. Missing on hosts + * older than 1.19.0, so read it as `api.host?.features?.x === true`. + */ +export interface HostFeaturesView { + /** + * The admin turned on the "External" sender label or the external + * recipient warning (policy organisation.externalMail) for this user. + */ + externalMailWarnings: boolean; +} + /** One setting `api.app.searchSettings` found, in the user's language. */ export interface SettingsSearchResult { /** The settings page's id: account, appearance, composing, vacation, ... */ From 45cc6988e051c46d37481008bc23e084191e75a6 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:18:07 +0100 Subject: [PATCH 014/137] feat: sign out after an admin-set idle time or session length, with a warning first --- app/(main)/[locale]/layout.tsx | 2 + app/(main)/[locale]/login/page.tsx | 17 +- .../admin/org/sections/sessions-section.tsx | 87 ++++++- .../__tests__/session-timeout-guard.test.tsx | 232 ++++++++++++++++++ components/session/session-timeout-dialog.tsx | 85 +++++++ components/session/session-timeout-guard.tsx | 221 +++++++++++++++++ .../host-api-app-session-timeout.test.ts | 43 ++++ lib/plugin-sandbox/host-api-app.ts | 8 + .../__tests__/activity-channel.test.ts | 69 ++++++ lib/session-policy/__tests__/limits.test.ts | 34 +++ .../__tests__/session-timer.test.ts | 143 +++++++++++ lib/session-policy/activity-channel.ts | 72 ++++++ lib/session-policy/limits.ts | 60 +++++ lib/session-policy/session-timer.ts | 139 +++++++++++ lib/session-policy/sign-out-reason.ts | 33 +++ locales/de/common.json | 12 + locales/en/common.json | 12 + .../auth-store-provider-logout.test.ts | 16 ++ stores/account-store.ts | 6 + stores/auth-store.ts | 15 +- 20 files changed, 1294 insertions(+), 12 deletions(-) create mode 100644 components/session/__tests__/session-timeout-guard.test.tsx create mode 100644 components/session/session-timeout-dialog.tsx create mode 100644 components/session/session-timeout-guard.tsx create mode 100644 lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts create mode 100644 lib/session-policy/__tests__/activity-channel.test.ts create mode 100644 lib/session-policy/__tests__/limits.test.ts create mode 100644 lib/session-policy/__tests__/session-timer.test.ts create mode 100644 lib/session-policy/activity-channel.ts create mode 100644 lib/session-policy/limits.ts create mode 100644 lib/session-policy/session-timer.ts create mode 100644 lib/session-policy/sign-out-reason.ts diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 76f9396ad..31c983c1d 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -19,6 +19,7 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { PushNotificationPrompt } from "@/components/push-notification-prompt"; import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt"; +import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard"; import { IS_LITE } from "@/lib/lite"; import { setRequestLocale } from "next-intl/server"; import { locales } from "@/i18n/routing"; @@ -61,6 +62,7 @@ export default async function LocaleLayout({ + {children} diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index efc06c706..84a214c2c 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -34,6 +34,7 @@ import { toAsciiDomain } from "@/lib/idn"; import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view"; import { buildMobileHandoffUrl, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff"; import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent"; +import { consumeSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason"; /** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */ function completeAddressDomain(username: string): string { @@ -139,6 +140,7 @@ function VersionBadge() { function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); + const tSessionLimits = useTranslations("session_limits"); const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; @@ -207,6 +209,8 @@ function LoginPageContent() { // those sessions then only last as long as the tab (lib/auth/lite-tokens.ts). const [liteTokenLoginSupported, setLiteTokenLoginSupported] = useState(null); const [sessionExpired, setSessionExpired] = useState(false); + // Signed out by the organisation's idle timeout or maximum session length. + const [sessionEnd, setSessionEnd] = useState(null); const [showPassword, setShowPassword] = useState(false); const [shakeError, setShakeError] = useState(false); const [showThemeMenu, setShowThemeMenu] = useState(false); @@ -332,6 +336,11 @@ function LoginPageContent() { sessionStorage.removeItem('session_expired'); } } catch { /* sessionStorage unavailable */ } + const ended = consumeSessionEnd(); + if (ended) { + setSessionEnd(ended); + setSessionExpired(true); + } }, []); useEffect(() => { @@ -1229,8 +1238,12 @@ function LoginPageContent() {
-

- {t("session_expired")} +

+ {sessionEnd?.kind === "idle" + ? tSessionLimits("reason_idle", { minutes: sessionEnd.minutes }) + : sessionEnd?.kind === "max" + ? tSessionLimits("reason_max", { hours: sessionEnd.hours }) + : t("session_expired")}

+ {mode === "idle" + ? + : } +
+ )} + + + ); +} diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx new file mode 100644 index 000000000..cb43453e6 --- /dev/null +++ b/components/session/session-timeout-guard.tsx @@ -0,0 +1,221 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAuthStore } from "@/stores/auth-store"; +import { useAccountStore } from "@/stores/account-store"; +import { usePolicyStore } from "@/stores/policy-store"; +import { policySubjectForClient } from "@/lib/admin/policy-subject"; +import type { PolicySubject } from "@/lib/admin/policy-scopes"; +import type { IJMAPClient } from "@/lib/jmap/client-interface"; +import { flushComposers } from "@/lib/compose-flush-registry"; +import { openActivityChannel, readLastActivity, type ActivityChannel } from "@/lib/session-policy/activity-channel"; +import { + NO_SESSION_LIMITS, + sameLimits, + sessionLimits, + setCurrentSessionLimits, + type AccountSession, + type SessionLimits, +} from "@/lib/session-policy/limits"; +import { createSessionTimer, type SessionEndKind, type SessionTimer, type SessionTimerState } from "@/lib/session-policy/session-timer"; +import { rememberSessionEnd } from "@/lib/session-policy/sign-out-reason"; +import { SessionTimeoutDialog } from "./session-timeout-dialog"; + +/** How long the sign-out waits for the open composers to save their drafts. */ +export const DRAFT_FLUSH_TIMEOUT_MS = 8000; +/** Local activity reaches the timer and the other tabs at most this often. */ +const ACTIVITY_THROTTLE_MS = 1000; +const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel", "scroll", "focus"] as const; + +const subjects = new WeakMap>(); +function subjectFor(client: IJMAPClient): Promise { + let subject = subjects.get(client); + if (!subject) { + subject = policySubjectForClient(client).catch(() => null); + subjects.set(client, subject); + } + return subject; +} + +/** The limits of every account signed in here, each with its own domain and group scopes. */ +async function collectLimits(): Promise { + const auth = useAuthStore.getState(); + const clients = new Map(auth.getAllConnectedClients()); + if (auth.client && auth.activeAccountId && !clients.has(auth.activeAccountId)) clients.set(auth.activeAccountId, auth.client); + const accounts: AccountSession[] = await Promise.all([...clients.entries()].map(async ([accountId, client]) => { + const subject = await subjectFor(client); + const session = usePolicyStore.getState().resolveFor(subject).organisation?.session; + return { session, signedInAt: signedInAtFor(accountId) }; + })); + return sessionLimits(accounts); +} + +/** + * When the account signed in. An account signed in before this was recorded + * counts as signed in now, the first time it is seen. + */ +function signedInAtFor(accountId: string): number | undefined { + const store = useAccountStore.getState(); + const account = store.getAccountById(accountId); + if (!account) return undefined; + if (typeof account.signedInAt === "number") return account.signedInAt; + const now = Date.now(); + store.updateAccount(accountId, { signedInAt: now }); + return now; +} + +const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: null }; + +/** + * The organisation's idle timeout and maximum session length + * (policy.json `organisation.session`). Activity in any Bulwark tab keeps + * every tab signed in; a warning counts down first; at zero the open and + * minimised composers save their drafts and every account signs out, with + * the reason on the login page. + */ +export function SessionTimeoutGuard() { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated); + const isDemoMode = useAuthStore((s) => s.isDemoMode); + const client = useAuthStore((s) => s.client); + const revision = useAuthStore((s) => s.connectedAccountsRevision); + const rawPolicy = usePolicyStore((s) => s.rawPolicy); + const accountsKey = useAccountStore((s) => s.accounts.map((a) => `${a.id}|${a.signedInAt ?? ""}|${a.isConnected}`).join(",")); + + const [limits, setLimits] = useState(NO_SESSION_LIMITS); + const [state, setState] = useState(IDLE_STATE); + const [ending, setEnding] = useState(false); + const [dismissedMax, setDismissedMax] = useState(null); + const timerRef = useRef(null); + const channelRef = useRef(null); + const limitsRef = useRef(limits); + limitsRef.current = limits; + const endingRef = useRef(false); + const active = isAuthenticated && !isDemoMode; + + useEffect(() => { + if (!active) { + setLimits(NO_SESSION_LIMITS); + return; + } + let cancelled = false; + void collectLimits().then((next) => { + if (!cancelled) setLimits((prev) => (sameLimits(prev, next) ? prev : next)); + }); + return () => { cancelled = true; }; + }, [active, client, revision, rawPolicy, accountsKey]); + + useEffect(() => { + setCurrentSessionLimits(active ? limits : null); + }, [active, limits]); + + const endSession = useCallback(async (kind: SessionEndKind) => { + if (endingRef.current) return; + endingRef.current = true; + setEnding(true); + const current = limitsRef.current; + rememberSessionEnd(kind === "max" + ? { kind: "max", hours: current.max?.hours ?? 0 } + : { kind: "idle", minutes: current.idle?.minutes ?? 0 }); + await flushComposers(DRAFT_FLUSH_TIMEOUT_MS); + await useAuthStore.getState().logoutAll({ endProviderSession: false }); + }, []); + + // The clock, while there is a limit to count. + const hasLimits = active && (!!limits.idle || !!limits.max); + useEffect(() => { + if (!hasLimits) { + timerRef.current?.dispose(); + timerRef.current = null; + setState(IDLE_STATE); + return; + } + if (timerRef.current) { + timerRef.current.setLimits(limits); + return; + } + // A tab opened or reloaded after a long pause starts from the pause, a + // fresh sign-in from the sign-in. + const newestSignIn = Math.max(0, ...useAccountStore.getState().accounts.map((a) => a.signedInAt ?? 0)); + const lastActivity = Math.max(readLastActivity() ?? Date.now(), newestSignIn); + timerRef.current = createSessionTimer({ + limits, + lastActivity, + onChange: setState, + onExpire: (kind) => { void endSession(kind); }, + }); + setState(timerRef.current.state()); + }, [hasLimits, limits, endSession]); + + useEffect(() => () => { + timerRef.current?.dispose(); + timerRef.current = null; + }, []); + + useEffect(() => { + if (!active) { + endingRef.current = false; + setEnding(false); + } + }, [active]); + + // Activity in this tab and the others. + useEffect(() => { + const channel = openActivityChannel((at) => timerRef.current?.remoteActivity(at)); + channelRef.current = channel; + let lastSeen = 0; + const onActivity = () => { + if (endingRef.current) return; + const at = Date.now(); + if (at - lastSeen < ACTIVITY_THROTTLE_MS) return; + const timer = timerRef.current; + if (timer) { + timer.activity(at); + // During the idle warning only "Stay signed in" counts. + const s = timer.state(); + if (s.phase === "expired" || (s.phase === "warning" && s.kind === "idle")) return; + } + lastSeen = at; + channel.post(at); + }; + const onVisibility = () => { + if (document.visibilityState === "visible") timerRef.current?.check(); + }; + for (const event of ACTIVITY_EVENTS) window.addEventListener(event, onActivity, { capture: true, passive: true }); + document.addEventListener("visibilitychange", onVisibility); + return () => { + for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onActivity, { capture: true }); + document.removeEventListener("visibilitychange", onVisibility); + channel.close(); + channelRef.current = null; + }; + }, []); + + const stay = useCallback(() => { + const timer = timerRef.current; + if (!timer) return; + timer.stay(); + channelRef.current?.post(timer.lastActivity()); + }, []); + + const signOutNow = useCallback(() => { + const kind = timerRef.current?.state().kind ?? "idle"; + void endSession(kind); + }, [endSession]); + + if (!active) return null; + if (ending) { + return ; + } + if (state.phase !== "warning" || !state.kind) return null; + if (state.kind === "max" && dismissedMax === state.deadline) return null; + return ( + setDismissedMax(state.deadline)} + onSignOut={signOutNow} + /> + ); +} diff --git a/lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts b/lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts new file mode 100644 index 000000000..a371e4192 --- /dev/null +++ b/lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts @@ -0,0 +1,43 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { doAppGetSettings } from '../host-api-app'; +import { usePolicyStore } from '@/stores/policy-store'; +import { useSettingsStore } from '@/stores/settings-store'; +import { DEFAULT_POLICY } from '@/lib/admin/types'; +import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy'; +import { setCurrentSessionLimits } from '@/lib/session-policy/limits'; + +/** + * Plugins read `sessionTimeout` from the settings view. The user setting of + * that name never did anything; the view reports the idle timeout the + * organisation set instead. + */ +describe('plugin settings view: sessionTimeout', () => { + beforeEach(() => { + useSettingsStore.setState({ sessionTimeout: 45 } as never); + usePolicyStore.setState({ + rawPolicy: DEFAULT_POLICY, + policy: { + ...DEFAULT_POLICY, + organisation: { ...DEFAULT_ORGANISATION_POLICY, session: { idleMinutes: 20, maxHours: 0, warnSeconds: 60 } }, + }, + }); + setCurrentSessionLimits(null); + }); + afterEach(() => { + setCurrentSessionLimits(null); + }); + + it("reports the active account's idle timeout before the guard has looked at every account", () => { + expect(doAppGetSettings().sessionTimeout).toBe(20); + }); + + it('reports the strictest idle timeout of the signed-in accounts', () => { + setCurrentSessionLimits({ idle: { minutes: 5, warnSeconds: 60 }, max: null }); + expect(doAppGetSettings().sessionTimeout).toBe(5); + }); + + it('reports 0 when no account has an idle timeout, whatever the old user setting says', () => { + setCurrentSessionLimits({ idle: null, max: null }); + expect(doAppGetSettings().sessionTimeout).toBe(0); + }); +}); diff --git a/lib/plugin-sandbox/host-api-app.ts b/lib/plugin-sandbox/host-api-app.ts index 86f7f58ad..b3ddc8968 100644 --- a/lib/plugin-sandbox/host-api-app.ts +++ b/lib/plugin-sandbox/host-api-app.ts @@ -17,6 +17,7 @@ import { productName } from '@/lib/product-name'; import { collectSubResults, getByPath, tabKeywords, tabSearchPaths, withAppName, type SettingsSearchTab, type SubResult, } from '@/lib/settings-search'; +import { currentSessionLimits } from '@/lib/session-policy/limits'; import type { AppSettingsView, SettingsSearchResult } from '../plugin-types'; type Messages = Record; @@ -144,6 +145,13 @@ export function doAppGetSettings(): AppSettingsView { if (typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) out[key] = value; else if (Array.isArray(value) && value.every((v) => typeof v === 'number' || typeof v === 'string')) out[key] = value.slice(0, 20) as number[] | string[]; } + // Minutes without activity before Bulwark signs out (0 = never). The user + // setting of that name was never read; the organisation's idle timeout is + // what applies, the strictest of the accounts signed in here. + const limits = currentSessionLimits(); + out.sessionTimeout = limits + ? limits.idle?.minutes ?? 0 + : usePolicyStore.getState().getOrganisation().session.idleMinutes; const theme = (useThemeStore.getState() as unknown as { theme?: unknown }).theme; if (typeof theme === 'string') out.theme = theme; const locale = useLocaleStore.getState().locale; diff --git a/lib/session-policy/__tests__/activity-channel.test.ts b/lib/session-policy/__tests__/activity-channel.test.ts new file mode 100644 index 000000000..beabf28e6 --- /dev/null +++ b/lib/session-policy/__tests__/activity-channel.test.ts @@ -0,0 +1,69 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LAST_ACTIVITY_KEY, openActivityChannel, readLastActivity } from '../activity-channel'; + +/** BroadcastChannel between "tabs" of one test: every instance of a name hears the others. */ +class FakeBroadcastChannel { + static instances: FakeBroadcastChannel[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + closed = false; + constructor(public name: string) { + FakeBroadcastChannel.instances.push(this); + } + postMessage(data: unknown) { + for (const other of FakeBroadcastChannel.instances) { + if (other !== this && !other.closed && other.name === this.name) other.onmessage?.({ data } as MessageEvent); + } + } + close() { + this.closed = true; + } +} + +describe('activity shared between tabs', () => { + beforeEach(() => { + localStorage.clear(); + FakeBroadcastChannel.instances = []; + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('tells the other tabs through a BroadcastChannel and keeps the time', () => { + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel); + const heardA = vi.fn(); + const heardB = vi.fn(); + const a = openActivityChannel(heardA); + const b = openActivityChannel(heardB); + + a.post(1234); + + expect(heardB).toHaveBeenCalledWith(1234); + expect(heardA).not.toHaveBeenCalled(); + expect(readLastActivity()).toBe(1234); + + b.close(); + a.post(5678); + expect(heardB).toHaveBeenCalledTimes(1); + a.close(); + }); + + it('falls back to storage events without BroadcastChannel', () => { + vi.stubGlobal('BroadcastChannel', undefined); + const heard = vi.fn(); + const channel = openActivityChannel(heard); + + // Another tab's write arrives as a storage event. + window.dispatchEvent(new StorageEvent('storage', { key: LAST_ACTIVITY_KEY, newValue: '4321' })); + window.dispatchEvent(new StorageEvent('storage', { key: 'something-else', newValue: '1' })); + window.dispatchEvent(new StorageEvent('storage', { key: LAST_ACTIVITY_KEY, newValue: 'garbage' })); + expect(heard).toHaveBeenCalledTimes(1); + expect(heard).toHaveBeenCalledWith(4321); + + channel.post(9999); + expect(readLastActivity()).toBe(9999); + + channel.close(); + window.dispatchEvent(new StorageEvent('storage', { key: LAST_ACTIVITY_KEY, newValue: '5555' })); + expect(heard).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lib/session-policy/__tests__/limits.test.ts b/lib/session-policy/__tests__/limits.test.ts new file mode 100644 index 000000000..676a30e08 --- /dev/null +++ b/lib/session-policy/__tests__/limits.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { sessionLimits } from '../limits'; + +const HOUR = 60 * 60 * 1000; +const session = (idleMinutes: number, maxHours: number, warnSeconds = 60) => ({ idleMinutes, maxHours, warnSeconds }); + +describe('session limits of the signed-in accounts', () => { + it('takes the shortest idle time that is set', () => { + const limits = sessionLimits([ + { session: session(30, 0, 120) }, + { session: session(0, 0) }, + { session: session(10, 0, 45) }, + ]); + expect(limits.idle).toEqual({ minutes: 10, warnSeconds: 45 }); + }); + + it('ignores accounts that set nothing', () => { + expect(sessionLimits([{ session: session(0, 0) }, { session: undefined }])).toEqual({ idle: null, max: null }); + expect(sessionLimits([])).toEqual({ idle: null, max: null }); + }); + + it('ends at the first maximum that runs out, counted from each sign-in', () => { + const limits = sessionLimits([ + { session: session(0, 8), signedInAt: 10 * HOUR }, + { session: session(0, 12), signedInAt: 0 }, + { session: session(0, 4, 30), signedInAt: 9 * HOUR }, + ]); + expect(limits.max).toEqual({ at: 12 * HOUR, hours: 12, warnSeconds: 60 }); + }); + + it('needs a sign-in time to count a maximum', () => { + expect(sessionLimits([{ session: session(0, 8) }]).max).toBeNull(); + }); +}); diff --git a/lib/session-policy/__tests__/session-timer.test.ts b/lib/session-policy/__tests__/session-timer.test.ts new file mode 100644 index 000000000..f5fef76d2 --- /dev/null +++ b/lib/session-policy/__tests__/session-timer.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createSessionTimer, type SessionTimerState } from '../session-timer'; +import type { SessionLimits } from '../limits'; + +const START = new Date('2026-10-28T09:00:00Z').getTime(); +const SECOND = 1000; +const MINUTE = 60 * SECOND; + +const idleOnly = (minutes: number, warnSeconds: number): SessionLimits => ({ idle: { minutes, warnSeconds }, max: null }); + +function setup(limits: SessionLimits) { + const states: SessionTimerState[] = []; + const onExpire = vi.fn(); + const timer = createSessionTimer({ limits, lastActivity: Date.now(), onChange: (s) => states.push(s), onExpire }); + return { timer, states, onExpire }; +} + +describe('session timer', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(START); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('warns warnSeconds before the idle time runs out, then signs out at zero', () => { + const { timer, onExpire } = setup(idleOnly(1, 30)); + expect(timer.state()).toMatchObject({ phase: 'active', kind: 'idle', deadline: START + MINUTE }); + + vi.advanceTimersByTime(30 * SECOND - 1); + expect(timer.state().phase).toBe('active'); + vi.advanceTimersByTime(1); + expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'idle', deadline: START + MINUTE }); + expect(onExpire).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(30 * SECOND - 1); + expect(onExpire).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onExpire).toHaveBeenCalledWith('idle'); + expect(timer.state().phase).toBe('expired'); + timer.dispose(); + }); + + it('starts the idle time again on activity before the warning', () => { + const { timer, onExpire } = setup(idleOnly(1, 30)); + vi.advanceTimersByTime(20 * SECOND); + timer.activity(); + // Without the reset the warning would have started at 30 s. + vi.advanceTimersByTime(29 * SECOND); + expect(timer.state().phase).toBe('active'); + vi.advanceTimersByTime(1 * SECOND); + expect(timer.state().phase).toBe('warning'); + expect(timer.state().deadline).toBe(START + 20 * SECOND + MINUTE); + vi.advanceTimersByTime(30 * SECOND); + expect(onExpire).toHaveBeenCalledTimes(1); + timer.dispose(); + }); + + it('keeps the warning up on local activity until the user chooses to stay', () => { + const { timer, onExpire } = setup(idleOnly(1, 30)); + vi.advanceTimersByTime(40 * SECOND); + expect(timer.state().phase).toBe('warning'); + + timer.activity(); + expect(timer.state().phase).toBe('warning'); + + timer.stay(); + expect(timer.state()).toMatchObject({ phase: 'active', deadline: START + 40 * SECOND + MINUTE }); + vi.advanceTimersByTime(MINUTE - 1); + expect(onExpire).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(onExpire).toHaveBeenCalledWith('idle'); + timer.dispose(); + }); + + it('ends the warning when another tab reports activity', () => { + const { timer, onExpire } = setup(idleOnly(1, 30)); + vi.advanceTimersByTime(45 * SECOND); + expect(timer.state().phase).toBe('warning'); + + timer.remoteActivity(Date.now()); + expect(timer.state().phase).toBe('active'); + vi.advanceTimersByTime(MINUTE - 1); + expect(onExpire).not.toHaveBeenCalled(); + timer.dispose(); + }); + + it('signs out after a sleep even when the wake-up arrives as activity first', () => { + const { timer, onExpire } = setup(idleOnly(15, 60)); + // The machine slept: the clock jumped, no timer ran. + vi.setSystemTime(START + 2 * 60 * MINUTE); + timer.activity(); + expect(onExpire).toHaveBeenCalledWith('idle'); + expect(timer.state().phase).toBe('expired'); + timer.remoteActivity(Date.now()); + timer.stay(); + expect(onExpire).toHaveBeenCalledTimes(1); + timer.dispose(); + }); + + it('runs no clock without limits', () => { + const { timer, onExpire } = setup({ idle: null, max: null }); + expect(timer.state()).toEqual({ phase: 'active', kind: null, deadline: null }); + vi.advanceTimersByTime(365 * 24 * 60 * MINUTE); + expect(onExpire).not.toHaveBeenCalled(); + timer.dispose(); + }); + + it('follows new limits', () => { + const { timer, onExpire } = setup({ idle: null, max: null }); + vi.advanceTimersByTime(10 * MINUTE); + timer.setLimits(idleOnly(5, 60)); + // The last activity was ten minutes ago: already past five minutes. + expect(onExpire).toHaveBeenCalledWith('idle'); + timer.dispose(); + }); + + it('warns before the maximum session length and does not move it on activity', () => { + const at = START + 2 * 60 * MINUTE; + const { timer, onExpire } = setup({ idle: { minutes: 30, warnSeconds: 60 }, max: { at, hours: 2, warnSeconds: 60 } }); + + for (let i = 0; i < 119; i++) { + vi.advanceTimersByTime(MINUTE); + timer.activity(); + } + expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'max', deadline: at }); + timer.stay(); + expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'max' }); + vi.advanceTimersByTime(MINUTE); + expect(onExpire).toHaveBeenCalledWith('max'); + timer.dispose(); + }); + + it('counts a maximum far away without running its timer early', () => { + const at = START + 365 * 24 * 60 * MINUTE; + const { timer, onExpire } = setup({ idle: null, max: { at, hours: 365 * 24, warnSeconds: 60 } }); + vi.advanceTimersByTime(30 * 24 * 60 * MINUTE); + expect(onExpire).not.toHaveBeenCalled(); + expect(timer.state().phase).toBe('active'); + timer.dispose(); + }); +}); diff --git a/lib/session-policy/activity-channel.ts b/lib/session-policy/activity-channel.ts new file mode 100644 index 000000000..283560c89 --- /dev/null +++ b/lib/session-policy/activity-channel.ts @@ -0,0 +1,72 @@ +/** + * Activity shared between Bulwark tabs, so working in one tab keeps the + * others signed in. Tabs tell each other through a BroadcastChannel; where + * there is none, through `storage` events on a localStorage key. The last + * activity is also kept in localStorage, so a tab opened (or reloaded) after + * a long pause starts from it rather than from the page load. + */ + +const CHANNEL = 'bulwark-session-activity'; +export const LAST_ACTIVITY_KEY = 'bulwark-last-activity'; + +export interface ActivityChannel { + /** Tell the other tabs about activity at `at` (ms). */ + post(at: number): void; + close(): void; +} + +function parse(value: unknown): number | null { + const at = typeof value === 'string' ? Number(value) : value; + return typeof at === 'number' && Number.isFinite(at) && at > 0 ? at : null; +} + +/** The last activity any tab recorded, or null. */ +export function readLastActivity(): number | null { + try { + return parse(window.localStorage.getItem(LAST_ACTIVITY_KEY)); + } catch { + return null; + } +} + +export function openActivityChannel(onActivity: (at: number) => void): ActivityChannel { + let channel: BroadcastChannel | null = null; + if (typeof BroadcastChannel !== 'undefined') { + try { + channel = new BroadcastChannel(CHANNEL); + channel.onmessage = (event: MessageEvent) => { + const at = parse((event.data as { at?: unknown } | null)?.at); + if (at) onActivity(at); + }; + } catch { + channel = null; + } + } + + // Without a channel, the localStorage write below reaches the other tabs as a storage event. + const onStorage = (event: StorageEvent) => { + if (event.key !== LAST_ACTIVITY_KEY) return; + const at = parse(event.newValue); + if (at) onActivity(at); + }; + if (!channel) window.addEventListener('storage', onStorage); + + return { + post(at) { + try { + window.localStorage.setItem(LAST_ACTIVITY_KEY, String(at)); + } catch { + /* storage unavailable */ + } + try { + channel?.postMessage({ at }); + } catch { + /* channel closed */ + } + }, + close() { + channel?.close(); + if (!channel) window.removeEventListener('storage', onStorage); + }, + }; +} diff --git a/lib/session-policy/limits.ts b/lib/session-policy/limits.ts new file mode 100644 index 000000000..6a939f507 --- /dev/null +++ b/lib/session-policy/limits.ts @@ -0,0 +1,60 @@ +/** + * The organisation's session limits (policy.json `organisation.session`) as + * they apply to this browser. Every account signed in here has its own + * effective policy (its domain and group scopes); the browser follows the + * strictest: the shortest idle time any of them sets, and the first + * maximum session length to run out. 0 is off, so it never wins. + */ + +import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy'; + +export type SessionPolicy = OrganisationPolicy['session']; + +export interface AccountSession { + session: SessionPolicy | undefined; + /** When the account signed in (ms); its maximum session length counts from here. */ + signedInAt?: number; +} + +export interface SessionLimits { + idle: { minutes: number; warnSeconds: number } | null; + /** `at`: when the first account's maximum session length runs out. */ + max: { at: number; hours: number; warnSeconds: number } | null; +} + +export const NO_SESSION_LIMITS: SessionLimits = { idle: null, max: null }; + +const HOUR_MS = 60 * 60 * 1000; + +export function sessionLimits(accounts: readonly AccountSession[]): SessionLimits { + let idle: SessionLimits['idle'] = null; + let max: SessionLimits['max'] = null; + for (const account of accounts) { + const session = account.session ?? DEFAULT_ORGANISATION_POLICY.session; + const warnSeconds = session.warnSeconds; + if (session.idleMinutes > 0 && (!idle || session.idleMinutes < idle.minutes)) { + idle = { minutes: session.idleMinutes, warnSeconds }; + } + if (session.maxHours > 0 && typeof account.signedInAt === 'number') { + const at = account.signedInAt + session.maxHours * HOUR_MS; + if (!max || at < max.at) max = { at, hours: session.maxHours, warnSeconds }; + } + } + return { idle, max }; +} + +export function sameLimits(a: SessionLimits, b: SessionLimits): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +let current: SessionLimits | null = null; + +/** The limits the guard counts with, for readers outside React (the plugin settings view). */ +export function setCurrentSessionLimits(limits: SessionLimits | null): void { + current = limits; +} + +/** Null before the guard has looked at the signed-in accounts. */ +export function currentSessionLimits(): SessionLimits | null { + return current; +} diff --git a/lib/session-policy/session-timer.ts b/lib/session-policy/session-timer.ts new file mode 100644 index 000000000..b9d54b8e0 --- /dev/null +++ b/lib/session-policy/session-timer.ts @@ -0,0 +1,139 @@ +/** + * The clock behind the organisation's idle timeout and maximum session + * length. It knows the last activity and the limits, and says when the + * warning starts and when the session ends; the guard component + * (components/session/session-timeout-guard.tsx) feeds it activity and + * does the signing out. + * + * Deadlines are times, not countdowns: a laptop that slept through one + * expires on the first check after waking, and activity that arrives after + * the deadline (the focus event of the wake-up itself) can't bring the + * session back. + */ + +import type { SessionLimits } from './limits'; + +export type SessionEndKind = 'idle' | 'max'; + +export interface SessionTimerState { + phase: 'active' | 'warning' | 'expired'; + /** Which limit the next deadline belongs to; null when no limit is set. */ + kind: SessionEndKind | null; + /** When the session ends (ms); null when no limit is set. */ + deadline: number | null; +} + +export interface SessionTimerOptions { + limits: SessionLimits; + lastActivity: number; + onChange?: (state: SessionTimerState) => void; + onExpire: (kind: SessionEndKind) => void; + now?: () => number; +} + +export interface SessionTimer { + /** Activity in this tab. Ignored while the idle warning shows: the user answers it with "Stay signed in". */ + activity(at?: number): void; + /** Activity in another tab, where the user may have answered the warning already. */ + remoteActivity(at: number): void; + /** "Stay signed in": restarts the idle time, never the maximum. */ + stay(): void; + setLimits(limits: SessionLimits): void; + /** Look at the clock now (after a wake-up or a tab becoming visible). */ + check(): void; + lastActivity(): number; + state(): SessionTimerState; + dispose(): void; +} + +const MINUTE_MS = 60 * 1000; +const MAX_WAIT_MS = 60 * MINUTE_MS; + +export function createSessionTimer(options: SessionTimerOptions): SessionTimer { + const now = options.now ?? Date.now; + let limits = options.limits; + let lastActivity = options.lastActivity; + let state: SessionTimerState = { phase: 'active', kind: null, deadline: null }; + let timer: ReturnType | null = null; + let disposed = false; + + const clear = () => { + if (timer) clearTimeout(timer); + timer = null; + }; + + const publish = (next: SessionTimerState) => { + if (next.phase === state.phase && next.kind === state.kind && next.deadline === state.deadline) return; + state = next; + options.onChange?.(state); + }; + + /** The deadline that comes first, with how long its warning lasts. */ + const target = (): { kind: SessionEndKind; deadline: number; warnMs: number } | null => { + const idleMs = limits.idle ? limits.idle.minutes * MINUTE_MS : 0; + const idle = limits.idle + ? { kind: 'idle' as const, deadline: lastActivity + idleMs, warnMs: Math.min(limits.idle.warnSeconds * 1000, idleMs) } + : null; + const max = limits.max + ? { kind: 'max' as const, deadline: limits.max.at, warnMs: limits.max.warnSeconds * 1000 } + : null; + if (idle && max) return max.deadline <= idle.deadline ? max : idle; + return idle ?? max; + }; + + const evaluate = () => { + if (disposed || state.phase === 'expired') return; + clear(); + const next = target(); + if (!next) { + publish({ phase: 'active', kind: null, deadline: null }); + return; + } + const at = now(); + if (at >= next.deadline) { + publish({ phase: 'expired', kind: next.kind, deadline: next.deadline }); + options.onExpire(next.kind); + return; + } + const warnFrom = next.deadline - next.warnMs; + const warning = at >= warnFrom; + publish({ phase: warning ? 'warning' : 'active', kind: next.kind, deadline: next.deadline }); + // Capped: setTimeout overflows past 24.8 days, and a long wait is checked again anyway. + timer = setTimeout(evaluate, Math.min((warning ? next.deadline : warnFrom) - at, MAX_WAIT_MS)); + }; + + const record = (at: number) => { + // A deadline that already passed ends the session before the activity counts. + evaluate(); + if (state.phase === 'expired') return; + if (at > lastActivity) lastActivity = at; + evaluate(); + }; + + evaluate(); + + return { + activity(at = now()) { + evaluate(); + if (state.phase === 'warning' && state.kind === 'idle') return; + record(at); + }, + remoteActivity(at) { + record(at); + }, + stay() { + record(now()); + }, + setLimits(next) { + limits = next; + evaluate(); + }, + check: evaluate, + lastActivity: () => lastActivity, + state: () => state, + dispose() { + disposed = true; + clear(); + }, + }; +} diff --git a/lib/session-policy/sign-out-reason.ts b/lib/session-policy/sign-out-reason.ts new file mode 100644 index 000000000..b52166520 --- /dev/null +++ b/lib/session-policy/sign-out-reason.ts @@ -0,0 +1,33 @@ +/** + * Why Bulwark signed the user out on its own, for the login page to say. + * Kept in sessionStorage: the tab that signed out shows it once. + */ + +export type SessionEndReason = + | { kind: 'idle'; minutes: number } + | { kind: 'max'; hours: number }; + +const KEY = 'session_end_reason'; + +export function rememberSessionEnd(reason: SessionEndReason): void { + try { + window.sessionStorage.setItem(KEY, JSON.stringify(reason)); + } catch { + /* storage unavailable */ + } +} + +/** The reason, once; null when the user wasn't signed out by a session limit. */ +export function consumeSessionEnd(): SessionEndReason | null { + try { + const raw = window.sessionStorage.getItem(KEY); + window.sessionStorage.removeItem(KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Record; + if (parsed.kind === 'idle' && typeof parsed.minutes === 'number') return { kind: 'idle', minutes: parsed.minutes }; + if (parsed.kind === 'max' && typeof parsed.hours === 'number') return { kind: 'max', hours: parsed.hours }; + return null; + } catch { + return null; + } +} diff --git a/locales/de/common.json b/locales/de/common.json index ffdd16235..5d888afd7 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -76,6 +76,18 @@ "back_to_login": "Zurück zur Anmeldung" } }, + "session_limits": { + "idle_title": "Sind Sie noch da?", + "idle_message": "Sie werden in {seconds, plural, one {# Sekunde} other {# Sekunden}} abgemeldet, weil es keine Aktivität gab. Geöffnete Nachrichten werden als Entwurf gespeichert.", + "stay": "Angemeldet bleiben", + "sign_out_now": "Jetzt abmelden", + "max_title": "Ihre Sitzung endet", + "max_message": "Ihre Organisation beendet Sitzungen {hours, plural, one {# Stunde} other {# Stunden}} nach der Anmeldung. Sie werden in {seconds, plural, one {# Sekunde} other {# Sekunden}} abgemeldet. Geöffnete Nachrichten werden als Entwurf gespeichert.", + "dismiss": "OK", + "signing_out": "Entwürfe werden gespeichert und Sie werden abgemeldet …", + "reason_idle": "Sie wurden nach {minutes, plural, one {# Minute} other {# Minuten}} ohne Aktivität abgemeldet.", + "reason_max": "Sie wurden abgemeldet, weil Ihre Sitzung die Höchstdauer von {hours, plural, one {# Stunde} other {# Stunden}} erreicht hat." + }, "sidebar": { "close": "Schließen", "compose": "Verfassen", diff --git a/locales/en/common.json b/locales/en/common.json index 95ec87fa3..f9ca53762 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -76,6 +76,18 @@ "back_to_login": "Back to login" } }, + "session_limits": { + "idle_title": "Are you still there?", + "idle_message": "You'll be signed out in {seconds, plural, one {# second} other {# seconds}} because there was no activity. Open messages are saved as drafts.", + "stay": "Stay signed in", + "sign_out_now": "Sign out now", + "max_title": "Your session is ending", + "max_message": "Your organisation ends sessions {hours, plural, one {# hour} other {# hours}} after sign-in. You'll be signed out in {seconds, plural, one {# second} other {# seconds}}. Open messages are saved as drafts.", + "dismiss": "OK", + "signing_out": "Saving drafts and signing out…", + "reason_idle": "You were signed out after {minutes, plural, one {# minute} other {# minutes}} without activity.", + "reason_max": "You were signed out because your session reached its maximum length of {hours, plural, one {# hour} other {# hours}}." + }, "sidebar": { "close": "Close", "compose": "Compose", diff --git a/stores/__tests__/auth-store-provider-logout.test.ts b/stores/__tests__/auth-store-provider-logout.test.ts index 237773b58..7a22f3f79 100644 --- a/stores/__tests__/auth-store-provider-logout.test.ts +++ b/stores/__tests__/auth-store-provider-logout.test.ts @@ -381,6 +381,22 @@ describe('signing out of several accounts', () => { expect(calls).toEqual(['DELETE /api/auth/session?all=true', 'DELETE /api/auth/token?all=true']); expect(replaceSpy).toHaveBeenCalledWith('/en/login'); }); + + it('leaves every provider session alone for a sign-out the session limits made', async () => { + signIn([alice, bob, carol], 'carol'); + const { calls } = mockFetch({ + 'DELETE /api/auth/session?all=true': () => ok(), + 'DELETE /api/auth/token?all=true': () => ok(), + }); + + await useAuthStore.getState().logoutAll({ endProviderSession: false }); + + expect(calls).toEqual(['DELETE /api/auth/session?all=true', 'DELETE /api/auth/token?all=true']); + expect(replaceSpy).toHaveBeenCalledWith('/en/login'); + expect(useAccountStore.getState().accounts).toEqual([]); + // The login page shows the form rather than signing straight back in. + expect(consumeSignedOut()).toBe(true); + }); }); // Leaving for the provider is final for the page, so that state is never diff --git a/stores/account-store.ts b/stores/account-store.ts index 2a6a37059..7522cf24d 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -43,6 +43,12 @@ export interface AccountEntry { avatarColor: string; /** Timestamp of last successful login */ lastLoginAt: number; + /** + * When the user last signed in to it with a password, token or provider + * login (not a restore). The organisation's maximum session length counts + * from here. `undefined` = signed in before this was recorded. + */ + signedInAt?: number; /** Whether this account is currently connected */ isConnected: boolean; /** Whether this account had a connection error */ diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 6a7421f7e..0a414af71 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -108,9 +108,10 @@ interface AuthState { logout: (options?: LogoutOptions) => Promise; /** * Sign out of every account. Ends at most one identity provider session: - * see {@link pickEndSessionSlot}. + * see {@link pickEndSessionSlot}; none with `endProviderSession: false` + * (the organisation's session limits, which sign out without asking). */ - logoutAll: () => Promise; + logoutAll: (options?: LogoutOptions) => Promise; removeAccount: (accountId: string) => void; switchAccount: (accountId: string) => Promise; checkAuth: () => Promise; @@ -1267,6 +1268,8 @@ export const LOGOUT_HOOK_BUDGET_MS = 2000; function notifySignedIn(state: { activeAccountId: string | null; username: string | null }): void { if (!state.activeAccountId) return; + // The organisation's maximum session length counts from a sign-in, never from a restore. + useAccountStore.getState().updateAccount(state.activeAccountId, { signedInAt: Date.now() }); void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' }); } @@ -2315,12 +2318,14 @@ export const useAuthStore = create()( clearPluginStorageForAccount(accountId); }, - logoutAll: async () => { + logoutAll: async (options) => { await beforeLogoutHooks('all'); const accountStore = useAccountStore.getState(); const allAccounts = [...accountStore.accounts]; - const endSessionSlot = get().isDemoMode ? null : pickEndSessionSlot(allAccounts, get().activeAccountId); + // `options` can be a click event when the action is wired straight to onClick. + const endProviderSession = options?.endProviderSession !== false; + const endSessionSlot = get().isDemoMode || !endProviderSession ? null : pickEndSessionSlot(allAccounts, get().activeAccountId); // Starts by marking every slot closing, so nothing renews a session // while the clients are torn down. @@ -3092,7 +3097,7 @@ export const useAuthStore = create()( bindClientStatusHandlers(client, set, get, accountId); accountStore.updateAccount(accountId, { awaitingSignIn: false, isConnected: true, hasError: false, errorMessage: undefined, - rememberMe, lastLoginAt: Date.now(), + rememberMe, lastLoginAt: Date.now(), signedInAt: Date.now(), }); void syncAccountDisplayName(accountId, client); set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 })); From 553f1e9149169cef18b01e761bb6740abb15b9fb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Wed, 28 Oct 2026 02:19:06 +0100 Subject: [PATCH 015/137] feat: add a switch that stops all third-party traffic #903 --- app/(main)/admin/_pages/telemetry.tsx | 8 +- app/(main)/admin/_pages/version.tsx | 6 + app/api/admin/config/route.ts | 12 + app/api/admin/marketplace/[slug]/route.ts | 5 + app/api/admin/marketplace/route.ts | 8 + app/api/admin/marketplace/updates/route.ts | 9 + app/api/admin/telemetry/route.ts | 6 + app/api/admin/version/route.ts | 2 + app/api/config/route.ts | 3 + app/api/connector/capabilities/route.ts | 3 +- app/api/favicon/route.ts | 5 + app/api/plugin-proxy/route.ts | 3 + app/api/plugins/route.ts | 7 +- app/api/translate/route.ts | 5 + components/calendar/event-detail-popover.tsx | 7 +- components/push-notification-prompt.tsx | 3 + components/settings/message-list-settings.tsx | 5 +- components/settings/notification-settings.tsx | 12 +- components/settings/plugins-settings.tsx | 5 +- components/ui/__tests__/avatar.test.tsx | 14 + components/ui/avatar.tsx | 10 +- hooks/use-config.ts | 7 + lib/__tests__/favicon-route-inflight.test.ts | 6 +- lib/__tests__/fixtures/config.ts | 1 + lib/__tests__/no-third-party-client.test.ts | 123 ++++++++ lib/__tests__/no-third-party-server.test.ts | 273 ++++++++++++++++++ lib/__tests__/plugin-sandbox-loader.test.ts | 19 ++ lib/admin/csp-frame-origins.ts | 7 + lib/admin/marketplace-directory.ts | 21 +- lib/admin/third-party-mode.ts | 37 +++ lib/admin/types.ts | 7 + lib/config-validation.ts | 1 + lib/lite-config.ts | 2 + lib/outgoing-hosts.ts | 255 ++++++++++++++++ lib/plugin-sandbox/host-api.ts | 6 + lib/plugin-sandbox/loader.ts | 8 + lib/push-relays.ts | 18 +- lib/telemetry/sender.ts | 2 + lib/telemetry/state.ts | 9 +- lib/third-party-mode.ts | 26 ++ lib/version-check/sender.ts | 9 + lib/web-push.ts | 16 +- locales/de/common.json | 6 + locales/en/common.json | 6 + scripts/lite/build.mjs | 1 + scripts/lite/lib.mjs | 4 + 46 files changed, 978 insertions(+), 30 deletions(-) create mode 100644 lib/__tests__/no-third-party-client.test.ts create mode 100644 lib/__tests__/no-third-party-server.test.ts create mode 100644 lib/admin/third-party-mode.ts create mode 100644 lib/outgoing-hosts.ts create mode 100644 lib/third-party-mode.ts diff --git a/app/(main)/admin/_pages/telemetry.tsx b/app/(main)/admin/_pages/telemetry.tsx index a745ac45c..796a1564d 100644 --- a/app/(main)/admin/_pages/telemetry.tsx +++ b/app/(main)/admin/_pages/telemetry.tsx @@ -15,7 +15,7 @@ import { cn } from '@/lib/utils'; interface TelemetryStatus { consent: 'pending' | 'on' | 'off'; - consentSource: 'env' | 'file'; + consentSource: 'no-third-party' | 'env' | 'file'; endpoint: string; defaultEndpoint: string; consentedAt: string | null; @@ -132,14 +132,16 @@ export function TelemetryPage() { ); } - const envOverridden = status.consentSource === 'env'; + const noThirdParty = status.consentSource === 'no-third-party'; + const envOverridden = status.consentSource === 'env' || noThirdParty; const isOn = status.consent === 'on'; const consentDescription = [ status.consent === 'pending' && 'Initialising - no heartbeats sent yet.', status.consent === 'on' && 'Heartbeats are enabled. Thanks for helping us improve!', status.consent === 'off' && 'Heartbeats are off (default).', - envOverridden && 'Locked by the BULWARK_TELEMETRY env var.', + noThirdParty && 'Locked off: no third-party traffic is on (Organisation > Security).', + envOverridden && !noThirdParty && 'Locked by the BULWARK_TELEMETRY env var.', ].filter(Boolean).join(' '); return ( diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx index e9dcd6d0c..7a700786a 100644 --- a/app/(main)/admin/_pages/version.tsx +++ b/app/(main)/admin/_pages/version.tsx @@ -24,6 +24,7 @@ interface VersionAdminStatus { endpoint: string; defaultEndpoint: string; disabledByEnv: boolean; + disabledByNoThirdPartyTraffic?: boolean; lastCheckedAt: string | null; lastSuccessAt: string | null; nextScheduledAt: string | null; @@ -206,6 +207,11 @@ export function VersionPage() { {data.disabledByEnv ? 'Yes' : 'No'} + {data.disabledByNoThirdPartyTraffic && ( + + Yes + + )} diff --git a/app/api/admin/config/route.ts b/app/api/admin/config/route.ts index 2981b5b8a..33e3cd203 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -10,6 +10,7 @@ import { logger } from '@/lib/logger'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; import { checkExtensionDirectoryUrl } from '@/lib/admin/directory-url'; +import { parseNoThirdPartyEnv } from '@/lib/third-party-mode'; // Strings that count as "no real secret configured" - used so the dashboard // can warn about a placeholder session secret without us ever returning the @@ -99,6 +100,17 @@ export async function PATCH(request: NextRequest) { if (problem) return NextResponse.json({ error: problem }, { status: 400 }); } + // The no-third-party switch is fail-closed: an "on" from the environment + // can't be undone from the panel (lib/admin/third-party-mode.ts). + if ('noThirdPartyTraffic' in updates) { + if (typeof updates.noThirdPartyTraffic !== 'boolean') { + return NextResponse.json({ error: 'noThirdPartyTraffic must be true or false' }, { status: 400 }); + } + if (!updates.noThirdPartyTraffic && parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) { + return NextResponse.json({ error: 'NO_THIRD_PARTY_TRAFFIC is set in the environment; change it there.' }, { status: 409 }); + } + } + // Enum keys only accept their declared values; a stray string would // otherwise be persisted and fail closed (or open) at the read site. for (const [key, value] of Object.entries(updates)) { diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index 4df6da909..f35fd899b 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -12,6 +12,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, historyFor, installedExtensions, installedStateOf, @@ -65,6 +67,9 @@ export async function GET( try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const { slug } = await params; const requested = request.nextUrl.searchParams.get('version'); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 50404e882..19cfb739e 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -32,6 +32,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, installedExtensions, installedStateOf, readBundleBytes, @@ -57,6 +59,9 @@ export async function GET(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const directoryUrl = await getDirectoryUrl(); const query: Record = {}; @@ -105,6 +110,9 @@ export async function POST(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const ip = getClientIP(request); const { slug, version, type, force } = await request.json(); diff --git a/app/api/admin/marketplace/updates/route.ts b/app/api/admin/marketplace/updates/route.ts index 52b14e8cd..7d2643452 100644 --- a/app/api/admin/marketplace/updates/route.ts +++ b/app/api/admin/marketplace/updates/route.ts @@ -8,6 +8,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, installedExtensions, v2Url, type DirectoryAdvisory, @@ -43,6 +45,13 @@ export async function GET(request: NextRequest) { const list = [...installed].map(([id, e]) => ({ id, ...e })); const ask = list.filter((e) => askDirectoryAbout(e.source)).map((e) => ({ slug: e.id, version: e.version })); + // The installed list is still useful with the official directory off (#903). + if (await directoryOff()) { + return NextResponse.json( + { installed: list, ...EMPTY, directoryUrl: null, channel: getDirectoryChannel(), appVersion: APP_VERSION || null, error: DIRECTORY_OFF_MESSAGE }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } const directoryUrl = await getDirectoryUrl(); const channel = getDirectoryChannel(); let answer: UpdatesAnswer = EMPTY; diff --git a/app/api/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts index 192e64b31..daa5c19f8 100644 --- a/app/api/admin/telemetry/route.ts +++ b/app/api/admin/telemetry/route.ts @@ -81,6 +81,12 @@ export async function POST(request: NextRequest) { { status: 409 }, ); } + if (source === 'no-third-party') { + return NextResponse.json( + { error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' }, + { status: 409 }, + ); + } if (body.consent !== 'on' && body.consent !== 'off') { return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 }); } diff --git a/app/api/admin/version/route.ts b/app/api/admin/version/route.ts index 2c63a7b33..004db83d2 100644 --- a/app/api/admin/version/route.ts +++ b/app/api/admin/version/route.ts @@ -9,6 +9,7 @@ import { disabledByEnv, DEFAULT_VERSION_ENDPOINT, } from '@/lib/version-check'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; /** * GET /api/admin/version @@ -27,6 +28,7 @@ export async function GET(request: NextRequest) { endpoint: effectiveEndpoint(state), defaultEndpoint: DEFAULT_VERSION_ENDPOINT, disabledByEnv: disabledByEnv(), + disabledByNoThirdPartyTraffic: await isNoThirdPartyTrafficOn(), lastCheckedAt: state.lastCheckedAt, lastSuccessAt: state.lastSuccessAt, nextScheduledAt: state.nextScheduledAt, diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 1faf393c3..d2293318d 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -11,6 +11,7 @@ import { pickRequestHost, type BrandingOverrideKey, } from '@/lib/admin/domain-branding'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; /** * Runtime configuration endpoint @@ -98,6 +99,8 @@ export async function GET(request: NextRequest) { embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", parentOrigin: configManager.get('parentOrigin', ''), sourceCodeUrl: httpUrlOrEmpty(configManager.get('sourceCodeUrl', '')), + // Browser-side switches of lib/outgoing-hosts.ts read this (#903). + noThirdPartyTraffic: await isNoThirdPartyTrafficOn(), }, { // Runtime settings can change without new asset URLs. Prevent stale diff --git a/app/api/connector/capabilities/route.ts b/app/api/connector/capabilities/route.ts index 4ff6fd1a1..2235298b9 100644 --- a/app/api/connector/capabilities/route.ts +++ b/app/api/connector/capabilities/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { configManager } from '@/lib/admin/config-manager'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; import { TARGET_NAMES } from '@/lib/connector/registry'; /** @@ -28,7 +29,7 @@ import { TARGET_NAMES } from '@/lib/connector/registry'; export async function GET() { await configManager.ensureLoaded(); - if (!configManager.get('connectorEnabled', true)) { + if (!configManager.get('connectorEnabled', true) || await isNoThirdPartyTrafficOn()) { return new NextResponse(null, { status: 404 }); } diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 7dc48fab8..311757fbe 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; // In-memory LRU cache: domain -> { data, contentType, fetchedAt } const CACHE_MAX_SIZE = 1000; @@ -439,6 +440,10 @@ function evictOldest() { } export async function GET(request: NextRequest) { + // Sender icons come from DuckDuckGo: off, before anything else, while the + // server must not contact outside services (#903). + if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('Sender icons'); + const domain = request.nextUrl.searchParams.get('domain'); if (!domain || !isValidDomain(domain)) { diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts index 5da089c90..2715160e7 100644 --- a/app/api/plugin-proxy/route.ts +++ b/app/api/plugin-proxy/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { configManager } from '@/lib/admin/config-manager'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials'; @@ -41,6 +42,8 @@ export async function POST(request: NextRequest) { if (!configManager.getPolicy().features.pluginsEnabled) { return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 }); } + // Every proxied request goes to a host outside this deployment (#903). + if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('The plugin proxy'); const creds = await getStalwartCredentials(request); if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 }); diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 238ecc0e7..b5652688a 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -4,6 +4,8 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { configManager } from '@/lib/admin/config-manager'; import { logger } from '@/lib/logger'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; +import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; /** * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes @@ -32,11 +34,14 @@ export async function GET(request: NextRequest) { // plugin without uninstalling it first. const devIds = new Set(devEntries.map(e => e.plugin.id)); const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id)); + // Plugins that declare network access never reach a browser while the + // server must stay off outside hosts (#903); the rest run as usual. + const noThirdParty = await isNoThirdPartyTrafficOn(); const plugins = [ ...devEntries.map(e => ({ ...e.plugin, dev: true })), ...installedEnabled.map(p => ({ ...p, dev: false })), - ].map(p => ({ + ].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({ id: p.id, name: p.name, version: p.version, diff --git a/app/api/translate/route.ts b/app/api/translate/route.ts index cdf729636..7507303f6 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { readFileEnv } from "@/lib/read-file-env"; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; // Host-side proxy backing the "Translate" plugin (manifest apiPostPaths: // ["/api/translate"]). The plugin slot iframe POSTs { text, target, source, @@ -284,6 +285,10 @@ export async function POST(request: NextRequest) { const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en'; const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto'; const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory'; + // MyMemory is a public service; the admin's own LibreTranslate is not (#903). + if (provider === 'mymemory' && await isNoThirdPartyTrafficOn()) { + return thirdPartyRefusal('Translation through MyMemory'); + } if (!text) { return NextResponse.json({ error: 'No text to translate' }, { status: 400 }); diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 4ab0eba77..0df3ef58d 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -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 { mapsLinksAllowed } from "@/lib/outgoing-hosts"; import { PluginSlot } from "@/components/plugins/plugin-slot"; import { eventToView } from "@/lib/plugin-projection"; @@ -160,7 +161,9 @@ export function EventDetailPopover({ const locationHref = useMemo(() => { if (!locationName) return null; const action = locationAction(locationName, meeting); - return action.kind === "url" ? action.uri : mapsUrl(action.query); + if (action.kind === "url") return action.uri; + // No Google Maps link while third-party traffic is off (#903). + return mapsLinksAllowed() ? mapsUrl(action.query) : null; }, [locationName, meeting]); const [locationCopied, setLocationCopied] = useState(false); const copiedTimer = useRef | null>(null); @@ -493,7 +496,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) diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index da584f836..47b5a70c3 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -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, ]); diff --git a/components/settings/message-list-settings.tsx b/components/settings/message-list-settings.tsx index 2f730c958..7d47fc158 100644 --- a/components/settings/message-list-settings.tsx +++ b/components/settings/message-list-settings.tsx @@ -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 { senderFaviconsAllowed } from '@/lib/outgoing-hosts'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; import { MessageListOrderSettings } from './message-list-order-settings'; import { Button } from '@/components/ui/button'; @@ -216,13 +217,13 @@ export function MessageListSettings() { {/* Sender favicons come from the /api/favicon proxy, absent in the static Lite build (components/ui/avatar.tsx). */} - {!IS_LITE && ( + {!IS_LITE && senderFaviconsAllowed() && ( updateSetting('senderFavicons', checked)} /> )} - {!IS_LITE && senderFavicons && ( + {!IS_LITE && senderFaviconsAllowed() && senderFavicons && (