DLP: group and tenant conditions, and how long held mail waits #58

Merged
jcoffey-dev merged 1 commits from feature/dlp-settings-pickers into main 2026-09-29 02:41:11 +00:00
4 changed files with 191 additions and 11 deletions

No files matched your search

+94 -2
View File
@@ -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<number | null>(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 (
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{editing ? (
<>
<span>{t('held.daysLabel', 'Held mail waits')}</span>
<Input
type="number"
min={1}
max={90}
className="h-8 w-20"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<span>{t('held.daysUnit', 'days (1 to 90) for newly held mail')}</span>
<Button size="sm" disabled={busy || !valid} onClick={() => void save()}>
{t('held.save', 'Save')}
</Button>
<Button size="sm" variant="ghost" disabled={busy} onClick={() => setEditing(false)}>
{t('held.cancel', 'Cancel')}
</Button>
</>
) : (
<>
<span>
{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.',
})}
</span>
{canChange && (
<Button
size="sm"
variant="ghost"
onClick={() => {
setValue(String(days));
setEditing(true);
}}
>
{t('held.changeDays', 'Change…')}
</Button>
)}
</>
)}
</div>
);
}
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.',
)}
/>
<KeepDays />
{load.kind === 'loading' && <LoadingFallback />}
{load.kind === 'error' && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">{load.message}</div>
+70 -2
View File
@@ -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 (
<span className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs">
{label ?? id}
<button type="button" onClick={onRemove} className="text-muted-foreground hover:text-foreground">
<X className="h-3 w-3" />
</button>
</span>
);
}
/** 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 (
<div className="flex flex-wrap items-center gap-1">
{ids.map((id) => (
<IdChip key={id} labelObject={labelObject} id={id} onRemove={() => onChange(ids.filter((x) => x !== id))} />
))}
<ObjectPicker
schema={schema}
objectName={objectName}
value=""
onChange={(id) => {
if (id && !ids.includes(id)) onChange([...ids, id]);
}}
placeholder={placeholder}
/>
</div>
);
}
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 <DetectorPicker value={value} onChange={onChange} />;
case 'senderGroup':
case 'senderTenant':
case 'recipientGroup':
return (
<p className="text-xs text-muted-foreground">{t('rules.keptAsIs', 'Kept as it is; edit it over the API.')}</p>
<IdPicker
objectName="x:Account/Group"
labelObject="x:Account"
ids={value.groups}
onChange={(groups) => onChange({ type: value.type, groups })}
placeholder={t('rules.pickGroup', 'Add a group…')}
/>
);
case 'senderTenant':
return (
<IdPicker
objectName="x:Tenant"
labelObject="x:Tenant"
ids={value.tenants}
onChange={(tenants) => onChange({ type: 'senderTenant', tenants })}
placeholder={t('rules.pickTenant', 'Add a tenant…')}
/>
);
default:
return null;
+14
View File
@@ -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<number> {
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<void> {
const result = await call('inbuxa:DlpSettings/set', { update: { singleton: { keepHeldDays: days } } });
const failed = setFailure(result, 'notUpdated', 'singleton');
if (failed) throw failed;
}
+13 -7
View File
@@ -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':