From 059c55b437d04962c5fd243b983722cabf38130a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 19:39:36 -0700 Subject: [PATCH] DLP: group and tenant conditions, and how long held mail waits --- src/features/dlp/HeldMailPage.tsx | 96 ++++++++++++++++++++++++++++++- src/features/dlp/RuleEditor.tsx | 72 ++++++++++++++++++++++- src/features/dlp/api.ts | 14 +++++ src/features/dlp/model.ts | 20 ++++--- 4 files changed, 191 insertions(+), 11 deletions(-) diff --git a/src/features/dlp/HeldMailPage.tsx b/src/features/dlp/HeldMailPage.tsx index c3da00f..5a6ae69 100644 --- a/src/features/dlp/HeldMailPage.tsx +++ b/src/features/dlp/HeldMailPage.tsx @@ -30,7 +30,16 @@ import { LoadingFallback } from '@/components/common/LoadingFallback'; import { useAccountStore } from '@/stores/accountStore'; import { toast } from '@/hooks/use-toast'; import { formatSize } from '@/features/hold/legalHold'; -import { decideHeld, fetchHeld, fetchPreview, RulesUnavailable, type HeldMessage } from './api'; +import { Input } from '@/components/ui/input'; +import { + decideHeld, + fetchHeld, + fetchKeepHeldDays, + fetchPreview, + RulesUnavailable, + saveKeepHeldDays, + type HeldMessage, +} from './api'; import { detectorName } from './model'; type Load = { kind: 'loading' } | { kind: 'ready'; held: HeldMessage[] } | { kind: 'error'; message: string }; @@ -164,6 +173,88 @@ function DecideDialog({ ); } +/** How long held mail waits, and changing it for those who may. */ +function KeepDays() { + const { t } = useTranslation(); + const canChange = useAccountStore((s) => s.hasPermission('sysDlpPolicyUpdate')); + const [days, setDays] = useState(null); + const [editing, setEditing] = useState(false); + const [value, setValue] = useState('7'); + const [busy, setBusy] = useState(false); + useEffect(() => { + const controller = new AbortController(); + fetchKeepHeldDays(controller.signal) + .then((d) => !controller.signal.aborted && setDays(d)) + // An older server, or no permission to see it: say nothing + .catch(() => {}); + return () => controller.abort(); + }, []); + if (days === null) return null; + const valid = /^\d+$/.test(value) && Number(value) >= 1 && Number(value) <= 90; + const save = async () => { + setBusy(true); + try { + await saveKeepHeldDays(Number(value)); + setDays(Number(value)); + setEditing(false); + } catch (e) { + toast({ + variant: 'destructive', + title: t('held.daysFailed', 'Not saved'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }; + return ( +
+ {editing ? ( + <> + {t('held.daysLabel', 'Held mail waits')} + setValue(e.target.value)} + /> + {t('held.daysUnit', 'days (1 to 90) for newly held mail')} + + + + ) : ( + <> + + {t('held.days', { + count: days, + defaultValue_one: 'If nobody decides within 1 day, it goes back to the sender.', + defaultValue_other: 'If nobody decides within {{count}} days, it goes back to the sender.', + })} + + {canChange && ( + + )} + + )} +
+ ); +} + export function HeldMailPage() { const { t } = useTranslation(); const canDecide = useAccountStore((s) => s.hasPermission('sysDlpReviewUpdate')); @@ -199,9 +290,10 @@ export function HeldMailPage() { title={t('held.title', 'Held mail')} subtitle={t( 'held.subtitle', - 'Outgoing mail a DLP rule held for review. Release it and it’s delivered; reject it and the sender is told. If nobody decides within 7 days, it goes back to the sender.', + 'Outgoing mail a DLP rule held for review. Release it and it’s delivered; reject it and the sender is told.', )} /> + {load.kind === 'loading' && } {load.kind === 'error' && (
{load.message}
diff --git a/src/features/dlp/RuleEditor.tsx b/src/features/dlp/RuleEditor.tsx index 10c0cde..a9fd434 100644 --- a/src/features/dlp/RuleEditor.tsx +++ b/src/features/dlp/RuleEditor.tsx @@ -28,6 +28,9 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { toast } from '@/hooks/use-toast'; +import { ObjectPicker } from '@/components/common/ObjectPicker'; +import { useObjectLabel } from '@/lib/objectOptions'; +import { useSchemaStore } from '@/stores/schemaStore'; import { saveRule } from './api'; import { DETECTORS, TEMPLATES, describeRule, type Action, type Condition, type Direction, type Rule } from './model'; @@ -48,6 +51,9 @@ const CONDITION_TYPES: { type: Condition['type']; label: string; dlpOnly?: boole { type: 'senderDomain', label: 'Sender’s domain is…' }, { type: 'recipientAddress', label: 'A recipient is…' }, { type: 'recipientDomain', label: 'A recipient’s domain is…' }, + { type: 'senderGroup', label: 'Sender is in a group' }, + { type: 'recipientGroup', label: 'A recipient is in a group' }, + { type: 'senderTenant', label: 'Sender is in a tenant' }, { type: 'header', label: 'A header…' }, { type: 'attachmentExtension', label: 'An attachment ends in…' }, { type: 'attachmentType', label: 'An attachment’s type is…' }, @@ -179,6 +185,53 @@ function DetectorPicker({ ); } +function IdChip({ labelObject, id, onRemove }: { labelObject: string; id: string; onRemove: () => void }) { + const schema = useSchemaStore((s) => s.schema); + const { label } = useObjectLabel(labelObject, id, schema!); + return ( + + {label ?? id} + + + ); +} + +/** Groups or tenants, picked from the directory. */ +function IdPicker({ + objectName, + labelObject, + ids, + onChange, + placeholder, +}: { + objectName: string; + labelObject: string; + ids: string[]; + onChange: (ids: string[]) => void; + placeholder: string; +}) { + const schema = useSchemaStore((s) => s.schema); + if (!schema) return null; + return ( +
+ {ids.map((id) => ( + onChange(ids.filter((x) => x !== id))} /> + ))} + { + if (id && !ids.includes(id)) onChange([...ids, id]); + }} + placeholder={placeholder} + /> +
+ ); +} + function ConditionFields({ value, onChange }: { value: Condition; onChange: (c: Condition) => void }) { const { t } = useTranslation(); const listField = (items: string[], build: (items: string[]) => Condition, hint: string) => ( @@ -288,10 +341,25 @@ function ConditionFields({ value, onChange }: { value: Condition; onChange: (c: case 'detected': return ; case 'senderGroup': - case 'senderTenant': case 'recipientGroup': return ( -

{t('rules.keptAsIs', 'Kept as it is; edit it over the API.')}

+ onChange({ type: value.type, groups })} + placeholder={t('rules.pickGroup', 'Add a group…')} + /> + ); + case 'senderTenant': + return ( + onChange({ type: 'senderTenant', tenants })} + placeholder={t('rules.pickTenant', 'Add a tenant…')} + /> ); default: return null; diff --git a/src/features/dlp/api.ts b/src/features/dlp/api.ts index 2dcd41d..85058be 100644 --- a/src/features/dlp/api.ts +++ b/src/features/dlp/api.ts @@ -135,3 +135,17 @@ export async function decideHeld( const failed = setFailure(result, 'notUpdated', id); if (failed) throw failed; } + +// --- Settings --------------------------------------------------------------- + +/** How many days held mail waits for a reviewer (1 to 90). */ +export async function fetchKeepHeldDays(signal?: AbortSignal): Promise { + const result = await call('inbuxa:DlpSettings/get', { ids: null }, signal); + return (result.list as { keepHeldDays?: number }[] | undefined)?.[0]?.keepHeldDays ?? 7; +} + +export async function saveKeepHeldDays(days: number): Promise { + const result = await call('inbuxa:DlpSettings/set', { update: { singleton: { keepHeldDays: days } } }); + const failed = setFailure(result, 'notUpdated', 'singleton'); + if (failed) throw failed; +} diff --git a/src/features/dlp/model.ts b/src/features/dlp/model.ts index f1df3b1..c26dc79 100644 --- a/src/features/dlp/model.ts +++ b/src/features/dlp/model.ts @@ -36,10 +36,10 @@ export type Condition = | { type: 'cantBeInspected' } | { type: 'messageSizeOver'; bytes: number } | { type: 'detected'; detectors: DetectorMin[] } - // Kept as the server sent them; the form doesn't edit these yet - | { type: 'senderGroup'; groups: number[] } - | { type: 'senderTenant'; tenants: number[] } - | { type: 'recipientGroup'; groups: number[] }; + // Group and tenant ids, as JMAP ids + | { type: 'senderGroup'; groups: string[] } + | { type: 'senderTenant'; tenants: string[] } + | { type: 'recipientGroup'; groups: string[] }; export type Action = | { type: 'addDisclaimer'; text: string; html?: string | null; position: Position } @@ -230,15 +230,21 @@ export function describeCondition(c: Condition): string { case 'senderDomain': return `the sender is at ${list(c.domains)}`; case 'senderGroup': - return `the sender is in one of ${c.groups.length} groups`; + return c.groups.length === 1 + ? 'the sender is in the chosen group' + : `the sender is in one of ${c.groups.length} groups`; case 'senderTenant': - return `the sender is in one of ${c.tenants.length} tenants`; + return c.tenants.length === 1 + ? 'the sender is in the chosen tenant' + : `the sender is in one of ${c.tenants.length} tenants`; case 'recipientAddress': return `a recipient is ${list(c.addresses)}`; case 'recipientDomain': return `a recipient is at ${list(c.domains)}`; case 'recipientGroup': - return `a recipient is in one of ${c.groups.length} groups`; + return c.groups.length === 1 + ? 'a recipient is in the chosen group' + : `a recipient is in one of ${c.groups.length} groups`; case 'recipientOutside': return 'a recipient is outside this server'; case 'words': -- 2.54.0