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
+35
View File
@@ -45,6 +45,7 @@ export type PostboxDirectoryItem = {
key_epoch: number;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
grouping_policy: PostboxGroupingPolicy;
access?: PostboxAccessDecision | null;
resource_revision: number;
etag: string;
@@ -74,6 +75,11 @@ export type PostboxProtectionPolicy = {
vacancy_escalation_content_access: "metadata_only";
};
export type PostboxGroupingPolicy = {
mode: "allow" | "same_classification" | "separate";
reason?: string | null;
};
export type PostboxProtectionProfile = {
id: PostboxProtectionProfileId;
label: string;
@@ -200,6 +206,13 @@ export type PostboxGrouping = {
postbox_ids: string[];
total_count: number;
unread_count: number;
constraints: Array<{
code: "source_requires_separation" | "classification_separation_required";
mode: PostboxGroupingPolicy["mode"];
postbox_id: string;
reason?: string | null;
enforced_by: "postbox_configuration";
}>;
resource_revision: number;
etag: string;
created_at: string;
@@ -288,6 +301,7 @@ export type PostboxTemplateRevision = {
encryption_profile: string;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
grouping_policy: PostboxGroupingPolicy;
history_policy: Record<string, unknown>;
routing_policy: PostboxRoutingPolicy;
retention_policy: Record<string, unknown>;
@@ -326,6 +340,7 @@ export type PostboxTemplateRevisionPayload = Pick<
| "encryption_profile"
| "encryption_vault_id"
| "protection_policy"
| "grouping_policy"
| "routing_policy"
>;
@@ -371,6 +386,7 @@ export type PostboxExactCreatePayload = {
encryption_profile: PostboxProtectionProfileId;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
grouping_policy: PostboxGroupingPolicy;
};
export type PostboxMessageAuthoringPayload = {
@@ -613,6 +629,25 @@ export function updatePostboxProtectionPolicy(
);
}
export function updatePostboxGroupingPolicy(
settings: ApiSettings,
postbox: PostboxDirectoryItem,
groupingPolicy: PostboxGroupingPolicy
): Promise<PostboxDirectoryItem> {
return apiFetch(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}/grouping-policy`,
{
method: "PUT",
headers: { "If-Match": postbox.etag },
body: JSON.stringify({
base_revision: postbox.resource_revision,
grouping_policy: groupingPolicy
})
}
);
}
export async function listPostboxOrganizationTargets(
settings: ApiSettings
): Promise<PostboxOrganizationTargets> {
@@ -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
};
}
@@ -29,17 +29,22 @@ export default function PostboxInboxWidget({
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const load = useCallback(async () => {
const postboxes = await listPostboxes(settings);
if (!postboxes.length) {
return { messages: [], total: 0 };
const eligible = postboxes.filter(
(postbox) => postbox.grouping_policy.mode === "allow"
);
const separatedCount = postboxes.length - eligible.length;
if (!eligible.length) {
return { messages: [], total: 0, separatedCount };
}
return listPostboxMessages(
const response = await listPostboxMessages(
settings,
postboxes.map((postbox) => postbox.id),
eligible.map((postbox) => postbox.id),
maxItems,
0,
"",
"unread"
);
return { ...response, separatedCount };
}, [maxItems, settings]);
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
@@ -50,6 +55,11 @@ export default function PostboxInboxWidget({
{error}
</DismissibleAlert>
)}
{data?.separatedCount ? (
<DismissibleAlert tone="info" dismissible={false}>
{data.separatedCount} governed Postbox source{data.separatedCount === 1 ? " is" : "s are"} shown only in separated inbox views.
</DismissibleAlert>
) : null}
<DashboardWidgetList
emptyText="No unread Postbox messages."
items={(data?.messages ?? []).map((message) => ({
+96 -8
View File
@@ -119,6 +119,9 @@ export default function PostboxPage({
const requestedPostboxId = useRef(
new URLSearchParams(location.search).get("postbox") ?? ""
);
const requestedGroupingId = useRef(
new URLSearchParams(location.search).get("grouping") ?? ""
);
const requestedMessageLoaded = useRef(false);
const requestedComposeLoaded = useRef(false);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
@@ -176,6 +179,14 @@ export default function PostboxPage({
}
return postboxes.map((postbox) => postbox.id);
}, [postboxes, selectedGrouping, selectedPostboxId]);
const scopeSeparationConflict = useMemo(
() => selectedPostboxId ? null : groupingConflict(postboxes, scopePostboxIds),
[postboxes, scopePostboxIds, selectedPostboxId]
);
const groupingDraftConflict = useMemo(
() => groupingConflict(postboxes, groupingDraft.postbox_ids),
[groupingDraft.postbox_ids, postboxes]
);
const scopeKey = scopePostboxIds.join("|");
const composeDisabledReason = postboxBusyReason(false, busy)
?? (!canSend ? POSTBOX_INTERFACE_I18N.noSendReason : undefined)
@@ -218,6 +229,9 @@ export default function PostboxPage({
setPostboxes(nextPostboxes);
setGroupings(nextGroupings);
setSelectedScope((current) => {
if (nextGroupings.some((grouping) => grouping.id === requestedGroupingId.current)) {
return requestedGroupingId.current;
}
if (current === "all" || nextGroupings.some((grouping) => grouping.id === current)) {
return current;
}
@@ -238,7 +252,7 @@ export default function PostboxPage({
}, [settings]);
const loadMessages = useCallback(async () => {
if (!scopePostboxIds.length) {
if (!scopePostboxIds.length || scopeSeparationConflict) {
setMessages([]);
setSelectedMessageId("");
setSelectedMessage(null);
@@ -274,7 +288,7 @@ export default function PostboxPage({
} finally {
setLoadingMessages(false);
}
}, [messageQuery, messageState, page, pageSize, scopeKey, settings]);
}, [messageQuery, messageState, page, pageSize, scopeKey, scopeSeparationConflict, settings]);
useEffect(() => {
void loadDirectory();
@@ -440,12 +454,22 @@ export default function PostboxPage({
}
function selectScope(scopeId: string) {
requestedGroupingId.current = scopeId === "all" ? "" : scopeId;
setSelectedScope(scopeId);
setSelectedPostboxId("");
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
setUnavailableSelection("");
const parameters = new URLSearchParams(location.search);
if (scopeId === "all") parameters.delete("grouping");
else parameters.set("grouping", scopeId);
parameters.delete("postbox");
const search = parameters.toString();
navigate(
{ pathname: location.pathname, search: search ? `?${search}` : "" },
{ replace: true, state: location.state }
);
}
function openNewGrouping() {
@@ -469,6 +493,10 @@ export default function PostboxPage({
async function saveGrouping(): Promise<boolean> {
if (!groupingDraft.name.trim()) return false;
if (groupingDraftConflict) {
setError(groupingDraftConflict);
return false;
}
setBusy(true);
setError("");
const payload = {
@@ -482,8 +510,7 @@ export default function PostboxPage({
? await updatePostboxGrouping(settings, existing, payload)
: await createPostboxGrouping(settings, payload);
await loadDirectory();
setSelectedScope(saved.id);
setSelectedPostboxId("");
selectScope(saved.id);
setGroupingDialogOpen(false);
setGroupingBaseline(groupingDraft);
return true;
@@ -503,8 +530,7 @@ export default function PostboxPage({
const existing = groupings.find((item) => item.id === deleteGroupingTarget.id);
if (!existing) throw new Error("The grouping is no longer available.");
await deletePostboxGrouping(settings, existing);
setSelectedScope("all");
setSelectedPostboxId("");
selectScope("all");
setGroupingDialogOpen(false);
setDeleteGroupingTarget(null);
await loadDirectory();
@@ -693,6 +719,19 @@ export default function PostboxPage({
/>
) : null}
</div>
{scopeSeparationConflict ? (
<DismissibleAlert tone="warning" dismissible={false}>
<strong>Combined view unavailable.</strong>{" "}
{scopeSeparationConflict} Select one source Postbox or edit the unified view.
</DismissibleAlert>
) : selectedGrouping?.constraints.length ? (
<DismissibleAlert tone="info" dismissible={false}>
This projection is governed by {selectedGrouping.constraints.length} source-separation rule{selectedGrouping.constraints.length === 1 ? "" : "s"}.
{selectedGrouping.constraints.find((item) => item.reason)?.reason
? ` ${selectedGrouping.constraints.find((item) => item.reason)?.reason}`
: ""}
</DismissibleAlert>
) : null}
</div>
<div className="postbox-directory-list">
@@ -972,8 +1011,11 @@ export default function PostboxPage({
<Button
variant="primary"
onClick={() => void saveGrouping()}
disabled={busy || !groupingDraft.name.trim()}
disabledReason={postboxBusyReason(false, busy) ?? (!groupingDraft.name.trim() ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
disabled={busy || !groupingDraft.name.trim() || Boolean(groupingDraftConflict)}
disabledReason={postboxBusyReason(false, busy)
?? (!groupingDraft.name.trim() ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)
?? groupingDraftConflict
?? undefined}
>
Save
</Button>
@@ -1013,6 +1055,13 @@ export default function PostboxPage({
<input
type="checkbox"
checked={groupingDraft.postbox_ids.includes(postbox.id)}
disabled={
!groupingDraft.postbox_ids.includes(postbox.id)
&& Boolean(groupingConflict(
postboxes,
[...groupingDraft.postbox_ids, postbox.id]
))
}
onChange={(event) =>
setGroupingDraft((current) => ({
...current,
@@ -1025,10 +1074,21 @@ export default function PostboxPage({
<span>
<strong>{postbox.name}</strong>
<small>{postbox.organization_unit_name} · {postbox.function_name}</small>
{postbox.grouping_policy.mode !== "allow" ? (
<small>
{postbox.grouping_policy.reason
|| postbox.grouping_policy.mode.replaceAll("_", " ")}
</small>
) : null}
</span>
</label>
))}
</fieldset>
{groupingDraftConflict ? (
<DismissibleAlert tone="warning" dismissible={false}>
{groupingDraftConflict}
</DismissibleAlert>
) : null}
</Dialog>
<Dialog
@@ -1304,6 +1364,34 @@ function attachmentResolutionExplanation(reasonCode: string): string {
return explanations[reasonCode] || "The referenced payload cannot currently be opened.";
}
function groupingConflict(
postboxes: PostboxDirectoryItem[],
postboxIds: string[]
): string | null {
const selected = postboxIds
.map((postboxId) => postboxes.find((postbox) => postbox.id === postboxId))
.filter((postbox): postbox is PostboxDirectoryItem => Boolean(postbox));
if (selected.length <= 1) return null;
const separate = selected.find(
(postbox) => postbox.grouping_policy.mode === "separate"
);
if (separate) {
return separate.grouping_policy.reason
|| `${separate.name} must remain a separate inbox.`;
}
const classificationRule = selected.find(
(postbox) => postbox.grouping_policy.mode === "same_classification"
);
if (
classificationRule
&& new Set(selected.map((postbox) => postbox.classification)).size > 1
) {
return classificationRule.grouping_policy.reason
|| "These Postboxes cannot be combined across classifications.";
}
return null;
}
function sourceName(
postboxes: PostboxDirectoryItem[],
postboxId: string
@@ -39,16 +39,22 @@ export default function PostboxQuickAccess({
const [selectedId, setSelectedId] = useState("");
const load = useCallback(async () => {
const postboxes = await listPostboxes(settings);
if (!postboxes.length) return { postboxes, messages: [], total: 0 };
const eligible = postboxes.filter(
(postbox) => postbox.grouping_policy.mode === "allow"
);
const separatedCount = postboxes.length - eligible.length;
if (!eligible.length) {
return { postboxes, messages: [], total: 0, separatedCount };
}
const response = await listPostboxMessages(
settings,
postboxes.map((postbox) => postbox.id),
eligible.map((postbox) => postbox.id),
MESSAGE_LIMIT,
0,
"",
"unread"
);
return { postboxes, ...response };
return { postboxes, separatedCount, ...response };
}, [settings]);
const { data, loading, error } = useDashboardWidgetData(load, 0);
const messages = data?.messages ?? [];
@@ -93,6 +99,11 @@ export default function PostboxQuickAccess({
</p>
) : null}
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
{data?.separatedCount ? (
<DismissibleAlert tone="info" dismissible={false}>
{data.separatedCount} Postbox source{data.separatedCount === 1 ? " is" : "s are"} available only in a separated inbox view.
</DismissibleAlert>
) : null}
{messages.length ? (
<SelectionList variant="navigation" label="i18n:govoplan-postbox.quick_unread_messages">