DLP: group and tenant conditions, and how long held mail waits #58
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
Reference in new issue
Block a user