fix: follow the organisation policy for delegates and recovering deleted mail

This commit is contained in:
Linus Rath
2026-10-31 16:10:35 +01:00
parent ab226b03b4
commit bc47c6a1af
8 changed files with 136 additions and 24 deletions
+2 -1
View File
@@ -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<NativeStackNavigationProp<RootStackParamList>>();
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);
+4 -2
View File
@@ -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;
@@ -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);
@@ -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<string, unknown>): 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\);/);
});
});
+19 -9
View File
@@ -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'),
};
}
@@ -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);
});
}
+24 -8
View File
@@ -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<string, unknown> | undefined): number {
const recovery = organisation?.recovery;
const days = recovery && typeof recovery === 'object' ? (recovery as Record<string, unknown>).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),
};
}
@@ -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);