Journal: search, read, export, journals and the chain check #62

Merged
jcoffey-dev merged 1 commits from feature/journaling into main 2026-09-29 05:23:38 +00:00
11 changed files with 1464 additions and 55 deletions

No files matched your search

+8
View File
@@ -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 />;
}
+62
View File
@@ -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 -54
View File
@@ -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) => (
+3
View File
@@ -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':
+766
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+205
View File
@@ -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;
}
+64
View File
@@ -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',
});
});
});
+176
View File
@@ -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
View File
@@ -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)"
}
}
+4
View File
@@ -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 };
}