feat: initialize governed postbox module

This commit is contained in:
2026-07-29 14:16:29 +02:00
parent 19216e5043
commit d848a9a503
31 changed files with 9776 additions and 1 deletions

View File

@@ -0,0 +1 @@
"""Backend implementation for GovOPlaN Postbox."""

View File

@@ -0,0 +1,33 @@
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAccessEvent,
PostboxAddress,
PostboxAttachmentReference,
PostboxBinding,
PostboxDelivery,
PostboxGrouping,
PostboxGroupingSource,
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
)
__all__ = [
"Postbox",
"PostboxAccessEvent",
"PostboxAddress",
"PostboxAttachmentReference",
"PostboxBinding",
"PostboxDelivery",
"PostboxGrouping",
"PostboxGroupingSource",
"PostboxMessage",
"PostboxMessageReceipt",
"PostboxParticipant",
"PostboxRoute",
"PostboxTemplate",
"PostboxTemplateRevision",
]

View File

@@ -0,0 +1,873 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class PostboxTemplate(Base, TimestampMixin):
__tablename__ = "postbox_templates"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"slug",
name="uq_postbox_templates_tenant_slug",
),
Index("ix_postbox_templates_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(120), nullable=False)
name: Mapped[str] = mapped_column(String(250), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(24),
default="draft",
nullable=False,
index=True,
)
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
published_revision_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
retired_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
revisions: Mapped[list["PostboxTemplateRevision"]] = relationship(
back_populates="template",
cascade="all, delete-orphan",
order_by="PostboxTemplateRevision.revision",
)
class PostboxTemplateRevision(Base, TimestampMixin):
__tablename__ = "postbox_template_revisions"
__table_args__ = (
UniqueConstraint(
"template_id",
"revision",
name="uq_postbox_template_revision_number",
),
Index(
"ix_postbox_template_revisions_function_scope",
"tenant_id",
"function_type_id",
"scope_kind",
"scope_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
template_id: Mapped[str] = mapped_column(
ForeignKey("postbox_templates.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
function_type_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
scope_kind: Mapped[str] = mapped_column(
String(30),
default="tenant",
nullable=False,
)
scope_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
name_pattern: Mapped[str] = mapped_column(
String(500),
default="{unit_name} / {function_name}",
nullable=False,
)
address_pattern: Mapped[str] = mapped_column(
String(500),
default="{template_slug}.{unit_slug}.{function_slug}",
nullable=False,
)
classification: Mapped[str] = mapped_column(
String(50),
default="internal",
nullable=False,
)
allow_vacant_delivery: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
)
encryption_profile: Mapped[str] = mapped_column(
String(80),
default="plaintext_v1",
nullable=False,
)
history_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
routing_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
retention_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
template: Mapped[PostboxTemplate] = relationship(back_populates="revisions")
class PostboxAddress(Base, TimestampMixin):
__tablename__ = "postbox_addresses"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"address_key",
name="uq_postbox_addresses_tenant_key",
),
UniqueConstraint(
"tenant_id",
"address",
name="uq_postbox_addresses_tenant_address",
),
Index(
"ix_postbox_addresses_function_scope",
"tenant_id",
"organization_unit_id",
"function_id",
"context_key",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
address_key: Mapped[str] = mapped_column(String(500), nullable=False)
address: Mapped[str] = mapped_column(String(500), nullable=False)
template_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_templates.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
template_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_template_revisions.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
organization_unit_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
organization_unit_name: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
)
function_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
function_name: Mapped[str | None] = mapped_column(String(500), nullable=True)
function_type_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
context_key: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(
String(24),
default="active",
nullable=False,
index=True,
)
postbox: Mapped["Postbox | None"] = relationship(
back_populates="address_record",
uselist=False,
)
class Postbox(Base, TimestampMixin):
__tablename__ = "postboxes"
__table_args__ = (
UniqueConstraint("address_id", name="uq_postboxes_address"),
Index("ix_postboxes_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
address_id: Mapped[str] = mapped_column(
ForeignKey("postbox_addresses.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(24),
default="active",
nullable=False,
index=True,
)
classification: Mapped[str] = mapped_column(
String(50),
default="internal",
nullable=False,
index=True,
)
encryption_profile: Mapped[str] = mapped_column(
String(80),
default="plaintext_v1",
nullable=False,
)
key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
address_record: Mapped[PostboxAddress] = relationship(
back_populates="postbox",
)
bindings: Mapped[list["PostboxBinding"]] = relationship(
back_populates="postbox",
cascade="all, delete-orphan",
)
messages: Mapped[list["PostboxMessage"]] = relationship(
back_populates="postbox",
cascade="all, delete-orphan",
)
class PostboxBinding(Base, TimestampMixin):
__tablename__ = "postbox_bindings"
__table_args__ = (
Index(
"ix_postbox_bindings_function_scope",
"tenant_id",
"organization_unit_id",
"function_id",
"is_active",
),
Index("ix_postbox_bindings_postbox_active", "postbox_id", "is_active"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
postbox_id: Mapped[str] = mapped_column(
ForeignKey("postboxes.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
binding_type: Mapped[str] = mapped_column(
String(30),
default="function",
nullable=False,
)
organization_unit_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
function_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
function_type_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
applies_to_subunits: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
)
source: Mapped[str] = mapped_column(
String(30),
default="exact",
nullable=False,
)
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
index=True,
)
valid_from: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
valid_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
settings: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
postbox: Mapped[Postbox] = relationship(back_populates="bindings")
class PostboxMessage(Base, TimestampMixin):
__tablename__ = "postbox_messages"
__table_args__ = (
Index(
"ix_postbox_messages_postbox_delivered",
"postbox_id",
"delivered_at",
),
Index("ix_postbox_messages_tenant_status", "tenant_id", "status"),
Index(
"ix_postbox_messages_producer",
"tenant_id",
"producer_module",
"producer_resource_type",
"producer_resource_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
postbox_id: Mapped[str] = mapped_column(
ForeignKey("postboxes.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
subject: Mapped[str] = mapped_column(String(1000), nullable=False)
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(30),
default="delivered",
nullable=False,
index=True,
)
classification: Mapped[str] = mapped_column(
String(50),
default="internal",
nullable=False,
index=True,
)
sender_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
producer_module: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
index=True,
)
producer_resource_type: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
index=True,
)
producer_resource_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
in_reply_to_message_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
replaces_message_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
encryption_profile: Mapped[str] = mapped_column(
String(80),
default="plaintext_v1",
nullable=False,
)
key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
ciphertext_ref: Mapped[str | None] = mapped_column(
String(1000),
nullable=True,
)
signed_manifest_ref: Mapped[str | None] = mapped_column(
String(1000),
nullable=True,
)
wrapped_keys: Mapped[list[dict[str, Any]]] = mapped_column(
JSON,
default=list,
nullable=False,
)
delivered_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True,
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
withdrawn_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
retention_hold_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
postbox: Mapped[Postbox] = relationship(back_populates="messages")
participants: Mapped[list["PostboxParticipant"]] = relationship(
back_populates="message",
cascade="all, delete-orphan",
order_by="PostboxParticipant.position",
)
attachments: Mapped[list["PostboxAttachmentReference"]] = relationship(
back_populates="message",
cascade="all, delete-orphan",
order_by="PostboxAttachmentReference.position",
)
receipts: Mapped[list["PostboxMessageReceipt"]] = relationship(
back_populates="message",
cascade="all, delete-orphan",
)
class PostboxParticipant(Base, TimestampMixin):
__tablename__ = "postbox_participants"
__table_args__ = (
Index("ix_postbox_participants_message_kind", "message_id", "kind"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
kind: Mapped[str] = mapped_column(String(30), nullable=False)
reference_type: Mapped[str] = mapped_column(String(50), nullable=False)
reference_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
label: Mapped[str | None] = mapped_column(String(500), nullable=True)
address: Mapped[str | None] = mapped_column(String(500), nullable=True)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
message: Mapped[PostboxMessage] = relationship(back_populates="participants")
class PostboxAttachmentReference(Base, TimestampMixin):
__tablename__ = "postbox_attachment_references"
__table_args__ = (
Index(
"ix_postbox_attachment_references_target",
"tenant_id",
"reference_type",
"reference_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
reference_type: Mapped[str] = mapped_column(String(50), nullable=False)
reference_id: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str | None] = mapped_column(String(1000), nullable=True)
media_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
digest: Mapped[str | None] = mapped_column(String(255), nullable=True)
ciphertext_ref: Mapped[str | None] = mapped_column(
String(1000),
nullable=True,
)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
message: Mapped[PostboxMessage] = relationship(back_populates="attachments")
class PostboxDelivery(Base, TimestampMixin):
__tablename__ = "postbox_deliveries"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"producer_module",
"idempotency_key",
name="uq_postbox_deliveries_producer_idempotency",
),
Index("ix_postbox_deliveries_postbox_status", "postbox_id", "status"),
Index(
"ix_postbox_deliveries_producer",
"tenant_id",
"producer_module",
"producer_resource_type",
"producer_resource_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
postbox_id: Mapped[str] = mapped_column(
ForeignKey("postboxes.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
producer_module: Mapped[str] = mapped_column(String(100), nullable=False)
producer_resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
producer_resource_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(
String(40),
default="accepted",
nullable=False,
index=True,
)
template_revision_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
organization_unit_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
function_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
holder_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
target_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
accepted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True,
)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
class PostboxRoute(Base, TimestampMixin):
__tablename__ = "postbox_routes"
__table_args__ = (
Index("ix_postbox_routes_delivery_kind", "delivery_id", "route_kind"),
Index("ix_postbox_routes_target", "tenant_id", "target_postbox_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
delivery_id: Mapped[str] = mapped_column(
ForeignKey("postbox_deliveries.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
source_postbox_id: Mapped[str] = mapped_column(
ForeignKey("postboxes.id", ondelete="RESTRICT"),
nullable=False,
)
source_message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="RESTRICT"),
nullable=False,
)
target_postbox_id: Mapped[str | None] = mapped_column(
ForeignKey("postboxes.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
target_message_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
nullable=True,
)
route_kind: Mapped[str] = mapped_column(String(40), nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False)
depth: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
source_route_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_routes.id", ondelete="SET NULL"),
nullable=True,
)
policy_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
class PostboxMessageReceipt(Base, TimestampMixin):
__tablename__ = "postbox_message_receipts"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"message_id",
"account_id",
name="uq_postbox_receipts_message_account",
),
Index(
"ix_postbox_receipts_account_state",
"tenant_id",
"account_id",
"read_at",
"acknowledged_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
identity_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
assignment_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
acknowledged_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
message: Mapped[PostboxMessage] = relationship(back_populates="receipts")
class PostboxGrouping(Base, TimestampMixin):
__tablename__ = "postbox_groupings"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"account_id",
"name",
name="uq_postbox_groupings_account_name",
),
Index("ix_postbox_groupings_account", "tenant_id", "account_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(250), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
settings: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
sources: Mapped[list["PostboxGroupingSource"]] = relationship(
back_populates="grouping",
cascade="all, delete-orphan",
order_by="PostboxGroupingSource.position",
)
class PostboxGroupingSource(Base, TimestampMixin):
__tablename__ = "postbox_grouping_sources"
__table_args__ = (
UniqueConstraint(
"grouping_id",
"postbox_id",
name="uq_postbox_grouping_sources_postbox",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
grouping_id: Mapped[str] = mapped_column(
ForeignKey("postbox_groupings.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
postbox_id: Mapped[str] = mapped_column(
ForeignKey("postboxes.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
grouping: Mapped[PostboxGrouping] = relationship(back_populates="sources")
class PostboxAccessEvent(Base, TimestampMixin):
__tablename__ = "postbox_access_events"
__table_args__ = (
Index(
"ix_postbox_access_events_resource",
"tenant_id",
"postbox_id",
"message_id",
"occurred_at",
),
Index(
"ix_postbox_access_events_actor",
"tenant_id",
"account_id",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
postbox_id: Mapped[str | None] = mapped_column(
ForeignKey("postboxes.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
message_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
account_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
identity_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
assignment_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
action: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
outcome: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
reason_code: Mapped[str] = mapped_column(String(80), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True,
)
details: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
__all__ = [
"Postbox",
"PostboxAccessEvent",
"PostboxAddress",
"PostboxAttachmentReference",
"PostboxBinding",
"PostboxDelivery",
"PostboxGrouping",
"PostboxGroupingSource",
"PostboxMessage",
"PostboxMessageReceipt",
"PostboxParticipant",
"PostboxRoute",
"PostboxTemplate",
"PostboxTemplateRevision",
"new_uuid",
]

View File

@@ -0,0 +1,359 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY
from govoplan_core.core.idm import (
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.organizations import CAPABILITY_ORGANIZATION_DIRECTORY
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
)
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_postbox.backend.db import models as postbox_models
MODULE_ID = "postbox"
MODULE_NAME = "Postbox"
MODULE_VERSION = "0.1.1"
READ_SCOPE = "postbox:postbox:read"
SEND_SCOPE = "postbox:message:write"
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
DELIVERY_SCOPE = "postbox:delivery:write"
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
TEMPLATE_ADMIN_SCOPE = "postbox:template:admin"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Postbox",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
PERMISSIONS = (
_permission(
READ_SCOPE,
"View assigned postboxes",
"Discover and read postboxes for currently effective organization-function assignments.",
),
_permission(
SEND_SCOPE,
"Send through assigned postboxes",
"Create replies and new messages in postboxes available through the current function context.",
),
_permission(
ACKNOWLEDGE_SCOPE,
"Acknowledge postbox messages",
"Record personal read and acknowledgement state for accessible messages.",
),
_permission(
DELIVERY_SCOPE,
"Deliver to postboxes",
"Accept idempotent deliveries from approved platform producers.",
),
_permission(
BINDING_ADMIN_SCOPE,
"Administer postbox bindings",
"Create, archive, and inspect exact organization-function postboxes and bindings.",
),
_permission(
TEMPLATE_ADMIN_SCOPE,
"Administer postbox templates",
"Create, revise, publish, and retire reusable function-scoped postbox templates.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="postbox_user",
name="Postbox user",
description="Use postboxes available through current function assignments.",
permissions=(READ_SCOPE, SEND_SCOPE, ACKNOWLEDGE_SCOPE),
default_authenticated=True,
),
RoleTemplate(
slug="postbox_manager",
name="Postbox manager",
description="Administer postbox templates and concrete function-bound containers.",
permissions=(
READ_SCOPE,
SEND_SCOPE,
ACKNOWLEDGE_SCOPE,
DELIVERY_SCOPE,
BINDING_ADMIN_SCOPE,
TEMPLATE_ADMIN_SCOPE,
),
),
)
def _configure(context: ModuleContext):
from govoplan_postbox.backend.runtime import configure_runtime, get_service
configure_runtime(registry=context.registry)
return get_service()
def _router(context: ModuleContext):
_configure(context)
from govoplan_postbox.backend.router import router
return router
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"postboxes": session.query(postbox_models.Postbox)
.filter(
postbox_models.Postbox.tenant_id == tenant_id,
postbox_models.Postbox.status == "active",
)
.count(),
"postbox_messages": session.query(postbox_models.PostboxMessage)
.filter(postbox_models.PostboxMessage.tenant_id == tenant_id)
.count(),
"vacant_deliveries": session.query(postbox_models.PostboxDelivery)
.filter(
postbox_models.PostboxDelivery.tenant_id == tenant_id,
postbox_models.PostboxDelivery.status == "accepted_vacant",
)
.count(),
}
_OWNED_TABLES = (
postbox_models.PostboxAccessEvent,
postbox_models.PostboxGroupingSource,
postbox_models.PostboxGrouping,
postbox_models.PostboxMessageReceipt,
postbox_models.PostboxRoute,
postbox_models.PostboxDelivery,
postbox_models.PostboxAttachmentReference,
postbox_models.PostboxParticipant,
postbox_models.PostboxMessage,
postbox_models.PostboxBinding,
postbox_models.Postbox,
postbox_models.PostboxAddress,
postbox_models.PostboxTemplateRevision,
postbox_models.PostboxTemplate,
)
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("identity", "organizations", "idm"),
optional_dependencies=(
"access",
"audit",
"campaigns",
"files",
"mail",
"notifications",
"policy",
"portal",
"views",
"workflow",
),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_IDENTITY_DIRECTORY,
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
CAPABILITY_ORGANIZATION_DIRECTORY,
),
provides_interfaces=tuple(
ModuleInterfaceProvider(name=name, version=MODULE_VERSION)
for name in (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
)
),
requires_interfaces=(
ModuleInterfaceRequirement(
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
version_min="0.1.8",
version_max_exclusive="0.2.0",
),
ModuleInterfaceRequirement(
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
version_min="0.1.8",
version_max_exclusive="0.2.0",
optional=True,
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
nav_items=(
NavItem(
path="/postbox",
label="Postbox",
icon="inbox",
required_any=(READ_SCOPE,),
order=58,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/postbox-webui",
routes=(
FrontendRoute(
path="/postbox",
component="PostboxPage",
required_any=(READ_SCOPE,),
order=58,
),
),
nav_items=(
NavItem(
path="/postbox",
label="Postbox",
icon="inbox",
required_any=(READ_SCOPE,),
order=58,
),
),
view_surfaces=(
ViewSurface(
id="postbox.inbox.directory",
module_id=MODULE_ID,
kind="section",
label="Postbox directory",
order=10,
),
ViewSurface(
id="postbox.inbox.messages",
module_id=MODULE_ID,
kind="section",
label="Postbox messages",
order=20,
),
ViewSurface(
id="postbox.widget.inbox",
module_id=MODULE_ID,
kind="section",
label="Postbox inbox widget",
order=25,
),
ViewSurface(
id="postbox.admin.templates",
module_id=MODULE_ID,
kind="section",
label="Postbox templates and bindings",
order=30,
),
),
),
route_factory=_router,
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
*_OWNED_TABLES,
label="Postbox",
),
retirement_notes=(
"Destructive retirement removes Postbox templates, addresses, "
"messages, delivery evidence, receipts, groupings, and access events "
"after the installer captures a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
postbox_models.Postbox,
postbox_models.PostboxMessage,
postbox_models.PostboxDelivery,
label="Postbox",
),
),
capability_factories={
CAPABILITY_POSTBOX_DIRECTORY: _configure,
CAPABILITY_POSTBOX_ACCESS: _configure,
CAPABILITY_POSTBOX_MESSAGES: _configure,
CAPABILITY_POSTBOX_DELIVERY: _configure,
CAPABILITY_POSTBOX_EVIDENCE: _configure,
},
documentation=(
DocumentationTopic(
id="postbox.function-bound-containers",
title="Function-bound Postboxes",
summary=(
"Durable institutional message containers whose access follows "
"effective organization-function assignments."
),
body=(
"Postboxes belong to responsibilities, not individual accounts. "
"A stable postbox remains addressable during vacancy and "
"reassignment. Current access combines a generic Postbox "
"permission with effective IDM assignment context. Templates "
"can lazily materialize unit-specific addresses, while exact "
"postboxes cover exceptional responsibilities. The first "
"implementation persists plaintext but reserves explicit "
"ciphertext, signed-manifest, and key-epoch fields; it does not "
"claim end-to-end encryption until a trusted policy profile is selected."
),
layer="available",
documentation_types=("admin", "user"),
audience=("administrator", "user", "campaign_manager"),
related_modules=(
"identity",
"idm",
"organizations",
"campaigns",
"files",
"notifications",
),
order=35,
),
),
)
def get_manifest() -> ModuleManifest:
return manifest

View File

@@ -0,0 +1 @@
"""Postbox-owned Alembic migrations."""

View File

@@ -0,0 +1 @@
"""Postbox release migration lineage."""

View File

@@ -0,0 +1,798 @@
"""v0.1.0 Postbox baseline
Revision ID: c7d2e5f8a1b4
Revises: None
Depends on: 8f9a0b1c2d3e
Create Date: 2026-07-28 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c7d2e5f8a1b4"
down_revision = None
branch_labels = None
depends_on = "8f9a0b1c2d3e"
def _timestamps() -> tuple[sa.Column, sa.Column]:
return (
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
def upgrade() -> None:
op.create_table(
"postbox_templates",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=120), nullable=False),
sa.Column("name", sa.String(length=250), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False),
sa.Column("current_revision", sa.Integer(), nullable=False),
sa.Column("published_revision_id", sa.String(length=36), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
*_timestamps(),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_templates")),
sa.UniqueConstraint(
"tenant_id",
"slug",
name="uq_postbox_templates_tenant_slug",
),
)
op.create_index(
op.f("ix_postbox_templates_tenant_id"),
"postbox_templates",
["tenant_id"],
)
op.create_index(
op.f("ix_postbox_templates_status"),
"postbox_templates",
["status"],
)
op.create_index(
op.f("ix_postbox_templates_published_revision_id"),
"postbox_templates",
["published_revision_id"],
)
op.create_index(
"ix_postbox_templates_tenant_status",
"postbox_templates",
["tenant_id", "status"],
)
op.create_table(
"postbox_template_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("template_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("function_type_id", sa.String(length=36), nullable=True),
sa.Column("scope_kind", sa.String(length=30), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("name_pattern", sa.String(length=500), nullable=False),
sa.Column("address_pattern", sa.String(length=500), nullable=False),
sa.Column("classification", sa.String(length=50), nullable=False),
sa.Column("allow_vacant_delivery", sa.Boolean(), nullable=False),
sa.Column("encryption_profile", sa.String(length=80), nullable=False),
sa.Column("history_policy", sa.JSON(), nullable=False),
sa.Column("routing_policy", sa.JSON(), nullable=False),
sa.Column("retention_policy", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
*_timestamps(),
sa.ForeignKeyConstraint(
["template_id"],
["postbox_templates.id"],
name=op.f(
"fk_postbox_template_revisions_template_id_postbox_templates"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_postbox_template_revisions"),
),
sa.UniqueConstraint(
"template_id",
"revision",
name="uq_postbox_template_revision_number",
),
)
for column in (
"tenant_id",
"template_id",
"function_type_id",
"scope_id",
"published_at",
):
op.create_index(
op.f(f"ix_postbox_template_revisions_{column}"),
"postbox_template_revisions",
[column],
)
op.create_index(
"ix_postbox_template_revisions_function_scope",
"postbox_template_revisions",
["tenant_id", "function_type_id", "scope_kind", "scope_id"],
)
op.create_table(
"postbox_addresses",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("address_key", sa.String(length=500), nullable=False),
sa.Column("address", sa.String(length=500), nullable=False),
sa.Column("template_id", sa.String(length=36), nullable=True),
sa.Column("template_revision_id", sa.String(length=36), nullable=True),
sa.Column("organization_unit_id", sa.String(length=36), nullable=True),
sa.Column("organization_unit_name", sa.String(length=500), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=True),
sa.Column("function_name", sa.String(length=500), nullable=True),
sa.Column("function_type_id", sa.String(length=36), nullable=True),
sa.Column("context_key", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["template_id"],
["postbox_templates.id"],
name=op.f("fk_postbox_addresses_template_id_postbox_templates"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["template_revision_id"],
["postbox_template_revisions.id"],
name=op.f(
"fk_postbox_addresses_template_revision_id_postbox_template_revisions"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_addresses")),
sa.UniqueConstraint(
"tenant_id",
"address_key",
name="uq_postbox_addresses_tenant_key",
),
sa.UniqueConstraint(
"tenant_id",
"address",
name="uq_postbox_addresses_tenant_address",
),
)
for column in (
"tenant_id",
"template_id",
"template_revision_id",
"organization_unit_id",
"function_id",
"function_type_id",
"context_key",
"status",
):
op.create_index(
op.f(f"ix_postbox_addresses_{column}"),
"postbox_addresses",
[column],
)
op.create_index(
"ix_postbox_addresses_function_scope",
"postbox_addresses",
[
"tenant_id",
"organization_unit_id",
"function_id",
"context_key",
],
)
op.create_table(
"postboxes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("address_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False),
sa.Column("classification", sa.String(length=50), nullable=False),
sa.Column("encryption_profile", sa.String(length=80), nullable=False),
sa.Column("key_epoch", sa.Integer(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
*_timestamps(),
sa.ForeignKeyConstraint(
["address_id"],
["postbox_addresses.id"],
name=op.f("fk_postboxes_address_id_postbox_addresses"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postboxes")),
sa.UniqueConstraint("address_id", name="uq_postboxes_address"),
)
for column in ("tenant_id", "address_id", "status", "classification"):
op.create_index(
op.f(f"ix_postboxes_{column}"),
"postboxes",
[column],
)
op.create_index(
"ix_postboxes_tenant_status",
"postboxes",
["tenant_id", "status"],
)
op.create_table(
"postbox_bindings",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("postbox_id", sa.String(length=36), nullable=False),
sa.Column("binding_type", sa.String(length=30), nullable=False),
sa.Column("organization_unit_id", sa.String(length=36), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=True),
sa.Column("function_type_id", sa.String(length=36), nullable=True),
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
sa.Column("source", sa.String(length=30), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("settings", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_bindings_postbox_id_postboxes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_bindings")),
)
for column in (
"tenant_id",
"postbox_id",
"organization_unit_id",
"function_id",
"function_type_id",
"is_active",
):
op.create_index(
op.f(f"ix_postbox_bindings_{column}"),
"postbox_bindings",
[column],
)
op.create_index(
"ix_postbox_bindings_function_scope",
"postbox_bindings",
[
"tenant_id",
"organization_unit_id",
"function_id",
"is_active",
],
)
op.create_index(
"ix_postbox_bindings_postbox_active",
"postbox_bindings",
["postbox_id", "is_active"],
)
op.create_table(
"postbox_messages",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("postbox_id", sa.String(length=36), nullable=False),
sa.Column("subject", sa.String(length=1000), nullable=False),
sa.Column("body_text", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("classification", sa.String(length=50), nullable=False),
sa.Column("sender_label", sa.String(length=500), nullable=True),
sa.Column("producer_module", sa.String(length=100), nullable=True),
sa.Column("producer_resource_type", sa.String(length=100), nullable=True),
sa.Column("producer_resource_id", sa.String(length=255), nullable=True),
sa.Column("in_reply_to_message_id", sa.String(length=36), nullable=True),
sa.Column("replaces_message_id", sa.String(length=36), nullable=True),
sa.Column("encryption_profile", sa.String(length=80), nullable=False),
sa.Column("key_epoch", sa.Integer(), nullable=False),
sa.Column("ciphertext_ref", sa.String(length=1000), nullable=True),
sa.Column("signed_manifest_ref", sa.String(length=1000), nullable=True),
sa.Column("wrapped_keys", sa.JSON(), nullable=False),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("withdrawn_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"retention_hold_until",
sa.DateTime(timezone=True),
nullable=True,
),
sa.Column("metadata", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_messages_postbox_id_postboxes"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["in_reply_to_message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_messages_in_reply_to_message_id_postbox_messages"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["replaces_message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_messages_replaces_message_id_postbox_messages"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_messages")),
)
for column in (
"tenant_id",
"postbox_id",
"status",
"classification",
"producer_module",
"producer_resource_type",
"producer_resource_id",
"in_reply_to_message_id",
"replaces_message_id",
"delivered_at",
"expires_at",
"withdrawn_at",
):
op.create_index(
op.f(f"ix_postbox_messages_{column}"),
"postbox_messages",
[column],
)
op.create_index(
"ix_postbox_messages_postbox_delivered",
"postbox_messages",
["postbox_id", "delivered_at"],
)
op.create_index(
"ix_postbox_messages_tenant_status",
"postbox_messages",
["tenant_id", "status"],
)
op.create_index(
"ix_postbox_messages_producer",
"postbox_messages",
[
"tenant_id",
"producer_module",
"producer_resource_type",
"producer_resource_id",
],
)
op.create_table(
"postbox_participants",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("message_id", sa.String(length=36), nullable=False),
sa.Column("kind", sa.String(length=30), nullable=False),
sa.Column("reference_type", sa.String(length=50), nullable=False),
sa.Column("reference_id", sa.String(length=255), nullable=True),
sa.Column("label", sa.String(length=500), nullable=True),
sa.Column("address", sa.String(length=500), nullable=True),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_participants_message_id_postbox_messages"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_participants")),
)
for column in ("tenant_id", "message_id", "reference_id"):
op.create_index(
op.f(f"ix_postbox_participants_{column}"),
"postbox_participants",
[column],
)
op.create_index(
"ix_postbox_participants_message_kind",
"postbox_participants",
["message_id", "kind"],
)
op.create_table(
"postbox_attachment_references",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("message_id", sa.String(length=36), nullable=False),
sa.Column("reference_type", sa.String(length=50), nullable=False),
sa.Column("reference_id", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=1000), nullable=True),
sa.Column("media_type", sa.String(length=255), nullable=True),
sa.Column("size_bytes", sa.Integer(), nullable=True),
sa.Column("digest", sa.String(length=255), nullable=True),
sa.Column("ciphertext_ref", sa.String(length=1000), nullable=True),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_attachment_references_message_id_postbox_messages"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_postbox_attachment_references"),
),
)
for column in ("tenant_id", "message_id"):
op.create_index(
op.f(f"ix_postbox_attachment_references_{column}"),
"postbox_attachment_references",
[column],
)
op.create_index(
"ix_postbox_attachment_references_target",
"postbox_attachment_references",
["tenant_id", "reference_type", "reference_id"],
)
op.create_table(
"postbox_deliveries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("postbox_id", sa.String(length=36), nullable=False),
sa.Column("message_id", sa.String(length=36), nullable=False),
sa.Column("producer_module", sa.String(length=100), nullable=False),
sa.Column("producer_resource_type", sa.String(length=100), nullable=False),
sa.Column("producer_resource_id", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("template_revision_id", sa.String(length=36), nullable=True),
sa.Column("organization_unit_id", sa.String(length=36), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=True),
sa.Column("holder_count", sa.Integer(), nullable=False),
sa.Column("target_snapshot", sa.JSON(), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_deliveries_postbox_id_postboxes"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_deliveries_message_id_postbox_messages"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_deliveries")),
sa.UniqueConstraint(
"tenant_id",
"producer_module",
"idempotency_key",
name="uq_postbox_deliveries_producer_idempotency",
),
)
for column in (
"tenant_id",
"postbox_id",
"message_id",
"producer_resource_id",
"status",
"template_revision_id",
"organization_unit_id",
"function_id",
"accepted_at",
):
op.create_index(
op.f(f"ix_postbox_deliveries_{column}"),
"postbox_deliveries",
[column],
)
op.create_index(
"ix_postbox_deliveries_postbox_status",
"postbox_deliveries",
["postbox_id", "status"],
)
op.create_index(
"ix_postbox_deliveries_producer",
"postbox_deliveries",
[
"tenant_id",
"producer_module",
"producer_resource_type",
"producer_resource_id",
],
)
op.create_table(
"postbox_routes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("delivery_id", sa.String(length=36), nullable=False),
sa.Column("source_postbox_id", sa.String(length=36), nullable=False),
sa.Column("source_message_id", sa.String(length=36), nullable=False),
sa.Column("target_postbox_id", sa.String(length=36), nullable=True),
sa.Column("target_message_id", sa.String(length=36), nullable=True),
sa.Column("route_kind", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("depth", sa.Integer(), nullable=False),
sa.Column("source_route_id", sa.String(length=36), nullable=True),
sa.Column("policy_snapshot", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["delivery_id"],
["postbox_deliveries.id"],
name=op.f("fk_postbox_routes_delivery_id_postbox_deliveries"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["source_postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_routes_source_postbox_id_postboxes"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["source_message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_routes_source_message_id_postbox_messages"
),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["target_postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_routes_target_postbox_id_postboxes"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["target_message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_routes_target_message_id_postbox_messages"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["source_route_id"],
["postbox_routes.id"],
name=op.f("fk_postbox_routes_source_route_id_postbox_routes"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_routes")),
)
for column in ("tenant_id", "delivery_id", "target_postbox_id"):
op.create_index(
op.f(f"ix_postbox_routes_{column}"),
"postbox_routes",
[column],
)
op.create_index(
"ix_postbox_routes_delivery_kind",
"postbox_routes",
["delivery_id", "route_kind"],
)
op.create_index(
"ix_postbox_routes_target",
"postbox_routes",
["tenant_id", "target_postbox_id"],
)
op.create_table(
"postbox_message_receipts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("message_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=255), nullable=False),
sa.Column("identity_id", sa.String(length=255), nullable=True),
sa.Column("assignment_id", sa.String(length=36), nullable=True),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_message_receipts_message_id_postbox_messages"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_postbox_message_receipts"),
),
sa.UniqueConstraint(
"tenant_id",
"message_id",
"account_id",
name="uq_postbox_receipts_message_account",
),
)
for column in (
"tenant_id",
"message_id",
"account_id",
"identity_id",
"assignment_id",
):
op.create_index(
op.f(f"ix_postbox_message_receipts_{column}"),
"postbox_message_receipts",
[column],
)
op.create_index(
"ix_postbox_receipts_account_state",
"postbox_message_receipts",
[
"tenant_id",
"account_id",
"read_at",
"acknowledged_at",
],
)
op.create_table(
"postbox_groupings",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=250), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
*_timestamps(),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_groupings")),
sa.UniqueConstraint(
"tenant_id",
"account_id",
"name",
name="uq_postbox_groupings_account_name",
),
)
for column in ("tenant_id", "account_id"):
op.create_index(
op.f(f"ix_postbox_groupings_{column}"),
"postbox_groupings",
[column],
)
op.create_index(
"ix_postbox_groupings_account",
"postbox_groupings",
["tenant_id", "account_id"],
)
op.create_table(
"postbox_grouping_sources",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("grouping_id", sa.String(length=36), nullable=False),
sa.Column("postbox_id", sa.String(length=36), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["grouping_id"],
["postbox_groupings.id"],
name=op.f(
"fk_postbox_grouping_sources_grouping_id_postbox_groupings"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["postbox_id"],
["postboxes.id"],
name=op.f(
"fk_postbox_grouping_sources_postbox_id_postboxes"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_postbox_grouping_sources"),
),
sa.UniqueConstraint(
"grouping_id",
"postbox_id",
name="uq_postbox_grouping_sources_postbox",
),
)
for column in ("tenant_id", "grouping_id", "postbox_id"):
op.create_index(
op.f(f"ix_postbox_grouping_sources_{column}"),
"postbox_grouping_sources",
[column],
)
op.create_table(
"postbox_access_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("postbox_id", sa.String(length=36), nullable=True),
sa.Column("message_id", sa.String(length=36), nullable=True),
sa.Column("account_id", sa.String(length=255), nullable=True),
sa.Column("identity_id", sa.String(length=255), nullable=True),
sa.Column("assignment_id", sa.String(length=36), nullable=True),
sa.Column("action", sa.String(length=80), nullable=False),
sa.Column("outcome", sa.String(length=30), nullable=False),
sa.Column("reason_code", sa.String(length=80), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("details", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_access_events_postbox_id_postboxes"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_access_events_message_id_postbox_messages"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_postbox_access_events"),
),
)
for column in (
"tenant_id",
"postbox_id",
"message_id",
"account_id",
"identity_id",
"assignment_id",
"action",
"outcome",
"occurred_at",
):
op.create_index(
op.f(f"ix_postbox_access_events_{column}"),
"postbox_access_events",
[column],
)
op.create_index(
"ix_postbox_access_events_resource",
"postbox_access_events",
["tenant_id", "postbox_id", "message_id", "occurred_at"],
)
op.create_index(
"ix_postbox_access_events_actor",
"postbox_access_events",
["tenant_id", "account_id", "occurred_at"],
)
def downgrade() -> None:
op.drop_table("postbox_access_events")
op.drop_table("postbox_grouping_sources")
op.drop_table("postbox_groupings")
op.drop_table("postbox_message_receipts")
op.drop_table("postbox_routes")
op.drop_table("postbox_deliveries")
op.drop_table("postbox_attachment_references")
op.drop_table("postbox_participants")
op.drop_table("postbox_messages")
op.drop_table("postbox_bindings")
op.drop_table("postboxes")
op.drop_table("postbox_addresses")
op.drop_table("postbox_template_revisions")
op.drop_table("postbox_templates")

View File

@@ -0,0 +1,732 @@
from __future__ import annotations
from dataclasses import asdict
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope
from govoplan_core.core.postbox import (
PostboxActorRef,
PostboxAttachmentRef,
PostboxDeliveryRequest,
PostboxParticipantRef,
PostboxTargetRef,
)
from govoplan_core.db.session import get_session
from govoplan_postbox.backend.manifest import (
ACKNOWLEDGE_SCOPE,
BINDING_ADMIN_SCOPE,
DELIVERY_SCOPE,
READ_SCOPE,
SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE,
)
from govoplan_postbox.backend.runtime import get_service
from govoplan_postbox.backend.schemas import (
PostboxAccessDecisionResponse,
PostboxDeliveryCreateRequest,
PostboxDeliveryResponse,
PostboxDirectoryItem,
PostboxDirectoryResponse,
PostboxExactCreateRequest,
PostboxGroupingItem,
PostboxGroupingListResponse,
PostboxGroupingPayload,
PostboxMaterializeRequest,
PostboxMessageItem,
PostboxMessageListResponse,
PostboxMessageStateRequest,
PostboxOrganizationTargetsResponse,
PostboxTemplateCreateRequest,
PostboxTemplateItem,
PostboxTemplateListResponse,
PostboxTemplatePublishRequest,
PostboxTemplateRevisionPayload,
)
from govoplan_postbox.backend.service import PostboxError
router = APIRouter(prefix="/postbox", tags=["postbox"])
def _require(principal: ApiPrincipal, scope: str) -> None:
if not has_scope(principal, scope):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing scope: {scope}",
)
def _require_any(principal: ApiPrincipal, *scopes: str) -> None:
if any(has_scope(principal, scope) for scope in scopes):
return
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Requires one of: {', '.join(scopes)}",
)
def _actor(
principal: ApiPrincipal,
*,
assignment_context_id: str | None = None,
) -> PostboxActorRef:
actions: set[str] = set()
if has_scope(principal, READ_SCOPE):
actions.update(("discover", "read"))
if has_scope(principal, SEND_SCOPE):
actions.add("send")
if has_scope(principal, ACKNOWLEDGE_SCOPE):
actions.add("acknowledge")
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
principal,
TEMPLATE_ADMIN_SCOPE,
):
actions.add("administer")
selected = assignment_context_id
if selected is None and len(principal.function_assignment_ids) == 1:
selected = next(iter(principal.function_assignment_ids))
return PostboxActorRef(
account_id=principal.account_id,
identity_id=principal.identity_id,
selected_assignment_id=selected,
acting_for_account_id=principal.acting_for_account_id,
authorized_actions=frozenset(actions), # type: ignore[arg-type]
)
def _http_error(exc: PostboxError) -> HTTPException:
if exc.code.endswith("_not_found") or exc.code in {
"message_not_found",
"postbox_not_found",
"template_not_found",
"revision_not_found",
"grouping_not_found",
"target_not_found",
}:
code = status.HTTP_404_NOT_FOUND
elif exc.code in {"access_denied", "grouping_source_denied"}:
code = status.HTTP_403_FORBIDDEN
elif exc.code in {
"template_slug_exists",
"address_collision",
"idempotency_conflict",
}:
code = status.HTTP_409_CONFLICT
else:
code = status.HTTP_400_BAD_REQUEST
return HTTPException(
status_code=code,
detail={"code": exc.code, "message": str(exc)},
)
def _directory_item(value) -> PostboxDirectoryItem:
return PostboxDirectoryItem.model_validate(asdict(value))
def _message_item(value) -> PostboxMessageItem:
return PostboxMessageItem.model_validate(asdict(value))
def _template_item(template) -> PostboxTemplateItem:
return PostboxTemplateItem(
id=template.id,
tenant_id=template.tenant_id,
slug=template.slug,
name=template.name,
description=template.description,
status=template.status,
current_revision=template.current_revision,
published_revision_id=template.published_revision_id,
revisions=[
{
"id": revision.id,
"revision": revision.revision,
"function_type_id": revision.function_type_id,
"scope_kind": revision.scope_kind,
"scope_id": revision.scope_id,
"name_pattern": revision.name_pattern,
"address_pattern": revision.address_pattern,
"classification": revision.classification,
"allow_vacant_delivery": revision.allow_vacant_delivery,
"encryption_profile": revision.encryption_profile,
"history_policy": dict(revision.history_policy or {}),
"routing_policy": dict(revision.routing_policy or {}),
"retention_policy": dict(revision.retention_policy or {}),
"published_at": revision.published_at,
"created_at": revision.created_at,
}
for revision in template.revisions
],
created_at=template.created_at,
updated_at=template.updated_at,
)
def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem:
return PostboxGroupingItem(
id=grouping.id,
name=grouping.name,
is_default=grouping.is_default,
postbox_ids=[
source.postbox_id
for source in grouping.sources
if source.postbox_id in visible_ids
],
created_at=grouping.created_at,
updated_at=grouping.updated_at,
)
@router.get("/directory", response_model=PostboxDirectoryResponse)
def api_postbox_directory(
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryResponse:
_require(principal, READ_SCOPE)
entries = get_service().list_visible_postboxes(
session,
tenant_id=principal.tenant_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
)
return PostboxDirectoryResponse(
postboxes=[_directory_item(entry) for entry in entries]
)
@router.get(
"/directory/{postbox_id}/access",
response_model=PostboxAccessDecisionResponse,
)
def api_postbox_access(
postbox_id: str,
action: Literal[
"discover",
"read",
"send",
"acknowledge",
"administer",
] = "read",
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxAccessDecisionResponse:
_require(principal, READ_SCOPE)
try:
decision = get_service().explain_access(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
action=action,
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxAccessDecisionResponse.model_validate(asdict(decision))
@router.get("/messages", response_model=PostboxMessageListResponse)
def api_list_postbox_messages(
postbox_id: list[str] = Query(default=[]),
assignment_context_id: str | None = None,
q: str | None = Query(default=None, max_length=200),
state_filter: Literal[
"all",
"unread",
"read",
"acknowledged",
] = Query(default="all", alias="state"),
limit: int = Query(default=100, ge=1, le=500),
offset: int = Query(default=0, ge=0),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxMessageListResponse:
_require(principal, READ_SCOPE)
actor = _actor(
principal,
assignment_context_id=assignment_context_id,
)
requested = tuple(postbox_id)
if not requested:
requested = tuple(
entry.id
for entry in get_service().list_visible_postboxes(
session,
tenant_id=principal.tenant_id,
actor=actor,
)
)
messages = get_service().list_messages(
session,
tenant_id=principal.tenant_id,
postbox_ids=requested,
actor=actor,
limit=limit,
offset=offset,
query=q,
state=state_filter,
)
total = get_service().count_messages(
session,
tenant_id=principal.tenant_id,
postbox_ids=requested,
actor=actor,
query=q,
state=state_filter,
)
return PostboxMessageListResponse(
messages=[_message_item(message) for message in messages],
total=total,
limit=limit,
offset=offset,
)
@router.get("/messages/{message_id}", response_model=PostboxMessageItem)
def api_get_postbox_message(
message_id: str,
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxMessageItem:
_require(principal, READ_SCOPE)
try:
message = get_service().get_message(
session,
tenant_id=principal.tenant_id,
message_id=message_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
)
except PostboxError as exc:
raise _http_error(exc) from exc
if message is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Postbox message not found.",
)
session.commit()
return _message_item(message)
@router.patch(
"/messages/{message_id}/state",
response_model=PostboxMessageItem,
)
def api_mark_postbox_message(
message_id: str,
payload: PostboxMessageStateRequest,
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxMessageItem:
_require(
principal,
ACKNOWLEDGE_SCOPE if payload.state == "acknowledged" else READ_SCOPE,
)
try:
message = get_service().mark_message(
session,
tenant_id=principal.tenant_id,
message_id=message_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
state=payload.state,
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _message_item(message)
@router.post(
"/deliveries",
response_model=PostboxDeliveryResponse,
status_code=status.HTTP_201_CREATED,
)
def api_deliver_to_postbox(
payload: PostboxDeliveryCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDeliveryResponse:
_require(principal, DELIVERY_SCOPE)
request = PostboxDeliveryRequest(
tenant_id=principal.tenant_id,
target=PostboxTargetRef(**payload.target.model_dump()),
producer_module=payload.producer_module,
producer_resource_type=payload.producer_resource_type,
producer_resource_id=payload.producer_resource_id,
idempotency_key=payload.idempotency_key,
subject=payload.subject,
body_text=payload.body_text,
sender_label=payload.sender_label,
classification=payload.classification,
participants=tuple(
PostboxParticipantRef(**participant.model_dump())
for participant in payload.participants
),
attachments=tuple(
PostboxAttachmentRef(**attachment.model_dump())
for attachment in payload.attachments
),
expires_at=payload.expires_at,
metadata=payload.metadata,
)
try:
result = get_service().deliver(session, request)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
session.commit()
return PostboxDeliveryResponse.model_validate(asdict(result))
@router.get("/groupings", response_model=PostboxGroupingListResponse)
def api_list_postbox_groupings(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxGroupingListResponse:
_require(principal, READ_SCOPE)
actor = _actor(principal)
visible_ids = {
item.id
for item in get_service().list_visible_postboxes(
session,
tenant_id=principal.tenant_id,
actor=actor,
)
}
groupings = get_service().list_groupings(
session,
tenant_id=principal.tenant_id,
actor=actor,
)
return PostboxGroupingListResponse(
groupings=[
_grouping_item(grouping, visible_ids=visible_ids)
for grouping in groupings
]
)
@router.post(
"/groupings",
response_model=PostboxGroupingItem,
status_code=status.HTTP_201_CREATED,
)
def api_create_postbox_grouping(
payload: PostboxGroupingPayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxGroupingItem:
_require(principal, READ_SCOPE)
actor = _actor(principal)
try:
grouping = get_service().save_grouping(
session,
tenant_id=principal.tenant_id,
actor=actor,
grouping_id=None,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
@router.put("/groupings/{grouping_id}", response_model=PostboxGroupingItem)
def api_update_postbox_grouping(
grouping_id: str,
payload: PostboxGroupingPayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxGroupingItem:
_require(principal, READ_SCOPE)
actor = _actor(principal)
try:
grouping = get_service().save_grouping(
session,
tenant_id=principal.tenant_id,
actor=actor,
grouping_id=grouping_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _grouping_item(grouping, visible_ids=set(payload.postbox_ids))
@router.delete("/groupings/{grouping_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_postbox_grouping(
grouping_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> None:
_require(principal, READ_SCOPE)
try:
get_service().delete_grouping(
session,
tenant_id=principal.tenant_id,
actor=_actor(principal),
grouping_id=grouping_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
@router.get(
"/admin/organization-targets",
response_model=PostboxOrganizationTargetsResponse,
)
def api_postbox_organization_targets(
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxOrganizationTargetsResponse:
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
return PostboxOrganizationTargetsResponse(
units=list(
get_service().organization_targets(tenant_id=principal.tenant_id)
)
)
@router.get("/admin/postboxes", response_model=PostboxDirectoryResponse)
def api_admin_postboxes(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryResponse:
_require(principal, BINDING_ADMIN_SCOPE)
return PostboxDirectoryResponse(
postboxes=[
_directory_item(item)
for item in get_service().list_admin_postboxes(
session,
tenant_id=principal.tenant_id,
)
]
)
@router.post(
"/admin/postboxes",
response_model=PostboxDirectoryItem,
status_code=status.HTTP_201_CREATED,
)
def api_create_exact_postbox(
payload: PostboxExactCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
try:
postbox = get_service().create_exact_postbox(
session,
tenant_id=principal.tenant_id,
actor_id=principal.account_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox.id),
)
)
@router.delete(
"/admin/postboxes/{postbox_id}",
response_model=PostboxDirectoryItem,
)
def api_archive_postbox(
postbox_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
try:
postbox = get_service().archive_postbox(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
actor_id=principal.account_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox.id),
)
)
@router.get("/admin/templates", response_model=PostboxTemplateListResponse)
def api_postbox_templates(
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateListResponse:
_require(principal, TEMPLATE_ADMIN_SCOPE)
return PostboxTemplateListResponse(
templates=[
_template_item(template)
for template in get_service().list_templates(
session,
tenant_id=principal.tenant_id,
)
]
)
@router.post(
"/admin/templates",
response_model=PostboxTemplateItem,
status_code=status.HTTP_201_CREATED,
)
def api_create_postbox_template(
payload: PostboxTemplateCreateRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
template = get_service().create_template(
session,
tenant_id=principal.tenant_id,
actor_id=principal.account_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _template_item(template)
@router.post(
"/admin/templates/{template_id}/revisions",
response_model=PostboxTemplateItem,
)
def api_revise_postbox_template(
template_id: str,
payload: PostboxTemplateRevisionPayload,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
template = get_service().revise_template(
session,
tenant_id=principal.tenant_id,
template_id=template_id,
actor_id=principal.account_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _template_item(template)
@router.post(
"/admin/templates/{template_id}/publish",
response_model=PostboxTemplateItem,
)
def api_publish_postbox_template(
template_id: str,
payload: PostboxTemplatePublishRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
template = get_service().publish_template(
session,
tenant_id=principal.tenant_id,
template_id=template_id,
revision_number=payload.revision,
actor_id=principal.account_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _template_item(template)
@router.post(
"/admin/templates/{template_id}/retire",
response_model=PostboxTemplateItem,
)
def api_retire_postbox_template(
template_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
template = get_service().retire_template(
session,
tenant_id=principal.tenant_id,
template_id=template_id,
actor_id=principal.account_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
return _template_item(template)
@router.post(
"/admin/templates/{template_id}/materialize",
response_model=PostboxDirectoryItem,
)
def api_materialize_postbox_template(
template_id: str,
payload: PostboxMaterializeRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
postbox = get_service().materialize_template(
session,
tenant_id=principal.tenant_id,
template_id=template_id,
actor_id=principal.account_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
session.commit()
entry = get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox.id),
)
if entry is None:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Materialized Postbox could not be reloaded.",
)
return _directory_item(entry)

View File

@@ -0,0 +1,31 @@
from __future__ import annotations
from govoplan_core.core.registry import PlatformRegistry
_registry: PlatformRegistry | None = None
_service: object | None = None
def configure_runtime(*, registry: object) -> None:
global _registry, _service
if not isinstance(registry, PlatformRegistry):
raise RuntimeError("Postbox requires a platform registry")
if registry is not _registry:
_registry = registry
_service = None
def get_registry() -> PlatformRegistry:
if _registry is None:
raise RuntimeError("Postbox runtime has not been configured")
return _registry
def get_service():
global _service
if _service is None:
from govoplan_postbox.backend.service import PostboxService
_service = PostboxService.from_registry(get_registry())
return _service

View File

@@ -0,0 +1,260 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
class PostboxAccessDecisionResponse(BaseModel):
allowed: bool
action: str
postbox_id: str
reason_code: str
explanation: str
organization_unit_id: str | None = None
function_id: str | None = None
assignment_ids: list[str] = Field(default_factory=list)
assignment_sources: list[str] = Field(default_factory=list)
selected_assignment_id: str | None = None
holder_count: int = 0
vacant: bool = True
class PostboxDirectoryItem(BaseModel):
id: str
tenant_id: str
address: str
address_key: str
name: str
status: str
classification: str
organization_unit_id: str | None = None
organization_unit_name: str | None = None
function_id: str | None = None
function_name: str | None = None
context_key: str | None = None
template_revision_id: str | None = None
holder_count: int = 0
vacant: bool = True
access: PostboxAccessDecisionResponse | None = None
class PostboxDirectoryResponse(BaseModel):
postboxes: list[PostboxDirectoryItem]
class PostboxParticipantPayload(BaseModel):
kind: str = Field(min_length=1, max_length=30)
reference_type: str = Field(min_length=1, max_length=50)
reference_id: str | None = Field(default=None, max_length=255)
label: str | None = Field(default=None, max_length=500)
address: str | None = Field(default=None, max_length=500)
class PostboxAttachmentPayload(BaseModel):
reference_type: str = Field(min_length=1, max_length=50)
reference_id: str = Field(min_length=1, max_length=255)
name: str | None = Field(default=None, max_length=1000)
media_type: str | None = Field(default=None, max_length=255)
size_bytes: int | None = Field(default=None, ge=0)
digest: str | None = Field(default=None, max_length=255)
metadata: dict[str, Any] = Field(default_factory=dict)
class PostboxMessageItem(BaseModel):
id: str
tenant_id: str
postbox_id: str
subject: str
body_text: str | None = None
status: str
classification: str
sender_label: str | None = None
delivered_at: datetime
read_at: datetime | None = None
acknowledged_at: datetime | None = None
expires_at: datetime | None = None
withdrawn_at: datetime | None = None
producer_module: str | None = None
producer_resource_type: str | None = None
producer_resource_id: str | None = None
encryption_profile: str
key_epoch: int
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class PostboxMessageListResponse(BaseModel):
messages: list[PostboxMessageItem]
total: int
limit: int
offset: int
class PostboxMessageStateRequest(BaseModel):
state: Literal["read", "acknowledged"]
class PostboxTargetPayload(BaseModel):
postbox_id: str | None = Field(default=None, max_length=36)
address_key: str | None = Field(default=None, max_length=500)
template_id: str | None = Field(default=None, max_length=36)
organization_unit_id: str | None = Field(default=None, max_length=36)
function_id: str | None = Field(default=None, max_length=36)
context_key: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_target(self) -> "PostboxTargetPayload":
direct = bool(self.postbox_id or self.address_key)
templated = bool(
self.template_id
and self.organization_unit_id
and self.function_id
)
if direct == templated:
raise ValueError(
"Specify one direct Postbox target or one complete template target."
)
return self
class PostboxDeliveryCreateRequest(BaseModel):
target: PostboxTargetPayload
producer_module: str = Field(min_length=1, max_length=100)
producer_resource_type: str = Field(min_length=1, max_length=100)
producer_resource_id: str | None = Field(default=None, max_length=255)
idempotency_key: str = Field(min_length=1, max_length=255)
subject: str = Field(min_length=1, max_length=1000)
body_text: str | None = None
sender_label: str | None = Field(default=None, max_length=500)
classification: str = Field(default="internal", min_length=1, max_length=50)
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
expires_at: datetime | None = None
metadata: dict[str, Any] = Field(default_factory=dict)
class PostboxDeliveryResponse(BaseModel):
delivery_id: str
postbox_id: str
message_id: str
address: str
status: str
vacant: bool
holder_count: int
duplicate: bool = False
evidence: dict[str, Any] = Field(default_factory=dict)
class PostboxExactCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=500)
description: str | None = None
organization_unit_id: str = Field(min_length=1, max_length=36)
function_id: str = Field(min_length=1, max_length=36)
address_key: str | None = Field(default=None, max_length=120)
classification: str = Field(default="internal", min_length=1, max_length=50)
class PostboxTemplateRevisionPayload(BaseModel):
function_type_id: str | None = Field(default=None, max_length=36)
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
scope_id: str | None = Field(default=None, max_length=255)
name_pattern: str = Field(
default="{unit_name} / {function_name}",
min_length=1,
max_length=500,
)
address_pattern: str = Field(
default="{template_slug}.{unit_slug}.{function_slug}",
min_length=1,
max_length=500,
)
classification: str = Field(default="internal", min_length=1, max_length=50)
allow_vacant_delivery: bool = True
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
slug: str = Field(min_length=1, max_length=120)
name: str = Field(min_length=1, max_length=250)
description: str | None = None
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
id: str
revision: int
encryption_profile: str
history_policy: dict[str, Any] = Field(default_factory=dict)
routing_policy: dict[str, Any] = Field(default_factory=dict)
retention_policy: dict[str, Any] = Field(default_factory=dict)
published_at: datetime | None = None
created_at: datetime
class PostboxTemplateItem(BaseModel):
id: str
tenant_id: str
slug: str
name: str
description: str | None = None
status: str
current_revision: int
published_revision_id: str | None = None
revisions: list[PostboxTemplateRevisionItem] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class PostboxTemplateListResponse(BaseModel):
templates: list[PostboxTemplateItem]
class PostboxTemplatePublishRequest(BaseModel):
revision: int | None = Field(default=None, ge=1)
class PostboxMaterializeRequest(BaseModel):
organization_unit_id: str = Field(min_length=1, max_length=36)
function_id: str = Field(min_length=1, max_length=36)
context_key: str | None = Field(default=None, max_length=255)
class PostboxOrganizationFunctionItem(BaseModel):
id: str
slug: str
name: str
function_type_id: str | None = None
delegable: bool = False
act_in_place_allowed: bool = False
class PostboxOrganizationUnitItem(BaseModel):
id: str
slug: str
name: str
unit_type_id: str | None = None
parent_id: str | None = None
functions: list[PostboxOrganizationFunctionItem] = Field(default_factory=list)
class PostboxOrganizationTargetsResponse(BaseModel):
units: list[PostboxOrganizationUnitItem]
class PostboxGroupingPayload(BaseModel):
name: str = Field(min_length=1, max_length=250)
is_default: bool = False
postbox_ids: list[str] = Field(default_factory=list, max_length=250)
class PostboxGroupingItem(PostboxGroupingPayload):
id: str
created_at: datetime
updated_at: datetime
class PostboxGroupingListResponse(BaseModel):
groupings: list[PostboxGroupingItem]

File diff suppressed because it is too large Load Diff