From bc47c6a1af772d49b4dd849e5d420f34aaf09295 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:10:35 +0100 Subject: [PATCH] fix: follow the organisation policy for delegates and recovering deleted mail --- src/components/email/TrashRecoveryLink.tsx | 3 +- src/components/settings/DelegateSettings.tsx | 6 +- .../settings/RecoverDeletedSettings.tsx | 5 +- .../delegation-recovery-policy.test.ts | 78 +++++++++++++++++++ src/lib/delegation/policy.ts | 28 ++++--- .../delegation/use-delegation-available.ts | 5 +- src/lib/recovery/policy.ts | 32 ++++++-- .../recovery/use-deleted-items-recovery.ts | 3 +- 8 files changed, 136 insertions(+), 24 deletions(-) create mode 100644 src/lib/__tests__/delegation-recovery-policy.test.ts diff --git a/src/components/email/TrashRecoveryLink.tsx b/src/components/email/TrashRecoveryLink.tsx index 210689e7..ac23a5e6 100644 --- a/src/components/email/TrashRecoveryLink.tsx +++ b/src/components/email/TrashRecoveryLink.tsx @@ -6,6 +6,7 @@ import { RotateCcw } from '../icons'; import { spacing, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; +import { usePolicyStore } from '../../stores/policy-store'; import { setPendingSettingsTab } from '../../navigation/pending-settings-tab'; import { recoveryPolicy } from '../../lib/recovery/policy'; import { useRecoveryStore } from '../../lib/recovery/recovery-store'; @@ -28,7 +29,7 @@ export function TrashRecoveryLink({ mailbox }: { mailbox: Mailbox | undefined }) const t = useLocaleStore((s) => s.t); const navigation = useNavigation>(); const { available, accountId, key } = useDeletedItemsRecovery(); - const { days } = recoveryPolicy(); + const days = usePolicyStore((s) => recoveryPolicy(s.effective).days); const recent = useRecoveryStore((s) => (key ? s.recent[key] : undefined)); const checkRecent = useRecoveryStore((s) => s.checkRecent); const ownTrash = isOwnTrash(mailbox, accountId); diff --git a/src/components/settings/DelegateSettings.tsx b/src/components/settings/DelegateSettings.tsx index ffcab891..201227b4 100644 --- a/src/components/settings/DelegateSettings.tsx +++ b/src/components/settings/DelegateSettings.tsx @@ -10,6 +10,7 @@ import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { useAuthStore } from '../../stores/auth-store'; import { useAccountStore } from '../../stores/account-store'; +import { usePolicyStore } from '../../stores/policy-store'; import { toast } from '../../stores/toast-store'; import { jmapClient } from '../../api/jmap-client'; import { listPrincipals, type PrincipalListing } from '../../api/files'; @@ -39,7 +40,7 @@ import { delegationPolicy } from '../../lib/delegation/policy'; // Settings > Sharing > Delegates. Give someone access to mail, calendars and // contacts in one step, and see and take back who has access to what. The // admin's hiding, rights limit and app gates come from lib/delegation/policy -// (nothing is limited until the app has the org policy). +// (the active account's organisation policy). // Names kept for a login go with it: removed from the app, or everyone signed out. useAccountStore.subscribe((state, prev) => { @@ -55,7 +56,8 @@ export function DelegateSettings() { const session = useAuthStore((s) => s.session); const accountKey = useAuthStore((s) => s.activeAccountId) ?? ''; const username = useAuthStore((s) => s.username); - const policy = delegationPolicy(); + const effectivePolicy = usePolicyStore((s) => s.effective); + const policy = useMemo(() => delegationPolicy(effectivePolicy), [effectivePolicy]); const levels = useMemo(() => allowedLevels(policy.allowedValues), [policy.allowedValues]); const { calendarsEnabled, contactsEnabled, filesEnabled } = policy; diff --git a/src/components/settings/RecoverDeletedSettings.tsx b/src/components/settings/RecoverDeletedSettings.tsx index 62a12d4b..74d59128 100644 --- a/src/components/settings/RecoverDeletedSettings.tsx +++ b/src/components/settings/RecoverDeletedSettings.tsx @@ -7,6 +7,7 @@ import { spacing, radius, typography, type ThemePalette } from '../../theme/toke import { useColors } from '../../theme/colors'; import { useLocaleStore } from '../../stores/locale-store'; import { useSettingsStore } from '../../stores/settings-store'; +import { usePolicyStore } from '../../stores/policy-store'; import { toast } from '../../stores/toast-store'; import { jmapClient } from '../../api/jmap-client'; import { listArchivedEmails, restoreArchivedItems } from '../../api/archived-items'; @@ -29,7 +30,7 @@ import { useDeletedItemsRecovery } from '../../lib/recovery/use-deleted-items-re // "Show older" past 2000. Restoring asks the server to put them back in the // Inbox; it does that in the background, and the mail list picks them up // from the push that follows. The admin's day limit comes from -// lib/recovery/policy (no limit until the app has the org policy). +// lib/recovery/policy (the active account's organisation policy). type ListState = | { status: 'loading' } @@ -52,7 +53,7 @@ export function RecoverDeletedSettings() { const locale = useLocaleStore((s) => s.locale); const timeFormat = useSettingsStore((s) => s.timeFormat); const { available, accountId, key } = useDeletedItemsRecovery(); - const { days } = recoveryPolicy(); + const days = usePolicyStore((s) => recoveryPolicy(s.effective).days); const record = useRecoveryStore((s) => s.record); const forgetRecent = useRecoveryStore((s) => s.forgetRecent); diff --git a/src/lib/__tests__/delegation-recovery-policy.test.ts b/src/lib/__tests__/delegation-recovery-policy.test.ts new file mode 100644 index 00000000..46a7b9fe --- /dev/null +++ b/src/lib/__tests__/delegation-recovery-policy.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { delegationPolicy } from '../delegation/policy'; +import { recoveryPolicy } from '../recovery/policy'; +import { sanitizePolicy, type OrgPolicy } from '../org-policy'; +import { usePolicyStore } from '../../stores/policy-store'; + +// Delegates and "Recover deleted items" follow the organisation policy +// (webmail components/settings/delegates/delegates-settings.tsx, +// hooks/use-deleted-items-recovery.ts, components/email/trash-recovery-link.tsx). +// Both used to answer fixed defaults whatever the admin set. + +function policyWith(raw: Record): OrgPolicy { + const policy = sanitizePolicy({ features: {}, restrictions: {}, defaults: {}, ...raw }); + if (!policy) throw new Error('policy did not sanitize'); + return policy; +} + +const read = (rel: string) => fs.readFileSync(path.resolve(__dirname, '..', '..', rel), 'utf8'); + +afterEach(() => { + usePolicyStore.setState({ effective: null }); +}); + +describe('delegationPolicy', () => { + it('shows everything without a policy', () => { + expect(delegationPolicy(null)).toEqual({ + hidden: false, allowedValues: null, calendarsEnabled: true, contactsEnabled: true, filesEnabled: true, + }); + }); + + it('reads the hidden page, the rights limit and the app gates', () => { + const policy = policyWith({ + restrictions: { delegation: { hidden: true }, sharingRights: { allowedValues: ['read'] } }, + features: { calendarEnabled: false, contactsEnabled: true, filesEnabled: false }, + }); + expect(delegationPolicy(policy)).toEqual({ + hidden: true, allowedValues: ['read'], calendarsEnabled: false, contactsEnabled: true, filesEnabled: false, + }); + }); + + it("reads the active account's policy from the store by default", () => { + usePolicyStore.setState({ effective: policyWith({ restrictions: { delegation: { hidden: true } } }) }); + expect(delegationPolicy().hidden).toBe(true); + }); +}); + +describe('recoveryPolicy', () => { + it('shows everything without a policy', () => { + expect(recoveryPolicy(null)).toEqual({ hidden: false, days: 0 }); + }); + + it('reads the hidden page and the day limit, clamped as the webmail does', () => { + expect(recoveryPolicy(policyWith({ + restrictions: { deletedItemsRecovery: { hidden: true } }, + organisation: { recovery: { days: 30 } }, + }))).toEqual({ hidden: true, days: 30 }); + expect(recoveryPolicy(policyWith({ organisation: { recovery: { days: 99999 } } })).days).toBe(3650); + expect(recoveryPolicy(policyWith({ organisation: { recovery: { days: -4 } } })).days).toBe(0); + expect(recoveryPolicy(policyWith({ organisation: { recovery: { days: '30' } } })).days).toBe(0); + }); + + it("reads the active account's policy from the store by default", () => { + usePolicyStore.setState({ effective: policyWith({ organisation: { recovery: { days: 14 } } }) }); + expect(recoveryPolicy().days).toBe(14); + }); +}); + +describe('policy wiring', () => { + it('re-reads the policy when it changes', () => { + expect(read('components/email/TrashRecoveryLink.tsx')).toMatch(/const days = usePolicyStore\(\(s\) => recoveryPolicy\(s\.effective\)\.days\);/); + expect(read('components/settings/RecoverDeletedSettings.tsx')).toMatch(/const days = usePolicyStore\(\(s\) => recoveryPolicy\(s\.effective\)\.days\);/); + expect(read('lib/recovery/use-deleted-items-recovery.ts')).toMatch(/const off = usePolicyStore\(\(s\) => recoveryPolicy\(s\.effective\)\.hidden\);/); + expect(read('components/settings/DelegateSettings.tsx')).toMatch(/const policy = useMemo\(\(\) => delegationPolicy\(effectivePolicy\), \[effectivePolicy\]\);/); + expect(read('lib/delegation/use-delegation-available.ts')).toMatch(/const policy = delegationPolicy\(effective\);/); + }); +}); diff --git a/src/lib/delegation/policy.ts b/src/lib/delegation/policy.ts index 36d7ee06..dd024eb1 100644 --- a/src/lib/delegation/policy.ts +++ b/src/lib/delegation/policy.ts @@ -1,10 +1,13 @@ -// What the organisation policy says about Delegates. The webmail reads it -// from its policy store: the admin can hide the page (restriction -// `delegation`), limit the rights offered (`sharingRights.allowedValues`, -// read and/or write) and switch calendars, contacts or files off -// (`calendarEnabled`, `contactsEnabled`, `filesEnabled`). The app has no -// policy route yet, so this answers "shown, every right, every app"; once -// the policy arrives, this is the one place to plug it in. +// What the organisation policy says about Delegates (webmail +// components/settings/delegates/delegates-settings.tsx): the admin can hide +// the page (restriction `delegation`), limit the rights offered +// (`sharingRights.allowedValues`, read and/or write) and switch calendars, +// contacts or files off (`calendarEnabled`, `contactsEnabled`, +// `filesEnabled`). Read from the active account's policy (policy store); an +// account without a webmail has none: shown, every right, every app. + +import { isFeatureEnabled, type OrgPolicy } from '../org-policy'; +import { usePolicyStore } from '../../stores/policy-store'; export interface DelegationPolicy { /** The admin hid the page. */ @@ -16,6 +19,13 @@ export interface DelegationPolicy { filesEnabled: boolean; } -export function delegationPolicy(): DelegationPolicy { - return { hidden: false, allowedValues: null, calendarsEnabled: true, contactsEnabled: true, filesEnabled: true }; +export function delegationPolicy(policy: OrgPolicy | null = usePolicyStore.getState().effective): DelegationPolicy { + const allowed = policy?.restrictions.sharingRights?.allowedValues; + return { + hidden: policy?.restrictions.delegation?.hidden === true, + allowedValues: Array.isArray(allowed) ? allowed : null, + calendarsEnabled: isFeatureEnabled(policy, 'calendarEnabled'), + contactsEnabled: isFeatureEnabled(policy, 'contactsEnabled'), + filesEnabled: isFeatureEnabled(policy, 'filesEnabled'), + }; } diff --git a/src/lib/delegation/use-delegation-available.ts b/src/lib/delegation/use-delegation-available.ts index aa0127e0..6f2d4c67 100644 --- a/src/lib/delegation/use-delegation-available.ts +++ b/src/lib/delegation/use-delegation-available.ts @@ -1,6 +1,7 @@ import { useAuthStore } from '../../stores/auth-store'; import { supportsDelegation } from '../../api/delegation'; import { delegationPolicy } from './policy'; +import { usePolicyStore } from '../../stores/policy-store'; /** * Whether Settings lists Delegates for the active account: not hidden by @@ -8,9 +9,11 @@ import { delegationPolicy } from './policy'; * share (webmail settings-app supportsSharing). Re-read with every session. */ export function useDelegationAvailable(): boolean { + // The organisation policy, re-read when it changes. + const effective = usePolicyStore((s) => s.effective); return useAuthStore((s) => { if (!s.session) return false; - const policy = delegationPolicy(); + const policy = delegationPolicy(effective); return !policy.hidden && supportsDelegation(policy); }); } diff --git a/src/lib/recovery/policy.ts b/src/lib/recovery/policy.ts index 337aee86..704a4e53 100644 --- a/src/lib/recovery/policy.ts +++ b/src/lib/recovery/policy.ts @@ -1,9 +1,13 @@ -// What the organisation policy says about "Recover deleted items". The -// webmail reads it from its policy store: the admin can hide the page -// (restriction `deletedItemsRecovery`) and limit the list to the last N days -// (`organisation.recovery.days`, 0 = everything the server keeps). The app -// has no policy route yet, so this answers "shown, no limit"; once the -// policy arrives, this is the one place to plug it in. +// What the organisation policy says about "Recover deleted items" (webmail +// hooks/use-deleted-items-recovery.ts, components/email/trash-recovery-link.tsx): +// the admin can hide the page (restriction `deletedItemsRecovery`) and limit +// the list to the last N days (`organisation.recovery.days`, 0 = everything +// the server keeps; webmail lib/admin/organisation-policy.ts clamps it to +// 0..3650). Read from the active account's policy (policy store); an account +// without a webmail has none: shown, no limit. + +import type { OrgPolicy } from '../org-policy'; +import { usePolicyStore } from '../../stores/policy-store'; export interface RecoveryPolicy { /** The admin hid the page: nothing is asked of the server. */ @@ -12,6 +16,18 @@ export interface RecoveryPolicy { days: number; } -export function recoveryPolicy(): RecoveryPolicy { - return { hidden: false, days: 0 }; +const MAX_DAYS = 3650; + +function recoveryDays(organisation: Record | undefined): number { + const recovery = organisation?.recovery; + const days = recovery && typeof recovery === 'object' ? (recovery as Record).days : undefined; + if (typeof days !== 'number' || !Number.isFinite(days)) return 0; + return Math.min(MAX_DAYS, Math.max(0, Math.round(days))); +} + +export function recoveryPolicy(policy: OrgPolicy | null = usePolicyStore.getState().effective): RecoveryPolicy { + return { + hidden: policy?.restrictions.deletedItemsRecovery?.hidden === true, + days: recoveryDays(policy?.organisation), + }; } diff --git a/src/lib/recovery/use-deleted-items-recovery.ts b/src/lib/recovery/use-deleted-items-recovery.ts index 6bee5c0e..51f17e77 100644 --- a/src/lib/recovery/use-deleted-items-recovery.ts +++ b/src/lib/recovery/use-deleted-items-recovery.ts @@ -7,6 +7,7 @@ import { } from '../../api/archived-items'; import { useAuthStore } from '../../stores/auth-store'; import { recoveryPolicy } from './policy'; +import { usePolicyStore } from '../../stores/policy-store'; import { recoveryKey, useRecoveryStore, type RecoveryClient } from './recovery-store'; /** The archive calls for one JMAP account, as the detection store asks them. */ @@ -41,7 +42,7 @@ export function useDeletedItemsRecovery(): DeletedItemsRecovery { // connectedAccountId, not accountId: the client is between sessions while an // account switch reconnects, and accountId throws then (a crash in render). const accountId = session ? jmapClient.connectedAccountId : null; - const off = recoveryPolicy().hidden; + const off = usePolicyStore((s) => recoveryPolicy(s.effective).hidden); const key = accountId ? recoveryKey(activeAccountId, accountId) : null; const check = useRecoveryStore((s) => (key ? s.checks[key] : undefined)); const detect = useRecoveryStore((s) => s.detect);