diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 90a0955..d162027 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -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 ; } + if (componentName === 'DlpRules') { + return ; + } + if (componentName === 'MailFlowRules') { + return ; + } + if (componentName === 'HeldMail') { + return ; + } if (componentName === 'DataInventory') { return ; } diff --git a/src/features/dlp/HeldMailPage.tsx b/src/features/dlp/HeldMailPage.tsx new file mode 100644 index 0000000..c3da00f --- /dev/null +++ b/src/features/dlp/HeldMailPage.tsx @@ -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(null); + const [failed, setFailed] = useState(null); + useEffect(() => { + fetchPreview(message.id) + .then(setText) + .catch((e: unknown) => setFailed(e instanceof Error ? e.message : String(e))); + }, [message.id]); + return ( + !open && onClose()}> + + + {message.subject || t('held.noSubject', '(no subject)')} + + {t('held.readRecorded', 'From {{sender}}. Reading it is recorded in the audit log.', { + sender: message.sender, + })} + + + {failed ? ( +

{failed}

+ ) : text === null ? ( + + ) : ( +
+            {text}
+          
+ )} +
+
+ ); +} + +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 ( + !open && onClose()}> + + + + {release + ? t('held.releaseTitle', 'Release “{{subject}}”', { subject: message.subject }) + : t('held.rejectTitle', 'Reject “{{subject}}”', { subject: message.subject })} + + + {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, + })} + + + {!release && ( +