from __future__ import annotations from collections.abc import Mapping from dataclasses import asdict from typing import Literal from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope from govoplan_core.core.concurrency import ( ConcurrencyError, MissingPreconditionError, RevisionConflictError, assert_revision_precondition, ) from govoplan_core.core.postbox import ( PostboxActorRef, PostboxAttachmentRef, PostboxDeliveryRequest, PostboxExternalRecipientTokenRef, PostboxMessageAuthoringRequest, PostboxParticipantRef, PostboxTargetRef, PostboxWrappedKeyRef, ) from govoplan_core.core.files import ( PostboxFileReferenceRequest, postbox_file_reference_provider, ) from govoplan_core.core.encryption import encryption_content_cipher from govoplan_core.db.session import get_session from govoplan_postbox.backend.manifest import ( ACKNOWLEDGE_SCOPE, BINDING_ADMIN_SCOPE, DELIVERY_SCOPE, READ_SCOPE, REPLY_SCOPE, SEND_SCOPE, TEMPLATE_ADMIN_SCOPE, ) from govoplan_postbox.backend.runtime import get_registry, get_service from govoplan_postbox.backend.schemas import ( PostboxAccessDecisionResponse, PostboxAttachmentResolutionItem, PostboxAttachmentResolutionResponse, PostboxDeliveryCreateRequest, PostboxDeliveryResponse, PostboxDirectoryItem, PostboxDirectoryResponse, PostboxExactCreateRequest, PostboxGroupingItem, PostboxGroupingListResponse, PostboxGroupingPayload, PostboxGroupingPolicyUpdateRequest, PostboxGroupingUpdateRequest, PostboxMaterializeRequest, PostboxMessageItem, PostboxMessageCreateRequest, PostboxMessageAuthoringPayload, PostboxMessageListResponse, PostboxMessageStateRequest, PostboxOrganizationTargetsResponse, PostboxMutationRequest, PostboxProtectionProfileItem, PostboxProtectionProfileListResponse, PostboxProtectionPolicyUpdateRequest, PostboxProtectionTransformRequest, PostboxProtectionTransitionCreateRequest, PostboxProtectionTransitionItemResponse, PostboxProtectionTransitionListResponse, PostboxProtectionTransitionResponse, PostboxRouteDryRunRequest, PostboxRouteDryRunResponse, PostboxTemplateCreateRequest, PostboxTemplateItem, PostboxTemplateListResponse, PostboxTemplatePreviewRequest, PostboxTemplatePreviewResponse, PostboxTemplatePublishRequest, 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, ) from govoplan_postbox.backend.principals import ( PostboxPrincipalError, actor_from_principal, ) router = APIRouter(prefix="/postbox", tags=["postbox"]) def _require(principal: ApiPrincipal, scope: str) -> None: if not has_scope(principal, scope): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {scope}", ) def _require_any(principal: ApiPrincipal, *scopes: str) -> None: if any(has_scope(principal, scope) for scope in scopes): return raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Requires one of: {', '.join(scopes)}", ) def _actor( principal: ApiPrincipal, *, assignment_context_id: str | None = None, ) -> PostboxActorRef: try: return actor_from_principal( principal, assignment_context_id=assignment_context_id, ) except PostboxPrincipalError as exc: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=str(exc), ) from exc def _http_error(exc: PostboxError) -> HTTPException: if exc.code.endswith("_not_found") or exc.code in { "message_not_found", "postbox_not_found", "template_not_found", "revision_not_found", "grouping_not_found", "target_not_found", "protection_transition_not_found", "transition_item_not_found", }: code = status.HTTP_404_NOT_FOUND elif exc.code in {"access_denied", "grouping_source_denied"}: code = status.HTTP_403_FORBIDDEN elif exc.code in { "template_slug_exists", "address_collision", "idempotency_conflict", "grouping_policy_conflict", }: code = status.HTTP_409_CONFLICT else: code = status.HTTP_400_BAD_REQUEST return HTTPException( status_code=code, detail={"code": exc.code, "message": str(exc)}, ) def _require_mutation_precondition( if_match: str | None, *, resource_type: str, resource_id: str, base_revision: int, ) -> None: try: assert_revision_precondition( if_match, resource_type=resource_type, resource_id=resource_id, submitted_base_revision=base_revision, ) except MissingPreconditionError as exc: raise HTTPException( status_code=status.HTTP_428_PRECONDITION_REQUIRED, detail=exc.as_dict(), ) from exc except ConcurrencyError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"code": "invalid_precondition", "message": str(exc)}, ) from exc def _concurrency_http_error(exc: ConcurrencyError) -> HTTPException: if isinstance(exc, MissingPreconditionError): return HTTPException( status_code=status.HTTP_428_PRECONDITION_REQUIRED, detail=exc.as_dict(), ) if isinstance(exc, RevisionConflictError): return HTTPException( status_code=status.HTTP_412_PRECONDITION_FAILED, detail=exc.as_dict(), ) return HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"code": "concurrency_conflict", "message": str(exc)}, ) def _set_etag(response: Response, etag: str | None) -> None: if etag: response.headers["ETag"] = etag def _authoring_request( payload: PostboxMessageAuthoringPayload, ) -> PostboxMessageAuthoringRequest: return PostboxMessageAuthoringRequest( idempotency_key=payload.idempotency_key, subject=payload.subject, body_text=payload.body_text, ciphertext_ref=payload.ciphertext_ref, signed_manifest_ref=payload.signed_manifest_ref, wrapped_keys=tuple( PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys ), classification=payload.classification, participants=tuple( PostboxParticipantRef(**participant.model_dump()) for participant in payload.participants ), attachments=tuple( PostboxAttachmentRef(**attachment.model_dump()) for attachment in payload.attachments ), metadata=payload.metadata, ) def _directory_item(value) -> PostboxDirectoryItem: return PostboxDirectoryItem.model_validate(asdict(value)) def _message_item(value) -> PostboxMessageItem: return PostboxMessageItem.model_validate(asdict(value)) def _template_item(template) -> PostboxTemplateItem: return PostboxTemplateItem( id=template.id, tenant_id=template.tenant_id, slug=template.slug, name=template.name, description=template.description, status=template.status, current_revision=template.current_revision, resource_revision=template.resource_revision, etag=template.strong_etag, published_revision_id=template.published_revision_id, revisions=[ { "id": revision.id, "revision": revision.revision, "function_type_id": revision.function_type_id, "scope_kind": revision.scope_kind, "scope_id": revision.scope_id, "scope_structure_id": revision.scope_structure_id, "scope_relation_type_ids": list(revision.scope_relation_type_ids or []), "name_pattern": revision.name_pattern, "address_pattern": revision.address_pattern, "classification": revision.classification, "allow_vacant_delivery": revision.allow_vacant_delivery, "portal_visible": revision.portal_visible, "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 {}), "published_at": revision.published_at, "created_at": revision.created_at, } for revision in template.revisions ], created_at=template.created_at, updated_at=template.updated_at, ) def _grouping_item( grouping, *, visible_ids: set[str], counts_by_postbox: Mapping[str, Mapping[str, int]] | None = None, ) -> PostboxGroupingItem: visible_source_ids = [ source.postbox_id for source in grouping.sources 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, is_default=grouping.is_default, resource_revision=grouping.resource_revision, etag=grouping.strong_etag, postbox_ids=visible_source_ids, total_count=sum( int(counts.get(postbox_id, {}).get("total", 0)) for postbox_id in count_source_ids ), unread_count=sum( int(counts.get(postbox_id, {}).get("unread", 0)) for postbox_id in count_source_ids ), constraints=constraints, created_at=grouping.created_at, updated_at=grouping.updated_at, ) def _protection_transition_item(value) -> PostboxProtectionTransitionResponse: return PostboxProtectionTransitionResponse( id=value.id, postbox_id=value.postbox_id, source_profile=value.source_profile, target_profile=value.target_profile, source_vault_id=value.source_vault_id, target_vault_id=value.target_vault_id, history_mode=value.history_mode, authority_mode=value.authority_mode, required_quorum=value.required_quorum, evidence_refs=list(value.evidence_refs or []), reason=value.reason, state=value.state, message_count=value.message_count, completed_count=value.completed_count, failed_count=value.failed_count, requested_by=value.requested_by, activated_at=value.activated_at, completed_at=value.completed_at, resource_revision=value.resource_revision, etag=value.strong_etag, configuration_snapshot=dict(value.configuration_snapshot or {}), items=[ PostboxProtectionTransitionItemResponse( id=item.id, message_id=item.message_id, source_profile=item.source_profile, target_profile=item.target_profile, state=item.state, source_digest=item.source_digest, target_digest=item.target_digest, completed_by=item.completed_by, completed_at=item.completed_at, error_code=item.error_code, evidence=dict(item.evidence or {}), ) for item in value.items ], ) @router.get("/directory", response_model=PostboxDirectoryResponse) def api_postbox_directory( assignment_context_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxDirectoryResponse: _require(principal, READ_SCOPE) entries = get_service().list_visible_postboxes( session, tenant_id=principal.tenant_id, actor=_actor( principal, assignment_context_id=assignment_context_id, ), ) return PostboxDirectoryResponse( postboxes=[_directory_item(entry) for entry in entries] ) @router.get( "/directory/{postbox_id}/access", response_model=PostboxAccessDecisionResponse, ) def api_postbox_access( postbox_id: str, action: Literal[ "discover", "read", "send", "reply", "acknowledge", "administer", ] = "read", assignment_context_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxAccessDecisionResponse: _require(principal, READ_SCOPE) try: decision = get_service().explain_access( session, tenant_id=principal.tenant_id, postbox_id=postbox_id, actor=_actor( principal, assignment_context_id=assignment_context_id, ), action=action, ) except PostboxError as exc: raise _http_error(exc) from exc return PostboxAccessDecisionResponse.model_validate(asdict(decision)) @router.get("/messages", response_model=PostboxMessageListResponse) def api_list_postbox_messages( postbox_id: list[str] = Query(default=[]), assignment_context_id: str | None = None, q: str | None = Query(default=None, max_length=200), state_filter: Literal[ "all", "unread", "read", "acknowledged", ] = Query(default="all", alias="state"), limit: int = Query(default=100, ge=1, le=500), offset: int = Query(default=0, ge=0), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxMessageListResponse: _require(principal, READ_SCOPE) actor = _actor( principal, assignment_context_id=assignment_context_id, ) requested = tuple(postbox_id) if not requested: requested = tuple( entry.id for entry in get_service().list_visible_postboxes( session, tenant_id=principal.tenant_id, actor=actor, ) ) messages = get_service().list_messages( session, tenant_id=principal.tenant_id, postbox_ids=requested, actor=actor, limit=limit, offset=offset, query=q, state=state_filter, ) total = get_service().count_messages( session, tenant_id=principal.tenant_id, postbox_ids=requested, actor=actor, query=q, state=state_filter, ) return PostboxMessageListResponse( messages=[_message_item(message) for message in messages], total=total, limit=limit, offset=offset, ) @router.post( "/messages", response_model=PostboxMessageItem, status_code=status.HTTP_201_CREATED, ) def api_create_postbox_message( payload: PostboxMessageCreateRequest, assignment_context_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxMessageItem: _require(principal, SEND_SCOPE) try: message = get_service().create_message( session, tenant_id=principal.tenant_id, postbox_id=payload.postbox_id, actor=_actor( principal, assignment_context_id=assignment_context_id, ), request=_authoring_request(payload), ) except PostboxError as exc: session.rollback() raise _http_error(exc) from exc session.commit() return _message_item(message) @router.post( "/messages/{message_id}/replies", response_model=PostboxMessageItem, status_code=status.HTTP_201_CREATED, ) def api_reply_to_postbox_message( message_id: str, payload: PostboxMessageAuthoringPayload, assignment_context_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxMessageItem: _require(principal, REPLY_SCOPE) try: message = get_service().reply_to_message( session, tenant_id=principal.tenant_id, message_id=message_id, actor=_actor( principal, assignment_context_id=assignment_context_id, ), request=_authoring_request(payload), ) except PostboxError as exc: session.rollback() raise _http_error(exc) from exc session.commit() return _message_item(message) @router.get("/messages/{message_id}", response_model=PostboxMessageItem) def api_get_postbox_message( message_id: str, assignment_context_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxMessageItem: _require(principal, READ_SCOPE) try: message = get_service().get_message( session, tenant_id=principal.tenant_id, message_id=message_id, actor=_actor( principal, assignment_context_id=assignment_context_id, ), ) except PostboxError as exc: raise _http_error(exc) from exc if message is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Postbox message not found.", ) session.commit() return _message_item(message) @router.get( "/messages/{message_id}/attachment-resolutions", response_model=PostboxAttachmentResolutionResponse, ) def api_resolve_postbox_message_attachments( message_id: str, assignment_context_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxAttachmentResolutionResponse: _require(principal, READ_SCOPE) try: message = get_service().get_message( session, tenant_id=principal.tenant_id, message_id=message_id, actor=_actor( principal, assignment_context_id=assignment_context_id, ), ) except PostboxError as exc: raise _http_error(exc) from exc if message is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Postbox message not found.", ) provider = postbox_file_reference_provider(get_registry()) file_types = { "file", "file_asset", "files:file", "file_version", "files:file_version", } requests = tuple( PostboxFileReferenceRequest( reference_type=attachment.reference_type, reference_id=attachment.reference_id, postbox_id=message.postbox_id, message_id=message.id, ) for attachment in message.attachments if attachment.reference_type.strip().casefold() in file_types ) resolved = ( provider.resolve_postbox_references( session, principal, tenant_id=principal.tenant_id, requests=requests, ) if provider is not None and requests else () ) by_reference = {(item.reference_type, item.reference_id): item for item in resolved} items: list[PostboxAttachmentResolutionItem] = [] for attachment in message.attachments: resolution = by_reference.get( (attachment.reference_type, attachment.reference_id) ) is_file = attachment.reference_type.strip().casefold() in file_types attachment_payload = asdict(attachment) attachment_payload.update( { "name": ( resolution.filename if resolution and resolution.filename else attachment.name ), "media_type": ( resolution.content_type if resolution and resolution.content_type else attachment.media_type ), "size_bytes": ( resolution.size_bytes if resolution and resolution.size_bytes is not None else attachment.size_bytes ), "digest": ( resolution.sha256 if resolution and resolution.sha256 else attachment.digest ), } ) items.append( PostboxAttachmentResolutionItem( **attachment_payload, available=bool(resolution and resolution.available), reason_code=( resolution.reason_code if resolution is not None else ( "files_provider_unavailable" if is_file else "reference_provider_unavailable" ) ), file_asset_id=resolution.file_asset_id if resolution else None, file_version_id=resolution.file_version_id if resolution else None, download_path=resolution.download_path if resolution else None, provenance=dict(resolution.provenance) if resolution else {}, ) ) session.commit() return PostboxAttachmentResolutionResponse(attachments=items) @router.patch( "/messages/{message_id}/state", response_model=PostboxMessageItem, ) def api_mark_postbox_message( message_id: str, payload: PostboxMessageStateRequest, assignment_context_id: str | None = None, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxMessageItem: _require( principal, ACKNOWLEDGE_SCOPE if payload.state == "acknowledged" else READ_SCOPE, ) try: message = get_service().mark_message( session, tenant_id=principal.tenant_id, message_id=message_id, actor=_actor( principal, assignment_context_id=assignment_context_id, ), state=payload.state, ) except PostboxError as exc: raise _http_error(exc) from exc session.commit() return _message_item(message) @router.post( "/deliveries", response_model=PostboxDeliveryResponse, status_code=status.HTTP_201_CREATED, ) def api_deliver_to_postbox( payload: PostboxDeliveryCreateRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxDeliveryResponse: _require(principal, DELIVERY_SCOPE) request = PostboxDeliveryRequest( tenant_id=principal.tenant_id, target=PostboxTargetRef(**payload.target.model_dump()), producer_module=payload.producer_module, producer_resource_type=payload.producer_resource_type, producer_resource_id=payload.producer_resource_id, idempotency_key=payload.idempotency_key, subject=payload.subject, body_text=payload.body_text, sender_label=payload.sender_label, classification=payload.classification, participants=tuple( PostboxParticipantRef(**participant.model_dump()) for participant in payload.participants ), attachments=tuple( PostboxAttachmentRef(**attachment.model_dump()) for attachment in payload.attachments ), expires_at=payload.expires_at, ciphertext_ref=payload.ciphertext_ref, signed_manifest_ref=payload.signed_manifest_ref, wrapped_keys=tuple( PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys ), external_recipient_tokens=tuple( PostboxExternalRecipientTokenRef(**item.model_dump()) for item in payload.external_recipient_tokens ), metadata=payload.metadata, ) try: result = get_service().deliver(session, request) except PostboxError as exc: session.rollback() raise _http_error(exc) from exc session.commit() return PostboxDeliveryResponse.model_validate(asdict(result)) @router.post( "/routing/dry-run", response_model=PostboxRouteDryRunResponse, ) def api_preview_postbox_routing( payload: PostboxRouteDryRunRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxRouteDryRunResponse: _require_any(principal, DELIVERY_SCOPE, TEMPLATE_ADMIN_SCOPE) try: result = get_service().preview_hierarchy_routes( session, tenant_id=principal.tenant_id, target=PostboxTargetRef(**payload.target.model_dump()), producer_module=payload.producer_module, classification=payload.classification, expires_at=payload.expires_at, ) except PostboxError as exc: raise _http_error(exc) from exc return PostboxRouteDryRunResponse.model_validate(result) @router.get("/groupings", response_model=PostboxGroupingListResponse) def api_list_postbox_groupings( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxGroupingListResponse: _require(principal, READ_SCOPE) actor = _actor(principal) visible_ids = { item.id for item in get_service().list_visible_postboxes( session, tenant_id=principal.tenant_id, actor=actor, ) } groupings = get_service().list_groupings( session, tenant_id=principal.tenant_id, actor=actor, ) counts_by_postbox = get_service().message_counts_by_postbox( session, tenant_id=principal.tenant_id, postbox_ids=tuple(visible_ids), actor=actor, ) return PostboxGroupingListResponse( groupings=[ _grouping_item( grouping, visible_ids=visible_ids, counts_by_postbox=counts_by_postbox, ) for grouping in groupings ] ) @router.post( "/groupings", response_model=PostboxGroupingItem, status_code=status.HTTP_201_CREATED, ) def api_create_postbox_grouping( payload: PostboxGroupingPayload, response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxGroupingItem: _require(principal, READ_SCOPE) actor = _actor(principal) try: grouping = get_service().save_grouping( session, tenant_id=principal.tenant_id, actor=actor, grouping_id=None, **payload.model_dump(), ) except PostboxError as exc: raise _http_error(exc) from exc session.commit() item = _grouping_item(grouping, visible_ids=set(payload.postbox_ids)) _set_etag(response, item.etag) return item @router.put("/groupings/{grouping_id}", response_model=PostboxGroupingItem) def api_update_postbox_grouping( grouping_id: str, payload: PostboxGroupingUpdateRequest, response: Response, if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxGroupingItem: _require(principal, READ_SCOPE) _require_mutation_precondition( if_match, resource_type="postbox_grouping", resource_id=grouping_id, base_revision=payload.base_revision, ) actor = _actor(principal) try: grouping = get_service().save_grouping( session, tenant_id=principal.tenant_id, actor=actor, grouping_id=grouping_id, expected_revision=payload.base_revision, **payload.model_dump(exclude={"base_revision"}), ) except PostboxError as exc: raise _http_error(exc) from exc except ConcurrencyError as exc: session.rollback() raise _concurrency_http_error(exc) from exc session.commit() item = _grouping_item(grouping, visible_ids=set(payload.postbox_ids)) _set_etag(response, item.etag) return item @router.delete("/groupings/{grouping_id}", status_code=status.HTTP_204_NO_CONTENT) def api_delete_postbox_grouping( grouping_id: str, payload: PostboxMutationRequest, if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> None: _require(principal, READ_SCOPE) _require_mutation_precondition( if_match, resource_type="postbox_grouping", resource_id=grouping_id, base_revision=payload.base_revision, ) try: get_service().delete_grouping( session, tenant_id=principal.tenant_id, actor=_actor(principal), grouping_id=grouping_id, expected_revision=payload.base_revision, ) except PostboxError as exc: raise _http_error(exc) from exc except ConcurrencyError as exc: session.rollback() raise _concurrency_http_error(exc) from exc session.commit() @router.get( "/admin/organization-targets", response_model=PostboxOrganizationTargetsResponse, ) def api_postbox_organization_targets( principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxOrganizationTargetsResponse: _require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE) return PostboxOrganizationTargetsResponse( units=list(get_service().organization_targets(tenant_id=principal.tenant_id)), structures=list( get_service().organization_hierarchy_targets(tenant_id=principal.tenant_id) ), ) @router.get("/admin/postboxes", response_model=PostboxDirectoryResponse) def api_admin_postboxes( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxDirectoryResponse: _require(principal, BINDING_ADMIN_SCOPE) return PostboxDirectoryResponse( postboxes=[ _directory_item(item) for item in get_service().list_admin_postboxes( session, tenant_id=principal.tenant_id, ) ] ) @router.get( "/admin/protection-profiles", response_model=PostboxProtectionProfileListResponse, ) def api_postbox_protection_profiles( principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxProtectionProfileListResponse: _require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE) managed_available = encryption_content_cipher(get_registry()) is not None return PostboxProtectionProfileListResponse( standard_profile=POSTBOX_STANDARD_PROFILE, profiles=[ PostboxProtectionProfileItem( **asdict(profile), available=( managed_available if profile.requires_encryption_module else True ), ) for profile in POSTBOX_PROTECTION_PROFILE_DEFINITIONS ], ) @router.get( "/admin/postboxes/{postbox_id}/protection-transitions", response_model=PostboxProtectionTransitionListResponse, ) def api_list_postbox_protection_transitions( postbox_id: str, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxProtectionTransitionListResponse: _require(principal, BINDING_ADMIN_SCOPE) try: values = get_service().list_protection_transitions( session, tenant_id=principal.tenant_id, postbox_id=postbox_id, ) except PostboxError as exc: raise _http_error(exc) from exc return PostboxProtectionTransitionListResponse( transitions=[_protection_transition_item(value) for value in values] ) @router.put( "/admin/postboxes/{postbox_id}/protection-policy", response_model=PostboxDirectoryItem, ) def api_update_postbox_protection_policy( postbox_id: str, payload: PostboxProtectionPolicyUpdateRequest, 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_protection_policy( session, tenant_id=principal.tenant_id, postbox_id=postbox_id, protection_policy=payload.protection_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.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, status_code=status.HTTP_201_CREATED, ) def api_create_postbox_protection_transition( postbox_id: str, payload: PostboxProtectionTransitionCreateRequest, response: Response, if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxProtectionTransitionResponse: _require(principal, BINDING_ADMIN_SCOPE) _require_mutation_precondition( if_match, resource_type="postbox", resource_id=postbox_id, base_revision=payload.base_revision, ) try: value = get_service().create_protection_transition( session, tenant_id=principal.tenant_id, postbox_id=postbox_id, expected_revision=payload.base_revision, actor_id=principal.account_id, **payload.model_dump( exclude={"base_revision", "acknowledge_irreversibility"} ), ) 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 = _protection_transition_item(value) _set_etag(response, item.etag) return item @router.post( "/admin/postboxes/{postbox_id}/protection-transitions/{transition_id}/transform", response_model=PostboxProtectionTransitionResponse, ) def api_apply_postbox_protection_transform( postbox_id: str, transition_id: str, payload: PostboxProtectionTransformRequest, response: Response, if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxProtectionTransitionResponse: _require(principal, BINDING_ADMIN_SCOPE) _require_mutation_precondition( if_match, resource_type="postbox_protection_transition", resource_id=transition_id, base_revision=payload.base_revision, ) try: value = get_service().apply_client_protection_transform( session, tenant_id=principal.tenant_id, postbox_id=postbox_id, transition_id=transition_id, expected_revision=payload.base_revision, actor_id=principal.account_id, wrapped_keys=tuple( PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys ), **payload.model_dump(exclude={"base_revision", "wrapped_keys"}), ) 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 = _protection_transition_item(value) _set_etag(response, item.etag) return item @router.post( "/admin/postboxes", response_model=PostboxDirectoryItem, status_code=status.HTTP_201_CREATED, ) def api_create_exact_postbox( payload: PostboxExactCreateRequest, response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxDirectoryItem: _require(principal, BINDING_ADMIN_SCOPE) try: postbox = get_service().create_exact_postbox( session, tenant_id=principal.tenant_id, actor_id=principal.account_id, **payload.model_dump(), ) except PostboxError as exc: raise _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.delete( "/admin/postboxes/{postbox_id}", response_model=PostboxDirectoryItem, ) def api_archive_postbox( postbox_id: str, payload: PostboxMutationRequest, 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: postbox = get_service().archive_postbox( session, tenant_id=principal.tenant_id, postbox_id=postbox_id, actor_id=principal.account_id, expected_revision=payload.base_revision, ) except PostboxError as exc: 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.get("/admin/templates", response_model=PostboxTemplateListResponse) def api_postbox_templates( session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxTemplateListResponse: _require(principal, TEMPLATE_ADMIN_SCOPE) return PostboxTemplateListResponse( templates=[ _template_item(template) for template in get_service().list_templates( session, tenant_id=principal.tenant_id, ) ] ) @router.post( "/admin/templates", response_model=PostboxTemplateItem, status_code=status.HTTP_201_CREATED, ) def api_create_postbox_template( payload: PostboxTemplateCreateRequest, response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxTemplateItem: _require(principal, TEMPLATE_ADMIN_SCOPE) try: template = get_service().create_template( session, tenant_id=principal.tenant_id, actor_id=principal.account_id, **payload.model_dump(), ) except PostboxError as exc: raise _http_error(exc) from exc session.commit() item = _template_item(template) _set_etag(response, item.etag) return item @router.post( "/admin/templates/preview", response_model=PostboxTemplatePreviewResponse, ) def api_preview_postbox_template( payload: PostboxTemplatePreviewRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxTemplatePreviewResponse: _require(principal, TEMPLATE_ADMIN_SCOPE) try: preview = get_service().preview_template_targets( session, tenant_id=principal.tenant_id, **payload.model_dump(), ) except PostboxError as exc: raise _http_error(exc) from exc return PostboxTemplatePreviewResponse.model_validate(preview) @router.post( "/admin/templates/{template_id}/revisions", response_model=PostboxTemplateItem, ) def api_revise_postbox_template( template_id: str, payload: PostboxTemplateReviseRequest, response: Response, if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxTemplateItem: _require(principal, TEMPLATE_ADMIN_SCOPE) _require_mutation_precondition( if_match, resource_type="postbox_template", resource_id=template_id, base_revision=payload.base_revision, ) try: template = get_service().revise_template( session, tenant_id=principal.tenant_id, template_id=template_id, actor_id=principal.account_id, expected_revision=payload.base_revision, **payload.model_dump(exclude={"base_revision"}), ) except PostboxError as exc: raise _http_error(exc) from exc except ConcurrencyError as exc: session.rollback() raise _concurrency_http_error(exc) from exc session.commit() item = _template_item(template) _set_etag(response, item.etag) return item @router.post( "/admin/templates/{template_id}/publish", response_model=PostboxTemplateItem, ) def api_publish_postbox_template( template_id: str, payload: PostboxTemplatePublishRequest, response: Response, if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxTemplateItem: _require(principal, TEMPLATE_ADMIN_SCOPE) _require_mutation_precondition( if_match, resource_type="postbox_template", resource_id=template_id, base_revision=payload.base_revision, ) try: template = get_service().publish_template( session, tenant_id=principal.tenant_id, template_id=template_id, revision_number=payload.revision, actor_id=principal.account_id, expected_revision=payload.base_revision, ) except PostboxError as exc: raise _http_error(exc) from exc except ConcurrencyError as exc: session.rollback() raise _concurrency_http_error(exc) from exc session.commit() item = _template_item(template) _set_etag(response, item.etag) return item @router.post( "/admin/templates/{template_id}/retire", response_model=PostboxTemplateItem, ) def api_retire_postbox_template( template_id: str, payload: PostboxMutationRequest, response: Response, if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxTemplateItem: _require(principal, TEMPLATE_ADMIN_SCOPE) _require_mutation_precondition( if_match, resource_type="postbox_template", resource_id=template_id, base_revision=payload.base_revision, ) try: template = get_service().retire_template( session, tenant_id=principal.tenant_id, template_id=template_id, actor_id=principal.account_id, expected_revision=payload.base_revision, ) except PostboxError as exc: raise _http_error(exc) from exc except ConcurrencyError as exc: session.rollback() raise _concurrency_http_error(exc) from exc session.commit() item = _template_item(template) _set_etag(response, item.etag) return item @router.post( "/admin/templates/{template_id}/materialize", response_model=PostboxDirectoryItem, ) def api_materialize_postbox_template( template_id: str, payload: PostboxMaterializeRequest, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxDirectoryItem: _require(principal, BINDING_ADMIN_SCOPE) _require(principal, TEMPLATE_ADMIN_SCOPE) try: postbox = get_service().materialize_template( session, tenant_id=principal.tenant_id, template_id=template_id, actor_id=principal.account_id, **payload.model_dump(), ) except PostboxError as exc: raise _http_error(exc) from exc session.commit() entry = get_service().resolve_postbox( session, tenant_id=principal.tenant_id, target=PostboxTargetRef(postbox_id=postbox.id), ) if entry is None: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Materialized Postbox could not be reloaded.", ) return _directory_item(entry)