diff --git a/pyproject.toml b/pyproject.toml index adff214..38bf87f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-postbox" -version = "0.1.1" +version = "0.1.2" description = "Function-bound institutional postboxes for GovOPlaN." readme = "README.md" requires-python = ">=3.12" diff --git a/src/govoplan_postbox/backend/access_decisions.py b/src/govoplan_postbox/backend/access_decisions.py new file mode 100644 index 0000000..6fc69dc --- /dev/null +++ b/src/govoplan_postbox/backend/access_decisions.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable, Sequence + +from govoplan_core.core.idm import OrganizationFunctionAssignmentRef +from govoplan_core.core.postbox import ( + PostboxAccessDecisionRef, + PostboxAction, + PostboxActorRef, +) + + +@dataclass(frozen=True, slots=True) +class PostboxAccessContext: + postbox_id: str + postbox_active: bool + action: PostboxAction + actor: PostboxActorRef + organization_unit_id: str | None + function_id: str | None + holder_count: int + binding_available: bool + binding_assignments: tuple[OrganizationFunctionAssignmentRef, ...] + + @property + def base(self) -> dict[str, object]: + return { + "action": self.action, + "postbox_id": self.postbox_id, + "organization_unit_id": self.organization_unit_id, + "function_id": self.function_id, + "holder_count": self.holder_count, + "vacant": self.holder_count == 0, + } + + +@dataclass(frozen=True, slots=True) +class AccessRule: + name: str + matches: Callable[[PostboxAccessContext], bool] + decision: Callable[[PostboxAccessContext], PostboxAccessDecisionRef] + + +def evaluate_postbox_access( + *, + postbox_id: str, + postbox_active: bool, + action: PostboxAction, + actor: PostboxActorRef, + organization_unit_id: str | None, + function_id: str | None, + holder_count: int, + binding_available: bool, + binding_assignments: Sequence[OrganizationFunctionAssignmentRef], +) -> PostboxAccessDecisionRef: + context = PostboxAccessContext( + postbox_id=postbox_id, + postbox_active=postbox_active, + action=action, + actor=actor, + organization_unit_id=organization_unit_id, + function_id=function_id, + holder_count=holder_count, + binding_available=binding_available, + binding_assignments=tuple(binding_assignments), + ) + for rule in ACCESS_DECISION_TABLE: + if rule.matches(context): + return rule.decision(context) + return _assignment_decision(context) + + +def _deny( + context: PostboxAccessContext, + *, + reason_code: str, + explanation: str, +) -> PostboxAccessDecisionRef: + return PostboxAccessDecisionRef( + allowed=False, + reason_code=reason_code, + explanation=explanation, + **context.base, + ) + + +def _inactive(context: PostboxAccessContext) -> PostboxAccessDecisionRef: + return _deny( + context, + reason_code="postbox_inactive", + explanation="The Postbox is not active.", + ) + + +def _permission_missing( + context: PostboxAccessContext, +) -> PostboxAccessDecisionRef: + return _deny( + context, + reason_code="generic_permission_missing", + explanation=( + "The account lacks the generic Postbox permission for this action." + ), + ) + + +def _administrator(context: PostboxAccessContext) -> PostboxAccessDecisionRef: + return PostboxAccessDecisionRef( + allowed=True, + reason_code="generic_administrator", + explanation="The account has Postbox administration permission.", + **context.base, + ) + + +def _binding_missing(context: PostboxAccessContext) -> PostboxAccessDecisionRef: + return _deny( + context, + reason_code="function_binding_missing", + explanation="This Postbox has no active organization-function binding.", + ) + + +def _assignment_decision( + context: PostboxAccessContext, +) -> PostboxAccessDecisionRef: + eligible: list[OrganizationFunctionAssignmentRef] = [] + acting_candidates: list[OrganizationFunctionAssignmentRef] = [] + for assignment in context.binding_assignments: + if assignment.source != "acting_for": + eligible.append(assignment) + continue + acting_candidates.append(assignment) + if context.actor.selected_assignment_id != assignment.id: + continue + if ( + assignment.acting_for_account_id + and context.actor.acting_for_account_id + != assignment.acting_for_account_id + ): + return _deny( + context, + reason_code="acting_account_mismatch", + explanation=( + "The selected acting assignment belongs to another " + "represented account context." + ), + ) + eligible.append(assignment) + + if not eligible: + if acting_candidates and not context.actor.selected_assignment_id: + return _deny( + context, + reason_code="acting_context_required", + explanation=( + "Select the acting assignment context before opening this " + "Postbox." + ), + ) + if acting_candidates: + return _deny( + context, + reason_code="acting_assignment_not_selected", + explanation=( + "The selected assignment context does not grant access to " + "this Postbox." + ), + ) + return _deny( + context, + reason_code="effective_assignment_missing", + explanation=( + "No current function assignment grants this account access " + "to the Postbox." + ), + ) + + selected = next( + ( + assignment + for assignment in eligible + if assignment.id == context.actor.selected_assignment_id + ), + eligible[0], + ) + return PostboxAccessDecisionRef( + allowed=True, + reason_code=f"effective_{selected.source}_assignment", + explanation=( + "Access follows the current effective organization-function " + f"assignment ({selected.source})." + ), + assignment_ids=tuple(item.id for item in eligible), + assignment_sources=tuple(item.source for item in eligible), + selected_assignment_id=selected.id, + **context.base, + ) + + +ACCESS_DECISION_TABLE = ( + AccessRule( + name="inactive_postbox", + matches=lambda context: not context.postbox_active, + decision=_inactive, + ), + AccessRule( + name="generic_permission", + matches=lambda context: context.action not in context.actor.authorized_actions, + decision=_permission_missing, + ), + AccessRule( + name="administrator", + matches=lambda context: context.action == "administer", + decision=_administrator, + ), + AccessRule( + name="active_function_binding", + matches=lambda context: ( + not context.binding_available + or not context.function_id + or not context.organization_unit_id + ), + decision=_binding_missing, + ), +) + + +__all__ = [ + "ACCESS_DECISION_TABLE", + "AccessRule", + "PostboxAccessContext", + "evaluate_postbox_access", +] diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index 460482a..59d35ff 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -48,7 +48,7 @@ from govoplan_postbox.backend.db import models as postbox_models MODULE_ID = "postbox" MODULE_NAME = "Postbox" -MODULE_VERSION = "0.1.1" +MODULE_VERSION = "0.1.2" READ_SCOPE = "postbox:postbox:read" SEND_SCOPE = "postbox:message:write" diff --git a/src/govoplan_postbox/backend/schemas.py b/src/govoplan_postbox/backend/schemas.py index 910d139..c8ef5ca 100644 --- a/src/govoplan_postbox/backend/schemas.py +++ b/src/govoplan_postbox/backend/schemas.py @@ -69,6 +69,7 @@ class PostboxMessageItem(BaseModel): subject: str body_text: str | None = None status: str + availability: Literal["available", "withdrawn", "expired"] classification: str sender_label: str | None = None delivered_at: datetime diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py index cd402d0..48bf499 100644 --- a/src/govoplan_postbox/backend/service.py +++ b/src/govoplan_postbox/backend/service.py @@ -3,6 +3,7 @@ from __future__ import annotations import hashlib import logging import re +from collections import Counter from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone from typing import Literal @@ -48,11 +49,13 @@ from govoplan_core.core.postbox import ( PostboxActorRef, PostboxAttachmentRef, PostboxDeliveryCatalogRef, + PostboxDeliveryReceiptSummaryRef, PostboxDeliveryRequest, PostboxDeliveryRejected, PostboxDeliveryResult, PostboxDeliveryTemplateRef, PostboxDirectoryEntryRef, + PostboxMessageAvailability, PostboxMessageRef, PostboxMessageListState, PostboxOrganizationFunctionTargetRef, @@ -84,6 +87,7 @@ from govoplan_postbox.backend.hierarchy_routing import ( normalized_routing_policy, plan_hierarchy_routes, ) +from govoplan_postbox.backend.access_decisions import evaluate_postbox_access logger = logging.getLogger(__name__) @@ -123,6 +127,28 @@ def _as_utc(value: datetime) -> datetime: return value.astimezone(timezone.utc) +def _message_availability( + message: PostboxMessage, + *, + now: datetime | None = None, +) -> PostboxMessageAvailability: + if message.withdrawn_at is not None: + return "withdrawn" + if message.expires_at is not None and _as_utc(message.expires_at) <= _as_utc( + now or utc_now() + ): + return "expired" + return "available" + + +def _first_timestamp(values: Sequence[datetime]) -> datetime | None: + return min(values, key=_as_utc) if values else None + + +def _last_timestamp(values: Sequence[datetime]) -> datetime | None: + return max(values, key=_as_utc) if values else None + + def _publish_postbox_event( session: Session, event_type: str, @@ -651,6 +677,12 @@ class PostboxService: ) if not decision.allowed: raise PostboxError("access_denied", decision.explanation) + availability = _message_availability(message) + if availability != "available": + raise PostboxError( + f"message_{availability}", + f"This Postbox message is {availability} and cannot be changed.", + ) receipt = ( db.query(PostboxMessageReceipt) .filter( @@ -1918,6 +1950,189 @@ class PostboxService: ) return self._message_ref(message, account_id="") + def delivery_receipt_summaries( + self, + session: object, + *, + tenant_id: str, + producer_module: str, + delivery_ids: Sequence[str], + ) -> Mapping[str, PostboxDeliveryReceiptSummaryRef]: + db = _session(session) + requested_ids = tuple( + dict.fromkeys( + str(delivery_id).strip() + for delivery_id in delivery_ids + if str(delivery_id).strip() + ) + ) + if not requested_ids: + return {} + if len(requested_ids) > 1000: + raise PostboxError( + "delivery_summary_limit", + "At most 1000 Postbox deliveries can be summarized at once.", + ) + + deliveries = ( + db.query(PostboxDelivery) + .filter( + PostboxDelivery.tenant_id == tenant_id, + PostboxDelivery.producer_module == producer_module, + PostboxDelivery.id.in_(requested_ids), + ) + .all() + ) + if not deliveries: + return {} + + accepted_ids = tuple(delivery.id for delivery in deliveries) + routes = ( + db.query(PostboxRoute) + .filter( + PostboxRoute.tenant_id == tenant_id, + PostboxRoute.delivery_id.in_(accepted_ids), + ) + .all() + ) + routes_by_delivery: dict[str, list[PostboxRoute]] = { + delivery_id: [] for delivery_id in accepted_ids + } + message_ids_by_delivery: dict[str, set[str]] = { + delivery.id: {delivery.message_id} for delivery in deliveries + } + for route in routes: + routes_by_delivery.setdefault(route.delivery_id, []).append(route) + if route.target_message_id: + message_ids_by_delivery.setdefault(route.delivery_id, set()).add( + route.target_message_id + ) + + message_ids = tuple( + { + message_id + for ids in message_ids_by_delivery.values() + for message_id in ids + } + ) + messages = ( + db.query(PostboxMessage) + .options(selectinload(PostboxMessage.receipts)) + .filter( + PostboxMessage.tenant_id == tenant_id, + PostboxMessage.id.in_(message_ids), + ) + .all() + ) + messages_by_id = {message.id: message for message in messages} + postbox_ids = tuple({message.postbox_id for message in messages}) + postboxes = ( + db.query(Postbox) + .options( + selectinload(Postbox.address_record), + selectinload(Postbox.bindings), + ) + .filter( + Postbox.tenant_id == tenant_id, + Postbox.id.in_(postbox_ids), + ) + .all() + ) + postboxes_by_id = {postbox.id: postbox for postbox in postboxes} + holder_cache = self._holder_cache( + tenant_id=tenant_id, + postboxes=postboxes, + ) + holder_counts: dict[str, int] = {} + for postbox in postboxes: + binding = self._active_binding(postbox) + function_id = ( + binding.function_id + if binding is not None + else postbox.address_record.function_id + ) + holder_counts[postbox.id] = len( + { + holder.identity_id + for holder in self._holders( + tenant_id, + function_id, + holder_cache, + ) + } + ) + + now = utc_now() + summaries: dict[str, PostboxDeliveryReceiptSummaryRef] = {} + for delivery in deliveries: + delivery_messages = [ + messages_by_id[message_id] + for message_id in message_ids_by_delivery.get(delivery.id, ()) + if message_id in messages_by_id + ] + read_times = [ + receipt.read_at + for message in delivery_messages + for receipt in message.receipts + if receipt.read_at is not None + ] + acknowledged_times = [ + receipt.acknowledged_at + for message in delivery_messages + for receipt in message.receipts + if receipt.acknowledged_at is not None + ] + availabilities = { + message.id: _message_availability(message, now=now) + for message in delivery_messages + } + readable_count = sum( + 1 + for message in delivery_messages + if availabilities[message.id] == "available" + and ( + postbox := postboxes_by_id.get(message.postbox_id) + ) is not None + and postbox.status == "active" + and holder_counts.get(postbox.id, 0) > 0 + ) + delivery_routes = routes_by_delivery.get(delivery.id, ()) + route_status_counts = Counter(route.status for route in delivery_routes) + summaries[delivery.id] = PostboxDeliveryReceiptSummaryRef( + delivery_id=delivery.id, + message_id=delivery.message_id, + postbox_id=delivery.postbox_id, + delivery_status=delivery.status, + accepted_at=delivery.accepted_at, + current_holder_count=holder_counts.get(delivery.postbox_id, 0), + currently_readable=readable_count > 0, + message_count=len(delivery_messages), + routed_message_count=len( + { + route.target_message_id + for route in delivery_routes + if route.target_message_id + } + ), + readable_message_count=readable_count, + read_receipt_count=len(read_times), + acknowledged_receipt_count=len(acknowledged_times), + withdrawn_message_count=sum( + availability == "withdrawn" + for availability in availabilities.values() + ), + expired_message_count=sum( + availability == "expired" + for availability in availabilities.values() + ), + first_read_at=_first_timestamp(read_times), + last_read_at=_last_timestamp(read_times), + first_acknowledged_at=_first_timestamp(acknowledged_times), + last_acknowledged_at=_last_timestamp(acknowledged_times), + route_status_counts=dict(sorted(route_status_counts.items())), + ) + return summaries + # Administration def list_admin_postboxes( self, @@ -2825,92 +3040,24 @@ class PostboxService: ) holders = self._holders(postbox.tenant_id, function_id, holder_cache) holder_count = len({holder.identity_id for holder in holders}) - base = { - "action": action, - "postbox_id": postbox.id, - "organization_unit_id": unit_id, - "function_id": function_id, - "holder_count": holder_count, - "vacant": holder_count == 0, - } - if postbox.status != "active": - return PostboxAccessDecisionRef( - allowed=False, - reason_code="postbox_inactive", - explanation="The Postbox is not active.", - **base, - ) - if action not in actor.authorized_actions: - return PostboxAccessDecisionRef( - allowed=False, - reason_code="generic_permission_missing", - explanation=( - "The account lacks the generic Postbox permission for this action." - ), - **base, - ) - if action == "administer": - return PostboxAccessDecisionRef( - allowed=True, - reason_code="generic_administrator", - explanation="The account has Postbox administration permission.", - **base, - ) - if binding is None or not function_id or not unit_id: - return PostboxAccessDecisionRef( - allowed=False, - reason_code="function_binding_missing", - explanation=( - "This Postbox has no active organization-function binding." - ), - **base, - ) - - matching: list[OrganizationFunctionAssignmentRef] = [] - for assignment in assignments: - if assignment.tenant_id != postbox.tenant_id: - continue - if not self._assignment_matches_binding(assignment, binding): - continue - if assignment.source == "acting_for": - if actor.selected_assignment_id != assignment.id: + binding_assignments: list[OrganizationFunctionAssignmentRef] = [] + if binding is not None: + for assignment in assignments: + if assignment.tenant_id != postbox.tenant_id: continue - if ( - assignment.acting_for_account_id - and actor.acting_for_account_id - != assignment.acting_for_account_id - ): + if not self._assignment_matches_binding(assignment, binding): continue - matching.append(assignment) - if not matching: - return PostboxAccessDecisionRef( - allowed=False, - reason_code="effective_assignment_missing", - explanation=( - "No current function assignment grants this account access " - "to the Postbox." - ), - **base, - ) - selected = next( - ( - assignment - for assignment in matching - if assignment.id == actor.selected_assignment_id - ), - matching[0], - ) - return PostboxAccessDecisionRef( - allowed=True, - reason_code=f"effective_{selected.source}_assignment", - explanation=( - "Access follows the current effective organization-function " - f"assignment ({selected.source})." - ), - assignment_ids=tuple(item.id for item in matching), - assignment_sources=tuple(item.source for item in matching), - selected_assignment_id=selected.id, - **base, + binding_assignments.append(assignment) + return evaluate_postbox_access( + postbox_id=postbox.id, + postbox_active=postbox.status == "active", + action=action, + actor=actor, + organization_unit_id=unit_id, + function_id=function_id, + holder_count=holder_count, + binding_available=binding is not None, + binding_assignments=binding_assignments, ) def _assignment_matches_binding( @@ -3046,13 +3193,16 @@ class PostboxService: ), None, ) + availability = _message_availability(message) + content_available = availability == "available" return PostboxMessageRef( id=message.id, tenant_id=message.tenant_id, postbox_id=message.postbox_id, subject=message.subject, - body_text=message.body_text, + body_text=message.body_text if content_available else None, status=message.status, + availability=availability, classification=message.classification, sender_label=message.sender_label, delivered_at=message.delivered_at, @@ -3076,6 +3226,7 @@ class PostboxService: address=item.address, ) for item in message.participants + if content_available ), attachments=tuple( PostboxAttachmentRef( @@ -3088,6 +3239,7 @@ class PostboxService: metadata=_mapping(item.metadata_), ) for item in message.attachments + if content_available ), metadata=_mapping(message.metadata_), ) diff --git a/tests/test_access_decisions.py b/tests/test_access_decisions.py new file mode 100644 index 0000000..029d162 --- /dev/null +++ b/tests/test_access_decisions.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.idm import OrganizationFunctionAssignmentRef +from govoplan_core.core.postbox import PostboxActorRef +from govoplan_postbox.backend.access_decisions import ( + ACCESS_DECISION_TABLE, + evaluate_postbox_access, +) + + +def assignment( + source: str = "direct", + *, + assignment_id: str | None = None, + tenant_id: str = "tenant-1", + acting_for_account_id: str | None = None, +) -> OrganizationFunctionAssignmentRef: + return OrganizationFunctionAssignmentRef( + id=assignment_id or f"{source}-assignment", + tenant_id=tenant_id, + identity_id="identity-1", + account_id="account-1", + function_id="function-1", + organization_unit_id="unit-1", + source=source, # type: ignore[arg-type] + acting_for_account_id=acting_for_account_id, + ) + + +def decide( + *, + status: str = "active", + action: str = "read", + authorized_actions: frozenset[str] = frozenset({"read"}), + binding_available: bool = True, + assignments=(), + selected_assignment_id: str | None = None, + acting_for_account_id: str | None = None, +): + return evaluate_postbox_access( + postbox_id="postbox-1", + postbox_active=status == "active", + action=action, # type: ignore[arg-type] + actor=PostboxActorRef( + account_id="account-1", + identity_id="identity-1", + selected_assignment_id=selected_assignment_id, + acting_for_account_id=acting_for_account_id, + authorized_actions=authorized_actions, # type: ignore[arg-type] + ), + organization_unit_id="unit-1" if binding_available else None, + function_id="function-1" if binding_available else None, + holder_count=len(assignments), + binding_available=binding_available, + binding_assignments=assignments, + ) + + +class PostboxAccessDecisionTableTests(unittest.TestCase): + def test_rule_order_is_fail_closed(self) -> None: + self.assertEqual( + [rule.name for rule in ACCESS_DECISION_TABLE], + [ + "inactive_postbox", + "generic_permission", + "administrator", + "active_function_binding", + ], + ) + inactive_admin = decide( + status="archived", + action="administer", + authorized_actions=frozenset({"administer"}), + ) + self.assertFalse(inactive_admin.allowed) + self.assertEqual(inactive_admin.reason_code, "postbox_inactive") + + def test_permission_and_binding_denials_have_stable_provenance(self) -> None: + missing_permission = decide( + assignments=(assignment(),), + authorized_actions=frozenset(), + ) + missing_binding = decide(binding_available=False) + + self.assertEqual( + missing_permission.reason_code, + "generic_permission_missing", + ) + self.assertEqual( + missing_binding.reason_code, + "function_binding_missing", + ) + + def test_administration_is_generic_but_still_requires_active_postbox(self) -> None: + decision = decide( + action="administer", + authorized_actions=frozenset({"administer"}), + binding_available=False, + ) + self.assertTrue(decision.allowed) + self.assertEqual(decision.reason_code, "generic_administrator") + + def test_direct_delegated_directory_governance_and_system_sources_are_allowed(self) -> None: + for source in ( + "direct", + "delegated", + "directory", + "governance", + "system", + ): + with self.subTest(source=source): + decision = decide(assignments=(assignment(source),)) + self.assertTrue(decision.allowed) + self.assertEqual( + decision.reason_code, + f"effective_{source}_assignment", + ) + + def test_selected_direct_context_is_preferred_without_hiding_other_matches(self) -> None: + direct = assignment("direct", assignment_id="direct-1") + delegated = assignment("delegated", assignment_id="delegated-1") + decision = decide( + assignments=(direct, delegated), + selected_assignment_id=delegated.id, + ) + + self.assertTrue(decision.allowed) + self.assertEqual(decision.selected_assignment_id, delegated.id) + self.assertEqual( + decision.assignment_ids, + ("direct-1", "delegated-1"), + ) + + def test_acting_access_requires_exact_assignment_and_represented_account(self) -> None: + acting = assignment( + "acting_for", + assignment_id="acting-1", + acting_for_account_id="represented-1", + ) + missing_context = decide(assignments=(acting,)) + wrong_assignment = decide( + assignments=(acting,), + selected_assignment_id="acting-other", + ) + wrong_account = decide( + assignments=(acting,), + selected_assignment_id=acting.id, + acting_for_account_id="represented-other", + ) + allowed = decide( + assignments=(acting,), + selected_assignment_id=acting.id, + acting_for_account_id="represented-1", + ) + + self.assertEqual( + missing_context.reason_code, + "acting_context_required", + ) + self.assertEqual( + wrong_assignment.reason_code, + "acting_assignment_not_selected", + ) + self.assertEqual( + wrong_account.reason_code, + "acting_account_mismatch", + ) + self.assertTrue(allowed.allowed) + self.assertEqual( + allowed.reason_code, + "effective_acting_for_assignment", + ) + + def test_vacancy_is_provenance_not_an_implicit_access_override(self) -> None: + denied = decide(assignments=()) + self.assertFalse(denied.allowed) + self.assertTrue(denied.vacant) + self.assertEqual( + denied.reason_code, + "effective_assignment_missing", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_service.py b/tests/test_service.py index 7b66500..e70b0c7 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -49,7 +49,7 @@ from govoplan_postbox.backend.db.models import ( PostboxTemplate, PostboxTemplateRevision, ) -from govoplan_postbox.backend.service import PostboxService +from govoplan_postbox.backend.service import PostboxError, PostboxService POSTBOX_TABLES = ( @@ -915,6 +915,12 @@ class PostboxServiceTests(unittest.TestCase): actor=self.actor, state="acknowledged", ) + receipt_summary = service.delivery_receipt_summaries( + session, + tenant_id="tenant-1", + producer_module="campaigns", + delivery_ids=(first.delivery_id,), + )[first.delivery_id] session.commit() self.assertFalse(first.duplicate) @@ -922,6 +928,20 @@ class PostboxServiceTests(unittest.TestCase): self.assertEqual(first.message_id, second.message_id) self.assertIsNotNone(marked.read_at) self.assertIsNotNone(marked.acknowledged_at) + self.assertTrue(receipt_summary.currently_readable) + self.assertEqual(1, receipt_summary.current_holder_count) + self.assertEqual(1, receipt_summary.read_receipt_count) + self.assertEqual(1, receipt_summary.acknowledged_receipt_count) + self.assertEqual(0, receipt_summary.withdrawn_message_count) + self.assertEqual( + {}, + service.delivery_receipt_summaries( + session, + tenant_id="tenant-1", + producer_module="another-module", + delivery_ids=(first.delivery_id,), + ), + ) self.assertEqual( 1, session.query(PostboxMessage).count(), @@ -971,6 +991,95 @@ class PostboxServiceTests(unittest.TestCase): [event.type for event in events], ) + def test_withdrawn_and_expired_messages_keep_metadata_but_hide_content( + self, + ) -> None: + self.idm.assignments.append(self.assignment) + with Session(self.engine) as session: + postbox = self._create_exact(session) + withdrawn = self.service.deliver( + session, + PostboxDeliveryRequest( + tenant_id="tenant-1", + target=PostboxTargetRef(postbox_id=postbox.id), + producer_module="campaigns", + producer_resource_type="campaign_recipient", + producer_resource_id="recipient-withdrawn", + idempotency_key="withdrawn-message", + subject="Withdrawn decision", + body_text="Sensitive withdrawn content", + ), + ) + expired = self.service.deliver( + session, + PostboxDeliveryRequest( + tenant_id="tenant-1", + target=PostboxTargetRef(postbox_id=postbox.id), + producer_module="campaigns", + producer_resource_type="campaign_recipient", + producer_resource_id="recipient-expired", + idempotency_key="expired-message", + subject="Expired decision", + body_text="Sensitive expired content", + expires_at=utc_now() - timedelta(minutes=1), + ), + ) + withdrawn_model = session.get(PostboxMessage, withdrawn.message_id) + assert withdrawn_model is not None + withdrawn_model.withdrawn_at = utc_now() + session.flush() + + withdrawn_ref = self.service.get_message( + session, + tenant_id="tenant-1", + message_id=withdrawn.message_id, + actor=self.actor, + ) + expired_ref = self.service.get_message( + session, + tenant_id="tenant-1", + message_id=expired.message_id, + actor=self.actor, + ) + + assert withdrawn_ref is not None + assert expired_ref is not None + self.assertEqual("withdrawn", withdrawn_ref.availability) + self.assertIsNone(withdrawn_ref.body_text) + self.assertEqual((), withdrawn_ref.attachments) + self.assertEqual("expired", expired_ref.availability) + self.assertIsNone(expired_ref.body_text) + with self.assertRaisesRegex(PostboxError, "withdrawn"): + self.service.mark_message( + session, + tenant_id="tenant-1", + message_id=withdrawn.message_id, + actor=self.actor, + state="read", + ) + with self.assertRaisesRegex(PostboxError, "expired"): + self.service.mark_message( + session, + tenant_id="tenant-1", + message_id=expired.message_id, + actor=self.actor, + state="acknowledged", + ) + + summaries = self.service.delivery_receipt_summaries( + session, + tenant_id="tenant-1", + producer_module="campaigns", + delivery_ids=(withdrawn.delivery_id, expired.delivery_id), + ) + self.assertFalse(summaries[withdrawn.delivery_id].currently_readable) + self.assertEqual( + 1, + summaries[withdrawn.delivery_id].withdrawn_message_count, + ) + self.assertFalse(summaries[expired.delivery_id].currently_readable) + self.assertEqual(1, summaries[expired.delivery_id].expired_message_count) + def test_hierarchy_linked_copy_snapshots_path_and_independent_state( self, ) -> None: @@ -1023,6 +1132,16 @@ class PostboxServiceTests(unittest.TestCase): session.commit() receipts = session.query(PostboxMessageReceipt).all() self.assertEqual([route.target_message_id], [item.message_id for item in receipts]) + summary = service.delivery_receipt_summaries( + session, + tenant_id="tenant-1", + producer_module="campaigns", + delivery_ids=(result.delivery_id,), + )[result.delivery_id] + self.assertEqual(2, summary.message_count) + self.assertEqual(1, summary.routed_message_count) + self.assertEqual(1, summary.read_receipt_count) + self.assertEqual({"accepted": 1}, summary.route_status_counts) def test_hierarchy_dry_run_explains_gates_depth_and_parallel_structure( self, diff --git a/webui/package.json b/webui/package.json index a5406cb..eea329d 100644 --- a/webui/package.json +++ b/webui/package.json @@ -1,6 +1,6 @@ { "name": "@govoplan/postbox-webui", - "version": "0.1.1", + "version": "0.1.2", "private": true, "type": "module", "main": "src/index.ts", diff --git a/webui/src/api/postbox.ts b/webui/src/api/postbox.ts index 9e47e3c..426ade4 100644 --- a/webui/src/api/postbox.ts +++ b/webui/src/api/postbox.ts @@ -64,6 +64,7 @@ export type PostboxMessage = { subject: string; body_text?: string | null; status: string; + availability: "available" | "withdrawn" | "expired"; classification: string; sender_label?: string | null; delivered_at: string; diff --git a/webui/src/features/postbox/PostboxPage.tsx b/webui/src/features/postbox/PostboxPage.tsx index a9d82c3..a0fbbf6 100644 --- a/webui/src/features/postbox/PostboxPage.tsx +++ b/webui/src/features/postbox/PostboxPage.tsx @@ -28,6 +28,7 @@ import { StatusBadge, ToggleSwitch, hasScope, + isApiError, type ApiSettings, type AuthInfo } from "@govoplan/core-webui"; @@ -80,6 +81,7 @@ export default function PostboxPage({ const [messages, setMessages] = useState([]); const [selectedMessageId, setSelectedMessageId] = useState(""); const [selectedMessage, setSelectedMessage] = useState(null); + const [unavailableSelection, setUnavailableSelection] = useState(""); const [total, setTotal] = useState(0); const [messageState, setMessageState] = useState("all"); const [searchDraft, setSearchDraft] = useState(""); @@ -145,6 +147,7 @@ export default function PostboxPage({ setMessages([]); setSelectedMessageId(""); setSelectedMessage(null); + setUnavailableSelection(""); setTotal(0); return; } @@ -203,15 +206,28 @@ export default function PostboxPage({ const message = await getPostboxMessage(settings, messageId); if (cancelled) return; if (!postboxes.some((postbox) => postbox.id === message.postbox_id)) { - setError("The linked message is not available in your current Postbox assignments."); + setSelectedMessageId(messageId); + setSelectedMessage(null); + setUnavailableSelection( + "This message is outside your current Postbox assignments." + ); return; } setSelectedScope("all"); setSelectedPostboxId(message.postbox_id); setSelectedMessageId(message.id); setSelectedMessage(message); + setUnavailableSelection(""); } catch (loadError) { - if (!cancelled) setError(errorMessage(loadError)); + if (!cancelled && isApiError(loadError, 403, 404)) { + setSelectedMessageId(messageId); + setSelectedMessage(null); + setUnavailableSelection( + "This message is no longer available or is outside your current Postbox assignments." + ); + } else if (!cancelled) { + setError(errorMessage(loadError)); + } } })(); return () => { @@ -222,10 +238,11 @@ export default function PostboxPage({ useEffect(() => { if (!selectedMessageId) return; let cancelled = false; + setUnavailableSelection(""); void (async () => { try { let message = await getPostboxMessage(settings, selectedMessageId); - if (!message.read_at) { + if (message.availability === "available" && !message.read_at) { message = await markPostboxMessage(settings, selectedMessageId, "read"); } if (cancelled) return; @@ -234,7 +251,14 @@ export default function PostboxPage({ items.map((item) => (item.id === message.id ? message : item)) ); } catch (loadError) { - if (!cancelled) setError(errorMessage(loadError)); + if (!cancelled && isApiError(loadError, 403, 404)) { + setSelectedMessage(null); + setUnavailableSelection( + "This message is no longer available or is outside your current Postbox assignments." + ); + } else if (!cancelled) { + setError(errorMessage(loadError)); + } } })(); return () => { @@ -243,7 +267,13 @@ export default function PostboxPage({ }, [selectedMessageId, settings]); async function acknowledgeSelected() { - if (!selectedMessage || !canAcknowledge) return; + if ( + !selectedMessage || + selectedMessage.availability !== "available" || + !canAcknowledge + ) { + return; + } setBusy(true); setError(""); try { @@ -268,6 +298,7 @@ export default function PostboxPage({ setPage(1); setSelectedMessageId(""); setSelectedMessage(null); + setUnavailableSelection(""); } function selectScope(scopeId: string) { @@ -276,6 +307,7 @@ export default function PostboxPage({ setPage(1); setSelectedMessageId(""); setSelectedMessage(null); + setUnavailableSelection(""); } function openNewGrouping() { @@ -544,6 +576,12 @@ export default function PostboxPage({ {message.acknowledged_at ? ( Acknowledged ) : null} + {message.availability !== "available" ? ( + + ) : null} ))} @@ -579,8 +617,20 @@ export default function PostboxPage({ @@ -590,6 +640,12 @@ export default function PostboxPage({ message={selectedMessage} postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)} /> + ) : unavailableSelection ? ( +
+ + Message unavailable +

{unavailableSelection}

+
) : (
@@ -694,6 +750,17 @@ function MessageDetail({ }) { return (
+ {message.availability !== "available" ? ( + + {message.availability === "withdrawn" + ? "This message was withdrawn. Its audit metadata remains visible, but its content and actions are unavailable." + : "This message has expired. Its audit metadata remains visible, but its content and actions are unavailable."} + + ) : null}