from __future__ import annotations from collections.abc import Mapping, Sequence from urllib.parse import quote from sqlalchemy import func, select from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal from govoplan_core.core.events import PlatformEvent from govoplan_core.core.modules import ModuleContext from govoplan_core.core.postbox import PostboxActorRef from govoplan_core.core.search import ( SearchAuthorizationRequest, SearchBackfillPage, SearchBackfillRequest, SearchDocument, SearchIndexChange, SearchResourceReference, SearchResourceType, ) from govoplan_postbox.backend.db.models import ( Postbox, PostboxMessage, PostboxRoute, ) from govoplan_postbox.backend.service import PostboxService PROVIDER_ID = "postbox.messages" RESOURCE_TYPE = "postbox_message" READ_SCOPE = "postbox:postbox:read" CONFIDENTIAL_SCOPE = "postbox:classification:confidential" RESTRICTED_SCOPE = "postbox:classification:restricted" class PostboxSearchSource: def __init__(self, service: PostboxService) -> None: self.service = service def resource_types(self) -> Sequence[SearchResourceType]: return ( SearchResourceType( provider_id=PROVIDER_ID, module_id="postbox", resource_type=RESOURCE_TYPE, label="Postbox messages", requires_authorization_recheck=True, ), ) def backfill( self, session: object, *, request: SearchBackfillRequest, ) -> SearchBackfillPage: _assert_source(request.provider_id, request.resource_type) db = _session(session) statement = ( select(PostboxMessage, Postbox) .join(Postbox, Postbox.id == PostboxMessage.postbox_id) .where( PostboxMessage.tenant_id == request.tenant_id, Postbox.tenant_id == request.tenant_id, ) ) if request.cursor: statement = statement.where(PostboxMessage.id > request.cursor) rows = list( db.execute( statement.order_by(PostboxMessage.id).limit(request.limit + 1) ).all() ) has_more = len(rows) > request.limit selected = rows[: request.limit] high_watermark = db.scalar( select(func.max(PostboxMessage.updated_at)).where( PostboxMessage.tenant_id == request.tenant_id ) ) return SearchBackfillPage( documents=tuple( _document(message, postbox=postbox) for message, postbox in selected ), next_cursor=( selected[-1][0].id if has_more and selected else None ), complete=not has_more, high_watermark=( high_watermark.isoformat() if high_watermark is not None else None ), ) def authorize( self, session: object, principal: object, *, requests: Sequence[SearchAuthorizationRequest], ) -> Mapping[str, bool]: decisions = {item.reference.key: False for item in requests} if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE): return decisions actor = _actor(principal) db = _session(session) for item in requests: reference = item.reference if ( reference.tenant_id != principal.tenant_id or reference.module_id != "postbox" or reference.resource_type != RESOURCE_TYPE ): continue try: decisions[reference.key] = self.service.can_read_message( db, tenant_id=principal.tenant_id, message_id=reference.resource_id, actor=actor, ) except (RuntimeError, ValueError): decisions[reference.key] = False return decisions def index_changes_for_event( self, session: object, *, event: PlatformEvent, delivery_key: str, ) -> Sequence[SearchIndexChange]: if ( event.module_id != "postbox" or event.tenant is None or event.resource is None or event.resource.id is None or event.resource.type not in {RESOURCE_TYPE, "postbox_route"} ): return () db = _session(session) message_id = event.resource.id if event.resource.type == "postbox_route": route = db.get(PostboxRoute, event.resource.id) if ( route is None or route.tenant_id != event.tenant.id or route.target_message_id is None ): return () message_id = route.target_message_id row = db.get(PostboxMessage, message_id) postbox = ( db.get(Postbox, row.postbox_id) if row is not None and row.tenant_id == event.tenant.id else None ) deleted = ( row is None or row.tenant_id != event.tenant.id or postbox is None ) cursor = event.event_id document = ( None if deleted else _document(row, postbox=postbox, change_cursor=cursor) ) reference = SearchResourceReference( tenant_id=event.tenant.id, module_id="postbox", resource_type=RESOURCE_TYPE, resource_id=message_id, ) return ( SearchIndexChange( change_id=f"{delivery_key}:{PROVIDER_ID}:{message_id}", provider_id=PROVIDER_ID, kind="delete" if deleted else "upsert", reference=reference, source_revision=( document.source_revision if document is not None else cursor ), cursor=cursor, document=document, occurred_at=event.occurred_at, ), ) def create_postbox_search_source( context: ModuleContext, ) -> PostboxSearchSource: return PostboxSearchSource(PostboxService.from_registry(context.registry)) def _actor(principal: ApiPrincipal) -> PostboxActorRef: selected = principal.acting_assignment_id if selected is None and len(principal.function_assignment_ids) == 1: selected = next(iter(principal.function_assignment_ids)) classifications = {"public", "internal"} if principal.has(CONFIDENTIAL_SCOPE): classifications.add("confidential") if principal.has(RESTRICTED_SCOPE): classifications.update(("confidential", "restricted")) return PostboxActorRef( account_id=principal.account_id, identity_id=principal.identity_id, selected_assignment_id=selected, acting_for_account_id=principal.acting_for_account_id, authorized_actions=frozenset({"discover", "read"}), authorized_classifications=frozenset(classifications), ) def _document( message: PostboxMessage, *, postbox: Postbox, change_cursor: str | None = None, ) -> SearchDocument: updated_at = message.updated_at or message.created_at body = ( message.body_text if message.encryption_profile == "plaintext_v1" and message.body_ciphertext is None else None ) tokens = [f"scope:{READ_SCOPE}"] if message.classification == "confidential": tokens.append(f"scope:{CONFIDENTIAL_SCOPE}") elif message.classification == "restricted": tokens.append(f"scope:{RESTRICTED_SCOPE}") return SearchDocument( tenant_id=message.tenant_id, module_id="postbox", provider_id=PROVIDER_ID, resource_type=RESOURCE_TYPE, resource_id=message.id, title=message.subject[:500], url=f"/postbox?messageId={quote(message.id, safe='')}", summary=(message.sender_label or postbox.name)[:4000], body=body[:200_000] if body else None, keywords=tuple( item[:200] for item in ( postbox.name, message.status, message.classification, message.producer_module or "", ) if item ), visibility="restricted", acl_tokens=tuple(dict.fromkeys(tokens)), metadata={ "postbox_id": message.postbox_id, "postbox_name": postbox.name, "status": message.status, "classification": message.classification, "sender_label": message.sender_label, "delivered_at": message.delivered_at.isoformat(), "withdrawn_at": ( message.withdrawn_at.isoformat() if message.withdrawn_at else None ), "encrypted": body is None and message.body_ciphertext is not None, }, source_revision=f"{message.status}:{updated_at.isoformat()}", change_cursor=change_cursor, source_updated_at=updated_at, requires_authorization_recheck=True, ) def _assert_source(provider_id: str, resource_type: str) -> None: if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE: raise ValueError("Unsupported Postbox search source.") def _session(value: object) -> Session: if not isinstance(value, Session): raise TypeError("Postbox search requires a SQLAlchemy session.") return value __all__ = [ "PROVIDER_ID", "PostboxSearchSource", "RESOURCE_TYPE", "create_postbox_search_source", ]