Files
govoplan-postbox/src/govoplan_postbox/backend/service.py
T

4448 lines
153 KiB
Python

from __future__ import annotations
import hashlib
import json
import logging
import re
from collections import Counter
from collections.abc import Mapping, Sequence
from dataclasses import asdict
from datetime import datetime, timedelta, timezone
from typing import Any, Literal
from sqlalchemy import and_, func, or_
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, object_session, selectinload
from govoplan_core.core.events import (
EventActorRef,
EventObjectRef,
EventTenantRef,
PlatformEvent,
emit_platform_event,
)
from govoplan_core.core.concurrency import MissingPreconditionError, claim_revision
from govoplan_core.core.identity import (
CAPABILITY_IDENTITY_DIRECTORY,
IdentityDirectory,
)
from govoplan_core.core.idm import (
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
IdmDirectory,
IdmFunctionAssignmentDirectory,
OrganizationFunctionAssignmentRef,
)
from govoplan_core.core.notifications import (
NotificationDispatchProvider,
NotificationDispatchRequest,
notification_dispatch_provider,
)
from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
OrganizationDirectory,
OrganizationFunctionRef,
OrganizationHierarchyDirectory,
OrganizationUnitRef,
)
from govoplan_core.core.postbox import (
PostboxAccessDecisionRef,
PostboxAction,
PostboxActorRef,
PostboxAttachmentRef,
PostboxBindingStatus,
PostboxDeliveryCatalogRef,
PostboxDeliveryReceiptSummaryRef,
PostboxDeliveryRequest,
PostboxDeliveryRejected,
PostboxDeliveryResult,
PostboxDeliveryTemplateRef,
PostboxDirectoryEntryRef,
PostboxExternalRecipientTokenRef,
PostboxMessageAvailability,
PostboxMessageAuthoringRequest,
PostboxMessageRef,
PostboxMessageListState,
PostboxOrganizationFunctionTargetRef,
PostboxOrganizationUnitTargetRef,
PostboxParticipantRef,
PostboxTargetRef,
PostboxWrappedKeyRef,
normalize_postbox_classification,
postbox_classification_allows,
)
from govoplan_core.core.registry import PlatformRegistry
from govoplan_core.security.time import utc_now
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAccessEvent,
PostboxAddress,
PostboxAttachmentReference,
PostboxBinding,
PostboxDelivery,
PostboxGrouping,
PostboxGroupingSource,
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
new_uuid,
)
from govoplan_postbox.backend.hierarchy_routing import (
HierarchyRouteCandidate,
HierarchyRoutePlan,
normalized_routing_policy,
plan_hierarchy_routes,
)
from govoplan_postbox.backend.access_decisions import evaluate_postbox_access
logger = logging.getLogger(__name__)
class PostboxError(PostboxDeliveryRejected, ValueError):
def __init__(
self,
code: str,
message: str,
*,
temporary: bool = False,
) -> None:
super().__init__(code, message, temporary=temporary)
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Postbox requires a SQLAlchemy Session")
return value
def _slug(value: str, *, fallback: str = "postbox") -> str:
normalized = re.sub(r"[^a-z0-9]+", "-", value.strip().casefold()).strip("-")
if normalized:
return normalized[:100]
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:12]
return f"{fallback}-{digest}"
def _mapping(value: Mapping[str, object] | None) -> dict[str, object]:
return dict(value or {})
def _validate_encryption_configuration(
profile: str,
vault_id: str | None,
) -> None:
normalized = str(profile or "").strip()
if normalized not in {"plaintext_v1", "server_envelope_v1"}:
raise PostboxError(
"unsupported_encryption_profile",
"Unsupported Postbox encryption profile.",
)
if normalized == "server_envelope_v1" and not str(vault_id or "").strip():
raise PostboxError(
"encryption_vault_missing",
"Server-envelope Postboxes require an encryption vault.",
)
if normalized == "plaintext_v1" and vault_id:
raise PostboxError(
"encryption_profile_mismatch",
"A plaintext Postbox cannot select an encryption vault.",
)
def _optional_datetime(value: object) -> datetime | None:
if isinstance(value, datetime):
return value
if isinstance(value, str) and value:
try:
return datetime.fromisoformat(value)
except ValueError:
return None
return None
def _external_token_record(
token: PostboxExternalRecipientTokenRef,
) -> dict[str, object]:
return {
"token_id": token.token_id,
"state": token.state,
"expires_at": token.expires_at.isoformat() if token.expires_at else None,
"one_time": token.one_time,
"key_fetched_at": (
token.key_fetched_at.isoformat() if token.key_fetched_at else None
),
"revoked_at": token.revoked_at.isoformat() if token.revoked_at else None,
"assurance_profile": token.assurance_profile,
"metadata": dict(token.metadata),
}
def _authoring_digest(
request: PostboxMessageAuthoringRequest,
*,
in_reply_to: PostboxMessage | None,
) -> str:
payload = {
"subject": request.subject.strip() or "(No subject)",
"body_text": request.body_text,
"classification": request.classification.strip().casefold(),
"in_reply_to_message_id": in_reply_to.id if in_reply_to else None,
"participants": [
{
"kind": item.kind,
"reference_type": item.reference_type,
"reference_id": item.reference_id,
"label": item.label,
"address": item.address,
}
for item in request.participants
],
"attachments": [
{
"reference_type": item.reference_type,
"reference_id": item.reference_id,
"name": item.name,
"media_type": item.media_type,
"size_bytes": item.size_bytes,
"digest": item.digest,
"metadata": dict(item.metadata),
}
for item in request.attachments
],
"metadata": dict(request.metadata),
}
encoded = json.dumps(
payload,
sort_keys=True,
separators=(",", ":"),
default=str,
).encode("utf-8")
return hashlib.sha256(encoded).hexdigest()
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=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(
session: Session,
event_type: str,
*,
tenant_id: str,
resource_type: str,
resource_id: str,
postbox_id: str | None = None,
actor_type: str | None = None,
actor_id: str | None = None,
payload: Mapping[str, object] | None = None,
) -> None:
emit_platform_event(
session,
PlatformEvent(
type=event_type,
module_id="postbox",
payload=dict(payload or {}),
actor=(
EventActorRef(type=actor_type, id=actor_id)
if actor_type
else None
),
tenant=EventTenantRef(id=tenant_id),
subject=(
EventObjectRef(type="postbox", id=postbox_id)
if postbox_id
else None
),
resource=EventObjectRef(type=resource_type, id=resource_id),
classification="internal",
)
)
class PostboxService:
def __init__(
self,
*,
identities: IdentityDirectory,
idm: IdmDirectory,
incumbencies: IdmFunctionAssignmentDirectory,
organizations: OrganizationDirectory,
hierarchy: OrganizationHierarchyDirectory | None = None,
notifications: NotificationDispatchProvider | None = None,
) -> None:
self._identities = identities
self._idm = idm
self._incumbencies = incumbencies
self._organizations = organizations
self._hierarchy = hierarchy
self._notifications = notifications
@staticmethod
def _message_body_storage(
session: Session,
*,
postbox: Postbox,
message_id: str,
body_text: str | None,
actor_id: str,
external_ciphertext_ref: str | None = None,
external_wrapped_keys: Sequence[object] = (),
) -> dict[str, object]:
profile = str(postbox.encryption_profile or "plaintext_v1").strip()
if external_ciphertext_ref:
return {
"body_text": None,
"body_ciphertext": None,
"ciphertext_ref": external_ciphertext_ref,
"encryption_envelope_id": None,
"encryption_resource_id": None,
"wrapped_keys": [asdict(item) for item in external_wrapped_keys],
}
if body_text is None or profile == "plaintext_v1":
return {
"body_text": body_text,
"body_ciphertext": None,
"ciphertext_ref": None,
"encryption_envelope_id": None,
"encryption_resource_id": None,
"wrapped_keys": [],
}
if profile != "server_envelope_v1":
raise PostboxError(
"unsupported_encryption_profile",
"This Postbox protection profile requires an external content producer.",
)
settings = postbox.settings if isinstance(postbox.settings, Mapping) else {}
vault_id = str(settings.get("encryption_vault_id") or "").strip()
if not vault_id:
raise PostboxError(
"encryption_vault_missing",
"The Postbox server-envelope profile has no configured vault.",
)
from govoplan_postbox.backend.content_protection import (
PostboxContentProtectionError,
protect_message_body,
)
try:
protected = protect_message_body(
session,
tenant_id=postbox.tenant_id,
message_id=message_id,
vault_id=vault_id,
plaintext=body_text,
actor_id=actor_id,
)
except PostboxContentProtectionError as exc:
raise PostboxError("content_protection_failed", str(exc)) from exc
envelope = protected.envelope
return {
"body_text": None,
"body_ciphertext": protected.ciphertext,
"ciphertext_ref": envelope.ciphertext_ref,
"encryption_envelope_id": envelope.envelope_id,
"encryption_resource_id": message_id,
"wrapped_keys": [
{
"recipient_type": "vault",
"recipient_id": vault_id,
"key_epoch": postbox.key_epoch,
"wrapped_key_ref": wrapped_key_ref,
"algorithm": envelope.algorithm_suite,
"metadata": {"envelope_id": envelope.envelope_id},
}
for wrapped_key_ref in envelope.wrapped_key_refs
],
}
@classmethod
def from_registry(cls, registry: PlatformRegistry) -> "PostboxService":
identities = registry.require_capability(CAPABILITY_IDENTITY_DIRECTORY)
idm = registry.require_capability(CAPABILITY_IDM_DIRECTORY)
incumbencies = registry.require_capability(
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS
)
organizations = registry.require_capability(
CAPABILITY_ORGANIZATION_DIRECTORY
)
hierarchy = registry.require_capability(
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY
)
if not isinstance(identities, IdentityDirectory):
raise RuntimeError(
f"Invalid capability: {CAPABILITY_IDENTITY_DIRECTORY}"
)
if not isinstance(idm, IdmDirectory):
raise RuntimeError(f"Invalid capability: {CAPABILITY_IDM_DIRECTORY}")
if not isinstance(incumbencies, IdmFunctionAssignmentDirectory):
raise RuntimeError(
f"Invalid capability: {CAPABILITY_IDM_FUNCTION_ASSIGNMENTS}"
)
if not isinstance(organizations, OrganizationDirectory):
raise RuntimeError(
f"Invalid capability: {CAPABILITY_ORGANIZATION_DIRECTORY}"
)
if not isinstance(hierarchy, OrganizationHierarchyDirectory):
raise RuntimeError(
"Invalid capability: "
f"{CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY}"
)
return cls(
identities=identities,
idm=idm,
incumbencies=incumbencies,
organizations=organizations,
hierarchy=hierarchy,
notifications=notification_dispatch_provider(registry),
)
# Capability: directory
def list_visible_postboxes(
self,
session: object,
*,
tenant_id: str,
actor: PostboxActorRef,
) -> tuple[PostboxDirectoryEntryRef, ...]:
db = _session(session)
assignments = self._assignments_for_actor(actor, tenant_id=tenant_id)
postboxes = (
db.query(Postbox)
.options(
selectinload(Postbox.address_record),
selectinload(Postbox.bindings),
)
.filter(
Postbox.tenant_id == tenant_id,
Postbox.status == "active",
)
.order_by(Postbox.name.asc(), Postbox.id.asc())
.all()
)
holder_cache = self._holder_cache(
tenant_id=tenant_id,
postboxes=postboxes,
)
visible: list[PostboxDirectoryEntryRef] = []
for postbox in postboxes:
decision = self._access_decision(
postbox,
actor=actor,
action="discover",
assignments=assignments,
holder_cache=holder_cache,
)
if decision.allowed:
visible.append(
self._directory_entry(
postbox,
decision=decision,
holder_cache=holder_cache,
)
)
return tuple(visible)
def resolve_postbox(
self,
session: object,
*,
tenant_id: str,
target: PostboxTargetRef,
materialize: bool = False,
) -> PostboxDirectoryEntryRef | None:
db = _session(session)
postbox: Postbox | None = None
if target.postbox_id:
postbox = self._get_postbox(
db,
tenant_id=tenant_id,
postbox_id=target.postbox_id,
required=False,
)
elif target.address_key:
postbox = (
db.query(Postbox)
.join(PostboxAddress, Postbox.address_id == PostboxAddress.id)
.options(
selectinload(Postbox.address_record),
selectinload(Postbox.bindings),
)
.filter(
Postbox.tenant_id == tenant_id,
PostboxAddress.address_key == target.address_key,
)
.one_or_none()
)
elif (
target.template_id
and target.organization_unit_id
and target.function_id
):
address_key = self._template_address_key(
target.template_id,
target.organization_unit_id,
target.function_id,
target.context_key,
)
postbox = (
db.query(Postbox)
.join(PostboxAddress, Postbox.address_id == PostboxAddress.id)
.options(
selectinload(Postbox.address_record),
selectinload(Postbox.bindings),
)
.filter(
Postbox.tenant_id == tenant_id,
PostboxAddress.address_key == address_key,
)
.one_or_none()
)
if postbox is None and materialize:
postbox = self.materialize_template(
db,
tenant_id=tenant_id,
template_id=target.template_id,
organization_unit_id=target.organization_unit_id,
function_id=target.function_id,
context_key=target.context_key,
actor_id=None,
)
if postbox is None:
return None
return self._directory_entry(postbox)
def delivery_catalog(
self,
session: object,
*,
tenant_id: str,
) -> PostboxDeliveryCatalogRef:
db = _session(session)
postboxes = tuple(
item
for item in self.list_admin_postboxes(db, tenant_id=tenant_id)
if item.status == "active"
)
templates: list[PostboxDeliveryTemplateRef] = []
for template in self.list_templates(db, tenant_id=tenant_id):
if template.status != "published" or not template.published_revision_id:
continue
revision = next(
(
item
for item in template.revisions
if item.id == template.published_revision_id
),
None,
)
if revision is None:
continue
templates.append(
PostboxDeliveryTemplateRef(
id=template.id,
slug=template.slug,
name=template.name,
description=template.description,
published_revision_id=revision.id,
function_type_id=revision.function_type_id,
scope_kind=revision.scope_kind,
scope_id=revision.scope_id,
classification=revision.classification,
allow_vacant_delivery=revision.allow_vacant_delivery,
)
)
organization_units = tuple(
PostboxOrganizationUnitTargetRef(
id=str(unit["id"]),
slug=str(unit["slug"]),
name=str(unit["name"]),
unit_type_id=(
str(unit["unit_type_id"])
if unit.get("unit_type_id") is not None
else None
),
parent_id=(
str(unit["parent_id"])
if unit.get("parent_id") is not None
else None
),
functions=tuple(
PostboxOrganizationFunctionTargetRef(
id=str(function["id"]),
slug=str(function["slug"]),
name=str(function["name"]),
function_type_id=(
str(function["function_type_id"])
if function.get("function_type_id") is not None
else None
),
)
for function in unit.get("functions", ())
if isinstance(function, Mapping)
),
)
for unit in self.organization_targets(tenant_id=tenant_id)
)
return PostboxDeliveryCatalogRef(
postboxes=postboxes,
templates=tuple(templates),
organization_units=organization_units,
)
# Capability: access
def explain_access(
self,
session: object,
*,
tenant_id: str,
postbox_id: str,
actor: PostboxActorRef,
action: PostboxAction,
) -> PostboxAccessDecisionRef:
db = _session(session)
postbox = self._get_postbox(
db,
tenant_id=tenant_id,
postbox_id=postbox_id,
)
decision = self._access_decision(
postbox,
actor=actor,
action=action,
assignments=self._assignments_for_actor(actor, tenant_id=tenant_id),
holder_cache={},
)
if not decision.allowed:
self._record_access_event(
db,
tenant_id=tenant_id,
postbox_id=postbox.id,
actor=actor,
action=f"access.{action}",
outcome="denied",
reason_code=decision.reason_code,
assignment_id=decision.selected_assignment_id,
)
return decision
# Capability: messages
def list_messages(
self,
session: object,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
limit: int = 100,
offset: int = 0,
query: str | None = None,
state: PostboxMessageListState = "all",
) -> tuple[PostboxMessageRef, ...]:
db = _session(session)
allowed_ids = self._allowed_postbox_ids(
db,
tenant_id=tenant_id,
postbox_ids=postbox_ids,
actor=actor,
action="read",
)
if not allowed_ids:
return ()
messages = (
self._messages_query(
db,
tenant_id=tenant_id,
allowed_ids=allowed_ids,
account_id=actor.account_id,
allowed_classifications=tuple(actor.authorized_classifications),
query=query,
state=state,
)
.options(
selectinload(PostboxMessage.participants),
selectinload(PostboxMessage.attachments),
selectinload(PostboxMessage.receipts),
)
.order_by(
PostboxMessage.delivered_at.desc(),
PostboxMessage.id.desc(),
)
.offset(max(offset, 0))
.limit(min(max(limit, 1), 500))
.all()
)
return tuple(
self._message_ref(message, account_id=actor.account_id)
for message in messages
)
def count_messages(
self,
session: object,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
query: str | None = None,
state: PostboxMessageListState = "all",
) -> int:
db = _session(session)
allowed_ids = self._allowed_postbox_ids(
db,
tenant_id=tenant_id,
postbox_ids=postbox_ids,
actor=actor,
action="read",
)
if not allowed_ids:
return 0
return int(
self._messages_query(
db,
tenant_id=tenant_id,
allowed_ids=allowed_ids,
account_id=actor.account_id,
allowed_classifications=tuple(actor.authorized_classifications),
query=query,
state=state,
)
.with_entities(func.count(PostboxMessage.id))
.scalar()
or 0
)
def _messages_query(
self,
session: Session,
*,
tenant_id: str,
allowed_ids: Sequence[str],
account_id: str,
allowed_classifications: Sequence[str],
query: str | None,
state: PostboxMessageListState,
):
result = session.query(PostboxMessage).filter(
PostboxMessage.tenant_id == tenant_id,
PostboxMessage.postbox_id.in_(allowed_ids),
PostboxMessage.classification.in_(allowed_classifications),
)
needle = (query or "").strip().casefold()
if needle:
pattern = f"%{needle}%"
result = result.filter(
or_(
func.lower(PostboxMessage.subject).like(pattern),
func.lower(
func.coalesce(PostboxMessage.sender_label, "")
).like(pattern),
PostboxMessage.participants.any(
or_(
func.lower(
func.coalesce(PostboxParticipant.label, "")
).like(pattern),
func.lower(
func.coalesce(PostboxParticipant.address, "")
).like(pattern),
)
),
)
)
read_receipt = PostboxMessage.receipts.any(
and_(
PostboxMessageReceipt.account_id == account_id,
PostboxMessageReceipt.read_at.is_not(None),
)
)
acknowledged_receipt = PostboxMessage.receipts.any(
and_(
PostboxMessageReceipt.account_id == account_id,
PostboxMessageReceipt.acknowledged_at.is_not(None),
)
)
if state == "unread":
result = result.filter(~read_receipt)
elif state == "read":
result = result.filter(read_receipt)
elif state == "acknowledged":
result = result.filter(acknowledged_receipt)
return result
def get_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
) -> PostboxMessageRef | None:
db = _session(session)
message = self._get_message(
db,
tenant_id=tenant_id,
message_id=message_id,
required=False,
)
if message is None:
return None
decision = self._message_access_decision(
db,
message=message,
actor=actor,
action="read",
)
if not decision.allowed:
self._record_access_event(
db,
tenant_id=tenant_id,
postbox_id=message.postbox_id,
message_id=message.id,
actor=actor,
action="message.read",
outcome="denied",
reason_code=decision.reason_code,
assignment_id=decision.selected_assignment_id,
)
return None
self._record_access_event(
db,
tenant_id=tenant_id,
postbox_id=message.postbox_id,
message_id=message.id,
actor=actor,
action="message.read",
outcome="allowed",
reason_code=decision.reason_code,
assignment_id=decision.selected_assignment_id,
)
return self._message_ref(message, account_id=actor.account_id)
def can_read_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
) -> bool:
"""Recheck message access without creating a read or denial event."""
db = _session(session)
message = self._get_message(
db,
tenant_id=tenant_id,
message_id=message_id,
required=False,
)
if message is None:
return False
return self._message_access_decision(
db,
message=message,
actor=actor,
action="read",
).allowed
def mark_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
state: Literal["read", "acknowledged"],
) -> PostboxMessageRef:
db = _session(session)
message = self._get_message(
db,
tenant_id=tenant_id,
message_id=message_id,
)
action: PostboxAction = (
"acknowledge" if state == "acknowledged" else "read"
)
decision = self._message_access_decision(
db,
message=message,
actor=actor,
action=action,
)
if not decision.allowed:
self._record_access_event(
db,
tenant_id=tenant_id,
postbox_id=message.postbox_id,
message_id=message.id,
actor=actor,
action=f"message.{state}",
outcome="denied",
reason_code=decision.reason_code,
assignment_id=decision.selected_assignment_id,
)
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(
PostboxMessageReceipt.tenant_id == tenant_id,
PostboxMessageReceipt.message_id == message.id,
PostboxMessageReceipt.account_id == actor.account_id,
)
.one_or_none()
)
if receipt is None:
receipt = PostboxMessageReceipt(
tenant_id=tenant_id,
message_id=message.id,
account_id=actor.account_id,
identity_id=actor.identity_id,
assignment_id=decision.selected_assignment_id,
metadata_={},
)
message.receipts.append(receipt)
was_read = receipt.read_at is not None
was_acknowledged = receipt.acknowledged_at is not None
now = utc_now()
if state == "read":
receipt.read_at = receipt.read_at or now
else:
receipt.read_at = receipt.read_at or now
receipt.acknowledged_at = receipt.acknowledged_at or now
db.flush()
self._record_access_event(
db,
tenant_id=tenant_id,
postbox_id=message.postbox_id,
message_id=message.id,
actor=actor,
action=f"message.{state}",
outcome="allowed",
reason_code=decision.reason_code,
assignment_id=decision.selected_assignment_id,
)
if (
state == "read"
and not was_read
or state == "acknowledged"
and not was_acknowledged
):
_publish_postbox_event(
db,
f"postbox.message.{state}.v1",
tenant_id=tenant_id,
resource_type="postbox_message",
resource_id=message.id,
postbox_id=message.postbox_id,
actor_type="account",
actor_id=actor.account_id,
payload={
"state": state,
"assignment_id": decision.selected_assignment_id,
},
)
db.refresh(message)
return self._message_ref(message, account_id=actor.account_id)
def create_message(
self,
session: object,
*,
tenant_id: str,
postbox_id: str,
actor: PostboxActorRef,
request: PostboxMessageAuthoringRequest,
) -> PostboxMessageRef:
db = _session(session)
postbox = self._get_postbox(
db,
tenant_id=tenant_id,
postbox_id=postbox_id,
)
return self._author_message(
db,
postbox=postbox,
actor=actor,
request=request,
action="send",
in_reply_to=None,
)
def reply_to_message(
self,
session: object,
*,
tenant_id: str,
message_id: str,
actor: PostboxActorRef,
request: PostboxMessageAuthoringRequest,
) -> PostboxMessageRef:
db = _session(session)
parent = self._get_message(
db,
tenant_id=tenant_id,
message_id=message_id,
)
availability = _message_availability(parent)
if availability != "available":
raise PostboxError(
f"message_{availability}",
f"This Postbox message is {availability} and cannot be replied to.",
)
postbox = self._get_postbox(
db,
tenant_id=tenant_id,
postbox_id=parent.postbox_id,
)
classification = self._validate_classification(request.classification)
if not postbox_classification_allows(
classification,
parent.classification,
):
raise PostboxError(
"reply_classification_too_low",
"A reply cannot be classified below its parent message.",
)
return self._author_message(
db,
postbox=postbox,
actor=actor,
request=request,
action="reply",
in_reply_to=parent,
)
def _author_message(
self,
session: Session,
*,
postbox: Postbox,
actor: PostboxActorRef,
request: PostboxMessageAuthoringRequest,
action: Literal["send", "reply"],
in_reply_to: PostboxMessage | None,
) -> PostboxMessageRef:
classification = self._validate_classification(request.classification)
if not postbox_classification_allows(
postbox.classification,
classification,
):
raise PostboxError(
"classification_not_allowed",
"The message classification exceeds the Postbox classification.",
)
decision = self._access_decision(
postbox,
actor=actor,
action=action,
assignments=self._assignments_for_actor(
actor,
tenant_id=postbox.tenant_id,
),
holder_cache={},
classification=classification,
)
if not decision.allowed:
self._record_access_event(
session,
tenant_id=postbox.tenant_id,
postbox_id=postbox.id,
message_id=in_reply_to.id if in_reply_to else None,
actor=actor,
action=f"message.{action}",
outcome="denied",
reason_code=decision.reason_code,
assignment_id=decision.selected_assignment_id,
)
raise PostboxError("access_denied", decision.explanation)
authoring_key = request.idempotency_key.strip()
if not authoring_key:
raise PostboxError(
"idempotency_key_required",
"Message authoring requires an idempotency key.",
)
digest = _authoring_digest(request, in_reply_to=in_reply_to)
existing = (
session.query(PostboxMessage)
.options(
selectinload(PostboxMessage.participants),
selectinload(PostboxMessage.attachments),
selectinload(PostboxMessage.receipts),
)
.filter(
PostboxMessage.tenant_id == postbox.tenant_id,
PostboxMessage.postbox_id == postbox.id,
PostboxMessage.authoring_key == authoring_key,
)
.one_or_none()
)
if existing is not None:
authoring = _mapping(existing.metadata_).get("authoring")
existing_digest = (
str(authoring.get("digest"))
if isinstance(authoring, Mapping) and authoring.get("digest")
else None
)
if existing_digest != digest:
raise PostboxError(
"idempotency_conflict",
"The message idempotency key was already used for different content.",
)
return self._message_ref(existing, account_id=actor.account_id)
identity = (
self._identities.get_identity(actor.identity_id)
if actor.identity_id
else self._identities.identity_for_account(actor.account_id)
)
sender_label = (
identity.display_name
if identity is not None and identity.display_name
else actor.account_id
)
metadata = _mapping(request.metadata)
metadata["authoring"] = {
"digest": digest,
"account_id": actor.account_id,
"identity_id": actor.identity_id,
"assignment_id": decision.selected_assignment_id,
"action": action,
}
message_id = new_uuid()
body_storage = self._message_body_storage(
session,
postbox=postbox,
message_id=message_id,
body_text=request.body_text,
actor_id=actor.account_id,
)
message = PostboxMessage(
id=message_id,
tenant_id=postbox.tenant_id,
postbox_id=postbox.id,
subject=request.subject.strip() or "(No subject)",
body_text=body_storage["body_text"],
body_ciphertext=body_storage["body_ciphertext"],
status="sent",
classification=classification,
sender_label=sender_label,
producer_module="postbox",
producer_resource_type="account_authored_message",
producer_resource_id=actor.account_id,
authoring_key=authoring_key,
in_reply_to_message_id=(in_reply_to.id if in_reply_to else None),
encryption_profile=postbox.encryption_profile,
key_epoch=postbox.key_epoch,
ciphertext_ref=body_storage["ciphertext_ref"],
encryption_envelope_id=body_storage["encryption_envelope_id"],
encryption_resource_id=body_storage["encryption_resource_id"],
wrapped_keys=body_storage["wrapped_keys"],
delivered_at=utc_now(),
metadata_=metadata,
)
message.participants.append(
PostboxParticipant(
tenant_id=postbox.tenant_id,
kind="author",
reference_type="account",
reference_id=actor.account_id,
label=sender_label,
position=0,
metadata_={"assignment_id": decision.selected_assignment_id},
)
)
for position, participant in enumerate(request.participants, start=1):
message.participants.append(
PostboxParticipant(
tenant_id=postbox.tenant_id,
kind=participant.kind,
reference_type=participant.reference_type,
reference_id=participant.reference_id,
label=participant.label,
address=participant.address,
position=position,
metadata_={},
)
)
for position, attachment in enumerate(request.attachments):
message.attachments.append(
PostboxAttachmentReference(
tenant_id=postbox.tenant_id,
reference_type=attachment.reference_type,
reference_id=attachment.reference_id,
name=attachment.name,
media_type=attachment.media_type,
size_bytes=attachment.size_bytes,
digest=attachment.digest,
position=position,
metadata_=_mapping(attachment.metadata),
)
)
session.add(message)
try:
session.flush()
except IntegrityError as exc:
raise PostboxError(
"idempotency_conflict",
"The message idempotency key was accepted concurrently.",
) from exc
self._record_access_event(
session,
tenant_id=postbox.tenant_id,
postbox_id=postbox.id,
message_id=message.id,
actor=actor,
action=f"message.{action}",
outcome="allowed",
reason_code=decision.reason_code,
assignment_id=decision.selected_assignment_id,
details={"in_reply_to_message_id": message.in_reply_to_message_id},
)
_publish_postbox_event(
session,
(
"postbox.message.replied.v1"
if in_reply_to is not None
else "postbox.message.authored.v1"
),
tenant_id=postbox.tenant_id,
resource_type="postbox_message",
resource_id=message.id,
postbox_id=postbox.id,
actor_type="account",
actor_id=actor.account_id,
payload={
"in_reply_to_message_id": message.in_reply_to_message_id,
"classification": classification,
"assignment_id": decision.selected_assignment_id,
},
)
return self._message_ref(message, account_id=actor.account_id)
# Capability: delivery
def deliver(
self,
session: object,
request: PostboxDeliveryRequest,
) -> PostboxDeliveryResult:
db = _session(session)
existing = (
db.query(PostboxDelivery)
.filter(
PostboxDelivery.tenant_id == request.tenant_id,
PostboxDelivery.producer_module == request.producer_module,
PostboxDelivery.idempotency_key == request.idempotency_key,
)
.one_or_none()
)
if existing is not None:
postbox = self._get_postbox(
db,
tenant_id=request.tenant_id,
postbox_id=existing.postbox_id,
)
return self._delivery_result(existing, postbox, duplicate=True)
entry = self.resolve_postbox(
db,
tenant_id=request.tenant_id,
target=request.target,
materialize=True,
)
if entry is None:
raise PostboxError(
"target_not_found",
"The Postbox delivery target could not be resolved.",
)
postbox = self._get_postbox(
db,
tenant_id=request.tenant_id,
postbox_id=entry.id,
)
if postbox.status != "active":
raise PostboxError("postbox_inactive", "The target Postbox is not active.")
classification = self._validate_classification(request.classification)
if not postbox_classification_allows(
postbox.classification,
classification,
):
raise PostboxError(
"classification_not_allowed",
"The message classification exceeds the target Postbox classification.",
)
holders = self._holders(
request.tenant_id,
postbox.address_record.function_id,
)
revision = self._address_revision(db, postbox.address_record)
if not holders and revision is not None and not revision.allow_vacant_delivery:
raise PostboxError(
"vacant_delivery_blocked",
"The target function is vacant and its template blocks vacant delivery.",
)
now = utc_now()
message_id = new_uuid()
body_storage = self._message_body_storage(
db,
postbox=postbox,
message_id=message_id,
body_text=request.body_text,
actor_id=f"module:{request.producer_module}",
external_ciphertext_ref=request.ciphertext_ref,
external_wrapped_keys=request.wrapped_keys,
)
message = PostboxMessage(
id=message_id,
tenant_id=request.tenant_id,
postbox_id=postbox.id,
subject=request.subject.strip() or "(No subject)",
body_text=body_storage["body_text"],
body_ciphertext=body_storage["body_ciphertext"],
status="delivered",
classification=classification,
sender_label=request.sender_label,
producer_module=request.producer_module,
producer_resource_type=request.producer_resource_type,
producer_resource_id=request.producer_resource_id,
encryption_profile=(
"external_envelope_v1"
if request.ciphertext_ref
else postbox.encryption_profile
),
key_epoch=postbox.key_epoch,
ciphertext_ref=body_storage["ciphertext_ref"],
encryption_envelope_id=body_storage["encryption_envelope_id"],
encryption_resource_id=body_storage["encryption_resource_id"],
signed_manifest_ref=request.signed_manifest_ref,
wrapped_keys=body_storage["wrapped_keys"],
external_recipient_tokens=[
_external_token_record(item)
for item in request.external_recipient_tokens
],
delivered_at=now,
expires_at=request.expires_at,
metadata_=_mapping(request.metadata),
)
for position, participant in enumerate(request.participants):
message.participants.append(
PostboxParticipant(
tenant_id=request.tenant_id,
kind=participant.kind,
reference_type=participant.reference_type,
reference_id=participant.reference_id,
label=participant.label,
address=participant.address,
position=position,
metadata_={},
)
)
for position, attachment in enumerate(request.attachments):
message.attachments.append(
PostboxAttachmentReference(
tenant_id=request.tenant_id,
reference_type=attachment.reference_type,
reference_id=attachment.reference_id,
name=attachment.name,
media_type=attachment.media_type,
size_bytes=attachment.size_bytes,
digest=attachment.digest,
position=position,
metadata_=_mapping(attachment.metadata),
)
)
db.add(message)
db.flush()
snapshot = {
"address": entry.address,
"address_key": entry.address_key,
"postbox_name": entry.name,
"organization_unit_id": entry.organization_unit_id,
"organization_unit_name": entry.organization_unit_name,
"function_id": entry.function_id,
"function_name": entry.function_name,
"context_key": entry.context_key,
"template_revision_id": entry.template_revision_id,
"holder_assignment_ids": [holder.id for holder in holders],
}
delivery = PostboxDelivery(
tenant_id=request.tenant_id,
postbox_id=postbox.id,
message_id=message.id,
producer_module=request.producer_module,
producer_resource_type=request.producer_resource_type,
producer_resource_id=request.producer_resource_id,
idempotency_key=request.idempotency_key,
status="accepted" if holders else "accepted_vacant",
template_revision_id=entry.template_revision_id,
organization_unit_id=entry.organization_unit_id,
function_id=entry.function_id,
holder_count=len({holder.identity_id for holder in holders}),
target_snapshot=snapshot,
accepted_at=now,
metadata_=_mapping(request.metadata),
)
db.add(delivery)
try:
db.flush()
except IntegrityError as exc:
raise PostboxError(
"idempotency_conflict",
"The delivery idempotency key was accepted concurrently.",
) from exc
self._record_access_event(
db,
tenant_id=request.tenant_id,
postbox_id=postbox.id,
message_id=message.id,
actor=None,
action="delivery.accept",
outcome="allowed",
reason_code=delivery.status,
details={
"producer_module": request.producer_module,
"producer_resource_type": request.producer_resource_type,
"producer_resource_id": request.producer_resource_id,
"delivery_id": delivery.id,
},
)
routes = self._apply_hierarchy_routing(
db,
request=request,
delivery=delivery,
source_postbox=postbox,
source_message=message,
source_revision=revision,
)
_publish_postbox_event(
db,
"postbox.delivery.accepted.v1",
tenant_id=request.tenant_id,
resource_type="postbox_message",
resource_id=message.id,
postbox_id=postbox.id,
actor_type="module",
actor_id=request.producer_module,
payload={
"delivery_id": delivery.id,
"status": delivery.status,
"vacant": not bool(holders),
"holder_count": delivery.holder_count,
"route_count": len(routes),
"route_ids": [route.id for route in routes],
"producer_resource_type": request.producer_resource_type,
"producer_resource_id": request.producer_resource_id,
},
)
self._notify_delivery_holders(
db,
request=request,
delivery=delivery,
message=message,
holders=holders,
)
return self._delivery_result(delivery, postbox)
def preview_hierarchy_routes(
self,
session: Session,
*,
tenant_id: str,
target: PostboxTargetRef,
producer_module: str,
classification: str,
expires_at: datetime | None,
) -> dict[str, object]:
classification = self._validate_classification(classification)
entry = self.resolve_postbox(
session,
tenant_id=tenant_id,
target=target,
materialize=False,
)
if entry is None:
raise PostboxError(
"target_not_materialized",
"Dry-run routing requires an existing source Postbox.",
)
source_postbox = self._get_postbox(
session,
tenant_id=tenant_id,
postbox_id=entry.id,
)
source_revision = self._address_revision(
session,
source_postbox.address_record,
)
plan = self._hierarchy_route_plan(
source_postbox=source_postbox,
source_revision=source_revision,
producer_module=producer_module,
classification=classification,
expires_at=expires_at,
)
target_template, target_revision, template_diagnostics = (
self._route_target_template(
session,
tenant_id=tenant_id,
plan=plan,
required=False,
)
)
routes = [
self._route_preview(
session,
tenant_id=tenant_id,
source_postbox=source_postbox,
candidate=candidate,
target_template=target_template,
target_revision=target_revision,
)
for candidate in plan.candidates
]
return {
"status": (
"blocked"
if template_diagnostics and plan.status == "planned"
else plan.status
),
"source_postbox_id": source_postbox.id,
"policy": dict(plan.policy),
"routes": routes,
"diagnostics": list(
dict.fromkeys((*plan.diagnostics, *template_diagnostics))
),
}
def dispatch_due_routes(
self,
session: object,
*,
tenant_id: str | None = None,
limit: int = 50,
) -> Mapping[str, object]:
db = _session(session)
bounded_limit = max(1, min(int(limit), 500))
now = utc_now()
query = db.query(PostboxRoute).filter(
PostboxRoute.status == "pending_vacancy_escalation",
PostboxRoute.execute_after.is_not(None),
PostboxRoute.execute_after <= now,
)
if tenant_id:
query = query.filter(PostboxRoute.tenant_id == tenant_id)
routes = (
query.order_by(
PostboxRoute.execute_after,
PostboxRoute.created_at,
PostboxRoute.id,
)
.with_for_update()
.limit(bounded_limit)
.all()
)
result = {
"selected": len(routes),
"delivered": 0,
"vacant": 0,
"rescheduled": 0,
"cancelled": 0,
"failed": 0,
"route_ids": [],
}
for route in routes:
result["route_ids"].append(route.id)
try:
with db.begin_nested():
outcome, rescheduled = self._dispatch_pending_route(
db,
route=route,
now=now,
)
result[outcome] += 1
if rescheduled:
result["rescheduled"] += 1
except PostboxError as exc:
failed_route = db.get(PostboxRoute, route.id)
if failed_route is not None:
failed_route.status = f"failed:{exc.code}"[:40]
failed_route.processed_at = now
result["failed"] += 1
except Exception: # noqa: BLE001 - preserve other due routes.
logger.exception(
"Postbox hierarchy route dispatch failed",
extra={"postbox_route_id": route.id},
)
failed_route = db.get(PostboxRoute, route.id)
if failed_route is not None:
failed_route.status = "failed:unexpected"
failed_route.processed_at = now
result["failed"] += 1
db.flush()
return result
def _hierarchy_route_plan(
self,
*,
source_postbox: Postbox,
source_revision: PostboxTemplateRevision | None,
producer_module: str,
classification: str,
expires_at: datetime | None,
) -> HierarchyRoutePlan:
routing_policy = (
source_revision.routing_policy
if source_revision is not None
else _mapping(
(source_postbox.settings or {}).get("routing_policy")
if isinstance(source_postbox.settings, Mapping)
else None
)
)
return plan_hierarchy_routes(
hierarchy=self._hierarchy,
incumbencies=self._incumbencies,
tenant_id=source_postbox.tenant_id,
source_unit_id=source_postbox.address_record.organization_unit_id,
routing_policy=routing_policy,
producer_module=producer_module,
classification=classification,
expires_at=expires_at,
now=utc_now(),
)
def _route_target_template(
self,
session: Session,
*,
tenant_id: str,
plan: HierarchyRoutePlan,
required: bool,
) -> tuple[
PostboxTemplate | None,
PostboxTemplateRevision | None,
tuple[str, ...],
]:
linked_copy = _mapping(plan.policy.get("linked_copy"))
template_id = str(linked_copy.get("target_template_id") or "")
if not template_id:
return None, None, (
("target_template_missing",)
if required or plan.status == "planned"
else ()
)
try:
template = self._get_template(
session,
tenant_id=tenant_id,
template_id=template_id,
)
except PostboxError:
return None, None, ("target_template_missing",)
if template.status != "published" or not template.published_revision_id:
return template, None, ("target_template_not_published",)
revision = next(
(
item
for item in template.revisions
if item.id == template.published_revision_id
),
None,
)
if revision is None:
return template, None, ("target_template_revision_missing",)
target_function_type_id = linked_copy.get("target_function_type_id")
if (
revision.function_type_id
and revision.function_type_id != target_function_type_id
):
return template, revision, ("target_template_function_mismatch",)
return template, revision, ()
def _route_preview(
self,
session: Session,
*,
tenant_id: str,
source_postbox: Postbox,
candidate: HierarchyRouteCandidate,
target_template: PostboxTemplate | None,
target_revision: PostboxTemplateRevision | None,
) -> dict[str, object]:
diagnostics = list(candidate.diagnostics)
status = candidate.status
target_postbox: Postbox | None = None
if (
candidate.function is not None
and target_template is not None
and target_revision is not None
):
key = self._template_address_key(
target_template.id,
candidate.unit.id,
candidate.function.id,
source_postbox.address_record.context_key,
)
target_postbox = self._postbox_for_address_key(
session,
tenant_id=tenant_id,
address_key=key,
)
if (
candidate.status == "vacant"
and not target_revision.allow_vacant_delivery
):
status = "vacancy_blocked"
diagnostics.append("target_template_blocks_vacancy")
return {
"depth": candidate.depth,
"organization_unit_id": candidate.unit.id,
"organization_unit_name": candidate.unit.name,
"function_id": (
candidate.function.id if candidate.function else None
),
"function_name": (
candidate.function.name if candidate.function else None
),
"target_postbox_id": (
target_postbox.id if target_postbox else None
),
"target_address": (
target_postbox.address_record.address
if target_postbox
else None
),
"status": status,
"vacant": candidate.holder_count == 0,
"holder_count": candidate.holder_count,
"path": [dict(item) for item in candidate.path],
"diagnostics": diagnostics,
}
def _apply_hierarchy_routing(
self,
session: Session,
*,
request: PostboxDeliveryRequest,
delivery: PostboxDelivery,
source_postbox: Postbox,
source_message: PostboxMessage,
source_revision: PostboxTemplateRevision | None,
) -> tuple[PostboxRoute, ...]:
plan = self._hierarchy_route_plan(
source_postbox=source_postbox,
source_revision=source_revision,
producer_module=request.producer_module,
classification=request.classification,
expires_at=request.expires_at,
)
target_template, target_revision, template_diagnostics = (
self._route_target_template(
session,
tenant_id=request.tenant_id,
plan=plan,
required=plan.status == "planned",
)
)
routing_snapshot: dict[str, object] = {
"status": (
"blocked"
if template_diagnostics and plan.status == "planned"
else plan.status
),
"policy": dict(plan.policy),
"diagnostics": list(
dict.fromkeys((*plan.diagnostics, *template_diagnostics))
),
"routes": [],
}
delivery.target_snapshot = {
**dict(delivery.target_snapshot or {}),
"hierarchy_routing": routing_snapshot,
}
if (
plan.status != "planned"
or target_template is None
or target_revision is None
or template_diagnostics
):
return ()
materialized: list[
tuple[HierarchyRouteCandidate, Postbox]
] = []
seen_postbox_ids = {source_postbox.id}
for candidate in plan.candidates:
if (
candidate.function is None
or candidate.status not in {"available", "vacant"}
):
continue
if (
candidate.status == "vacant"
and not target_revision.allow_vacant_delivery
):
continue
try:
target_postbox = self.materialize_template(
session,
tenant_id=request.tenant_id,
template_id=target_template.id,
organization_unit_id=candidate.unit.id,
function_id=candidate.function.id,
context_key=source_postbox.address_record.context_key,
actor_id=None,
)
except PostboxError as exc:
routing_snapshot["diagnostics"] = list(
dict.fromkeys(
(
*routing_snapshot["diagnostics"],
f"target_{candidate.unit.id}:{exc.code}",
)
)
)
continue
if (
target_postbox.id in seen_postbox_ids
or target_postbox.status != "active"
):
continue
seen_postbox_ids.add(target_postbox.id)
materialized.append((candidate, target_postbox))
linked_copy = _mapping(plan.policy.get("linked_copy"))
fanout = linked_copy.get("fanout", "nearest")
selected = materialized if fanout == "all" else materialized[:1]
routes: list[PostboxRoute] = []
for candidate, target_postbox in selected:
route = self._create_linked_copy_route(
session,
request=request,
delivery=delivery,
source_postbox=source_postbox,
source_message=source_message,
target_postbox=target_postbox,
candidate=candidate,
policy=plan.policy,
route_kind="linked_copy",
source_route_id=None,
)
routes.append(route)
attention = _mapping(plan.policy.get("attention"))
if (
fanout == "nearest"
and routes
and routes[0].status == "accepted_vacant"
and attention.get("mode") == "vacancy_escalation"
and len(materialized) > 1
):
remaining = [
self._materialized_route_snapshot(candidate, postbox)
for candidate, postbox in materialized[1:]
]
pending = self._schedule_escalation_route(
session,
delivery=delivery,
source_postbox=source_postbox,
source_message=source_message,
source_route=routes[0],
target=remaining[0],
remaining=remaining[1:],
policy=plan.policy,
now=utc_now(),
)
routes.append(pending)
routing_snapshot["routes"] = [
self._route_evidence(route) for route in routes
]
routing_snapshot["status"] = "routed" if routes else "no_route"
delivery.target_snapshot = {
**dict(delivery.target_snapshot or {}),
"hierarchy_routing": routing_snapshot,
}
source_message.metadata_ = {
**dict(source_message.metadata_ or {}),
"hierarchy_route_ids": [route.id for route in routes],
}
return tuple(routes)
def _create_linked_copy_route(
self,
session: Session,
*,
request: PostboxDeliveryRequest,
delivery: PostboxDelivery,
source_postbox: Postbox,
source_message: PostboxMessage,
target_postbox: Postbox,
candidate: HierarchyRouteCandidate,
policy: Mapping[str, object],
route_kind: str,
source_route_id: str | None,
) -> PostboxRoute:
now = utc_now()
route = PostboxRoute(
tenant_id=request.tenant_id,
delivery_id=delivery.id,
source_postbox_id=source_postbox.id,
source_message_id=source_message.id,
target_postbox_id=target_postbox.id,
target_message_id=None,
route_kind=route_kind,
status="processing",
depth=candidate.depth,
source_route_id=source_route_id,
execute_after=None,
processed_at=None,
policy_snapshot={
"policy": dict(policy),
"target": self._materialized_route_snapshot(
candidate,
target_postbox,
),
"evaluated_at": now.isoformat(),
"classification": request.classification,
"producer_module": request.producer_module,
},
)
session.add(route)
session.flush()
message = self._copy_routed_message(
session,
source_message=source_message,
target_postbox=target_postbox,
route=route,
delivered_at=now,
)
route.target_message_id = message.id
route.processed_at = now
route.status = (
"accepted" if candidate.holders else "accepted_vacant"
)
session.flush()
self._record_access_event(
session,
tenant_id=request.tenant_id,
postbox_id=target_postbox.id,
message_id=message.id,
actor=None,
action=f"route.{route_kind}",
outcome="allowed",
reason_code=route.status,
details={
"route_id": route.id,
"delivery_id": delivery.id,
"source_postbox_id": source_postbox.id,
"source_message_id": source_message.id,
"depth": candidate.depth,
},
)
_publish_postbox_event(
session,
f"postbox.route.{route_kind}.accepted.v1",
tenant_id=request.tenant_id,
resource_type="postbox_route",
resource_id=route.id,
postbox_id=target_postbox.id,
actor_type="module",
actor_id=request.producer_module,
payload=self._route_evidence(route),
)
self._notify_delivery_holders(
session,
request=request,
delivery=delivery,
message=message,
holders=candidate.holders,
)
return route
def _copy_routed_message(
self,
session: Session,
*,
source_message: PostboxMessage,
target_postbox: Postbox,
route: PostboxRoute,
delivered_at: datetime,
) -> PostboxMessage:
message_id = new_uuid()
if source_message.ciphertext_ref:
body_storage = {
"body_text": None,
"body_ciphertext": source_message.body_ciphertext,
"ciphertext_ref": source_message.ciphertext_ref,
"encryption_envelope_id": source_message.encryption_envelope_id,
"encryption_resource_id": (
source_message.encryption_resource_id or source_message.id
),
"wrapped_keys": list(source_message.wrapped_keys or []),
}
encryption_profile = source_message.encryption_profile
else:
body_storage = self._message_body_storage(
session,
postbox=target_postbox,
message_id=message_id,
body_text=source_message.body_text,
actor_id=f"module:{source_message.producer_module or 'postbox'}",
)
encryption_profile = target_postbox.encryption_profile
message = PostboxMessage(
id=message_id,
tenant_id=source_message.tenant_id,
postbox_id=target_postbox.id,
subject=source_message.subject,
body_text=body_storage["body_text"],
body_ciphertext=body_storage["body_ciphertext"],
status="delivered",
classification=source_message.classification,
sender_label=source_message.sender_label,
producer_module=source_message.producer_module,
producer_resource_type=source_message.producer_resource_type,
producer_resource_id=source_message.producer_resource_id,
encryption_profile=encryption_profile,
key_epoch=target_postbox.key_epoch,
ciphertext_ref=body_storage["ciphertext_ref"],
encryption_envelope_id=body_storage["encryption_envelope_id"],
encryption_resource_id=body_storage["encryption_resource_id"],
signed_manifest_ref=source_message.signed_manifest_ref,
wrapped_keys=body_storage["wrapped_keys"],
external_recipient_tokens=list(
source_message.external_recipient_tokens or []
),
delivered_at=delivered_at,
expires_at=source_message.expires_at,
retention_hold_until=source_message.retention_hold_until,
metadata_={
**dict(source_message.metadata_ or {}),
"postbox_route": {
"route_id": route.id,
"route_kind": route.route_kind,
"source_postbox_id": source_message.postbox_id,
"source_message_id": source_message.id,
"delivery_id": route.delivery_id,
"depth": route.depth,
},
},
)
for participant in source_message.participants:
message.participants.append(
PostboxParticipant(
tenant_id=source_message.tenant_id,
kind=participant.kind,
reference_type=participant.reference_type,
reference_id=participant.reference_id,
label=participant.label,
address=participant.address,
position=participant.position,
metadata_=dict(participant.metadata_ or {}),
)
)
for attachment in source_message.attachments:
message.attachments.append(
PostboxAttachmentReference(
tenant_id=source_message.tenant_id,
reference_type=attachment.reference_type,
reference_id=attachment.reference_id,
name=attachment.name,
media_type=attachment.media_type,
size_bytes=attachment.size_bytes,
digest=attachment.digest,
ciphertext_ref=attachment.ciphertext_ref,
position=attachment.position,
metadata_=dict(attachment.metadata_ or {}),
)
)
session.add(message)
session.flush()
return message
def _schedule_escalation_route(
self,
session: Session,
*,
delivery: PostboxDelivery,
source_postbox: Postbox,
source_message: PostboxMessage,
source_route: PostboxRoute,
target: Mapping[str, object],
remaining: Sequence[Mapping[str, object]],
policy: Mapping[str, object],
now: datetime,
) -> PostboxRoute:
attention = _mapping(policy.get("attention"))
delay_minutes = int(attention.get("delay_minutes") or 0)
route = PostboxRoute(
tenant_id=delivery.tenant_id,
delivery_id=delivery.id,
source_postbox_id=source_postbox.id,
source_message_id=source_message.id,
target_postbox_id=str(target["target_postbox_id"]),
target_message_id=None,
route_kind="attention_escalation",
status="pending_vacancy_escalation",
depth=int(target["depth"]),
source_route_id=source_route.id,
execute_after=now + timedelta(minutes=delay_minutes),
processed_at=None,
policy_snapshot={
"policy": dict(policy),
"target": dict(target),
"remaining_targets": [dict(item) for item in remaining],
"evaluated_at": now.isoformat(),
"classification": source_message.classification,
"producer_module": delivery.producer_module,
},
)
session.add(route)
session.flush()
_publish_postbox_event(
session,
"postbox.route.attention_escalation.scheduled.v1",
tenant_id=delivery.tenant_id,
resource_type="postbox_route",
resource_id=route.id,
postbox_id=route.target_postbox_id,
actor_type="module",
actor_id=delivery.producer_module,
payload=self._route_evidence(route),
)
return route
def _dispatch_pending_route(
self,
session: Session,
*,
route: PostboxRoute,
now: datetime,
) -> tuple[str, bool]:
if route.source_route_id:
previous_route = (
session.query(PostboxRoute)
.filter(
PostboxRoute.id == route.source_route_id,
PostboxRoute.tenant_id == route.tenant_id,
)
.one_or_none()
)
previous_target = (
self._get_postbox(
session,
tenant_id=route.tenant_id,
postbox_id=previous_route.target_postbox_id or "",
required=False,
)
if previous_route is not None
else None
)
previous_holders = (
self._holders(
route.tenant_id,
previous_target.address_record.function_id,
)
if previous_target is not None
else ()
)
acknowledged = (
session.query(PostboxMessageReceipt)
.filter(
PostboxMessageReceipt.tenant_id == route.tenant_id,
PostboxMessageReceipt.message_id
== previous_route.target_message_id,
PostboxMessageReceipt.acknowledged_at.is_not(None),
)
.count()
if (
previous_route is not None
and previous_route.target_message_id
)
else 0
)
if previous_holders or acknowledged:
route.status = (
"cancelled_acknowledged"
if acknowledged
else "cancelled_vacancy_resolved"
)
route.processed_at = now
return "cancelled", False
source_message = self._get_message(
session,
tenant_id=route.tenant_id,
message_id=route.source_message_id,
)
delivery = (
session.query(PostboxDelivery)
.filter(
PostboxDelivery.id == route.delivery_id,
PostboxDelivery.tenant_id == route.tenant_id,
)
.one_or_none()
)
target_postbox = self._get_postbox(
session,
tenant_id=route.tenant_id,
postbox_id=route.target_postbox_id or "",
required=False,
)
if (
delivery is None
or target_postbox is None
or target_postbox.status != "active"
or source_message.withdrawn_at is not None
or source_message.expires_at is not None
and _as_utc(source_message.expires_at) <= _as_utc(now)
):
route.status = "cancelled"
route.processed_at = now
return "cancelled", False
target_revision = self._address_revision(
session,
target_postbox.address_record,
)
holders = self._holders(
route.tenant_id,
target_postbox.address_record.function_id,
)
if (
not holders
and target_revision is not None
and not target_revision.allow_vacant_delivery
):
route.status = "vacancy_blocked"
route.processed_at = now
return (
"cancelled",
self._schedule_following_escalation(
session,
route=route,
delivery=delivery,
source_message=source_message,
now=now,
),
)
message = self._copy_routed_message(
session,
source_message=source_message,
target_postbox=target_postbox,
route=route,
delivered_at=now,
)
route.target_message_id = message.id
route.status = "accepted" if holders else "accepted_vacant"
route.processed_at = now
request = PostboxDeliveryRequest(
tenant_id=route.tenant_id,
target=PostboxTargetRef(postbox_id=target_postbox.id),
producer_module=delivery.producer_module,
producer_resource_type=delivery.producer_resource_type,
producer_resource_id=delivery.producer_resource_id,
idempotency_key=delivery.idempotency_key,
subject=source_message.subject,
body_text=source_message.body_text,
sender_label=source_message.sender_label,
classification=source_message.classification,
expires_at=source_message.expires_at,
metadata=dict(source_message.metadata_ or {}),
)
self._notify_delivery_holders(
session,
request=request,
delivery=delivery,
message=message,
holders=holders,
)
_publish_postbox_event(
session,
"postbox.route.attention_escalation.accepted.v1",
tenant_id=route.tenant_id,
resource_type="postbox_route",
resource_id=route.id,
postbox_id=target_postbox.id,
actor_type="module",
actor_id=delivery.producer_module,
payload=self._route_evidence(route),
)
rescheduled = False
if not holders:
rescheduled = self._schedule_following_escalation(
session,
route=route,
delivery=delivery,
source_message=source_message,
now=now,
)
return ("delivered" if holders else "vacant"), rescheduled
def _schedule_following_escalation(
self,
session: Session,
*,
route: PostboxRoute,
delivery: PostboxDelivery,
source_message: PostboxMessage,
now: datetime,
) -> bool:
snapshot = dict(route.policy_snapshot or {})
remaining = [
dict(item)
for item in snapshot.get("remaining_targets", [])
if isinstance(item, Mapping)
]
if not remaining:
return False
source_postbox = self._get_postbox(
session,
tenant_id=route.tenant_id,
postbox_id=route.source_postbox_id,
)
self._schedule_escalation_route(
session,
delivery=delivery,
source_postbox=source_postbox,
source_message=source_message,
source_route=route,
target=remaining[0],
remaining=remaining[1:],
policy=_mapping(snapshot.get("policy")),
now=now,
)
return True
def _materialized_route_snapshot(
self,
candidate: HierarchyRouteCandidate,
postbox: Postbox,
) -> dict[str, object]:
return {
"depth": candidate.depth,
"organization_unit_id": candidate.unit.id,
"organization_unit_name": candidate.unit.name,
"organization_unit_type_id": candidate.unit.unit_type_id,
"function_id": (
candidate.function.id if candidate.function else None
),
"function_name": (
candidate.function.name if candidate.function else None
),
"function_type_id": (
candidate.function.function_type_id
if candidate.function
else None
),
"target_postbox_id": postbox.id,
"target_address": postbox.address_record.address,
"vacant_at_evaluation": candidate.holder_count == 0,
"holder_count_at_evaluation": candidate.holder_count,
"holder_assignment_ids": [
holder.id for holder in candidate.holders
],
"holder_assignment_sources": [
holder.source for holder in candidate.holders
],
"path": [dict(item) for item in candidate.path],
}
def _route_evidence(self, route: PostboxRoute) -> dict[str, object]:
snapshot = dict(route.policy_snapshot or {})
return {
"route_id": route.id,
"route_kind": route.route_kind,
"status": route.status,
"depth": route.depth,
"source_route_id": route.source_route_id,
"source_postbox_id": route.source_postbox_id,
"source_message_id": route.source_message_id,
"target_postbox_id": route.target_postbox_id,
"target_message_id": route.target_message_id,
"execute_after": (
route.execute_after.isoformat()
if route.execute_after
else None
),
"processed_at": (
route.processed_at.isoformat()
if route.processed_at
else None
),
"target_snapshot": snapshot.get("target", {}),
"policy_snapshot": snapshot.get("policy", {}),
}
def _notify_delivery_holders(
self,
session: Session,
*,
request: PostboxDeliveryRequest,
delivery: PostboxDelivery,
message: PostboxMessage,
holders: Sequence[OrganizationFunctionAssignmentRef],
) -> None:
if self._notifications is None:
return
recipients: dict[str, tuple[str, str]] = {}
for holder in holders:
if holder.account_id:
recipients.setdefault(
holder.account_id,
("account", holder.id),
)
elif holder.identity_id:
recipients.setdefault(
holder.identity_id,
("identity", holder.id),
)
for recipient_id, (recipient_type, assignment_id) in recipients.items():
try:
with session.begin_nested():
self._notifications.enqueue_notification(
session,
NotificationDispatchRequest(
tenant_id=request.tenant_id,
source_module="postbox",
source_resource_type="postbox_message",
source_resource_id=message.id,
event_kind="postbox.delivery.accepted.v1",
channel="inbox",
recipient_type=recipient_type,
recipient_id=recipient_id,
subject="New Postbox message",
body_text=(
"A new message is available in one of your "
"currently assigned Postboxes."
),
action_url=f"/postbox?message={message.id}",
priority=2,
payload={
"postbox_id": message.postbox_id,
"message_id": message.id,
"delivery_id": delivery.id,
"delivery_status": delivery.status,
},
metadata={
"assignment_id": assignment_id,
"producer_module": request.producer_module,
},
),
enqueue_delivery=False,
)
except Exception: # noqa: BLE001 - delivery remains authoritative.
logger.warning(
"Postbox delivery notification enqueue failed",
exc_info=True,
extra={
"postbox_delivery_id": delivery.id,
"postbox_message_id": message.id,
"notification_recipient_id": recipient_id,
},
)
# Capability: evidence
def link_evidence(
self,
session: object,
*,
tenant_id: str,
message_id: str,
attachment: PostboxAttachmentRef,
) -> PostboxMessageRef:
db = _session(session)
message = self._get_message(
db,
tenant_id=tenant_id,
message_id=message_id,
)
position = len(message.attachments)
message.attachments.append(
PostboxAttachmentReference(
tenant_id=tenant_id,
reference_type=attachment.reference_type,
reference_id=attachment.reference_id,
name=attachment.name,
media_type=attachment.media_type,
size_bytes=attachment.size_bytes,
digest=attachment.digest,
position=position,
metadata_=_mapping(attachment.metadata),
)
)
db.flush()
_publish_postbox_event(
db,
"postbox.evidence.linked.v1",
tenant_id=tenant_id,
resource_type="postbox_attachment_reference",
resource_id=message.attachments[-1].id,
postbox_id=message.postbox_id,
payload={
"message_id": message.id,
"reference_type": attachment.reference_type,
"reference_id": attachment.reference_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
def list_admin_postboxes(
self,
session: Session,
*,
tenant_id: str,
) -> tuple[PostboxDirectoryEntryRef, ...]:
postboxes = (
session.query(Postbox)
.options(
selectinload(Postbox.address_record),
selectinload(Postbox.bindings),
)
.filter(Postbox.tenant_id == tenant_id)
.order_by(Postbox.name.asc(), Postbox.id.asc())
.all()
)
holder_cache = self._holder_cache(
tenant_id=tenant_id,
postboxes=postboxes,
)
return tuple(
self._directory_entry(
postbox,
holder_cache=holder_cache,
)
for postbox in postboxes
)
def create_exact_postbox(
self,
session: Session,
*,
tenant_id: str,
name: str,
organization_unit_id: str,
function_id: str,
address_key: str | None,
description: str | None,
classification: str,
actor_id: str | None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
) -> Postbox:
classification = self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
unit, function = self._validate_function_target(
tenant_id=tenant_id,
organization_unit_id=organization_unit_id,
function_id=function_id,
)
key = (
f"exact:{_slug(address_key)}"
if address_key
else f"exact:{organization_unit_id}:{function_id}"
)
existing = self._postbox_for_address_key(
session,
tenant_id=tenant_id,
address_key=key,
)
if existing is not None:
return existing
address = (
f"{_slug(address_key or name)}.{_slug(unit.slug)}."
f"{_slug(function.slug)}@postbox"
)[:500]
return self._create_postbox_records(
session,
tenant_id=tenant_id,
address_key=key,
address=address,
name=name.strip() or f"{unit.name} / {function.name}",
description=description,
classification=classification,
organization_unit=unit,
function=function,
context_key=None,
template=None,
revision=None,
source="exact",
actor_id=actor_id,
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
)
def archive_postbox(
self,
session: Session,
*,
tenant_id: str,
postbox_id: str,
actor_id: str | None,
expected_revision: int,
) -> Postbox:
postbox = self._get_postbox(
session,
tenant_id=tenant_id,
postbox_id=postbox_id,
)
self._claim_resource_revision(
session,
model=Postbox,
resource=postbox,
resource_type="postbox",
tenant_id=tenant_id,
expected_revision=expected_revision,
)
postbox.status = "archived"
postbox.archived_at = utc_now()
postbox.address_record.status = "archived"
for binding in postbox.bindings:
binding.is_active = False
self._record_access_event(
session,
tenant_id=tenant_id,
postbox_id=postbox.id,
actor=(
PostboxActorRef(
account_id=actor_id,
authorized_actions=frozenset({"administer"}),
)
if actor_id
else None
),
action="postbox.archive",
outcome="allowed",
reason_code="administrator",
)
session.flush()
_publish_postbox_event(
session,
"postbox.archived.v1",
tenant_id=tenant_id,
resource_type="postbox",
resource_id=postbox.id,
postbox_id=postbox.id,
actor_type="user",
actor_id=actor_id,
)
return postbox
def list_templates(
self,
session: Session,
*,
tenant_id: str,
) -> tuple[PostboxTemplate, ...]:
return tuple(
session.query(PostboxTemplate)
.options(selectinload(PostboxTemplate.revisions))
.filter(PostboxTemplate.tenant_id == tenant_id)
.order_by(PostboxTemplate.name.asc(), PostboxTemplate.id.asc())
.all()
)
def create_template(
self,
session: Session,
*,
tenant_id: str,
slug: str,
name: str,
description: str | None,
function_type_id: str | None,
scope_kind: str,
scope_id: str | None,
name_pattern: str,
address_pattern: str,
classification: str,
allow_vacant_delivery: bool,
actor_id: str | None,
routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
) -> PostboxTemplate:
classification = self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
clean_slug = _slug(slug or name, fallback="template")
if (
session.query(PostboxTemplate)
.filter(
PostboxTemplate.tenant_id == tenant_id,
PostboxTemplate.slug == clean_slug,
)
.count()
):
raise PostboxError(
"template_slug_exists",
f"A Postbox template with slug '{clean_slug}' already exists.",
)
self._validate_scope(
tenant_id=tenant_id,
scope_kind=scope_kind,
scope_id=scope_id,
)
self._validate_patterns(name_pattern, address_pattern)
template = PostboxTemplate(
tenant_id=tenant_id,
slug=clean_slug,
name=name.strip(),
description=description,
status="draft",
current_revision=1,
created_by=actor_id,
updated_by=actor_id,
)
revision = PostboxTemplateRevision(
tenant_id=tenant_id,
revision=1,
function_type_id=function_type_id,
scope_kind=scope_kind,
scope_id=scope_id,
name_pattern=name_pattern,
address_pattern=address_pattern,
classification=classification,
allow_vacant_delivery=allow_vacant_delivery,
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
history_policy={},
routing_policy=normalized_routing_policy(routing_policy),
retention_policy={},
created_by=actor_id,
)
template.revisions.append(revision)
session.add(template)
session.flush()
_publish_postbox_event(
session,
"postbox.template.created.v1",
tenant_id=tenant_id,
resource_type="postbox_template",
resource_id=template.id,
actor_type="user",
actor_id=actor_id,
payload={"revision": 1, "status": template.status},
)
return template
def revise_template(
self,
session: Session,
*,
tenant_id: str,
template_id: str,
function_type_id: str | None,
scope_kind: str,
scope_id: str | None,
name_pattern: str,
address_pattern: str,
classification: str,
allow_vacant_delivery: bool,
actor_id: str | None,
expected_revision: int,
routing_policy: Mapping[str, object] | None = None,
encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None,
) -> PostboxTemplate:
classification = self._validate_classification(classification)
_validate_encryption_configuration(
encryption_profile,
encryption_vault_id,
)
template = self._get_template(
session,
tenant_id=tenant_id,
template_id=template_id,
)
if template.status == "retired":
raise PostboxError(
"template_retired",
"A retired Postbox template cannot be revised.",
)
self._claim_resource_revision(
session,
model=PostboxTemplate,
resource=template,
resource_type="postbox_template",
tenant_id=tenant_id,
expected_revision=expected_revision,
)
self._validate_scope(
tenant_id=tenant_id,
scope_kind=scope_kind,
scope_id=scope_id,
)
self._validate_patterns(name_pattern, address_pattern)
next_revision = max(
(revision.revision for revision in template.revisions),
default=0,
) + 1
template.revisions.append(
PostboxTemplateRevision(
tenant_id=tenant_id,
revision=next_revision,
function_type_id=function_type_id,
scope_kind=scope_kind,
scope_id=scope_id,
name_pattern=name_pattern,
address_pattern=address_pattern,
classification=classification,
allow_vacant_delivery=allow_vacant_delivery,
encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id,
history_policy={},
routing_policy=normalized_routing_policy(routing_policy),
retention_policy={},
created_by=actor_id,
)
)
template.current_revision = next_revision
template.status = "draft"
template.updated_by = actor_id
session.flush()
_publish_postbox_event(
session,
"postbox.template.revised.v1",
tenant_id=tenant_id,
resource_type="postbox_template",
resource_id=template.id,
actor_type="user",
actor_id=actor_id,
payload={"revision": next_revision, "status": template.status},
)
return template
def publish_template(
self,
session: Session,
*,
tenant_id: str,
template_id: str,
revision_number: int | None,
actor_id: str | None,
expected_revision: int,
) -> PostboxTemplate:
template = self._get_template(
session,
tenant_id=tenant_id,
template_id=template_id,
)
self._claim_resource_revision(
session,
model=PostboxTemplate,
resource=template,
resource_type="postbox_template",
tenant_id=tenant_id,
expected_revision=expected_revision,
)
revision = next(
(
item
for item in template.revisions
if item.revision == (revision_number or template.current_revision)
),
None,
)
if revision is None:
raise PostboxError(
"revision_not_found",
"The requested Postbox template revision does not exist.",
)
if revision.published_at is None:
revision.published_at = utc_now()
template.published_revision_id = revision.id
template.current_revision = revision.revision
template.status = "published"
template.updated_by = actor_id
session.flush()
_publish_postbox_event(
session,
"postbox.template.published.v1",
tenant_id=tenant_id,
resource_type="postbox_template",
resource_id=template.id,
actor_type="user",
actor_id=actor_id,
payload={
"revision": revision.revision,
"revision_id": revision.id,
"status": template.status,
},
)
return template
def retire_template(
self,
session: Session,
*,
tenant_id: str,
template_id: str,
actor_id: str | None,
expected_revision: int,
) -> PostboxTemplate:
template = self._get_template(
session,
tenant_id=tenant_id,
template_id=template_id,
)
self._claim_resource_revision(
session,
model=PostboxTemplate,
resource=template,
resource_type="postbox_template",
tenant_id=tenant_id,
expected_revision=expected_revision,
)
template.status = "retired"
template.retired_at = utc_now()
template.updated_by = actor_id
session.flush()
_publish_postbox_event(
session,
"postbox.template.retired.v1",
tenant_id=tenant_id,
resource_type="postbox_template",
resource_id=template.id,
actor_type="user",
actor_id=actor_id,
payload={"status": template.status},
)
return template
def materialize_template(
self,
session: Session,
*,
tenant_id: str,
template_id: str,
organization_unit_id: str,
function_id: str,
context_key: str | None,
actor_id: str | None,
) -> Postbox:
template = self._get_template(
session,
tenant_id=tenant_id,
template_id=template_id,
)
if template.status != "published" or not template.published_revision_id:
raise PostboxError(
"template_not_published",
"Only a published Postbox template can materialize an address.",
)
revision = next(
(
item
for item in template.revisions
if item.id == template.published_revision_id
),
None,
)
if revision is None:
raise PostboxError(
"revision_not_found",
"The published Postbox template revision is unavailable.",
)
unit, function = self._validate_function_target(
tenant_id=tenant_id,
organization_unit_id=organization_unit_id,
function_id=function_id,
)
if (
revision.function_type_id
and function.function_type_id != revision.function_type_id
):
raise PostboxError(
"function_type_mismatch",
"The selected function does not match the template function type.",
)
if not self._unit_in_scope(
unit,
tenant_id=tenant_id,
scope_kind=revision.scope_kind,
scope_id=revision.scope_id,
):
raise PostboxError(
"unit_out_of_scope",
"The selected organization unit is outside the template scope.",
)
key = self._template_address_key(
template.id,
unit.id,
function.id,
context_key,
)
existing = self._postbox_for_address_key(
session,
tenant_id=tenant_id,
address_key=key,
)
if existing is not None:
return existing
variables = {
"template_slug": template.slug,
"template_name": template.name,
"unit_id": unit.id,
"unit_slug": unit.slug,
"unit_name": unit.name,
"function_id": function.id,
"function_slug": function.slug,
"function_name": function.name,
"context_key": context_key or "",
}
try:
rendered_name = revision.name_pattern.format_map(variables).strip()
rendered_address = (
revision.address_pattern.format_map(variables).strip()
)
except KeyError as exc:
raise PostboxError(
"invalid_template_pattern",
f"Unknown Postbox template variable: {exc.args[0]}",
) from exc
address = (
".".join(
part
for part in (
_slug(rendered_address),
_slug(context_key) if context_key else "",
)
if part
)
+ "@postbox"
)[:500]
return self._create_postbox_records(
session,
tenant_id=tenant_id,
address_key=key,
address=address,
name=rendered_name or f"{unit.name} / {function.name}",
description=template.description,
classification=revision.classification,
organization_unit=unit,
function=function,
context_key=context_key,
template=template,
revision=revision,
source="template",
actor_id=actor_id,
)
def organization_targets(
self,
*,
tenant_id: str,
) -> tuple[dict[str, object], ...]:
units = self._organizations.organization_units_for_tenant(tenant_id)
result: list[dict[str, object]] = []
for unit in units:
if unit.status != "active":
continue
functions = self._organizations.functions_for_organization_unit(
unit.id,
include_subunits=False,
)
result.append(
{
"id": unit.id,
"slug": unit.slug,
"name": unit.name,
"unit_type_id": unit.unit_type_id,
"parent_id": unit.parent_id,
"functions": [
{
"id": function.id,
"slug": function.slug,
"name": function.name,
"function_type_id": function.function_type_id,
"delegable": function.delegable,
"act_in_place_allowed": function.act_in_place_allowed,
}
for function in functions
if function.status == "active"
],
}
)
return tuple(result)
def organization_hierarchy_targets(
self,
*,
tenant_id: str,
) -> tuple[dict[str, object], ...]:
if self._hierarchy is None:
return ()
catalog = self._hierarchy.hierarchy_catalog(tenant_id)
relation_types_by_structure: dict[
str,
list[dict[str, object]],
] = {}
for relation_type in catalog.relation_types:
if not relation_type.structure_id:
continue
relation_types_by_structure.setdefault(
relation_type.structure_id,
[],
).append(
{
"id": relation_type.id,
"slug": relation_type.slug,
"name": relation_type.name,
"structure_id": relation_type.structure_id,
"is_hierarchical": relation_type.is_hierarchical,
"status": relation_type.status,
}
)
return tuple(
{
"id": structure.id,
"slug": structure.slug,
"name": structure.name,
"structure_kind": structure.structure_kind,
"status": structure.status,
"relation_types": relation_types_by_structure.get(
structure.id,
[],
),
}
for structure in catalog.structures
)
# Grouping projections
def list_groupings(
self,
session: Session,
*,
tenant_id: str,
actor: PostboxActorRef,
) -> tuple[PostboxGrouping, ...]:
return tuple(
session.query(PostboxGrouping)
.options(selectinload(PostboxGrouping.sources))
.filter(
PostboxGrouping.tenant_id == tenant_id,
PostboxGrouping.account_id == actor.account_id,
)
.order_by(
PostboxGrouping.is_default.desc(),
PostboxGrouping.name.asc(),
)
.all()
)
def save_grouping(
self,
session: Session,
*,
tenant_id: str,
actor: PostboxActorRef,
grouping_id: str | None,
name: str,
is_default: bool,
postbox_ids: Sequence[str],
expected_revision: int | None = None,
) -> PostboxGrouping:
grouping = (
session.query(PostboxGrouping)
.options(selectinload(PostboxGrouping.sources))
.filter(
PostboxGrouping.id == grouping_id,
PostboxGrouping.tenant_id == tenant_id,
PostboxGrouping.account_id == actor.account_id,
)
.one_or_none()
if grouping_id
else None
)
if grouping_id and grouping is None:
raise PostboxError("grouping_not_found", "Postbox grouping not found.")
if grouping is not None:
self._claim_resource_revision(
session,
model=PostboxGrouping,
resource=grouping,
resource_type="postbox_grouping",
tenant_id=tenant_id,
expected_revision=expected_revision,
account_id=actor.account_id,
)
existing_ids = tuple(
source.postbox_id for source in grouping.sources
) if grouping is not None else ()
requested = tuple(dict.fromkeys(postbox_ids))
visibility_candidates = tuple(dict.fromkeys((*requested, *existing_ids)))
allowed = set(
self._allowed_postbox_ids(
session,
tenant_id=tenant_id,
postbox_ids=visibility_candidates,
actor=actor,
action="read",
)
)
if not set(requested).issubset(allowed):
raise PostboxError(
"grouping_source_denied",
"A grouping can only include Postboxes currently visible to the account.",
)
retained_hidden_ids = tuple(
postbox_id
for postbox_id in existing_ids
if postbox_id not in allowed and postbox_id not in requested
)
saved_ids = (*requested, *retained_hidden_ids)
if grouping is None:
grouping = PostboxGrouping(
tenant_id=tenant_id,
account_id=actor.account_id,
name=name.strip(),
is_default=is_default,
settings={},
)
session.add(grouping)
session.flush()
else:
grouping.name = name.strip()
grouping.is_default = is_default
grouping.sources.clear()
if is_default:
(
session.query(PostboxGrouping)
.filter(
PostboxGrouping.tenant_id == tenant_id,
PostboxGrouping.account_id == actor.account_id,
PostboxGrouping.id != grouping.id,
PostboxGrouping.is_default.is_(True),
)
.update(
{
PostboxGrouping.is_default: False,
PostboxGrouping.resource_revision: (
PostboxGrouping.resource_revision + 1
),
}
)
)
for position, postbox_id in enumerate(saved_ids):
grouping.sources.append(
PostboxGroupingSource(
tenant_id=tenant_id,
postbox_id=postbox_id,
position=position,
)
)
session.flush()
return grouping
def delete_grouping(
self,
session: Session,
*,
tenant_id: str,
actor: PostboxActorRef,
grouping_id: str,
expected_revision: int,
) -> None:
grouping = (
session.query(PostboxGrouping)
.filter(
PostboxGrouping.id == grouping_id,
PostboxGrouping.tenant_id == tenant_id,
PostboxGrouping.account_id == actor.account_id,
)
.one_or_none()
)
if grouping is None:
raise PostboxError("grouping_not_found", "Postbox grouping not found.")
self._claim_resource_revision(
session,
model=PostboxGrouping,
resource=grouping,
resource_type="postbox_grouping",
tenant_id=tenant_id,
expected_revision=expected_revision,
account_id=actor.account_id,
)
session.delete(grouping)
session.flush()
# Internal helpers
def _claim_resource_revision(
self,
session: Session,
*,
model: type[Any],
resource: Any,
resource_type: str,
tenant_id: str,
expected_revision: int | None,
account_id: str | None = None,
) -> int:
if expected_revision is None:
raise MissingPreconditionError(
resource_type=resource_type,
resource_id=resource.id,
)
filters = [
model.id == resource.id,
model.tenant_id == tenant_id,
]
if account_id is not None:
filters.append(model.account_id == account_id)
next_revision = claim_revision(
session,
model=model,
filters=filters,
revision_attribute="resource_revision",
expected_revision=expected_revision,
resource_type=resource_type,
resource_id=resource.id,
)
resource.resource_revision = next_revision
return next_revision
def _get_postbox(
self,
session: Session,
*,
tenant_id: str,
postbox_id: str,
required: bool = True,
) -> Postbox | None:
postbox = (
session.query(Postbox)
.options(
selectinload(Postbox.address_record),
selectinload(Postbox.bindings),
)
.filter(
Postbox.id == postbox_id,
Postbox.tenant_id == tenant_id,
)
.one_or_none()
)
if postbox is None and required:
raise PostboxError("postbox_not_found", "Postbox not found.")
return postbox
def _get_message(
self,
session: Session,
*,
tenant_id: str,
message_id: str,
required: bool = True,
) -> PostboxMessage | None:
message = (
session.query(PostboxMessage)
.options(
selectinload(PostboxMessage.participants),
selectinload(PostboxMessage.attachments),
selectinload(PostboxMessage.receipts),
)
.filter(
PostboxMessage.id == message_id,
PostboxMessage.tenant_id == tenant_id,
)
.one_or_none()
)
if message is None and required:
raise PostboxError("message_not_found", "Postbox message not found.")
return message
def _message_access_decision(
self,
session: Session,
*,
message: PostboxMessage,
actor: PostboxActorRef,
action: PostboxAction,
) -> PostboxAccessDecisionRef:
postbox = self._get_postbox(
session,
tenant_id=message.tenant_id,
postbox_id=message.postbox_id,
)
return self._access_decision(
postbox,
actor=actor,
action=action,
assignments=self._assignments_for_actor(
actor,
tenant_id=message.tenant_id,
),
holder_cache={},
classification=message.classification,
)
def _get_template(
self,
session: Session,
*,
tenant_id: str,
template_id: str,
) -> PostboxTemplate:
template = (
session.query(PostboxTemplate)
.options(selectinload(PostboxTemplate.revisions))
.filter(
PostboxTemplate.id == template_id,
PostboxTemplate.tenant_id == tenant_id,
)
.one_or_none()
)
if template is None:
raise PostboxError(
"template_not_found",
"Postbox template not found.",
)
return template
def _address_revision(
self,
session: Session,
address: PostboxAddress,
) -> PostboxTemplateRevision | None:
if not address.template_revision_id:
return None
return session.get(
PostboxTemplateRevision,
address.template_revision_id,
)
def _assignments_for_actor(
self,
actor: PostboxActorRef,
*,
tenant_id: str,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
assignments = self._idm.organization_function_assignments_for_account(
actor.account_id,
tenant_id=tenant_id,
)
if assignments:
return tuple(assignments)
if actor.identity_id:
return tuple(
self._idm.organization_function_assignments_for_identity(
actor.identity_id,
tenant_id=tenant_id,
)
)
identity = self._identities.identity_for_account(actor.account_id)
if identity is None:
return ()
return tuple(
self._idm.organization_function_assignments_for_identity(
identity.id,
tenant_id=tenant_id,
)
)
def _holders(
self,
tenant_id: str,
function_id: str | None,
cache: dict[
str,
tuple[OrganizationFunctionAssignmentRef, ...],
]
| None = None,
) -> tuple[OrganizationFunctionAssignmentRef, ...]:
if not function_id:
return ()
if cache is not None and function_id in cache:
return cache[function_id]
try:
holders = tuple(
self._incumbencies.organization_function_assignments_for_function(
function_id,
tenant_id=tenant_id,
)
)
except ValueError:
holders = ()
if cache is not None:
cache[function_id] = holders
return holders
def _holder_cache(
self,
*,
tenant_id: str,
postboxes: Sequence[Postbox],
) -> dict[str, tuple[OrganizationFunctionAssignmentRef, ...]]:
discovered: list[str] = []
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
)
if function_id:
discovered.append(function_id)
function_ids = tuple(dict.fromkeys(discovered))
if not function_ids:
return {}
try:
incumbencies = (
self._incumbencies.organization_function_incumbencies(
function_ids,
tenant_id=tenant_id,
)
)
except ValueError:
return {}
return {
function_id: tuple(
incumbencies[function_id].assignments
)
for function_id in function_ids
if function_id in incumbencies
}
def _active_binding(self, postbox: Postbox) -> PostboxBinding | None:
return self._binding_resolution(postbox)[0]
def _binding_resolution(
self,
postbox: Postbox,
) -> tuple[PostboxBinding | None, PostboxBindingStatus]:
now = utc_now()
current: PostboxBinding | None = None
for binding in postbox.bindings:
if not binding.is_active:
continue
if binding.valid_from and binding.valid_from > now:
continue
if binding.valid_until and binding.valid_until <= now:
continue
current = binding
break
if current is None:
return (None, "not_effective" if postbox.bindings else "missing")
if not current.organization_unit_id or not current.function_id:
return current, "missing"
try:
unit = self._organizations.get_organization_unit(
current.organization_unit_id
)
function = self._organizations.get_function(current.function_id)
except Exception:
logger.exception(
"Postbox organization binding resolution failed",
extra={"postbox_id": postbox.id},
)
return current, "directory_unavailable"
if unit is None:
return current, "unit_missing"
if unit.tenant_id != postbox.tenant_id:
return current, "unit_tenant_mismatch"
if unit.status != "active":
return current, "unit_inactive"
if function is None:
return current, "function_missing"
if function.tenant_id != postbox.tenant_id:
return current, "function_tenant_mismatch"
if function.status != "active":
return current, "function_inactive"
if function.organization_unit_id != unit.id:
return current, "function_reassigned"
return current, "active"
def _access_decision(
self,
postbox: Postbox,
*,
actor: PostboxActorRef,
action: PostboxAction,
assignments: Sequence[OrganizationFunctionAssignmentRef],
holder_cache: dict[str, tuple[OrganizationFunctionAssignmentRef, ...]],
classification: str | None = None,
) -> PostboxAccessDecisionRef:
binding, binding_status = self._binding_resolution(postbox)
function_id = (
binding.function_id
if binding is not None
else postbox.address_record.function_id
)
unit_id = (
binding.organization_unit_id
if binding is not None
else postbox.address_record.organization_unit_id
)
holders = self._holders(postbox.tenant_id, function_id, holder_cache)
holder_count = len({holder.identity_id for holder in holders})
binding_assignments: list[OrganizationFunctionAssignmentRef] = []
if binding is not None and binding_status == "active":
for assignment in assignments:
if assignment.tenant_id != postbox.tenant_id:
continue
if not self._assignment_matches_binding(assignment, binding):
continue
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 and binding_status == "active",
binding_assignments=binding_assignments,
binding_status=binding_status,
classification=classification or postbox.classification,
)
def _assignment_matches_binding(
self,
assignment: OrganizationFunctionAssignmentRef,
binding: PostboxBinding,
) -> bool:
if (
assignment.function_id == binding.function_id
and assignment.organization_unit_id == binding.organization_unit_id
):
return True
if not assignment.applies_to_subunits or not binding.function_type_id:
return False
assigned_function = self._organizations.get_function(
assignment.function_id
)
target_function = (
self._organizations.get_function(binding.function_id)
if binding.function_id
else None
)
if (
assigned_function is None
or target_function is None
or assigned_function.function_type_id != binding.function_type_id
or target_function.function_type_id != binding.function_type_id
or not binding.organization_unit_id
):
return False
return self._is_descendant(
binding.organization_unit_id,
assignment.organization_unit_id,
)
def _is_descendant(self, unit_id: str, ancestor_id: str) -> bool:
seen: set[str] = set()
current_id: str | None = unit_id
while current_id and current_id not in seen:
if current_id == ancestor_id:
return True
seen.add(current_id)
unit = self._organizations.get_organization_unit(current_id)
current_id = unit.parent_id if unit is not None else None
return False
def _allowed_postbox_ids(
self,
session: Session,
*,
tenant_id: str,
postbox_ids: Sequence[str],
actor: PostboxActorRef,
action: PostboxAction,
) -> tuple[str, ...]:
requested = tuple(dict.fromkeys(postbox_ids))
if not requested:
return ()
postboxes = (
session.query(Postbox)
.options(
selectinload(Postbox.address_record),
selectinload(Postbox.bindings),
)
.filter(
Postbox.tenant_id == tenant_id,
Postbox.id.in_(requested),
)
.all()
)
assignments = self._assignments_for_actor(actor, tenant_id=tenant_id)
holder_cache: dict[str, tuple[OrganizationFunctionAssignmentRef, ...]] = {}
return tuple(
postbox.id
for postbox in postboxes
if self._access_decision(
postbox,
actor=actor,
action=action,
assignments=assignments,
holder_cache=holder_cache,
).allowed
)
def _directory_entry(
self,
postbox: Postbox,
*,
decision: PostboxAccessDecisionRef | None = None,
holder_cache: dict[
str,
tuple[OrganizationFunctionAssignmentRef, ...],
]
| None = None,
) -> PostboxDirectoryEntryRef:
address = postbox.address_record
holders = self._holders(
postbox.tenant_id,
address.function_id,
holder_cache,
)
holder_count = len({holder.identity_id for holder in holders})
return PostboxDirectoryEntryRef(
id=postbox.id,
tenant_id=postbox.tenant_id,
address=address.address,
address_key=address.address_key,
name=postbox.name,
status=postbox.status,
classification=postbox.classification,
organization_unit_id=address.organization_unit_id,
organization_unit_name=address.organization_unit_name,
function_id=address.function_id,
function_name=address.function_name,
context_key=address.context_key,
template_revision_id=address.template_revision_id,
holder_count=holder_count,
vacant=holder_count == 0,
access=decision,
resource_revision=postbox.resource_revision,
etag=postbox.strong_etag,
)
def _message_ref(
self,
message: PostboxMessage,
*,
account_id: str,
) -> PostboxMessageRef:
receipt = next(
(
item
for item in message.receipts
if item.account_id == account_id
),
None,
)
availability = _message_availability(message)
content_available = availability == "available"
body_text = message.body_text if content_available else None
if (
content_available
and message.encryption_envelope_id
and message.body_ciphertext is not None
):
session = object_session(message)
if session is None:
raise PostboxError(
"encrypted_content_unavailable",
"Encrypted Postbox content requires an attached database session.",
)
from govoplan_postbox.backend.content_protection import (
PostboxContentProtectionError,
unprotect_message_body,
)
try:
body_text = unprotect_message_body(
session,
tenant_id=message.tenant_id,
message_id=message.encryption_resource_id or message.id,
envelope_id=message.encryption_envelope_id,
ciphertext=message.body_ciphertext,
)
except PostboxContentProtectionError as exc:
raise PostboxError(
"encrypted_content_unavailable",
str(exc),
) from exc
return PostboxMessageRef(
id=message.id,
tenant_id=message.tenant_id,
postbox_id=message.postbox_id,
subject=message.subject,
body_text=body_text,
status=message.status,
availability=availability,
classification=message.classification,
sender_label=message.sender_label,
delivered_at=message.delivered_at,
read_at=receipt.read_at if receipt else None,
acknowledged_at=receipt.acknowledged_at if receipt else None,
expires_at=message.expires_at,
withdrawn_at=message.withdrawn_at,
producer_module=message.producer_module,
producer_resource_type=message.producer_resource_type,
producer_resource_id=message.producer_resource_id,
in_reply_to_message_id=message.in_reply_to_message_id,
replaces_message_id=message.replaces_message_id,
encryption_profile=message.encryption_profile,
key_epoch=message.key_epoch,
ciphertext_ref=message.ciphertext_ref,
signed_manifest_ref=message.signed_manifest_ref,
wrapped_keys=tuple(
PostboxWrappedKeyRef(
recipient_type=str(item.get("recipient_type") or "unknown"),
recipient_id=str(item.get("recipient_id") or "unknown"),
key_epoch=int(item.get("key_epoch") or message.key_epoch),
wrapped_key_ref=str(item.get("wrapped_key_ref") or ""),
algorithm=(
str(item["algorithm"]) if item.get("algorithm") else None
),
metadata=_mapping(item.get("metadata")),
)
for item in message.wrapped_keys or []
if isinstance(item, Mapping) and item.get("wrapped_key_ref")
),
external_recipient_tokens=tuple(
PostboxExternalRecipientTokenRef(
token_id=str(item.get("token_id") or ""),
state=str(item.get("state") or "pending"),
expires_at=_optional_datetime(item.get("expires_at")),
one_time=bool(item.get("one_time", False)),
key_fetched_at=_optional_datetime(item.get("key_fetched_at")),
revoked_at=_optional_datetime(item.get("revoked_at")),
assurance_profile=(
str(item["assurance_profile"])
if item.get("assurance_profile")
else None
),
metadata=_mapping(item.get("metadata")),
)
for item in message.external_recipient_tokens or []
if isinstance(item, Mapping) and item.get("token_id")
),
participants=tuple(
PostboxParticipantRef(
kind=item.kind,
reference_type=item.reference_type,
reference_id=item.reference_id,
label=item.label,
address=item.address,
)
for item in message.participants
if content_available
),
attachments=tuple(
PostboxAttachmentRef(
reference_type=item.reference_type,
reference_id=item.reference_id,
name=item.name,
media_type=item.media_type,
size_bytes=item.size_bytes,
digest=item.digest,
metadata=_mapping(item.metadata_),
)
for item in message.attachments
if content_available
),
metadata=_mapping(message.metadata_),
)
def _delivery_result(
self,
delivery: PostboxDelivery,
postbox: Postbox,
*,
duplicate: bool = False,
) -> PostboxDeliveryResult:
session = object_session(delivery)
route_evidence = (
[
self._route_evidence(route)
for route in session.query(PostboxRoute)
.filter(
PostboxRoute.tenant_id == delivery.tenant_id,
PostboxRoute.delivery_id == delivery.id,
)
.order_by(
PostboxRoute.depth,
PostboxRoute.created_at,
PostboxRoute.id,
)
.all()
]
if session is not None
else []
)
return PostboxDeliveryResult(
delivery_id=delivery.id,
postbox_id=delivery.postbox_id,
message_id=delivery.message_id,
address=postbox.address_record.address,
status=delivery.status,
vacant=delivery.holder_count == 0,
holder_count=delivery.holder_count,
duplicate=duplicate,
evidence={
"producer_module": delivery.producer_module,
"producer_resource_type": delivery.producer_resource_type,
"producer_resource_id": delivery.producer_resource_id,
"template_revision_id": delivery.template_revision_id,
"target_snapshot": dict(delivery.target_snapshot or {}),
"accepted_at": delivery.accepted_at.isoformat(),
"hierarchy_routes": route_evidence,
},
)
def _template_address_key(
self,
template_id: str,
organization_unit_id: str,
function_id: str,
context_key: str | None,
) -> str:
context_digest = (
hashlib.sha256(context_key.encode("utf-8")).hexdigest()
if context_key
else "-"
)
return (
f"template:{template_id}:unit:{organization_unit_id}:"
f"function:{function_id}:context:{context_digest}"
)
def _postbox_for_address_key(
self,
session: Session,
*,
tenant_id: str,
address_key: str,
) -> Postbox | None:
return (
session.query(Postbox)
.join(PostboxAddress, Postbox.address_id == PostboxAddress.id)
.options(
selectinload(Postbox.address_record),
selectinload(Postbox.bindings),
)
.filter(
Postbox.tenant_id == tenant_id,
PostboxAddress.address_key == address_key,
)
.one_or_none()
)
def _create_postbox_records(
self,
session: Session,
*,
tenant_id: str,
address_key: str,
address: str,
name: str,
description: str | None,
classification: str,
organization_unit: OrganizationUnitRef,
function: OrganizationFunctionRef,
context_key: str | None,
template: PostboxTemplate | None,
revision: PostboxTemplateRevision | None,
source: str,
actor_id: str | None,
encryption_profile: str | None = None,
encryption_vault_id: str | None = None,
) -> Postbox:
effective_profile = (
revision.encryption_profile
if revision is not None
else str(encryption_profile or "plaintext_v1")
)
effective_vault_id = (
revision.encryption_vault_id
if revision is not None
else encryption_vault_id
)
if effective_profile == "server_envelope_v1" and not str(
effective_vault_id or ""
).strip():
raise PostboxError(
"encryption_vault_missing",
"Server-envelope Postboxes require an encryption vault.",
)
if effective_profile not in {"plaintext_v1", "server_envelope_v1"}:
raise PostboxError(
"unsupported_encryption_profile",
"Unsupported Postbox encryption profile.",
)
address_record = PostboxAddress(
tenant_id=tenant_id,
address_key=address_key,
address=address,
template_id=template.id if template else None,
template_revision_id=revision.id if revision else None,
organization_unit_id=organization_unit.id,
organization_unit_name=organization_unit.name,
function_id=function.id,
function_name=function.name,
function_type_id=function.function_type_id,
context_key=context_key,
status="active",
)
postbox = Postbox(
tenant_id=tenant_id,
address_record=address_record,
name=name,
description=description,
status="active",
classification=classification,
encryption_profile=effective_profile,
key_epoch=1,
settings=(
{"encryption_vault_id": str(effective_vault_id)}
if effective_vault_id
else {}
),
)
postbox.bindings.append(
PostboxBinding(
tenant_id=tenant_id,
binding_type="function",
organization_unit_id=organization_unit.id,
function_id=function.id,
function_type_id=function.function_type_id,
source=source,
is_active=True,
settings={
"template_id": template.id if template else None,
"template_revision_id": revision.id if revision else None,
},
)
)
session.add(postbox)
try:
session.flush()
except IntegrityError as exc:
raise PostboxError(
"address_collision",
"The stable Postbox address collides with an existing address.",
) from exc
self._record_access_event(
session,
tenant_id=tenant_id,
postbox_id=postbox.id,
actor=(
PostboxActorRef(
account_id=actor_id,
authorized_actions=frozenset({"administer"}),
)
if actor_id
else None
),
action="postbox.materialize",
outcome="allowed",
reason_code=source,
details={
"address_key": address_key,
"template_id": template.id if template else None,
"template_revision_id": revision.id if revision else None,
"organization_unit_id": organization_unit.id,
"function_id": function.id,
},
)
_publish_postbox_event(
session,
"postbox.materialized.v1",
tenant_id=tenant_id,
resource_type="postbox",
resource_id=postbox.id,
postbox_id=postbox.id,
actor_type="user",
actor_id=actor_id,
payload={
"source": source,
"template_id": template.id if template else None,
"template_revision_id": revision.id if revision else None,
"organization_unit_id": organization_unit.id,
"function_id": function.id,
},
)
return postbox
def _validate_function_target(
self,
*,
tenant_id: str,
organization_unit_id: str,
function_id: str,
) -> tuple[OrganizationUnitRef, OrganizationFunctionRef]:
unit = self._organizations.get_organization_unit(organization_unit_id)
function = self._organizations.get_function(function_id)
if unit is None or unit.tenant_id != tenant_id:
raise PostboxError(
"organization_unit_not_found",
"Organization unit not found in the active tenant.",
)
if function is None or function.tenant_id != tenant_id:
raise PostboxError(
"function_not_found",
"Organization function not found in the active tenant.",
)
if function.organization_unit_id != unit.id:
raise PostboxError(
"function_unit_mismatch",
"The selected function does not belong to the organization unit.",
)
if unit.status != "active" or function.status != "active":
raise PostboxError(
"organization_target_inactive",
"The selected organization unit or function is inactive.",
)
return unit, function
def _validate_classification(self, classification: str) -> str:
normalized = normalize_postbox_classification(classification)
if normalized is None:
raise PostboxError(
"classification_unsupported",
"Postbox classification must be public, internal, confidential, or restricted.",
)
return normalized
def _validate_scope(
self,
*,
tenant_id: str,
scope_kind: str,
scope_id: str | None,
) -> None:
if scope_kind not in {"tenant", "unit", "subtree", "unit_type"}:
raise PostboxError(
"invalid_scope_kind",
"Postbox template scope must be tenant, unit, subtree, or unit_type.",
)
if scope_kind == "tenant":
if scope_id:
raise PostboxError(
"invalid_scope_id",
"Tenant-scoped Postbox templates do not take a scope id.",
)
return
if not scope_id:
raise PostboxError(
"scope_id_required",
"This Postbox template scope requires a scope id.",
)
if scope_kind in {"unit", "subtree"}:
unit = self._organizations.get_organization_unit(scope_id)
if unit is None or unit.tenant_id != tenant_id:
raise PostboxError(
"scope_unit_not_found",
"The Postbox template scope unit was not found.",
)
def _unit_in_scope(
self,
unit: OrganizationUnitRef,
*,
tenant_id: str,
scope_kind: str,
scope_id: str | None,
) -> bool:
if unit.tenant_id != tenant_id:
return False
if scope_kind == "tenant":
return True
if scope_kind == "unit":
return unit.id == scope_id
if scope_kind == "subtree" and scope_id:
return self._is_descendant(unit.id, scope_id)
if scope_kind == "unit_type":
return unit.unit_type_id == scope_id
return False
def _validate_patterns(
self,
name_pattern: str,
address_pattern: str,
) -> None:
variables = {
"template_slug": "template",
"template_name": "Template",
"unit_id": "unit-id",
"unit_slug": "unit",
"unit_name": "Unit",
"function_id": "function-id",
"function_slug": "function",
"function_name": "Function",
"context_key": "context",
}
try:
if not name_pattern.format_map(variables).strip():
raise ValueError("empty name")
if not address_pattern.format_map(variables).strip():
raise ValueError("empty address")
except (KeyError, ValueError) as exc:
raise PostboxError(
"invalid_template_pattern",
f"Invalid Postbox template pattern: {exc}",
) from exc
def _record_access_event(
self,
session: Session,
*,
tenant_id: str,
action: str,
outcome: str,
reason_code: str,
actor: PostboxActorRef | None,
postbox_id: str | None = None,
message_id: str | None = None,
assignment_id: str | None = None,
details: Mapping[str, object] | None = None,
) -> None:
session.add(
PostboxAccessEvent(
tenant_id=tenant_id,
postbox_id=postbox_id,
message_id=message_id,
account_id=actor.account_id if actor else None,
identity_id=actor.identity_id if actor else None,
assignment_id=assignment_id,
action=action,
outcome=outcome,
reason_code=reason_code,
occurred_at=utc_now(),
details=_mapping(details),
)
)
__all__ = ["PostboxError", "PostboxService"]