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]
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"
@@ -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_NAME = "Postbox"
MODULE_VERSION = "0.1.1"
MODULE_VERSION = "0.1.2"
READ_SCOPE = "postbox:postbox:read"
SEND_SCOPE = "postbox:message:write"
+1
View File
@@ -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
+234 -82
View File
@@ -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] = []
binding_assignments: list[OrganizationFunctionAssignmentRef] = []
if binding is not None:
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
if (
assignment.acting_for_account_id
and actor.acting_for_account_id
!= assignment.acting_for_account_id
):
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_),
)
+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,
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,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/postbox-webui",
"version": "0.1.1",
"version": "0.1.2",
"private": true,
"type": "module",
"main": "src/index.ts",
+1
View File
@@ -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;
+74 -7
View File
@@ -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<PostboxMessage[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState("");
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
const [unavailableSelection, setUnavailableSelection] = useState("");
const [total, setTotal] = useState(0);
const [messageState, setMessageState] = useState<MessageStateFilter>("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 ? (
<span><CheckCheck size={13} /> Acknowledged</span>
) : null}
{message.availability !== "available" ? (
<StatusBadge
status="warning"
label={message.availability === "withdrawn" ? "Withdrawn" : "Expired"}
/>
) : null}
</span>
</SelectionListItem>
))}
@@ -579,8 +617,20 @@ export default function PostboxPage({
</div>
<Button
onClick={() => void acknowledgeSelected()}
disabled={!selectedMessage || Boolean(selectedMessage.acknowledged_at) || busy}
disabledReason={!canAcknowledge ? "You cannot acknowledge Postbox messages." : undefined}
disabled={
!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
</Button>
@@ -590,6 +640,12 @@ export default function PostboxPage({
message={selectedMessage}
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">
<Inbox size={24} />
@@ -694,6 +750,17 @@ function MessageDetail({
}) {
return (
<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>
<div className="postbox-detail-status">
<StatusBadge status={message.status} />