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
+6 -1
View File
@@ -56,7 +56,12 @@ materializing data.
Users holding several functions may group selected postboxes into unified
inbox views. These are query projections only: messages, address, read state,
retention, and evidence remain attached to their source postboxes.
retention, and evidence remain attached to their source postboxes. Each exact
Postbox or template revision may allow grouping, require equal classifications,
or remain entirely separate with an administrator-supplied explanation. The
rule is re-evaluated on every combined query, including after assignment or
policy changes; a View can select a personal projection through the stable
`?grouping=<grouping-id>` route parameter without granting access.
Hierarchy propagation is off by default. Explicit copy, attention/escalation,
and shared-visibility rules are distinct, bounded, classification-aware, and
+13 -1
View File
@@ -182,7 +182,19 @@ for later reassignment do not leak counts into the projection.
Every item and action continues to show the source function, unit, postbox,
assignment/delegation context, and classification. Policy may require some
postboxes to remain separate.
postboxes to remain separate. Every exact Postbox and immutable template
revision therefore selects one grouping rule: allow combining, combine only
with the same classification, or always remain separate. The configured reason
is returned as constraint provenance. Rules are checked both when saving a
personal grouping and when reading any aggregate projection, so an existing
preference cannot bypass a later policy or assignment change.
The route parameter `?grouping=<personal-grouping-id>` is the stable,
permission-neutral selector for a task-focused View. Postbox ignores an unknown
or no-longer-visible selection, rechecks all sources, and never treats the View
as authority. Temporarily unavailable source preferences remain stored without
returning their metadata or counts and become eligible again only after current
access is restored.
## Hierarchy Routing
@@ -167,6 +167,11 @@ class PostboxTemplateRevision(Base, TimestampMixin):
default=dict,
nullable=False,
)
grouping_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
routing_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
@@ -1012,6 +1017,7 @@ class PostboxGroupingSource(Base, TimestampMixin):
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
grouping: Mapped[PostboxGrouping] = relationship(back_populates="sources")
postbox: Mapped[Postbox] = relationship()
class PostboxAccessEvent(Base, TimestampMixin):
@@ -0,0 +1,61 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Literal, TypedDict
PostboxGroupingPolicyMode = Literal[
"allow",
"same_classification",
"separate",
]
class NormalizedPostboxGroupingPolicy(TypedDict):
mode: PostboxGroupingPolicyMode
reason: str | None
def normalize_postbox_grouping_policy(
policy: Mapping[str, object] | None = None,
) -> NormalizedPostboxGroupingPolicy:
value = policy or {}
mode = str(value.get("mode") or "allow").strip().casefold()
if mode not in {"allow", "same_classification", "separate"}:
mode = "separate"
reason_value = value.get("reason")
reason = str(reason_value).strip() if reason_value is not None else None
return {
"mode": mode, # type: ignore[typeddict-item]
"reason": reason or None,
}
def grouping_policy_conflicts(
sources: Sequence[tuple[str, str, Mapping[str, object] | None]],
) -> tuple[str, ...]:
"""Return privacy-safe conflict codes for a proposed source projection."""
if len(sources) <= 1:
return ()
policies = [
(postbox_id, classification, normalize_postbox_grouping_policy(policy))
for postbox_id, classification, policy in sources
]
conflicts: list[str] = []
if any(policy["mode"] == "separate" for _, _, policy in policies):
conflicts.append("source_requires_separation")
if (
any(policy["mode"] == "same_classification" for _, _, policy in policies)
and len({classification for _, classification, _ in policies}) > 1
):
conflicts.append("classification_separation_required")
return tuple(conflicts)
__all__ = [
"NormalizedPostboxGroupingPolicy",
"PostboxGroupingPolicyMode",
"grouping_policy_conflicts",
"normalize_postbox_grouping_policy",
]
+52
View File
@@ -531,6 +531,58 @@ manifest = ModuleManifest(
related_modules=("search", "idm", "encryption"),
order=34,
),
DocumentationTopic(
id="postbox.unified-inbox-policy",
title="Configure source-preserving unified Postbox views",
summary="Group currently visible function Postboxes without merging containers, bypassing separation policy, or granting authority.",
body=(
"A personal unified view stores ordered Postbox identifiers only. Messages, receipts, retention, encryption, function and unit provenance, and audit evidence remain at the source. "
"Every exact Postbox and immutable template revision can allow grouping, require all combined sources to share its classification, or require that Postbox to remain separate. Administrators record an explanation and the API returns that rule as constraint provenance. "
"Postbox validates a grouping when it is saved and validates every aggregate message query again, so assignment churn or a later stricter rule cannot leave an unsafe combined projection active. Temporarily unavailable source preferences remain stored but reveal no metadata or counts. "
"The stable `?grouping=<grouping-id>` route parameter lets a task-focused View select a personal projection. Unknown, hidden, or stale identifiers never grant access; current IDM assignment, acting context, classification, and Postbox permission are always rechecked."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("administrator", "user", "auditor"),
related_modules=("views", "idm", "policy", "audit"),
links=(
DocumentationLink(
label="Postbox",
href="/postbox",
kind="runtime",
),
DocumentationLink(
label="Postbox administration",
href="/admin?section=postbox",
kind="runtime",
),
),
translations={
"de": {
"title": "Quellenerhaltende zusammengefasste Postfachansichten konfigurieren",
"summary": "Aktuell sichtbare Funktionspostfächer gruppieren, ohne Container zusammenzuführen, Trennregeln zu umgehen oder Berechtigungen zu erteilen.",
"body": (
"Eine persönliche zusammengefasste Ansicht speichert nur geordnete Postfachkennungen. Nachrichten, Lesestatus, Aufbewahrung, Verschlüsselung, Funktions- und Organisationsbezug sowie Prüfnachweise verbleiben an der Quelle. "
"Jedes exakte Postfach und jede unveränderliche Vorlagenrevision kann Gruppierung erlauben, für alle Quellen dieselbe Klassifikation verlangen oder das Postfach vollständig getrennt halten. Die Administration hinterlegt eine Begründung; die API liefert Regel und Herkunft als Einschränkung. "
"Postbox prüft die Regel beim Speichern und erneut bei jeder zusammengefassten Nachrichtenabfrage. Änderungen an Zuweisungen oder später verschärfte Regeln lassen daher keine unsichere Projektion bestehen. Vorübergehend unsichtbare Quellenpräferenzen bleiben ohne Preisgabe von Metadaten oder Zählwerten erhalten. "
"Der stabile Routenparameter `?grouping=<grouping-id>` erlaubt einer aufgabenbezogenen View die Auswahl einer persönlichen Projektion. Unbekannte, unsichtbare oder veraltete Kennungen erteilen keinen Zugriff; aktuelle IDM-Zuweisung, Handlungskontext, Klassifikation und Postfachberechtigung werden stets erneut geprüft."
),
}
},
metadata={
"kind": "guide",
"help_contexts": [
"postbox.inbox.directory",
"postbox.action.delete-grouping",
"postbox.admin.templates",
],
"privacy_notes": [
"Hidden grouping sources do not expose metadata or counts.",
"A View selects a projection but never grants Postbox access.",
],
},
order=34,
),
DocumentationTopic(
id="postbox.content-protection-policy",
title="Choose and change Postbox content protection",
@@ -0,0 +1,31 @@
"""v0.1.18 governed unified-Postbox grouping policy.
Revision ID: d8b4f1a6c9e2
Revises: a7c1e4f8b2d6
"""
from alembic import op
import sqlalchemy as sa
revision = "d8b4f1a6c9e2"
down_revision = "a7c1e4f8b2d6"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch_op:
batch_op.add_column(
sa.Column(
"grouping_policy",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
)
)
def downgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch_op:
batch_op.drop_column("grouping_policy")
+98 -2
View File
@@ -52,6 +52,7 @@ from govoplan_postbox.backend.schemas import (
PostboxGroupingItem,
PostboxGroupingListResponse,
PostboxGroupingPayload,
PostboxGroupingPolicyUpdateRequest,
PostboxGroupingUpdateRequest,
PostboxMaterializeRequest,
PostboxMessageItem,
@@ -80,6 +81,10 @@ from govoplan_postbox.backend.schemas import (
PostboxTemplateReviseRequest,
)
from govoplan_postbox.backend.service import PostboxError
from govoplan_postbox.backend.grouping_policies import (
grouping_policy_conflicts,
normalize_postbox_grouping_policy,
)
from govoplan_postbox.backend.protection_profiles import (
POSTBOX_PROTECTION_PROFILE_DEFINITIONS,
POSTBOX_STANDARD_PROFILE,
@@ -145,6 +150,7 @@ def _http_error(exc: PostboxError) -> HTTPException:
"template_slug_exists",
"address_collision",
"idempotency_conflict",
"grouping_policy_conflict",
}:
code = status.HTTP_409_CONFLICT
else:
@@ -265,6 +271,7 @@ def _template_item(template) -> PostboxTemplateItem:
"encryption_profile": revision.encryption_profile,
"encryption_vault_id": revision.encryption_vault_id,
"protection_policy": dict(revision.history_policy or {}),
"grouping_policy": dict(revision.grouping_policy or {}),
"history_policy": dict(revision.history_policy or {}),
"routing_policy": dict(revision.routing_policy or {}),
"retention_policy": dict(revision.retention_policy or {}),
@@ -290,6 +297,48 @@ def _grouping_item(
if source.postbox_id in visible_ids
]
counts = counts_by_postbox or {}
constraints = []
policy_sources = []
for source in grouping.sources:
if source.postbox_id not in visible_ids:
continue
settings = (
source.postbox.settings
if isinstance(source.postbox.settings, Mapping)
else {}
)
policy = normalize_postbox_grouping_policy(
settings.get("grouping_policy")
if isinstance(settings.get("grouping_policy"), Mapping)
else None
)
policy_sources.append(
(
source.postbox_id,
source.postbox.classification,
policy,
)
)
if policy["mode"] == "allow":
continue
constraints.append(
{
"code": (
"source_requires_separation"
if policy["mode"] == "separate"
else "classification_separation_required"
),
"mode": policy["mode"],
"postbox_id": source.postbox_id,
"reason": policy["reason"],
"enforced_by": "postbox_configuration",
}
)
count_source_ids = (
[]
if grouping_policy_conflicts(policy_sources)
else visible_source_ids
)
return PostboxGroupingItem(
id=grouping.id,
name=grouping.name,
@@ -299,12 +348,13 @@ def _grouping_item(
postbox_ids=visible_source_ids,
total_count=sum(
int(counts.get(postbox_id, {}).get("total", 0))
for postbox_id in visible_source_ids
for postbox_id in count_source_ids
),
unread_count=sum(
int(counts.get(postbox_id, {}).get("unread", 0))
for postbox_id in visible_source_ids
for postbox_id in count_source_ids
),
constraints=constraints,
created_at=grouping.created_at,
updated_at=grouping.updated_at,
)
@@ -1033,6 +1083,52 @@ def api_update_postbox_protection_policy(
return item
@router.put(
"/admin/postboxes/{postbox_id}/grouping-policy",
response_model=PostboxDirectoryItem,
)
def api_update_postbox_grouping_policy(
postbox_id: str,
payload: PostboxGroupingPolicyUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox",
resource_id=postbox_id,
base_revision=payload.base_revision,
)
try:
get_service().update_grouping_policy(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
grouping_policy=payload.grouping_policy.model_dump(),
actor_id=principal.account_id,
expected_revision=payload.base_revision,
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox_id),
)
)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/postboxes/{postbox_id}/protection-transitions",
response_model=PostboxProtectionTransitionResponse,
+35
View File
@@ -5,6 +5,7 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
from govoplan_postbox.backend.grouping_policies import PostboxGroupingPolicyMode
from govoplan_postbox.backend.protection_profiles import (
POSTBOX_MANAGED_ENVELOPE_PROFILE,
POSTBOX_PLAINTEXT_PROFILE,
@@ -58,6 +59,7 @@ class PostboxDirectoryItem(BaseModel):
key_epoch: int = Field(default=1, ge=1)
encryption_vault_id: str | None = None
protection_policy: dict[str, Any] = Field(default_factory=dict)
grouping_policy: dict[str, Any] = Field(default_factory=dict)
access: PostboxAccessDecisionResponse | None = None
resource_revision: int = Field(default=1, ge=1)
etag: str | None = None
@@ -465,6 +467,16 @@ class PostboxProtectionPolicyPayload(BaseModel):
return self
class PostboxGroupingPolicyPayload(BaseModel):
mode: PostboxGroupingPolicyMode = "allow"
reason: str | None = Field(default=None, max_length=1000)
@model_validator(mode="after")
def normalize_reason(self) -> "PostboxGroupingPolicyPayload":
self.reason = self.reason.strip() if self.reason else None
return self
class PostboxExactCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=500)
description: str | None = None
@@ -478,6 +490,9 @@ class PostboxExactCreateRequest(BaseModel):
protection_policy: PostboxProtectionPolicyPayload = Field(
default_factory=PostboxProtectionPolicyPayload
)
grouping_policy: PostboxGroupingPolicyPayload = Field(
default_factory=PostboxGroupingPolicyPayload
)
@model_validator(mode="after")
def validate_encryption(self) -> "PostboxExactCreateRequest":
@@ -498,6 +513,11 @@ class PostboxProtectionPolicyUpdateRequest(BaseModel):
protection_policy: PostboxProtectionPolicyPayload
class PostboxGroupingPolicyUpdateRequest(BaseModel):
base_revision: int = Field(ge=1)
grouping_policy: PostboxGroupingPolicyPayload
class PostboxTemplateRevisionPayload(BaseModel):
function_type_id: str | None = Field(default=None, max_length=36)
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
@@ -522,6 +542,9 @@ class PostboxTemplateRevisionPayload(BaseModel):
protection_policy: PostboxProtectionPolicyPayload = Field(
default_factory=PostboxProtectionPolicyPayload
)
grouping_policy: PostboxGroupingPolicyPayload = Field(
default_factory=PostboxGroupingPolicyPayload
)
routing_policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload
)
@@ -861,10 +884,22 @@ class PostboxGroupingUpdateRequest(PostboxGroupingPayload):
base_revision: int = Field(ge=1)
class PostboxGroupingConstraintItem(BaseModel):
code: Literal[
"source_requires_separation",
"classification_separation_required",
]
mode: PostboxGroupingPolicyMode
postbox_id: str
reason: str | None = None
enforced_by: Literal["postbox_configuration"] = "postbox_configuration"
class PostboxGroupingItem(PostboxGroupingPayload):
id: str
total_count: int = Field(default=0, ge=0)
unread_count: int = Field(default=0, ge=0)
constraints: list[PostboxGroupingConstraintItem] = Field(default_factory=list)
resource_revision: int = Field(ge=1)
etag: str
created_at: datetime
+173 -4
View File
@@ -106,6 +106,10 @@ from govoplan_postbox.backend.hierarchy_routing import (
normalized_routing_policy,
plan_hierarchy_routes,
)
from govoplan_postbox.backend.grouping_policies import (
grouping_policy_conflicts,
normalize_postbox_grouping_policy,
)
from govoplan_postbox.backend.access_decisions import evaluate_postbox_access
from govoplan_postbox.backend.protection_profiles import (
POSTBOX_EXTERNAL_E2EE_PROFILE,
@@ -746,6 +750,11 @@ class PostboxService:
state: PostboxMessageListState = "all",
) -> tuple[PostboxMessageRef, ...]:
db = _session(session)
self._enforce_grouping_policy(
db,
tenant_id=tenant_id,
postbox_ids=postbox_ids,
)
history_cutoffs = self._allowed_postbox_history_cutoffs(
db,
tenant_id=tenant_id,
@@ -794,6 +803,11 @@ class PostboxService:
state: PostboxMessageListState = "all",
) -> int:
db = _session(session)
self._enforce_grouping_policy(
db,
tenant_id=tenant_id,
postbox_ids=postbox_ids,
)
history_cutoffs = self._allowed_postbox_history_cutoffs(
db,
tenant_id=tenant_id,
@@ -828,7 +842,7 @@ class PostboxService:
limit: int = 100,
query: str | None = None,
) -> tuple[tuple[PostboxMessageRef, ...], int]:
"""Return unread messages that can still be acted on by this actor."""
"""Return source-labelled unread work; this is not a personal grouping."""
db = _session(session)
history_cutoffs = self._allowed_postbox_history_cutoffs(
@@ -2942,6 +2956,7 @@ class PostboxService:
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
protection_policy: Mapping[str, object] | None = None,
grouping_policy: Mapping[str, object] | None = None,
) -> Postbox:
classification = self._validate_classification(classification)
_validate_encryption_configuration(
@@ -2987,6 +3002,7 @@ class PostboxService:
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
protection_policy=protection_policy,
grouping_policy=grouping_policy,
portal_visible=portal_visible,
)
@@ -3107,6 +3123,67 @@ class PostboxService:
)
return postbox
def update_grouping_policy(
self,
session: Session,
*,
tenant_id: str,
postbox_id: str,
grouping_policy: Mapping[str, object],
actor_id: str | None,
expected_revision: int,
) -> Postbox:
postbox = self._get_postbox(
session,
tenant_id=tenant_id,
postbox_id=postbox_id,
)
self._claim_resource_revision(
session,
model=Postbox,
resource=postbox,
resource_type="postbox",
tenant_id=tenant_id,
expected_revision=expected_revision,
)
settings = dict(postbox.settings or {})
previous = normalize_postbox_grouping_policy(
_mapping(settings.get("grouping_policy"))
)
current = normalize_postbox_grouping_policy(grouping_policy)
settings["grouping_policy"] = current
postbox.settings = settings
session.flush()
self._record_access_event(
session,
tenant_id=tenant_id,
postbox_id=postbox.id,
actor=(
PostboxActorRef(
account_id=actor_id,
authorized_actions=frozenset({"administer"}),
)
if actor_id
else None
),
action="postbox.grouping.policy.update",
outcome="allowed",
reason_code="administrator",
details={"previous": previous, "current": current},
)
_publish_postbox_event(
session,
"postbox.grouping.policy.updated.v1",
tenant_id=tenant_id,
resource_type="postbox",
resource_id=postbox.id,
postbox_id=postbox.id,
actor_type="user",
actor_id=actor_id,
payload={"previous": previous, "current": current},
)
return postbox
def list_protection_transitions(
self,
session: Session,
@@ -4114,11 +4191,12 @@ class PostboxService:
encryption_profile: str,
encryption_vault_id: str | None,
protection_policy: Mapping[str, object] | None,
grouping_policy: Mapping[str, object] | None = None,
template_id: str | None,
context_key: str | None,
limit: int,
) -> dict[str, object]:
del description, portal_visible, protection_policy
del description, portal_visible, protection_policy, grouping_policy
self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
@@ -4311,6 +4389,7 @@ class PostboxService:
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
protection_policy: Mapping[str, object] | None = None,
grouping_policy: Mapping[str, object] | None = None,
) -> PostboxTemplate:
classification = self._validate_classification(classification)
_validate_encryption_configuration(
@@ -4366,6 +4445,7 @@ class PostboxService:
history_policy=normalize_postbox_protection_policy(
dict(protection_policy or {})
),
grouping_policy=normalize_postbox_grouping_policy(grouping_policy),
routing_policy=normalized_routing_policy(routing_policy),
retention_policy={},
created_by=actor_id,
@@ -4407,6 +4487,7 @@ class PostboxService:
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
protection_policy: Mapping[str, object] | None = None,
grouping_policy: Mapping[str, object] | None = None,
) -> PostboxTemplate:
classification = self._validate_classification(classification)
_validate_encryption_configuration(
@@ -4465,6 +4546,7 @@ class PostboxService:
history_policy=normalize_postbox_protection_policy(
dict(protection_policy or {})
),
grouping_policy=normalize_postbox_grouping_policy(grouping_policy),
routing_policy=normalized_routing_policy(routing_policy),
retention_policy={},
created_by=actor_id,
@@ -4763,6 +4845,66 @@ class PostboxService:
)
# Grouping projections
@staticmethod
def _grouping_policy_sources(
session: Session,
*,
tenant_id: str,
postbox_ids: Sequence[str],
) -> tuple[tuple[str, str, Mapping[str, object] | None], ...]:
requested = tuple(dict.fromkeys(postbox_ids))
if not requested:
return ()
rows = (
session.query(Postbox.id, Postbox.classification, Postbox.settings)
.filter(
Postbox.tenant_id == tenant_id,
Postbox.id.in_(requested),
)
.all()
)
by_id = {
str(postbox_id): (
str(classification),
_mapping(settings).get("grouping_policy"),
)
for postbox_id, classification, settings in rows
}
return tuple(
(
postbox_id,
by_id[postbox_id][0],
(
by_id[postbox_id][1]
if isinstance(by_id[postbox_id][1], Mapping)
else None
),
)
for postbox_id in requested
if postbox_id in by_id
)
def _enforce_grouping_policy(
self,
session: Session,
*,
tenant_id: str,
postbox_ids: Sequence[str],
) -> None:
conflicts = grouping_policy_conflicts(
self._grouping_policy_sources(
session,
tenant_id=tenant_id,
postbox_ids=postbox_ids,
)
)
if conflicts:
raise PostboxError(
"grouping_policy_conflict",
"The requested unified Postbox projection conflicts with an enforced source separation policy: "
+ ", ".join(conflicts),
)
def list_groupings(
self,
session: Session,
@@ -4772,7 +4914,11 @@ class PostboxService:
) -> tuple[PostboxGrouping, ...]:
return tuple(
session.query(PostboxGrouping)
.options(selectinload(PostboxGrouping.sources))
.options(
selectinload(PostboxGrouping.sources).selectinload(
PostboxGroupingSource.postbox
)
)
.filter(
PostboxGrouping.tenant_id == tenant_id,
PostboxGrouping.account_id == actor.account_id,
@@ -4798,7 +4944,11 @@ class PostboxService:
) -> PostboxGrouping:
grouping = (
session.query(PostboxGrouping)
.options(selectinload(PostboxGrouping.sources))
.options(
selectinload(PostboxGrouping.sources).selectinload(
PostboxGroupingSource.postbox
)
)
.filter(
PostboxGrouping.id == grouping_id,
PostboxGrouping.tenant_id == tenant_id,
@@ -4847,6 +4997,11 @@ class PostboxService:
if postbox_id not in allowed and postbox_id not in requested
)
saved_ids = (*requested, *retained_hidden_ids)
self._enforce_grouping_policy(
session,
tenant_id=tenant_id,
postbox_ids=saved_ids,
)
if grouping is None:
grouping = PostboxGrouping(
tenant_id=tenant_id,
@@ -5480,6 +5635,13 @@ class PostboxService:
and isinstance(postbox.settings.get("protection_policy"), Mapping)
else normalize_postbox_protection_policy()
),
grouping_policy=(
normalize_postbox_grouping_policy(
_mapping(postbox.settings.get("grouping_policy"))
)
if isinstance(postbox.settings, Mapping)
else normalize_postbox_grouping_policy()
),
access=decision,
resource_revision=postbox.resource_revision,
etag=postbox.strong_etag,
@@ -5714,6 +5876,7 @@ class PostboxService:
encryption_profile: str | None = None,
encryption_vault_id: str | None = None,
protection_policy: Mapping[str, object] | None = None,
grouping_policy: Mapping[str, object] | None = None,
portal_visible: bool = False,
) -> Postbox:
effective_profile = (
@@ -5736,6 +5899,11 @@ class PostboxService:
if revision is not None
else normalize_postbox_protection_policy(dict(protection_policy or {}))
)
effective_grouping_policy = (
normalize_postbox_grouping_policy(revision.grouping_policy)
if revision is not None
else normalize_postbox_grouping_policy(grouping_policy)
)
if (
effective_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE
and not str(effective_vault_id or "").strip()
@@ -5780,6 +5948,7 @@ class PostboxService:
),
"portal_visible": effective_portal_visible,
"protection_policy": effective_protection_policy,
"grouping_policy": effective_grouping_policy,
},
)
postbox.bindings.append(
+10
View File
@@ -42,6 +42,10 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions."
"a7c1e4f8b2d6_v016_protection_transitions"
)
grouping_policy_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"d8b4f1a6c9e2_v017_grouping_policy"
)
engine = create_engine("sqlite:///:memory:")
try:
with engine.begin() as connection:
@@ -54,6 +58,7 @@ class PostboxMigrationTests(unittest.TestCase):
scope_original = scope_migration.op
portal_original = portal_migration.op
transition_original = transition_migration.op
grouping_policy_original = grouping_policy_migration.op
migration.op = operations
route_migration.op = operations
occ_migration.op = operations
@@ -62,6 +67,7 @@ class PostboxMigrationTests(unittest.TestCase):
scope_migration.op = operations
portal_migration.op = operations
transition_migration.op = operations
grouping_policy_migration.op = operations
try:
migration.upgrade()
route_migration.upgrade()
@@ -71,6 +77,7 @@ class PostboxMigrationTests(unittest.TestCase):
scope_migration.upgrade()
portal_migration.upgrade()
transition_migration.upgrade()
grouping_policy_migration.upgrade()
tables = set(inspect(connection).get_table_names())
self.assertIn("postboxes", tables)
self.assertIn("postbox_messages", tables)
@@ -110,6 +117,7 @@ class PostboxMigrationTests(unittest.TestCase):
"scope_structure_id",
"scope_relation_type_ids",
"portal_visible",
"grouping_policy",
}.issubset(template_revision_columns)
)
self.assertIn("authoring_key", message_columns)
@@ -134,6 +142,7 @@ class PostboxMigrationTests(unittest.TestCase):
self.assertTrue(
{"execute_after", "processed_at"}.issubset(route_columns)
)
grouping_policy_migration.downgrade()
transition_migration.downgrade()
portal_migration.downgrade()
scope_migration.downgrade()
@@ -158,6 +167,7 @@ class PostboxMigrationTests(unittest.TestCase):
scope_migration.op = scope_original
portal_migration.op = portal_original
transition_migration.op = transition_original
grouping_policy_migration.op = grouping_policy_original
finally:
engine.dispose()
+207
View File
@@ -546,6 +546,7 @@ class PostboxServiceTests(unittest.TestCase):
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
protection_policy: dict[str, object] | None = None,
grouping_policy: dict[str, object] | None = None,
) -> Postbox:
return self.service.create_exact_postbox(
session,
@@ -560,6 +561,7 @@ class PostboxServiceTests(unittest.TestCase):
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
protection_policy=protection_policy,
grouping_policy=grouping_policy,
)
def _routing_policy(
@@ -2091,6 +2093,211 @@ class PostboxServiceTests(unittest.TestCase):
}
self.assertEqual({parent.id, child.id}, visible_ids)
def test_grouping_policy_enforces_function_and_classification_separation(
self,
) -> None:
child_assignment = OrganizationFunctionAssignmentRef(
id="assignment-child",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id="function-child",
organization_unit_id="unit-child",
source="delegated",
delegated_from_assignment_id="assignment-1",
)
self.idm.assignments.extend((self.assignment, child_assignment))
privileged_actor = replace(
self.actor,
authorized_classifications=frozenset(
{"public", "internal", "confidential"}
),
)
with Session(self.engine) as session:
parent = self._create_exact(
session,
grouping_policy={
"mode": "same_classification",
"reason": "Confidential responsibilities remain partitioned.",
},
)
child = self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="Service Desk / Delegated complaints",
organization_unit_id="unit-child",
function_id="function-child",
address_key=None,
description=None,
classification="confidential",
actor_id="admin-1",
)
with self.assertRaisesRegex(
PostboxError,
"classification_separation_required",
):
self.service.save_grouping(
session,
tenant_id="tenant-1",
actor=privileged_actor,
grouping_id=None,
name="Mixed classification",
is_default=False,
postbox_ids=(parent.id, child.id),
)
grouping = self.service.save_grouping(
session,
tenant_id="tenant-1",
actor=privileged_actor,
grouping_id=None,
name="Same responsibility",
is_default=True,
postbox_ids=(parent.id,),
)
session.commit()
self.service.update_grouping_policy(
session,
tenant_id="tenant-1",
postbox_id=parent.id,
grouping_policy={
"mode": "separate",
"reason": "This function must remain a dedicated inbox.",
},
actor_id="admin-1",
expected_revision=parent.resource_revision,
)
session.commit()
with self.assertRaisesRegex(PostboxError, "source_requires_separation"):
self.service.list_messages(
session,
tenant_id="tenant-1",
postbox_ids=(parent.id, child.id),
actor=privileged_actor,
)
self.assertEqual(
(parent.id,),
tuple(source.postbox_id for source in grouping.sources),
)
def test_grouping_pagination_is_stable_and_delegation_expiry_hides_source(
self,
) -> None:
delegated = OrganizationFunctionAssignmentRef(
id="assignment-child",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id="function-child",
organization_unit_id="unit-child",
source="delegated",
delegated_from_assignment_id="assignment-1",
)
self.idm.assignments.extend((self.assignment, delegated))
with Session(self.engine) as session:
parent = self._create_exact(session)
child = self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="Service Desk / Delegated intake",
organization_unit_id="unit-child",
function_id="function-child",
address_key=None,
description=None,
classification="internal",
actor_id="admin-1",
)
grouping = self.service.save_grouping(
session,
tenant_id="tenant-1",
actor=self.actor,
grouping_id=None,
name="Delegated work",
is_default=True,
postbox_ids=(parent.id, child.id),
)
focused_grouping = self.service.save_grouping(
session,
tenant_id="tenant-1",
actor=self.actor,
grouping_id=None,
name="Delegated intake only",
is_default=False,
postbox_ids=(child.id,),
)
self.assertEqual(
(child.id,),
tuple(source.postbox_id for source in focused_grouping.sources),
)
for index, postbox in enumerate((parent, child, parent, child), start=1):
self.service.deliver(
session,
PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=postbox.id),
producer_module="tests",
producer_resource_type="stable_page",
producer_resource_id=str(index),
idempotency_key=f"stable-page-{index}",
subject=f"Message {index}",
classification="internal",
),
)
boundary = utc_now()
session.query(PostboxMessage).update(
{PostboxMessage.delivered_at: boundary}
)
session.commit()
first = self.service.list_messages(
session,
tenant_id="tenant-1",
postbox_ids=(parent.id, child.id),
actor=self.actor,
limit=2,
offset=0,
)
second = self.service.list_messages(
session,
tenant_id="tenant-1",
postbox_ids=(parent.id, child.id),
actor=self.actor,
limit=2,
offset=2,
)
repeated = self.service.list_messages(
session,
tenant_id="tenant-1",
postbox_ids=(parent.id, child.id),
actor=self.actor,
limit=2,
offset=0,
)
self.assertEqual(
[item.id for item in first], [item.id for item in repeated]
)
self.assertFalse({item.id for item in first} & {item.id for item in second})
self.idm.assignments = [
self.assignment,
replace(delegated, status="expired"),
]
visible_ids = {
item.id
for item in self.service.list_visible_postboxes(
session,
tenant_id="tenant-1",
actor=self.actor,
)
}
self.assertEqual({parent.id}, visible_ids)
self.assertEqual(
(parent.id, child.id),
tuple(source.postbox_id for source in grouping.sources),
)
if __name__ == "__main__":
unittest.main()
+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">