feat(postbox): enforce unified inbox separation

This commit is contained in:
2026-08-20 04:22:29 +02:00
parent 174ee97719
commit 41ea8d8e23
16 changed files with 1029 additions and 25 deletions
@@ -51,8 +51,10 @@ import {
retirePostboxTemplate,
revisePostboxTemplate,
updatePostboxProtectionPolicy,
updatePostboxGroupingPolicy,
type PostboxDirectoryItem,
type PostboxExactCreatePayload,
type PostboxGroupingPolicy,
type PostboxOrganizationFunction,
type PostboxOrganizationStructure,
type PostboxOrganizationUnit,
@@ -114,6 +116,11 @@ const protectionPolicyDefaults = (): PostboxProtectionPolicy => ({
vacancy_escalation_content_access: "metadata_only"
});
const groupingPolicyDefaults = (): PostboxGroupingPolicy => ({
mode: "allow",
reason: null
});
const routingDefaults = (): PostboxRoutingPolicy => ({
linked_copy: {
enabled: false,
@@ -157,6 +164,7 @@ const templateDefaults = (): TemplateDraft => ({
encryption_profile: "server_envelope_v1",
encryption_vault_id: "",
protection_policy: protectionPolicyDefaults(),
grouping_policy: groupingPolicyDefaults(),
routing_policy: routingDefaults()
});
@@ -170,7 +178,8 @@ const exactDefaults = (): ExactDraft => ({
portal_visible: false,
encryption_profile: "server_envelope_v1",
encryption_vault_id: "",
protection_policy: protectionPolicyDefaults()
protection_policy: protectionPolicyDefaults(),
grouping_policy: groupingPolicyDefaults()
});
const protectionTransitionDefaults = (
@@ -248,6 +257,9 @@ export default function PostboxAdminPanel({
const [policyDialogOpen, setPolicyDialogOpen] = useState(false);
const [policyDraft, setPolicyDraft] = useState<PostboxProtectionPolicy>(protectionPolicyDefaults);
const [policyBaseline, setPolicyBaseline] = useState<PostboxProtectionPolicy>(protectionPolicyDefaults);
const [groupingPolicyDialogOpen, setGroupingPolicyDialogOpen] = useState(false);
const [groupingPolicyDraft, setGroupingPolicyDraft] = useState<PostboxGroupingPolicy>(groupingPolicyDefaults);
const [groupingPolicyBaseline, setGroupingPolicyBaseline] = useState<PostboxGroupingPolicy>(groupingPolicyDefaults);
const { requestDiscard } = useUnsavedChanges();
const selectedTemplate = useMemo(
@@ -283,9 +295,11 @@ export default function PostboxAdminPanel({
const materializeDirty = materializeDialogOpen && draftKey(materializeDraft) !== draftKey(materializeBaseline);
const protectionDirty = protectionDialogOpen && draftKey(protectionDraft) !== draftKey(protectionBaseline);
const policyDirty = policyDialogOpen && draftKey(policyDraft) !== draftKey(policyBaseline);
const groupingPolicyDirty = groupingPolicyDialogOpen
&& draftKey(groupingPolicyDraft) !== draftKey(groupingPolicyBaseline);
useUnsavedDraftGuard({
dirty: templateDirty || exactDirty || materializeDirty || protectionDirty || policyDirty,
dirty: templateDirty || exactDirty || materializeDirty || protectionDirty || policyDirty || groupingPolicyDirty,
title: "Unsaved Postbox administration draft",
message: "Save or discard the open Postbox administration draft before leaving this surface.",
onSave: saveActiveDraft,
@@ -375,6 +389,7 @@ export default function PostboxAdminPanel({
encryption_profile: revision.encryption_profile,
encryption_vault_id: revision.encryption_vault_id ?? "",
protection_policy: revision.protection_policy ?? protectionPolicyDefaults(),
grouping_policy: revision.grouping_policy ?? groupingPolicyDefaults(),
routing_policy: revision.routing_policy ?? routingDefaults()
};
setTemplatePreview(null);
@@ -581,6 +596,38 @@ export default function PostboxAdminPanel({
}
}
function openGroupingPolicy() {
if (!selectedPostbox) return;
const next = selectedPostbox.grouping_policy || groupingPolicyDefaults();
setGroupingPolicyDraft(next);
setGroupingPolicyBaseline(next);
setGroupingPolicyDialogOpen(true);
}
async function saveGroupingPolicy(): Promise<boolean> {
if (!selectedPostbox) return false;
setBusy(true);
setError("");
setSuccess("");
try {
await updatePostboxGroupingPolicy(
settings,
selectedPostbox,
groupingPolicyDraft
);
setGroupingPolicyBaseline(groupingPolicyDraft);
setGroupingPolicyDialogOpen(false);
setSuccess("Unified-inbox separation policy updated.");
await load();
return true;
} catch (actionError) {
setError(errorMessage(actionError));
return false;
} finally {
setBusy(false);
}
}
function openMaterialize(template: PostboxTemplate) {
const revision = currentRevision(template);
const compatible = compatibleTargets(units, revision?.function_type_id);
@@ -660,6 +707,7 @@ export default function PostboxAdminPanel({
if (materializeDirty) return materialize();
if (protectionDirty) return saveProtectionTransition();
if (policyDirty) return saveProtectionPolicy();
if (groupingPolicyDirty) return saveGroupingPolicy();
return Promise.resolve(true);
}
@@ -684,6 +732,10 @@ export default function PostboxAdminPanel({
setPolicyDraft(policyBaseline);
setPolicyDialogOpen(false);
}
if (groupingPolicyDialogOpen) {
setGroupingPolicyDraft(groupingPolicyBaseline);
setGroupingPolicyDialogOpen(false);
}
}
function closeTemplateDialog() {
@@ -731,6 +783,15 @@ export default function PostboxAdminPanel({
else close();
}
function closeGroupingPolicyDialog() {
const close = () => {
setGroupingPolicyDraft(groupingPolicyBaseline);
setGroupingPolicyDialogOpen(false);
};
if (groupingPolicyDirty) requestDiscard(close);
else close();
}
return (
<AdminPageLayout
title="Postboxes"
@@ -807,6 +868,7 @@ export default function PostboxAdminPanel({
onSelect={setSelectedPostboxId}
onChangeProtection={openProtectionTransition}
onEditPolicy={openProtectionPolicy}
onEditGroupingPolicy={openGroupingPolicy}
onArchive={setArchiveTarget}
busy={busy}
/>
@@ -872,6 +934,15 @@ export default function PostboxAdminPanel({
onClose={closePolicyDialog}
onSave={() => void saveProtectionPolicy()}
/>
<GroupingPolicyDialog
open={groupingPolicyDialogOpen}
postbox={selectedPostbox}
draft={groupingPolicyDraft}
busy={busy}
onChange={setGroupingPolicyDraft}
onClose={closeGroupingPolicyDialog}
onSave={() => void saveGroupingPolicy()}
/>
<ConfirmDialog
open={Boolean(archiveTarget)}
title="Archive Postbox"
@@ -1051,6 +1122,7 @@ function PostboxWorkspace({
onSelect,
onChangeProtection,
onEditPolicy,
onEditGroupingPolicy,
onArchive,
busy
}: {
@@ -1060,6 +1132,7 @@ function PostboxWorkspace({
onSelect: (id: string) => void;
onChangeProtection: () => void;
onEditPolicy: () => void;
onEditGroupingPolicy: () => void;
onArchive: (postbox: PostboxDirectoryItem) => void;
busy: boolean;
}) {
@@ -1104,6 +1177,13 @@ function PostboxWorkspace({
>
<Pencil size={16} /> Edit policy
</Button>
<Button
onClick={onEditGroupingPolicy}
disabled={busy || selected.status !== "active"}
disabledReason={postboxBusyReason(false, busy) ?? (selected.status !== "active" ? POSTBOX_INTERFACE_I18N.archivedPostbox : undefined)}
>
<Boxes size={16} /> Inbox separation
</Button>
<Button
onClick={onChangeProtection}
disabled={busy || selected.status !== "active"}
@@ -1134,6 +1214,7 @@ function PostboxWorkspace({
<div><dt>Key epoch</dt><dd>{selected.key_epoch}</dd></div>
<div><dt>New incumbent history</dt><dd>{selected.protection_policy.new_incumbent_history.replaceAll("_", " ")}</dd></div>
<div><dt>External recipient assurance</dt><dd>{selected.protection_policy.external_recipient_assurance.replaceAll("_", " ")}</dd></div>
<div><dt>Unified-inbox policy</dt><dd>{selected.grouping_policy.mode.replaceAll("_", " ")}</dd></div>
</dl>
{transitions.length ? (
<div className="postbox-revision-history">
@@ -1435,6 +1516,10 @@ function TemplateDialog({
protection_policy
})}
/>
<GroupingPolicyFields
policy={draft.grouping_policy}
onChange={(grouping_policy) => onChange({ ...draft, grouping_policy })}
/>
<div className="postbox-routing-section">
<div className="postbox-routing-heading">
<div>
@@ -1825,6 +1910,10 @@ function ExactPostboxDialog({
protection_policy
})}
/>
<GroupingPolicyFields
policy={draft.grouping_policy}
onChange={(grouping_policy) => onChange({ ...draft, grouping_policy })}
/>
</FormGrid>
</Dialog>
);
@@ -2054,6 +2143,51 @@ function AuthorityField({
);
}
function GroupingPolicyFields({
policy,
onChange
}: {
policy: PostboxGroupingPolicy;
onChange: (policy: PostboxGroupingPolicy) => void;
}) {
return (
<div className="postbox-protection-section">
<div className="postbox-routing-heading">
<div>
<strong>Unified-inbox separation</strong>
<span>Keep source containers and institutional responsibilities visibly separated where required.</span>
<DocumentationHelpLink reference={POSTBOX_FIELD_DOCUMENTATION} />
</div>
</div>
<FormGrid>
<FormField label="Grouping rule" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={policy.mode}
onChange={(event) => onChange({
...policy,
mode: event.target.value as PostboxGroupingPolicy["mode"]
})}
>
<option value="allow">May be combined</option>
<option value="same_classification">Only with the same classification</option>
<option value="separate">Always keep this Postbox separate</option>
</select>
</FormField>
<FormField label="Explanation" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={policy.reason || ""}
placeholder="Why this separation is required"
onChange={(event) => onChange({
...policy,
reason: event.target.value || null
})}
/>
</FormField>
</FormGrid>
</div>
);
}
function QuorumField({
label,
value,
@@ -2130,6 +2264,47 @@ function ProtectionPolicyDialog({
);
}
function GroupingPolicyDialog({
open,
postbox,
draft,
busy,
onChange,
onClose,
onSave
}: {
open: boolean;
postbox: PostboxDirectoryItem | null;
draft: PostboxGroupingPolicy;
busy: boolean;
onChange: (policy: PostboxGroupingPolicy) => void;
onClose: () => void;
onSave: () => void;
}) {
return (
<Dialog
open={open}
title={postbox ? `Unified-inbox policy for ${postbox.name}` : "Unified-inbox policy"}
className="postbox-dialog"
onClose={onClose}
closeDisabled={busy}
footer={
<div className="button-row compact-actions">
<Button onClick={onClose} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>Cancel</Button>
<Button variant="primary" onClick={onSave} disabled={busy} disabledReason={postboxBusyReason(false, busy)}>
<Save size={16} /> Save separation policy
</Button>
</div>
}
>
<GroupingPolicyFields policy={draft} onChange={onChange} />
<p className="postbox-form-note">
The rule is evaluated whenever a personal grouping or aggregate message projection is used. Existing preferences are retained, but a newly enforced rule prevents an unsafe combined projection and explains its configured source.
</p>
</Dialog>
);
}
function ProtectionTransitionDialog({
open,
postbox,
@@ -2435,6 +2610,7 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
? draft.encryption_vault_id?.trim() || null
: null,
protection_policy: draft.protection_policy,
grouping_policy: draft.grouping_policy,
routing_policy: draft.routing_policy
};
}