diff --git a/README.md b/README.md index 153acc2..de5ccdd 100644 --- a/README.md +++ b/README.md @@ -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=` 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 diff --git a/docs/POSTBOX_CONCEPT.md b/docs/POSTBOX_CONCEPT.md index 602094a..d20fc41 100644 --- a/docs/POSTBOX_CONCEPT.md +++ b/docs/POSTBOX_CONCEPT.md @@ -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=` 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 diff --git a/src/govoplan_postbox/backend/db/models.py b/src/govoplan_postbox/backend/db/models.py index a8ed106..85bf618 100644 --- a/src/govoplan_postbox/backend/db/models.py +++ b/src/govoplan_postbox/backend/db/models.py @@ -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): diff --git a/src/govoplan_postbox/backend/grouping_policies.py b/src/govoplan_postbox/backend/grouping_policies.py new file mode 100644 index 0000000..d6056c4 --- /dev/null +++ b/src/govoplan_postbox/backend/grouping_policies.py @@ -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", +] diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index bf55073..bc5900b 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -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=` 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=` 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", diff --git a/src/govoplan_postbox/backend/migrations/versions/d8b4f1a6c9e2_v017_grouping_policy.py b/src/govoplan_postbox/backend/migrations/versions/d8b4f1a6c9e2_v017_grouping_policy.py new file mode 100644 index 0000000..668cf4b --- /dev/null +++ b/src/govoplan_postbox/backend/migrations/versions/d8b4f1a6c9e2_v017_grouping_policy.py @@ -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") diff --git a/src/govoplan_postbox/backend/router.py b/src/govoplan_postbox/backend/router.py index 64d9af8..5a02da6 100644 --- a/src/govoplan_postbox/backend/router.py +++ b/src/govoplan_postbox/backend/router.py @@ -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, diff --git a/src/govoplan_postbox/backend/schemas.py b/src/govoplan_postbox/backend/schemas.py index 977b157..417cae7 100644 --- a/src/govoplan_postbox/backend/schemas.py +++ b/src/govoplan_postbox/backend/schemas.py @@ -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 diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py index 9b5af0d..e532c79 100644 --- a/src/govoplan_postbox/backend/service.py +++ b/src/govoplan_postbox/backend/service.py @@ -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( diff --git a/tests/test_migration.py b/tests/test_migration.py index 8f914bd..a617f1f 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -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() diff --git a/tests/test_service.py b/tests/test_service.py index 37cbfac..52abc3b 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -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() diff --git a/webui/src/api/postbox.ts b/webui/src/api/postbox.ts index b3c6636..caa76cd 100644 --- a/webui/src/api/postbox.ts +++ b/webui/src/api/postbox.ts @@ -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; routing_policy: PostboxRoutingPolicy; retention_policy: Record; @@ -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 { + 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 { diff --git a/webui/src/features/postbox/PostboxAdminPanel.tsx b/webui/src/features/postbox/PostboxAdminPanel.tsx index 7efc88e..0d30e3f 100644 --- a/webui/src/features/postbox/PostboxAdminPanel.tsx +++ b/webui/src/features/postbox/PostboxAdminPanel.tsx @@ -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(protectionPolicyDefaults); const [policyBaseline, setPolicyBaseline] = useState(protectionPolicyDefaults); + const [groupingPolicyDialogOpen, setGroupingPolicyDialogOpen] = useState(false); + const [groupingPolicyDraft, setGroupingPolicyDraft] = useState(groupingPolicyDefaults); + const [groupingPolicyBaseline, setGroupingPolicyBaseline] = useState(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 { + 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 ( @@ -872,6 +934,15 @@ export default function PostboxAdminPanel({ onClose={closePolicyDialog} onSave={() => void saveProtectionPolicy()} /> + void saveGroupingPolicy()} + /> void; onChangeProtection: () => void; onEditPolicy: () => void; + onEditGroupingPolicy: () => void; onArchive: (postbox: PostboxDirectoryItem) => void; busy: boolean; }) { @@ -1104,6 +1177,13 @@ function PostboxWorkspace({ > Edit policy + + + + } + > + +

+ 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. +

+ + ); +} + 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 }; } diff --git a/webui/src/features/postbox/PostboxInboxWidget.tsx b/webui/src/features/postbox/PostboxInboxWidget.tsx index 9689020..1391d7c 100644 --- a/webui/src/features/postbox/PostboxInboxWidget.tsx +++ b/webui/src/features/postbox/PostboxInboxWidget.tsx @@ -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} )} + {data?.separatedCount ? ( + + {data.separatedCount} governed Postbox source{data.separatedCount === 1 ? " is" : "s are"} shown only in separated inbox views. + + ) : null} ({ diff --git a/webui/src/features/postbox/PostboxPage.tsx b/webui/src/features/postbox/PostboxPage.tsx index bd7f7d2..5e33c05 100644 --- a/webui/src/features/postbox/PostboxPage.tsx +++ b/webui/src/features/postbox/PostboxPage.tsx @@ -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([]); @@ -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 { 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} + {scopeSeparationConflict ? ( + + Combined view unavailable.{" "} + {scopeSeparationConflict} Select one source Postbox or edit the unified view. + + ) : selectedGrouping?.constraints.length ? ( + + 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}` + : ""} + + ) : null}
@@ -972,8 +1011,11 @@ export default function PostboxPage({ @@ -1013,6 +1055,13 @@ export default function PostboxPage({ setGroupingDraft((current) => ({ ...current, @@ -1025,10 +1074,21 @@ export default function PostboxPage({ {postbox.name} {postbox.organization_unit_name} · {postbox.function_name} + {postbox.grouping_policy.mode !== "allow" ? ( + + {postbox.grouping_policy.reason + || postbox.grouping_policy.mode.replaceAll("_", " ")} + + ) : null} ))} + {groupingDraftConflict ? ( + + {groupingDraftConflict} + + ) : null} 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 diff --git a/webui/src/features/postbox/PostboxQuickAccess.tsx b/webui/src/features/postbox/PostboxQuickAccess.tsx index eb691f6..bd53f3c 100644 --- a/webui/src/features/postbox/PostboxQuickAccess.tsx +++ b/webui/src/features/postbox/PostboxQuickAccess.tsx @@ -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({

) : null} {error ? {error} : null} + {data?.separatedCount ? ( + + {data.separatedCount} Postbox source{data.separatedCount === 1 ? " is" : "s are"} available only in a separated inbox view. + + ) : null} {messages.length ? (