5 Commits
v0.1.7 ... main

33 changed files with 10053 additions and 46 deletions

View File

@@ -2,9 +2,9 @@
## Scope
This repository owns the `postbox` module: in-platform postboxes, role-organization-bound access, postbox messages, postbox directory APIs, internal and portal postbox surfaces, campaign postbox integration, postbox-owned migrations, and future `@govoplan/postbox-webui`.
This repository owns the `postbox` module: in-platform postboxes, function-organization-bound access, postbox messages, postbox directory APIs, internal and portal postbox surfaces, campaign postbox integration, postbox-owned migrations, and future `@govoplan/postbox-webui`.
Postboxes are not login-bound mailboxes. They are platform-owned communication and access containers whose visibility is derived from organizational role assignments, explicit postbox bindings, and capability contracts.
Postboxes are not login-bound mailboxes. They are platform-owned communication and access containers whose visibility is derived primarily from effective identity-to-organization-function assignments, explicit postbox bindings, and capability contracts.
## Local Commands
@@ -18,14 +18,15 @@ cd /mnt/DATA/git/govoplan-core
For combined checks once implementation starts, run:
```bash
cd /mnt/DATA/git/govoplan-core
./scripts/check-focused.sh
cd /mnt/DATA/git/govoplan
tools/checks/check-focused.sh
```
## Working Rules
- Keep postbox behavior in this module, not core.
- Derive role-organization access through core/access contracts; do not duplicate RBAC membership logic locally.
- Resolve units and functions through Organizations and effective identity-to-function assignments through IDM. Use Core/Access for generic Postbox action authorization; do not turn a function assignment into an RBAC role merely to open its function postbox.
- Do not duplicate identity, organization, assignment, hierarchy, or RBAC logic locally.
- Do not import mail, files, campaign, or portal internals. Use manifests, capabilities, events, API routes, and typed DTOs.
- Treat postbox access changes as auditable security events, especially when access changes because a role assignment changes.
- Treat postbox access changes as auditable security events, especially when access changes because a function assignment, delegation, acting context, or generic Postbox permission changes.
- Keep campaign delivery, file evidence, and portal usage optional behind capability boundaries.

View File

@@ -1,6 +1,11 @@
# govoplan-postbox
GovOPlaN Postbox provides platform-owned postboxes for internal work, portals, campaign flows, and role-bound organizational communication.
<!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Postbox provides platform-owned postboxes for internal work, portals,
campaign flows, and function-bound organizational communication.
## Ownership
@@ -9,18 +14,47 @@ This repository owns:
- backend module manifest `postbox`
- postbox permissions and policy checks
- postbox, binding, message, participant, attachment-reference, and audit-facing data models
- role-organization-bound access resolution for postboxes
- function-organization-bound access resolution for postboxes through
normalized Identity, IDM, Organizations, and Core/Access contracts
- API routes for postbox directory, messages, access checks, and administration
- 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 discovery, migrations, CSRF/API helpers, and shell layout. Access owns identities, users, groups, roles, memberships, and administrative RBAC surfaces.
Core owns auth, tenants, RBAC evaluation, database/session primitives, module
discovery, migrations, CSRF/API helpers, and shell layout. Identity owns
identities and account links. Organizations owns units, structures, function
types, and concrete functions. IDM owns effective identity-to-function
assignments, delegation, and acting-for facts. Access owns generic application
roles, permissions, and administrative RBAC surfaces.
## Role-bound postboxes
## Function-bound postboxes
A role-bound postbox is linked to an organizational unit and one or more roles. A person can access that postbox while their identity has an effective matching role in that organizational unit. Access is not tied to a login mailbox, personal email address, or static user assignment.
A function-bound postbox has a stable institutional address linked to an
organizational unit and one or more functions. It can exist with zero, one, or
several current incumbents. A person can access it only while their identity
has an effective assignment or time-bounded delegation in that organizational
context and their account may perform the relevant Postbox action. Access is
not tied to a login mailbox, personal email address, static user assignment, or
function-to-RBAC-role mapping.
When the role assignment changes, postbox access changes with it. The postbox keeps durable message and evidence history, while authorization remains derived from current platform role state.
When the assignment changes, postbox access and encrypted key grants change
with it. The postbox keeps durable content and evidence history through
vacancy, hand-over, and delegation; multiple incumbents receive independently
auditable access. Revocation prevents future platform key access but cannot
erase plaintext already fetched or exported.
Reusable Postbox templates may target a function type and organization scope.
Unit-specific addresses are resolved lazily and remain stable through vacancy
and reassignment. Exact postboxes remain available for exceptional
responsibilities or case/service contexts.
Users holding several functions may group selected postboxes into unified
inbox views. These are query projections only: messages, address, read state,
retention, and evidence remain attached to their source postboxes.
Hierarchy propagation is off by default. Explicit copy, attention/escalation,
and shared-visibility rules are distinct, bounded, classification-aware, and
snapshotted when a message is delivered.
## Module integration
@@ -38,3 +72,24 @@ Frontend package:
```
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
```

View File

@@ -4,7 +4,13 @@
GovOPlaN Postbox provides in-platform postboxes that are addressable containers for messages, files, workflow evidence, and operational handoff. They can be used internally, exposed through portals, and connected to campaign workflows.
The key distinction from a mailbox is ownership. A mailbox is usually bound to a login, user credential, or external mail account. A GovOPlaN postbox is bound to platform context: organization, role, process, portal, campaign, or service responsibility.
The key distinction from a mailbox is ownership. A mailbox is usually bound to
a login, user credential, or external mail account. A GovOPlaN postbox is a
durable communication and content-access container bound to institutional
context: primarily a function in an organizational unit, and where explicitly
needed a role, process, portal, campaign, or service responsibility. It may look
like an inbox for a message task or like a vault for content shared with the
current holders of that responsibility; neither form is owned by one account.
The strategic target is an encrypted administrative postbox. The first
implementation may start with ordinary persisted messages, but the model must
@@ -13,45 +19,87 @@ manifests, external-recipient tokens, or honest retraction semantics. The
cross-module target architecture is recorded in
`govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`.
## Function/Role-Organization-Bound Access
## Function-Organization-Bound Access
The special access pattern is a postbox linked to an organizational unit and
one or more roles or functions. A person can access that postbox while their
account has an effective matching function assignment in that organizational
unit, or while a matching function maps to one of the required roles.
The primary access pattern is a postbox linked to an organizational unit and
one or more institutional functions. A person can access that postbox while
their identity/account has an effective matching function assignment in that
organizational unit and their account may perform the requested Postbox action.
Opening a function postbox does not require mapping the function to an RBAC
role.
Example:
- Organizational unit: `District Office North`
- Required role: `Case Clerk`
- Required function: `Case Clerk`
- Postbox: `District Office North / Case Clerk Intake`
Any identity/account currently holding the `Case Clerk` function or a mapped
role for `District Office North` can see the postbox. When the function,
delegation, or role mapping is removed or expires, access disappears without
moving messages or reassigning a mailbox.
Any identity/account currently holding the `Case Clerk` function for `District
Office North` can see the postbox if it also satisfies the generic Postbox
permission and applicable policy. When the function assignment or delegation
is removed or expires, access disappears without moving messages or
reassigning a mailbox.
The postbox exists independently of its holders. It remains addressable while
the function is vacant and may accept durable deliveries according to policy;
the UI must then show that no current human holder can open or act on the
content. Assigning one or several incumbents grants access in their explicit
function context. It does not transfer ownership of the container or rewrite
its history.
This makes postboxes useful for responsibilities that outlive individuals:
- intake desks
- role-based service queues
- function-based service queues
- campaign sender or response desks
- portal message inboxes for organizational responsibilities
- file or evidence drops linked to a role in an organization
- file or evidence drops linked to a function in an organization
## Authorization Model
### Incumbency, hand-over, and delegation
Postbox authorization should be derived from access-owned identity and role data through core/access contracts. The postbox module stores postbox bindings and postbox-specific permissions, but it should not duplicate membership, group, or role resolution.
- Zero, one, or several people may hold a function at the same time.
- A postbox can remain vacant without being deleted, redirected to a personal
account, or losing content.
- A new assignment can grant access to the function's permitted history through
a current key epoch. Which historical epochs a new incumbent receives is an
explicit postbox policy, not an accidental consequence of account creation.
- A delegation is a time-bounded access grant in the represented function
context. Expiry removes future platform key access and action authority; it
cannot destroy plaintext or exports already obtained.
- Hand-over and revocation rotate the function/postbox key epoch. Per-content
data keys should normally be rewrapped; policy may require content
re-encryption for a stronger rotation event.
- Stored content is not silently substituted or overwritten. A correction,
replacement, or new version is a new linked object with provenance while the
previous signed/ciphertext manifest remains governed by retention policy.
## Directory And Authorization Model
The normalized ownership boundary is:
- Organizations owns units, structures, function types, and concrete
functions.
- Identity owns identities and account links.
- IDM owns effective identity-to-function assignments, validity, delegation,
acting-for context, and assignment lifecycle facts.
- Core/Access authorizes generic Postbox actions.
- Postbox owns templates, stable addresses, containers, messages, routing,
visibility decisions, grouping preferences, and retention.
The Postbox module stores postbox bindings and postbox-specific decisions, but
it must not duplicate identity, organization, assignment, hierarchy, or RBAC
resolution.
The minimum authorization inputs are:
- postbox id
- tenant id
- organizational unit id
- required function id, role id, or role key
- required function id or function type id
- actor identity id
- current effective function assignments, delegations, and roles from the
access module
- current effective function assignments, delegations, and acting context from
IDM
- generic Postbox permissions from Core/Access
- optional explicit administrative grants for postbox administration
The expected result is a narrow access decision:
@@ -62,27 +110,100 @@ The expected result is a narrow access decision:
- can attach or link files
- can administer bindings
Access changes must be auditable because a person can gain or lose postbox visibility through role assignment changes rather than direct postbox membership edits.
Access changes must be auditable because a person can gain or lose postbox
visibility through function-assignment changes rather than direct postbox
membership edits.
Runtime integration must use the access kernel capabilities:
Runtime integration must use the kernel capabilities:
- `access.semanticDirectory` to inspect identity/account/function facts.
- `access.explanation` to attach identity/account/function/role/right
provenance to access decisions.
- `identity.directory` to resolve identities and account links.
- `idm.directory` to resolve effective function assignments.
- `organizations.directory` to resolve function, function-type, unit, and
hierarchy facts.
- the Core/Access permission evaluator for generic Postbox actions.
- access explanation/audit contracts to attach permission and acting-context
provenance where available.
Postbox must not import access ORM models or duplicate function/role
resolution. Acting-in-place access should require an explicit selected acting
context once Access exposes that runtime selector.
Postbox must not import Identity, IDM, Organizations, or Access ORM models.
Acting-in-place access requires an explicit selected acting context. A function
assignment is an organizational responsibility fact; it does not grant
unrelated application permissions.
## Templates And Stable Addresses
A reusable Postbox template can target a function type and an organization
scope, such as a unit type, structure, or subtree. Postbox resolves a stable
unit-specific address from the tenant, template revision, concrete unit,
concrete function, and optional case/service context.
Addresses should be resolved lazily and idempotently rather than eagerly
creating empty containers for every unit. They remain durable through vacancy
and reassignment. A delivery snapshots the template revision and normalized
organization/function references so later hierarchy changes do not rewrite
history.
Exact postboxes remain useful for exceptional responsibilities that do not
belong to a reusable function type.
## Unified Inbox Projections
A user with several functions can group selected visible postboxes into named
unified inbox views and keep other responsibilities separate. Grouping is a
query projection only. It never merges source containers, messages, read or
acknowledgement state, retention, encryption keys, or audit evidence.
Every item and action continues to show the source function, unit, postbox,
assignment/delegation context, and classification. Policy may require some
postboxes to remain separate.
## Hierarchy Routing
Hierarchy behavior is disabled by default. The system distinguishes:
- a linked copy delivered to a parent function postbox
- attention or escalation metadata sent to a parent responsibility
- shared visibility over the original message
These have different privacy, retention, acknowledgement, and audit effects
and must not be treated as synonyms.
The first production slice should implement explicit linked-copy routing with
a selected structure, target function mapping, maximum depth, stop condition,
classification gate, loop protection, and delivery-time route snapshot.
Organization changes do not retroactively expose old messages.
Vacancy is a visible delivery/attention state rather than an automatic grant
to an unrelated personal account. Policy may trigger a bounded escalation
after a delay.
## Campaign Distribution
Campaign can use Postbox as an explicit delivery channel through
`postbox.delivery`. A campaign may select Mail, Postbox, both, or a configured
fallback order for a target. It must never switch channels silently.
Validation and build preview stable function/unit/context destinations,
vacancies, hierarchy-copy effects, classifications, and duplicates. Delivery
uses idempotency keys and returns per-target evidence. Postbox owns acceptance,
routing, message state, read/acknowledgement state, and postbox ids; Campaign
owns campaign preparation, jobs, recipient reports, and channel-attempt
evidence.
## Domain Objects
The initial domain model should stay small:
- `Postbox`: the addressable container.
- `PostboxTemplate` and immutable revisions: reusable function/scope
configuration.
- `PostboxAddress`: the stable tenant/function/unit/context destination.
- `Postbox`: the addressable container, materialized when needed.
- `PostboxBinding`: the binding to organization, role, portal, campaign, service, or explicit context.
- `PostboxMessage`: a platform-native message or message reference.
- `PostboxParticipant`: normalized sender, recipient, author, or actor reference.
- `PostboxAttachmentRef`: reference to a file, evidence item, generated campaign artifact, or external attachment.
- `PostboxDelivery` and `PostboxRoute`: idempotent producer acceptance and
linked copy/escalation provenance.
- `PostboxGrouping`: a per-user source-preserving inbox projection.
- `PostboxAccessEvent`: auditable record of access-affecting changes and sensitive actions.
Messages and files should be linked by stable ids and typed references. The postbox module should not import file, mail, or campaign internals.
@@ -99,15 +220,23 @@ Postbox should expose narrow capabilities through core:
Optional consumers:
- Campaign can use postboxes for campaign sender context, reply intake, review queues, and role-bound access to campaign artifacts.
- Files can expose file references to a postbox when the actor's role grants access.
- Campaign can use postboxes for function-targeted delivery, sender context,
reply intake, review queues, and access to campaign artifacts.
- Files can expose file references to a postbox when the actor has effective
source-postbox access.
- Portal can show portal-facing postboxes without owning the postbox access model.
- Mail can bridge external mailbox delivery into postboxes when configured, without making postboxes mailbox-bound.
## Operational Rules
- Current role state controls current access.
- Historical message records remain durable even when no current person holds the role.
- Current function assignment and delegation state controls current access.
- Historical message records remain durable even when no current person holds
the function; vacancy is a visible attention/access state, not a missing
postbox.
- Multiple incumbents receive independent device-bound key grants and remain
distinguishable in access and action evidence.
- Delegation start, expiry, withdrawal, key grant, and key-epoch rotation are
separate auditable events.
- Administration of bindings should require explicit postbox administration permission plus access/RBAC authority for the target organization.
- Sensitive access decisions and binding changes should emit audit events.
- Retention rules should be postbox-owned but able to reference campaign, file, and portal provenance.
@@ -117,6 +246,58 @@ Optional consumers:
key epochs, recipient device references, and external capability tokens even
before full E2EE ships.
### Retention, Audit, And Privacy
Postbox retention is owned by the postbox module because postbox messages are
platform-native communication records, not mailbox folders and not ordinary file
shares. Retention policies may reference provenance from campaign, file, portal,
mail, or workflow modules, but those modules should pass stable ids and typed
evidence references through capabilities instead of giving postbox direct access
to their internals.
The postbox module should emit audit events for:
- postbox creation, archival, and destructive retirement
- binding creation, changes, expiry, and removal
- sensitive access checks when an actor gains or loses visibility
- message creation, read/download of sensitive content, attachment linking, and
delivery handoff
- retention holds, retention expiry, export, and destruction decisions
Privacy behavior must separate current access from historical evidence. Losing
a function assignment or generic Postbox permission removes future visibility,
but it does not rewrite the fact that a person previously accessed a message or
that a message existed. Deletion and destructive retention actions must
preserve legally required audit/evidence records while removing or redacting
content according to the effective policy.
When E2EE is enabled later, retention and audit metadata must remain operable
without decrypting message content. UI copy should be honest: expiry,
withdrawal, or revocation can prevent future platform access, but it cannot
guarantee removal of plaintext already fetched, exported, printed, or delivered
outside the platform.
### Migration And Compatibility Ownership
Postbox-owned tables, DTOs, migrations, and capability names belong in
`govoplan-postbox`. Core may temporarily contain compatibility imports or
legacy migration references only when needed to keep existing installations
upgradable while code is being extracted.
Compatibility code must be narrow and documented:
- new postbox behavior is implemented in `govoplan-postbox`
- old import paths may re-export postbox DTOs or helpers during a transition,
but must not become active owners of postbox logic
- migrations that move tables to postbox ownership must preserve existing data
and have explicit downgrade/retirement notes
- optional integrations with campaign, files, portal, or mail remain capability
contracts, not direct imports
Once supported release migrations have crossed the compatibility window, legacy
core import aliases and old table ownership comments should be removed through
a normal cleanup issue.
## First Implementation Shape
The first implementation should define the backend manifest, permissions, DTOs, and migrations before building rich UI. A minimal API can then support directory lookup, access checks, message creation, message listing, and binding administration.
@@ -124,9 +305,9 @@ The first implementation should define the backend manifest, permissions, DTOs,
The WebUI should start as an administration and inbox surface:
- postbox directory
- role-bound access explanation
- function-bound access explanation
- message list and message detail
- binding editor for organization and role links
- template and binding editor for organization/function links
- audit-visible administrative actions
Campaign, files, portal, and mail behavior should arrive as optional integrations after the core postbox model is stable.
@@ -143,3 +324,19 @@ Before the data model is considered stable, verify that it can represent:
- external recipient token state
- expiry and withdrawal state separate from deletion
- retention state that can operate without decrypting content
## E2EE decisions still to settle before implementation
The product direction above is selected, but the first trusted profile still
needs bounded decisions on:
- whether a new incumbent receives all retained history, history from a
policy-defined date, or only content delivered during the assignment;
- organizational recovery/escrow and the authority required when every holder
loses all registered device keys;
- whether ordinary rotation only rewraps per-content keys or also re-encrypts
ciphertext, and which events require the stronger path;
- assurance and quorum requirements for delegation, hand-over, emergency
access, export, and destructive retention; and
- how attention/escalation works during a vacancy without granting plaintext
access to an unrelated personal account.

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"]
}