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