Journal: search, read, export, journals and the chain check #62
No files matched your search
@@ -79,6 +79,11 @@ const MailFlowRulesPage = lazyFeature(
|
||||
() => import('@/features/dlp/RulesPage'),
|
||||
(m) => m.MailFlowRulesPage,
|
||||
);
|
||||
// INBUXA: journaling (journaling spec, §3)
|
||||
const JournalPage = lazyFeature(
|
||||
() => import('@/features/journal/JournalPage'),
|
||||
(m) => m.JournalPage,
|
||||
);
|
||||
const HeldMailPage = lazyFeature(
|
||||
() => import('@/features/dlp/HeldMailPage'),
|
||||
(m) => m.HeldMailPage,
|
||||
@@ -240,6 +245,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
if (componentName === 'MailFlowRules') {
|
||||
return <MailFlowRulesPage />;
|
||||
}
|
||||
if (componentName === 'Journal') {
|
||||
return <JournalPage />;
|
||||
}
|
||||
if (componentName === 'HeldMail') {
|
||||
return <HeldMailPage />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: picking directory objects by id, each shown by its name: DLP
|
||||
* rule conditions and journal scopes.
|
||||
*/
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
import { ObjectPicker } from '@/components/common/ObjectPicker';
|
||||
import { useObjectLabel } from '@/lib/objectOptions';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/** Accounts, groups, domains or tenants, picked from the directory. */
|
||||
export 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>
|
||||
);
|
||||
}
|
||||
@@ -28,9 +28,8 @@ 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 { IdPicker } from './IdPicker';
|
||||
import { JournalSelect } from '@/features/journal/JournalSelect';
|
||||
import { saveRule } from './api';
|
||||
import { DETECTORS, TEMPLATES, describeRule, type Action, type Condition, type Direction, type Rule } from './model';
|
||||
|
||||
@@ -185,53 +184,6 @@ 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) => (
|
||||
@@ -416,6 +368,7 @@ const TRANSPORT_ACTIONS: { type: Action['type']; label: string }[] = [
|
||||
{ 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: 'journal', label: 'Journal it' },
|
||||
{ type: 'refuse', label: 'Refuse it' },
|
||||
];
|
||||
|
||||
@@ -435,6 +388,8 @@ function blankAction(type: Action['type']): Action {
|
||||
return { type, addresses: [] };
|
||||
case 'route':
|
||||
return { type, queue: '' };
|
||||
case 'journal':
|
||||
return { type, journal: '' };
|
||||
case 'refuse':
|
||||
return { type, text: '' };
|
||||
case 'block':
|
||||
@@ -539,6 +494,8 @@ function ActionFields({ value, onChange }: { value: Action; onChange: (a: Action
|
||||
onChange={(e) => onChange({ ...value, queue: e.target.value })}
|
||||
/>
|
||||
);
|
||||
case 'journal':
|
||||
return <JournalSelect value={value.journal} onChange={(journal) => onChange({ ...value, journal })} />;
|
||||
case 'refuse':
|
||||
return (
|
||||
<Textarea
|
||||
@@ -710,10 +667,27 @@ export function RuleEditor({ initial, onClose, onSaved }: { initial: Rule; onClo
|
||||
<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-3">
|
||||
<DlpAction
|
||||
value={rule.actions.find((a) => a.type !== 'journal') ?? { type: 'warn', notice: '' }}
|
||||
onChange={(a) => patch({ actions: [a, ...rule.actions.filter((x) => x.type === 'journal')] })}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">{t('rules.dlpJournal', 'Also journal it')}</span>
|
||||
<JournalSelect
|
||||
allowNone
|
||||
value={rule.actions.find((a) => a.type === 'journal')?.journal ?? ''}
|
||||
onChange={(journal) =>
|
||||
patch({
|
||||
actions: [
|
||||
...rule.actions.filter((a) => a.type !== 'journal'),
|
||||
...(journal ? [{ type: 'journal' as const, journal }] : []),
|
||||
],
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{rule.actions.map((action, i) => (
|
||||
|
||||
@@ -50,6 +50,7 @@ export type Action =
|
||||
| { type: 'redirect'; addresses: string[] }
|
||||
| { type: 'refuse'; text: string }
|
||||
| { type: 'route'; queue: string }
|
||||
| { type: 'journal'; journal: string }
|
||||
| { type: 'block'; notice: string }
|
||||
| { type: 'warn'; notice: string }
|
||||
| { type: 'hold'; notice: string; notifySender?: boolean };
|
||||
@@ -297,6 +298,8 @@ export function describeAction(a: Action): string {
|
||||
return 'refuse it';
|
||||
case 'route':
|
||||
return `send it through the ${a.queue} queue`;
|
||||
case 'journal':
|
||||
return 'journal it';
|
||||
case 'block':
|
||||
return 'block it';
|
||||
case 'warn':
|
||||
|
||||
@@ -0,0 +1,766 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: Management › Compliance › Journal (journaling spec, §3). Two
|
||||
* tabs: Search (who may search: find, read and export what was journaled,
|
||||
* each recorded) and Journals (what's journaled, where it goes and for how
|
||||
* long, and the check of the chain).
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Download, Eye, Loader2, Pencil, Plus, Search, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
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 { IdPicker } from '@/features/dlp/IdPicker';
|
||||
import {
|
||||
deleteJournal,
|
||||
exportEntries,
|
||||
fetchJournals,
|
||||
fetchReport,
|
||||
JournalUnavailable,
|
||||
PAGE,
|
||||
saveJournal,
|
||||
searchEntries,
|
||||
setJournalEnabled,
|
||||
verifyJournal,
|
||||
type ChainReport,
|
||||
} from './api';
|
||||
import {
|
||||
describeJournal,
|
||||
emptyFilter,
|
||||
MAX_RETENTION_DAYS,
|
||||
MIN_RETENTION_DAYS,
|
||||
newJournal,
|
||||
problem,
|
||||
whoOf,
|
||||
type EntryFilter,
|
||||
type Journal,
|
||||
type JournalDirection,
|
||||
type JournalEntry,
|
||||
type Who,
|
||||
} from './model';
|
||||
|
||||
function when(iso: string | null | undefined): string {
|
||||
return iso ? new Date(iso).toLocaleString() : '';
|
||||
}
|
||||
|
||||
function message(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
// --- Search ---------------------------------------------------------------
|
||||
|
||||
function ReadDialog({ entry, onClose }: { entry: JournalEntry; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
fetchReport(entry.id)
|
||||
.then(setText)
|
||||
.catch((e: unknown) => setFailed(message(e)));
|
||||
}, [entry.id]);
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{entry.subject || t('journal.noSubject', '(no subject)')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'journal.readRecorded',
|
||||
'The journal report: the envelope, then the message as it was sent. Reading it is recorded in the audit log.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{failed ? (
|
||||
<p className="text-sm text-destructive">{failed}</p>
|
||||
) : text === null ? (
|
||||
<LoadingFallback />
|
||||
) : (
|
||||
<pre className="max-h-[65vh] overflow-auto whitespace-pre-wrap break-all rounded-md border bg-muted/40 p-3 text-xs">
|
||||
{text}
|
||||
</pre>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportDialog({ filter, total, onClose }: { filter: EntryFilter; total: number; onClose: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const { count, sha256 } = await exportEntries(filter, reason);
|
||||
toast({
|
||||
title: t('journal.exported', '{{count}} reports exported', { count }),
|
||||
description: t('journal.exportedHash', 'SHA-256 {{sha256}}', { sha256 }),
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: t('journal.exportFailed', 'Not exported'), description: message(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('journal.exportTitle', 'Export {{count}} reports', { count: total })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'journal.exportBody',
|
||||
'A ZIP of the journal reports this search finds, with a manifest that lists each one’s SHA-256. The export and its reason are recorded in the audit log.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('journal.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('journal.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void run()} disabled={busy || !reason.trim()}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||
{t('journal.export', 'Export')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
type Results = { entries: JournalEntry[]; total: number; filter: EntryFilter };
|
||||
|
||||
function SearchTab({ journals }: { journals: Journal[] }) {
|
||||
const { t } = useTranslation();
|
||||
const canExport = useAccountStore((s) => s.hasPermission('sysJournalExport'));
|
||||
const [filter, setFilter] = useState<EntryFilter>(emptyFilter());
|
||||
const [results, setResults] = useState<Results | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [reading, setReading] = useState<JournalEntry | null>(null);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const patch = (next: Partial<EntryFilter>) => setFilter((f) => ({ ...f, ...next }));
|
||||
const journalName = (id: string) => journals.find((j) => j.id === id)?.name ?? id;
|
||||
|
||||
const run = async (from: EntryFilter, position: number) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const page = await searchEntries(from, position);
|
||||
setResults((current) => ({
|
||||
entries: position === 0 || !current ? page.entries : [...current.entries, ...page.entries],
|
||||
total: page.total,
|
||||
filter: from,
|
||||
}));
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('journal.searchFailed', 'The search didn’t run'),
|
||||
description: message(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
className="grid gap-3 rounded-xl border p-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void run(filter, 0);
|
||||
}}
|
||||
>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('journal.address', 'Sender or recipient')}</span>
|
||||
<Input
|
||||
value={filter.address}
|
||||
placeholder="[email protected]"
|
||||
onChange={(e) => patch({ address: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('journal.subjectWords', 'Words in the subject')}</span>
|
||||
<Input value={filter.text} onChange={(e) => patch({ text: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('journal.which', 'Which mail')}</span>
|
||||
<Select value={filter.direction} onValueChange={(d) => patch({ direction: d as JournalDirection })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">{t('journal.dirAny', 'All mail')}</SelectItem>
|
||||
<SelectItem value="outgoing">{t('journal.dirOutgoing', 'Sent outside')}</SelectItem>
|
||||
<SelectItem value="incoming">{t('journal.dirIncoming', 'Arriving from outside')}</SelectItem>
|
||||
<SelectItem value="internal">{t('journal.dirInternal', 'Between people here')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('journal.from', 'From (date)')}</span>
|
||||
<Input type="date" value={filter.from} onChange={(e) => patch({ from: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('journal.to', 'To (date)')}</span>
|
||||
<Input type="date" value={filter.to} onChange={(e) => patch({ to: e.target.value })} />
|
||||
</label>
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('journal.inJournal', 'Journal')}</span>
|
||||
<Select
|
||||
value={filter.journalId || '__all__'}
|
||||
onValueChange={(id) => patch({ journalId: id === '__all__' ? '' : id })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{t('journal.anyJournal', 'Any journal')}</SelectItem>
|
||||
{journals.map((j) => (
|
||||
<SelectItem key={j.id} value={j.id ?? ''}>
|
||||
{j.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:col-span-2 lg:col-span-3">
|
||||
<Button type="submit" disabled={busy}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Search className="mr-2 h-4 w-4" />}
|
||||
{t('journal.search', 'Search')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('journal.searchRecorded', 'Each search is recorded in the audit log, with what was searched for.')}
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{results && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('journal.found', '{{count}} found', { count: results.total })}
|
||||
</p>
|
||||
{canExport && results.total > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => setExporting(true)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
{t('journal.exportAction', 'Export…')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{results && results.entries.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
{t('journal.nothingFound', 'Nothing journaled matches.')}
|
||||
</div>
|
||||
)}
|
||||
{results?.entries.map((entry) => (
|
||||
<section key={entry.id} className="space-y-2 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">{entry.subject || t('journal.noSubject', '(no subject)')}</p>
|
||||
<p className="break-all text-sm text-muted-foreground">
|
||||
{t('journal.fromTo', '{{sender}} to {{to}}', {
|
||||
sender: entry.sender || '<>',
|
||||
to: entry.recipients.join(', '),
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{when(entry.receivedAt)} · {formatSize(entry.size)} ·{' '}
|
||||
{t('journal.keptUntil', 'kept until {{date}}', {
|
||||
date: new Date(entry.expiresAt).toLocaleDateString(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setReading(entry)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
{t('journal.read', 'Read…')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
{entry.direction === 'outgoing'
|
||||
? t('journal.dirOutgoing', 'Sent outside')
|
||||
: entry.direction === 'incoming'
|
||||
? t('journal.dirIncoming', 'Arriving from outside')
|
||||
: t('journal.dirInternal', 'Between people here')}
|
||||
</Badge>
|
||||
{entry.held && <Badge variant="secondary">{t('journal.heldBadge', 'Held for review')}</Badge>}
|
||||
{entry.journalIds.map((id) => (
|
||||
<Badge key={id} variant="secondary">
|
||||
{journalName(id)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
{results && results.entries.length < results.total && (
|
||||
<Button variant="outline" disabled={busy} onClick={() => void run(results.filter, results.entries.length)}>
|
||||
{t('journal.more', 'Show {{count}} more', { count: Math.min(PAGE, results.total - results.entries.length) })}
|
||||
</Button>
|
||||
)}
|
||||
{reading && <ReadDialog entry={reading} onClose={() => setReading(null)} />}
|
||||
{exporting && results && (
|
||||
<ExportDialog filter={results.filter} total={results.total} onClose={() => setExporting(false)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Journals -------------------------------------------------------------
|
||||
|
||||
function JournalEditor({ initial, onClose, onSaved }: { initial: Journal; onClose: () => void; onSaved: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [journal, setJournal] = useState<Journal>(initial);
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const patch = (next: Partial<Journal>) => setJournal((j) => ({ ...j, ...next }));
|
||||
const scope = journal.scope;
|
||||
const setScope = (next: Partial<Journal['scope']>) => patch({ scope: { ...scope, ...next } });
|
||||
const [who, setWho] = useState<Who>(whoOf(initial.scope));
|
||||
const issue =
|
||||
who === 'chosen' && whoOf(scope) !== 'chosen' ? t('journal.chooseSomeone', 'Choose who.') : problem(journal);
|
||||
|
||||
const chooseWho = (next: Who) => {
|
||||
setWho(next);
|
||||
if (next === 'everyone') setScope({ everyone: true, accounts: [], groups: [], domains: [], tenants: [] });
|
||||
if (next === 'rules') setScope({ everyone: false, accounts: [], groups: [], domains: [], tenants: [] });
|
||||
if (next === 'chosen') setScope({ everyone: false });
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await saveJournal(journal, reason);
|
||||
toast({ title: t('journal.saved', 'Journal saved') });
|
||||
onSaved();
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: t('journal.notSaved', 'Not saved'), description: message(e) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const whoChoices: { value: Who; label: string; body: string }[] = [
|
||||
{
|
||||
value: 'everyone',
|
||||
label: t('journal.whoEveryone', 'Everyone'),
|
||||
body: t('journal.whoEveryoneBody', 'All mail this server handles, in the direction below.'),
|
||||
},
|
||||
{
|
||||
value: 'chosen',
|
||||
label: t('journal.whoChosen', 'Chosen people'),
|
||||
body: t('journal.whoChosenBody', 'Mail to or from accounts, groups, domains or tenants you choose.'),
|
||||
},
|
||||
{
|
||||
value: 'rules',
|
||||
label: t('journal.whoRules', 'Only what rules send'),
|
||||
body: t('journal.whoRulesBody', 'Mail a mail flow or DLP rule sends here with Journal it.'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-h-[90vh] max-w-3xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{journal.id
|
||||
? t('journal.editTitle', 'Change “{{name}}”', { name: initial.name })
|
||||
: t('journal.newTitle', 'New journal')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{describeJournal(journal)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
|
||||
<label className="space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('journal.name', 'Name')}</span>
|
||||
<Input value={journal.name} onChange={(e) => patch({ name: e.target.value })} />
|
||||
</label>
|
||||
<label className="flex items-end gap-2 pb-2 text-sm">
|
||||
<Switch checked={journal.enabled} onCheckedChange={(enabled) => patch({ enabled })} />
|
||||
{t('journal.on', 'On')}
|
||||
</label>
|
||||
</div>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('journal.description', 'Description (optional)')}</span>
|
||||
<Textarea rows={2} value={journal.description} onChange={(e) => patch({ description: e.target.value })} />
|
||||
</label>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('journal.whose', 'Whose mail')}</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-3">
|
||||
{whoChoices.map((choice) => (
|
||||
<button
|
||||
key={choice.value}
|
||||
type="button"
|
||||
onClick={() => chooseWho(choice.value)}
|
||||
className={`rounded-md border p-3 text-left text-sm ${who === choice.value ? 'border-primary bg-primary/5' : ''}`}
|
||||
>
|
||||
<p className="font-medium">{choice.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{choice.body}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{who === 'chosen' && (
|
||||
<div className="space-y-2 rounded-md border p-3 text-sm">
|
||||
<IdPicker
|
||||
objectName="x:Account"
|
||||
labelObject="x:Account"
|
||||
ids={scope.accounts}
|
||||
onChange={(accounts) => setScope({ accounts })}
|
||||
placeholder={t('journal.addAccount', 'Add an account…')}
|
||||
/>
|
||||
<IdPicker
|
||||
objectName="x:Account/Group"
|
||||
labelObject="x:Account"
|
||||
ids={scope.groups}
|
||||
onChange={(groups) => setScope({ groups })}
|
||||
placeholder={t('journal.addGroup', 'Add a group…')}
|
||||
/>
|
||||
<IdPicker
|
||||
objectName="x:Domain"
|
||||
labelObject="x:Domain"
|
||||
ids={scope.domains}
|
||||
onChange={(domains) => setScope({ domains })}
|
||||
placeholder={t('journal.addDomain', 'Add a domain…')}
|
||||
/>
|
||||
<IdPicker
|
||||
objectName="x:Tenant"
|
||||
labelObject="x:Tenant"
|
||||
ids={scope.tenants}
|
||||
onChange={(tenants) => setScope({ tenants })}
|
||||
placeholder={t('journal.addTenant', 'Add a tenant…')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{who !== 'rules' && (
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">{t('journal.which', 'Which mail')}</span>
|
||||
<Select value={journal.direction} onValueChange={(d) => patch({ direction: d as JournalDirection })}>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="any">{t('journal.dirAny', 'All mail')}</SelectItem>
|
||||
<SelectItem value="outgoing">{t('journal.dirOutgoing', 'Sent outside')}</SelectItem>
|
||||
<SelectItem value="incoming">{t('journal.dirIncoming', 'Arriving from outside')}</SelectItem>
|
||||
<SelectItem value="internal">{t('journal.dirInternal', 'Between people here')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</label>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('journal.whereTitle', 'Where it goes')}</h3>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={journal.builtIn} onCheckedChange={(on) => patch({ builtIn: on === true })} />
|
||||
{t('journal.builtIn', 'The built-in journal, searchable here')}
|
||||
</label>
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t('journal.archiveAddress', 'An outside archive’s journal address (optional)')}
|
||||
</span>
|
||||
<Input
|
||||
className="w-96 max-w-full"
|
||||
placeholder="[email protected]"
|
||||
value={journal.archiveAddress ?? ''}
|
||||
onChange={(e) => patch({ archiveAddress: e.target.value || null })}
|
||||
/>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t(
|
||||
'journal.archiveHint',
|
||||
'Each report is sent there as mail. One the archive doesn’t take is kept in the built-in journal instead, and shown here.',
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t('journal.retention', 'Keep entries for (days, {{min}} to {{max}})', {
|
||||
min: MIN_RETENTION_DAYS,
|
||||
max: MAX_RETENTION_DAYS,
|
||||
})}
|
||||
</span>
|
||||
<Input
|
||||
type="number"
|
||||
className="w-32"
|
||||
min={MIN_RETENTION_DAYS}
|
||||
max={MAX_RETENTION_DAYS}
|
||||
value={journal.retentionDays}
|
||||
onChange={(e) => patch({ retentionDays: Number(e.target.value) })}
|
||||
/>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t(
|
||||
'journal.retentionHint',
|
||||
'Each entry keeps the time it was written with: changing this applies to new entries. Entries about someone under a legal hold stay while the hold does.',
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="block space-y-1 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t('journal.changeReason', 'Reason, for the audit log (optional)')}
|
||||
</span>
|
||||
<Input value={reason} maxLength={500} onChange={(e) => setReason(e.target.value)} />
|
||||
</label>
|
||||
{issue && <p className="text-sm text-muted-foreground">{issue}</p>}
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose} disabled={busy}>
|
||||
{t('journal.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={() => void save()} disabled={busy || issue !== null}>
|
||||
{busy ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
|
||||
{t('journal.save', 'Save')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckResult({ chains, verified }: { chains: ChainReport[]; verified: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`rounded-md border p-3 text-sm ${verified ? '' : 'border-destructive'}`}>
|
||||
<p className="font-medium">
|
||||
{verified
|
||||
? t('journal.checkOk', 'The journal checks out: nothing was changed or removed before its time.')
|
||||
: t('journal.checkBroken', 'The journal doesn’t check out.')}
|
||||
</p>
|
||||
{chains.map((chain) => (
|
||||
<p key={chain.node} className="text-xs text-muted-foreground">
|
||||
{chain.brokenAt
|
||||
? t('journal.chainBroken', 'Node {{node}}: at {{at}}, {{reason}}', {
|
||||
node: chain.node,
|
||||
at: chain.brokenAt,
|
||||
reason: chain.reason ?? '',
|
||||
})
|
||||
: t('journal.chainOk', 'Node {{node}}: {{entries}} entries, {{purged}} past their time and purged', {
|
||||
node: chain.node,
|
||||
entries: chain.entries,
|
||||
purged: chain.purged,
|
||||
})}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JournalsTab({ journals, onChanged }: { journals: Journal[]; onChanged: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
const canChange = useAccountStore((s) => s.hasPermission('sysJournalUpdate'));
|
||||
const [editing, setEditing] = useState<Journal | null>(null);
|
||||
const [confirming, setConfirming] = useState<string | null>(null);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [check, setCheck] = useState<{ verified: boolean; chains: ChainReport[] } | null>(null);
|
||||
|
||||
const act = async (work: () => Promise<void>) => {
|
||||
try {
|
||||
await work();
|
||||
onChanged();
|
||||
} catch (e) {
|
||||
toast({ variant: 'destructive', title: t('journal.notSaved', 'Not saved'), description: message(e) });
|
||||
}
|
||||
};
|
||||
|
||||
const runCheck = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
setCheck(await verifyJournal());
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('journal.checkFailed', 'The check didn’t run'),
|
||||
description: message(e),
|
||||
});
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canChange && (
|
||||
<Button onClick={() => setEditing(newJournal())}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('journal.new', 'New journal…')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => void runCheck()} disabled={checking}>
|
||||
{checking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <ShieldCheck className="mr-2 h-4 w-4" />}
|
||||
{t('journal.check', 'Check the journal')}
|
||||
</Button>
|
||||
</div>
|
||||
{check && <CheckResult {...check} />}
|
||||
{journals.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
{t('journal.none', 'No journals yet: nothing is journaled.')}
|
||||
</div>
|
||||
)}
|
||||
{journals.map((journal) => (
|
||||
<section key={journal.id} className={`space-y-2 rounded-xl border p-4 ${journal.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">{journal.name}</p>
|
||||
<p className="text-sm">{describeJournal(journal)}</p>
|
||||
{journal.description && <p className="text-xs text-muted-foreground">{journal.description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={journal.enabled}
|
||||
disabled={!canChange}
|
||||
onCheckedChange={(on) => void act(() => setJournalEnabled(journal, on))}
|
||||
aria-label={t('journal.on', 'On')}
|
||||
/>
|
||||
{canChange && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(journal)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
{t('journal.edit', 'Change…')}
|
||||
</Button>
|
||||
{confirming === journal.id ? (
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setConfirming(null);
|
||||
if (journal.id) void act(() => deleteJournal(journal.id as string));
|
||||
}}
|
||||
>
|
||||
{t('journal.deleteConfirm', 'Delete it; its entries stay')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setConfirming(journal.id ?? null)}
|
||||
aria-label={t('journal.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(journal.archiveFailures?.count ?? 0) > 0 && (
|
||||
<p className="flex items-start gap-2 rounded-md bg-amber-500/10 p-2 text-sm">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
|
||||
{t(
|
||||
'journal.archiveFailed',
|
||||
'{{count}} reports weren’t taken by {{address}}; the last {{when}}, because {{reason}}. They’re kept in the built-in journal.',
|
||||
{
|
||||
count: journal.archiveFailures?.count ?? 0,
|
||||
address: journal.archiveAddress ?? '',
|
||||
when: when(journal.archiveFailures?.lastAt),
|
||||
reason: journal.archiveFailures?.lastReason ?? '',
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
{editing && (
|
||||
<JournalEditor
|
||||
initial={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSaved={() => {
|
||||
setEditing(null);
|
||||
onChanged();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Load = { kind: 'loading' } | { kind: 'ready'; journals: Journal[] } | { kind: 'error'; message: string };
|
||||
|
||||
export function JournalPage() {
|
||||
const { t } = useTranslation();
|
||||
const canSearch = useAccountStore((s) => s.hasPermission('sysJournalSearch'));
|
||||
const [load, setLoad] = useState<Load>({ kind: 'loading' });
|
||||
const [fetches, setFetches] = useState(0);
|
||||
const refetch = useCallback(() => setFetches((n) => n + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchJournals(controller.signal)
|
||||
.then((journals) => !controller.signal.aborted && setLoad({ kind: 'ready', journals }))
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setLoad({
|
||||
kind: 'error',
|
||||
message:
|
||||
e instanceof JournalUnavailable
|
||||
? t('journal.unavailable', 'This server doesn’t journal mail.')
|
||||
: message(e),
|
||||
});
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [fetches, t]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl space-y-5">
|
||||
<PageHeader
|
||||
icon="archive"
|
||||
title={t('journal.title', 'Journal')}
|
||||
subtitle={t(
|
||||
'journal.subtitle',
|
||||
'A copy of mail as it passes, with who it was really from and to (Bcc included), kept where nobody can change or remove it until its time.',
|
||||
)}
|
||||
/>
|
||||
{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' && (
|
||||
<Tabs defaultValue={canSearch ? 'search' : 'journals'}>
|
||||
<TabsList>
|
||||
{canSearch && <TabsTrigger value="search">{t('journal.searchTab', 'Search')}</TabsTrigger>}
|
||||
<TabsTrigger value="journals">{t('journal.journalsTab', 'Journals')}</TabsTrigger>
|
||||
</TabsList>
|
||||
{canSearch && (
|
||||
<TabsContent value="search" className="pt-4">
|
||||
<SearchTab journals={load.journals} />
|
||||
</TabsContent>
|
||||
)}
|
||||
<TabsContent value="journals" className="pt-4">
|
||||
<JournalsTab journals={load.journals} onChanged={refetch} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: choosing a journal, for a rule's Journal it action (journaling
|
||||
* spec, JR-10).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { fetchJournals } from './api';
|
||||
import type { Journal } from './model';
|
||||
|
||||
const NONE = '__none__';
|
||||
|
||||
export function JournalSelect({
|
||||
value,
|
||||
onChange,
|
||||
allowNone = false,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
allowNone?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [journals, setJournals] = useState<Journal[] | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchJournals(controller.signal)
|
||||
.then((list) => !controller.signal.aborted && setJournals(list))
|
||||
.catch(() => !controller.signal.aborted && setFailed(true));
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('journal.selectUnavailable', 'Journals can’t be listed: seeing them needs the permission to see journals.')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Select value={value || (allowNone ? NONE : '')} onValueChange={(id) => onChange(id === NONE ? '' : id)}>
|
||||
<SelectTrigger className="w-72">
|
||||
<SelectValue placeholder={t('journal.choose', 'Choose a journal…')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{allowNone && <SelectItem value={NONE}>{t('journal.noJournal', 'Don’t journal it')}</SelectItem>}
|
||||
{(journals ?? []).map((journal) => (
|
||||
<SelectItem key={journal.id} value={journal.id ?? ''}>
|
||||
{journal.name}
|
||||
{!journal.enabled && ` ${t('journal.offSuffix', '(off)')}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: `inbuxa:Journal`, `inbuxa:JournalEntry`, `inbuxa:JournalExport`
|
||||
* and `inbuxa:JournalVerification` over JMAP (journaling spec, JR-6,
|
||||
* JR-9, JR-15, JR-16). Every search, read and export is recorded by the
|
||||
* server before it answers.
|
||||
*/
|
||||
|
||||
import { apiFetch } from '@/services/api';
|
||||
import { fetchSession, getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { toServerFilter, type EntryFilter, type Journal, type JournalEntry } from './model';
|
||||
|
||||
const CAPABILITY = 'urn:inbuxa:jmap';
|
||||
|
||||
/** The server has no journal (an older server). */
|
||||
export class JournalUnavailable 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 JournalUnavailable(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>;
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
// --- Journals -------------------------------------------------------------
|
||||
|
||||
export async function fetchJournals(signal?: AbortSignal): Promise<Journal[]> {
|
||||
const result = await call('inbuxa:Journal/get', { ids: null }, signal);
|
||||
return (result.list as Journal[] | undefined) ?? [];
|
||||
}
|
||||
|
||||
function toServer(journal: Journal): Record<string, unknown> {
|
||||
return {
|
||||
name: journal.name.trim(),
|
||||
description: journal.description,
|
||||
enabled: journal.enabled,
|
||||
direction: journal.direction,
|
||||
scope: journal.scope,
|
||||
retentionDays: journal.retentionDays,
|
||||
builtIn: journal.builtIn,
|
||||
archiveAddress: journal.archiveAddress?.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function saveJournal(journal: Journal, reason?: string): Promise<void> {
|
||||
const args: Record<string, unknown> = reason?.trim() ? { reason: reason.trim() } : {};
|
||||
if (journal.id) {
|
||||
const result = await call('inbuxa:Journal/set', { ...args, update: { [journal.id]: toServer(journal) } });
|
||||
const failed = setFailure(result, 'notUpdated', journal.id);
|
||||
if (failed) throw failed;
|
||||
return;
|
||||
}
|
||||
const result = await call('inbuxa:Journal/set', { ...args, create: { j: toServer(journal) } });
|
||||
const failed = setFailure(result, 'notCreated', 'j');
|
||||
if (failed) throw failed;
|
||||
}
|
||||
|
||||
export async function setJournalEnabled(journal: Journal, enabled: boolean): Promise<void> {
|
||||
if (!journal.id) return;
|
||||
const result = await call('inbuxa:Journal/set', { update: { [journal.id]: { enabled } } });
|
||||
const failed = setFailure(result, 'notUpdated', journal.id);
|
||||
if (failed) throw failed;
|
||||
}
|
||||
|
||||
export async function deleteJournal(id: string): Promise<void> {
|
||||
const result = await call('inbuxa:Journal/set', { destroy: [id] });
|
||||
const failed = setFailure(result, 'notDestroyed', id);
|
||||
if (failed) throw failed;
|
||||
}
|
||||
|
||||
// --- Entries --------------------------------------------------------------
|
||||
|
||||
export const PAGE = 50;
|
||||
|
||||
/** One page of matching entries, newest first, and how many match. */
|
||||
export async function searchEntries(
|
||||
filter: EntryFilter,
|
||||
position: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ entries: JournalEntry[]; total: number }> {
|
||||
const accountId = getAccountId('x:Account');
|
||||
const responses = await jmapRequest(
|
||||
[
|
||||
[
|
||||
'inbuxa:JournalEntry/query',
|
||||
{ accountId, filter: toServerFilter(filter), position, limit: PAGE, calculateTotal: true },
|
||||
'q',
|
||||
],
|
||||
[
|
||||
'inbuxa:JournalEntry/get',
|
||||
{ accountId, '#ids': { resultOf: 'q', name: 'inbuxa:JournalEntry/query', path: '/ids' } },
|
||||
'g',
|
||||
],
|
||||
],
|
||||
signal,
|
||||
[CAPABILITY],
|
||||
);
|
||||
const [queryName, query] = responses[0] ?? [];
|
||||
if (queryName !== 'inbuxa:JournalEntry/query') throw failure(queryName, query);
|
||||
const total = (query as { total?: number }).total ?? 0;
|
||||
const [getName, get] = responses[1] ?? [];
|
||||
if (getName !== 'inbuxa:JournalEntry/get') {
|
||||
// No ids: the get has nothing to return
|
||||
if ((query as { ids?: string[] }).ids?.length === 0) return { entries: [], total };
|
||||
throw failure(getName, get);
|
||||
}
|
||||
return { entries: ((get as { list?: JournalEntry[] }).list ?? []) as JournalEntry[], total };
|
||||
}
|
||||
|
||||
/** The whole journal report. Reading it is recorded. */
|
||||
export async function fetchReport(id: string): Promise<string> {
|
||||
const result = await call('inbuxa:JournalEntry/get', { ids: [id], properties: ['report'] });
|
||||
const report = (result.list as { report?: string | null }[] | undefined)?.[0]?.report;
|
||||
if (typeof report !== 'string') throw new Error('The report couldn’t be read.');
|
||||
return report;
|
||||
}
|
||||
|
||||
function pathOf(url: string): string {
|
||||
try {
|
||||
const u = new URL(url, 'http://placeholder');
|
||||
return u.pathname + u.search;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function saveBlob(blob: Blob, fileName: string) {
|
||||
const href = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = href;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(href), 10_000);
|
||||
}
|
||||
|
||||
/** Asks for a ZIP of the matching reports, and saves it. */
|
||||
export async function exportEntries(filter: EntryFilter, reason: string): Promise<{ count: number; sha256: string }> {
|
||||
const accountId = getAccountId('x:Account');
|
||||
const result = await call('inbuxa:JournalExport/set', {
|
||||
create: { x: { filter: toServerFilter(filter), reason: reason.trim() } },
|
||||
});
|
||||
const failed = setFailure(result, 'notCreated', 'x');
|
||||
if (failed) throw failed;
|
||||
const created = (result.created as { x?: { blobId: string; count: number; sha256: string } } | undefined)?.x;
|
||||
if (!created) throw new Error('Export failed');
|
||||
const fileName = `journal-${new Date().toISOString().slice(0, 10)}.zip`;
|
||||
const session = await fetchSession();
|
||||
const template =
|
||||
typeof session.downloadUrl === 'string'
|
||||
? session.downloadUrl
|
||||
: '/jmap/download/{accountId}/{blobId}/{name}?accept={type}';
|
||||
const url = template
|
||||
.replace('{accountId}', encodeURIComponent(accountId))
|
||||
.replace('{blobId}', encodeURIComponent(created.blobId))
|
||||
.replace('{name}', encodeURIComponent(fileName))
|
||||
.replace('{type}', encodeURIComponent('application/zip'));
|
||||
const response = await apiFetch(pathOf(url));
|
||||
if (!response.ok) throw new Error(`Download failed (${response.status})`);
|
||||
saveBlob(await response.blob(), fileName);
|
||||
return { count: created.count, sha256: created.sha256 };
|
||||
}
|
||||
|
||||
export interface ChainReport {
|
||||
node: number;
|
||||
entries: number;
|
||||
purged: number;
|
||||
brokenAt?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/** Rechecks every chain and report. */
|
||||
export async function verifyJournal(): Promise<{ verified: boolean; chains: ChainReport[] }> {
|
||||
const result = await call('inbuxa:JournalVerification/set', { create: { v: {} } });
|
||||
const created = (result.created as { v?: { verified: boolean; chains: ChainReport[] } } | undefined)?.v;
|
||||
if (!created) throw setFailure(result, 'notCreated', 'v') ?? new Error('The check didn’t run');
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describeJournal, newJournal, problem, retentionText, toServerFilter, whoOf, emptyFilter } from './model';
|
||||
|
||||
describe('journals in words', () => {
|
||||
it('says whose mail, where it goes and for how long', () => {
|
||||
expect(describeJournal(newJournal())).toBe(
|
||||
'Journal all mail to and from everyone into the built-in journal, kept 7 years.',
|
||||
);
|
||||
const finance = {
|
||||
...newJournal(),
|
||||
direction: 'outgoing' as const,
|
||||
scope: { everyone: false, accounts: [], groups: ['b', 'c'], domains: [], tenants: ['d'] },
|
||||
retentionDays: 90,
|
||||
builtIn: false,
|
||||
archiveAddress: '[email protected]',
|
||||
};
|
||||
expect(describeJournal(finance)).toBe(
|
||||
'Journal mail sent outside by people in 2 groups and 1 tenant to [email protected], kept 90 days.',
|
||||
);
|
||||
const rules = { ...newJournal(), scope: { ...newJournal().scope, everyone: false } };
|
||||
expect(whoOf(rules.scope)).toBe('rules');
|
||||
expect(describeJournal(rules)).toContain('what mail flow and DLP rules send here');
|
||||
});
|
||||
|
||||
it('counts years only when whole', () => {
|
||||
expect(retentionText(365)).toBe('1 year');
|
||||
expect(retentionText(3650)).toBe('10 years');
|
||||
expect(retentionText(400)).toBe('400 days');
|
||||
});
|
||||
|
||||
it('says what a journal still needs', () => {
|
||||
expect(problem({ ...newJournal(), name: 'All' })).toBeNull();
|
||||
expect(problem(newJournal())).toBe('Give the journal a name.');
|
||||
expect(problem({ ...newJournal(), name: 'A', retentionDays: 29 })).toContain('between 30 and 3650');
|
||||
expect(problem({ ...newJournal(), name: 'A', builtIn: false })).toContain('archive');
|
||||
expect(problem({ ...newJournal(), name: 'A', archiveAddress: 'not an address' })).toContain('isn’t an email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('search filters', () => {
|
||||
it('sends only what was filled in, the end date whole', () => {
|
||||
expect(toServerFilter(emptyFilter())).toEqual({});
|
||||
expect(
|
||||
toServerFilter({
|
||||
...emptyFilter(),
|
||||
address: ' bob@ ',
|
||||
direction: 'incoming',
|
||||
from: '2026-09-01',
|
||||
to: '2026-09-30',
|
||||
}),
|
||||
).toEqual({
|
||||
address: 'bob@',
|
||||
direction: 'incoming',
|
||||
after: '2026-09-01T00:00:00Z',
|
||||
before: '2026-10-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* INBUXA: journals and journal entries as the server sends them
|
||||
* (journaling spec, JR-3, JR-9, JR-15), and each journal in words.
|
||||
*/
|
||||
|
||||
export type JournalDirection = 'outgoing' | 'incoming' | 'internal' | 'any';
|
||||
|
||||
export interface JournalScope {
|
||||
everyone: boolean;
|
||||
accounts: string[];
|
||||
groups: string[];
|
||||
domains: string[];
|
||||
tenants: string[];
|
||||
}
|
||||
|
||||
export interface ArchiveFailures {
|
||||
count: number;
|
||||
lastAt: string | null;
|
||||
lastReason: string | null;
|
||||
}
|
||||
|
||||
export interface Journal {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
direction: JournalDirection;
|
||||
scope: JournalScope;
|
||||
retentionDays: number;
|
||||
builtIn: boolean;
|
||||
archiveAddress: string | null;
|
||||
archiveFailures?: ArchiveFailures;
|
||||
createdBy?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export const MIN_RETENTION_DAYS = 30;
|
||||
export const MAX_RETENTION_DAYS = 3650;
|
||||
|
||||
export function newJournal(): Journal {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
direction: 'any',
|
||||
scope: { everyone: true, accounts: [], groups: [], domains: [], tenants: [] },
|
||||
retentionDays: 365 * 7,
|
||||
builtIn: true,
|
||||
archiveAddress: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Who a journal takes: everyone, chosen people, or only what rules send. */
|
||||
export type Who = 'everyone' | 'chosen' | 'rules';
|
||||
|
||||
export function whoOf(scope: JournalScope): Who {
|
||||
if (scope.everyone) return 'everyone';
|
||||
const chosen = scope.accounts.length + scope.groups.length + scope.domains.length + scope.tenants.length;
|
||||
return chosen > 0 ? 'chosen' : 'rules';
|
||||
}
|
||||
|
||||
function plural(n: number, one: string, many: string): string {
|
||||
return `${n} ${n === 1 ? one : many}`;
|
||||
}
|
||||
|
||||
function list(items: string[]): string {
|
||||
if (items.length <= 1) return items.join('');
|
||||
return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
|
||||
}
|
||||
|
||||
/** "7 years", "90 days". */
|
||||
export function retentionText(days: number): string {
|
||||
if (days % 365 === 0) return plural(days / 365, 'year', 'years');
|
||||
return plural(days, 'day', 'days');
|
||||
}
|
||||
|
||||
/** "Journal mail sent outside by 2 groups into the built-in journal, kept 7 years." */
|
||||
export function describeJournal(journal: Journal): string {
|
||||
const who = whoOf(journal.scope);
|
||||
const chosen = list(
|
||||
[
|
||||
journal.scope.accounts.length && plural(journal.scope.accounts.length, 'account', 'accounts'),
|
||||
journal.scope.groups.length && plural(journal.scope.groups.length, 'group', 'groups'),
|
||||
journal.scope.domains.length && plural(journal.scope.domains.length, 'domain', 'domains'),
|
||||
journal.scope.tenants.length && plural(journal.scope.tenants.length, 'tenant', 'tenants'),
|
||||
].filter((part): part is string => Boolean(part)),
|
||||
);
|
||||
const people = who === 'everyone' ? 'everyone' : `people in ${chosen}`;
|
||||
const what =
|
||||
who === 'rules'
|
||||
? 'what mail flow and DLP rules send here'
|
||||
: journal.direction === 'outgoing'
|
||||
? `mail sent outside by ${people}`
|
||||
: journal.direction === 'incoming'
|
||||
? `mail arriving from outside for ${people}`
|
||||
: journal.direction === 'internal'
|
||||
? `mail ${people === 'everyone' ? 'between people here' : `from ${people} to people here`}`
|
||||
: `all mail to and from ${people}`;
|
||||
const archive = journal.archiveAddress?.trim();
|
||||
const where =
|
||||
journal.builtIn && archive
|
||||
? `into the built-in journal and to ${archive}`
|
||||
: archive
|
||||
? `to ${archive}`
|
||||
: 'into the built-in journal';
|
||||
return `Journal ${what} ${where}, kept ${retentionText(journal.retentionDays)}.`;
|
||||
}
|
||||
|
||||
/** What a journal needs before it can be saved; null when it's ready. */
|
||||
export function problem(journal: Journal): string | null {
|
||||
if (!journal.name.trim()) return 'Give the journal a name.';
|
||||
if (
|
||||
!Number.isInteger(journal.retentionDays) ||
|
||||
journal.retentionDays < MIN_RETENTION_DAYS ||
|
||||
journal.retentionDays > MAX_RETENTION_DAYS
|
||||
)
|
||||
return `Keep entries between ${MIN_RETENTION_DAYS} and ${MAX_RETENTION_DAYS} days.`;
|
||||
if (whoOf(journal.scope) === 'chosen' && journal.scope.everyone) return 'Choose everyone or chosen people.';
|
||||
if (!journal.builtIn && !journal.archiveAddress?.trim())
|
||||
return 'Keep entries in the built-in journal, send them to an archive, or both.';
|
||||
if (journal.archiveAddress?.trim() && !/^[^\s@<>,;]+@[^\s@<>,;]+\.[^\s@<>,;]+$/.test(journal.archiveAddress.trim()))
|
||||
return 'The archive address isn’t an email address.';
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface JournalEntry {
|
||||
id: string;
|
||||
receivedAt: string;
|
||||
direction: Exclude<JournalDirection, 'any'>;
|
||||
sender: string;
|
||||
authenticated: boolean;
|
||||
recipients: string[];
|
||||
subject: string;
|
||||
messageId: string;
|
||||
journalIds: string[];
|
||||
held: boolean;
|
||||
size: number;
|
||||
sha256: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/** A search of the journal. Dates are YYYY-MM-DD, whole days. */
|
||||
export interface EntryFilter {
|
||||
text: string;
|
||||
address: string;
|
||||
direction: JournalDirection;
|
||||
from: string;
|
||||
to: string;
|
||||
journalId: string;
|
||||
}
|
||||
|
||||
export function emptyFilter(): EntryFilter {
|
||||
return { text: '', address: '', direction: 'any', from: '', to: '', journalId: '' };
|
||||
}
|
||||
|
||||
/** The filter as the server takes it; `to` includes that whole day. */
|
||||
export function toServerFilter(filter: EntryFilter): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
if (filter.text.trim()) out.text = filter.text.trim();
|
||||
if (filter.address.trim()) out.address = filter.address.trim();
|
||||
if (filter.direction !== 'any') out.direction = filter.direction;
|
||||
if (filter.from) out.after = `${filter.from}T00:00:00Z`;
|
||||
if (filter.to) {
|
||||
const next = new Date(`${filter.to}T00:00:00Z`);
|
||||
next.setUTCDate(next.getUTCDate() + 1);
|
||||
out.before = next.toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
if (filter.journalId) out.journalId = filter.journalId;
|
||||
return out;
|
||||
}
|
||||
+84
-1
@@ -970,7 +970,8 @@
|
||||
"noneFlow": "No mail flow rules yet.",
|
||||
"edit": "Change…",
|
||||
"deleteConfirm": "Delete it",
|
||||
"delete": "Delete"
|
||||
"delete": "Delete",
|
||||
"dlpJournal": "Also journal it"
|
||||
},
|
||||
"dnsWizard": {
|
||||
"stepProvider": "Your DNS host",
|
||||
@@ -1673,5 +1674,87 @@
|
||||
"passed": "Passed checks ({{count}})",
|
||||
"notCheckedCount": ", {{count}} not checked",
|
||||
"notChecked": "— not checked: {{why}}"
|
||||
},
|
||||
"journal": {
|
||||
"noSubject": "(no subject)",
|
||||
"readRecorded": "The journal report: the envelope, then the message as it was sent. Reading it is recorded in the audit log.",
|
||||
"exported": "{{count}} reports exported",
|
||||
"exportedHash": "SHA-256 {{sha256}}",
|
||||
"exportFailed": "Not exported",
|
||||
"exportTitle": "Export {{count}} reports",
|
||||
"exportBody": "A ZIP of the journal reports this search finds, with a manifest that lists each one’s SHA-256. The export and its reason are recorded in the audit log.",
|
||||
"reason": "Reason, for the audit log",
|
||||
"cancel": "Cancel",
|
||||
"export": "Export",
|
||||
"searchFailed": "The search didn’t run",
|
||||
"address": "Sender or recipient",
|
||||
"subjectWords": "Words in the subject",
|
||||
"which": "Which mail",
|
||||
"dirAny": "All mail",
|
||||
"dirOutgoing": "Sent outside",
|
||||
"dirIncoming": "Arriving from outside",
|
||||
"dirInternal": "Between people here",
|
||||
"from": "From (date)",
|
||||
"to": "To (date)",
|
||||
"inJournal": "Journal",
|
||||
"anyJournal": "Any journal",
|
||||
"search": "Search",
|
||||
"searchRecorded": "Each search is recorded in the audit log, with what was searched for.",
|
||||
"found": "{{count}} found",
|
||||
"exportAction": "Export…",
|
||||
"nothingFound": "Nothing journaled matches.",
|
||||
"fromTo": "{{sender}} to {{to}}",
|
||||
"keptUntil": "kept until {{date}}",
|
||||
"read": "Read…",
|
||||
"heldBadge": "Held for review",
|
||||
"more": "Show {{count}} more",
|
||||
"chooseSomeone": "Choose who.",
|
||||
"saved": "Journal saved",
|
||||
"notSaved": "Not saved",
|
||||
"whoEveryone": "Everyone",
|
||||
"whoEveryoneBody": "All mail this server handles, in the direction below.",
|
||||
"whoChosen": "Chosen people",
|
||||
"whoChosenBody": "Mail to or from accounts, groups, domains or tenants you choose.",
|
||||
"whoRules": "Only what rules send",
|
||||
"whoRulesBody": "Mail a mail flow or DLP rule sends here with Journal it.",
|
||||
"editTitle": "Change “{{name}}”",
|
||||
"newTitle": "New journal",
|
||||
"name": "Name",
|
||||
"on": "On",
|
||||
"description": "Description (optional)",
|
||||
"whose": "Whose mail",
|
||||
"addAccount": "Add an account…",
|
||||
"addGroup": "Add a group…",
|
||||
"addDomain": "Add a domain…",
|
||||
"addTenant": "Add a tenant…",
|
||||
"whereTitle": "Where it goes",
|
||||
"builtIn": "The built-in journal, searchable here",
|
||||
"archiveAddress": "An outside archive’s journal address (optional)",
|
||||
"archiveHint": "Each report is sent there as mail. One the archive doesn’t take is kept in the built-in journal instead, and shown here.",
|
||||
"retention": "Keep entries for (days, {{min}} to {{max}})",
|
||||
"retentionHint": "Each entry keeps the time it was written with: changing this applies to new entries. Entries about someone under a legal hold stay while the hold does.",
|
||||
"changeReason": "Reason, for the audit log (optional)",
|
||||
"save": "Save",
|
||||
"checkOk": "The journal checks out: nothing was changed or removed before its time.",
|
||||
"checkBroken": "The journal doesn’t check out.",
|
||||
"chainBroken": "Node {{node}}: at {{at}}, {{reason}}",
|
||||
"chainOk": "Node {{node}}: {{entries}} entries, {{purged}} past their time and purged",
|
||||
"checkFailed": "The check didn’t run",
|
||||
"new": "New journal…",
|
||||
"check": "Check the journal",
|
||||
"none": "No journals yet: nothing is journaled.",
|
||||
"edit": "Change…",
|
||||
"deleteConfirm": "Delete it; its entries stay",
|
||||
"delete": "Delete",
|
||||
"archiveFailed": "{{count}} reports weren’t taken by {{address}}; the last {{when}}, because {{reason}}. They’re kept in the built-in journal.",
|
||||
"unavailable": "This server doesn’t journal mail.",
|
||||
"title": "Journal",
|
||||
"subtitle": "A copy of mail as it passes, with who it was really from and to (Bcc included), kept where nobody can change or remove it until its time.",
|
||||
"searchTab": "Search",
|
||||
"journalsTab": "Journals",
|
||||
"selectUnavailable": "Journals can’t be listed: seeing them needs the permission to see journals.",
|
||||
"choose": "Choose a journal…",
|
||||
"noJournal": "Don’t journal it",
|
||||
"offSuffix": "(off)"
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,10 @@ function checkSpecialLink(
|
||||
if (viewName === 'CustomComponent/DlpRules') {
|
||||
return { visible: canGet ? canGet('sysDlpPolicy') : true, enterprise: false };
|
||||
}
|
||||
// INBUXA: journaling (journaling spec, §3)
|
||||
if (viewName === 'CustomComponent/Journal') {
|
||||
return { visible: canGet ? canGet('sysJournal') : true, enterprise: false };
|
||||
}
|
||||
if (viewName === 'CustomComponent/HeldMail') {
|
||||
return { visible: canGet ? canGet('sysDlpReview') : true, enterprise: false };
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user