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
+ Compact
+ ,
+ );
+}
+
+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 (
+
+
+ {t("org_locked")}
+
+ );
+}
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",
)}
>
{label}
- {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 (
+
+ {label}
+ 0 && 'border-destructive')}
+ onChange={(e) => setText(e.target.value)}
+ onBlur={() => {
+ if (text === null) return;
+ onCommit(splitList(text));
+ setText(null);
+ }}
+ />
+ {invalid.length > 0 && (
+ Not valid, ignored when saved: {invalid.join(', ')}
+ )}
+
+ );
+}
+
+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 (
+
+
+
+ Name
+ draft.updateScope(scope.id, (s) => { s.label = e.target.value; })}
+ />
+
+
+
move(-1)} disabled={index === 0} aria-label="Move up" title="Move up"
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30">
+
+
+
move(1)} disabled={index === count - 1} aria-label="Move down" title="Move down"
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30">
+
+
+
draft.update((p) => { p.scopes = (p.scopes ?? []).filter((s) => s.id !== scope.id); })}
+ className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-destructive">
+
+
+
+
+
+ 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`}
+ draft.setScopeId(scope.id)}>
+ {draft.scopeId === scope.id ? 'Selected on the Organisation pages' : 'Edit its settings on the Organisation pages'}
+
+
+
+ );
+}
+
+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 (
+
+
+
+ User address
+ setEmail(e.target.value)} placeholder="anna@example.com"
+ className={cn(inputClass, 'w-full')} spellCheck={false} autoComplete="off" />
+
+
+ Groups the user is a member of
+ setGroupsText(e.target.value)} placeholder="sales@example.com"
+ className={cn(inputClass, 'w-full')} spellCheck={false} autoComplete="off" />
+
+
+ {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.
+ ) : (
+
+
+
+
+ Setting
+ Value
+ Set by
+
+
+
+ {rows.map(([path, source]) => (
+
+ {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 (
+
+
+
+
addScope('domain')}>
+ Add domains
+
+
addScope('group')}>
+ Add groups
+
+
+ {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.setScopeId(EVERYONE)}
+ >
+ Everyone
+
+ {draft.scopes.map((scope) => (
+
draft.setScopeId(scope.id)}
+ >
+ {scope.label}
+
+ ))}
+
+ 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 && (
+
+ Use Everyone's value
+
+ )}
+
+ );
+}
+
+/**
+ * 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 (
+
+
+
+ );
+}
+
+/**
+ * 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 (
+
+
+ void exportFile()}>
+ Export policy.json
+
+ input.current?.click()}
+ >
+ {busy ? : } Import a file
+
+ { 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} )}
+
+ )}
+
+ void apply()}>
+ Replace the current policy
+
+ setPending(null)}>
+ Cancel
+
+
+
+ )}
+
+ );
+}
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 (
+
+
+ {t('sender_label')}
+ {` (${tooltip})`}
+
+ );
+}
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. */}
+ answer(false)} data-external-recipient-back="">
+ {t('back')}
+
+ answer(true)} data-external-recipient-send="">
+ {t('send_anyway')}
+
+
+
+
+ );
+}
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")}
0 ? `${value} ${field.unit}` : field.zero;
+}
+
+/** Organisation > Security > Sessions: the idle timeout and the maximum session length. */
export function SessionsSection({ draft }: { draft: OrgPolicyDraft }) {
- void draft;
return (
-
- Nothing to set here yet.
+
+ {FIELDS.map((field) => {
+ const { value, own, everyone } = draft.orgValue('session', field.key);
+ const inputId = `org-session-${field.key}`;
+ return (
+
+
+
+ 0 ? value : ''}
+ onChange={(e) => {
+ const raw = e.target.value.trim();
+ const parsed = raw === '' ? 0 : Math.round(Number(raw));
+ if (!Number.isFinite(parsed)) return;
+ draft.setOrgValue('session', field.key, Math.min(field.max, Math.max(0, parsed)));
+ }}
+ className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+ {field.unit}
+
+
draft.clearOrgValue('session', field.key)}
+ />
+
+
+ );
+ })}
);
}
diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx
new file mode 100644
index 000000000..f693eb685
--- /dev/null
+++ b/components/session/__tests__/session-timeout-guard.test.tsx
@@ -0,0 +1,232 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS } from '../session-timeout-guard';
+import { useAuthStore } from '@/stores/auth-store';
+import { useAccountStore, type AccountEntry } from '@/stores/account-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
+import { consumeSessionEnd } from '@/lib/session-policy/sign-out-reason';
+import { LAST_ACTIVITY_KEY } from '@/lib/session-policy/activity-channel';
+
+/** BroadcastChannel between "tabs" of one test. */
+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;
+ }
+}
+
+const START = new Date('2026-10-28T09:00:00Z').getTime();
+const SECOND = 1000;
+
+function fakeClient(email: string) {
+ return {
+ getUsername: () => email,
+ getSharedAccounts: () => [],
+ getAccountInfo: () => undefined,
+ getPrincipalTypes: async () => ({}),
+ };
+}
+
+function account(id: string, email: string, signedInAt = START): AccountEntry {
+ return {
+ id, label: email, serverUrl: 'https://mail.example.org', username: email, authMode: 'basic', cookieSlot: 0,
+ rememberMe: true, displayName: email, email, avatarColor: '#336699', lastLoginAt: START, signedInAt,
+ isConnected: true, hasError: false, isDefault: true,
+ };
+}
+
+function policyWith(session: { idleMinutes: number; maxHours: number; warnSeconds: number }, scopes: SettingsPolicy['scopes'] = []): SettingsPolicy {
+ return { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, session }, scopes };
+}
+
+let logoutAll: ReturnType;
+const realGetAllConnectedClients = useAuthStore.getState().getAllConnectedClients;
+
+async function signIn(policy: SettingsPolicy, email = 'org-alice@example.org') {
+ usePolicyStore.setState({ rawPolicy: policy });
+ useAccountStore.setState({ accounts: [account('alice', email)], activeAccountId: 'alice' });
+ useAuthStore.setState({
+ isAuthenticated: true,
+ isDemoMode: false,
+ activeAccountId: 'alice',
+ client: fakeClient(email) as never,
+ logoutAll: logoutAll as never,
+ });
+ render( );
+ // The limits are worked out asynchronously (the policy subject).
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+}
+
+const advance = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); });
+
+describe('session timeout guard', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(START);
+ vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
+ FakeBroadcastChannel.instances = [];
+ localStorage.clear();
+ sessionStorage.clear();
+ resetComposeFlushRegistry();
+ logoutAll = vi.fn(async () => {});
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null, getAllConnectedClients: realGetAllConnectedClients });
+ });
+
+ it('warns at idle minus warnSeconds and goes away on "Stay signed in"', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+
+ await advance(29 * SECOND);
+ expect(screen.queryByText('idle_title')).toBeNull();
+ await advance(1 * SECOND);
+ expect(screen.getByText('idle_title')).toBeTruthy();
+
+ fireEvent.click(screen.getByText('stay'));
+ expect(screen.queryByText('idle_title')).toBeNull();
+ await advance(29 * SECOND);
+ expect(screen.queryByText('idle_title')).toBeNull();
+ expect(logoutAll).not.toHaveBeenCalled();
+ });
+
+ it('starts the idle time again on activity in this tab', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+
+ await advance(20 * SECOND);
+ fireEvent.keyDown(window, { key: 'a' });
+ await advance(29 * SECOND);
+ expect(screen.queryByText('idle_title')).toBeNull();
+ await advance(1 * SECOND);
+ expect(screen.getByText('idle_title')).toBeTruthy();
+ });
+
+ it('starts the idle time again on activity in another tab', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ const otherTab = new FakeBroadcastChannel('bulwark-session-activity');
+
+ await advance(40 * SECOND);
+ expect(screen.getByText('idle_title')).toBeTruthy();
+ await act(async () => { otherTab.postMessage({ at: Date.now() }); });
+ expect(screen.queryByText('idle_title')).toBeNull();
+
+ await advance(59 * SECOND);
+ expect(logoutAll).not.toHaveBeenCalled();
+ });
+
+ it('tells the other tabs about activity here', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ const otherTab = new FakeBroadcastChannel('bulwark-session-activity');
+ const heard = vi.fn();
+ otherTab.onmessage = (event) => heard((event.data as { at: number }).at);
+
+ await advance(10 * SECOND);
+ fireEvent.pointerDown(window);
+ expect(heard).toHaveBeenCalledWith(START + 10 * SECOND);
+ expect(localStorage.getItem(LAST_ACTIVITY_KEY)).toBe(String(START + 10 * SECOND));
+ });
+
+ it('saves the drafts, then signs every account out with the reason, at zero', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ const order: string[] = [];
+ registerComposeFlush(async () => {
+ await new Promise((r) => setTimeout(r, 500));
+ order.push('draft saved');
+ });
+ logoutAll.mockImplementation(async () => { order.push('signed out'); });
+
+ await advance(60 * SECOND);
+ expect(screen.getByText('signing_out')).toBeTruthy();
+ expect(logoutAll).not.toHaveBeenCalled();
+ await advance(500);
+
+ expect(order).toEqual(['draft saved', 'signed out']);
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
+ });
+
+ it('signs out after the flush timeout when a draft save hangs', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ registerComposeFlush(() => new Promise(() => {}));
+
+ await advance(60 * SECOND);
+ await advance(DRAFT_FLUSH_TIMEOUT_MS - 1);
+ expect(logoutAll).not.toHaveBeenCalled();
+ await advance(1);
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ });
+
+ it('uses the strictest of the signed-in accounts', async () => {
+ const policy = policyWith({ idleMinutes: 0, maxHours: 0, warnSeconds: 30 }, [
+ { id: 'example', label: 'example.org', match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 10 } } } },
+ { id: 'two', label: 'org-two.test', match: { domains: ['org-two.test'] }, policy: { organisation: { session: { idleMinutes: 2 } } } },
+ ]);
+ usePolicyStore.setState({ rawPolicy: policy });
+ useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org'), account('dave', 'org-dave@org-two.test')], activeAccountId: 'alice' });
+ const alice = fakeClient('org-alice@example.org');
+ const dave = fakeClient('org-dave@org-two.test');
+ useAuthStore.setState({
+ isAuthenticated: true,
+ isDemoMode: false,
+ activeAccountId: 'alice',
+ client: alice as never,
+ logoutAll: logoutAll as never,
+ getAllConnectedClients: (() => new Map([['alice', alice], ['dave', dave]])) as never,
+ });
+ render( );
+ await advance(0);
+
+ // Two minutes (dave's domain), not ten (alice's).
+ await advance(2 * 60 * SECOND - 1);
+ expect(logoutAll).not.toHaveBeenCalled();
+ await advance(1);
+ expect(logoutAll).toHaveBeenCalledTimes(1);
+ expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 2 });
+ });
+
+ it('gives an account in a scope without a timeout none', async () => {
+ const policy = policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }, [
+ { id: 'two', label: 'org-two.test', match: { domains: ['org-two.test'] }, policy: { organisation: { session: { idleMinutes: 0 } } } },
+ ]);
+ await signIn(policy, 'org-dave@org-two.test');
+
+ await advance(10 * 60 * SECOND);
+ expect(screen.queryByText('idle_title')).toBeNull();
+ expect(logoutAll).not.toHaveBeenCalled();
+ });
+
+ it('warns before the maximum session length and signs out at it', async () => {
+ usePolicyStore.setState({ rawPolicy: policyWith({ idleMinutes: 0, maxHours: 1, warnSeconds: 60 }) });
+ useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org', START - 50 * 60 * SECOND)], activeAccountId: 'alice' });
+ useAuthStore.setState({
+ isAuthenticated: true, isDemoMode: false, activeAccountId: 'alice',
+ client: fakeClient('org-alice@example.org') as never, logoutAll: logoutAll as never,
+ getAllConnectedClients: (() => new Map()) as never,
+ });
+ render( );
+ await advance(0);
+
+ await advance(9 * 60 * SECOND);
+ expect(screen.getByText('max_title')).toBeTruthy();
+ fireEvent.click(screen.getByText('dismiss'));
+ expect(screen.queryByText('max_title')).toBeNull();
+ await advance(60 * SECOND);
+ await advance(0);
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 });
+ });
+});
diff --git a/components/session/session-timeout-dialog.tsx b/components/session/session-timeout-dialog.tsx
new file mode 100644
index 000000000..917b77085
--- /dev/null
+++ b/components/session/session-timeout-dialog.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { useEffect, useId, useState } from "react";
+import { useTranslations } from "next-intl";
+import { useFocusTrap } from "@/hooks/use-focus-trap";
+import { Button } from "@/components/ui/button";
+import { Clock, Loader2 } from "@/components/icons";
+import type { SessionEndKind } from "@/lib/session-policy/session-timer";
+
+interface SessionTimeoutDialogProps {
+ /** 'ending': the session is over, drafts are being saved. */
+ mode: SessionEndKind | 'ending';
+ deadline: number | null;
+ /** Hours of the maximum session length, for its message. */
+ maxHours?: number;
+ onStay: () => void;
+ onDismiss: () => void;
+ onSignOut: () => void;
+}
+
+function secondsLeft(deadline: number | null): number {
+ return deadline === null ? 0 : Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
+}
+
+/**
+ * The warning before an organisation session limit signs the user out. For
+ * the idle timeout it asks whether to stay; the maximum session length
+ * can't be extended, so that one only says when it ends.
+ */
+export function SessionTimeoutDialog({ mode, deadline, maxHours, onStay, onDismiss, onSignOut }: SessionTimeoutDialogProps) {
+ const t = useTranslations("session_limits");
+ const id = useId();
+ const [seconds, setSeconds] = useState(() => secondsLeft(deadline));
+ const dialogRef = useFocusTrap({
+ isActive: true,
+ onEscape: mode === "idle" ? onStay : mode === "max" ? onDismiss : undefined,
+ restoreFocus: true,
+ });
+
+ useEffect(() => {
+ setSeconds(secondsLeft(deadline));
+ if (mode === "ending") return;
+ const interval = setInterval(() => setSeconds(secondsLeft(deadline)), 250);
+ return () => clearInterval(interval);
+ }, [deadline, mode]);
+
+ const title = mode === "max" ? t("max_title") : mode === "idle" ? t("idle_title") : t("signing_out");
+
+ return (
+
+
+
+
+ {mode === "ending" ? : }
+
+
+
{title}
+ {mode !== "ending" && (
+
+ {mode === "max"
+ ? t("max_message", { hours: maxHours ?? 0, seconds })
+ : t("idle_message", { seconds })}
+
+ )}
+
+
+ {mode !== "ending" && (
+
+ {t("sign_out_now")}
+ {mode === "idle"
+ ? {t("stay")}
+ : {t("dismiss")} }
+
+ )}
+
+
+ );
+}
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