diff --git a/.github/workflows/static-lite.yml b/.github/workflows/static-lite.yml index 149e12036..9def1243f 100644 --- a/.github/workflows/static-lite.yml +++ b/.github/workflows/static-lite.yml @@ -81,6 +81,12 @@ jobs: LITE_DEMO_MODE: ${{ inputs.demo }} run: npm run build:lite + # Every host literal in the bundle must be a placeholder, the configured + # JMAP server, a built-in host the no-third-party switch turns off, or a + # string listed as never requested (#903). + - name: Scan the bundle for outside hosts + run: node scripts/lite/scan-hosts.mjs out + - name: Package zip env: REF_NAME: ${{ github.ref_name }} diff --git a/CHANGELOG.md b/CHANGELOG.md index e9bab4990..42bb670be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ # Changelog +## Unreleased + +### Features + +- **Organisation**: Admins can set an organisation signature with placeholders for each user's name, address, company, job title, department and phone numbers, per domain or group, and lock it so it replaces everyone's own signature. Users fill in their details once under Settings > Account. Lines whose details are empty are left out (#908) +- **Organisation**: Admins can add a legal disclaimer that goes below every message when it is sent, never into drafts, and is shown read-only in the composer +- **Organisation**: Admins can give every user a library of templates, per domain or group, that users pick under "Organisation" in the template picker and can copy but not change +- **Templates**: On Stalwart 1.0, members of a group mailbox share team templates, and users' own templates are kept in their account so they follow them to every browser. Templates already in a browser move over on the first sign-in. Admins can lock team templates or leave users only the organisation and team templates +- **Tags**: On Stalwart 1.0, members of a group mailbox see the same tag names and colors on its mail, in the list, the tag picker, the sidebar and the unified inbox. They edit them under Settings > Folders & tags, where tags already on the group's mail can be given a name. Admins can lock the list per domain or group, for example so only team leads change it (#470, #1310) +- **Organisation**: Admins can label mail from outside the organisation with an "External" chip and have users confirm before sending to or inviting external recipients (composer, quick reply and meeting invitations), per domain or group. Partner domains and contacts count as internal. This replaces the External Mail Warning plugin, whose safe domains can be imported. Plugins can read `api.host.features.externalSenderLabel` and `externalRecipientWarning` +- **Organisation**: Admins can sign users out after a time without activity or a fixed time after sign-in, per domain or group. A countdown with "Stay signed in" comes first, open and minimised messages and quick replies are saved as drafts, and the login page says why +- **Delegates**: Settings > Sharing gives a colleague read, or read and write, access to all mail folders or just the Inbox, calendars and contacts in one step, lists who has access to what, and removes access everywhere at once. Admins can hide it per domain or group and limit every share dialog to read access on Organisation > Sharing +- **Recover deleted items**: On Stalwart Enterprise with archiving turned on, users bring back mail they deleted for good under Settings > Recover deleted items, or from a link in Trash when there is something to recover. Restored mail goes back to the Inbox unread. Admins can limit how many days back it lists or hide it per domain or group (#1319) +- **Privacy**: One switch, `NO_THIRD_PARTY_TRAFFIC` or Organisation > Security, stops every connection to services outside your own servers: telemetry, the version check, the built-in push relay, the official extension directory, sender icons, MyMemory translation, map links, OpenPGP key lookups and remote pictures in mail. Plugins that need network access don't run, and the page's CSP allows only the hosts you configured. Push registrations move to your own relay. The admin page lists what stays reachable (#903) +- **Team inbox**: The new official Team inbox plugin lets members of a team mailbox assign messages to each other, mark them done, and filter by "Assigned to me" and "Open" +- **Plugins**: Plugins can add keyword filters to the message list, shown as chips above it, put icons on row badges, show actions only in team mailboxes or for some keywords, change several keywords in one step, see the user's own account and which mailboxes are team mailboxes, and keep a small shared value in a team mailbox on Stalwart 1.0. Looking people up in the directory needs the new `directory:read` permission. Plugins can no longer set `$draft`, `$junk`, `$notjunk` or `$phishing` + ## 1.18.0 (2026-10-26) 1.18.0 works with Stalwart 1.0, released today, as well as with 0.16. Sign-in, calendars, contacts, files and sending were adapted to what changed in 1.0, and a Stalwart that was moved from 0.16 to 1.0 with Stalwart's migration tool keeps working with the same open tabs. It also fixes the findings of a security review of the whole webmail, more places where an action in a view that mixes accounts could hit another account's message with the same id, and a series of calendar bugs around repeating events. Plugins can show their own search results and a count on their buttons, quick reply can be turned off, and a new device offers to restore your saved account list. diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index a44a30286..d34288e8a 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 { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; @@ -20,6 +21,8 @@ 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 { AccountTemplateSync } from "@/components/templates/account-template-sync"; import { IS_LITE } from "@/lib/lite"; import { setRequestLocale } from "next-intl/server"; import { locales } from "@/i18n/routing"; @@ -62,8 +65,11 @@ export default async function LocaleLayout({ + + {children} + diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index d5b972efa..43b3080e6 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -35,6 +35,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 { @@ -178,6 +179,7 @@ function VersionBadge() { function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); + const tSessionLimits = useTranslations("session_limits"); const tTheme = useTranslations("settings.appearance.theme"); const params = useParams(); const searchParams = useSearchParams(); @@ -247,6 +249,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); @@ -372,6 +376,11 @@ function LoginPageContent() { sessionStorage.removeItem('session_expired'); } } catch { /* sessionStorage unavailable */ } + const ended = consumeSessionEnd(); + if (ended) { + setSessionEnd(ended); + setSessionExpired(true); + } }, []); useEffect(() => { @@ -1269,8 +1278,15 @@ 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")} + {sessionEnd?.draftsUnsaved && ( + {tSessionLimits("drafts_not_saved")} + )}

+ + +
+ +
+ DOMAIN_RE.test(d)} + onCommit={(domains) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, domains }; })} + /> + GROUP_RE.test(g)} + onCommit={(groups) => draft.updateScope(scope.id, (s) => { s.match = { ...s.match, groups }; })} + /> +
+
+ {overrides === 0 ? 'No settings of its own yet' : `${overrides} setting${overrides === 1 ? '' : 's'} of its own`} + +
+ + ); +} + +function sourceLabel(source: string, scopes: PolicyScope[]): string { + if (source === 'global') return 'Everyone'; + return scopes.find((s) => s.id === source)?.label ?? source; +} + +function valueAt(policy: Record, path: string): unknown { + const [root, ...rest] = path.split('.'); + // Keys of features, restrictions and defaults never contain dots; organisation paths are section.key. + let value: unknown = policy[root]; + for (const part of rest) value = (value as Record | undefined)?.[part]; + return value; +} + +function formatValue(value: unknown): string { + if (typeof value === 'string') return value.length > 60 ? `${value.slice(0, 60)}…` : value || '(empty)'; + const json = JSON.stringify(value); + return json && json.length > 80 ? `${json.slice(0, 80)}…` : String(json); +} + +function EffectivePolicy({ draft }: { draft: OrgPolicyDraft }) { + const [email, setEmail] = useState(''); + const [groupsText, setGroupsText] = useState(''); + const subject = email.includes('@') ? { email: email.trim().toLowerCase(), groups: splitList(groupsText) } : null; + const resolved = useMemo( + () => (draft.policy && subject ? resolvePolicy(draft.policy, subject) : null), + // eslint-disable-next-line react-hooks/exhaustive-deps + [draft.policy, subject?.email, subject?.groups.join(',')], + ); + const rows = resolved + ? Object.entries(resolved.sources).sort(([a], [b]) => a.localeCompare(b)) + : []; + return ( +
+
+ + +
+ {resolved && ( +
+

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

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

Everything is at Bulwark's defaults.

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

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

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

None yet: every user gets Everyone's settings.

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

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

+ )} +
+ + + + + + +
+ ); +} diff --git a/app/(main)/admin/_pages/policy.tsx b/app/(main)/admin/_pages/policy.tsx index c8e88a1c0..2ce7fdaee 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 Security).', + envOverridden && !noThirdParty && 'Locked by the BULWARK_TELEMETRY env var.', ].filter(Boolean).join(' '); return ( diff --git a/app/(main)/admin/_pages/version.tsx b/app/(main)/admin/_pages/version.tsx index 102962781..6093aa948 100644 --- a/app/(main)/admin/_pages/version.tsx +++ b/app/(main)/admin/_pages/version.tsx @@ -24,6 +24,7 @@ interface VersionAdminStatus { endpoint: string; defaultEndpoint: string; disabledByEnv: boolean; + disabledByNoThirdPartyTraffic?: boolean; lastCheckedAt: string | null; lastSuccessAt: string | null; nextScheduledAt: string | null; @@ -205,6 +206,11 @@ export function VersionPage() { {data.disabledByEnv ? 'Yes' : 'No'} + {data.disabledByNoThirdPartyTraffic && ( + + Yes + + )} 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/__tests__/policy-scopes-route.test.ts b/app/api/admin/__tests__/policy-scopes-route.test.ts new file mode 100644 index 000000000..3c27c1320 --- /dev/null +++ b/app/api/admin/__tests__/policy-scopes-route.test.ts @@ -0,0 +1,80 @@ +// @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('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); + }); + + 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/config/route.ts b/app/api/admin/config/route.ts index 2981b5b8a..33e3cd203 100644 --- a/app/api/admin/config/route.ts +++ b/app/api/admin/config/route.ts @@ -10,6 +10,7 @@ import { logger } from '@/lib/logger'; import { hasSessionSecret } from '@/lib/auth/session-secret'; import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response'; import { checkExtensionDirectoryUrl } from '@/lib/admin/directory-url'; +import { parseNoThirdPartyEnv } from '@/lib/third-party-mode'; // Strings that count as "no real secret configured" - used so the dashboard // can warn about a placeholder session secret without us ever returning the @@ -99,6 +100,17 @@ export async function PATCH(request: NextRequest) { if (problem) return NextResponse.json({ error: problem }, { status: 400 }); } + // The no-third-party switch is fail-closed: an "on" from the environment + // can't be undone from the panel (lib/admin/third-party-mode.ts). + if ('noThirdPartyTraffic' in updates) { + if (typeof updates.noThirdPartyTraffic !== 'boolean') { + return NextResponse.json({ error: 'noThirdPartyTraffic must be true or false' }, { status: 400 }); + } + if (!updates.noThirdPartyTraffic && parseNoThirdPartyEnv(process.env.NO_THIRD_PARTY_TRAFFIC)) { + return NextResponse.json({ error: 'NO_THIRD_PARTY_TRAFFIC is set in the environment; change it there.' }, { status: 409 }); + } + } + // Enum keys only accept their declared values; a stray string would // otherwise be persisted and fail closed (or open) at the read site. for (const [key, value] of Object.entries(updates)) { diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts index 4df6da909..f35fd899b 100644 --- a/app/api/admin/marketplace/[slug]/route.ts +++ b/app/api/admin/marketplace/[slug]/route.ts @@ -12,6 +12,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, historyFor, installedExtensions, installedStateOf, @@ -65,6 +67,9 @@ export async function GET( try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const { slug } = await params; const requested = request.nextUrl.searchParams.get('version'); diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts index 50404e882..19cfb739e 100644 --- a/app/api/admin/marketplace/route.ts +++ b/app/api/admin/marketplace/route.ts @@ -32,6 +32,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, installedExtensions, installedStateOf, readBundleBytes, @@ -57,6 +59,9 @@ export async function GET(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const directoryUrl = await getDirectoryUrl(); const query: Record = {}; @@ -105,6 +110,9 @@ export async function POST(request: NextRequest) { try { const result = await requireAdminAuth(request); if ('error' in result) return result.error; + if (await directoryOff()) { + return NextResponse.json({ error: DIRECTORY_OFF_MESSAGE, code: 'no-third-party-traffic' }, { status: 403 }); + } const ip = getClientIP(request); const { slug, version, type, force } = await request.json(); diff --git a/app/api/admin/marketplace/updates/route.ts b/app/api/admin/marketplace/updates/route.ts index 52b14e8cd..7d2643452 100644 --- a/app/api/admin/marketplace/updates/route.ts +++ b/app/api/admin/marketplace/updates/route.ts @@ -8,6 +8,8 @@ import { directoryJson, getDirectoryChannel, getDirectoryUrl, + directoryOff, + DIRECTORY_OFF_MESSAGE, installedExtensions, v2Url, type DirectoryAdvisory, @@ -43,6 +45,13 @@ export async function GET(request: NextRequest) { const list = [...installed].map(([id, e]) => ({ id, ...e })); const ask = list.filter((e) => askDirectoryAbout(e.source)).map((e) => ({ slug: e.id, version: e.version })); + // The installed list is still useful with the official directory off (#903). + if (await directoryOff()) { + return NextResponse.json( + { installed: list, ...EMPTY, directoryUrl: null, channel: getDirectoryChannel(), appVersion: APP_VERSION || null, error: DIRECTORY_OFF_MESSAGE }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } const directoryUrl = await getDirectoryUrl(); const channel = getDirectoryChannel(); let answer: UpdatesAnswer = EMPTY; diff --git a/app/api/admin/outgoing-hosts/route.ts b/app/api/admin/outgoing-hosts/route.ts new file mode 100644 index 000000000..d7eaeccce --- /dev/null +++ b/app/api/admin/outgoing-hosts/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { requireAdminAuth } from '@/lib/admin/session'; +import { configManager } from '@/lib/admin/config-manager'; +import { isConfigReadOnly } from '@/lib/admin/paths'; +import { getPluginRegistry } from '@/lib/admin/plugin-registry'; +import { noThirdPartyTrafficState } from '@/lib/admin/third-party-mode'; +import { listReachableHosts } from '@/lib/admin/reachable-hosts'; +import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; +import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry'; +import { logger } from '@/lib/logger'; + +/** + * GET /api/admin/outgoing-hosts - the "no third-party traffic" switch with + * where its value comes from, the built-in outside hosts it turns off, the + * admin-configured hosts that stay reachable, and the installed plugins it + * refuses (#903). Organisation > Security renders this; the switch itself is + * saved through PATCH /api/admin/config like every other config key. + */ +export async function GET(request: NextRequest) { + try { + const auth = await requireAdminAuth(request); + if ('error' in auth) return auth.error; + + await configManager.ensureLoaded(); + const state = noThirdPartyTrafficState(); + const policy = configManager.getPolicy(); + const registry = await getPluginRegistry().catch(() => ({ plugins: [] })); + + return NextResponse.json( + { + noThirdPartyTraffic: state.on, + source: state.source, + // The panel can't turn it off when the environment pins it on, and + // can't change it at all on a read-only config. + readOnly: isConfigReadOnly(), + envLocked: state.source === 'env', + builtIn: OUTGOING_HOSTS, + reachable: listReachableHosts({ get: (key) => configManager.get(key), policy, env: process.env }), + networkPlugins: registry.plugins + .filter((plugin) => plugin.enabled && pluginDeclaresNetwork(plugin)) + .map((plugin) => ({ id: plugin.id, name: plugin.name })), + }, + { headers: { 'Cache-Control': 'no-store' } }, + ); + } catch (error) { + logger.error('Outgoing hosts read error', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} diff --git a/app/api/admin/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/app/api/admin/policy/route.ts b/app/api/admin/policy/route.ts index 6e707577e..145260bd2 100644 --- a/app/api/admin/policy/route.ts +++ b/app/api/admin/policy/route.ts @@ -3,8 +3,9 @@ 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 { canSeeInstanceDetails } from '@/lib/auth/instance-details'; +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'; /** @@ -28,12 +29,30 @@ 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, { - headers: { 'Cache-Control': 'no-store' }, + // 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 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', ...(domains !== null ? { [POLICY_DOMAINS_HEADER]: domains } : {}) }, }); } catch (error) { logger.error('Policy read error', { error: error instanceof Error ? error.message : 'Unknown error' }); @@ -72,6 +91,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/admin/telemetry/route.ts b/app/api/admin/telemetry/route.ts index 192e64b31..daa5c19f8 100644 --- a/app/api/admin/telemetry/route.ts +++ b/app/api/admin/telemetry/route.ts @@ -81,6 +81,12 @@ export async function POST(request: NextRequest) { { status: 409 }, ); } + if (source === 'no-third-party') { + return NextResponse.json( + { error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' }, + { status: 409 }, + ); + } if (body.consent !== 'on' && body.consent !== 'off') { return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 }); } diff --git a/app/api/admin/version/route.ts b/app/api/admin/version/route.ts index 2c63a7b33..004db83d2 100644 --- a/app/api/admin/version/route.ts +++ b/app/api/admin/version/route.ts @@ -9,6 +9,7 @@ import { disabledByEnv, DEFAULT_VERSION_ENDPOINT, } from '@/lib/version-check'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; /** * GET /api/admin/version @@ -27,6 +28,7 @@ export async function GET(request: NextRequest) { endpoint: effectiveEndpoint(state), defaultEndpoint: DEFAULT_VERSION_ENDPOINT, disabledByEnv: disabledByEnv(), + disabledByNoThirdPartyTraffic: await isNoThirdPartyTrafficOn(), lastCheckedAt: state.lastCheckedAt, lastSuccessAt: state.lastSuccessAt, nextScheduledAt: state.nextScheduledAt, diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 3b0bbb5dd..e68645a30 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -23,6 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation'; import { clearPairReauthInStore } from '@/lib/auth/pair-reauth'; import { loginClientAddress } from '@/lib/auth/client-address'; import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; +import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age'; function sessionCookieOptions() { return { @@ -73,6 +74,37 @@ function getSlot(request: NextRequest): number { return slot; } +/** + * The organisation's maximum session length for a remembered session + * (lib/auth/session-age.ts). A cookie from before sign-in times were + * recorded counts as signed in when the server first enforced the limits, + * a fixed time, so a kept copy of it runs out like any other. Returns the + * sign-in time, or the limit when the session is too old: its cookies are + * cleared then. + */ +async function checkSessionAge( + cookieStore: Awaited>, + slot: number, + credentials: SessionCredentials, +): Promise<{ signedInAt: number } | { maxHours: number }> { + const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince(); + // An impersonation login is `target%master`; the policy is the target's. + const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]); + if (isPastMaxAge(signedInAt, maxHours)) { + cookieStore.delete(sessionCookieName(slot)); + clearStalwartAuthContextInStore(cookieStore, slot); + return { maxHours }; + } + return { signedInAt }; +} + +function sessionTooOld(maxHours: number) { + return NextResponse.json( + { error: SESSION_TOO_OLD, maxHours }, + { status: 401, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } }, + ); +} + export async function POST(request: NextRequest) { // CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax, // so a cross-site top-level POST would otherwise reach this handler. @@ -162,7 +194,9 @@ export async function POST(request: NextRequest) { throw error; } refund(); - const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password); + // A remembered sign-in starts the maximum session length. + const signedInAt = Date.now(); + const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password, signedInAt); const cookieStore = await cookies(); cookieStore.set(cookieName, token, sessionCookieOptions()); setStalwartAuthContextInStore(cookieStore, slot, { @@ -176,7 +210,8 @@ export async function POST(request: NextRequest) { const warning = insecureCookieHint(request); if (warning) logger.warn(`session: ${warning}`); - return NextResponse.json(warning ? { ok: true, warning } : { ok: true }); + // The browser counts the maximum session length from the server's time. + return NextResponse.json(warning ? { ok: true, warning, signedInAt } : { ok: true, signedInAt }); } catch (error) { if (error instanceof JmapAuthVerificationError) { return NextResponse.json( @@ -202,12 +237,15 @@ export async function GET(request: NextRequest) { } const credentials = decryptSession(token); - if (!credentials) { + if (!credentials || holdsMasterPassword(credentials)) { cookieStore.delete(cookieName); clearStalwartAuthContextInStore(cookieStore, slot); return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); } + const age = await checkSessionAge(cookieStore, slot, credentials); + if ('maxHours' in age) return sessionTooOld(age.maxHours); + setStalwartAuthContextInStore(cookieStore, slot, { serverUrl: credentials.serverUrl, username: credentials.username, @@ -217,7 +255,7 @@ export async function GET(request: NextRequest) { // Only return non-sensitive fields. Use PUT to retrieve full credentials. const { serverUrl, username } = credentials; return NextResponse.json( - { serverUrl, username }, + { serverUrl, username, signedInAt: age.signedInAt }, { headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } }, ); } catch (error) { @@ -258,13 +296,17 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: 'Invalid session' }, { status: 401 }); } + const age = await checkSessionAge(cookieStore, slot, credentials); + if ('maxHours' in age) return sessionTooOld(age.maxHours); + setStalwartAuthContextInStore(cookieStore, slot, { serverUrl: credentials.serverUrl, username: credentials.username, authHeader: sessionAuthHeader(credentials), }); - return NextResponse.json(credentials, { + // The browser counts the maximum session length from the server's time. + return NextResponse.json({ ...credentials, signedInAt: age.signedInAt }, { headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' }, }); } catch (error) { diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts index de4a8f8de..f79fdb6b9 100644 --- a/app/api/auth/sso/complete/route.ts +++ b/app/api/auth/sso/complete/route.ts @@ -14,6 +14,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config'; import { storeIdToken } from '@/lib/oauth/end-session'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; +import { writeSignInStamp } from '@/lib/auth/session-age'; const SSO_PENDING_COOKIE = 'sso_pending'; const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes @@ -105,6 +106,7 @@ export async function POST(request: NextRequest) { // Exchange code for tokens const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId); + const signedInAt = Date.now(); // For the mobile handoff flow the tokens are handed back to the app // verbatim - we deliberately don't write any cookies on the webmail @@ -115,6 +117,8 @@ export async function POST(request: NextRequest) { if (tokens.refresh_token) { const cookieName = refreshTokenCookieName(slot); cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); + // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts). + writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }); } const serverCookieName = refreshTokenServerCookieName(slot); if (pendingServerId) { @@ -166,6 +170,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in, + signed_in_at: signedInAt, }); } catch (error) { // Clean up pending cookie on any error diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index 0d07ec38b..fe25532d3 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -1,7 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { logger } from '@/lib/logger'; import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth'; +import { cookies } from 'next/headers'; import { setStalwartAuthContext } from '@/lib/stalwart/auth-context'; +import { readSignInStamp, writeSignInStamp } from '@/lib/auth/session-age'; +import { refreshTokenCookieName } from '@/lib/oauth/tokens'; import { configManager } from '@/lib/admin/config-manager'; import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { recordLogin } from '@/lib/telemetry/login-tracker'; @@ -101,6 +104,16 @@ export async function POST(request: NextRequest) { // Just signed in: an older refusal of this credential no longer applies. recordStoredCredentialStatus(authHeader, 200); + // A token sign-in's stamp (lib/auth/session-age.ts) knows no account: + // the token routes never see one. The first verified account names it, + // so the maximum session length of its domain applies on renewal. + const cookieStore = await cookies(); + const refreshToken = cookieStore.get(refreshTokenCookieName(slot))?.value; + const stamp = refreshToken ? readSignInStamp(cookieStore, slot, refreshToken) : null; + if (refreshToken && stamp && !stamp.account) { + writeSignInStamp(cookieStore, slot, { signedInAt: stamp.signedInAt, refreshToken, account: accountName }); + } + await setStalwartAuthContext(slot, { serverUrl: normalizedServerUrl, username, diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index 05e7ec29c..b2ea53c3e 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -30,6 +30,15 @@ import { } from '@/lib/oauth/end-session'; import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; +import { + clearSignInStamp, + isPastMaxAge, + maxSessionHoursFor, + SESSION_TOO_OLD, + tokenSignedInAt, + writeSignInStamp, +} from '@/lib/auth/session-age'; +import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; function parseSlot(raw: string | null): number | null { if (raw === null) return null; @@ -142,16 +151,21 @@ export async function POST(request: NextRequest) { const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId); + // A sign-in: the maximum session length counts from here, on the server + // and, from this answer, in the browser. + const signedInAt = Date.now(); const response = NextResponse.json({ access_token: tokens.access_token, expires_in: tokens.expires_in, + signed_in_at: signedInAt, }); const cookieStore = await cookies(); if (tokens.refresh_token) { const cookieName = refreshTokenCookieName(slot); cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); + writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }); } const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600, [tokens.refresh_token, serverId]); storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken, serverId]); @@ -189,6 +203,25 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ error: 'No refresh token' }, { status: 401 }); } + // The organisation's maximum session length (lib/auth/session-age.ts). + // A refresh token without a matching stamp (from before stamps, or one + // whose stamp was deleted) counts from the fixed time the server first + // enforced the limits, never from now. + const age = await tokenSignedInAt(cookieStore, slot, refreshToken); + if (age) { + const context = readStalwartAuthContextFromStore(cookieStore, slot); + const account = age.stamp?.account ?? context?.accountName ?? context?.username ?? null; + const maxHours = await maxSessionHoursFor(account); + if (isPastMaxAge(age.signedInAt, maxHours)) { + await signOutSlot(cookieStore, slot, request.nextUrl.basePath, false); + clearStalwartAuthContextInStore(cookieStore, slot); + return NextResponse.json({ error: SESSION_TOO_OLD, maxHours }, { status: 401 }); + } + // Bind the time to this token from now on. + if (!age.stamp) writeSignInStamp(cookieStore, slot, { signedInAt: age.signedInAt, refreshToken, ...(account ? { account } : {}) }); + } + const signedIn = age ? { signed_in_at: age.signedInAt } : {}; + // A session restore calls this to get its token back, not because the // current one expired. Serving the cached token avoids spending a refresh // the IdP may legitimately reject: Rauthy stamps refresh tokens with @@ -204,6 +237,7 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ access_token: cached.accessToken, expires_in: cached.expiresIn, + ...signedIn, }); } } @@ -252,6 +286,14 @@ export async function PUT(request: NextRequest) { if (tokens.refresh_token) { cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions()); + // The stamp follows the rotated token, with the same sign-in time and lifetime. + if (age) { + writeSignInStamp(cookieStore, slot, { + signedInAt: age.signedInAt, + refreshToken: tokens.refresh_token, + ...(age.stamp?.account ? { account: age.stamp.account } : {}), + }); + } } const expiresIn = tokens.expires_in || 3600; @@ -265,6 +307,7 @@ export async function PUT(request: NextRequest) { return NextResponse.json({ access_token: tokens.access_token, expires_in: expiresIn, + ...signedIn, }); } catch (error) { logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' }); @@ -307,6 +350,7 @@ async function signOutSlot( for (const name of [cookieName, refreshTokenServerCookieName(slot), accessTokenCookieName(slot)]) { if (cookieStore.get(name)) cookieStore.delete(name); } + clearSignInStamp(cookieStore, slot); if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath); return endSessionUrl; } diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 6172b7283..9b61b428f 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login'; import { loginClientAddress } from '@/lib/auth/client-address'; import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; +import { writeSignInStamp } from '@/lib/auth/session-age'; /** * Exchange a password + (optional) TOTP code for OAuth tokens. @@ -208,11 +209,15 @@ async function storeAndRespond( rememberMe: boolean, ): Promise { const cookieStore = await cookies(); + // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts). + const signedInAt = Date.now(); if (tokens.refresh_token) { const cookieName = refreshTokenCookieName(slot); // Without "Remember me" the sign-in ends with the browser session. const { maxAge: _maxAge, ...sessionOptions } = getCookieOptions(); - cookieStore.set(cookieName, tokens.refresh_token, rememberMe ? getCookieOptions() : sessionOptions); + const options = rememberMe ? getCookieOptions() : sessionOptions; + cookieStore.set(cookieName, tokens.refresh_token, options); + writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }, options); } const serverCookieName = refreshTokenServerCookieName(slot); if (serverId) { @@ -225,5 +230,6 @@ async function storeAndRespond( access_token: tokens.access_token, expires_in: tokens.expires_in || 3600, has_refresh_token: !!tokens.refresh_token, + signed_in_at: signedInAt, }); } diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 1faf393c3..d2293318d 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -11,6 +11,7 @@ import { pickRequestHost, type BrandingOverrideKey, } from '@/lib/admin/domain-branding'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; /** * Runtime configuration endpoint @@ -98,6 +99,8 @@ export async function GET(request: NextRequest) { embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'", parentOrigin: configManager.get('parentOrigin', ''), sourceCodeUrl: httpUrlOrEmpty(configManager.get('sourceCodeUrl', '')), + // Browser-side switches of lib/outgoing-hosts.ts read this (#903). + noThirdPartyTraffic: await isNoThirdPartyTrafficOn(), }, { // Runtime settings can change without new asset URLs. Prevent stale diff --git a/app/api/connector/capabilities/route.ts b/app/api/connector/capabilities/route.ts index 4ff6fd1a1..2235298b9 100644 --- a/app/api/connector/capabilities/route.ts +++ b/app/api/connector/capabilities/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import { configManager } from '@/lib/admin/config-manager'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; import { TARGET_NAMES } from '@/lib/connector/registry'; /** @@ -28,7 +29,7 @@ import { TARGET_NAMES } from '@/lib/connector/registry'; export async function GET() { await configManager.ensureLoaded(); - if (!configManager.get('connectorEnabled', true)) { + if (!configManager.get('connectorEnabled', true) || await isNoThirdPartyTrafficOn()) { return new NextResponse(null, { status: 404 }); } diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 7b8128fdb..d437585ce 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -4,6 +4,7 @@ import { createDemoFileNodes } from '@/lib/demo/fixtures/files'; import { generateDemoId } from '@/lib/demo/demo-utils'; import type { FileNode } from '@/lib/jmap/types'; import JSZip from 'jszip'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; /** * Mock JMAP server for local development. @@ -2035,9 +2036,14 @@ function handleVacationResponseGet(_args: MethodArgs, callId: string): MethodRes return ['VacationResponse/get', { accountId: ACCOUNT_ID, state: nextState(), list: [{ id: 'vacation-1', isEnabled: false, fromDate: null, toDate: null, subject: null, textBody: null, htmlBody: null }], notFound: [] }, callId]; } +// The sample photos come from randomuser.me: none while third-party traffic +// is off (#903). Set per request in POST, which can await the config. +let samplePhotos = true; + function handleContactCardGet(args: MethodArgs, callId: string): MethodResult { const ids = args.ids as string[] | undefined; - const list = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts; + const found = ids ? contacts.filter((c) => ids.includes(c.id)) : contacts; + const list = samplePhotos ? found : found.map(({ media: _media, ...rest }) => rest); return ['ContactCard/get', { accountId: ACCOUNT_ID, state: nextState(), list, notFound: [] }, callId]; } @@ -2826,6 +2832,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ // JMAP API endpoint if (joined === 'api') { + samplePhotos = !(await isNoThirdPartyTrafficOn()); try { const body = await request.json(); const methodCalls = body.methodCalls as Array<[string, MethodArgs, string]>; diff --git a/app/api/favicon/route.ts b/app/api/favicon/route.ts index 7dc48fab8..311757fbe 100644 --- a/app/api/favicon/route.ts +++ b/app/api/favicon/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; // In-memory LRU cache: domain -> { data, contentType, fetchedAt } const CACHE_MAX_SIZE = 1000; @@ -439,6 +440,10 @@ function evictOldest() { } export async function GET(request: NextRequest) { + // Sender icons come from DuckDuckGo: off, before anything else, while the + // server must not contact outside services (#903). + if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('Sender icons'); + const domain = request.nextUrl.searchParams.get('domain'); if (!domain || !isValidDomain(domain)) { diff --git a/app/api/plugin-proxy/route.ts b/app/api/plugin-proxy/route.ts index 5da089c90..2715160e7 100644 --- a/app/api/plugin-proxy/route.ts +++ b/app/api/plugin-proxy/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { configManager } from '@/lib/admin/config-manager'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; import { CREDENTIAL_ID_RE } from '@/lib/plugin-secrets/credentials'; @@ -41,6 +42,8 @@ export async function POST(request: NextRequest) { if (!configManager.getPolicy().features.pluginsEnabled) { return NextResponse.json({ error: 'Plugins are turned off', code: 'disabled' }, { status: 403 }); } + // Every proxied request goes to a host outside this deployment (#903). + if (await isNoThirdPartyTrafficOn()) return thirdPartyRefusal('The plugin proxy'); const creds = await getStalwartCredentials(request); if (!creds) return NextResponse.json({ error: 'Not authenticated', code: 'unauthenticated' }, { status: 401 }); diff --git a/app/api/plugin-secrets/__tests__/route.test.ts b/app/api/plugin-secrets/__tests__/route.test.ts index 60b85a064..7f33684cb 100644 --- a/app/api/plugin-secrets/__tests__/route.test.ts +++ b/app/api/plugin-secrets/__tests__/route.test.ts @@ -13,7 +13,7 @@ vi.mock('@/lib/stalwart/credentials', () => ({ getStalwartCredentials: async () => (signedIn ? { ...claimed, serverUrl: 'https://mail.example.org', authHeader: 'x' } : null), })); vi.mock('@/lib/admin/config-manager', () => ({ - configManager: { ensureLoaded: async () => {}, getPolicy: () => ({ features: { pluginsEnabled } }) }, + configManager: { ensureLoaded: async () => {}, get: () => undefined, getPolicy: () => ({ features: { pluginsEnabled } }) }, })); vi.mock('@/lib/security/same-origin', async () => { const { NextResponse } = await import('next/server'); diff --git a/app/api/plugins/route.ts b/app/api/plugins/route.ts index 238ecc0e7..b5652688a 100644 --- a/app/api/plugins/route.ts +++ b/app/api/plugins/route.ts @@ -4,6 +4,8 @@ import { getPluginRegistry, getThemeRegistry } from '@/lib/admin/plugin-registry import { listDevPlugins } from '@/lib/admin/plugin-dev'; import { configManager } from '@/lib/admin/config-manager'; import { logger } from '@/lib/logger'; +import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode'; +import { pluginDeclaresNetwork } from '@/lib/outgoing-hosts'; /** * GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes @@ -32,11 +34,14 @@ export async function GET(request: NextRequest) { // plugin without uninstalling it first. const devIds = new Set(devEntries.map(e => e.plugin.id)); const installedEnabled = pluginRegistry.plugins.filter(p => p.enabled && !devIds.has(p.id)); + // Plugins that declare network access never reach a browser while the + // server must stay off outside hosts (#903); the rest run as usual. + const noThirdParty = await isNoThirdPartyTrafficOn(); const plugins = [ ...devEntries.map(e => ({ ...e.plugin, dev: true })), ...installedEnabled.map(p => ({ ...p, dev: false })), - ].map(p => ({ + ].filter(p => !(noThirdParty && pluginDeclaresNetwork(p))).map(p => ({ id: p.id, name: p.name, version: p.version, diff --git a/app/api/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/app/api/translate/route.ts b/app/api/translate/route.ts index cdf729636..7507303f6 100644 --- a/app/api/translate/route.ts +++ b/app/api/translate/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; import { readFileEnv } from "@/lib/read-file-env"; import { getStalwartCredentials } from '@/lib/stalwart/credentials'; +import { isNoThirdPartyTrafficOn, thirdPartyRefusal } from '@/lib/admin/third-party-mode'; // Host-side proxy backing the "Translate" plugin (manifest apiPostPaths: // ["/api/translate"]). The plugin slot iframe POSTs { text, target, source, @@ -284,6 +285,10 @@ export async function POST(request: NextRequest) { const target = typeof body.target === 'string' && body.target.trim() ? body.target.trim() : 'en'; const source = typeof body.source === 'string' && body.source.trim() ? body.source.trim() : 'auto'; const provider: Provider = body.provider === 'libretranslate' ? 'libretranslate' : 'mymemory'; + // MyMemory is a public service; the admin's own LibreTranslate is not (#903). + if (provider === 'mymemory' && await isNoThirdPartyTrafficOn()) { + return thirdPartyRefusal('Translation through MyMemory'); + } if (!text) { return NextResponse.json({ error: 'No text to translate' }, { status: 400 }); diff --git a/components/admin/__tests__/third-party-section.test.tsx b/components/admin/__tests__/third-party-section.test.tsx new file mode 100644 index 000000000..8ecadf4ac --- /dev/null +++ b/components/admin/__tests__/third-party-section.test.tsx @@ -0,0 +1,129 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { listReachableHosts } from '@/lib/admin/reachable-hosts'; +import { OUTGOING_HOSTS } from '@/lib/admin/outgoing-hosts-registry'; +import type { OrgPolicyDraft } from '@/components/admin/org/use-org-policy-draft'; + +const apiFetch = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/browser-navigation', async (orig) => ({ + ...(await orig()), + apiFetch: (...args: unknown[]) => apiFetch(...args), +})); + +const { ThirdPartySection } = await import('@/components/admin/org/sections/third-party-section'); + +const setMessage = vi.fn(); +const draft = { setMessage } as unknown as OrgPolicyDraft; + +function state(overrides: Record = {}) { + return { + noThirdPartyTraffic: false, + source: 'default', + readOnly: false, + envLocked: false, + builtIn: OUTGOING_HOSTS, + reachable: [{ kind: 'JMAP server', host: 'mail.corp.example', setIn: 'JMAP_SERVER_URL', side: 'both' }], + networkPlugins: [], + ...overrides, + }; +} + +function switchOf() { + return within(screen.getByTestId('third-party-switch')).getByRole('switch'); +} + +beforeEach(() => { + apiFetch.mockReset(); + setMessage.mockReset(); +}); + +describe('ThirdPartySection', () => { + it('saves the switch as config through /api/admin/config', async () => { + let current = state(); + apiFetch.mockImplementation(async (url: string, init?: RequestInit) => { + if (url === '/api/admin/config' && init?.method === 'PATCH') { + current = state({ noThirdPartyTraffic: true, source: 'admin' }); + return Response.json({ ok: true }); + } + return Response.json(current); + }); + render(); + await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'false')); + expect(screen.getByTestId('third-party-scope-note').textContent).toMatch(/whole server, not per domain or group/); + expect(screen.getByTestId('third-party-reachable').textContent).toContain('mail.corp.example'); + + await act(async () => { fireEvent.click(switchOf()); }); + expect(apiFetch).toHaveBeenCalledWith('/api/admin/config', expect.objectContaining({ + method: 'PATCH', body: JSON.stringify({ noThirdPartyTraffic: true }), + })); + await waitFor(() => expect(switchOf()).toHaveAttribute('aria-checked', 'true')); + expect(screen.getByText('config.json')).toBeInTheDocument(); + // No relay of their own: push needs one now. + expect(screen.getByText(/Push notifications now need a relay you run yourself/)).toBeInTheDocument(); + }); + + it('is read-only when the environment sets it', async () => { + apiFetch.mockImplementation(async () => Response.json(state({ noThirdPartyTraffic: true, source: 'env', envLocked: true }))); + render(); + await waitFor(() => expect(switchOf()).toBeDisabled()); + expect(screen.getByText('NO_THIRD_PARTY_TRAFFIC')).toBeInTheDocument(); + expect(screen.getByText(/Set by NO_THIRD_PARTY_TRAFFIC in the environment/)).toBeInTheDocument(); + }); + + it('is read-only on a read-only config', async () => { + apiFetch.mockImplementation(async () => Response.json(state({ readOnly: true }))); + render(); + await waitFor(() => expect(switchOf()).toBeDisabled()); + expect(screen.getByText(/The configuration is read-only/)).toBeInTheDocument(); + }); +}); + +describe('listReachableHosts', () => { + it('lists the hosts the deployment configured and leaves the built-in ones out', () => { + const config: Record = { + jmapServerUrl: 'https://mail.corp.example', + jmapServers: [{ id: 'b', label: 'Branch', url: 'https://jmap.branch.example', oauth: { issuerUrl: 'https://sso.corp.example' } }], + oauthEnabled: false, + oauthIssuerUrl: 'https://ignored.example', + extensionDirectoryUrl: 'https://extensions.bulwarkmail.org', + wopiClientUrl: 'https://office.corp.example', + loginLogoLightUrl: '/branding/Bulwark_Logo_Color.svg', + appLogoLightUrl: 'https://cdn.corp.example/logo.svg', + }; + const hosts = listReachableHosts({ + get: (key) => config[key], + policy: { + pushRelays: [{ label: 'Own', url: 'https://push.corp.example' }, { label: 'Built-in', url: 'https://notifications.relay.bulwarkmail.org' }], + pushRelayUrl: '', + defaultSidebarApps: [{ id: 'w', name: 'Wiki', url: 'https://wiki.corp.example', icon: 'Globe', openMode: 'inline', showOnMobile: true }], + }, + env: { LIBRETRANSLATE_URL: 'https://translate.corp.example' }, + }); + expect(hosts.map((h) => `${h.kind}: ${h.host}`)).toEqual([ + 'JMAP server: mail.corp.example', + 'JMAP server: jmap.branch.example', + 'OAuth issuer: sso.corp.example', + 'Push relay: push.corp.example', + 'Sidebar app: wiki.corp.example', + 'Document editor (WOPI): office.corp.example', + 'Translation (LibreTranslate): translate.corp.example', + 'Branding image: cdn.corp.example', + 'Sidebar apps users add: any address your users enter', + 'Calendar subscriptions: any address your users enter', + ]); + }); + + it('names the relay browsers were built with, flags a different one in the environment, and lists what users enter', () => { + const hosts = listReachableHosts({ + get: (key) => ({ allowCustomJmapEndpoint: true } as Record)[key], + policy: { pushRelays: [], pushRelayUrl: '', defaultSidebarApps: [], features: { calendarEnabled: false, sidebarAppsEnabled: false } }, + env: { NEXT_PUBLIC_PUSH_RELAY_URL: 'https://push.runtime.example' }, + buildRelayUrl: 'https://push.build.example', + }); + expect(hosts.map((h) => [h.host, h.setIn, !!h.note])).toEqual([ + ['push.build.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (build)', false], + ['push.runtime.example', 'NEXT_PUBLIC_PUSH_RELAY_URL (environment)', true], + ['any address your users enter', 'ALLOW_CUSTOM_JMAP_ENDPOINT', true], + ]); + }); +}); diff --git a/components/admin/admin-shell.tsx b/components/admin/admin-shell.tsx index e0f6f0286..eac06295f 100644 --- a/components/admin/admin-shell.tsx +++ b/components/admin/admin-shell.tsx @@ -7,17 +7,22 @@ import { ArrowLeft, ArrowUpCircle, Activity, + Building2, ChevronRight, KeyRound, LayoutDashboard, + LayoutTemplate, LogOut, Palette, + PenLine, Puzzle, Scale, ScrollText, Search, Server, + Share2, Shield, + ShieldCheck, Store, SwatchBook, X, @@ -43,6 +48,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, @@ -53,6 +63,7 @@ const PAGE_ICONS: Record = { const GROUP_LABELS: Record = { top: null, + organisation: 'Organisation', extensions: 'Extensions', system: 'System', }; @@ -159,7 +170,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/__tests__/external-mail-section.test.tsx b/components/admin/org/__tests__/external-mail-section.test.tsx new file mode 100644 index 000000000..29a1ff1b3 --- /dev/null +++ b/components/admin/org/__tests__/external-mail-section.test.tsx @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// Organisation > Security > External mail: the switches and the domain list +// go into the draft at the chosen scope, and the External Mail Warning +// plugin's safe domains can be copied in as an unsaved change. + +const puts: Array> = []; +let stored: Record = {}; +let plugins: Array> = []; +let pluginConfig: Record = {}; +vi.mock('@/lib/browser-navigation', () => ({ + apiFetch: vi.fn(async (url: string, init?: RequestInit) => { + if (url === '/api/admin/plugins') return new Response(JSON.stringify(plugins)); + if (url === '/api/admin/plugins/external-mail-warning/config') return new Response(JSON.stringify(pluginConfig)); + if (init?.method === 'PUT') { + const body = JSON.parse(String(init.body)); + puts.push(body); + stored = body; + return new Response('{"ok":true}'); + } + return new Response(JSON.stringify(stored)); + }), + withBasePath: (p: string) => p, + getPathPrefix: () => '', +})); + +import { ExternalMailSection } from '../sections/external-mail-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: { externalMail: { domains: ['partner.com'] } }, + scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +async function renderSection() { + render(); + await waitFor(() => expect(screen.getByTestId('org-external-sender-label')).toBeInTheDocument()); +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + plugins = []; + pluginConfig = {}; + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('External mail section', () => { + it('starts with both switches off and saves what the admin turns on', async () => { + await renderSection(); + expect(screen.getByTestId('org-external-sender-label').getAttribute('aria-checked')).toBe('false'); + expect(screen.getByTestId('org-external-recipient-warning').getAttribute('aria-checked')).toBe('false'); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + fireEvent.click(screen.getByTestId('org-external-recipient-warning')); + await act(async () => { await latest!.save(); }); + expect((puts[0].organisation as { externalMail: unknown }).externalMail).toMatchObject({ senderLabel: true, recipientWarning: true, domains: ['partner.com'] }); + }); + + it('writes a scope override only into that scope', async () => { + await renderSection(); + act(() => latest!.setScopeId('two')); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ externalMail: { senderLabel: true } }); + expect(latest!.policy?.organisation?.externalMail.senderLabel).toBe(false); + }); + + it('takes the typed domain list apart when the field is left', async () => { + await renderSection(); + const field = screen.getByTestId('org-external-domains'); + fireEvent.change(field, { target: { value: 'partner.com, Sister.example\n@third.test' } }); + fireEvent.blur(field); + expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'sister.example', 'third.test']); + expect(screen.queryByTestId('org-external-domains-rejected')).toBeNull(); + }); + + it('stores an IDN in ASCII, shows it in Unicode and names entries that are not domains', async () => { + await renderSection(); + const field = screen.getByTestId('org-external-domains') as HTMLTextAreaElement; + fireEvent.change(field, { target: { value: 'bücher.de\nnot_a_domain, x' } }); + fireEvent.blur(field); + expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['xn--bcher-kva.de']); + expect(screen.getByTestId('org-external-domains-rejected').textContent).toContain('not_a_domain, x'); + await waitFor(() => expect(field.value).toBe('bücher.de'), { timeout: 5000 }); + }); + + it('offers no import and no note without the plugin', async () => { + await renderSection(); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + expect(screen.queryByTestId('org-external-import-plugin')).toBeNull(); + expect(screen.queryByTestId('org-external-plugin-note')).toBeNull(); + }); + + it('asks to update an old plugin while the warnings are on and imports its safe domains as a draft change', async () => { + plugins = [{ id: 'external-mail-warning', version: '1.5.1' }]; + pluginConfig = { safeDomains: 'partner.com, acme.example\nsub.acme.example' }; + await renderSection(); + await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument()); + expect(screen.queryByTestId('org-external-plugin-note')).toBeNull(); + + fireEvent.click(screen.getByTestId('org-external-recipient-warning')); + expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('Update or remove the External Mail Warning plugin'); + + await act(async () => { fireEvent.click(screen.getByTestId('org-external-import-plugin')); }); + await waitFor(() => expect(latest!.orgValue('externalMail', 'domains').value).toEqual(['partner.com', 'acme.example', 'sub.acme.example'])); + expect(latest!.dirty).toBe(true); + expect(puts).toHaveLength(0); + }); + + it('only suggests removing a plugin that steps back by itself', async () => { + plugins = [{ id: 'external-mail-warning', version: '1.6.0' }]; + await renderSection(); + await waitFor(() => expect(screen.getByTestId('org-external-import-plugin')).toBeInTheDocument()); + fireEvent.click(screen.getByTestId('org-external-sender-label')); + expect(screen.getByTestId('org-external-plugin-note').textContent).toContain('You can remove the External Mail Warning plugin'); + }); +}); diff --git a/components/admin/org/__tests__/recovery-section.test.tsx b/components/admin/org/__tests__/recovery-section.test.tsx new file mode 100644 index 000000000..afc867313 --- /dev/null +++ b/components/admin/org/__tests__/recovery-section.test.tsx @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; + +// Organisation > Sharing > Deleted items: the days limit per scope, the +// deletedItemsRecovery hide switch, and what Stalwart needs for it to work. + +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 { RecoverySection } from '../sections/recovery-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + organisation: {}, + scopes: [{ id: 'two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +async function renderSection() { + render(); + await waitFor(() => expect(screen.getByTestId('org-recovery-requirements')).toBeInTheDocument()); +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('Recover deleted items section', () => { + it('says it needs the Enterprise license and archiving in Stalwart, with the docs', async () => { + await renderSection(); + const note = screen.getByTestId('org-recovery-requirements'); + expect(note.textContent).toContain('Stalwart Enterprise license'); + expect(note.textContent).toContain('archiveDeletedItemsFor'); + expect(screen.getByRole('link', { name: 'How to set it up' }).getAttribute('href')).toBe('https://bulwarkmail.org/docs/guides/organisation/recover-deleted-items'); + }); + + it('saves the days limit, with empty meaning everything the server keeps', async () => { + await renderSection(); + const field = screen.getByLabelText('Show deleted mail of the last (days)') as HTMLInputElement; + expect(field.value).toBe(''); + fireEvent.change(field, { target: { value: '14' } }); + await act(async () => { await latest!.save(); }); + expect((puts[0].organisation as { recovery: unknown }).recovery).toEqual({ days: 14 }); + fireEvent.change(field, { target: { value: '' } }); + expect(latest!.orgValue('recovery', 'days').value).toBe(0); + }); + + it('writes a scope override only into that scope', async () => { + await renderSection(); + act(() => latest!.setScopeId('two')); + fireEvent.change(screen.getByLabelText('Show deleted mail of the last (days)'), { target: { value: '7' } }); + expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ recovery: { days: 7 } }); + expect(latest!.policy?.organisation?.recovery.days).toBe(0); + }); + + it('hides the feature for users through deletedItemsRecovery', async () => { + await renderSection(); + fireEvent.click(screen.getByRole('checkbox', { name: 'Hide' })); + expect(latest!.restriction('deletedItemsRecovery').value).toEqual({ hidden: true }); + }); +}); diff --git a/components/admin/org/__tests__/shared-tags-section.test.tsx b/components/admin/org/__tests__/shared-tags-section.test.tsx new file mode 100644 index 000000000..621994ded --- /dev/null +++ b/components/admin/org/__tests__/shared-tags-section.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; + +// Organisation > Sharing > Shared tags: one sentence and the lock for the +// `sharedTags` restriction, written at the scope chosen in the scope bar. + +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 { SharedTagsSection } from '../sections/shared-tags-section'; +import { useOrgPolicyDraft, useOrgScopeStore, EVERYONE } from '../use-org-policy-draft'; +import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys'; +import { resolvePolicy } from '@/lib/admin/policy-scopes'; + +const POLICY = { + restrictions: {}, features: {}, defaults: {}, themePolicy: {}, + forceEnabledPlugins: [], approvedPlugins: [], forceEnabledThemes: [], + scopes: [{ id: 'leads', label: 'Leads', match: { groups: ['leads@example.org'] }, policy: {} }], +}; + +let latest: ReturnType | null = null; +function Harness() { + const draft = useOrgPolicyDraft('test'); + latest = draft; + if (draft.loading) return null; + return ; +} + +beforeEach(() => { + puts.length = 0; + stored = JSON.parse(JSON.stringify(POLICY)); + latest = null; + useOrgScopeStore.setState({ scopeId: EVERYONE }); +}); + +describe('Shared tags section', () => { + it('registers sharedTags on the Sharing page, lock only', () => { + expect(RESTRICTION_KEYS.find((def) => def.key === 'sharedTags')).toMatchObject({ page: 'org-sharing', modes: ['locked'] }); + }); + + it('locks shared tags for everyone and unlocks them in a group scope', async () => { + render(); + const section = await screen.findByTestId('org-shared-tags-section'); + expect(within(section).queryByText('Hide')).not.toBeInTheDocument(); + + fireEvent.click(within(section).getByRole('checkbox')); + expect(latest!.policy?.restrictions.sharedTags).toEqual({ locked: true }); + + act(() => latest!.setScopeId('leads')); + await waitFor(() => expect(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox')).toBeChecked()); + fireEvent.click(within(screen.getByTestId('org-shared-tags-section')).getByRole('checkbox')); + // In a scope an empty restriction is a real override: it unlocks. + expect(latest!.policy?.scopes?.[0].policy.restrictions?.sharedTags).toEqual({}); + const lead = resolvePolicy(latest!.policy!, { email: 'anna@example.org', groups: ['leads@example.org'] }).policy; + const member = resolvePolicy(latest!.policy!, { email: 'bob@example.org', groups: ['team@example.org'] }).policy; + expect(lead.restrictions.sharedTags?.locked).not.toBe(true); + expect(member.restrictions.sharedTags?.locked).toBe(true); + + await act(async () => { await latest!.save(); }); + expect(puts[0]).toMatchObject({ + restrictions: { sharedTags: { locked: true } }, + scopes: [{ id: 'leads', policy: { restrictions: { sharedTags: {} } } }], + }); + }); +}); diff --git a/components/admin/org/org-page.tsx b/components/admin/org/org-page.tsx new file mode 100644 index 000000000..fbb0cf8ac --- /dev/null +++ b/components/admin/org/org-page.tsx @@ -0,0 +1,178 @@ +'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 { SectionNav, SettingItem } from '@/components/settings/settings-section'; +import { adminPageDef, type AdminPageId } from '@/lib/admin/admin-nav'; +import type { RestrictionKeyDef } from '@/lib/admin/restriction-keys'; +import type { PolicyScope } from '@/lib/admin/policy-scopes'; +import { cn } from '@/lib/utils'; +import { EVERYONE, type OrgPolicyDraft } from './use-org-policy-draft'; + +export const chipClass = (selected: boolean) => cn( + 'h-8 px-3 rounded-md text-sm font-medium transition-colors max-w-full truncate', + selected ? 'bg-primary text-primary-foreground' : 'bg-muted text-foreground hover:bg-accent hover:text-accent-foreground', +); + +/** "example.com, sales@example.com": what a scope matches, for labels. */ +export function scopeMatchSummary(scope: PolicyScope): string { + const parts = [...(scope.match.domains ?? []), ...(scope.match.groups ?? [])]; + return parts.length > 0 ? parts.join(', ') : 'matches nobody yet'; +} + +/** + * Which part of the organisation an Organisation page edits: Everyone, or + * one domain or group scope. Chips on wide screens; they wrap at phone width. + */ +export function ScopeBar({ draft }: { draft: OrgPolicyDraft }) { + return ( +
+
+ Applies to + + {draft.scopes.map((scope) => ( + + ))} + + Manage domains and groups + +
+ {draft.scope && ( +

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

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

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

+ ); +} + +/** + * Lock, hide and allowed values of one restriction key, at the selected + * scope. Every user-facing option of the Organisation features is + * restricted through this, never through a flag of its own. + */ +export function RestrictionRow({ draft, def }: { draft: OrgPolicyDraft; def: RestrictionKeyDef }) { + const { value, own, everyone } = draft.restriction(def.key); + const current = value ?? {}; + const modes = def.modes ?? ['locked', 'hidden']; + const set = (patch: Partial) => draft.setRestriction(def.key, { ...current, ...patch }); + const describe = (r: typeof current | undefined) => + r?.hidden ? 'hidden' : r?.locked ? 'locked' : r?.allowedValues?.length ? `limited to ${r.allowedValues.join(', ')}` : 'users choose'; + return ( + +
+
+ {modes.includes('locked') && ( + + )} + {modes.includes('hidden') && ( + + )} + {modes.includes('allowedValues') && def.allowedValues?.map((option) => { + const allowed = current.allowedValues as string[] | undefined; + const checked = !allowed || allowed.length === 0 || allowed.includes(option); + return ( + + ); + })} +
+ draft.setRestriction(def.key, undefined)} /> +
+
+ ); +} + +/** + * The frame of an Organisation page: title and the one sentence on what it + * does for users, the scope bar, messages, and the save bar. + */ +export function OrgPage({ pageId, draft, children, scoped = true }: { + pageId: AdminPageId; + draft: OrgPolicyDraft; + children: ReactNode; + /** Pages whose settings can be set per scope show the scope bar. */ + scoped?: boolean; +}) { + const page = adminPageDef(pageId); + return ( +
+ + {draft.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/policy-file-section.tsx b/components/admin/org/policy-file-section.tsx new file mode 100644 index 000000000..bbb7e737a --- /dev/null +++ b/components/admin/org/policy-file-section.tsx @@ -0,0 +1,144 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { Download, Loader2, Upload } from '@/components/icons'; +import { SettingsSection } from '@/components/settings/settings-section'; +import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/browser-navigation'; +import type { KeyChanges, PolicyChangeSummary } from '@/lib/admin/policy-diff'; +import type { OrgPolicyDraft } from './use-org-policy-draft'; + +function describe(label: string, changes: KeyChanges): string | null { + const parts = [ + changes.added.length ? `${changes.added.length} added` : null, + changes.changed.length ? `${changes.changed.length} changed` : null, + changes.removed.length ? `${changes.removed.length} removed` : null, + ].filter(Boolean); + return parts.length ? `${label}: ${parts.join(', ')}` : null; +} + +function summaryLines(summary: PolicyChangeSummary): string[] { + return [ + describe('Domains and groups', summary.scopes), + describe('Organisation settings', summary.organisation), + describe('Locked and hidden settings', summary.restrictions), + describe('Features', summary.features), + describe('Defaults for new users', summary.defaults), + summary.other.length ? `Also: ${summary.other.join(', ')}` : null, + ].filter((line): line is string => !!line); +} + +/** + * Export and import of the whole user policy as policy.json, so the policy + * can live in git, move to another instance or go next to a Lite build. + * An import shows what it changes before it replaces anything. + */ +export function PolicyFileSection({ draft }: { draft: OrgPolicyDraft }) { + const input = useRef(null); + const [pending, setPending] = useState<{ name: string; text: string; summary: PolicyChangeSummary } | null>(null); + const [busy, setBusy] = useState(false); + + async function exportFile() { + const res = await apiFetch('/api/admin/policy/file'); + if (!res.ok) { + draft.setMessage({ type: 'error', text: 'Could not export the policy' }); + return; + } + const blob = await res.blob(); + const name = /filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') ?? '')?.[1] ?? 'bulwark-policy.json'; + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = name; + link.click(); + URL.revokeObjectURL(url); + } + + async function pick(file: File) { + setBusy(true); + try { + const text = await file.text(); + const res = await apiFetch('/api/admin/policy/file?preview=1', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: text }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + draft.setMessage({ type: 'error', text: data.error || 'Could not read the file' }); + return; + } + setPending({ name: file.name, text, summary: data.summary }); + } finally { + setBusy(false); + if (input.current) input.current.value = ''; + } + } + + async function apply() { + if (!pending) return; + setBusy(true); + try { + const res = await apiFetch('/api/admin/policy/file', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: pending.text }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + draft.setMessage({ type: 'error', text: data.error || 'Could not import the file' }); + return; + } + setPending(null); + await draft.reload(); + draft.setMessage({ type: 'success', text: `Imported ${pending.name}.` }); + } finally { + setBusy(false); + } + } + + return ( + +
+ + + { const file = e.target.files?.[0]; if (file) void pick(file); }} + /> +
+ {draft.dirty &&

Save or discard your changes first.

} + {pending && ( +
+

Importing {pending.name}

+ {pending.summary.unchanged ? ( +

It matches the current policy; nothing would change.

+ ) : ( +
    + {summaryLines(pending.summary).map((line) =>
  • {line}
  • )} +
+ )} +
+ + +
+
+ )} +
+ ); +} diff --git a/components/admin/org/sections/delegates-section.tsx b/components/admin/org/sections/delegates-section.tsx new file mode 100644 index 000000000..1fecb99c5 --- /dev/null +++ b/components/admin/org/sections/delegates-section.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { SettingsSection } from '@/components/settings/settings-section'; +import { RESTRICTION_KEYS } from '@/lib/admin/restriction-keys'; +import { RestrictionRow } from '../org-page'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +const DELEGATION = RESTRICTION_KEYS.find((def) => def.key === 'delegation')!; +const SHARING_RIGHTS = RESTRICTION_KEYS.find((def) => def.key === 'sharingRights')!; + +/** Organisation > Sharing > Delegates: whether users may delegate, and which rights any share dialog offers. */ +export function DelegatesSection({ draft }: { draft: OrgPolicyDraft }) { + const rights = draft.restriction(SHARING_RIGHTS.key).value; + const noneTicked = Array.isArray(rights?.allowedValues) && rights.allowedValues.length === 0; + return ( + + + + {noneTicked && ( +

+ With neither ticked nothing is limited, so users can give both. Tick "read" to allow read access only. +

+ )} +

+ Both limit what Bulwark offers. Stalwart doesn't enforce them, so users can still share through other clients. Shared + mailboxes of groups are yours to manage in Stalwart. +

+
+ ); +} diff --git a/components/admin/org/sections/external-mail-section.tsx b/components/admin/org/sections/external-mail-section.tsx new file mode 100644 index 000000000..e77c38efc --- /dev/null +++ b/components/admin/org/sections/external-mail-section.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Download, Info } from '@/components/icons'; +import { Button } from '@/components/ui/button'; +import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section'; +import { AdminLink } from '@/components/admin/admin-link'; +import { parseDomainList } from '@/lib/admin/organisation-policy'; +import { toUnicodeDomain } from '@/lib/idn'; +import { apiFetch } from '@/lib/browser-navigation'; +import { EXTERNAL_MAIL_WARNING_PLUGIN_ID } from '@/lib/plugin-sandbox/host-features'; +import { InheritNote } from '../org-page'; +import type { OrgPolicyDraft } from '../use-org-policy-draft'; + +/** The first release of the plugin core took over from that steps back on its own. */ +export const PLUGIN_STEPS_BACK_FROM = '1.6.0'; + +function older(version: string, than: string): boolean { + const a = version.split(/[-+]/)[0].split('.').map(Number); + const b = than.split(/[-+]/)[0].split('.').map(Number); + for (let i = 0; i < 3; i++) if ((a[i] || 0) !== (b[i] || 0)) return (a[i] || 0) < (b[i] || 0); + return false; +} + +type InstalledPlugin = { version: string }; + +/** The External Mail Warning plugin when the admin installed it, else null. */ +function useInstalledPlugin(): InstalledPlugin | null { + const [plugin, setPlugin] = useState(null); + useEffect(() => { + let cancelled = false; + void (async () => { + try { + const res = await apiFetch('/api/admin/plugins'); + const list = res.ok ? ((await res.json()) as Array<{ id: string; version?: string }>) : []; + const found = Array.isArray(list) ? list.find((p) => p.id === EXTERNAL_MAIL_WARNING_PLUGIN_ID) : undefined; + if (!cancelled) setPlugin(found ? { version: found.version ?? '' } : null); + } catch { + if (!cancelled) setPlugin(null); + } + })(); + return () => { cancelled = true; }; + }, []); + return plugin; +} + +const onOff = (v: boolean) => (v ? 'on' : 'off'); + +/** + * Organisation > Security > External mail: the "External" sender label, + * the confirm before sending outside, which domains are internal, and the + * hand-over from the External Mail Warning plugin. + */ +export function ExternalMailSection({ draft }: { draft: OrgPolicyDraft }) { + const senderLabel = draft.orgValue('externalMail', 'senderLabel'); + const recipientWarning = draft.orgValue('externalMail', 'recipientWarning'); + const domains = draft.orgValue('externalMail', 'domains'); + const trustContacts = draft.orgValue('externalMail', 'trustContacts'); + const plugin = useInstalledPlugin(); + const anyOn = senderLabel.value || recipientWarning.value; + + // The list is typed as text and taken apart when the field is left, so a + // half-typed domain isn't thrown away on every key. It is stored in ASCII + // (xn--) form and shown in Unicode; entries that aren't domains are named. + const stored = domains.value.join('\n'); + const joined = domains.value.map(toUnicodeDomain).join('\n'); + const [text, setText] = useState(joined); + const [rejected, setRejected] = useState([]); + useEffect(() => { setText(joined); }, [joined]); + const commitDomains = (raw: string) => { + const parsed = parseDomainList(raw) ?? { domains: [], rejected: [] }; + setRejected(parsed.rejected); + if (parsed.domains.join('\n') !== stored) draft.setOrgValue('externalMail', 'domains', parsed.domains); + else setText(joined); + }; + + const [importing, setImporting] = useState(false); + const importFromPlugin = async () => { + setImporting(true); + try { + const res = await apiFetch(`/api/admin/plugins/${EXTERNAL_MAIL_WARNING_PLUGIN_ID}/config`); + if (!res.ok) throw new Error(String(res.status)); + const config = (await res.json()) as Record; + const parsed = parseDomainList(config.safeDomains) ?? { domains: [], rejected: [] }; + const fromPlugin = parsed.domains; + setRejected(parsed.rejected); + const added = fromPlugin.filter((d) => !domains.value.includes(d)); + if (added.length > 0) draft.setOrgValue('externalMail', 'domains', [...domains.value, ...added]); + draft.setMessage({ + type: 'success', + text: added.length > 0 + ? `Added ${added.length} ${added.length === 1 ? 'domain' : 'domains'} from the plugin. Save to apply them.` + : fromPlugin.length > 0 ? 'The list already has every domain the plugin had.' : 'The plugin has no safe domains set.', + }); + } catch { + draft.setMessage({ type: 'error', text: 'Could not read the plugin settings' }); + } finally { + setImporting(false); + } + }; + + const outdated = !!plugin && older(plugin.version, PLUGIN_STEPS_BACK_FROM); + + return ( + + +
+ draft.setOrgValue('externalMail', 'senderLabel', v)} testId="org-external-sender-label" /> + draft.clearOrgValue('externalMail', 'senderLabel')} /> +
+
+ + +
+ draft.setOrgValue('externalMail', 'recipientWarning', v)} testId="org-external-recipient-warning" /> + draft.clearOrgValue('externalMail', 'recipientWarning')} /> +
+
+ + +
+ draft.setOrgValue('externalMail', 'trustContacts', v)} testId="org-external-trust-contacts" /> + draft.clearOrgValue('externalMail', 'trustContacts')} /> +
+
+ +
+ +

+ Your users' own domains always count as internal. Add partner or sister-company domains here, one per line or separated by commas. A domain covers its subdomains: example.com also covers eu.example.com. +

+