DLP rules, mail flow rules and held mail pages #56

Merged
jcoffey-dev merged 1 commits from feature/dlp-pages into main 2026-09-29 01:45:37 +00:00
9 changed files with 1744 additions and 0 deletions

No files matched your search

+22
View File
@@ -70,6 +70,19 @@ const ComplianceOverviewPage = lazyFeature(
() => import('@/features/compliance/ComplianceOverviewPage'),
(m) => m.ComplianceOverviewPage,
);
// INBUXA: DLP and mail flow rules (dlp-and-mail-flow-rules spec, §3)
const DlpRulesPage = lazyFeature(
() => import('@/features/dlp/RulesPage'),
(m) => m.DlpRulesPage,
);
const MailFlowRulesPage = lazyFeature(
() => import('@/features/dlp/RulesPage'),
(m) => m.MailFlowRulesPage,
);
const HeldMailPage = lazyFeature(
() => import('@/features/dlp/HeldMailPage'),
(m) => m.HeldMailPage,
);
const DataInventoryPage = lazyFeature(
() => import('@/features/compliance/DataInventoryPage'),
(m) => m.DataInventoryPage,
@@ -220,6 +233,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (componentName === 'ComplianceOverview') {
return <ComplianceOverviewPage />;
}
if (componentName === 'DlpRules') {
return <DlpRulesPage />;
}
if (componentName === 'MailFlowRules') {
return <MailFlowRulesPage />;
}
if (componentName === 'HeldMail') {
return <HeldMailPage />;
}
if (componentName === 'DataInventory') {
return <DataInventoryPage />;
}
+288
View File
@@ -0,0 +1,288 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: Management › Compliance › Held mail (dlp-and-mail-flow-rules
* spec, §2.6): outgoing mail a DLP rule held, waiting for a reviewer to
* release or reject it. Reading one is recorded as access to the sender's
* mail; every decision needs a reason for the audit log.
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Eye, Loader2, Send, Undo2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { PageHeader } from '@/components/common/PageHeader';
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 { detectorName } from './model';
type Load = { kind: 'loading' } | { kind: 'ready'; held: HeldMessage[] } | { kind: 'error'; message: string };
function when(iso: string): string {
return iso ? new Date(iso).toLocaleString() : '';
}
function ReadDialog({ message, onClose }: { message: HeldMessage; onClose: () => void }) {
const { t } = useTranslation();
const [text, setText] = useState<string | null>(null);
const [failed, setFailed] = useState<string | null>(null);
useEffect(() => {
fetchPreview(message.id)
.then(setText)
.catch((e: unknown) => setFailed(e instanceof Error ? e.message : String(e)));
}, [message.id]);
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{message.subject || t('held.noSubject', '(no subject)')}</DialogTitle>
<DialogDescription>
{t('held.readRecorded', 'From {{sender}}. Reading it is recorded in the audit log.', {
sender: message.sender,
})}
</DialogDescription>
</DialogHeader>
{failed ? (
<p className="text-sm text-destructive">{failed}</p>
) : text === null ? (
<LoadingFallback />
) : (
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-md border bg-muted/40 p-3 text-sm">
{text}
</pre>
)}
</DialogContent>
</Dialog>
);
}
function DecideDialog({
message,
decision,
onClose,
onDone,
}: {
message: HeldMessage;
decision: 'release' | 'reject';
onClose: () => void;
onDone: () => void;
}) {
const { t } = useTranslation();
const [reason, setReason] = useState('');
const [note, setNote] = useState('');
const [busy, setBusy] = useState(false);
const release = decision === 'release';
const run = async () => {
setBusy(true);
try {
await decideHeld(message.id, decision, reason, note);
toast({
title: release
? t('held.released', 'Released: it’s on its way')
: t('held.rejected', 'Rejected: the sender was told'),
});
onDone();
} catch (e) {
toast({
variant: 'destructive',
title: t('held.decideFailed', 'Nothing was changed'),
description: e instanceof Error ? e.message : String(e),
});
} finally {
setBusy(false);
}
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{release
? t('held.releaseTitle', 'Release “{{subject}}”', { subject: message.subject })
: t('held.rejectTitle', 'Reject “{{subject}}”', { subject: message.subject })}
</DialogTitle>
<DialogDescription>
{release
? t('held.releaseBody', 'It will be delivered to {{to}} as it was sent.', {
to: message.recipients.join(', '),
})
: t('held.rejectBody', 'It’s taken out of the queue, and {{sender}} is told it wasn’t sent.', {
sender: message.sender,
})}
</DialogDescription>
</DialogHeader>
{!release && (
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('held.note', 'A note for the sender (optional)')}</span>
<Textarea value={note} maxLength={1000} onChange={(e) => setNote(e.target.value)} />
</label>
)}
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('held.reason', 'Reason, for the audit log')}</span>
<Textarea value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
</label>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
{t('held.cancel', 'Cancel')}
</Button>
<Button
variant={release ? 'default' : 'destructive'}
onClick={() => void run()}
disabled={busy || !reason.trim()}
>
{busy ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : release ? (
<Send className="mr-2 h-4 w-4" />
) : (
<Undo2 className="mr-2 h-4 w-4" />
)}
{release ? t('held.releaseGo', 'Release') : t('held.rejectGo', 'Reject')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export function HeldMailPage() {
const { t } = useTranslation();
const canDecide = useAccountStore((s) => s.hasPermission('sysDlpReviewUpdate'));
const [load, setLoad] = useState<Load>({ kind: 'loading' });
const [fetches, setFetches] = useState(0);
const [reading, setReading] = useState<HeldMessage | null>(null);
const [deciding, setDeciding] = useState<{ message: HeldMessage; decision: 'release' | 'reject' } | null>(null);
const refetch = useCallback(() => setFetches((n) => n + 1), []);
useEffect(() => {
const controller = new AbortController();
fetchHeld(controller.signal)
.then((held) => !controller.signal.aborted && setLoad({ kind: 'ready', held }))
.catch((e: unknown) => {
if (controller.signal.aborted) return;
setLoad({
kind: 'error',
message:
e instanceof RulesUnavailable
? t('held.unavailable', 'This server doesn’t hold mail for review.')
: e instanceof Error
? e.message
: String(e),
});
});
return () => controller.abort();
}, [fetches, t]);
return (
<div className="mx-auto max-w-5xl space-y-5">
<PageHeader
icon="shield-alert"
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.',
)}
/>
{load.kind === 'loading' && <LoadingFallback />}
{load.kind === 'error' && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">{load.message}</div>
)}
{load.kind === 'ready' && load.held.length === 0 && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
{t('held.none', 'Nothing is waiting for review.')}
</div>
)}
{load.kind === 'ready' &&
load.held.map((message) => (
<section key={message.id} className="space-y-3 rounded-xl border p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="font-medium">{message.subject || t('held.noSubject', '(no subject)')}</p>
<p className="text-sm text-muted-foreground">
{t('held.fromTo', '{{sender}} to {{to}}', {
sender: message.sender,
to: message.recipients.join(', '),
})}
{' · '}
{formatSize(message.size)}
</p>
<p className="text-xs text-muted-foreground">
{t('held.when', 'Held {{held}}; goes back to the sender {{expires}} if nobody decides', {
held: when(message.heldAt),
expires: when(message.expiresAt),
})}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => setReading(message)}>
<Eye className="mr-2 h-4 w-4" />
{t('held.read', 'Read…')}
</Button>
{canDecide && (
<>
<Button size="sm" onClick={() => setDeciding({ message, decision: 'release' })}>
{t('held.releaseAction', 'Release…')}
</Button>
<Button variant="outline" size="sm" onClick={() => setDeciding({ message, decision: 'reject' })}>
{t('held.rejectAction', 'Reject…')}
</Button>
</>
)}
</div>
</div>
<div className="flex flex-wrap gap-2">
{message.rules.map((rule) => (
<Badge key={rule.name} variant="secondary" title={rule.notice}>
{rule.name}
</Badge>
))}
{message.counts
.filter((c) => c.count > 0)
.map((c) => (
<Badge key={c.detector} variant="outline">
{c.detector === 'words'
? t('held.words', 'Words')
: c.detector === 'pattern'
? t('held.pattern', 'Pattern')
: detectorName(c.detector)}
{': '}
{c.count}
</Badge>
))}
</div>
</section>
))}
{reading && <ReadDialog message={reading} onClose={() => setReading(null)} />}
{deciding && (
<DecideDialog
message={deciding.message}
decision={deciding.decision}
onClose={() => setDeciding(null)}
onDone={() => {
setDeciding(null);
refetch();
}}
/>
)}
</div>
);
}
+716
View File
@@ -0,0 +1,716 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: the form for a DLP rule or a mail flow rule (spec §2.2–§2.4, §3):
* conditions, exceptions, actions, and the rule in words as it's built.
* The server checks everything again when it's saved.
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { toast } from '@/hooks/use-toast';
import { saveRule } from './api';
import { DETECTORS, TEMPLATES, describeRule, type Action, type Condition, type Direction, type Rule } from './model';
/** One entry per line (commas work too). */
function toList(text: string): string[] {
return text
.split(/[\n,]/)
.map((s) => s.trim())
.filter(Boolean);
}
const CONDITION_TYPES: { type: Condition['type']; label: string; dlpOnly?: boolean }[] = [
{ type: 'recipientOutside', label: 'A recipient is outside this server' },
{ type: 'detected', label: 'Contains sensitive data (detectors)', dlpOnly: true },
{ type: 'words', label: 'Contains words or phrases' },
{ type: 'pattern', label: 'Matches a pattern' },
{ type: 'senderAddress', label: 'Sender is…' },
{ type: 'senderDomain', label: 'Sender’s domain is…' },
{ type: 'recipientAddress', label: 'A recipient is…' },
{ type: 'recipientDomain', label: 'A recipient’s domain is…' },
{ type: 'header', label: 'A header…' },
{ type: 'attachmentExtension', label: 'An attachment ends in…' },
{ type: 'attachmentType', label: 'An attachment’s type is…' },
{ type: 'attachmentName', label: 'An attachment’s name matches…' },
{ type: 'attachmentSizeOver', label: 'An attachment is over a size' },
{ type: 'attachmentCountOver', label: 'More than a number of attachments' },
{ type: 'cantBeInspected', label: 'An attachment can’t be inspected' },
{ type: 'messageSizeOver', label: 'The message is over a size' },
];
function blankCondition(type: Condition['type']): Condition {
switch (type) {
case 'senderAddress':
case 'recipientAddress':
return { type, addresses: [] };
case 'senderDomain':
case 'recipientDomain':
return { type, domains: [] };
case 'words':
return { type, words: [], atLeast: 1 };
case 'pattern':
return { type, pattern: '', atLeast: 1 };
case 'header':
return { type, name: '', contains: '' };
case 'attachmentType':
return { type, types: [] };
case 'attachmentExtension':
return { type, extensions: [] };
case 'attachmentName':
return { type, pattern: '' };
case 'attachmentSizeOver':
case 'messageSizeOver':
return { type, bytes: 10 * 1024 * 1024 };
case 'attachmentCountOver':
return { type, count: 10 };
case 'detected':
return { type, detectors: [] };
case 'senderGroup':
return { type, groups: [] };
case 'senderTenant':
return { type, tenants: [] };
case 'recipientGroup':
return { type, groups: [] };
default:
return { type: type as 'recipientOutside' };
}
}
function DetectorPicker({
value,
onChange,
}: {
value: Condition & { type: 'detected' };
onChange: (c: Condition) => void;
}) {
const { t } = useTranslation();
const chosen = new Map(value.detectors.map((d) => [d.id, d.atLeast]));
const set = (next: Map<string, number>) =>
onChange({ type: 'detected', detectors: [...next].map(([id, atLeast]) => ({ id, atLeast })) });
const regions = [...new Set(DETECTORS.map((d) => d.region))];
return (
<div className="space-y-2">
<Select
value=""
onValueChange={(id) => {
const template = TEMPLATES.find((tpl) => tpl.id === id);
if (!template) return;
const next = new Map(chosen);
for (const d of template.detectors) if (!next.has(d)) next.set(d, 1);
set(next);
}}
>
<SelectTrigger className="w-72">
<SelectValue placeholder={t('rules.addTemplate', 'Add a template…')} />
</SelectTrigger>
<SelectContent>
{TEMPLATES.map((tpl) => (
<SelectItem key={tpl.id} value={tpl.id}>
{tpl.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="max-h-64 space-y-3 overflow-auto rounded-md border p-2">
{regions.map((region) => (
<div key={region}>
<p className="text-xs font-medium uppercase text-muted-foreground">{region}</p>
{DETECTORS.filter((d) => d.region === region).map((d) => (
<div key={d.id} className="flex items-center gap-2 py-0.5 text-sm">
<Checkbox
checked={chosen.has(d.id)}
onCheckedChange={(on) => {
const next = new Map(chosen);
if (on) next.set(d.id, 1);
else next.delete(d.id);
set(next);
}}
/>
<span className="flex-1">
{d.name}
{d.needsWord && (
<span className="ml-1 text-xs text-muted-foreground">
{t('rules.needsWord', '(with a word nearby)')}
</span>
)}
</span>
{chosen.has(d.id) && (
<label className="flex items-center gap-1 text-xs text-muted-foreground">
{t('rules.atLeast', 'at least')}
<Input
type="number"
min={1}
className="h-7 w-16"
value={chosen.get(d.id)}
onChange={(e) => {
const next = new Map(chosen);
next.set(d.id, Math.max(1, Number(e.target.value) || 1));
set(next);
}}
/>
</label>
)}
</div>
))}
</div>
))}
</div>
</div>
);
}
function ConditionFields({ value, onChange }: { value: Condition; onChange: (c: Condition) => void }) {
const { t } = useTranslation();
const listField = (items: string[], build: (items: string[]) => Condition, hint: string) => (
<Textarea
rows={2}
placeholder={hint}
defaultValue={items.join('\n')}
onBlur={(e) => onChange(build(toList(e.target.value)))}
/>
);
const count = (n: number, build: (n: number) => Condition) => (
<label className="flex items-center gap-2 text-sm text-muted-foreground">
{t('rules.atLeast', 'at least')}
<Input
type="number"
min={1}
className="w-20"
value={n}
onChange={(e) => onChange(build(Math.max(1, Number(e.target.value) || 1)))}
/>
{t('rules.times', 'times')}
</label>
);
const megabytes = (bytes: number, build: (b: number) => Condition) => (
<label className="flex items-center gap-2 text-sm">
<Input
type="number"
min={0}
className="w-28"
value={Math.round(bytes / (1024 * 1024))}
onChange={(e) => onChange(build(Math.max(0, Number(e.target.value) || 0) * 1024 * 1024))}
/>
MB
</label>
);
switch (value.type) {
case 'senderAddress':
case 'recipientAddress':
return listField(value.addresses, (addresses) => ({ type: value.type, addresses }), '[email protected]');
case 'senderDomain':
case 'recipientDomain':
return listField(value.domains, (domains) => ({ type: value.type, domains }), 'example.com');
case 'words':
return (
<div className="space-y-2">
{listField(
value.words,
(words) => ({ ...value, words }),
t('rules.wordsHint', 'One word or phrase per line'),
)}
{count(value.atLeast, (atLeast) => ({ ...value, atLeast }))}
</div>
);
case 'pattern':
return (
<div className="space-y-2">
<Input
className="font-mono"
value={value.pattern}
onChange={(e) => onChange({ ...value, pattern: e.target.value })}
/>
{count(value.atLeast, (atLeast) => ({ ...value, atLeast }))}
</div>
);
case 'header':
return (
<div className="flex flex-wrap gap-2">
<Input
className="w-48"
placeholder="X-Classification"
value={value.name}
onChange={(e) => onChange({ ...value, name: e.target.value })}
/>
<Input
className="flex-1"
placeholder={t('rules.headerContains', 'contains… (empty: the header exists)')}
value={value.contains ?? ''}
onChange={(e) => onChange({ type: 'header', name: value.name, contains: e.target.value || null })}
/>
</div>
);
case 'attachmentType':
return listField(value.types, (types) => ({ type: 'attachmentType', types }), 'application/zip');
case 'attachmentExtension':
return listField(value.extensions, (extensions) => ({ type: 'attachmentExtension', extensions }), 'exe');
case 'attachmentName':
return (
<Input
className="font-mono"
value={value.pattern}
onChange={(e) => onChange({ ...value, pattern: e.target.value })}
/>
);
case 'attachmentSizeOver':
case 'messageSizeOver':
return megabytes(value.bytes, (bytes) => ({ type: value.type, bytes }));
case 'attachmentCountOver':
return (
<Input
type="number"
min={0}
className="w-24"
value={value.count}
onChange={(e) => onChange({ ...value, count: Math.max(0, Number(e.target.value) || 0) })}
/>
);
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>
);
default:
return null;
}
}
function ConditionList({
kind,
items,
onChange,
addLabel,
}: {
kind: Rule['kind'];
items: Condition[];
onChange: (items: Condition[]) => void;
addLabel: string;
}) {
const { t } = useTranslation();
const label = (type: Condition['type']) => CONDITION_TYPES.find((c) => c.type === type)?.label ?? type;
return (
<div className="space-y-2">
{items.map((condition, i) => (
<div key={i} className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{t(`rules.cond.${condition.type}`, label(condition.type))}</span>
<Button variant="ghost" size="icon" onClick={() => onChange(items.filter((_, j) => j !== i))}>
<X className="h-4 w-4" />
</Button>
</div>
<ConditionFields value={condition} onChange={(c) => onChange(items.map((x, j) => (j === i ? c : x)))} />
</div>
))}
<Select value="" onValueChange={(type) => onChange([...items, blankCondition(type as Condition['type'])])}>
<SelectTrigger className="w-72">
<SelectValue placeholder={addLabel} />
</SelectTrigger>
<SelectContent>
{CONDITION_TYPES.filter((c) => kind === 'dlp' || !c.dlpOnly).map((c) => (
<SelectItem key={c.type} value={c.type}>
{t(`rules.cond.${c.type}`, c.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
const TRANSPORT_ACTIONS: { type: Action['type']; label: string }[] = [
{ type: 'addDisclaimer', label: 'Add a disclaimer' },
{ type: 'prefixSubject', label: 'Start the subject with…' },
{ type: 'addHeader', label: 'Add a header' },
{ type: 'removeHeader', label: 'Remove a header' },
{ type: 'addRecipient', label: 'Send a copy to…' },
{ type: 'redirect', label: 'Send it to someone else instead' },
{ type: 'route', label: 'Send it through a queue' },
{ type: 'refuse', label: 'Refuse it' },
];
function blankAction(type: Action['type']): Action {
switch (type) {
case 'addDisclaimer':
return { type, text: '', position: 'bottom' };
case 'addHeader':
return { type, name: '', value: '' };
case 'removeHeader':
return { type, name: '' };
case 'prefixSubject':
return { type, text: '' };
case 'addRecipient':
return { type, address: '' };
case 'redirect':
return { type, addresses: [] };
case 'route':
return { type, queue: '' };
case 'refuse':
return { type, text: '' };
case 'block':
case 'warn':
return { type, notice: '' };
case 'hold':
return { type, notice: '', notifySender: true };
}
}
function ActionFields({ value, onChange }: { value: Action; onChange: (a: Action) => void }) {
const { t } = useTranslation();
switch (value.type) {
case 'addDisclaimer':
return (
<div className="space-y-2">
<Textarea
rows={3}
placeholder={t('rules.disclaimerText', 'The disclaimer')}
value={value.text}
onChange={(e) => onChange({ ...value, text: e.target.value })}
/>
<Textarea
rows={2}
className="font-mono text-xs"
placeholder={t('rules.disclaimerHtml', 'HTML version (optional; the text is used otherwise)')}
value={value.html ?? ''}
onChange={(e) => onChange({ ...value, html: e.target.value || null })}
/>
<Select
value={value.position}
onValueChange={(position) => onChange({ ...value, position: position as 'top' | 'bottom' })}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="bottom">{t('rules.bottom', 'At the bottom')}</SelectItem>
<SelectItem value="top">{t('rules.top', 'At the top')}</SelectItem>
</SelectContent>
</Select>
</div>
);
case 'addHeader':
return (
<div className="flex gap-2">
<Input
className="w-48"
placeholder="X-Tag"
value={value.name}
onChange={(e) => onChange({ ...value, name: e.target.value })}
/>
<Input
className="flex-1"
value={value.value}
onChange={(e) => onChange({ ...value, value: e.target.value })}
/>
</div>
);
case 'removeHeader':
return (
<Input
className="w-64"
placeholder="X-Tag"
value={value.name}
onChange={(e) => onChange({ ...value, name: e.target.value })}
/>
);
case 'prefixSubject':
return (
<Input
className="w-64"
placeholder="[External]"
value={value.text}
onChange={(e) => onChange({ ...value, text: e.target.value })}
/>
);
case 'addRecipient':
return (
<Input
className="w-72"
placeholder="[email protected]"
value={value.address}
onChange={(e) => onChange({ ...value, address: e.target.value })}
/>
);
case 'redirect':
return (
<Textarea
rows={2}
placeholder="[email protected]"
defaultValue={value.addresses.join('\n')}
onBlur={(e) => onChange({ ...value, addresses: toList(e.target.value) })}
/>
);
case 'route':
return (
<Input
className="w-64"
placeholder={t('rules.queueName', 'Queue name')}
value={value.queue}
onChange={(e) => onChange({ ...value, queue: e.target.value })}
/>
);
case 'refuse':
return (
<Textarea
rows={2}
placeholder={t('rules.refuseText', 'What the sender is told')}
value={value.text}
onChange={(e) => onChange({ ...value, text: e.target.value })}
/>
);
case 'block':
case 'warn':
case 'hold':
return null;
}
}
function DlpAction({ value, onChange }: { value: Action; onChange: (a: Action) => void }) {
const { t } = useTranslation();
const notice = 'notice' in value ? value.notice : '';
const choices: { type: 'block' | 'warn' | 'hold'; label: string; body: string }[] = [
{
type: 'warn',
label: t('rules.warn', 'Warn'),
body: t('rules.warnBody', 'The sender sees the notice and may send anyway, giving a reason that’s recorded.'),
},
{
type: 'hold',
label: t('rules.hold', 'Hold for review'),
body: t('rules.holdBody', 'It waits under Held mail until a reviewer releases or rejects it.'),
},
{
type: 'block',
label: t('rules.block', 'Block'),
body: t('rules.blockBody', 'It isn’t sent, and the sender sees the notice.'),
},
];
return (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3">
{choices.map((choice) => (
<button
key={choice.type}
type="button"
onClick={() =>
onChange(
choice.type === 'hold' ? { type: 'hold', notice, notifySender: true } : { type: choice.type, notice },
)
}
className={`rounded-md border p-3 text-left text-sm ${value.type === choice.type ? 'border-primary bg-primary/5' : ''}`}
>
<p className="font-medium">{choice.label}</p>
<p className="text-xs text-muted-foreground">{choice.body}</p>
</button>
))}
</div>
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('rules.notice', 'Notice the sender sees')}</span>
<Textarea rows={2} value={notice} onChange={(e) => onChange({ ...value, notice: e.target.value } as Action)} />
</label>
{value.type === 'hold' && (
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={value.notifySender ?? false}
onCheckedChange={(on) => onChange({ ...value, notifySender: on === true })}
/>
{t('rules.notifySender', 'Tell the sender it’s held')}
</label>
)}
</div>
);
}
export function RuleEditor({ initial, onClose, onSaved }: { initial: Rule; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation();
const [rule, setRule] = useState<Rule>(initial);
const [reason, setReason] = useState('');
const [busy, setBusy] = useState(false);
const dlp = rule.kind === 'dlp';
const patch = (next: Partial<Rule>) => setRule((r) => ({ ...r, ...next }));
const save = async () => {
setBusy(true);
try {
await saveRule(rule, reason);
toast({ title: t('rules.saved', 'Rule saved') });
onSaved();
} catch (e) {
toast({
variant: 'destructive',
title: t('rules.notSaved', 'Not saved'),
description: e instanceof Error ? e.message : String(e),
});
} finally {
setBusy(false);
}
};
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{rule.id
? t('rules.editTitle', 'Change “{{name}}”', { name: initial.name })
: dlp
? t('rules.newDlp', 'New DLP rule')
: t('rules.newFlow', 'New mail flow rule')}
</DialogTitle>
<DialogDescription>{describeRule(rule)}</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-[1fr_auto_auto]">
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">{t('rules.name', 'Name')}</span>
<Input value={rule.name} onChange={(e) => patch({ name: e.target.value })} />
</label>
<label className="space-y-1 text-sm">
<span className="text-muted-foreground">{t('rules.priority', 'Order')}</span>
<Input
type="number"
className="w-24"
value={rule.priority}
onChange={(e) => patch({ priority: Number(e.target.value) || 0 })}
/>
</label>
<label className="flex items-end gap-2 pb-2 text-sm">
<Switch checked={rule.enabled} onCheckedChange={(enabled) => patch({ enabled })} />
{t('rules.enabled', 'On')}
</label>
</div>
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('rules.description', 'Description (optional)')}</span>
<Textarea rows={2} value={rule.description} onChange={(e) => patch({ description: e.target.value })} />
</label>
{!dlp && (
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('rules.direction', 'Which mail')}</span>
<Select value={rule.direction} onValueChange={(direction) => patch({ direction: direction as Direction })}>
<SelectTrigger className="w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="outgoing">{t('rules.outgoing', 'Mail sent from here')}</SelectItem>
<SelectItem value="incoming">{t('rules.incoming', 'Mail arriving here')}</SelectItem>
<SelectItem value="any">{t('rules.any', 'All mail')}</SelectItem>
</SelectContent>
</Select>
</label>
)}
<section className="space-y-2">
<h3 className="text-sm font-semibold">{t('rules.conditions', 'When all of these are true')}</h3>
<ConditionList
kind={rule.kind}
items={rule.conditions}
onChange={(conditions) => patch({ conditions })}
addLabel={t('rules.addCondition', 'Add a condition…')}
/>
</section>
<section className="space-y-2">
<h3 className="text-sm font-semibold">{t('rules.exceptions', 'Except when any of these is true')}</h3>
<ConditionList
kind={rule.kind}
items={rule.exceptions}
onChange={(exceptions) => patch({ exceptions })}
addLabel={t('rules.addException', 'Add an exception…')}
/>
</section>
<section className="space-y-2">
<h3 className="text-sm font-semibold">{t('rules.then', 'Then')}</h3>
{dlp ? (
<DlpAction
value={rule.actions[0] ?? { type: 'warn', notice: '' }}
onChange={(a) => patch({ actions: [a] })}
/>
) : (
<div className="space-y-2">
{rule.actions.map((action, i) => (
<div key={i} className="space-y-2 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{t(
`rules.act.${action.type}`,
TRANSPORT_ACTIONS.find((a) => a.type === action.type)?.label ?? action.type,
)}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => patch({ actions: rule.actions.filter((_, j) => j !== i) })}
>
<X className="h-4 w-4" />
</Button>
</div>
<ActionFields
value={action}
onChange={(a) => patch({ actions: rule.actions.map((x, j) => (j === i ? a : x)) })}
/>
</div>
))}
<Select
value=""
onValueChange={(type) => patch({ actions: [...rule.actions, blankAction(type as Action['type'])] })}
>
<SelectTrigger className="w-72">
<SelectValue placeholder={t('rules.addAction', 'Add an action…')} />
</SelectTrigger>
<SelectContent>
{TRANSPORT_ACTIONS.map((a) => (
<SelectItem key={a.type} value={a.type}>
{t(`rules.act.${a.type}`, a.label)}
</SelectItem>
))}
</SelectContent>
</Select>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={rule.stopProcessing}
onCheckedChange={(on) => patch({ stopProcessing: on === true })}
/>
{t('rules.stop', 'Stop here: later rules don’t run for this message')}
</label>
</div>
)}
</section>
<label className="block space-y-1 text-sm">
<span className="text-muted-foreground">{t('rules.reason', 'Reason, for the audit log (optional)')}</span>
<Input value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
</label>
<DialogFooter>
<Button variant="ghost" onClick={onClose} disabled={busy}>
{t('rules.cancel', 'Cancel')}
</Button>
<Button onClick={() => void save()} disabled={busy || !rule.name.trim() || rule.actions.length === 0}>
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
{t('rules.save', 'Save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+179
View File
@@ -0,0 +1,179 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: Management › Compliance › Data loss prevention, and Settings ›
* Mail flow › Rules (dlp-and-mail-flow-rules spec, §3): the rules of one
* kind, in the order they run, each in words; new, change, switch off,
* delete, with the kind's own permissions.
*/
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pencil, Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { PageHeader } from '@/components/common/PageHeader';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { useAccountStore } from '@/stores/accountStore';
import { toast } from '@/hooks/use-toast';
import { deleteRule, fetchRules, RulesUnavailable, setRuleEnabled } from './api';
import { describeRule, newRule, type Kind, type Rule } from './model';
import { RuleEditor } from './RuleEditor';
type Load = { kind: 'loading' } | { kind: 'ready'; rules: Rule[] } | { kind: 'error'; message: string };
function RulesPage({ kind }: { kind: Kind }) {
const { t } = useTranslation();
const dlp = kind === 'dlp';
const canChange = useAccountStore((s) => s.hasPermission(dlp ? 'sysDlpPolicyUpdate' : 'sysMailRuleUpdate'));
const [load, setLoad] = useState<Load>({ kind: 'loading' });
const [fetches, setFetches] = useState(0);
const [editing, setEditing] = useState<Rule | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const refetch = useCallback(() => setFetches((n) => n + 1), []);
useEffect(() => {
const controller = new AbortController();
fetchRules(kind, controller.signal)
.then((rules) => !controller.signal.aborted && setLoad({ kind: 'ready', rules }))
.catch((e: unknown) => {
if (controller.signal.aborted) return;
setLoad({
kind: 'error',
message:
e instanceof RulesUnavailable
? t('rules.unavailable', 'This server doesn’t have mail rules.')
: e instanceof Error
? e.message
: String(e),
});
});
return () => controller.abort();
}, [kind, fetches, t]);
const act = async (work: () => Promise<void>) => {
try {
await work();
refetch();
} catch (e) {
toast({
variant: 'destructive',
title: t('rules.notSaved', 'Not saved'),
description: e instanceof Error ? e.message : String(e),
});
}
};
return (
<div className="mx-auto max-w-5xl space-y-5">
<PageHeader
icon={dlp ? 'shield-alert' : 'route'}
title={dlp ? t('rules.dlpTitle', 'Data loss prevention') : t('rules.flowTitle', 'Mail flow rules')}
subtitle={
dlp
? t(
'rules.dlpSubtitle',
'Rules that check outgoing mail for what shouldn’t leave (card numbers, national ID numbers, keys, your own words) and warn, hold it for review, or block it. Every match is in the audit log, never what was found.',
)
: t(
'rules.flowSubtitle',
'Rules that change mail as it passes: disclaimers, banners, headers, copies, redirects, refusals. They run after the server’s own Sieve script, in the order below.',
)
}
actions={
canChange && (
<Button onClick={() => setEditing(newRule(kind))}>
<Plus className="mr-2 h-4 w-4" />
{t('rules.new', 'New rule…')}
</Button>
)
}
/>
{load.kind === 'loading' && <LoadingFallback />}
{load.kind === 'error' && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">{load.message}</div>
)}
{load.kind === 'ready' && load.rules.length === 0 && (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
{dlp
? t('rules.noneDlp', 'No DLP rules yet: outgoing mail isn’t checked.')
: t('rules.noneFlow', 'No mail flow rules yet.')}
</div>
)}
{load.kind === 'ready' &&
load.rules.map((rule) => (
<section key={rule.id} className={`space-y-2 rounded-xl border p-4 ${rule.enabled ? '' : 'opacity-60'}`}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<p className="font-medium">
<span className="mr-2 text-xs tabular-nums text-muted-foreground">{rule.priority}</span>
{rule.name}
</p>
<p className="text-sm">{describeRule(rule)}</p>
{rule.description && <p className="text-xs text-muted-foreground">{rule.description}</p>}
</div>
<div className="flex items-center gap-2">
<Switch
checked={rule.enabled}
disabled={!canChange}
onCheckedChange={(on) => void act(() => setRuleEnabled(rule, on))}
aria-label={t('rules.enabled', 'On')}
/>
{canChange && (
<>
<Button variant="outline" size="sm" onClick={() => setEditing(rule)}>
<Pencil className="mr-2 h-4 w-4" />
{t('rules.edit', 'Change…')}
</Button>
{confirming === rule.id ? (
<Button
variant="destructive"
size="sm"
onClick={() => {
setConfirming(null);
if (rule.id) void act(() => deleteRule(rule.id as string));
}}
>
{t('rules.deleteConfirm', 'Delete it')}
</Button>
) : (
<Button
variant="ghost"
size="sm"
onClick={() => setConfirming(rule.id ?? null)}
aria-label={t('rules.delete', 'Delete')}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</>
)}
</div>
</div>
</section>
))}
{editing && (
<RuleEditor
initial={editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
refetch();
}}
/>
)}
</div>
);
}
export function DlpRulesPage() {
return <RulesPage kind="dlp" />;
}
export function MailFlowRulesPage() {
return <RulesPage kind="transport" />;
}
+137
View File
@@ -0,0 +1,137 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: `inbuxa:MailRule` and `inbuxa:HeldMessage` over JMAP
* (dlp-and-mail-flow-rules spec, §2.2, §2.6). The pages draw from here.
*/
import { getAccountId, jmapRequest } from '@/services/jmap/client';
import type { Kind, Rule } from './model';
const CAPABILITY = 'urn:inbuxa:jmap';
/** The server has no mail rules (an older server). */
export class RulesUnavailable extends Error {}
function failure(name: string | undefined, result: unknown): Error {
const r = result as { type?: string; description?: string } | undefined;
if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') return new RulesUnavailable(r.type);
return new Error(r?.description ?? r?.type ?? `${name ?? 'Request'} failed`);
}
async function call(
method: string,
args: Record<string, unknown>,
signal?: AbortSignal,
): Promise<Record<string, unknown>> {
const responses = await jmapRequest([[method, { accountId: getAccountId('x:Account'), ...args }, '0']], signal, [
CAPABILITY,
]);
const [name, result] = responses[0] ?? [];
if (name !== method) throw failure(name, result);
return result as Record<string, unknown>;
}
/** The error the server gave one object of a set call, if it gave one. */
function setFailure(result: Record<string, unknown>, bucket: string, key: string): Error | null {
const failed = (
result[bucket] as Record<string, { type?: string; description?: string; properties?: string[] }> | undefined
)?.[key];
if (!failed) return null;
const where = failed.properties?.length ? ` (${failed.properties.join(', ')})` : '';
return new Error(`${failed.description ?? failed.type ?? 'Not saved'}${where}`);
}
// --- Rules ----------------------------------------------------------------
/** The rules this person may see, in the order they run. */
export async function fetchRules(kind: Kind, signal?: AbortSignal): Promise<Rule[]> {
const result = await call('inbuxa:MailRule/get', { ids: null }, signal);
return ((result.list as Rule[] | undefined) ?? []).filter((rule) => rule.kind === kind);
}
/** What the server stores: everything but what it sets itself. */
function toServer(rule: Rule): Record<string, unknown> {
return {
name: rule.name,
description: rule.description,
kind: rule.kind,
enabled: rule.enabled,
priority: rule.priority,
direction: rule.direction,
conditions: rule.conditions,
exceptions: rule.exceptions,
actions: rule.actions,
stopProcessing: rule.stopProcessing,
};
}
export async function saveRule(rule: Rule, reason?: string): Promise<string | undefined> {
const args: Record<string, unknown> = reason?.trim() ? { reason: reason.trim() } : {};
if (rule.id) {
const result = await call('inbuxa:MailRule/set', { ...args, update: { [rule.id]: toServer(rule) } });
const failed = setFailure(result, 'notUpdated', rule.id);
if (failed) throw failed;
return rule.id;
}
const result = await call('inbuxa:MailRule/set', { ...args, create: { r: toServer(rule) } });
const failed = setFailure(result, 'notCreated', 'r');
if (failed) throw failed;
return (result.created as Record<string, { id?: string }> | undefined)?.r?.id;
}
export async function setRuleEnabled(rule: Rule, enabled: boolean): Promise<void> {
if (!rule.id) return;
const result = await call('inbuxa:MailRule/set', { update: { [rule.id]: { enabled } } });
const failed = setFailure(result, 'notUpdated', rule.id);
if (failed) throw failed;
}
export async function deleteRule(id: string): Promise<void> {
const result = await call('inbuxa:MailRule/set', { destroy: [id] });
const failed = setFailure(result, 'notDestroyed', id);
if (failed) throw failed;
}
// --- Held mail --------------------------------------------------------------
export interface HeldMessage {
id: string;
sender: string;
recipients: string[];
subject: string;
size: number;
rules: { name: string; notice: string }[];
counts: { detector: string; count: number }[];
heldAt: string;
expiresAt: string;
}
export async function fetchHeld(signal?: AbortSignal): Promise<HeldMessage[]> {
const result = await call('inbuxa:HeldMessage/get', { ids: null }, signal);
return (result.list as HeldMessage[] | undefined) ?? [];
}
/** The message's text. The server records every read. */
export async function fetchPreview(id: string): Promise<string> {
const result = await call('inbuxa:HeldMessage/get', { ids: [id], properties: ['id', 'preview'] });
const item = (result.list as { preview?: string | null }[] | undefined)?.[0];
return item?.preview ?? '';
}
export async function decideHeld(
id: string,
decision: 'release' | 'reject',
reason: string,
note?: string,
): Promise<void> {
const patch: Record<string, unknown> = { decision };
if (decision === 'reject' && note?.trim()) patch.note = note.trim();
const result = await call('inbuxa:HeldMessage/set', { reason: reason.trim(), update: { [id]: patch } });
const failed = setFailure(result, 'notUpdated', id);
if (failed) throw failed;
}
+73
View File
@@ -0,0 +1,73 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import { DETECTORS, TEMPLATES, describeRule, detectorName, newRule, type Rule } from './model';
describe('detectors and templates', () => {
it('match the server tables', () => {
// 49 detectors in crates/features/src/mailflow/detectors; regenerate
// model.ts from there when that changes
expect(DETECTORS).toHaveLength(49);
expect(new Set(DETECTORS.map((d) => d.id)).size).toBe(DETECTORS.length);
for (const template of TEMPLATES) {
for (const id of template.detectors) {
expect(
DETECTORS.some((d) => d.id === id),
`${template.id}: ${id}`,
).toBe(true);
}
}
expect(detectorName('payment-card')).toBe('Payment card number');
expect(detectorName('unknown')).toBe('unknown');
});
});
describe('rules in words', () => {
it('describes a DLP rule', () => {
const rule: Rule = {
...newRule('dlp'),
name: 'Cards leaving',
conditions: [
{ type: 'recipientOutside' },
{
type: 'detected',
detectors: [
{ id: 'payment-card', atLeast: 5 },
{ id: 'iban', atLeast: 1 },
],
},
],
exceptions: [{ type: 'senderDomain', domains: ['finance.example.com'] }],
actions: [{ type: 'hold', notice: 'Held', notifySender: true }],
};
expect(describeRule(rule)).toBe(
'If a recipient is outside this server and the message contains 5 or more Payment card number or IBAN, unless the sender is at finance.example.com: hold it for review.',
);
});
it('describes a mail flow rule with no conditions', () => {
const rule: Rule = {
...newRule('transport'),
direction: 'incoming',
conditions: [],
actions: [
{ type: 'prefixSubject', text: '[External]' },
{ type: 'addHeader', name: 'X-Origin', value: 'outside' },
],
};
expect(describeRule(rule)).toBe(
'For all mail arriving here: start the subject with “[External]”, then add the header X-Origin: outside.',
);
});
it('starts new rules the way the server accepts them', () => {
const dlp = newRule('dlp');
expect(dlp.direction).toBe('outgoing');
expect(dlp.actions).toHaveLength(1);
expect(newRule('transport').actions[0].type).toBe('addDisclaimer');
});
});
+317
View File
@@ -0,0 +1,317 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA: DLP and mail flow rules (dlp-and-mail-flow-rules spec, §2.2–§2.4):
* the rule as the server stores it, the detectors and templates it can
* use, and each rule described in words for the form's preview.
*/
export type Kind = 'dlp' | 'transport';
export type Direction = 'outgoing' | 'incoming' | 'any';
export type Position = 'top' | 'bottom';
export interface DetectorMin {
id: string;
atLeast: number;
}
export type Condition =
| { type: 'senderAddress'; addresses: string[] }
| { type: 'senderDomain'; domains: string[] }
| { type: 'recipientAddress'; addresses: string[] }
| { type: 'recipientDomain'; domains: string[] }
| { type: 'recipientOutside' }
| { type: 'words'; words: string[]; atLeast: number }
| { type: 'pattern'; pattern: string; atLeast: number }
| { type: 'header'; name: string; contains?: string | null; matches?: string | null }
| { type: 'attachmentType'; types: string[] }
| { type: 'attachmentExtension'; extensions: string[] }
| { type: 'attachmentName'; pattern: string }
| { type: 'attachmentSizeOver'; bytes: number }
| { type: 'attachmentCountOver'; count: number }
| { 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[] };
export type Action =
| { type: 'addDisclaimer'; text: string; html?: string | null; position: Position }
| { type: 'addHeader'; name: string; value: string }
| { type: 'removeHeader'; name: string }
| { type: 'prefixSubject'; text: string }
| { type: 'addRecipient'; address: string }
| { type: 'redirect'; addresses: string[] }
| { type: 'refuse'; text: string }
| { type: 'route'; queue: string }
| { type: 'block'; notice: string }
| { type: 'warn'; notice: string }
| { type: 'hold'; notice: string; notifySender?: boolean };
export interface Rule {
id?: string;
name: string;
description: string;
kind: Kind;
enabled: boolean;
priority: number;
direction: Direction;
conditions: Condition[];
exceptions: Condition[];
actions: Action[];
stopProcessing: boolean;
createdBy?: string;
createdAt?: string;
updatedAt?: string;
}
export interface Detector {
id: string;
name: string;
region: string;
needsWord: boolean;
}
export interface Template {
id: string;
name: string;
detectors: string[];
}
/** Generated from the server's detector tables (crates/features/src/mailflow/detectors). */
export const DETECTORS: Detector[] = [
{ id: 'payment-card', name: 'Payment card number', region: 'Any', needsWord: false },
{ id: 'iban', name: 'IBAN', region: 'Any', needsWord: false },
{ id: 'swift-bic', name: 'SWIFT/BIC code', region: 'Any', needsWord: true },
{ id: 'email-addresses', name: 'Email addresses', region: 'Any', needsWord: false },
{ id: 'phone-numbers', name: 'Phone numbers', region: 'Any', needsWord: true },
{ id: 'date-of-birth', name: 'Date of birth', region: 'Any', needsWord: true },
{ id: 'passport', name: 'Passport number', region: 'Any', needsWord: true },
{ id: 'private-key', name: 'Private key', region: 'Any', needsWord: false },
{ id: 'credentials', name: 'Cloud and service credentials', region: 'Any', needsWord: false },
{ id: 'us-ssn', name: 'US Social Security number', region: 'Us', needsWord: false },
{ id: 'us-itin', name: 'US ITIN', region: 'Us', needsWord: false },
{ id: 'us-ein', name: 'US EIN', region: 'Us', needsWord: true },
{ id: 'us-aba-routing', name: 'US bank routing number', region: 'Us', needsWord: true },
{ id: 'us-drivers-license', name: "US driver's license", region: 'Us', needsWord: true },
{ id: 'us-mbi', name: 'US Medicare Beneficiary Identifier', region: 'Us', needsWord: false },
{ id: 'us-npi', name: 'US National Provider Identifier', region: 'Us', needsWord: true },
{ id: 'us-dea', name: 'US DEA registration number', region: 'Us', needsWord: false },
{ id: 'uk-nino', name: 'UK National Insurance number', region: 'Uk', needsWord: false },
{ id: 'uk-nhs', name: 'UK NHS number', region: 'Uk', needsWord: false },
{ id: 'uk-utr', name: 'UK Unique Taxpayer Reference', region: 'Uk', needsWord: true },
{ id: 'ca-sin', name: 'Canadian Social Insurance Number', region: 'Canada', needsWord: false },
{ id: 'au-tfn', name: 'Australian Tax File Number', region: 'Australia', needsWord: false },
{ id: 'au-medicare', name: 'Australian Medicare number', region: 'Australia', needsWord: false },
{ id: 'de-tax-id', name: 'Germany: tax ID (Steuer-ID)', region: 'Eu', needsWord: false },
{ id: 'de-id-card', name: 'Germany: ID card number', region: 'Eu', needsWord: false },
{ id: 'fr-nir', name: 'France: social security number (NIR)', region: 'Eu', needsWord: false },
{ id: 'es-dni-nie', name: 'Spain: DNI and NIE', region: 'Eu', needsWord: false },
{ id: 'it-codice-fiscale', name: 'Italy: codice fiscale', region: 'Eu', needsWord: false },
{ id: 'nl-bsn', name: 'Netherlands: BSN', region: 'Eu', needsWord: false },
{ id: 'be-national-number', name: 'Belgium: national number', region: 'Eu', needsWord: false },
{ id: 'pl-pesel', name: 'Poland: PESEL', region: 'Eu', needsWord: false },
{ id: 'se-personnummer', name: 'Sweden: personnummer', region: 'Eu', needsWord: false },
{ id: 'dk-cpr', name: 'Denmark: CPR number', region: 'Eu', needsWord: true },
{ id: 'fi-hetu', name: 'Finland: personal identity code', region: 'Eu', needsWord: false },
{ id: 'ie-pps', name: 'Ireland: PPS number', region: 'Eu', needsWord: false },
{ id: 'pt-nif', name: 'Portugal: NIF', region: 'Eu', needsWord: false },
{ id: 'at-svnr', name: 'Austria: social insurance number', region: 'Eu', needsWord: false },
{ id: 'no-fnr', name: 'Norway: national identity number', region: 'Europe', needsWord: false },
{ id: 'ch-ahv', name: 'Switzerland: AHV number', region: 'Europe', needsWord: false },
{ id: 'in-aadhaar', name: 'India: Aadhaar', region: 'Asia', needsWord: false },
{ id: 'in-pan', name: 'India: PAN', region: 'Asia', needsWord: true },
{ id: 'cn-resident-id', name: 'China: resident ID', region: 'Asia', needsWord: false },
{ id: 'jp-my-number', name: 'Japan: My Number', region: 'Asia', needsWord: false },
{ id: 'sg-nric', name: 'Singapore: NRIC and FIN', region: 'Asia', needsWord: false },
{ id: 'kr-rrn', name: 'South Korea: resident registration number', region: 'Asia', needsWord: true },
{ id: 'br-cpf', name: 'Brazil: CPF', region: 'Americas', needsWord: false },
{ id: 'br-cnpj', name: 'Brazil: CNPJ', region: 'Americas', needsWord: false },
{ id: 'mx-curp', name: 'Mexico: CURP', region: 'Americas', needsWord: false },
{ id: 'za-id', name: 'South Africa: ID number', region: 'Africa', needsWord: false },
];
export const TEMPLATES: Template[] = [
{
id: 'payment-and-bank',
name: 'Payment cards and bank accounts',
detectors: ['payment-card', 'iban', 'swift-bic', 'us-aba-routing'],
},
{
id: 'us-personal',
name: 'US personal identifiers',
detectors: ['us-ssn', 'us-itin', 'us-ein', 'us-drivers-license', 'passport', 'date-of-birth'],
},
{
id: 'uk-personal',
name: 'UK personal identifiers',
detectors: ['uk-nino', 'uk-utr', 'uk-nhs', 'passport', 'date-of-birth'],
},
{
id: 'eu-national',
name: 'EU national identifiers',
detectors: [
'de-tax-id',
'de-id-card',
'fr-nir',
'es-dni-nie',
'it-codice-fiscale',
'nl-bsn',
'be-national-number',
'pl-pesel',
'se-personnummer',
'dk-cpr',
'fi-hetu',
'ie-pps',
'pt-nif',
'at-svnr',
],
},
{ id: 'health', name: 'Health identifiers', detectors: ['uk-nhs', 'us-mbi', 'us-npi', 'us-dea', 'au-medicare'] },
{ id: 'credentials', name: 'Credentials and keys', detectors: ['private-key', 'credentials'] },
{ id: 'contact-lists', name: 'Contact lists', detectors: ['email-addresses', 'phone-numbers'] },
];
export function detectorName(id: string): string {
return DETECTORS.find((d) => d.id === id)?.name ?? id;
}
/** A new rule of a kind, ready for the form. */
export function newRule(kind: Kind): Rule {
return kind === 'dlp'
? {
name: '',
description: '',
kind,
enabled: true,
priority: 10,
direction: 'outgoing',
conditions: [{ type: 'recipientOutside' }, { type: 'detected', detectors: [] }],
exceptions: [],
actions: [{ type: 'warn', notice: '' }],
stopProcessing: false,
}
: {
name: '',
description: '',
kind,
enabled: true,
priority: 10,
direction: 'outgoing',
conditions: [],
exceptions: [],
actions: [{ type: 'addDisclaimer', text: '', position: 'bottom' }],
stopProcessing: false,
};
}
function list(items: string[], none = 'nothing'): string {
if (items.length === 0) return none;
if (items.length === 1) return items[0];
return `${items.slice(0, -1).join(', ')} or ${items[items.length - 1]}`;
}
function size(bytes: number): string {
if (bytes >= 1024 * 1024) return `${Math.round(bytes / (1024 * 1024))} MB`;
if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB`;
return `${bytes} bytes`;
}
export function describeCondition(c: Condition): string {
switch (c.type) {
case 'senderAddress':
return `the sender is ${list(c.addresses)}`;
case 'senderDomain':
return `the sender is at ${list(c.domains)}`;
case 'senderGroup':
return `the sender is in one of ${c.groups.length} groups`;
case 'senderTenant':
return `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`;
case 'recipientOutside':
return 'a recipient is outside this server';
case 'words':
return `the message contains ${c.atLeast > 1 ? `${c.atLeast} or more of ` : ''}${list(c.words.map((w) => `“${w}”`))}`;
case 'pattern':
return `the message matches /${c.pattern}/${c.atLeast > 1 ? ` ${c.atLeast} or more times` : ''}`;
case 'header':
return c.contains
? `the ${c.name} header contains “${c.contains}”`
: c.matches
? `the ${c.name} header matches /${c.matches}/`
: `the message has a ${c.name} header`;
case 'attachmentType':
return `an attachment is ${list(c.types)}`;
case 'attachmentExtension':
return `an attachment ends in ${list(c.extensions.map((e) => `.${e.replace(/^\./, '')}`))}`;
case 'attachmentName':
return `an attachment's name matches /${c.pattern}/`;
case 'attachmentSizeOver':
return `an attachment is over ${size(c.bytes)}`;
case 'attachmentCountOver':
return `there are more than ${c.count} attachments`;
case 'cantBeInspected':
return 'an attachment can’t be inspected';
case 'messageSizeOver':
return `the message is over ${size(c.bytes)}`;
case 'detected':
return `the message contains ${list(
c.detectors.map((d) => `${d.atLeast > 1 ? `${d.atLeast} or more ` : ''}${detectorName(d.id)}`),
'no detectors yet',
)}`;
}
}
export function describeAction(a: Action): string {
switch (a.type) {
case 'addDisclaimer':
return `add a disclaimer at the ${a.position}`;
case 'addHeader':
return `add the header ${a.name}: ${a.value}`;
case 'removeHeader':
return `remove the ${a.name} header`;
case 'prefixSubject':
return `start the subject with “${a.text}”`;
case 'addRecipient':
return `send a copy to ${a.address}`;
case 'redirect':
return `send it to ${list(a.addresses)} instead`;
case 'refuse':
return 'refuse it';
case 'route':
return `send it through the ${a.queue} queue`;
case 'block':
return 'block it';
case 'warn':
return 'warn the sender, who may send anyway with a reason';
case 'hold':
return 'hold it for review';
}
}
/** "If a recipient is outside … and …, unless …, hold it for review." */
export function describeRule(rule: Rule): string {
const direction =
rule.kind === 'dlp' || rule.direction === 'outgoing'
? 'mail sent from here'
: rule.direction === 'incoming'
? 'mail arriving here'
: 'any mail';
const when = rule.conditions.length
? `If ${rule.conditions.map(describeCondition).join(' and ')}`
: `For all ${direction}`;
const unless = rule.exceptions.length ? `, unless ${rule.exceptions.map(describeCondition).join(' or ')}` : '';
const then = rule.actions.length ? rule.actions.map(describeAction).join(', then ') : 'do nothing';
return `${when}${unless}: ${then}.`;
}
+11
View File
@@ -90,6 +90,17 @@ function checkSpecialLink(
return { visible: canGet ? canGet('sysCompliance') : true, enterprise: false };
}
// INBUXA: DLP and mail flow rules, and held mail (dlp-and-mail-flow-rules spec, §2.8)
if (viewName === 'CustomComponent/DlpRules') {
return { visible: canGet ? canGet('sysDlpPolicy') : true, enterprise: false };
}
if (viewName === 'CustomComponent/HeldMail') {
return { visible: canGet ? canGet('sysDlpReview') : true, enterprise: false };
}
if (viewName === 'CustomComponent/MailFlowRules') {
return { visible: canGet ? canGet('sysMailRule') : true, enterprise: false };
}
// inbuxa: locked accounts, for whoever may see them (AL-12).
if (viewName === 'CustomComponent/AccountLocks') {
return { visible: canGet ? canGet('sysAccountLock') : true, enterprise: false };
+1
View File
@@ -92,6 +92,7 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [
pages: [
p('x:MtaMilter', 'Milters'),
p('x:MtaHook', 'Hooks'),
p('CustomComponent/MailFlowRules', 'Rules'),
p('x:SieveSystemScript', 'Server Sieve scripts'),
p('x:SieveSystemInterpreter', 'Server Sieve interpreter'),
],