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

@@ -18,7 +18,7 @@ This repository owns:
normalized Identity, IDM, Organizations, and Core/Access contracts normalized Identity, IDM, Organizations, and Core/Access contracts
- API routes for postbox directory, messages, access checks, and administration - API routes for postbox directory, messages, access checks, and administration
- optional integration capabilities for campaign, files, portal, notification, and mail-facing workflows - optional integration capabilities for campaign, files, portal, notification, and mail-facing workflows
- future WebUI package `@govoplan/postbox-webui` - inbox and tenant administration WebUI package `@govoplan/postbox-webui`
Core owns auth, tenants, RBAC evaluation, database/session primitives, module Core owns auth, tenants, RBAC evaluation, database/session primitives, module
discovery, migrations, CSRF/API helpers, and shell layout. Identity owns discovery, migrations, CSRF/API helpers, and shell layout. Identity owns
@@ -72,3 +72,24 @@ Frontend package:
``` ```
Platform RBAC, module capability contracts, and governance rules are documented in `govoplan-core/docs/`. Platform RBAC, module capability contracts, and governance rules are documented in `govoplan-core/docs/`.
## Current implementation
The first usable slice includes immutable template revisions, stable lazy
addresses, exact function-bound Postboxes, current IDM assignment access
decisions, vacancy status, idempotent producer delivery, source-preserving
message and attachment references, personal read/acknowledgement receipts,
unified inbox projections, access evidence, an inbox route, and tenant
administration.
The persistence model reserves ciphertext manifests, wrapped keys, key epochs,
expiry, and withdrawal state. The active profile remains `plaintext_v1`; the
module does not claim end-to-end encryption until the history, recovery, and
handover policy choices in the security issues are resolved.
Run focused checks with:
```bash
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
cd webui && npm run test:ui-structure
```

22
pyproject.toml Normal file
View File

@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-postbox"
version = "0.1.1"
description = "Function-bound institutional postboxes for GovOPlaN."
readme = "README.md"
requires-python = ">=3.12"
license = "AGPL-3.0-or-later"
authors = [{ name = "GovOPlaN" }]
dependencies = ["govoplan-core>=0.1.14"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_postbox = ["py.typed"]
[project.entry-points."govoplan.modules"]
postbox = "govoplan_postbox.backend.manifest:get_manifest"

View File

@@ -0,0 +1,3 @@
"""GovOPlaN Postbox module."""
__version__ = "0.1.0"

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

View File

@@ -0,0 +1 @@

43
tests/test_manifest.py Normal file
View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import unittest
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
)
from govoplan_postbox.backend.manifest import get_manifest
class PostboxManifestTests(unittest.TestCase):
def test_manifest_announces_owned_contracts_and_dependencies(self) -> None:
manifest = get_manifest()
self.assertEqual(manifest.id, "postbox")
self.assertEqual(
{"identity", "organizations", "idm"},
set(manifest.dependencies),
)
self.assertEqual(
{
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
},
set(manifest.capability_factories),
)
self.assertEqual("@govoplan/postbox-webui", manifest.frontend.package_name)
self.assertEqual(["/postbox"], [route.path for route in manifest.frontend.routes])
self.assertIn(
"idm.function_assignments",
manifest.required_capabilities,
)
if __name__ == "__main__":
unittest.main()

61
tests/test_migration.py Normal file
View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import importlib
import unittest
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import create_engine, inspect
class PostboxMigrationTests(unittest.TestCase):
def test_baseline_creates_and_drops_owned_tables(self) -> None:
migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"c7d2e5f8a1b4_v010_postbox_baseline"
)
engine = create_engine("sqlite:///:memory:")
try:
with engine.begin() as connection:
operations = Operations(MigrationContext.configure(connection))
original = migration.op
migration.op = operations
try:
migration.upgrade()
tables = set(inspect(connection).get_table_names())
self.assertIn("postboxes", tables)
self.assertIn("postbox_messages", tables)
self.assertIn("postbox_deliveries", tables)
self.assertIn("postbox_access_events", tables)
message_columns = {
column["name"]
for column in inspect(connection).get_columns(
"postbox_messages"
)
}
self.assertTrue(
{
"ciphertext_ref",
"signed_manifest_ref",
"wrapped_keys",
"key_epoch",
"expires_at",
"withdrawn_at",
}.issubset(message_columns)
)
migration.downgrade()
self.assertFalse(
{
table
for table in inspect(connection).get_table_names()
if table.startswith("postbox")
}
)
finally:
migration.op = original
finally:
engine.dispose()
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import ast
import unittest
from pathlib import Path
class PostboxModuleBoundaryTests(unittest.TestCase):
def test_backend_does_not_import_sibling_module_implementations(self) -> None:
root = Path(__file__).parents[1] / "src" / "govoplan_postbox"
forbidden = (
"govoplan_access",
"govoplan_campaign",
"govoplan_files",
"govoplan_identity",
"govoplan_idm",
"govoplan_mail",
"govoplan_organizations",
"govoplan_portal",
)
violations: list[str] = []
for path in root.rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
names: list[str] = []
if isinstance(node, ast.Import):
names.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
names.append(node.module)
for name in names:
if name.startswith(forbidden):
violations.append(f"{path.relative_to(root)}: {name}")
self.assertEqual([], violations)
if __name__ == "__main__":
unittest.main()

302
tests/test_router.py Normal file
View File

@@ -0,0 +1,302 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.identity import IdentityRef
from govoplan_core.core.idm import (
OrganizationFunctionAssignmentRef,
OrganizationFunctionIncumbencyRef,
)
from govoplan_core.core.organizations import (
OrganizationFunctionRef,
OrganizationUnitRef,
)
from govoplan_core.db.base import Base
from govoplan_core.db.session import get_session
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAccessEvent,
PostboxAddress,
PostboxAttachmentReference,
PostboxBinding,
PostboxDelivery,
PostboxGrouping,
PostboxGroupingSource,
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
)
from govoplan_postbox.backend.router import router
from govoplan_postbox.backend.service import PostboxService
TABLES = (
PostboxTemplate.__table__,
PostboxTemplateRevision.__table__,
PostboxAddress.__table__,
Postbox.__table__,
PostboxBinding.__table__,
PostboxMessage.__table__,
PostboxParticipant.__table__,
PostboxAttachmentReference.__table__,
PostboxDelivery.__table__,
PostboxRoute.__table__,
PostboxMessageReceipt.__table__,
PostboxGrouping.__table__,
PostboxGroupingSource.__table__,
PostboxAccessEvent.__table__,
)
class FakeIdentityDirectory:
def get_identity(self, identity_id: str):
return IdentityRef(id=identity_id, primary_account_id="account-1")
def identity_for_account(self, account_id: str):
return IdentityRef(id="identity-1", primary_account_id=account_id)
def identities_for_accounts(self, account_ids):
return tuple(self.identity_for_account(account_id) for account_id in account_ids)
def accounts_for_identity(self, identity_id: str):
return ()
class FakeIdmDirectory:
def __init__(self, assignment: OrganizationFunctionAssignmentRef) -> None:
self.assignment = assignment
def get_organization_function_assignment(self, assignment_id: str):
return self.assignment if assignment_id == self.assignment.id else None
def organization_function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
):
return (self.assignment,) if identity_id == self.assignment.identity_id else ()
def organization_function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
):
return (self.assignment,) if account_id == self.assignment.account_id else ()
def organization_function_assignments_for_function(
self,
function_id: str,
*,
tenant_id: str | None = None,
):
return (self.assignment,) if function_id == self.assignment.function_id else ()
def organization_function_incumbencies(
self,
function_ids,
*,
tenant_id: str,
effective_at=None,
):
del effective_at
return {
function_id: OrganizationFunctionIncumbencyRef(
tenant_id=tenant_id,
function_id=function_id,
assignments=self.organization_function_assignments_for_function(
function_id,
tenant_id=tenant_id,
),
)
for function_id in function_ids
}
class FakeOrganizationDirectory:
unit = OrganizationUnitRef(
id="unit-1",
tenant_id="tenant-1",
slug="district",
name="District",
)
function = OrganizationFunctionRef(
id="function-1",
tenant_id="tenant-1",
organization_unit_id="unit-1",
slug="clerk",
name="Clerk",
function_type_id="clerk-type",
)
def get_organization_unit(self, organization_unit_id: str):
return self.unit if organization_unit_id == self.unit.id else None
def organization_units_for_tenant(self, tenant_id: str):
return (self.unit,) if tenant_id == self.unit.tenant_id else ()
def get_function(self, function_id: str):
return self.function if function_id == self.function.id else None
def functions_for_organization_unit(
self,
organization_unit_id: str,
*,
include_subunits: bool = False,
):
return (self.function,) if organization_unit_id == self.unit.id else ()
class PostboxRouterTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(self.engine, tables=TABLES)
assignment = OrganizationFunctionAssignmentRef(
id="assignment-1",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id="function-1",
organization_unit_id="unit-1",
)
idm = FakeIdmDirectory(assignment)
self.service = PostboxService(
identities=FakeIdentityDirectory(), # type: ignore[arg-type]
idm=idm, # type: ignore[arg-type]
incumbencies=idm, # type: ignore[arg-type]
organizations=FakeOrganizationDirectory(), # type: ignore[arg-type]
)
with Session(self.engine) as session:
self.postbox = self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="District / Clerk",
organization_unit_id="unit-1",
function_id="function-1",
address_key=None,
description=None,
classification="internal",
actor_id="account-1",
)
session.commit()
self.postbox_id = self.postbox.id
principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
identity_id="identity-1",
scopes=frozenset(
{
"postbox:postbox:read",
"postbox:message:write",
"postbox:message:acknowledge",
"postbox:delivery:write",
"postbox:binding:admin",
"postbox:template:admin",
}
),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="membership-1"),
)
app = FastAPI()
app.include_router(router, prefix="/api/v1")
def session_dependency():
with Session(self.engine) as session:
yield session
app.dependency_overrides[get_session] = session_dependency
app.dependency_overrides[get_api_principal] = lambda: principal
self.patch = patch(
"govoplan_postbox.backend.router.get_service",
return_value=self.service,
)
self.patch.start()
self.client = TestClient(app)
def tearDown(self) -> None:
self.client.close()
self.patch.stop()
self.engine.dispose()
def test_directory_delivery_message_and_receipt_round_trip(self) -> None:
directory = self.client.get("/api/v1/postbox/directory")
self.assertEqual(200, directory.status_code, directory.text)
self.assertEqual(self.postbox_id, directory.json()["postboxes"][0]["id"])
delivery = self.client.post(
"/api/v1/postbox/deliveries",
json={
"target": {"postbox_id": self.postbox_id},
"producer_module": "campaigns",
"producer_resource_type": "campaign_recipient",
"producer_resource_id": "recipient-1",
"idempotency_key": "campaign-1:recipient-1",
"subject": "Decision",
"body_text": "The decision is ready.",
},
)
self.assertEqual(201, delivery.status_code, delivery.text)
message_id = delivery.json()["message_id"]
messages = self.client.get(
"/api/v1/postbox/messages",
params={"postbox_id": self.postbox_id},
)
self.assertEqual(200, messages.status_code, messages.text)
self.assertEqual(1, messages.json()["total"])
self.assertEqual(message_id, messages.json()["messages"][0]["id"])
filtered = self.client.get(
"/api/v1/postbox/messages",
params={
"postbox_id": self.postbox_id,
"q": "decision",
"state": "unread",
},
)
self.assertEqual(200, filtered.status_code, filtered.text)
self.assertEqual(1, filtered.json()["total"])
acknowledged = self.client.patch(
f"/api/v1/postbox/messages/{message_id}/state",
json={"state": "acknowledged"},
)
self.assertEqual(200, acknowledged.status_code, acknowledged.text)
self.assertIsNotNone(acknowledged.json()["read_at"])
self.assertIsNotNone(acknowledged.json()["acknowledged_at"])
unread = self.client.get(
"/api/v1/postbox/messages",
params={
"postbox_id": self.postbox_id,
"state": "unread",
},
)
self.assertEqual(200, unread.status_code, unread.text)
self.assertEqual(0, unread.json()["total"])
if __name__ == "__main__":
unittest.main()

664
tests/test_service.py Normal file
View File

@@ -0,0 +1,664 @@
from __future__ import annotations
import unittest
from datetime import timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.events import EventBus, event_bus_context
from govoplan_core.core.identity import IdentityRef
from govoplan_core.core.idm import (
OrganizationFunctionAssignmentRef,
OrganizationFunctionIncumbencyRef,
)
from govoplan_core.core.organizations import (
OrganizationFunctionRef,
OrganizationUnitRef,
)
from govoplan_core.core.postbox import (
PostboxActorRef,
PostboxDeliveryRequest,
PostboxTargetRef,
)
from govoplan_core.db.base import Base
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,
)
from govoplan_postbox.backend.service import PostboxService
POSTBOX_TABLES = (
PostboxTemplate.__table__,
PostboxTemplateRevision.__table__,
PostboxAddress.__table__,
Postbox.__table__,
PostboxBinding.__table__,
PostboxMessage.__table__,
PostboxParticipant.__table__,
PostboxAttachmentReference.__table__,
PostboxDelivery.__table__,
PostboxRoute.__table__,
PostboxMessageReceipt.__table__,
PostboxGrouping.__table__,
PostboxGroupingSource.__table__,
PostboxAccessEvent.__table__,
)
class FakeIdentityDirectory:
def get_identity(self, identity_id: str):
return IdentityRef(id=identity_id, primary_account_id="account-1")
def identity_for_account(self, account_id: str):
return IdentityRef(
id="identity-1",
primary_account_id=account_id,
account_ids=(account_id,),
)
def identities_for_accounts(self, account_ids):
return tuple(self.identity_for_account(account_id) for account_id in account_ids)
def accounts_for_identity(self, identity_id: str):
return ()
class FakeIdmDirectory:
def __init__(self) -> None:
self.assignments: list[OrganizationFunctionAssignmentRef] = []
def get_organization_function_assignment(self, assignment_id: str):
return next(
(
assignment
for assignment in self.assignments
if assignment.id == assignment_id
),
None,
)
def organization_function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
):
return tuple(
assignment
for assignment in self.assignments
if assignment.identity_id == identity_id
and (tenant_id is None or assignment.tenant_id == tenant_id)
and assignment.status == "active"
)
def organization_function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
):
return tuple(
assignment
for assignment in self.assignments
if assignment.account_id == account_id
and (tenant_id is None or assignment.tenant_id == tenant_id)
and assignment.status == "active"
)
def organization_function_assignments_for_function(
self,
function_id: str,
*,
tenant_id: str | None = None,
):
return tuple(
assignment
for assignment in self.assignments
if assignment.function_id == function_id
and (tenant_id is None or assignment.tenant_id == tenant_id)
and assignment.status == "active"
)
def organization_function_incumbencies(
self,
function_ids,
*,
tenant_id: str,
effective_at=None,
):
del effective_at
return {
function_id: OrganizationFunctionIncumbencyRef(
tenant_id=tenant_id,
function_id=function_id,
assignments=self.organization_function_assignments_for_function(
function_id,
tenant_id=tenant_id,
),
)
for function_id in function_ids
}
class FakeOrganizationDirectory:
def __init__(self) -> None:
self.units = {
"unit-1": OrganizationUnitRef(
id="unit-1",
tenant_id="tenant-1",
slug="district-north",
name="District North",
unit_type_id="district",
),
"unit-child": OrganizationUnitRef(
id="unit-child",
tenant_id="tenant-1",
slug="service-desk",
name="Service Desk",
unit_type_id="desk",
parent_id="unit-1",
),
}
self.functions = {
"function-1": OrganizationFunctionRef(
id="function-1",
tenant_id="tenant-1",
organization_unit_id="unit-1",
slug="case-clerk",
name="Case Clerk",
function_type_id="case-clerk-type",
delegable=True,
),
"function-child": OrganizationFunctionRef(
id="function-child",
tenant_id="tenant-1",
organization_unit_id="unit-child",
slug="case-clerk",
name="Case Clerk",
function_type_id="case-clerk-type",
delegable=True,
),
}
def get_organization_unit(self, organization_unit_id: str):
return self.units.get(organization_unit_id)
def organization_units_for_tenant(self, tenant_id: str):
return tuple(
unit for unit in self.units.values() if unit.tenant_id == tenant_id
)
def get_function(self, function_id: str):
return self.functions.get(function_id)
def functions_for_organization_unit(
self,
organization_unit_id: str,
*,
include_subunits: bool = False,
):
return tuple(
function
for function in self.functions.values()
if function.organization_unit_id == organization_unit_id
)
class FakeNotificationDispatch:
def __init__(self) -> None:
self.requests = []
def enqueue_notification(
self,
session,
request,
*,
enqueue_delivery=True,
):
del session
self.requests.append((request, enqueue_delivery))
return {"id": f"notification-{len(self.requests)}"}
class PostboxServiceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine, tables=POSTBOX_TABLES)
self.idm = FakeIdmDirectory()
self.organizations = FakeOrganizationDirectory()
self.service = PostboxService(
identities=FakeIdentityDirectory(), # type: ignore[arg-type]
idm=self.idm, # type: ignore[arg-type]
incumbencies=self.idm, # type: ignore[arg-type]
organizations=self.organizations, # type: ignore[arg-type]
)
self.assignment = OrganizationFunctionAssignmentRef(
id="assignment-1",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id="function-1",
organization_unit_id="unit-1",
source="direct",
)
self.actor = PostboxActorRef(
account_id="account-1",
identity_id="identity-1",
authorized_actions=frozenset(
{"discover", "read", "send", "acknowledge"}
),
)
def tearDown(self) -> None:
self.engine.dispose()
def _create_exact(self, session: Session) -> Postbox:
return self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="District North / Case Clerk Intake",
organization_unit_id="unit-1",
function_id="function-1",
address_key=None,
description=None,
classification="internal",
actor_id="admin-1",
)
def test_access_follows_current_assignment_and_reports_vacancy(self) -> None:
with Session(self.engine) as session:
postbox = self._create_exact(session)
session.commit()
denied = self.service.explain_access(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
actor=self.actor,
action="read",
)
self.assertFalse(denied.allowed)
self.assertTrue(denied.vacant)
self.idm.assignments.append(self.assignment)
allowed = self.service.explain_access(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
actor=self.actor,
action="read",
)
self.assertTrue(allowed.allowed)
self.assertFalse(allowed.vacant)
self.assertEqual("assignment-1", allowed.selected_assignment_id)
self.idm.assignments.clear()
revoked = self.service.explain_access(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
actor=self.actor,
action="read",
)
self.assertFalse(revoked.allowed)
def test_acting_assignment_requires_selected_context(self) -> None:
acting = OrganizationFunctionAssignmentRef(
id="acting-assignment",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id="function-1",
organization_unit_id="unit-1",
source="acting_for",
delegated_from_assignment_id="source-assignment",
acting_for_account_id="represented-account",
)
self.idm.assignments.append(acting)
with Session(self.engine) as session:
postbox = self._create_exact(session)
session.commit()
denied = self.service.explain_access(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
actor=self.actor,
action="read",
)
allowed = self.service.explain_access(
session,
tenant_id="tenant-1",
postbox_id=postbox.id,
actor=PostboxActorRef(
account_id="account-1",
identity_id="identity-1",
selected_assignment_id=acting.id,
acting_for_account_id="represented-account",
authorized_actions=frozenset({"read"}),
),
action="read",
)
self.assertFalse(denied.allowed)
self.assertTrue(allowed.allowed)
self.assertEqual("effective_acting_for_assignment", allowed.reason_code)
def test_template_revision_is_immutable_and_materialization_idempotent(self) -> None:
with Session(self.engine) as session:
template = self.service.create_template(
session,
tenant_id="tenant-1",
slug="case-intake",
name="Case intake",
description=None,
function_type_id="case-clerk-type",
scope_kind="subtree",
scope_id="unit-1",
name_pattern="{unit_name} / {function_name} Intake",
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
classification="internal",
allow_vacant_delivery=True,
actor_id="admin-1",
)
self.service.publish_template(
session,
tenant_id="tenant-1",
template_id=template.id,
revision_number=1,
actor_id="admin-1",
)
first = self.service.materialize_template(
session,
tenant_id="tenant-1",
template_id=template.id,
organization_unit_id="unit-child",
function_id="function-child",
context_key="case-42",
actor_id="admin-1",
)
second = self.service.materialize_template(
session,
tenant_id="tenant-1",
template_id=template.id,
organization_unit_id="unit-child",
function_id="function-child",
context_key="case-42",
actor_id="admin-1",
)
revised = self.service.revise_template(
session,
tenant_id="tenant-1",
template_id=template.id,
function_type_id="case-clerk-type",
scope_kind="subtree",
scope_id="unit-1",
name_pattern="{unit_name} / {function_name} Work",
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
classification="restricted",
allow_vacant_delivery=True,
actor_id="admin-1",
)
self.assertEqual(first.id, second.id)
self.assertEqual(2, len(revised.revisions))
self.assertEqual(
"{unit_name} / {function_name} Intake",
revised.revisions[0].name_pattern,
)
self.assertEqual(
"{unit_name} / {function_name} Work",
revised.revisions[1].name_pattern,
)
def test_delivery_catalog_exposes_exact_and_derived_target_choices(
self,
) -> None:
with Session(self.engine) as session:
exact = self._create_exact(session)
template = self.service.create_template(
session,
tenant_id="tenant-1",
slug="case-intake",
name="Case intake",
description="Functional intake",
function_type_id="case-clerk-type",
scope_kind="subtree",
scope_id="unit-1",
name_pattern="{unit_name} / {function_name} Intake",
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
classification="internal",
allow_vacant_delivery=True,
actor_id="admin-1",
)
self.service.publish_template(
session,
tenant_id="tenant-1",
template_id=template.id,
revision_number=None,
actor_id="admin-1",
)
session.commit()
catalog = self.service.delivery_catalog(
session,
tenant_id="tenant-1",
)
self.assertEqual([exact.id], [item.id for item in catalog.postboxes])
self.assertEqual(
["case-intake"],
[item.slug for item in catalog.templates],
)
north = next(
unit
for unit in catalog.organization_units
if unit.id == "unit-1"
)
self.assertEqual(
["function-1"],
[function.id for function in north.functions],
)
def test_delivery_is_idempotent_and_receipts_are_per_account(self) -> None:
self.idm.assignments.append(self.assignment)
notifications = FakeNotificationDispatch()
service = PostboxService(
identities=FakeIdentityDirectory(), # type: ignore[arg-type]
idm=self.idm, # type: ignore[arg-type]
incumbencies=self.idm, # type: ignore[arg-type]
organizations=self.organizations, # type: ignore[arg-type]
notifications=notifications, # type: ignore[arg-type]
)
events = []
event_bus = EventBus()
event_bus.subscribe("*", events.append)
with Session(self.engine) as session:
postbox = service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="District North / Case Clerk Intake",
organization_unit_id="unit-1",
function_id="function-1",
address_key=None,
description=None,
classification="internal",
actor_id="admin-1",
)
request = PostboxDeliveryRequest(
tenant_id="tenant-1",
target=PostboxTargetRef(postbox_id=postbox.id),
producer_module="campaigns",
producer_resource_type="campaign_recipient",
producer_resource_id="recipient-1",
idempotency_key="campaign-1:recipient-1:postbox",
subject="Permit decision",
body_text="The decision is available.",
expires_at=utc_now() + timedelta(days=30),
)
with event_bus_context(event_bus):
first = service.deliver(session, request)
second = service.deliver(session, request)
self.assertEqual(
1,
service.count_messages(
session,
tenant_id="tenant-1",
postbox_ids=(postbox.id,),
actor=self.actor,
query="permit",
state="unread",
),
)
marked = service.mark_message(
session,
tenant_id="tenant-1",
message_id=first.message_id,
actor=self.actor,
state="acknowledged",
)
service.mark_message(
session,
tenant_id="tenant-1",
message_id=first.message_id,
actor=self.actor,
state="acknowledged",
)
session.commit()
self.assertFalse(first.duplicate)
self.assertTrue(second.duplicate)
self.assertEqual(first.message_id, second.message_id)
self.assertIsNotNone(marked.read_at)
self.assertIsNotNone(marked.acknowledged_at)
self.assertEqual(
1,
session.query(PostboxMessage).count(),
)
self.assertEqual(
1,
session.query(PostboxDelivery).count(),
)
self.assertEqual(
1,
session.query(PostboxMessageReceipt).count(),
)
self.assertEqual(
(),
service.list_messages(
session,
tenant_id="tenant-1",
postbox_ids=(postbox.id,),
actor=self.actor,
query="not present",
),
)
self.assertEqual(
1,
service.count_messages(
session,
tenant_id="tenant-1",
postbox_ids=(postbox.id,),
actor=self.actor,
state="acknowledged",
),
)
self.assertEqual(1, len(notifications.requests))
notification, enqueue_delivery = notifications.requests[0]
self.assertEqual("account-1", notification.recipient_id)
self.assertEqual("account", notification.recipient_type)
self.assertEqual(
f"/postbox?message={first.message_id}",
notification.action_url,
)
self.assertFalse(enqueue_delivery)
self.assertEqual(
[
"postbox.delivery.accepted.v1",
"postbox.message.acknowledged.v1",
],
[event.type for event in events],
)
def test_grouping_update_retains_temporarily_hidden_sources(self) -> None:
child_assignment = OrganizationFunctionAssignmentRef(
id="assignment-child",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id="function-child",
organization_unit_id="unit-child",
source="direct",
)
self.idm.assignments.extend((self.assignment, child_assignment))
with Session(self.engine) as session:
parent = self._create_exact(session)
child = self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="Service Desk / Case Clerk Intake",
organization_unit_id="unit-child",
function_id="function-child",
address_key=None,
description=None,
classification="internal",
actor_id="admin-1",
)
grouping = self.service.save_grouping(
session,
tenant_id="tenant-1",
actor=self.actor,
grouping_id=None,
name="Assigned work",
is_default=True,
postbox_ids=(parent.id, child.id),
)
session.commit()
grouping_id = grouping.id
self.idm.assignments.remove(child_assignment)
updated = self.service.save_grouping(
session,
tenant_id="tenant-1",
actor=self.actor,
grouping_id=grouping_id,
name="Assigned work",
is_default=True,
postbox_ids=(parent.id,),
)
session.commit()
self.assertEqual(
(parent.id, child.id),
tuple(source.postbox_id for source in updated.sources),
)
self.idm.assignments.append(child_assignment)
visible_ids = {
item.id
for item in self.service.list_visible_postboxes(
session,
tenant_id="tenant-1",
actor=self.actor,
)
}
self.assertEqual({parent.id, child.id}, visible_ids)
if __name__ == "__main__":
unittest.main()

31
webui/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "@govoplan/postbox-webui",
"version": "0.1.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/postbox.css": "./src/styles/postbox.css"
},
"scripts": {
"test:ui-structure": "node scripts/test-postbox-page-structure.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.14",
"lucide-react": "^1.23.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": ">=7.18.2 <8"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const page = readFileSync(
new URL("../src/features/postbox/PostboxPage.tsx", import.meta.url),
"utf8"
);
const admin = readFileSync(
new URL("../src/features/postbox/PostboxAdminPanel.tsx", import.meta.url),
"utf8"
);
const styles = readFileSync(
new URL("../src/styles/postbox.css", import.meta.url),
"utf8"
);
assert.match(page, /postbox-shell/);
assert.match(page, /postbox-directory/);
assert.match(page, /postbox-message-list/);
assert.match(page, /postbox-detail/);
assert.match(page, /postbox\.inbox\.directory/);
assert.match(page, /postbox\.inbox\.messages/);
assert.match(admin, /AdminPageLayout/);
assert.match(admin, /Postbox templates/);
assert.match(admin, /Exact postbox/);
assert.match(styles, /\.postbox-shell\s*\{/);
assert.match(styles, /grid-template-columns:/);
console.log("Postbox WebUI structure checks passed.");

362
webui/src/api/postbox.ts Normal file
View File

@@ -0,0 +1,362 @@
import {
apiFetch,
apiPath,
apiPostJson,
type ApiSettings
} from "@govoplan/core-webui";
export type PostboxAccessDecision = {
allowed: boolean;
action: string;
postbox_id: string;
reason_code: string;
explanation: string;
organization_unit_id?: string | null;
function_id?: string | null;
assignment_ids: string[];
assignment_sources: string[];
selected_assignment_id?: string | null;
holder_count: number;
vacant: boolean;
};
export type PostboxDirectoryItem = {
id: string;
tenant_id: string;
address: string;
address_key: string;
name: string;
status: string;
classification: string;
organization_unit_id?: string | null;
organization_unit_name?: string | null;
function_id?: string | null;
function_name?: string | null;
context_key?: string | null;
template_revision_id?: string | null;
holder_count: number;
vacant: boolean;
access?: PostboxAccessDecision | null;
};
export type PostboxParticipant = {
kind: string;
reference_type: string;
reference_id?: string | null;
label?: string | null;
address?: string | null;
};
export type PostboxAttachment = {
reference_type: string;
reference_id: string;
name?: string | null;
media_type?: string | null;
size_bytes?: number | null;
digest?: string | null;
metadata: Record<string, unknown>;
};
export type PostboxMessage = {
id: string;
tenant_id: string;
postbox_id: string;
subject: string;
body_text?: string | null;
status: string;
classification: string;
sender_label?: string | null;
delivered_at: string;
read_at?: string | null;
acknowledged_at?: string | null;
expires_at?: string | null;
withdrawn_at?: string | null;
producer_module?: string | null;
producer_resource_type?: string | null;
producer_resource_id?: string | null;
encryption_profile: string;
key_epoch: number;
ciphertext_ref?: string | null;
signed_manifest_ref?: string | null;
participants: PostboxParticipant[];
attachments: PostboxAttachment[];
metadata: Record<string, unknown>;
};
export type PostboxGrouping = {
id: string;
name: string;
is_default: boolean;
postbox_ids: string[];
created_at: string;
updated_at: string;
};
export type PostboxOrganizationFunction = {
id: string;
slug: string;
name: string;
function_type_id?: string | null;
delegable: boolean;
act_in_place_allowed: boolean;
};
export type PostboxOrganizationUnit = {
id: string;
slug: string;
name: string;
unit_type_id?: string | null;
parent_id?: string | null;
functions: PostboxOrganizationFunction[];
};
export type PostboxTemplateRevision = {
id: string;
revision: number;
function_type_id?: string | null;
scope_kind: "tenant" | "unit" | "subtree" | "unit_type";
scope_id?: string | null;
name_pattern: string;
address_pattern: string;
classification: string;
allow_vacant_delivery: boolean;
encryption_profile: string;
history_policy: Record<string, unknown>;
routing_policy: Record<string, unknown>;
retention_policy: Record<string, unknown>;
published_at?: string | null;
created_at: string;
};
export type PostboxTemplate = {
id: string;
tenant_id: string;
slug: string;
name: string;
description?: string | null;
status: string;
current_revision: number;
published_revision_id?: string | null;
revisions: PostboxTemplateRevision[];
created_at: string;
updated_at: string;
};
export type PostboxTemplateRevisionPayload = Pick<
PostboxTemplateRevision,
| "function_type_id"
| "scope_kind"
| "scope_id"
| "name_pattern"
| "address_pattern"
| "classification"
| "allow_vacant_delivery"
>;
export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
slug: string;
name: string;
description?: string | null;
};
export type PostboxExactCreatePayload = {
name: string;
description?: string | null;
organization_unit_id: string;
function_id: string;
address_key?: string | null;
classification: string;
};
export async function listPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
settings,
"/api/v1/postbox/directory"
);
return response.postboxes;
}
export async function listPostboxMessages(
settings: ApiSettings,
postboxIds: string[],
limit = 100,
offset = 0,
query = "",
state: "all" | "unread" | "read" | "acknowledged" = "all"
): Promise<{ messages: PostboxMessage[]; total: number; limit: number; offset: number }> {
return apiFetch(
settings,
apiPath("/api/v1/postbox/messages", {
postbox_id: postboxIds,
limit,
offset,
q: query || undefined,
state
})
);
}
export function getPostboxMessage(
settings: ApiSettings,
messageId: string
): Promise<PostboxMessage> {
return apiFetch(settings, `/api/v1/postbox/messages/${encodeURIComponent(messageId)}`);
}
export function markPostboxMessage(
settings: ApiSettings,
messageId: string,
state: "read" | "acknowledged"
): Promise<PostboxMessage> {
return apiFetch(
settings,
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/state`,
{
method: "PATCH",
body: JSON.stringify({ state })
}
);
}
export async function listPostboxGroupings(settings: ApiSettings): Promise<PostboxGrouping[]> {
const response = await apiFetch<{ groupings: PostboxGrouping[] }>(
settings,
"/api/v1/postbox/groupings"
);
return response.groupings;
}
export function createPostboxGrouping(
settings: ApiSettings,
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
): Promise<PostboxGrouping> {
return apiPostJson(settings, "/api/v1/postbox/groupings", payload);
}
export function updatePostboxGrouping(
settings: ApiSettings,
groupingId: string,
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
): Promise<PostboxGrouping> {
return apiFetch(
settings,
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`,
{
method: "PUT",
body: JSON.stringify(payload)
}
);
}
export function deletePostboxGrouping(
settings: ApiSettings,
groupingId: string
): Promise<void> {
return apiFetch(
settings,
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`,
{ method: "DELETE" }
);
}
export async function listAdminPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
settings,
"/api/v1/postbox/admin/postboxes"
);
return response.postboxes;
}
export async function listPostboxOrganizationTargets(
settings: ApiSettings
): Promise<PostboxOrganizationUnit[]> {
const response = await apiFetch<{ units: PostboxOrganizationUnit[] }>(
settings,
"/api/v1/postbox/admin/organization-targets"
);
return response.units;
}
export function createExactPostbox(
settings: ApiSettings,
payload: PostboxExactCreatePayload
): Promise<PostboxDirectoryItem> {
return apiPostJson(settings, "/api/v1/postbox/admin/postboxes", payload);
}
export function archivePostbox(
settings: ApiSettings,
postboxId: string
): Promise<PostboxDirectoryItem> {
return apiFetch(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}`,
{ method: "DELETE" }
);
}
export async function listPostboxTemplates(settings: ApiSettings): Promise<PostboxTemplate[]> {
const response = await apiFetch<{ templates: PostboxTemplate[] }>(
settings,
"/api/v1/postbox/admin/templates"
);
return response.templates;
}
export function createPostboxTemplate(
settings: ApiSettings,
payload: PostboxTemplateCreatePayload
): Promise<PostboxTemplate> {
return apiPostJson(settings, "/api/v1/postbox/admin/templates", payload);
}
export function revisePostboxTemplate(
settings: ApiSettings,
templateId: string,
payload: PostboxTemplateRevisionPayload
): Promise<PostboxTemplate> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/revisions`,
payload
);
}
export function publishPostboxTemplate(
settings: ApiSettings,
templateId: string,
revision?: number
): Promise<PostboxTemplate> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/publish`,
{ revision: revision ?? null }
);
}
export function retirePostboxTemplate(
settings: ApiSettings,
templateId: string
): Promise<PostboxTemplate> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/retire`,
{}
);
}
export function materializePostboxTemplate(
settings: ApiSettings,
templateId: string,
payload: {
organization_unit_id: string;
function_id: string;
context_key?: string | null;
}
): Promise<PostboxDirectoryItem> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/materialize`,
payload
);
}

View File

@@ -0,0 +1,977 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Archive,
Boxes,
Building2,
Inbox,
Pencil,
Plus,
RefreshCw,
Rocket,
Save,
Trash2
} from "lucide-react";
import {
AdminPageLayout,
Button,
ConfirmDialog,
Dialog,
FormField,
IconButton,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
type ApiSettings
} from "@govoplan/core-webui";
import {
archivePostbox,
createExactPostbox,
createPostboxTemplate,
listAdminPostboxes,
listPostboxOrganizationTargets,
listPostboxTemplates,
materializePostboxTemplate,
publishPostboxTemplate,
retirePostboxTemplate,
revisePostboxTemplate,
type PostboxDirectoryItem,
type PostboxExactCreatePayload,
type PostboxOrganizationFunction,
type PostboxOrganizationUnit,
type PostboxTemplate,
type PostboxTemplateCreatePayload,
type PostboxTemplateRevisionPayload
} from "../../api/postbox";
type AdminMode = "templates" | "postboxes";
type TemplateDraft = PostboxTemplateCreatePayload & { templateId: string };
type ExactDraft = PostboxExactCreatePayload;
type MaterializeDraft = {
templateId: string;
organization_unit_id: string;
function_id: string;
context_key: string;
};
const templateDefaults = (): TemplateDraft => ({
templateId: "",
slug: "",
name: "",
description: "",
function_type_id: null,
scope_kind: "tenant",
scope_id: null,
name_pattern: "{unit_name} / {function_name}",
address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
classification: "internal",
allow_vacant_delivery: true
});
const exactDefaults = (): ExactDraft => ({
name: "",
description: "",
organization_unit_id: "",
function_id: "",
address_key: "",
classification: "internal"
});
export default function PostboxAdminPanel({
settings,
canManageBindings,
canManageTemplates
}: {
settings: ApiSettings;
canManageBindings: boolean;
canManageTemplates: boolean;
}) {
const [mode, setMode] = useState<AdminMode>(
canManageTemplates ? "templates" : "postboxes"
);
const [templates, setTemplates] = useState<PostboxTemplate[]>([]);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [units, setUnits] = useState<PostboxOrganizationUnit[]>([]);
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const [selectedPostboxId, setSelectedPostboxId] = useState("");
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [templateDialogOpen, setTemplateDialogOpen] = useState(false);
const [templateDraft, setTemplateDraft] = useState<TemplateDraft>(templateDefaults);
const [exactDialogOpen, setExactDialogOpen] = useState(false);
const [exactDraft, setExactDraft] = useState<ExactDraft>(exactDefaults);
const [materializeDialogOpen, setMaterializeDialogOpen] = useState(false);
const [materializeDraft, setMaterializeDraft] = useState<MaterializeDraft>({
templateId: "",
organization_unit_id: "",
function_id: "",
context_key: ""
});
const [archiveTarget, setArchiveTarget] = useState<PostboxDirectoryItem | null>(null);
const selectedTemplate = useMemo(
() => templates.find((template) => template.id === selectedTemplateId) ?? templates[0] ?? null,
[templates, selectedTemplateId]
);
const selectedPostbox = useMemo(
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? postboxes[0] ?? null,
[postboxes, selectedPostboxId]
);
const functionTypes = useMemo(() => {
const values = new Map<string, string>();
for (const unit of units) {
for (const fn of unit.functions) {
if (fn.function_type_id && !values.has(fn.function_type_id)) {
values.set(fn.function_type_id, fn.name);
}
}
}
return [...values].map(([id, name]) => ({ id, name }));
}, [units]);
const unitTypes = useMemo(() => {
const values = new Map<string, string>();
for (const unit of units) {
if (unit.unit_type_id && !values.has(unit.unit_type_id)) {
values.set(unit.unit_type_id, unit.name);
}
}
return [...values].map(([id, example]) => ({ id, example }));
}, [units]);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const [nextTemplates, nextPostboxes, nextUnits] = await Promise.all([
canManageTemplates ? listPostboxTemplates(settings) : Promise.resolve([]),
canManageBindings ? listAdminPostboxes(settings) : Promise.resolve([]),
listPostboxOrganizationTargets(settings)
]);
setTemplates(nextTemplates);
setPostboxes(nextPostboxes);
setUnits(nextUnits);
setSelectedTemplateId((current) =>
current && nextTemplates.some((template) => template.id === current)
? current
: nextTemplates[0]?.id ?? ""
);
setSelectedPostboxId((current) =>
current && nextPostboxes.some((postbox) => postbox.id === current)
? current
: nextPostboxes[0]?.id ?? ""
);
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoading(false);
}
}, [canManageBindings, canManageTemplates, settings]);
useEffect(() => {
void load();
}, [load]);
function openNewTemplate() {
setTemplateDraft(templateDefaults());
setTemplateDialogOpen(true);
}
function openTemplateRevision(template: PostboxTemplate) {
const revision = currentRevision(template);
if (!revision) return;
setTemplateDraft({
templateId: template.id,
slug: template.slug,
name: template.name,
description: template.description || "",
function_type_id: revision.function_type_id ?? null,
scope_kind: revision.scope_kind,
scope_id: revision.scope_id ?? null,
name_pattern: revision.name_pattern,
address_pattern: revision.address_pattern,
classification: revision.classification,
allow_vacant_delivery: revision.allow_vacant_delivery
});
setTemplateDialogOpen(true);
}
async function saveTemplate() {
setBusy(true);
setError("");
setSuccess("");
try {
if (templateDraft.templateId) {
await revisePostboxTemplate(
settings,
templateDraft.templateId,
revisionPayload(templateDraft)
);
setSuccess("A new immutable template revision was created.");
} else {
await createPostboxTemplate(settings, {
slug: templateDraft.slug,
name: templateDraft.name,
description: templateDraft.description || null,
...revisionPayload(templateDraft)
});
setSuccess("Postbox template created as a draft.");
}
setTemplateDialogOpen(false);
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
async function publishSelected() {
if (!selectedTemplate) return;
setBusy(true);
setError("");
setSuccess("");
try {
await publishPostboxTemplate(
settings,
selectedTemplate.id,
selectedTemplate.current_revision
);
setSuccess(`Published revision ${selectedTemplate.current_revision}.`);
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
async function retireSelected() {
if (!selectedTemplate) return;
setBusy(true);
setError("");
setSuccess("");
try {
await retirePostboxTemplate(settings, selectedTemplate.id);
setSuccess("Postbox template retired. Existing addresses remain durable.");
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
function openExact() {
const unit = units.find((item) => item.functions.length);
setExactDraft({
...exactDefaults(),
organization_unit_id: unit?.id ?? "",
function_id: unit?.functions[0]?.id ?? ""
});
setExactDialogOpen(true);
}
async function saveExact() {
setBusy(true);
setError("");
setSuccess("");
try {
await createExactPostbox(settings, {
...exactDraft,
description: exactDraft.description || null,
address_key: exactDraft.address_key || null
});
setExactDialogOpen(false);
setSuccess("Exact function-bound Postbox created.");
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
function openMaterialize(template: PostboxTemplate) {
const revision = currentRevision(template);
const compatible = compatibleTargets(units, revision?.function_type_id);
const unit = compatible[0];
setMaterializeDraft({
templateId: template.id,
organization_unit_id: unit?.id ?? "",
function_id: unit?.functions[0]?.id ?? "",
context_key: ""
});
setMaterializeDialogOpen(true);
}
async function materialize() {
setBusy(true);
setError("");
setSuccess("");
try {
await materializePostboxTemplate(settings, materializeDraft.templateId, {
organization_unit_id: materializeDraft.organization_unit_id,
function_id: materializeDraft.function_id,
context_key: materializeDraft.context_key || null
});
setMaterializeDialogOpen(false);
setSuccess("Stable Postbox address resolved and materialized.");
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
async function confirmArchive() {
if (!archiveTarget) return;
setBusy(true);
setError("");
setSuccess("");
try {
await archivePostbox(settings, archiveTarget.id);
setSuccess("Postbox archived. Messages and delivery evidence were retained.");
setArchiveTarget(null);
await load();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
return (
<AdminPageLayout
title="Postboxes"
description="Manage durable organization-function addresses and reusable templates."
loading={loading}
error={error}
success={success}
className="postbox-admin-page"
actions={
<>
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => void load()}
disabled={busy}
/>
{mode === "templates" && canManageTemplates ? (
<Button variant="primary" onClick={openNewTemplate}>
<Plus size={16} /> New template
</Button>
) : null}
{mode === "postboxes" && canManageBindings ? (
<Button variant="primary" onClick={openExact}>
<Plus size={16} /> Exact postbox
</Button>
) : null}
</>
}
>
<SegmentedControl
value={mode}
onChange={(value) => setMode(value as AdminMode)}
options={[
...(canManageTemplates ? [{ id: "templates", label: "Templates" }] : []),
...(canManageBindings ? [{ id: "postboxes", label: "Postboxes" }] : [])
]}
ariaLabel="Postbox administration section"
/>
{mode === "templates" ? (
<TemplateWorkspace
templates={templates}
selected={selectedTemplate}
onSelect={setSelectedTemplateId}
onRevise={openTemplateRevision}
onPublish={() => void publishSelected()}
onRetire={() => void retireSelected()}
onMaterialize={openMaterialize}
busy={busy}
canManageBindings={canManageBindings}
/>
) : (
<PostboxWorkspace
postboxes={postboxes}
selected={selectedPostbox}
onSelect={setSelectedPostboxId}
onArchive={setArchiveTarget}
busy={busy}
/>
)}
<TemplateDialog
open={templateDialogOpen}
draft={templateDraft}
units={units}
functionTypes={functionTypes}
unitTypes={unitTypes}
busy={busy}
onChange={setTemplateDraft}
onClose={() => setTemplateDialogOpen(false)}
onSave={() => void saveTemplate()}
/>
<ExactPostboxDialog
open={exactDialogOpen}
draft={exactDraft}
units={units}
busy={busy}
onChange={setExactDraft}
onClose={() => setExactDialogOpen(false)}
onSave={() => void saveExact()}
/>
<MaterializeDialog
open={materializeDialogOpen}
draft={materializeDraft}
template={templates.find((item) => item.id === materializeDraft.templateId) ?? null}
units={units}
busy={busy}
onChange={setMaterializeDraft}
onClose={() => setMaterializeDialogOpen(false)}
onSave={() => void materialize()}
/>
<ConfirmDialog
open={Boolean(archiveTarget)}
title="Archive Postbox"
message={
archiveTarget
? `Archive "${archiveTarget.name}"? Its messages and delivery evidence remain retained, but the address stops accepting new delivery.`
: ""
}
confirmLabel="Archive"
tone="danger"
busy={busy}
onConfirm={() => void confirmArchive()}
onCancel={() => setArchiveTarget(null)}
/>
</AdminPageLayout>
);
}
function TemplateWorkspace({
templates,
selected,
onSelect,
onRevise,
onPublish,
onRetire,
onMaterialize,
busy,
canManageBindings
}: {
templates: PostboxTemplate[];
selected: PostboxTemplate | null;
onSelect: (id: string) => void;
onRevise: (template: PostboxTemplate) => void;
onPublish: () => void;
onRetire: () => void;
onMaterialize: (template: PostboxTemplate) => void;
busy: boolean;
canManageBindings: boolean;
}) {
const revision = selected ? currentRevision(selected) : null;
return (
<div className="postbox-admin-workspace">
<aside className="postbox-admin-list">
{!templates.length ? <p className="postbox-note">No Postbox templates.</p> : null}
{templates.length ? (
<SelectionList label="Postbox templates">
{templates.map((template) => (
<SelectionListItem
key={template.id}
selected={selected?.id === template.id}
onClick={() => onSelect(template.id)}
>
<span className="postbox-item-title">
<strong>{template.name}</strong>
<StatusBadge status={template.status} />
</span>
<span className="postbox-item-context">
{template.slug} · revision {template.current_revision}
</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</aside>
<section className="postbox-admin-detail">
{selected && revision ? (
<>
<div className="postbox-admin-detail-heading">
<div>
<span className="postbox-detail-kicker">Template</span>
<h2>{selected.name}</h2>
<p>{selected.description || "No description."}</p>
</div>
<div className="button-row compact-actions">
<Button onClick={() => onRevise(selected)} disabled={busy || selected.status === "retired"}>
<Pencil size={16} /> New revision
</Button>
<Button onClick={onPublish} disabled={busy || selected.status === "retired" || Boolean(revision.published_at)}>
<Save size={16} /> Publish
</Button>
{canManageBindings ? (
<Button onClick={() => onMaterialize(selected)} disabled={busy || selected.status !== "published"}>
<Rocket size={16} /> Resolve address
</Button>
) : null}
<Button variant="danger" onClick={onRetire} disabled={busy || selected.status === "retired"}>
<Trash2 size={16} /> Retire
</Button>
</div>
</div>
<dl className="postbox-admin-properties">
<div><dt>Revision</dt><dd>{revision.revision}{revision.published_at ? " · published" : " · draft"}</dd></div>
<div><dt>Function type</dt><dd>{revision.function_type_id || "Any function type"}</dd></div>
<div><dt>Scope</dt><dd>{revision.scope_kind}{revision.scope_id ? ` · ${revision.scope_id}` : ""}</dd></div>
<div><dt>Classification</dt><dd>{revision.classification}</dd></div>
<div><dt>Vacant delivery</dt><dd>{revision.allow_vacant_delivery ? "Accepted" : "Blocked"}</dd></div>
<div><dt>Encryption</dt><dd>{revision.encryption_profile}</dd></div>
<div><dt>Name pattern</dt><dd>{revision.name_pattern}</dd></div>
<div><dt>Address pattern</dt><dd>{revision.address_pattern}</dd></div>
</dl>
<div className="postbox-revision-history">
<h3>Immutable revisions</h3>
{selected.revisions.map((item) => (
<div key={item.id}>
<strong>Revision {item.revision}</strong>
<span>{item.classification} · {item.scope_kind}</span>
<StatusBadge
status={item.published_at ? "published" : "draft"}
label={item.published_at ? "Published" : "Draft"}
/>
</div>
))}
</div>
</>
) : (
<div className="postbox-empty">
<Boxes size={24} />
<strong>Select a template</strong>
<p>Published revisions lazily resolve stable unit-specific addresses.</p>
</div>
)}
</section>
</div>
);
}
function PostboxWorkspace({
postboxes,
selected,
onSelect,
onArchive,
busy
}: {
postboxes: PostboxDirectoryItem[];
selected: PostboxDirectoryItem | null;
onSelect: (id: string) => void;
onArchive: (postbox: PostboxDirectoryItem) => void;
busy: boolean;
}) {
return (
<div className="postbox-admin-workspace">
<aside className="postbox-admin-list">
{!postboxes.length ? <p className="postbox-note">No materialized Postboxes.</p> : null}
{postboxes.length ? (
<SelectionList label="Materialized Postboxes">
{postboxes.map((postbox) => (
<SelectionListItem
key={postbox.id}
selected={selected?.id === postbox.id}
onClick={() => onSelect(postbox.id)}
>
<span className="postbox-item-title">
<strong>{postbox.name}</strong>
<StatusBadge status={postbox.status} />
</span>
<span className="postbox-item-context">
{postbox.organization_unit_name} · {postbox.function_name}
</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</aside>
<section className="postbox-admin-detail">
{selected ? (
<>
<div className="postbox-admin-detail-heading">
<div>
<span className="postbox-detail-kicker">Postbox</span>
<h2>{selected.name}</h2>
<p>{selected.address}</p>
</div>
<Button
variant="danger"
onClick={() => onArchive(selected)}
disabled={busy || selected.status !== "active"}
>
<Archive size={16} /> Archive
</Button>
</div>
<dl className="postbox-admin-properties">
<div><dt>Organization unit</dt><dd>{selected.organization_unit_name || "None"}</dd></div>
<div><dt>Function</dt><dd>{selected.function_name || "None"}</dd></div>
<div><dt>Address key</dt><dd>{selected.address_key}</dd></div>
<div><dt>Classification</dt><dd>{selected.classification}</dd></div>
<div><dt>Current holders</dt><dd>{selected.holder_count}</dd></div>
<div><dt>Vacancy</dt><dd>{selected.vacant ? "Vacant" : "Staffed"}</dd></div>
<div><dt>Context</dt><dd>{selected.context_key || "None"}</dd></div>
<div><dt>Template revision</dt><dd>{selected.template_revision_id || "Exact Postbox"}</dd></div>
</dl>
</>
) : (
<div className="postbox-empty">
<Inbox size={24} />
<strong>Select a Postbox</strong>
<p>Materialized Postboxes remain durable through vacancy and reassignment.</p>
</div>
)}
</section>
</div>
);
}
function TemplateDialog({
open,
draft,
units,
functionTypes,
unitTypes,
busy,
onChange,
onClose,
onSave
}: {
open: boolean;
draft: TemplateDraft;
units: PostboxOrganizationUnit[];
functionTypes: Array<{ id: string; name: string }>;
unitTypes: Array<{ id: string; example: string }>;
busy: boolean;
onChange: (draft: TemplateDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const isRevision = Boolean(draft.templateId);
const scopeOptions = draft.scope_kind === "unit_type"
? unitTypes.map((item) => ({ id: item.id, label: `${item.id} (${item.example})` }))
: units.map((unit) => ({ id: unit.id, label: unit.name }));
const valid =
draft.name.trim() &&
draft.slug.trim() &&
draft.name_pattern.trim() &&
draft.address_pattern.trim() &&
(draft.scope_kind === "tenant" || Boolean(draft.scope_id));
return (
<Dialog
open={open}
title={isRevision ? "Create template revision" : "New Postbox template"}
className="postbox-dialog postbox-template-dialog"
onClose={onClose}
closeDisabled={busy}
footer={
<div className="button-row compact-actions">
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={onSave} disabled={busy || !valid}>
{isRevision ? "Create revision" : "Create draft"}
</Button>
</div>
}
>
<div className="postbox-form-grid two-columns">
<FormField label="Name">
<input
value={draft.name}
disabled={isRevision}
onChange={(event) => onChange({ ...draft, name: event.target.value })}
/>
</FormField>
<FormField label="Slug">
<input
value={draft.slug}
disabled={isRevision}
onChange={(event) => onChange({ ...draft, slug: event.target.value })}
/>
</FormField>
<FormField label="Description">
<input
value={draft.description || ""}
disabled={isRevision}
onChange={(event) => onChange({ ...draft, description: event.target.value })}
/>
</FormField>
<FormField label="Function type">
<select
value={draft.function_type_id || ""}
onChange={(event) => onChange({
...draft,
function_type_id: event.target.value || null
})}
>
<option value="">Any function type</option>
{functionTypes.map((item) => (
<option key={item.id} value={item.id}>{item.name} · {item.id}</option>
))}
</select>
</FormField>
<FormField label="Scope">
<select
value={draft.scope_kind}
onChange={(event) => {
const scope_kind = event.target.value as TemplateDraft["scope_kind"];
onChange({
...draft,
scope_kind,
scope_id: scope_kind === "tenant" ? null : ""
});
}}
>
<option value="tenant">Tenant</option>
<option value="unit">One unit</option>
<option value="subtree">Unit subtree</option>
<option value="unit_type">Unit type</option>
</select>
</FormField>
{draft.scope_kind !== "tenant" ? (
<FormField label="Scope target">
<select
value={draft.scope_id || ""}
onChange={(event) => onChange({ ...draft, scope_id: event.target.value || null })}
>
<option value="">Select target</option>
{scopeOptions.map((item) => (
<option key={item.id} value={item.id}>{item.label}</option>
))}
</select>
</FormField>
) : <div />}
<FormField label="Name pattern">
<input
value={draft.name_pattern}
onChange={(event) => onChange({ ...draft, name_pattern: event.target.value })}
/>
</FormField>
<FormField label="Address pattern">
<input
value={draft.address_pattern}
onChange={(event) => onChange({ ...draft, address_pattern: event.target.value })}
/>
</FormField>
<FormField label="Classification">
<input
value={draft.classification}
onChange={(event) => onChange({ ...draft, classification: event.target.value })}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Accept delivery while vacant"
checked={draft.allow_vacant_delivery}
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
/>
</div>
</div>
<p className="postbox-form-note">
Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.
</p>
</Dialog>
);
}
function ExactPostboxDialog({
open,
draft,
units,
busy,
onChange,
onClose,
onSave
}: {
open: boolean;
draft: ExactDraft;
units: PostboxOrganizationUnit[];
busy: boolean;
onChange: (draft: ExactDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const unit = units.find((item) => item.id === draft.organization_unit_id);
return (
<Dialog
open={open}
title="New exact Postbox"
className="postbox-dialog"
onClose={onClose}
closeDisabled={busy}
footer={
<div className="button-row compact-actions">
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button
variant="primary"
onClick={onSave}
disabled={busy || !draft.name.trim() || !draft.organization_unit_id || !draft.function_id}
>
Create Postbox
</Button>
</div>
}
>
<div className="postbox-form-grid two-columns">
<FormField label="Name">
<input value={draft.name} onChange={(event) => onChange({ ...draft, name: event.target.value })} />
</FormField>
<FormField label="Address key">
<input value={draft.address_key || ""} placeholder="Generated when empty" onChange={(event) => onChange({ ...draft, address_key: event.target.value })} />
</FormField>
<FormField label="Organization unit">
<select
value={draft.organization_unit_id}
onChange={(event) => {
const nextUnit = units.find((item) => item.id === event.target.value);
onChange({
...draft,
organization_unit_id: event.target.value,
function_id: nextUnit?.functions[0]?.id ?? ""
});
}}
>
<option value="">Select unit</option>
{units.filter((item) => item.functions.length).map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Function">
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
<option value="">Select function</option>
{(unit?.functions || []).map((fn) => (
<option key={fn.id} value={fn.id}>{fn.name}</option>
))}
</select>
</FormField>
<FormField label="Classification">
<input value={draft.classification} onChange={(event) => onChange({ ...draft, classification: event.target.value })} />
</FormField>
<FormField label="Description">
<input value={draft.description || ""} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
</FormField>
</div>
</Dialog>
);
}
function MaterializeDialog({
open,
draft,
template,
units,
busy,
onChange,
onClose,
onSave
}: {
open: boolean;
draft: MaterializeDraft;
template: PostboxTemplate | null;
units: PostboxOrganizationUnit[];
busy: boolean;
onChange: (draft: MaterializeDraft) => void;
onClose: () => void;
onSave: () => void;
}) {
const revision = template ? currentRevision(template) : null;
const compatible = compatibleTargets(units, revision?.function_type_id);
const unit = compatible.find((item) => item.id === draft.organization_unit_id);
return (
<Dialog
open={open}
title={`Resolve address${template ? ` · ${template.name}` : ""}`}
className="postbox-dialog"
onClose={onClose}
closeDisabled={busy}
footer={
<div className="button-row compact-actions">
<Button onClick={onClose} disabled={busy}>Cancel</Button>
<Button variant="primary" onClick={onSave} disabled={busy || !draft.organization_unit_id || !draft.function_id}>
Resolve address
</Button>
</div>
}
>
<div className="postbox-form-grid">
<FormField label="Organization unit">
<select
value={draft.organization_unit_id}
onChange={(event) => {
const nextUnit = compatible.find((item) => item.id === event.target.value);
onChange({
...draft,
organization_unit_id: event.target.value,
function_id: nextUnit?.functions[0]?.id ?? ""
});
}}
>
<option value="">Select unit</option>
{compatible.map((item) => (
<option key={item.id} value={item.id}>{item.name}</option>
))}
</select>
</FormField>
<FormField label="Function">
<select value={draft.function_id} onChange={(event) => onChange({ ...draft, function_id: event.target.value })}>
<option value="">Select function</option>
{(unit?.functions || []).map((fn) => (
<option key={fn.id} value={fn.id}>{fn.name}</option>
))}
</select>
</FormField>
<FormField label="Optional case or service context">
<input value={draft.context_key} onChange={(event) => onChange({ ...draft, context_key: event.target.value })} />
</FormField>
</div>
</Dialog>
);
}
function currentRevision(template: PostboxTemplate) {
return template.revisions.find((revision) => revision.revision === template.current_revision)
?? template.revisions.at(-1)
?? null;
}
function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
return {
function_type_id: draft.function_type_id || null,
scope_kind: draft.scope_kind,
scope_id: draft.scope_kind === "tenant" ? null : draft.scope_id || null,
name_pattern: draft.name_pattern,
address_pattern: draft.address_pattern,
classification: draft.classification,
allow_vacant_delivery: draft.allow_vacant_delivery
};
}
function compatibleTargets(
units: PostboxOrganizationUnit[],
functionTypeId?: string | null
): PostboxOrganizationUnit[] {
return units
.map((unit) => ({
...unit,
functions: functionTypeId
? unit.functions.filter((fn) => fn.function_type_id === functionTypeId)
: unit.functions
}))
.filter((unit) => unit.functions.length);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Postbox request failed";
}

View File

@@ -0,0 +1,90 @@
import { useCallback } from "react";
import { Inbox, Paperclip } from "lucide-react";
import { Link } from "react-router-dom";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
useDashboardWidgetData,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listPostboxMessages,
listPostboxes
} from "../../api/postbox";
export default function PostboxInboxWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const load = useCallback(async () => {
const postboxes = await listPostboxes(settings);
if (!postboxes.length) {
return { messages: [], total: 0 };
}
return listPostboxMessages(
settings,
postboxes.map((postbox) => postbox.id),
maxItems,
0,
"",
"unread"
);
}, [maxItems, settings]);
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
return (
<LoadingFrame loading={loading} label="Loading unread Postbox messages">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No unread Postbox messages."
items={(data?.messages ?? []).map((message) => ({
id: message.id,
title: message.subject,
detail: message.sender_label || message.producer_module || "Postbox",
meta: new Intl.DateTimeFormat(undefined, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(message.delivered_at)),
leading: <Inbox size={17} aria-hidden="true" />,
trailing: message.attachments.length ? (
<span title={`${message.attachments.length} attachments`}>
<Paperclip size={15} aria-hidden="true" />
</span>
) : undefined,
to: `/postbox?message=${encodeURIComponent(message.id)}`
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/postbox">
{data?.total ? `Open Postbox (${data.total} unread)` : "Open Postbox"}
</Link>
</div>
</LoadingFrame>
);
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}

View File

@@ -0,0 +1,800 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Archive,
Building2,
CheckCheck,
Inbox,
Layers3,
MailOpen,
Paperclip,
Pencil,
Plus,
RefreshCw,
Search,
Trash2,
UserRoundCheck,
X
} from "lucide-react";
import {
Button,
DataGridPaginationBar,
Dialog,
DismissibleAlert,
FormField,
IconButton,
SegmentedControl,
SelectionList,
SelectionListItem,
StatusBadge,
ToggleSwitch,
hasScope,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import {
createPostboxGrouping,
deletePostboxGrouping,
getPostboxMessage,
listPostboxGroupings,
listPostboxMessages,
listPostboxes,
markPostboxMessage,
updatePostboxGrouping,
type PostboxDirectoryItem,
type PostboxGrouping,
type PostboxMessage
} from "../../api/postbox";
type GroupingDraft = {
id: string;
name: string;
is_default: boolean;
postbox_ids: string[];
};
type MessageStateFilter = "all" | "unread" | "read" | "acknowledged";
const emptyGrouping = (): GroupingDraft => ({
id: "",
name: "",
is_default: false,
postbox_ids: []
});
export default function PostboxPage({
settings,
auth
}: {
settings: ApiSettings;
auth: AuthInfo;
}) {
const requestedMessageId = useRef(
new URLSearchParams(window.location.search).get("message") ?? ""
);
const requestedMessageLoaded = useRef(false);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
const [selectedScope, setSelectedScope] = useState("all");
const [selectedPostboxId, setSelectedPostboxId] = useState("");
const [messages, setMessages] = useState<PostboxMessage[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState("");
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
const [total, setTotal] = useState(0);
const [messageState, setMessageState] = useState<MessageStateFilter>("all");
const [searchDraft, setSearchDraft] = useState("");
const [messageQuery, setMessageQuery] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [loadingDirectory, setLoadingDirectory] = useState(true);
const [loadingMessages, setLoadingMessages] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [groupingDialogOpen, setGroupingDialogOpen] = useState(false);
const [groupingDraft, setGroupingDraft] = useState<GroupingDraft>(emptyGrouping);
const canAcknowledge = hasScope(auth, "postbox:message:acknowledge");
const selectedPostbox = useMemo(
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null,
[postboxes, selectedPostboxId]
);
const selectedGrouping = useMemo(
() => groupings.find((grouping) => grouping.id === selectedScope) ?? null,
[groupings, selectedScope]
);
const scopePostboxIds = useMemo(() => {
if (selectedPostboxId) return [selectedPostboxId];
if (selectedGrouping) {
const visible = new Set(postboxes.map((postbox) => postbox.id));
return selectedGrouping.postbox_ids.filter((postboxId) => visible.has(postboxId));
}
return postboxes.map((postbox) => postbox.id);
}, [postboxes, selectedGrouping, selectedPostboxId]);
const scopeKey = scopePostboxIds.join("|");
const loadDirectory = useCallback(async () => {
setLoadingDirectory(true);
setError("");
try {
const [nextPostboxes, nextGroupings] = await Promise.all([
listPostboxes(settings),
listPostboxGroupings(settings)
]);
setPostboxes(nextPostboxes);
setGroupings(nextGroupings);
setSelectedScope((current) => {
if (current === "all" || nextGroupings.some((grouping) => grouping.id === current)) {
return current;
}
return nextGroupings.find((grouping) => grouping.is_default)?.id ?? "all";
});
setSelectedPostboxId((current) =>
current && nextPostboxes.some((postbox) => postbox.id === current)
? current
: ""
);
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoadingDirectory(false);
}
}, [settings]);
const loadMessages = useCallback(async () => {
if (!scopePostboxIds.length) {
setMessages([]);
setSelectedMessageId("");
setSelectedMessage(null);
setTotal(0);
return;
}
setLoadingMessages(true);
setError("");
try {
const response = await listPostboxMessages(
settings,
scopePostboxIds,
pageSize,
(page - 1) * pageSize,
messageQuery,
messageState
);
setMessages(response.messages);
setTotal(response.total);
setSelectedMessageId((current) =>
current &&
(
response.messages.some((message) => message.id === current) ||
current === requestedMessageId.current
)
? current
: ""
);
if (!response.messages.length) setSelectedMessage(null);
} catch (loadError) {
setError(errorMessage(loadError));
} finally {
setLoadingMessages(false);
}
}, [messageQuery, messageState, page, pageSize, scopeKey, settings]);
useEffect(() => {
void loadDirectory();
}, [loadDirectory]);
useEffect(() => {
void loadMessages();
}, [loadMessages]);
useEffect(() => {
const messageId = requestedMessageId.current;
if (
requestedMessageLoaded.current ||
!messageId ||
loadingDirectory ||
!postboxes.length
) {
return;
}
requestedMessageLoaded.current = true;
let cancelled = false;
void (async () => {
try {
const message = await getPostboxMessage(settings, messageId);
if (cancelled) return;
if (!postboxes.some((postbox) => postbox.id === message.postbox_id)) {
setError("The linked message is not available in your current Postbox assignments.");
return;
}
setSelectedScope("all");
setSelectedPostboxId(message.postbox_id);
setSelectedMessageId(message.id);
setSelectedMessage(message);
} catch (loadError) {
if (!cancelled) setError(errorMessage(loadError));
}
})();
return () => {
cancelled = true;
};
}, [loadingDirectory, postboxes, settings]);
useEffect(() => {
if (!selectedMessageId) return;
let cancelled = false;
void (async () => {
try {
let message = await getPostboxMessage(settings, selectedMessageId);
if (!message.read_at) {
message = await markPostboxMessage(settings, selectedMessageId, "read");
}
if (cancelled) return;
setSelectedMessage(message);
setMessages((items) =>
items.map((item) => (item.id === message.id ? message : item))
);
} catch (loadError) {
if (!cancelled) setError(errorMessage(loadError));
}
})();
return () => {
cancelled = true;
};
}, [selectedMessageId, settings]);
async function acknowledgeSelected() {
if (!selectedMessage || !canAcknowledge) return;
setBusy(true);
setError("");
try {
const next = await markPostboxMessage(
settings,
selectedMessage.id,
"acknowledged"
);
setSelectedMessage(next);
setMessages((items) =>
items.map((item) => (item.id === next.id ? next : item))
);
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
function selectPostbox(postboxId: string) {
setSelectedPostboxId(postboxId);
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}
function selectScope(scopeId: string) {
setSelectedScope(scopeId);
setSelectedPostboxId("");
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}
function openNewGrouping() {
setGroupingDraft(emptyGrouping());
setGroupingDialogOpen(true);
}
function openGrouping(grouping: PostboxGrouping) {
setGroupingDraft({
id: grouping.id,
name: grouping.name,
is_default: grouping.is_default,
postbox_ids: [...grouping.postbox_ids]
});
setGroupingDialogOpen(true);
}
async function saveGrouping() {
if (!groupingDraft.name.trim()) return;
setBusy(true);
setError("");
const payload = {
name: groupingDraft.name.trim(),
is_default: groupingDraft.is_default,
postbox_ids: groupingDraft.postbox_ids
};
try {
const saved = groupingDraft.id
? await updatePostboxGrouping(settings, groupingDraft.id, payload)
: await createPostboxGrouping(settings, payload);
await loadDirectory();
setSelectedScope(saved.id);
setSelectedPostboxId("");
setGroupingDialogOpen(false);
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
async function removeGrouping() {
if (!groupingDraft.id) return;
setBusy(true);
setError("");
try {
await deletePostboxGrouping(settings, groupingDraft.id);
setSelectedScope("all");
setSelectedPostboxId("");
setGroupingDialogOpen(false);
await loadDirectory();
} catch (actionError) {
setError(errorMessage(actionError));
} finally {
setBusy(false);
}
}
return (
<main className="workspace-data-page module-entry-page postbox-page">
<div className="postbox-shell">
<aside className="postbox-directory" data-view-surface="postbox.inbox.directory">
<div className="postbox-bar">
<div className="postbox-bar-title">
<Inbox size={17} aria-hidden="true" />
<strong>Postbox</strong>
</div>
<div className="postbox-icon-actions">
<IconButton
label="New unified view"
icon={<Plus size={16} />}
onClick={openNewGrouping}
/>
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => void loadDirectory()}
disabled={loadingDirectory || busy}
/>
</div>
</div>
<div className="postbox-scope-control">
<label htmlFor="postbox-scope">Inbox view</label>
<div className="postbox-scope-row">
<select
id="postbox-scope"
value={selectedScope}
onChange={(event) => selectScope(event.target.value)}
>
<option value="all">All postboxes</option>
{groupings.map((grouping) => (
<option key={grouping.id} value={grouping.id}>
{grouping.name}{grouping.is_default ? " (default)" : ""}
</option>
))}
</select>
{selectedGrouping ? (
<IconButton
label="Edit unified view"
icon={<Pencil size={15} />}
onClick={() => openGrouping(selectedGrouping)}
/>
) : null}
</div>
</div>
<div className="postbox-directory-list">
{loadingDirectory ? <p className="postbox-note">Loading postboxes</p> : null}
{!loadingDirectory && !postboxes.length ? (
<div className="postbox-empty compact">
<Archive size={20} />
<strong>No assigned postboxes</strong>
<p>Postboxes appear when your account has a current matching function assignment.</p>
</div>
) : null}
{postboxes.length ? (
<SelectionList label="Assigned postboxes">
{postboxes.map((postbox) => (
<SelectionListItem
key={postbox.id}
selected={selectedPostboxId === postbox.id}
className="postbox-directory-item"
onClick={() => selectPostbox(postbox.id)}
>
<span className="postbox-item-title">
<strong>{postbox.name}</strong>
{postbox.vacant ? (
<StatusBadge status="warning" label="Vacant" />
) : null}
</span>
<span className="postbox-item-context">
{postbox.organization_unit_name || "No organization"} ·{" "}
{postbox.function_name || "No function"}
</span>
<span className="postbox-item-address">{postbox.address}</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</div>
</aside>
<section className="postbox-message-list" data-view-surface="postbox.inbox.messages">
<div className="postbox-bar">
<div className="postbox-bar-title">
{selectedPostbox ? (
<>
<Building2 size={17} aria-hidden="true" />
<strong>{selectedPostbox.name}</strong>
</>
) : selectedGrouping ? (
<>
<Layers3 size={17} aria-hidden="true" />
<strong>{selectedGrouping.name}</strong>
</>
) : (
<>
<Layers3 size={17} aria-hidden="true" />
<strong>All postboxes</strong>
</>
)}
<span className="postbox-total">{total}</span>
</div>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => void loadMessages()}
disabled={loadingMessages || busy}
/>
</div>
<div className="postbox-message-filters">
<div className="postbox-search-row">
<input
type="search"
value={searchDraft}
placeholder="Search messages"
aria-label="Search Postbox messages"
onChange={(event) => setSearchDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
event.preventDefault();
setMessageQuery(searchDraft.trim());
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}}
/>
{searchDraft || messageQuery ? (
<IconButton
label="Clear message search"
icon={<X size={15} />}
onClick={() => {
setSearchDraft("");
setMessageQuery("");
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}}
/>
) : null}
<IconButton
label="Search messages"
icon={<Search size={15} />}
onClick={() => {
setMessageQuery(searchDraft.trim());
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}}
/>
</div>
<SegmentedControl<MessageStateFilter>
ariaLabel="Message state"
width="fill"
options={[
{ id: "all", label: "All" },
{ id: "unread", label: "Unread" },
{ id: "read", label: "Read" },
{ id: "acknowledged", label: "Acknowledged" }
]}
value={messageState}
onChange={(next) => {
setMessageState(next);
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}}
/>
</div>
{error ? (
<DismissibleAlert tone="danger" compact resetKey={error}>
{error}
</DismissibleAlert>
) : null}
<div className="postbox-messages">
{loadingMessages ? <p className="postbox-note">Loading messages</p> : null}
{!loadingMessages && !messages.length ? (
<div className="postbox-empty">
<MailOpen size={24} />
<strong>No messages</strong>
<p>This view has no delivered Postbox messages.</p>
</div>
) : null}
{messages.length ? (
<SelectionList label="Postbox messages">
{messages.map((message) => (
<SelectionListItem
key={message.id}
selected={selectedMessageId === message.id}
className={`postbox-message-item ${message.read_at ? "is-read" : "is-unread"}`}
onClick={() => setSelectedMessageId(message.id)}
>
<span className="postbox-message-heading">
<strong>{message.subject}</strong>
<time>{formatDate(message.delivered_at)}</time>
</span>
<span className="postbox-message-preview">
{message.sender_label || message.producer_module || "Platform"}
</span>
<span className="postbox-message-meta">
<span>{sourceName(postboxes, message.postbox_id)}</span>
{message.attachments.length ? (
<span><Paperclip size={13} /> {message.attachments.length}</span>
) : null}
{message.acknowledged_at ? (
<span><CheckCheck size={13} /> Acknowledged</span>
) : null}
</span>
</SelectionListItem>
))}
</SelectionList>
) : null}
</div>
<DataGridPaginationBar
page={page}
pageSize={pageSize}
totalRows={total}
pageSizeOptions={[25, 50, 100, 200]}
disabled={loadingMessages}
ariaLabel="Postbox message pagination"
onPageChange={(next) => {
setPage(next);
setSelectedMessageId("");
setSelectedMessage(null);
}}
onPageSizeChange={(next) => {
setPageSize(next);
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
}}
/>
</section>
<section className="postbox-detail">
<div className="postbox-bar">
<div className="postbox-bar-title">
<MailOpen size={17} aria-hidden="true" />
<strong>{selectedMessage?.subject || "Message"}</strong>
</div>
<Button
onClick={() => void acknowledgeSelected()}
disabled={!selectedMessage || Boolean(selectedMessage.acknowledged_at) || busy}
disabledReason={!canAcknowledge ? "You cannot acknowledge Postbox messages." : undefined}
>
<CheckCheck size={16} /> Acknowledge
</Button>
</div>
{selectedMessage ? (
<MessageDetail
message={selectedMessage}
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
/>
) : (
<div className="postbox-empty">
<Inbox size={24} />
<strong>Select a message</strong>
<p>Source, function context, content, and evidence remain attached to the originating postbox.</p>
</div>
)}
</section>
</div>
<Dialog
open={groupingDialogOpen}
title={groupingDraft.id ? "Edit unified view" : "New unified view"}
className="postbox-dialog"
onClose={() => setGroupingDialogOpen(false)}
closeDisabled={busy}
footer={
<div className="postbox-dialog-actions">
{groupingDraft.id ? (
<Button
variant="danger"
onClick={() => void removeGrouping()}
disabled={busy}
>
<Trash2 size={16} /> Delete
</Button>
) : <span />}
<div className="button-row compact-actions">
<Button onClick={() => setGroupingDialogOpen(false)} disabled={busy}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void saveGrouping()}
disabled={busy || !groupingDraft.name.trim()}
>
Save
</Button>
</div>
</div>
}
>
<div className="postbox-form-grid">
<FormField label="Name">
<input
value={groupingDraft.name}
onChange={(event) =>
setGroupingDraft((current) => ({
...current,
name: event.target.value
}))
}
/>
</FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Default unified view"
checked={groupingDraft.is_default}
onChange={(checked) =>
setGroupingDraft((current) => ({
...current,
is_default: checked
}))
}
/>
</div>
</div>
<fieldset className="postbox-source-selector">
<legend>Source postboxes</legend>
{postboxes.map((postbox) => (
<label key={postbox.id}>
<input
type="checkbox"
checked={groupingDraft.postbox_ids.includes(postbox.id)}
onChange={(event) =>
setGroupingDraft((current) => ({
...current,
postbox_ids: event.target.checked
? [...current.postbox_ids, postbox.id]
: current.postbox_ids.filter((id) => id !== postbox.id)
}))
}
/>
<span>
<strong>{postbox.name}</strong>
<small>{postbox.organization_unit_name} · {postbox.function_name}</small>
</span>
</label>
))}
</fieldset>
</Dialog>
</main>
);
}
function MessageDetail({
message,
postbox
}: {
message: PostboxMessage;
postbox?: PostboxDirectoryItem;
}) {
return (
<div className="postbox-message-detail">
<header>
<div className="postbox-detail-status">
<StatusBadge status={message.status} />
<StatusBadge status={message.classification} />
{message.acknowledged_at ? (
<StatusBadge status="success" label="Acknowledged" />
) : null}
</div>
<h1>{message.subject}</h1>
<div className="postbox-detail-byline">
<span>{message.sender_label || message.producer_module || "Platform"}</span>
<time>{formatLongDate(message.delivered_at)}</time>
</div>
</header>
<section className="postbox-provenance">
<h2>Source and responsibility</h2>
<dl>
<div><dt>Postbox</dt><dd>{postbox?.name || message.postbox_id}</dd></div>
<div><dt>Organization</dt><dd>{postbox?.organization_unit_name || "Not recorded"}</dd></div>
<div><dt>Function</dt><dd>{postbox?.function_name || "Not recorded"}</dd></div>
<div><dt>Address</dt><dd>{postbox?.address || "Not loaded"}</dd></div>
<div><dt>Producer</dt><dd>{producerLabel(message)}</dd></div>
<div><dt>Encryption profile</dt><dd>{message.encryption_profile} · epoch {message.key_epoch}</dd></div>
</dl>
</section>
<section className="postbox-body">
<p>{message.body_text || "No plaintext body is available for this message."}</p>
</section>
{message.participants.length ? (
<section className="postbox-participants">
<h2>Participants</h2>
{message.participants.map((participant, index) => (
<div key={`${participant.kind}-${participant.reference_id || index}`}>
<strong>{participant.kind}</strong>
<span>{participant.label || participant.address || participant.reference_id || participant.reference_type}</span>
</div>
))}
</section>
) : null}
<section className="postbox-attachments">
<h2>Evidence and attachments</h2>
{!message.attachments.length ? <p>No attachment references.</p> : null}
{message.attachments.map((attachment) => (
<div key={`${attachment.reference_type}:${attachment.reference_id}`}>
<Paperclip size={15} />
<span>
<strong>{attachment.name || attachment.reference_id}</strong>
<small>{attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""}</small>
</span>
</div>
))}
</section>
{postbox?.access ? (
<section className="postbox-access-explanation">
<UserRoundCheck size={17} />
<div>
<strong>Current access</strong>
<p>{postbox.access.explanation}</p>
</div>
</section>
) : null}
</div>
);
}
function sourceName(
postboxes: PostboxDirectoryItem[],
postboxId: string
): string {
return postboxes.find((postbox) => postbox.id === postboxId)?.name ?? "Postbox";
}
function producerLabel(message: PostboxMessage): string {
const resource = [
message.producer_module,
message.producer_resource_type,
message.producer_resource_id
].filter(Boolean);
return resource.length ? resource.join(" / ") : "Platform-native message";
}
function formatDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit"
}).format(date);
}
function formatLongDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, {
dateStyle: "long",
timeStyle: "short"
}).format(date);
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : "Postbox request failed";
}

3
webui/src/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { postboxModule as default, postboxModule } from "./module";
export { default as PostboxPage } from "./features/postbox/PostboxPage";
export { default as PostboxAdminPanel } from "./features/postbox/PostboxAdminPanel";

151
webui/src/module.ts Normal file
View File

@@ -0,0 +1,151 @@
import { createElement, lazy } from "react";
import {
hasScope,
type AdminSectionsUiCapability,
type DashboardWidgetsUiCapability,
type PlatformWebModule
} from "@govoplan/core-webui";
import PostboxInboxWidget from "./features/postbox/PostboxInboxWidget";
import "./styles/postbox.css";
const PostboxPage = lazy(() => import("./features/postbox/PostboxPage"));
const PostboxAdminPanel = lazy(
() => import("./features/postbox/PostboxAdminPanel")
);
const readScope = ["postbox:postbox:read"];
const postboxDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "postbox.inbox",
surfaceId: "postbox.widget.inbox",
title: "Postbox inbox",
description: "Unread messages across accessible Postboxes.",
moduleId: "postbox",
category: "Communication",
order: 55,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: readScope,
refreshIntervalMs: 30_000,
defaultConfiguration: {
maxItems: 5
},
configurationFields: [
{
id: "maxItems",
label: "Maximum messages",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(PostboxInboxWidget, {
settings,
refreshKey,
configuration
})
}
]
};
const postboxAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "postbox",
label: "Postboxes",
group: "TENANT",
order: 45,
surfaceId: "postbox.admin.templates",
anyOf: [
"postbox:binding:admin",
"postbox:template:admin"
],
render: ({ settings, auth }) =>
createElement(PostboxAdminPanel, {
settings,
canManageBindings: hasScope(auth, "postbox:binding:admin"),
canManageTemplates: hasScope(auth, "postbox:template:admin")
})
}
]
};
export const postboxModule: PlatformWebModule = {
id: "postbox",
label: "Postbox",
version: "0.1.0",
dependencies: ["identity", "organizations", "idm"],
optionalDependencies: [
"access",
"audit",
"campaigns",
"files",
"mail",
"notifications",
"policy",
"portal",
"views",
"workflow"
],
navItems: [
{
to: "/postbox",
label: "Postbox",
iconName: "inbox",
anyOf: readScope,
order: 58
}
],
routes: [
{
path: "/postbox",
anyOf: readScope,
order: 58,
surfaceId: "postbox.inbox.messages",
render: ({ settings, auth }) =>
createElement(PostboxPage, { settings, auth })
}
],
viewSurfaces: [
{
id: "postbox.inbox.directory",
moduleId: "postbox",
kind: "section",
label: "Postbox directory",
order: 10
},
{
id: "postbox.inbox.messages",
moduleId: "postbox",
kind: "section",
label: "Postbox messages",
order: 20
},
{
id: "postbox.widget.inbox",
moduleId: "postbox",
kind: "section",
label: "Postbox inbox widget",
order: 25
},
{
id: "postbox.admin.templates",
moduleId: "postbox",
kind: "section",
label: "Postbox templates and bindings",
order: 30
}
],
uiCapabilities: {
"admin.sections": postboxAdminSections,
"dashboard.widgets": postboxDashboardWidgets
}
};
export default postboxModule;

View File

@@ -0,0 +1,640 @@
.postbox-page {
position: relative;
display: grid;
grid-template-rows: minmax(0, 1fr);
height: calc(100vh - 115px);
min-height: 0;
overflow: hidden;
padding: 0;
color: var(--text);
background: var(--bg);
}
.postbox-page *,
.postbox-page *::before,
.postbox-page *::after,
.postbox-admin-page *,
.postbox-admin-page *::before,
.postbox-admin-page *::after {
box-sizing: border-box;
}
.postbox-shell {
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns:
minmax(250px, 310px)
minmax(290px, 370px)
minmax(360px, 1fr);
height: 100%;
overflow: hidden;
background: var(--panel);
}
.postbox-directory,
.postbox-message-list,
.postbox-detail {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--panel);
}
.postbox-directory,
.postbox-message-list {
border-right: var(--border-line);
}
.postbox-directory {
background: var(--panel-soft);
}
.postbox-bar,
.postbox-bar-title,
.postbox-icon-actions,
.postbox-scope-row,
.postbox-item-title,
.postbox-message-heading,
.postbox-message-meta,
.postbox-detail-status,
.postbox-detail-byline,
.postbox-access-explanation,
.postbox-attachments > div,
.postbox-participants > div {
display: flex;
align-items: center;
}
.postbox-bar {
flex: 0 0 auto;
min-height: 54px;
justify-content: space-between;
gap: 10px;
border-bottom: var(--border-line);
background: var(--panel-header);
padding: 8px 12px;
}
.postbox-bar-title {
min-width: 0;
gap: 8px;
color: var(--text-strong);
}
.postbox-bar-title strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-icon-actions {
gap: 5px;
}
.postbox-total {
min-width: 24px;
height: 22px;
display: inline-grid;
place-items: center;
border-radius: 999px;
background: var(--line);
color: var(--text-strong);
font-size: 12px;
font-weight: 800;
}
.postbox-scope-control {
flex: 0 0 auto;
border-bottom: var(--border-line);
padding: 9px 10px;
}
.postbox-scope-control > label {
display: block;
margin-bottom: 5px;
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.postbox-scope-row {
gap: 6px;
}
.postbox-scope-row select {
min-width: 0;
flex: 1;
}
.postbox-directory-list,
.postbox-messages {
min-height: 0;
overflow: auto;
padding: 7px;
}
.postbox-directory-list,
.postbox-messages {
flex: 1 1 auto;
}
.postbox-message-filters {
flex: 0 0 auto;
display: grid;
gap: 7px;
border-bottom: var(--border-line);
padding: 8px 9px;
}
.postbox-search-row {
display: flex;
align-items: center;
gap: 5px;
}
.postbox-search-row input {
min-width: 0;
flex: 1;
}
.postbox-message-list > .data-grid-pagination {
flex: 0 0 auto;
flex-wrap: wrap;
gap: 7px 12px;
border-top: var(--border-line);
}
.postbox-message-list > .data-grid-pagination .data-grid-page-controls {
width: 100%;
justify-content: center;
}
.postbox-directory-list .selection-list,
.postbox-messages .selection-list,
.postbox-admin-list .selection-list {
gap: 4px;
}
.postbox-directory-item,
.postbox-message-item,
.postbox-admin-list .selection-list-item {
display: grid;
min-height: 64px;
gap: 4px;
align-content: center;
text-align: left;
}
.postbox-item-title,
.postbox-message-heading {
min-width: 0;
justify-content: space-between;
gap: 10px;
}
.postbox-item-title strong,
.postbox-message-heading strong {
min-width: 0;
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-item-context,
.postbox-item-address,
.postbox-message-preview,
.postbox-message-meta {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-message-item.is-read strong {
font-weight: 550;
}
.postbox-message-item.is-unread strong {
font-weight: 800;
}
.postbox-message-heading time {
flex: 0 0 auto;
color: var(--muted);
font-size: 11px;
}
.postbox-message-meta {
justify-content: flex-start;
gap: 10px;
}
.postbox-message-meta span {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 3px;
}
.postbox-message-list > .alert {
flex: 0 0 auto;
margin: 8px 10px 0;
}
.postbox-message-detail {
min-height: 0;
overflow: auto;
padding: 20px 24px 28px;
}
.postbox-message-detail > header,
.postbox-provenance,
.postbox-body,
.postbox-participants,
.postbox-attachments {
border-bottom: var(--border-line);
padding-bottom: 18px;
margin-bottom: 18px;
}
.postbox-detail-status {
flex-wrap: wrap;
gap: 6px;
}
.postbox-message-detail h1 {
max-width: 900px;
margin: 12px 0 9px;
color: var(--text-strong);
font-size: 24px;
font-weight: 650;
overflow-wrap: anywhere;
}
.postbox-detail-byline {
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 12px;
}
.postbox-provenance h2,
.postbox-participants h2,
.postbox-attachments h2 {
margin: 0 0 10px;
color: var(--text-strong);
font-size: 14px;
}
.postbox-provenance dl,
.postbox-admin-properties {
display: grid;
grid-template-columns: repeat(2, minmax(170px, 1fr));
gap: 10px 18px;
margin: 0;
}
.postbox-provenance dt,
.postbox-admin-properties dt {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.postbox-provenance dd,
.postbox-admin-properties dd {
min-width: 0;
margin: 3px 0 0;
overflow-wrap: anywhere;
}
.postbox-body p {
max-width: 900px;
margin: 0;
line-height: 1.6;
white-space: pre-wrap;
}
.postbox-participants,
.postbox-attachments {
display: grid;
gap: 7px;
}
.postbox-participants > div,
.postbox-attachments > div {
justify-content: flex-start;
gap: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
padding: 9px 10px;
}
.postbox-participants > div strong {
min-width: 90px;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
}
.postbox-attachments > p {
margin: 0;
color: var(--muted);
}
.postbox-attachments > div span {
min-width: 0;
display: grid;
gap: 2px;
}
.postbox-attachments > div strong,
.postbox-attachments > div small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-attachments > div small {
color: var(--muted);
}
.postbox-access-explanation {
align-items: flex-start;
gap: 10px;
border-left: 3px solid var(--green);
background: var(--success-soft);
padding: 10px 12px;
}
.postbox-access-explanation p {
margin: 3px 0 0;
color: var(--muted);
line-height: 1.4;
}
.postbox-empty {
min-height: 100%;
display: grid;
place-items: center;
align-content: center;
gap: 7px;
padding: 30px;
color: var(--muted);
text-align: center;
}
.postbox-empty.compact {
min-height: 180px;
padding: 20px;
}
.postbox-empty strong {
color: var(--text-strong);
}
.postbox-empty p,
.postbox-note {
max-width: 470px;
margin: 0;
color: var(--muted);
font-size: 13px;
}
.postbox-dialog {
width: min(720px, calc(100vw - 32px));
max-width: none;
}
.postbox-template-dialog {
width: min(900px, calc(100vw - 32px));
}
.postbox-form-grid {
display: grid;
gap: 14px;
}
.postbox-form-grid.two-columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.postbox-form-grid input,
.postbox-form-grid select {
width: 100%;
}
.postbox-toggle-field {
min-height: 62px;
display: flex;
align-items: flex-end;
padding-bottom: 7px;
}
.postbox-form-note {
margin: 15px 0 0;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.postbox-dialog-actions {
width: 100%;
display: flex;
justify-content: space-between;
gap: 12px;
}
.postbox-source-selector {
max-height: 310px;
display: grid;
gap: 5px;
overflow: auto;
border: var(--border-line);
margin: 16px 0 0;
padding: 10px;
}
.postbox-source-selector legend {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.postbox-source-selector label {
display: flex;
align-items: center;
gap: 9px;
border-radius: var(--radius-sm);
padding: 7px 8px;
}
.postbox-source-selector label:hover {
background: var(--sidebar-hover-bg);
}
.postbox-source-selector label span {
min-width: 0;
display: grid;
gap: 2px;
}
.postbox-source-selector small {
color: var(--muted);
}
.postbox-admin-page > .segmented-control {
margin-bottom: 14px;
}
.postbox-admin-workspace {
min-height: 520px;
display: grid;
grid-template-columns: minmax(250px, 320px) minmax(0, 1fr);
border: var(--border-line);
background: var(--panel);
overflow: hidden;
}
.postbox-admin-list {
min-width: 0;
min-height: 0;
max-height: 680px;
overflow: auto;
border-right: var(--border-line);
background: var(--panel-soft);
padding: 8px;
}
.postbox-admin-detail {
min-width: 0;
min-height: 0;
max-height: 680px;
overflow: auto;
padding: 20px 24px 26px;
}
.postbox-admin-detail-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
border-bottom: var(--border-line);
padding-bottom: 17px;
margin-bottom: 18px;
}
.postbox-admin-detail-heading h2 {
margin: 2px 0 4px;
color: var(--text-strong);
font-size: 20px;
}
.postbox-admin-detail-heading p {
margin: 0;
color: var(--muted);
}
.postbox-detail-kicker {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.postbox-admin-properties {
border-bottom: var(--border-line);
padding-bottom: 18px;
}
.postbox-revision-history {
margin-top: 18px;
}
.postbox-revision-history h3 {
margin: 0 0 9px;
color: var(--text-strong);
font-size: 14px;
}
.postbox-revision-history > div {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(160px, 2fr) auto;
align-items: center;
gap: 10px;
border-top: var(--border-line);
padding: 9px 0;
}
.postbox-revision-history > div span:not(.status-badge) {
color: var(--muted);
}
@media (max-width: 1180px) {
.postbox-shell {
grid-template-columns: minmax(230px, 290px) minmax(280px, 340px) minmax(330px, 1fr);
overflow-x: auto;
}
}
@media (max-width: 900px) {
.postbox-page {
height: auto;
min-height: calc(100vh - 115px);
overflow: auto;
}
.postbox-shell {
grid-template-columns: 1fr;
height: auto;
overflow: visible;
}
.postbox-directory,
.postbox-message-list {
min-height: 260px;
max-height: 42vh;
border-right: 0;
border-bottom: var(--border-line);
}
.postbox-detail {
min-height: 440px;
}
.postbox-admin-workspace {
grid-template-columns: 1fr;
}
.postbox-admin-list {
max-height: 260px;
border-right: 0;
border-bottom: var(--border-line);
}
.postbox-admin-detail-heading {
flex-direction: column;
}
}
@media (max-width: 640px) {
.postbox-form-grid.two-columns,
.postbox-provenance dl,
.postbox-admin-properties {
grid-template-columns: 1fr;
}
.postbox-message-detail {
padding: 16px;
}
}

1
webui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

20
webui/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "tests"]
}