feat: complete postbox access evidence

This commit is contained in:
2026-07-30 14:26:59 +02:00
parent 28b60782de
commit bdc161e889
10 changed files with 858 additions and 96 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "govoplan-postbox" name = "govoplan-postbox"
version = "0.1.1" version = "0.1.2"
description = "Function-bound institutional postboxes for GovOPlaN." description = "Function-bound institutional postboxes for GovOPlaN."
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
@@ -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",
]
+1 -1
View File
@@ -48,7 +48,7 @@ from govoplan_postbox.backend.db import models as postbox_models
MODULE_ID = "postbox" MODULE_ID = "postbox"
MODULE_NAME = "Postbox" MODULE_NAME = "Postbox"
MODULE_VERSION = "0.1.1" MODULE_VERSION = "0.1.2"
READ_SCOPE = "postbox:postbox:read" READ_SCOPE = "postbox:postbox:read"
SEND_SCOPE = "postbox:message:write" SEND_SCOPE = "postbox:message:write"
+1
View File
@@ -69,6 +69,7 @@ class PostboxMessageItem(BaseModel):
subject: str subject: str
body_text: str | None = None body_text: str | None = None
status: str status: str
availability: Literal["available", "withdrawn", "expired"]
classification: str classification: str
sender_label: str | None = None sender_label: str | None = None
delivered_at: datetime delivered_at: datetime
+237 -85
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import logging import logging
import re import re
from collections import Counter
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Literal from typing import Literal
@@ -48,11 +49,13 @@ from govoplan_core.core.postbox import (
PostboxActorRef, PostboxActorRef,
PostboxAttachmentRef, PostboxAttachmentRef,
PostboxDeliveryCatalogRef, PostboxDeliveryCatalogRef,
PostboxDeliveryReceiptSummaryRef,
PostboxDeliveryRequest, PostboxDeliveryRequest,
PostboxDeliveryRejected, PostboxDeliveryRejected,
PostboxDeliveryResult, PostboxDeliveryResult,
PostboxDeliveryTemplateRef, PostboxDeliveryTemplateRef,
PostboxDirectoryEntryRef, PostboxDirectoryEntryRef,
PostboxMessageAvailability,
PostboxMessageRef, PostboxMessageRef,
PostboxMessageListState, PostboxMessageListState,
PostboxOrganizationFunctionTargetRef, PostboxOrganizationFunctionTargetRef,
@@ -84,6 +87,7 @@ from govoplan_postbox.backend.hierarchy_routing import (
normalized_routing_policy, normalized_routing_policy,
plan_hierarchy_routes, plan_hierarchy_routes,
) )
from govoplan_postbox.backend.access_decisions import evaluate_postbox_access
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -123,6 +127,28 @@ def _as_utc(value: datetime) -> datetime:
return value.astimezone(timezone.utc) 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( def _publish_postbox_event(
session: Session, session: Session,
event_type: str, event_type: str,
@@ -651,6 +677,12 @@ class PostboxService:
) )
if not decision.allowed: if not decision.allowed:
raise PostboxError("access_denied", decision.explanation) 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 = ( receipt = (
db.query(PostboxMessageReceipt) db.query(PostboxMessageReceipt)
.filter( .filter(
@@ -1918,6 +1950,189 @@ class PostboxService:
) )
return self._message_ref(message, account_id="") 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 # Administration
def list_admin_postboxes( def list_admin_postboxes(
self, self,
@@ -2825,92 +3040,24 @@ class PostboxService:
) )
holders = self._holders(postbox.tenant_id, function_id, holder_cache) holders = self._holders(postbox.tenant_id, function_id, holder_cache)
holder_count = len({holder.identity_id for holder in holders}) holder_count = len({holder.identity_id for holder in holders})
base = { binding_assignments: list[OrganizationFunctionAssignmentRef] = []
"action": action, if binding is not None:
"postbox_id": postbox.id, for assignment in assignments:
"organization_unit_id": unit_id, if assignment.tenant_id != postbox.tenant_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:
continue continue
if ( if not self._assignment_matches_binding(assignment, binding):
assignment.acting_for_account_id
and actor.acting_for_account_id
!= assignment.acting_for_account_id
):
continue continue
matching.append(assignment) binding_assignments.append(assignment)
if not matching: return evaluate_postbox_access(
return PostboxAccessDecisionRef( postbox_id=postbox.id,
allowed=False, postbox_active=postbox.status == "active",
reason_code="effective_assignment_missing", action=action,
explanation=( actor=actor,
"No current function assignment grants this account access " organization_unit_id=unit_id,
"to the Postbox." function_id=function_id,
), holder_count=holder_count,
**base, binding_available=binding is not None,
) binding_assignments=binding_assignments,
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,
) )
def _assignment_matches_binding( def _assignment_matches_binding(
@@ -3046,13 +3193,16 @@ class PostboxService:
), ),
None, None,
) )
availability = _message_availability(message)
content_available = availability == "available"
return PostboxMessageRef( return PostboxMessageRef(
id=message.id, id=message.id,
tenant_id=message.tenant_id, tenant_id=message.tenant_id,
postbox_id=message.postbox_id, postbox_id=message.postbox_id,
subject=message.subject, subject=message.subject,
body_text=message.body_text, body_text=message.body_text if content_available else None,
status=message.status, status=message.status,
availability=availability,
classification=message.classification, classification=message.classification,
sender_label=message.sender_label, sender_label=message.sender_label,
delivered_at=message.delivered_at, delivered_at=message.delivered_at,
@@ -3076,6 +3226,7 @@ class PostboxService:
address=item.address, address=item.address,
) )
for item in message.participants for item in message.participants
if content_available
), ),
attachments=tuple( attachments=tuple(
PostboxAttachmentRef( PostboxAttachmentRef(
@@ -3088,6 +3239,7 @@ class PostboxService:
metadata=_mapping(item.metadata_), metadata=_mapping(item.metadata_),
) )
for item in message.attachments for item in message.attachments
if content_available
), ),
metadata=_mapping(message.metadata_), metadata=_mapping(message.metadata_),
) )
+187
View File
@@ -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()
+120 -1
View File
@@ -49,7 +49,7 @@ from govoplan_postbox.backend.db.models import (
PostboxTemplate, PostboxTemplate,
PostboxTemplateRevision, PostboxTemplateRevision,
) )
from govoplan_postbox.backend.service import PostboxService from govoplan_postbox.backend.service import PostboxError, PostboxService
POSTBOX_TABLES = ( POSTBOX_TABLES = (
@@ -915,6 +915,12 @@ class PostboxServiceTests(unittest.TestCase):
actor=self.actor, actor=self.actor,
state="acknowledged", 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() session.commit()
self.assertFalse(first.duplicate) self.assertFalse(first.duplicate)
@@ -922,6 +928,20 @@ class PostboxServiceTests(unittest.TestCase):
self.assertEqual(first.message_id, second.message_id) self.assertEqual(first.message_id, second.message_id)
self.assertIsNotNone(marked.read_at) self.assertIsNotNone(marked.read_at)
self.assertIsNotNone(marked.acknowledged_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( self.assertEqual(
1, 1,
session.query(PostboxMessage).count(), session.query(PostboxMessage).count(),
@@ -971,6 +991,95 @@ class PostboxServiceTests(unittest.TestCase):
[event.type for event in events], [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( def test_hierarchy_linked_copy_snapshots_path_and_independent_state(
self, self,
) -> None: ) -> None:
@@ -1023,6 +1132,16 @@ class PostboxServiceTests(unittest.TestCase):
session.commit() session.commit()
receipts = session.query(PostboxMessageReceipt).all() receipts = session.query(PostboxMessageReceipt).all()
self.assertEqual([route.target_message_id], [item.message_id for item in receipts]) 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( def test_hierarchy_dry_run_explains_gates_depth_and_parallel_structure(
self, self,
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@govoplan/postbox-webui", "name": "@govoplan/postbox-webui",
"version": "0.1.1", "version": "0.1.2",
"private": true, "private": true,
"type": "module", "type": "module",
"main": "src/index.ts", "main": "src/index.ts",
+1
View File
@@ -64,6 +64,7 @@ export type PostboxMessage = {
subject: string; subject: string;
body_text?: string | null; body_text?: string | null;
status: string; status: string;
availability: "available" | "withdrawn" | "expired";
classification: string; classification: string;
sender_label?: string | null; sender_label?: string | null;
delivered_at: string; delivered_at: string;
+74 -7
View File
@@ -28,6 +28,7 @@ import {
StatusBadge, StatusBadge,
ToggleSwitch, ToggleSwitch,
hasScope, hasScope,
isApiError,
type ApiSettings, type ApiSettings,
type AuthInfo type AuthInfo
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
@@ -80,6 +81,7 @@ export default function PostboxPage({
const [messages, setMessages] = useState<PostboxMessage[]>([]); const [messages, setMessages] = useState<PostboxMessage[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState(""); const [selectedMessageId, setSelectedMessageId] = useState("");
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null); const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
const [unavailableSelection, setUnavailableSelection] = useState("");
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [messageState, setMessageState] = useState<MessageStateFilter>("all"); const [messageState, setMessageState] = useState<MessageStateFilter>("all");
const [searchDraft, setSearchDraft] = useState(""); const [searchDraft, setSearchDraft] = useState("");
@@ -145,6 +147,7 @@ export default function PostboxPage({
setMessages([]); setMessages([]);
setSelectedMessageId(""); setSelectedMessageId("");
setSelectedMessage(null); setSelectedMessage(null);
setUnavailableSelection("");
setTotal(0); setTotal(0);
return; return;
} }
@@ -203,15 +206,28 @@ export default function PostboxPage({
const message = await getPostboxMessage(settings, messageId); const message = await getPostboxMessage(settings, messageId);
if (cancelled) return; if (cancelled) return;
if (!postboxes.some((postbox) => postbox.id === message.postbox_id)) { 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; return;
} }
setSelectedScope("all"); setSelectedScope("all");
setSelectedPostboxId(message.postbox_id); setSelectedPostboxId(message.postbox_id);
setSelectedMessageId(message.id); setSelectedMessageId(message.id);
setSelectedMessage(message); setSelectedMessage(message);
setUnavailableSelection("");
} catch (loadError) { } 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 () => { return () => {
@@ -222,10 +238,11 @@ export default function PostboxPage({
useEffect(() => { useEffect(() => {
if (!selectedMessageId) return; if (!selectedMessageId) return;
let cancelled = false; let cancelled = false;
setUnavailableSelection("");
void (async () => { void (async () => {
try { try {
let message = await getPostboxMessage(settings, selectedMessageId); let message = await getPostboxMessage(settings, selectedMessageId);
if (!message.read_at) { if (message.availability === "available" && !message.read_at) {
message = await markPostboxMessage(settings, selectedMessageId, "read"); message = await markPostboxMessage(settings, selectedMessageId, "read");
} }
if (cancelled) return; if (cancelled) return;
@@ -234,7 +251,14 @@ export default function PostboxPage({
items.map((item) => (item.id === message.id ? message : item)) items.map((item) => (item.id === message.id ? message : item))
); );
} catch (loadError) { } 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 () => { return () => {
@@ -243,7 +267,13 @@ export default function PostboxPage({
}, [selectedMessageId, settings]); }, [selectedMessageId, settings]);
async function acknowledgeSelected() { async function acknowledgeSelected() {
if (!selectedMessage || !canAcknowledge) return; if (
!selectedMessage ||
selectedMessage.availability !== "available" ||
!canAcknowledge
) {
return;
}
setBusy(true); setBusy(true);
setError(""); setError("");
try { try {
@@ -268,6 +298,7 @@ export default function PostboxPage({
setPage(1); setPage(1);
setSelectedMessageId(""); setSelectedMessageId("");
setSelectedMessage(null); setSelectedMessage(null);
setUnavailableSelection("");
} }
function selectScope(scopeId: string) { function selectScope(scopeId: string) {
@@ -276,6 +307,7 @@ export default function PostboxPage({
setPage(1); setPage(1);
setSelectedMessageId(""); setSelectedMessageId("");
setSelectedMessage(null); setSelectedMessage(null);
setUnavailableSelection("");
} }
function openNewGrouping() { function openNewGrouping() {
@@ -544,6 +576,12 @@ export default function PostboxPage({
{message.acknowledged_at ? ( {message.acknowledged_at ? (
<span><CheckCheck size={13} /> Acknowledged</span> <span><CheckCheck size={13} /> Acknowledged</span>
) : null} ) : null}
{message.availability !== "available" ? (
<StatusBadge
status="warning"
label={message.availability === "withdrawn" ? "Withdrawn" : "Expired"}
/>
) : null}
</span> </span>
</SelectionListItem> </SelectionListItem>
))} ))}
@@ -579,8 +617,20 @@ export default function PostboxPage({
</div> </div>
<Button <Button
onClick={() => void acknowledgeSelected()} onClick={() => void acknowledgeSelected()}
disabled={!selectedMessage || Boolean(selectedMessage.acknowledged_at) || busy} disabled={
disabledReason={!canAcknowledge ? "You cannot acknowledge Postbox messages." : undefined} !selectedMessage ||
selectedMessage.availability !== "available" ||
Boolean(selectedMessage.acknowledged_at) ||
busy
}
disabledReason={
!canAcknowledge
? "You cannot acknowledge Postbox messages."
: selectedMessage &&
selectedMessage.availability !== "available"
? "Withdrawn or expired messages cannot be acknowledged."
: undefined
}
> >
<CheckCheck size={16} /> Acknowledge <CheckCheck size={16} /> Acknowledge
</Button> </Button>
@@ -590,6 +640,12 @@ export default function PostboxPage({
message={selectedMessage} message={selectedMessage}
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)} postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
/> />
) : unavailableSelection ? (
<div className="postbox-empty postbox-unavailable-message">
<Archive size={24} />
<strong>Message unavailable</strong>
<p>{unavailableSelection}</p>
</div>
) : ( ) : (
<div className="postbox-empty"> <div className="postbox-empty">
<Inbox size={24} /> <Inbox size={24} />
@@ -694,6 +750,17 @@ function MessageDetail({
}) { }) {
return ( return (
<div className="postbox-message-detail"> <div className="postbox-message-detail">
{message.availability !== "available" ? (
<DismissibleAlert
tone="warning"
compact
resetKey={`${message.id}:${message.availability}`}
>
{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."}
</DismissibleAlert>
) : null}
<header> <header>
<div className="postbox-detail-status"> <div className="postbox-detail-status">
<StatusBadge status={message.status} /> <StatusBadge status={message.status} />