21 Commits
Author SHA1 Message Date
zemion 1f67eb25d7 fix(ui): align contextual documentation with headings
Verified with the coordinated workspace changes by devkit full run
2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed).
This shared UI pass does not mark the individual module reviews complete.
2026-09-09 02:03:59 +02:00
zemion 3274d30665 fix(packaging): expose immutable WebUI Git package for v0.1.23
Module Package Release / publish-packages (push) Successful in 10s
2026-09-08 02:06:10 +02:00
zemion aff2623496 feat: contribute Messages product surface
Module Package Release / publish-packages (push) Successful in 13s
2026-08-24 12:53:44 +02:00
zemion 4bdc3ab60c fix(webui): bind consequential postbox controls to help
Module Package Release / publish-packages (push) Successful in 12s
2026-08-24 11:36:42 +02:00
zemion aba6280f21 docs: complete German structured documentation
Module Package Release / publish-packages (push) Successful in 11s
2026-08-24 01:15:37 +02:00
zemion b24d291cc8 docs(postbox): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 06:50:34 +02:00
zemion e5da713d5f feat(postbox): add governed DSAR coverage 2026-08-21 00:46:06 +02:00
zemion 8a21876634 feat(postbox): reconcile lifecycle notifications 2026-08-20 04:58:34 +02:00
zemion 41ea8d8e23 feat(postbox): enforce unified inbox separation 2026-08-20 04:22:29 +02:00
zemion 174ee97719 feat(postbox): add governed content protection profiles 2026-08-20 03:42:58 +02:00
zemion 15d93aaa25 feat(webui): complete postbox quick access 2026-08-19 19:49:00 +02:00
zemion d005065e50 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion ce334fee7d style: use shared WebUI foundation tokens 2026-08-18 21:32:50 +02:00
zemion 6b0e3ae37d Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion 225a3a233b Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion 570709b0bd Adopt shared WebUI layout primitives 2026-08-18 10:42:53 +02:00
zemion 11f175cbb3 feat: integrate files and portal surfaces 2026-08-07 14:53:46 +02:00
zemion 60f50f7906 Contribute Postbox to Quick Access 2026-08-06 19:02:55 +02:00
zemion 36530b6dce Project unread postbox work with shared authorization 2026-08-06 16:06:17 +02:00
zemion d107c09f74 Add scoped postbox template previews 2026-08-06 12:42:20 +02:00
zemion c53646a8aa Release v0.1.18
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 21:07:52 +02:00
41 changed files with 10538 additions and 642 deletions
+74 -7
View File
@@ -48,9 +48,20 @@ Unit-specific addresses are resolved lazily and remain stable through vacancy
and reassignment. Exact postboxes remain available for exceptional
responsibilities or case/service contexts.
Subtree templates select an explicit Organizations structure and optional
hierarchical relation types. The administration UI can dry-run a draft against
the current organization and incumbency state, showing generated addresses,
vacancy, existing targets, collisions, and hierarchy diagnostics without
materializing data.
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.
retention, and evidence remain attached to their source postboxes. Each exact
Postbox or template revision may allow grouping, require equal classifications,
or remain entirely separate with an administrator-supplied explanation. The
rule is re-evaluated on every combined query, including after assignment or
policy changes; a View can select a personal projection through the stable
`?grouping=<grouping-id>` route parameter without granting access.
Hierarchy propagation is off by default. Explicit copy, attention/escalation,
and shared-visibility rules are distinct, bounded, classification-aware, and
@@ -84,7 +95,8 @@ 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. Published template revisions can also opt into bounded linked
administration. Grouping summaries expose batched total and unread counts for
currently visible sources. Published template revisions can also opt into bounded linked
copies through one explicit organization structure. Classification, producer,
retention, stop, depth, target-template, and target-function gates are frozen
at delivery time and exposed through delivery evidence and the routing dry-run
@@ -96,12 +108,49 @@ an independently readable copy in the next frozen function Postbox. The
`govoplan.postbox.dispatch_routes` periodic Core worker drains due routes when
Celery beat and a worker consuming the `postbox` queue are enabled.
Postboxes support `plaintext_v1` and an optional `server_envelope_v1` profile.
The latter stores message bodies as ciphertext through the Encryption
capability and fails closed on reads if that capability or key is unavailable.
That worker also reconciles current IDM incumbencies in batches against a
durable metadata-only cursor. Assignment, delegation, vacancy, and
reassignment changes emit versioned platform events and optional in-app
Notifications for newly eligible current holders. Delivery, action-required,
escalation-due, read, and acknowledgement events link back to Postbox-owned
resources without copying message subjects, bodies, or attachment details into
lifecycle events. Notification preferences, quiet periods, and future external
channel policy remain owned by Notifications; every deep link rechecks current
Postbox access.
Postboxes expose three configurable content-protection profiles. The recommended
`server_envelope_v1` profile stores message bodies as ciphertext through an
institution-controlled Encryption vault and fails closed if its capability or
key is unavailable. `external_e2ee_v1` accepts only ciphertext, a signed
manifest, wrapped recipient keys, and a verified content digest produced by an
approved external client; GovOPlaN cannot decrypt that content. `plaintext_v1`
keeps content unencrypted for deployments that explicitly accept that boundary.
Subjects, participants, routing, attachment references, and lifecycle metadata
remain observable. Existing externally produced ciphertext references remain
supported, but neither path is described as end-to-end encryption.
remain observable in every profile.
Administrators may govern future-only changes or migrate retained history.
Transitions record user-consent and/or institutional key-holder evidence,
quorum, reason, per-message digest continuity, and completion state. Managed
envelope changes use the Encryption migration ledger. Any transition to or from
E2EE waits for client-supplied transforms for historical messages; the module
does not claim or silently simulate native browser/device key custody.
## Data-subject requests
Postbox contributes a tenant-isolated provider to the Core data-subject request
workflow. It finds bounded personal message, participant, receipt, grouping,
access, configuration-authorship, and content-protection metadata. It never
decrypts or exports ciphertext, envelopes, wrapped keys, external-recipient
tokens, opaque metadata, or unrelated participant data. Institutional delivery,
routing, acknowledgement, access, template, and protection-transition evidence
is retained with an explicit reason and message content remains subject to
manual records and third-party privacy review.
Personal unified-inbox groupings are the one directly executable erasure
operation. Execution revalidates tenant, subject ownership, and the grouping
revision, then deletes only the personal projection and its source preferences;
source Postboxes and messages are unchanged. Files attachments, producer
records, identities, and function assignments remain with their owning modules.
Run focused checks with:
@@ -109,3 +158,21 @@ Run focused checks with:
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
cd webui && npm run test:ui-structure
```
## Git-source WebUI package
The repository root exposes `@govoplan/postbox-webui` for Git-tagged release
dependencies. It mirrors the owning `webui/package.json` version, public
TypeScript/CSS exports and peer requirements, with entry paths under
`webui/src`. Consumers provide the shared Core/React peers; the facade runs no
development or install scripts. The source archive contains `webui/src`, this
README and any repository license file. Run module development checks from `webui/`; Python
installation remains governed by `pyproject.toml`.
Das Repository stellt `@govoplan/postbox-webui` am Wurzelpfad für versionierte
Git-Abhängigkeiten bereit. Version, öffentliche TypeScript-/CSS-Exporte und
Peer-Anforderungen entsprechen `webui/package.json`; die Einstiegspfade liegen
unter `webui/src`. Gemeinsame Core-/React-Peers stellt die einbindende Anwendung
bereit. Die Fassade führt keine Entwicklungs- oder Installationsskripte aus.
Entwicklungsprüfungen bleiben in `webui/`, die Python-Installation weiterhin in
`pyproject.toml` definiert.
+132 -45
View File
@@ -12,14 +12,16 @@ 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 current
implementation supports ordinary persisted messages and an optional
server-readable Encryption envelope for message bodies. The model also retains
external ciphertext, wrapped-key, signed-manifest, external-recipient-token,
and key-epoch metadata needed for later independently reviewed E2EE profiles.
The server-envelope profile is not E2EE, and subjects, routing, participants,
and attachment references remain visible. The
cross-module target architecture is recorded in
The strategic target is a policy-selectable administrative postbox. The current
implementation offers unencrypted content, an institution-managed Encryption
envelope, and a strict external E2EE boundary. E2EE messages contain only an
external ciphertext reference, signed-manifest reference, wrapped recipient
keys, and a verified plaintext digest; an approved producer or client owns the
actual cryptographic operation and private-key custody. GovOPlaN cannot decrypt
that profile. The institution-managed envelope remains server-readable by
authorized institutional key holders. Subjects, routing, participants, and
attachment references remain visible in every profile. The cross-module target
architecture is recorded in
`govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`.
## Function-Organization-Bound Access
@@ -139,6 +141,19 @@ 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.
Subtree scope is explicit about the Organizations structure and may restrict
the hierarchical relation types used within that structure. It does not infer
scope from the legacy `parent_id` when an administrator creates or revises a
template. This prevents an administrative, reporting, and project hierarchy
from being confused when they contain the same units.
Before saving a draft, administrators can run a read-only impact preview. It
uses the same scope, function matching, address rendering, and incumbent rules
as materialization and reports ready targets, already materialized addresses,
vacancies, collisions, cycles, depth limits, and ambiguous paths. The preview
does not create a template, address, Postbox, or delivery. A large result is
bounded in the UI while its aggregate counts remain visible.
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
@@ -155,9 +170,31 @@ 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.
The optional Tasks module may aggregate available unread Postbox messages into
the common work inbox. This is a current, permission-rechecked projection of a
personal read receipt, not a copied task or message. Reading the message in
Postbox removes the projection; Postbox remains authoritative for content,
access, acknowledgement, reply, retention, and evidence.
Grouping summaries calculate total and unread counts over the currently
visible source Postboxes in one tenant-bounded query. Hidden sources retained
for later reassignment do not leak counts into the projection.
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.
postboxes to remain separate. Every exact Postbox and immutable template
revision therefore selects one grouping rule: allow combining, combine only
with the same classification, or always remain separate. The configured reason
is returned as constraint provenance. Rules are checked both when saving a
personal grouping and when reading any aggregate projection, so an existing
preference cannot bypass a later policy or assignment change.
The route parameter `?grouping=<personal-grouping-id>` is the stable,
permission-neutral selector for a task-focused View. Postbox ignores an unknown
or no-longer-visible selection, rechecks all sources, and never treats the View
as authority. Temporarily unavailable source preferences remain stored without
returning their metadata or counts and become eligible again only after current
access is restored.
## Hierarchy Routing
@@ -247,6 +284,9 @@ Optional consumers:
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.
- Notifications can receive generic, metadata-only Postbox lifecycle commands
for current IDM holders. It owns user preferences, quiet periods, and future
email/push channel policy; Postbox remains correct when it is absent.
## Operational Rules
@@ -258,6 +298,20 @@ Optional consumers:
distinguishable in access and action evidence.
- Delegation start, expiry, withdrawal, key grant, and key-epoch rotation are
separate auditable events.
- The periodic Postbox worker batches IDM incumbency resolution and reconciles
assignment/delegation visibility, vacancy, and reassignment through a durable
cursor. First deployment establishes a quiet baseline; later changes and
failed notification handoffs are deduplicated and retryable.
- Notification and platform-event payloads contain stable Postbox, message,
route, delivery, function, assignment, classification, and producer
provenance only. Subjects, bodies, participant addresses, attachment details,
ciphertext, and key material never enter lifecycle payloads.
- Producers mark actionable deliveries through the typed `action_required`
delivery flag; Postbox emits a dedicated event and raises the generic in-app
priority without copying the producer's subject or body.
- Notification links do not preserve authority. Assignment expiry, withdrawal,
classification policy, and generic Access permissions are rechecked when the
target is opened.
- 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.
@@ -333,47 +387,80 @@ The WebUI should start as an administration and inbox surface:
Campaign, files, portal, and mail behavior should arrive as optional integrations after the core postbox model is stable.
### Current content-protection profile
### Configurable content-protection profiles
An exact Postbox or template revision may select `server_envelope_v1` and an
Encryption vault. New locally authored and delivered message bodies are then
stored in `body_ciphertext` with an owner-bound envelope reference; clear body
text is not persisted. Reads ask the optional `encryption.content_cipher`
capability to open the exact tenant, message, and envelope tuple. Missing
Encryption, a lost deployment key, a destroyed vault key, ciphertext tampering,
or a mismatched resource causes a fail-closed read.
An exact Postbox or template revision selects one profile. The administration
surface recommends the managed profile and requires its vault explicitly; the
API retains the legacy plaintext default when an older integration omits these
new fields so an upgrade cannot make an unavailable Encryption module block
existing automation.
Plaintext Postboxes continue to work without Encryption. A protected Postbox
cannot silently fall back to plaintext. Database recovery of protected messages
requires Postbox and Encryption tables from the same consistency point plus the
matching provider/deployment key. Hierarchy-routed copies retain the source
envelope reference rather than decrypting and re-encrypting during routing.
- `server_envelope_v1` is the recommended standard. New local message bodies
are encrypted through the optional `encryption.content_cipher` capability,
stored in `body_ciphertext`, and linked to an owner-bound envelope in the
institution's selected vault. Authorized reads ask that capability to open
the exact tenant, message, and envelope tuple. Missing Encryption, unavailable
or destroyed keys, tampering, and resource mismatch fail closed.
- `external_e2ee_v1` is a server-blind storage contract. Clear bodies are
rejected. A producer must provide a ciphertext reference, signed manifest,
wrapped recipient keys for the current key epoch, and `sha256` content digest.
The server retains and authorizes those artifacts but has no private key with
which to decrypt them.
- `plaintext_v1` stores the body without content encryption. It remains
available for deployments that explicitly choose transport and
infrastructure controls only.
## E2EE Readiness Checklist
No profile hides operational metadata. Subjects, senders, participants,
routing, timestamps, classifications, attachment references, receipts,
retention state, and access evidence remain server-visible. Native browser or
device enrollment, private-key custody, offline recovery, and independently
reviewed cryptographic clients are not bundled by Postbox; an institution that
selects E2EE must provide and govern that client/provider boundary.
Before the data model is considered stable, verify that it can represent:
### Protection and hand-over policy
- message or attachment ciphertext references
- signed manifest references
- recipient, role, or function key wrapping records
- key epoch and device-key references
- key-fetch/access audit events
- external recipient token state
- expiry and withdrawal state separate from deletion
- retention state that can operate without decrypting content
Each Postbox snapshots policy for the choices that cannot safely be inferred:
## E2EE decisions still to settle before implementation
- a new incumbent sees all retained history, content since assignment, or a
bounded look-back period;
- ordinary and compromise rotations select key rewrapping or full content
re-encryption;
- recovery, hand-over, emergency access, export, and destruction name the
required user-consent, institutional key-holder, or dual-control authority
and quorum;
- external retrieval requires strong identity, email plus a one-time code, or
may be disabled; and
- vacancy escalation is always metadata-only and never gives an unrelated
personal account content access.
The product direction above is selected, but the first trusted profile still
needs bounded decisions on:
The defaults are deliberately conservative: history since assignment,
ordinary rewrapping, re-encryption after compromise, two-person institutional
recovery, dual-control hand-over/emergency/export/destruction, strong external
identity, and metadata-only vacancy escalation. These are product defaults, not
hard-coded policy decisions; administrators can change them per template or
exact Postbox.
- 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.
### Governed profile transitions
A profile change increments the Postbox key epoch and applies immediately to
new messages. The administrator chooses whether retained history stays under
its existing profile or is migrated. Every transition records an idempotency
key, source and target profiles/vaults, user-consent and/or institutional
authorization evidence, quorum, reason, immutable configuration snapshot,
message digests, and per-message outcome.
Plaintext-to-managed and managed-to-plaintext migrations can complete through
the configured Encryption capability. Managed decrypt, export, and
re-encryption operations are also written to the Encryption migration ledger;
old envelopes are not merely orphaned. A transition to or from E2EE pauses each
historical message until an approved external client supplies the ciphertext or
plaintext transform and evidence. Postbox checks the immutable SHA-256 digest
before committing the new representation. Leaving E2EE requires user-consent
evidence; changing institution-managed history requires institutional
key-holder evidence; dual control can require both. Previously viewed, copied,
printed, or exported cleartext cannot be recalled and must be acknowledged.
Database recovery of managed messages requires Postbox and Encryption tables
from the same consistency point plus the provider/deployment key. Recovery of
E2EE content additionally depends on the institution's external private-key
custody and client procedures.
+33
View File
@@ -0,0 +1,33 @@
{
"name": "@govoplan/postbox-webui",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "webui/src/index.ts",
"module": "webui/src/index.ts",
"types": "webui/src/index.ts",
"exports": {
".": {
"types": "./webui/src/index.ts",
"import": "./webui/src/index.ts"
},
"./styles/postbox.css": "./webui/src/styles/postbox.css"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
},
"files": [
"webui/src",
"README.md",
"LICENSE"
]
}
+2 -2
View File
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
[project]
name = "govoplan-postbox"
version = "0.1.17"
version = "0.1.23"
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.17"]
dependencies = ["govoplan-core>=0.1.18"]
[tool.setuptools.packages.find]
where = ["src"]
+1 -1
View File
@@ -1,3 +1,3 @@
"""GovOPlaN Postbox module."""
__version__ = "0.1.17"
__version__ = "0.1.23"
@@ -25,6 +25,8 @@ def protect_message_body(
vault_id: str,
plaintext: str,
actor_id: str,
operation_ref: str = "v1",
policy_decision_ref: str = "postbox:configured-server-envelope:v1",
) -> ProtectedContent:
capability = encryption_content_cipher(get_registry())
if capability is None:
@@ -43,8 +45,8 @@ def protect_message_body(
vault_id=vault_id,
ciphertext_ref=f"postbox-db://messages/{message_id}/body",
plaintext=plaintext.encode("utf-8"),
policy_decision_ref="postbox:configured-server-envelope:v1",
idempotency_key=f"postbox-message:{message_id}:body:v1",
policy_decision_ref=policy_decision_ref,
idempotency_key=f"postbox-message:{message_id}:body:{operation_ref}",
actor_id=actor_id,
metadata={"content_type": "text/plain;charset=utf-8"},
),
@@ -10,6 +10,8 @@ from govoplan_postbox.backend.db.models import (
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxProtectionTransition,
PostboxProtectionTransitionItem,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
@@ -27,6 +29,8 @@ __all__ = [
"PostboxMessage",
"PostboxMessageReceipt",
"PostboxParticipant",
"PostboxProtectionTransition",
"PostboxProtectionTransitionItem",
"PostboxRoute",
"PostboxTemplate",
"PostboxTemplateRevision",
+143 -6
View File
@@ -121,6 +121,16 @@ class PostboxTemplateRevision(Base, TimestampMixin):
nullable=True,
index=True,
)
scope_structure_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
scope_relation_type_ids: Mapped[list[str]] = mapped_column(
JSON,
default=list,
nullable=False,
)
name_pattern: Mapped[str] = mapped_column(
String(500),
default="{unit_name} / {function_name}",
@@ -141,19 +151,27 @@ class PostboxTemplateRevision(Base, TimestampMixin):
default=True,
nullable=False,
)
portal_visible: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
)
encryption_profile: Mapped[str] = mapped_column(
String(80),
default="plaintext_v1",
nullable=False,
)
encryption_vault_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
encryption_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
history_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
grouping_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
routing_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
@@ -308,6 +326,11 @@ class Postbox(Base, TimestampMixin):
back_populates="postbox",
cascade="all, delete-orphan",
)
protection_transitions: Mapped[list["PostboxProtectionTransition"]] = relationship(
back_populates="postbox",
cascade="all, delete-orphan",
order_by="PostboxProtectionTransition.created_at",
)
@property
def strong_etag(self) -> str:
@@ -424,9 +447,7 @@ class PostboxMessage(Base, TimestampMixin):
)
subject: Mapped[str] = mapped_column(String(1000), nullable=False)
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
body_ciphertext: Mapped[bytes | None] = mapped_column(
LargeBinary, nullable=True
)
body_ciphertext: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
status: Mapped[str] = mapped_column(
String(30),
default="delivered",
@@ -543,6 +564,121 @@ class PostboxMessage(Base, TimestampMixin):
)
class PostboxProtectionTransition(Base, TimestampMixin):
__tablename__ = "postbox_protection_transitions"
__table_args__ = (
Index(
"ix_postbox_protection_transition_state",
"tenant_id",
"postbox_id",
"state",
),
UniqueConstraint(
"tenant_id",
"postbox_id",
"idempotency_key",
name="uq_postbox_protection_transition_idem",
),
)
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,
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
source_profile: Mapped[str] = mapped_column(String(80), nullable=False)
target_profile: Mapped[str] = mapped_column(String(80), nullable=False)
source_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
target_vault_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
history_mode: Mapped[str] = mapped_column(String(30), nullable=False)
authority_mode: Mapped[str] = mapped_column(String(40), nullable=False)
required_quorum: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
evidence_refs: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
reason: Mapped[str] = mapped_column(Text, nullable=False)
state: Mapped[str] = mapped_column(
String(30), default="pending", nullable=False, index=True
)
message_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
completed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
failed_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
requested_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
activated_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
resource_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
configuration_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON, default=dict, nullable=False
)
postbox: Mapped[Postbox] = relationship(back_populates="protection_transitions")
items: Mapped[list["PostboxProtectionTransitionItem"]] = relationship(
back_populates="transition",
cascade="all, delete-orphan",
order_by="PostboxProtectionTransitionItem.created_at",
)
@property
def strong_etag(self) -> str:
return strong_resource_etag(
"postbox_protection_transition",
self.id,
self.resource_revision,
)
class PostboxProtectionTransitionItem(Base, TimestampMixin):
__tablename__ = "postbox_protection_transition_items"
__table_args__ = (
UniqueConstraint(
"transition_id",
"message_id",
name="uq_postbox_protection_transition_message",
),
Index(
"ix_postbox_protection_transition_item_state",
"tenant_id",
"transition_id",
"state",
),
)
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)
transition_id: Mapped[str] = mapped_column(
ForeignKey("postbox_protection_transitions.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
source_profile: Mapped[str] = mapped_column(String(80), nullable=False)
target_profile: Mapped[str] = mapped_column(String(80), nullable=False)
state: Mapped[str] = mapped_column(
String(30), default="pending", nullable=False, index=True
)
source_digest: Mapped[str | None] = mapped_column(String(255), nullable=True)
target_digest: Mapped[str | None] = mapped_column(String(255), nullable=True)
completed_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
error_code: Mapped[str | None] = mapped_column(String(100), nullable=True)
evidence: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
transition: Mapped[PostboxProtectionTransition] = relationship(
back_populates="items"
)
class PostboxParticipant(Base, TimestampMixin):
__tablename__ = "postbox_participants"
__table_args__ = (
@@ -881,6 +1017,7 @@ class PostboxGroupingSource(Base, TimestampMixin):
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
grouping: Mapped[PostboxGrouping] = relationship(back_populates="sources")
postbox: Mapped[Postbox] = relationship()
class PostboxAccessEvent(Base, TimestampMixin):
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,137 @@
"""German translations for public structured documentation metadata."""
from __future__ import annotations
from typing import Any
GERMAN_STRUCTURED_TRANSLATIONS: dict[str, dict[str, Any]] = {'postbox.content-protection-policy': {'privacy_notes': ['E2EE schützt Inhalte, nicht operative '
'Metadaten.',
'Verwaltete Umschläge sind von '
'autorisierten institutionellen '
'Schlüsselinhabern entschlüsselbar.',
'Profilübergänge bewahren '
'Autoritätsnachweise und inhaltliche '
'Kontinuität.']},
'postbox.function-bound-containers': {'privacy_notes': ['Der Verzeichniszugriff wird aus '
'aktuellen effektiven '
'Funktionszuweisungen abgeleitet.',
'Nicht verfügbare Nachrichtenzustände '
'zeigen nur gespeicherte Audit-Metadaten '
'an, die der aktuell handelnden Person '
'erlaubt sind.',
'Themen, Teilnehmer, Routing-Fakten und '
'Anhangreferenzen bleiben beobachtbare '
'Metadaten.']},
'postbox.lifecycle-notifications': {'privacy_notes': ['Lifecycle-Benachrichtigungen enthalten '
'niemals Nachrichten- oder Anhanginhalte.',
'Der aktuelle Zugriff wird erneut '
'überprüft, wenn ein Benachrichtigungslink '
'geöffnet wird.',
'Benachrichtigungen besitzen Präferenz und '
'Durchsetzung externer Kanäle.']},
'postbox.privacy.data-subject-requests': {'limitations': ['Verschlüsselte Inhalte werden vom '
'Schutzstaat gemeldet, aber nicht vom '
'Anbieter entschlüsselt oder '
'exportiert.',
'Attachment-Nutzlasten, '
'Produzentendatensätze, '
'Identitätsdatensätze und '
'Zuweisungsdatensätze verbleiben bei '
'ihren eigenen Modulen.'],
'steps': ['Führen Sie den Postbox-Anbieter aus und '
'überprüfen Sie Nachricht, Teilnehmer, '
'Empfang, Gruppierung, Zugriff und '
'Governance-Dispositionen.',
'Bewahren Sie die institutionelle Lieferung, '
'das Routing, die Bestätigung, den Zugang und '
'die Schutznachweise mit dem Grund auf.',
'Überprüfen Sie Klartextinhalte auf Daten von '
'Drittanbietern und entsprechende Datensätze '
'oder halten Sie Richtlinien vor einer '
'separaten Lifecycle-Aktion.',
'Führen Sie das Löschen von '
'Personengruppierungen nur aus einem neuen '
'Plan aus; überprüfen Sie, ob die '
'Quellpostfächer und Nachrichten unverändert '
'bleiben.']},
'postbox.reference.fields-and-consequences': {'consequence_classes': {'archive_postbox': 'Stoppt '
'neue '
'Lieferung, '
'während '
'Nachrichten, '
'Quittungen '
'und '
'Nachweise '
'beibehalten '
'werden.',
'delete_grouping': 'Löscht '
'nur die '
'persönliche '
'Projektion; '
'Quellpostfächer '
'und '
'Nachrichten '
'bleiben '
'unverändert.',
'preview_template': 'Lies '
'die '
'aktuelle '
'Organisation, '
'Hierarchie '
'und '
'den '
'Status '
'der '
'Amtsinhaber, '
'ohne '
'eine '
'Adresse '
'oder '
'Postbox '
'zu '
'materialisieren.',
'publish_template': 'Friert '
'eine '
'unveränderliche '
'Adress- '
'und '
'Routing-Revision '
'für '
'zukünftige '
'Materialisierung '
'ein.',
'retire_template': 'Stoppt '
'neue '
'Revisionen '
'und '
'Materialisierungen, '
'während '
'bestehende '
'Adressen '
'beibehalten '
'werden.',
'route_copy': 'Erstellt '
'einen '
'separat '
'aufbewahrten '
'Liefer- und '
'Beweisdatensatz '
'bei jedem '
'begrenzten '
'Ziel.',
'withdraw_or_expire': 'Blockiert '
'den '
'Zugriff '
'auf '
'zukünftige '
'Inhalte, '
'während '
'erlaubte '
'Audit-Metadaten '
'beibehalten '
'werden.'}},
'postbox.unified-inbox-policy': {'privacy_notes': ['Versteckte Gruppierungsquellen zeigen keine '
'Metadaten oder Zählungen.',
'Ein View wählt eine Projektion aus, gewährt '
'jedoch niemals Postbox-Zugriff.']}}
@@ -0,0 +1,61 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import Literal, TypedDict
PostboxGroupingPolicyMode = Literal[
"allow",
"same_classification",
"separate",
]
class NormalizedPostboxGroupingPolicy(TypedDict):
mode: PostboxGroupingPolicyMode
reason: str | None
def normalize_postbox_grouping_policy(
policy: Mapping[str, object] | None = None,
) -> NormalizedPostboxGroupingPolicy:
value = policy or {}
mode = str(value.get("mode") or "allow").strip().casefold()
if mode not in {"allow", "same_classification", "separate"}:
mode = "separate"
reason_value = value.get("reason")
reason = str(reason_value).strip() if reason_value is not None else None
return {
"mode": mode, # type: ignore[typeddict-item]
"reason": reason or None,
}
def grouping_policy_conflicts(
sources: Sequence[tuple[str, str, Mapping[str, object] | None]],
) -> tuple[str, ...]:
"""Return privacy-safe conflict codes for a proposed source projection."""
if len(sources) <= 1:
return ()
policies = [
(postbox_id, classification, normalize_postbox_grouping_policy(policy))
for postbox_id, classification, policy in sources
]
conflicts: list[str] = []
if any(policy["mode"] == "separate" for _, _, policy in policies):
conflicts.append("source_requires_separation")
if (
any(policy["mode"] == "same_classification" for _, _, policy in policies)
and len({classification for _, classification, _ in policies}) > 1
):
conflicts.append("classification_separation_required")
return tuple(conflicts)
__all__ = [
"NormalizedPostboxGroupingPolicy",
"PostboxGroupingPolicyMode",
"grouping_policy_conflicts",
"normalize_postbox_grouping_policy",
]
+593 -23
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from govoplan_core.core.modules import with_documentation_structured_translations
from govoplan_postbox.backend.german_structured_documentation import GERMAN_STRUCTURED_TRANSLATIONS
from pathlib import Path
from govoplan_core.core.access import (
@@ -8,6 +11,7 @@ from govoplan_core.core.access import (
)
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY
from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
from govoplan_core.core.files import CAPABILITY_FILES_POSTBOX_REFERENCES
from govoplan_core.core.idm import (
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
@@ -17,6 +21,8 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
@@ -28,6 +34,10 @@ from govoplan_core.core.modules import (
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAvailabilityExplanation,
ProductAreaContribution,
ProductSurfaceContribution,
QuickAccessTool,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
@@ -42,28 +52,32 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_PORTAL,
CAPABILITY_POSTBOX_ROUTING,
)
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.tasks import WorkItemProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_postbox.backend.db import models as postbox_models
from govoplan_postbox.backend.dsar_provider import POSTBOX_DSAR_CAPABILITY
from govoplan_postbox.backend.search_source import create_postbox_search_source
from govoplan_postbox.backend.permissions import (
ACKNOWLEDGE_SCOPE,
BINDING_ADMIN_SCOPE,
CONFIDENTIAL_SCOPE,
DELIVERY_SCOPE,
READ_SCOPE,
REPLY_SCOPE,
RESTRICTED_SCOPE,
SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE,
)
MODULE_ID = "postbox"
MODULE_NAME = "Postbox"
MODULE_VERSION = "0.1.17"
READ_SCOPE = "postbox:postbox:read"
SEND_SCOPE = "postbox:message:write"
REPLY_SCOPE = "postbox:message:reply"
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
DELIVERY_SCOPE = "postbox:delivery:write"
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
TEMPLATE_ADMIN_SCOPE = "postbox:template:admin"
CONFIDENTIAL_SCOPE = "postbox:classification:confidential"
RESTRICTED_SCOPE = "postbox:classification:restricted"
MODULE_VERSION = "0.1.23"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -169,6 +183,19 @@ def _router(context: ModuleContext):
return router
def _work_items(context: ModuleContext):
from govoplan_postbox.backend.work_items import PostboxWorkItemProvider
return PostboxWorkItemProvider(registry=context.registry)
def _postbox_dsar_provider(context: ModuleContext) -> object:
del context
from govoplan_postbox.backend.dsar_provider import PostboxDsarProvider
return PostboxDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"postboxes": session.query(postbox_models.Postbox)
@@ -198,6 +225,8 @@ _OWNED_TABLES = (
postbox_models.PostboxDelivery,
postbox_models.PostboxAttachmentReference,
postbox_models.PostboxParticipant,
postbox_models.PostboxProtectionTransitionItem,
postbox_models.PostboxProtectionTransition,
postbox_models.PostboxMessage,
postbox_models.PostboxBinding,
postbox_models.Postbox,
@@ -225,6 +254,7 @@ manifest = ModuleManifest(
"views",
"workflow_engine",
"search",
"tasks",
),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
@@ -244,6 +274,8 @@ manifest = ModuleManifest(
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_ROUTING,
CAPABILITY_POSTBOX_PORTAL,
POSTBOX_DSAR_CAPABILITY,
)
),
requires_interfaces=(
@@ -275,6 +307,12 @@ manifest = ModuleManifest(
version_max_exclusive="2.0.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_FILES_POSTBOX_REFERENCES,
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
@@ -284,6 +322,13 @@ manifest = ModuleManifest(
factory=create_postbox_search_source,
),
),
work_item_providers=(
WorkItemProviderRegistration(
id="postbox.unread",
factory=_work_items,
order=40,
),
),
nav_items=(
NavItem(
path="/postbox",
@@ -342,6 +387,75 @@ manifest = ModuleManifest(
label="Postbox templates and bindings",
order=30,
),
ViewSurface(
id="postbox.quick_access.messages",
module_id=MODULE_ID,
kind="quick_access",
label="Postbox Quick Access",
order=35,
),
),
product_areas=(
ProductAreaContribution(
id="communication",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.communication",
icon="mail",
description="i18n:govoplan-core.product_area.communication_description",
surface_ids=("postbox.nav.postbox", "postbox.route.postbox"),
order=40,
),
),
product_surfaces=(
ProductSurfaceContribution(
id="communication.messages",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_surface.messages",
description="i18n:govoplan-core.product_surface.messages_description",
icon="mail",
entry_path="/messages",
route_path="/postbox",
surface_ids=("postbox.nav.postbox", "postbox.route.postbox"),
presentations=("task", "reader"),
capability_ids=(CAPABILITY_POSTBOX_MESSAGES,),
search_source_ids=("postbox.messages",),
help_context_ids=("postbox.quick_access.messages",),
documentation_topic_ids=("postbox.quick-access-and-product-area",),
required_any=(READ_SCOPE,),
aliases=("/inbox",),
order=20,
unavailable=ProductAvailabilityExplanation(
reason="authorization",
title="i18n:govoplan-core.product_surface.messages_unavailable",
description="i18n:govoplan-core.product_surface.messages_unavailable_description",
resolution="i18n:govoplan-core.product_surface.messages_unavailable_resolution",
responsible_role="i18n:govoplan-core.access_administrator",
),
degraded=ProductAvailabilityExplanation(
reason="provider_degraded",
title="i18n:govoplan-core.product_surface.messages_degraded",
description="i18n:govoplan-core.product_surface.messages_degraded_description",
resolution="i18n:govoplan-core.product_surface.messages_degraded_resolution",
responsible_role="i18n:govoplan-core.integration_operator",
),
),
),
quick_access_tools=(
QuickAccessTool(
id="postbox.messages",
module_id=MODULE_ID,
category_id="messages",
label="i18n:govoplan-postbox.postbox",
description="i18n:govoplan-postbox.quick_access_description",
surface_id="postbox.quick_access.messages",
icon="inbox",
full_page_path="/postbox",
required_any=(READ_SCOPE,),
order=20,
modes=("browse", "author"),
returned_reference_kinds=("postbox.message",),
help_context_id="postbox.quick_access.messages",
),
),
),
route_factory=_router,
@@ -376,8 +490,212 @@ manifest = ModuleManifest(
CAPABILITY_POSTBOX_DELIVERY: _configure,
CAPABILITY_POSTBOX_EVIDENCE: _configure,
CAPABILITY_POSTBOX_ROUTING: _configure,
CAPABILITY_POSTBOX_PORTAL: lambda context: __import__(
"govoplan_postbox.backend.portal_projection",
fromlist=["create_portal_projection"],
).create_portal_projection(context),
POSTBOX_DSAR_CAPABILITY: _postbox_dsar_provider,
},
capability_documentation={
POSTBOX_DSAR_CAPABILITY: CapabilityDocumentation(
label="Postbox data-subject request provider",
summary=(
"Finds bounded personal Postbox communication, preference, access, "
"and governance metadata without exposing ciphertext, keys, tokens, "
"opaque metadata, or unrelated participants."
),
contract_version="0.1.0",
documentation_types=("admin",),
audience=("privacy_officer", "postbox_admin", "records_manager"),
),
},
documentation=(
DocumentationTopic(
id="postbox.privacy.data-subject-requests",
title="Review Postbox data in a data-subject request",
summary=(
"Collect tenant-scoped personal Postbox data while preserving "
"institutional communication, access, and protection evidence."
),
body=(
"Postbox searches corroborated account, identity, membership, email, "
"assignment, and namespaced Postbox references. Results include bounded "
"message content for privacy review, only matching participant data, "
"personal read and acknowledgement receipts, personal unified-inbox "
"groupings, attributed access events, and configuration or content-"
"protection authorship. Delivery, routing, receipt, access, template, and "
"protection-transition records retain explicit institutional evidence "
"reasons. Ciphertext, server envelopes, wrapped keys, external-recipient "
"tokens, opaque metadata, transition evidence payloads, and unrelated "
"participants are never exported by this provider. Personal groupings are "
"the only automated erasure action: execution rechecks tenant, owner, and "
"revision, then removes only the projection. Messages and other "
"institutional records require a separate authorized retention, third-party "
"privacy, and records review. Files, producer modules, Identity, and IDM "
"remain authoritative for their own data."
),
layer="configured",
documentation_types=("admin",),
audience=(
"privacy_officer",
"postbox_admin",
"records_manager",
"operator",
),
related_modules=(
"access",
"audit",
"files",
"identity",
"idm",
"records",
),
conditions=(
DocumentationCondition(
required_modules=("postbox", "access"),
any_scopes=(
"access:privacy:read",
"access:privacy:manage",
"access:privacy:erase",
),
),
),
links=(
DocumentationLink(
label="Data-subject requests",
href="/admin?section=tenant-data-subject-requests",
kind="runtime",
),
DocumentationLink(
label="Postbox concept",
href="docs/POSTBOX_CONCEPT.md",
kind="source",
),
),
translations={
"de": {
"title": "Postfachdaten in einem Betroffenenersuchen prüfen",
"summary": (
"Mandantenbezogene personenbezogene Postfachdaten erfassen "
"und institutionelle Kommunikations-, Zugriffs- und "
"Schutznachweise bewahren."
),
"body": (
"Postbox sucht nach bestätigten Konto-, Identitäts-, "
"Mitgliedschafts-, E-Mail-, Zuweisungs- und namensraumgebundenen "
"Postbox-Referenzen. Die Ergebnisse enthalten begrenzte "
"Nachrichteninhalte zur Datenschutzprüfung, ausschließlich passende "
"Beteiligtenangaben, persönliche Lese- und Bestätigungsbelege, "
"persönliche Sammelansichten, zugeordnete Zugriffsereignisse sowie "
"Urheberschaft an Konfigurationen und Inhaltsschutzwechseln. "
"Zustellung, Routing, Empfangsbelege, Zugriff, Vorlagen und "
"Schutzwechsel behalten ausdrückliche institutionelle "
"Aufbewahrungsgründe. Chiffrate, Server-Umschläge, umhüllte "
"Schlüssel, externe Empfänger-Token, undurchsichtige Metadaten, "
"Nachweisnutzdaten von Schutzwechseln und Angaben unbeteiligter "
"Personen werden niemals exportiert. Nur persönliche "
"Sammelansichten können automatisiert gelöscht werden: Die "
"Ausführung prüft Mandant, Eigentümer und Revision erneut und "
"entfernt weder Quellpostfächer noch Nachrichten. Nachrichten und "
"andere institutionelle Datensätze benötigen eine gesondert "
"autorisierte Aufbewahrungs-, Drittschutz- und Aktenprüfung. Files, "
"erzeugende Module, Identity und IDM bleiben für ihre Daten "
"zuständig."
),
}
},
metadata={
"kind": "workflow",
"route": "/admin?section=tenant-data-subject-requests",
"help_contexts": ["admin.privacy.data-subject-requests"],
"steps": [
"Run the Postbox provider and review message, participant, receipt, grouping, access, and governance dispositions.",
"Retain institutional delivery, routing, acknowledgement, access, and protection evidence with its reason.",
"Review plaintext content for third-party data and applicable records or hold policy before a separate lifecycle action.",
"Execute personal-grouping deletion only from a fresh plan; verify source Postboxes and messages remain unchanged.",
],
"limitations": [
"Encrypted content is reported by protection state but is not decrypted or exported by the provider.",
"Attachment payloads, producer records, identity records, and assignment records remain with their owning modules.",
],
},
order=31,
),
DocumentationTopic(
id="postbox.files.evidence-references",
title="Open permitted Files evidence from Postbox",
summary="Resolve exact Files versions from a Postbox message without merging Postbox and Files permissions.",
body=(
"A Postbox message retains typed attachment references even when Files is absent or the current user cannot read the payload. "
"When Files is installed, Postbox asks its public capability to resolve the exact referenced version. The download is exposed only "
"when the user also has Files download permission and resource access; Postbox access never silently grants Files access. Missing, "
"deleted, forbidden, and provider-unavailable payloads remain visible as explained evidence references."
),
layer="configured",
documentation_types=("user", "admin"),
audience=("administrator", "user", "campaign_manager"),
related_modules=("files", "audit", "campaigns"),
translations={
"de": {
"title": "Zulässige Files-Nachweise aus dem Postfach öffnen",
"summary": "Exakte Files-Versionen aus einer Postfachnachricht auflösen, ohne Postfach- und Dateiberechtigungen zu vermischen.",
"body": (
"Eine Postfachnachricht bewahrt typisierte Anlagenverweise auch dann, wenn Files fehlt oder die aktuelle Person die Nutzdaten nicht lesen darf. "
"Ist Files installiert, lässt Postbox die exakt referenzierte Version über dessen öffentliche Capability auflösen. Ein Download wird nur angeboten, "
"wenn zusätzlich die Files-Downloadberechtigung und der Ressourcenzugriff bestehen; Postfachzugriff erteilt niemals stillschweigend Dateizugriff. "
"Fehlende, gelöschte, nicht erlaubte oder mangels Anbieter nicht auflösbare Nutzdaten bleiben als erklärte Nachweisverweise sichtbar."
),
}
},
metadata={
"kind": "guide",
"help_contexts": ["postbox.message.attachments"],
},
order=32,
),
DocumentationTopic(
id="postbox.quick-access-and-product-area",
title="Postbox in Communication and Messages",
summary="Use function-bound Postboxes in Communication and the shared Messages Quick Access drawer.",
body=(
"Postbox contributes its inbox to Communication. With Quick Access enabled, its owner-rendered surface lists at most "
"seven currently readable unread messages beside independent Mail and future chat providers. "
"The stable `/messages` product entry and `/inbox` migration alias select Postbox only when its route is "
"currently authorized and visible in the effective View; otherwise another Messages owner is selected or the shell "
"explains how access can be restored. "
"Selecting or opening a "
"message returns a typed Postbox reference; accounts with message-write permission can launch the full owner-rendered "
"composer for a currently accessible function Postbox. Directory, message, and submission calls recheck the active "
"assignment or acting context. Function assignment, classification, read-receipt, retention, encryption, and evidence "
"semantics remain in Postbox."
),
layer="configured",
documentation_types=("user", "admin"),
audience=("administrator", "user", "campaign_manager"),
related_modules=("quick_access", "views", "mail"),
translations={
"de": {
"title": "Postfach in Kommunikation und Nachrichten",
"summary": "Funktionsgebundene Postfächer in Kommunikation und der gemeinsamen Schnellzugriffseinblendung Nachrichten verwenden.",
"body": (
"Postbox ordnet seinen Eingang Kommunikation zu. Ist der Schnellzugriff aktiviert, erscheint die vom Modul gerenderte "
"Oberfläche mit höchstens sieben aktuell lesbaren ungelesenen Nachrichten neben unabhängigen Beiträgen aus Mail. "
"Der stabile Produkteinstieg `/messages` und der Migrationsalias `/inbox` wählen Postbox nur, wenn seine Route "
"aktuell berechtigt und in der wirksamen View sichtbar ist. Andernfalls wird ein anderer Nachrichten-Eigentümer "
"gewählt oder die Shell erklärt die Wiederherstellung des Zugriffs. "
"Auswahl oder Öffnen liefert eine typisierte Postbox-Referenz; mit Schreibberechtigung lässt sich der vollständige "
"Editor für ein aktuell zugängliches Funktionspostfach öffnen. Verzeichnis, Nachricht und Versand prüfen die aktive "
"Zuweisung beziehungsweise den Handlungskontext erneut. Klassifikation, Lesestatus, Aufbewahrung, Verschlüsselung "
"und Nachweise verbleiben bei Postbox."
),
}
},
metadata={
"kind": "reference",
"help_contexts": ["postbox.quick_access.messages"],
},
order=33,
),
DocumentationTopic(
id="postbox.search.messages",
title="Search authorized Postbox messages",
@@ -393,8 +711,185 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"),
audience=("administrator", "user", "campaign_manager"),
related_modules=("search", "idm", "encryption"),
translations={
"de": {
"title": "Berechtigte Postfachnachrichten durchsuchen",
"summary": "Betreffzeilen und zulässige Klartextinhalte aus Postbox für die berechtigungsgeprüfte Plattform-Suche bereitstellen.",
"body": (
"Ist Search installiert, stellt Postbox ausschließlich Betreffzeilen, Absenderbezeichnungen und Klartextinhalte "
"von Nachrichten bereit. Chiffrat und Schlüsselmaterial werden niemals indiziert. Jedes Ergebnis ist auf den "
"Mandanten begrenzt und prüft aktuelle Funktionszuweisung, Postfachbindung, Klassifikation, Handlungskontext "
"und allgemeine Leseberechtigung erneut, ohne die Nachricht als gelesen zu markieren. Abgeschlossene Zustellungen "
"und Nachrichtenänderungen aktualisieren den abgeleiteten Index über den dauerhaften Plattform-Ereignispfad."
),
}
},
order=34,
),
DocumentationTopic(
id="postbox.unified-inbox-policy",
title="Configure source-preserving unified Postbox views",
summary="Group currently visible function Postboxes without merging containers, bypassing separation policy, or granting authority.",
body=(
"A personal unified view stores ordered Postbox identifiers only. Messages, receipts, retention, encryption, function and unit provenance, and audit evidence remain at the source. "
"Every exact Postbox and immutable template revision can allow grouping, require all combined sources to share its classification, or require that Postbox to remain separate. Administrators record an explanation and the API returns that rule as constraint provenance. "
"Postbox validates a grouping when it is saved and validates every aggregate message query again, so assignment churn or a later stricter rule cannot leave an unsafe combined projection active. Temporarily unavailable source preferences remain stored but reveal no metadata or counts. "
"The stable `?grouping=<grouping-id>` route parameter lets a task-focused View select a personal projection. Unknown, hidden, or stale identifiers never grant access; current IDM assignment, acting context, classification, and Postbox permission are always rechecked."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("administrator", "user", "auditor"),
related_modules=("views", "idm", "policy", "audit"),
links=(
DocumentationLink(
label="Postbox",
href="/postbox",
kind="runtime",
),
DocumentationLink(
label="Postbox administration",
href="/admin?section=postbox",
kind="runtime",
),
),
translations={
"de": {
"title": "Quellenerhaltende zusammengefasste Postfachansichten konfigurieren",
"summary": "Aktuell sichtbare Funktionspostfächer gruppieren, ohne Container zusammenzuführen, Trennregeln zu umgehen oder Berechtigungen zu erteilen.",
"body": (
"Eine persönliche zusammengefasste Ansicht speichert nur geordnete Postfachkennungen. Nachrichten, Lesestatus, Aufbewahrung, Verschlüsselung, Funktions- und Organisationsbezug sowie Prüfnachweise verbleiben an der Quelle. "
"Jedes exakte Postfach und jede unveränderliche Vorlagenrevision kann Gruppierung erlauben, für alle Quellen dieselbe Klassifikation verlangen oder das Postfach vollständig getrennt halten. Die Administration hinterlegt eine Begründung; die API liefert Regel und Herkunft als Einschränkung. "
"Postbox prüft die Regel beim Speichern und erneut bei jeder zusammengefassten Nachrichtenabfrage. Änderungen an Zuweisungen oder später verschärfte Regeln lassen daher keine unsichere Projektion bestehen. Vorübergehend unsichtbare Quellenpräferenzen bleiben ohne Preisgabe von Metadaten oder Zählwerten erhalten. "
"Der stabile Routenparameter `?grouping=<grouping-id>` erlaubt einer aufgabenbezogenen View die Auswahl einer persönlichen Projektion. Unbekannte, unsichtbare oder veraltete Kennungen erteilen keinen Zugriff; aktuelle IDM-Zuweisung, Handlungskontext, Klassifikation und Postfachberechtigung werden stets erneut geprüft."
),
}
},
metadata={
"kind": "guide",
"help_contexts": [
"postbox.inbox.directory",
"postbox.action.delete-grouping",
"postbox.admin.templates",
],
"privacy_notes": [
"Hidden grouping sources do not expose metadata or counts.",
"A View selects a projection but never grants Postbox access.",
],
},
order=34,
),
DocumentationTopic(
id="postbox.lifecycle-notifications",
title="Receive governed Postbox lifecycle notifications",
summary="Notify current function holders about durable Postbox work and responsibility changes without copying message content.",
body=(
"Postbox emits versioned delivery, action-required, escalation-due, read, acknowledgement, assignment, delegation, vacancy, and reassignment events. "
"A periodic worker resolves current IDM incumbencies in batches and compares them with a durable metadata-only cursor. The first run establishes a quiet baseline; later changes and failed notification handoffs are deduplicated and retryable. "
"When Notifications is installed, newly eligible current holders receive generic in-app entries linked to the owning Postbox or message. Subjects, bodies, participant addresses, attachment details, ciphertext, and key material are never copied into lifecycle event payloads. "
"Notifications owns personal preferences, quiet periods, and future email or push channel policy. Postbox remains authoritative and fully usable without that optional module. Opening any notification rechecks current assignment, delegation, classification, and generic Postbox permission, so expired access cannot be restored by an old link."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("administrator", "user", "auditor"),
related_modules=("notifications", "idm", "access", "audit"),
links=(
DocumentationLink(
label="Postbox",
href="/postbox",
kind="runtime",
),
),
translations={
"de": {
"title": "Geregelte Postfach-Lebenszyklusmeldungen erhalten",
"summary": "Aktuelle Funktionsinhaber über dauerhafte Postfacharbeit und Verantwortungswechsel informieren, ohne Nachrichteninhalte zu kopieren.",
"body": (
"Postbox erzeugt versionierte Ereignisse für Zustellung, Handlungsbedarf, fällige Eskalation, Lesen, Bestätigung, Zuweisung, Delegation, Vakanz und Neubesetzung. "
"Ein periodischer Worker löst aktuelle IDM-Stellenbesetzungen gebündelt auf und vergleicht sie mit einem dauerhaften, ausschließlich Metadaten enthaltenden Cursor. Der erste Lauf legt geräuschlos den Ausgangsstand fest; spätere Änderungen und fehlgeschlagene Übergaben werden dedupliziert und erneut versucht. "
"Ist Notifications installiert, erhalten neu berechtigte aktuelle Stelleninhaber allgemeine In-App-Hinweise mit Verweis auf das zuständige Postfach oder die Nachricht. Betreff, Inhalt, Beteiligtenadressen, Anlagendetails, Chiffrat und Schlüsselmaterial werden niemals in Lebenszyklusereignisse kopiert. "
"Notifications verantwortet persönliche Einstellungen, Ruhezeiten und künftige E-Mail- oder Push-Kanalregeln. Postbox bleibt ohne dieses optionale Modul vollständig funktionsfähig. Beim Öffnen werden aktuelle Zuweisung, Delegation, Klassifikation und allgemeine Postfachberechtigung erneut geprüft; ein alter Link stellt abgelaufenen Zugriff nicht wieder her."
),
}
},
metadata={
"kind": "guide",
"help_contexts": [
"postbox.inbox.messages",
"postbox.blocker.assignment",
"postbox.state.unavailable",
],
"privacy_notes": [
"Lifecycle notifications never contain message or attachment content.",
"Current access is rechecked when a notification link is opened.",
"Notifications owns preference and external-channel enforcement.",
],
},
order=35,
),
DocumentationTopic(
id="postbox.content-protection-policy",
title="Choose and change Postbox content protection",
summary="Configure plaintext, institution-managed envelope, or externally managed E2EE content with governed hand-over and migration evidence.",
body=(
"Every exact Postbox and immutable template revision selects a content-protection profile. "
"Institution-managed server envelopes are the recommended standard and require an Encryption vault; authorized institutional key holders can decrypt them. "
"External E2EE rejects clear message bodies and requires an approved producer or client to supply ciphertext, a signed manifest, wrapped recipient keys, and a verified digest; GovOPlaN cannot decrypt that content. "
"Plaintext remains an explicit deployment choice. Subjects, routing, participants, attachment references, lifecycle state, and other operational metadata remain visible in every profile. "
"The accompanying policy selects history for new incumbents, ordinary and compromise rotation, recovery, hand-over, emergency access, export, destruction, external-recipient assurance, and metadata-only vacancy escalation. "
"A profile transition applies to future messages immediately and may retain or migrate history. It records user-consent and/or institutional key-holder evidence, quorum, reason, digest continuity, and per-message outcome. "
"Transitions to or from E2EE wait for approved client transformations; native device key custody and cryptographic clients are not supplied by Postbox. Previously decrypted, copied, printed, or exported content cannot be recalled."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("administrator", "user", "auditor"),
related_modules=("encryption", "identity_trust", "policy", "audit"),
links=(
DocumentationLink(
label="Postbox administration",
href="/admin?section=postbox",
kind="runtime",
),
DocumentationLink(
label="Protection profile catalog API",
href="/api/v1/postbox/admin/protection-profiles",
kind="api",
),
DocumentationLink(
label="Postbox protection concept",
href="docs/POSTBOX_CONCEPT.md",
kind="source",
),
),
translations={
"de": {
"title": "Inhaltsschutz für Postfächer wählen und ändern",
"summary": "Klartext, institutionell verwaltete Umschlagverschlüsselung oder extern verwaltete Ende-zu-Ende-Verschlüsselung mit geregelten Übergabe- und Migrationsnachweisen konfigurieren.",
"body": (
"Jedes exakte Postfach und jede unveränderliche Vorlagenrevision wählt ein Inhaltsschutzprofil. "
"Institutionell verwaltete Server-Umschläge sind der empfohlene Standard und benötigen einen Encryption-Tresor; berechtigte institutionelle Schlüsselverantwortliche können sie entschlüsseln. "
"Externe Ende-zu-Ende-Verschlüsselung weist Klartextnachrichten ab und verlangt von einem zugelassenen Erzeuger oder Client Chiffrat, signiertes Manifest, umhüllte Empfängerschlüssel und einen geprüften Digest; GovOPlaN kann diesen Inhalt nicht entschlüsseln. "
"Klartext bleibt eine ausdrückliche Wahl. Betreff, Routing, Beteiligte, Anlagenverweise, Lebenszyklus und weitere Betriebsmetadaten bleiben bei allen Profilen sichtbar. "
"Die begleitende Richtlinie regelt den Verlauf für neue Stelleninhaber, normale Rotation und Kompromittierung, Wiederherstellung, Übergabe, Notfallzugriff, Export, Vernichtung, externe Empfängerprüfung und rein metadatenbasierte Vakanzeskalation. "
"Ein Profilwechsel gilt sofort für neue Nachrichten und kann den Bestand beibehalten oder migrieren. Er protokolliert Einwilligungen und/oder institutionelle Freigaben, Quorum, Grund, Digest-Kontinuität und Ergebnis je Nachricht. "
"Wechsel zu oder von E2EE warten auf freigegebene Client-Transformationen; Geräte-Schlüsselverwahrung und Kryptografie-Clients liefert Postbox nicht mit. Bereits entschlüsselte, kopierte, gedruckte oder exportierte Inhalte können nicht zurückgerufen werden."
),
}
},
metadata={
"kind": "guide",
"help_contexts": [
"postbox.admin.templates",
"postbox.field.protection-profile",
"postbox.action.protection-transition",
],
"privacy_notes": [
"E2EE protects content, not operational metadata.",
"Managed envelopes are decryptable by authorized institutional key holders.",
"Profile transitions preserve authority evidence and content-digest continuity.",
],
},
order=36,
),
DocumentationTopic(
id="postbox.function-bound-containers",
title="Function-bound Postboxes",
@@ -403,17 +898,21 @@ manifest = ModuleManifest(
"effective organization-function assignments."
),
body=(
"Documentation books sit immediately beside the visible heading or contextual label for "
"Postboxes and the relevant protection, hierarchy or inbox setting, not among operational "
"action buttons. Field help remains beside its label. "
"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. Plaintext "
"Postboxes remain available without Encryption. A "
"server-envelope profile stores message bodies as ciphertext and "
"uses the optional Encryption capability for authorized reads. "
"External ciphertext profiles retain producer-managed references "
"and keys; neither profile is described as end-to-end encryption."
"postboxes cover exceptional responsibilities. Administrators "
"choose plaintext, the recommended institution-managed Encryption "
"envelope, or a strict externally produced E2EE contract. Managed "
"envelopes remain decryptable by authorized institutional key holders; "
"E2EE rejects plaintext and GovOPlaN has no private decryption key. "
"When Tasks is enabled, currently readable unread messages also appear "
"in the common work inbox and disappear when the personal read receipt is recorded."
),
layer="available",
documentation_types=("admin", "user"),
@@ -425,6 +924,7 @@ manifest = ModuleManifest(
"campaigns",
"files",
"notifications",
"tasks",
),
links=(
DocumentationLink(
@@ -438,6 +938,26 @@ manifest = ModuleManifest(
kind="api",
),
),
translations={
"de": {
"title": "Funktionsgebundene Postfächer",
"summary": "Dauerhafte institutionelle Nachrichtencontainer, deren Zugriff den wirksamen Zuweisungen zu Organisationsfunktionen folgt.",
"body": (
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für Postfächer und die jeweilige Schutz-, Hierarchie- oder "
"Posteingangseinstellung, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe bleibt "
"neben der Feldbezeichnung. "
"Postfächer gehören zu Verantwortlichkeiten und nicht zu einzelnen Konten. Ein stabiles Postfach bleibt während "
"einer Vakanz und Neubesetzung adressierbar. Der aktuelle Zugriff verbindet eine allgemeine Postfachberechtigung "
"mit dem wirksamen IDM-Zuweisungskontext. Vorlagen können einheitsspezifische Adressen bei Bedarf erzeugen; exakte "
"Postfächer bilden besondere Verantwortlichkeiten ab. Die Administration wählt Klartext, die empfohlene institutionell "
"verwaltete Encryption-Umschlagverschlüsselung oder einen strikten, extern erzeugten E2EE-Vertrag. Verwaltete Umschläge "
"bleiben für berechtigte institutionelle Schlüsselverantwortliche entschlüsselbar; E2EE weist Klartext zurück und "
"GovOPlaN besitzt keinen privaten Entschlüsselungsschlüssel. Ist Tasks aktiviert, erscheinen aktuell lesbare ungelesene "
"Nachrichten auch im gemeinsamen Arbeitsvorrat und verschwinden dort, sobald der persönliche Lesestatus gespeichert ist."
),
}
},
metadata={
"kind": "guide",
"help_contexts": [
@@ -454,7 +974,7 @@ manifest = ModuleManifest(
"Subjects, participants, routing facts, and attachment references remain observable metadata.",
],
},
order=35,
order=37,
),
DocumentationTopic(
id="postbox.reference.fields-and-consequences",
@@ -464,6 +984,9 @@ manifest = ModuleManifest(
"classification, retention, and lifecycle fields."
),
body=(
"Documentation books sit immediately beside the visible heading or contextual label for "
"Postboxes and the relevant protection, hierarchy or inbox setting, not among operational "
"action buttons. Field help remains beside its label. "
"A Postbox address is durable and bound to an organization function. "
"Template revisions are immutable after publication; retiring a template "
"does not remove materialized addresses. Archiving an address stops new "
@@ -473,7 +996,12 @@ manifest = ModuleManifest(
"Hierarchy copies are independent deliveries with their own evidence; "
"vacancy escalation is delayed and separately auditable. Message expiry "
"or withdrawal blocks future content access but cannot retract plaintext "
"already copied, exported, or printed."
"already copied, exported, or printed. Subtree templates select one "
"explicit organization structure and optional relation types. Their "
"read-only impact preview reports generated addresses, current holders, "
"vacancy, collisions, cycles, depth limits, and ambiguous paths without "
"creating templates or Postboxes. Grouping totals include only source "
"Postboxes currently visible to the account."
),
layer="configured",
documentation_types=("admin", "user"),
@@ -497,6 +1025,29 @@ manifest = ModuleManifest(
kind="source",
),
),
translations={
"de": {
"title": "Postfachfelder und ihre Auswirkungen",
"summary": "Referenz für Adresse, Vorlage, Routing, Nachricht, Gruppierung, Klassifikation, Aufbewahrung und Lebenszyklus.",
"body": (
"Dokumentationsbücher stehen unmittelbar neben der sichtbaren Überschrift oder "
"Kontextbezeichnung für Postfächer und die jeweilige Schutz-, Hierarchie- oder "
"Posteingangseinstellung, nicht zwischen ausführbaren Aktionsschaltflächen. Feldhilfe bleibt "
"neben der Feldbezeichnung. "
"Eine Postfachadresse ist dauerhaft und an eine Organisationsfunktion gebunden. Vorlagenrevisionen sind nach der "
"Veröffentlichung unveränderlich; die Stilllegung einer Vorlage entfernt keine bereits erzeugten Adressen. Die "
"Archivierung einer Adresse stoppt neue Zustellungen und bewahrt Nachrichten und Nachweise. Zusammengefasste "
"Posteingänge sind ausschließlich persönliche Projektionen und verschieben oder löschen niemals Quellnachrichten. "
"Die Klassifikation begrenzt zulässige Zustellungs- und Hierarchiekopieziele. Hierarchiekopien sind eigenständige "
"Zustellungen mit eigenen Nachweisen; die Vakanzeskalation erfolgt verzögert und getrennt prüfbar. Ablauf oder Rücknahme "
"einer Nachricht sperrt den künftigen Inhaltszugriff, kann aber bereits kopierten, exportierten oder gedruckten Klartext "
"nicht zurückrufen. Teilbaumvorlagen wählen genau eine Organisationsstruktur und optional Beziehungstypen. Ihre "
"schreibgeschützte Auswirkungsvorschau meldet erzeugte Adressen, aktuelle Stelleninhaber, Vakanzen, Kollisionen, Zyklen, "
"Tiefengrenzen und mehrdeutige Pfade, ohne Vorlagen oder Postfächer anzulegen. Gruppierungssummen umfassen nur Quellpostfächer, "
"die für das aktuelle Konto sichtbar sind."
),
}
},
metadata={
"kind": "reference",
"help_contexts": [
@@ -506,6 +1057,7 @@ manifest = ModuleManifest(
"postbox.field.classification",
"postbox.field.retention",
"postbox.field.hierarchy-routing",
"postbox.action.preview-template",
"postbox.field.recipients",
"postbox.action.archive",
"postbox.action.retire-template",
@@ -513,6 +1065,7 @@ manifest = ModuleManifest(
],
"consequence_classes": {
"publish_template": "Freezes an immutable address and routing revision for future materialization.",
"preview_template": "Reads current organization, hierarchy, and incumbency state without materializing any address or Postbox.",
"retire_template": "Stops new revisions and materialization while retaining existing addresses.",
"archive_postbox": "Stops new delivery while retaining messages, receipts, and evidence.",
"delete_grouping": "Deletes only the personal projection; source Postboxes and messages remain unchanged.",
@@ -520,7 +1073,7 @@ manifest = ModuleManifest(
"withdraw_or_expire": "Blocks future content access while retaining permitted audit metadata.",
},
},
order=36,
order=38,
),
),
architecture=declared_module_architecture(
@@ -532,10 +1085,22 @@ manifest = ModuleManifest(
known_limits=(
"Subjects, routing metadata, participants, and attachment references remain plaintext metadata.",
"Server-envelope protection is server-decryptable and is not end-to-end encryption.",
"External ciphertext profiles require a separately governed producer and client key-custody profile.",
"External E2EE requires a separately governed producer/client, private-key custody, device enrollment, and independent cryptographic review.",
),
owned_concepts=(
"postbox",
"postbox address",
"postbox message",
"delivery receipt",
"access event",
"postbox protection transition",
),
non_owned_concepts=(
"identity",
"function assignment",
"campaign",
"cryptographic key custody",
),
owned_concepts=("postbox", "postbox address", "postbox message", "delivery receipt", "access event"),
non_owned_concepts=("identity", "function assignment", "campaign", "cryptographic key custody"),
recovery_docs=("docs/POSTBOX_CONCEPT.md",),
security_docs=("docs/POSTBOX_CONCEPT.md",),
operations_docs=("README.md",),
@@ -543,5 +1108,10 @@ manifest = ModuleManifest(
)
manifest = with_documentation_structured_translations(
manifest, locale="de", translations=GERMAN_STRUCTURED_TRANSLATIONS
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -0,0 +1,112 @@
"""v0.1.18 governed Postbox protection transitions.
Revision ID: a7c1e4f8b2d6
Revises: f2a5c8e1b4d7
"""
from alembic import op
import sqlalchemy as sa
revision = "a7c1e4f8b2d6"
down_revision = "f2a5c8e1b4d7"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"postbox_protection_transitions",
sa.Column("id", sa.String(36), nullable=False),
sa.Column("tenant_id", sa.String(36), nullable=False),
sa.Column("postbox_id", sa.String(36), nullable=False),
sa.Column("idempotency_key", sa.String(255), nullable=False),
sa.Column("source_profile", sa.String(80), nullable=False),
sa.Column("target_profile", sa.String(80), nullable=False),
sa.Column("source_vault_id", sa.String(255), nullable=True),
sa.Column("target_vault_id", sa.String(255), nullable=True),
sa.Column("history_mode", sa.String(30), nullable=False),
sa.Column("authority_mode", sa.String(40), nullable=False),
sa.Column("required_quorum", sa.Integer(), nullable=False),
sa.Column("evidence_refs", sa.JSON(), nullable=False),
sa.Column("reason", sa.Text(), nullable=False),
sa.Column("state", sa.String(30), nullable=False),
sa.Column("message_count", sa.Integer(), nullable=False),
sa.Column("completed_count", sa.Integer(), nullable=False),
sa.Column("failed_count", sa.Integer(), nullable=False),
sa.Column("requested_by", sa.String(255), nullable=True),
sa.Column("activated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("resource_revision", sa.Integer(), nullable=False),
sa.Column("configuration_snapshot", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(["postbox_id"], ["postboxes.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"tenant_id",
"postbox_id",
"idempotency_key",
name="uq_postbox_protection_transition_idem",
),
)
op.create_index(
"ix_postbox_protection_transition_state",
"postbox_protection_transitions",
["tenant_id", "postbox_id", "state"],
)
for column in ("tenant_id", "postbox_id", "state"):
op.create_index(
f"ix_postbox_protection_transitions_{column}",
"postbox_protection_transitions",
[column],
)
op.create_table(
"postbox_protection_transition_items",
sa.Column("id", sa.String(36), nullable=False),
sa.Column("tenant_id", sa.String(36), nullable=False),
sa.Column("transition_id", sa.String(36), nullable=False),
sa.Column("message_id", sa.String(36), nullable=False),
sa.Column("source_profile", sa.String(80), nullable=False),
sa.Column("target_profile", sa.String(80), nullable=False),
sa.Column("state", sa.String(30), nullable=False),
sa.Column("source_digest", sa.String(255), nullable=True),
sa.Column("target_digest", sa.String(255), nullable=True),
sa.Column("completed_by", sa.String(255), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("error_code", sa.String(100), nullable=True),
sa.Column("evidence", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["transition_id"],
["postbox_protection_transitions.id"],
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["message_id"], ["postbox_messages.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"transition_id",
"message_id",
name="uq_postbox_protection_transition_message",
),
)
op.create_index(
"ix_postbox_protection_transition_item_state",
"postbox_protection_transition_items",
["tenant_id", "transition_id", "state"],
)
for column in ("tenant_id", "transition_id", "message_id", "state"):
op.create_index(
f"ix_postbox_protection_transition_items_{column}",
"postbox_protection_transition_items",
[column],
)
def downgrade() -> None:
op.drop_table("postbox_protection_transition_items")
op.drop_table("postbox_protection_transitions")
@@ -0,0 +1,31 @@
"""v0.1.18 governed unified-Postbox grouping policy.
Revision ID: d8b4f1a6c9e2
Revises: a7c1e4f8b2d6
"""
from alembic import op
import sqlalchemy as sa
revision = "d8b4f1a6c9e2"
down_revision = "a7c1e4f8b2d6"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch_op:
batch_op.add_column(
sa.Column(
"grouping_policy",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
)
)
def downgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch_op:
batch_op.drop_column("grouping_policy")
@@ -0,0 +1,43 @@
"""Add explicit Postbox template hierarchy scope.
Revision ID: e9f4a7b2c5d8
Revises: d8e3f6a9b2c5
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9f4a7b2c5d8"
down_revision = "d8e3f6a9b2c5"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.add_column(
sa.Column("scope_structure_id", sa.String(length=36), nullable=True)
)
batch.add_column(
sa.Column(
"scope_relation_type_ids",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'"),
)
)
batch.create_index(
"ix_postbox_template_revisions_scope_structure",
["scope_structure_id"],
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.drop_index("ix_postbox_template_revisions_scope_structure")
batch.drop_column("scope_relation_type_ids")
batch.drop_column("scope_structure_id")
@@ -0,0 +1,33 @@
"""Add explicit portal visibility to Postbox template revisions.
Revision ID: f2a5c8e1b4d7
Revises: e9f4a7b2c5d8
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f2a5c8e1b4d7"
down_revision = "e9f4a7b2c5d8"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.add_column(
sa.Column(
"portal_visible",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.drop_column("portal_visible")
@@ -0,0 +1,25 @@
from __future__ import annotations
READ_SCOPE = "postbox:postbox:read"
SEND_SCOPE = "postbox:message:write"
REPLY_SCOPE = "postbox:message:reply"
ACKNOWLEDGE_SCOPE = "postbox:message:acknowledge"
DELIVERY_SCOPE = "postbox:delivery:write"
BINDING_ADMIN_SCOPE = "postbox:binding:admin"
TEMPLATE_ADMIN_SCOPE = "postbox:template:admin"
CONFIDENTIAL_SCOPE = "postbox:classification:confidential"
RESTRICTED_SCOPE = "postbox:classification:restricted"
__all__ = [
"ACKNOWLEDGE_SCOPE",
"BINDING_ADMIN_SCOPE",
"CONFIDENTIAL_SCOPE",
"DELIVERY_SCOPE",
"READ_SCOPE",
"REPLY_SCOPE",
"RESTRICTED_SCOPE",
"SEND_SCOPE",
"TEMPLATE_ADMIN_SCOPE",
]
@@ -0,0 +1,106 @@
from __future__ import annotations
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.postbox import (
PostboxPortalEntryRef,
PostboxPortalProjectionProvider,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_postbox.backend.db.models import Postbox, PostboxMessage
from govoplan_postbox.backend.principals import actor_from_principal
from govoplan_postbox.backend.runtime import configure_runtime, get_service
class PortalProjection(PostboxPortalProjectionProvider):
"""Read-only Portal projection; Postbox remains the access authority."""
def list_portal_entries(
self,
session: object,
principal: object,
*,
tenant_id: str,
limit: int = 100,
) -> tuple[PostboxPortalEntryRef, ...]:
if not isinstance(session, Session):
raise TypeError("Postbox Portal projection requires a SQLAlchemy session.")
if not isinstance(principal, ApiPrincipal):
raise TypeError("Postbox Portal projection requires an API principal.")
if principal.tenant_id != tenant_id:
return ()
actor = actor_from_principal(principal)
visible = tuple(
get_service().list_visible_postboxes(
session,
tenant_id=tenant_id,
actor=actor,
)
)
if not visible:
return ()
visible_by_id = {entry.id: entry for entry in visible}
rows = (
session.query(Postbox)
.filter(
Postbox.tenant_id == tenant_id,
Postbox.id.in_(tuple(visible_by_id)),
Postbox.status == "active",
)
.all()
)
enabled_ids = {
row.id
for row in rows
if bool((row.settings or {}).get("portal_visible"))
}
if not enabled_ids:
return ()
ordered_ids = tuple(
entry.id
for entry in sorted(
(visible_by_id[item_id] for item_id in enabled_ids),
key=lambda item: (item.name.casefold(), item.id),
)[: max(1, min(limit, 500))]
)
counts = get_service().message_counts_by_postbox(
session,
tenant_id=tenant_id,
postbox_ids=ordered_ids,
actor=actor,
)
latest_rows = (
session.query(
PostboxMessage.postbox_id,
func.max(PostboxMessage.delivered_at),
)
.filter(
PostboxMessage.tenant_id == tenant_id,
PostboxMessage.postbox_id.in_(ordered_ids),
PostboxMessage.classification.in_(
tuple(actor.authorized_classifications)
),
)
.group_by(PostboxMessage.postbox_id)
.all()
)
latest = {str(postbox_id): delivered_at for postbox_id, delivered_at in latest_rows}
return tuple(
PostboxPortalEntryRef(
postbox=visible_by_id[postbox_id],
unread_count=int(counts.get(postbox_id, {}).get("unread", 0)),
latest_message_at=latest.get(postbox_id),
route_path=f"/postbox?postbox={postbox_id}",
)
for postbox_id in ordered_ids
)
def create_portal_projection(context: ModuleContext) -> PortalProjection:
configure_runtime(registry=context.registry)
return PortalProjection()
__all__ = ["PortalProjection", "create_portal_projection"]
@@ -0,0 +1,65 @@
from __future__ import annotations
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.postbox import PostboxActorRef
from govoplan_postbox.backend.permissions import (
ACKNOWLEDGE_SCOPE,
BINDING_ADMIN_SCOPE,
CONFIDENTIAL_SCOPE,
READ_SCOPE,
REPLY_SCOPE,
RESTRICTED_SCOPE,
SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE,
)
class PostboxPrincipalError(ValueError):
pass
def actor_from_principal(
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, REPLY_SCOPE):
actions.add("reply")
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")
if (
assignment_context_id is not None
and assignment_context_id not in principal.function_assignment_ids
):
raise PostboxPrincipalError(
"The selected assignment context is not active for this principal."
)
selected = assignment_context_id or principal.acting_assignment_id
if selected is None and len(principal.function_assignment_ids) == 1:
selected = next(iter(principal.function_assignment_ids))
classifications = {"public", "internal"}
if has_scope(principal, CONFIDENTIAL_SCOPE):
classifications.add("confidential")
if has_scope(principal, RESTRICTED_SCOPE):
classifications.update(("confidential", "restricted"))
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]
authorized_classifications=frozenset(classifications), # type: ignore[arg-type]
)
__all__ = ["PostboxPrincipalError", "actor_from_principal"]
@@ -0,0 +1,106 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
POSTBOX_PLAINTEXT_PROFILE = "plaintext_v1"
POSTBOX_MANAGED_ENVELOPE_PROFILE = "server_envelope_v1"
POSTBOX_EXTERNAL_E2EE_PROFILE = "external_e2ee_v1"
POSTBOX_LEGACY_EXTERNAL_ENVELOPE_PROFILE = "external_envelope_v1"
POSTBOX_STANDARD_PROFILE = POSTBOX_MANAGED_ENVELOPE_PROFILE
PostboxProtectionProfile = Literal[
"plaintext_v1",
"server_envelope_v1",
"external_e2ee_v1",
]
SUPPORTED_POSTBOX_PROTECTION_PROFILES = frozenset(
{
POSTBOX_PLAINTEXT_PROFILE,
POSTBOX_MANAGED_ENVELOPE_PROFILE,
POSTBOX_EXTERNAL_E2EE_PROFILE,
}
)
def normalize_postbox_protection_policy(
policy: dict[str, object] | None = None,
) -> dict[str, object]:
return {
"new_incumbent_history": "since_assignment",
"history_days": None,
"ordinary_rotation": "rewrap",
"compromise_rotation": "reencrypt",
"recovery_authority": "institutional_key_holders",
"recovery_quorum": 2,
"handover_authority": "dual_control",
"handover_quorum": 2,
"emergency_access": "dual_control",
"emergency_quorum": 2,
"export_authority": "dual_control",
"export_quorum": 2,
"destruction_authority": "dual_control",
"destruction_quorum": 2,
"external_recipient_assurance": "strong_identity",
"vacancy_escalation_content_access": "metadata_only",
**(policy or {}),
}
@dataclass(frozen=True, slots=True)
class PostboxProtectionProfileDefinition:
id: PostboxProtectionProfile
label: str
description: str
server_can_decrypt: bool
requires_encryption_module: bool
requires_external_client: bool
standard: bool = False
POSTBOX_PROTECTION_PROFILE_DEFINITIONS = (
PostboxProtectionProfileDefinition(
id=POSTBOX_MANAGED_ENVELOPE_PROFILE,
label="Institution-managed envelope",
description=(
"The Encryption provider protects content and authorized institutional "
"key holders can govern recovery. This is the standard profile."
),
server_can_decrypt=True,
requires_encryption_module=True,
requires_external_client=False,
standard=True,
),
PostboxProtectionProfileDefinition(
id=POSTBOX_EXTERNAL_E2EE_PROFILE,
label="External end-to-end envelope",
description=(
"A reviewed client or producer supplies ciphertext, a signed manifest, "
"and recipient-wrapped keys. GovOPlaN stores and routes them but cannot "
"decrypt the content."
),
server_can_decrypt=False,
requires_encryption_module=False,
requires_external_client=True,
),
PostboxProtectionProfileDefinition(
id=POSTBOX_PLAINTEXT_PROFILE,
label="No application-layer encryption",
description=(
"Postbox stores readable message content. Transport and storage controls "
"may still apply, but this profile is not encrypted by Postbox."
),
server_can_decrypt=True,
requires_encryption_module=False,
requires_external_client=False,
),
)
def is_e2ee_profile(profile: str) -> bool:
return profile in {
POSTBOX_EXTERNAL_E2EE_PROFILE,
POSTBOX_LEGACY_EXTERNAL_ENVELOPE_PROFILE,
}
+533 -53
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import asdict
from typing import Literal
@@ -23,21 +24,26 @@ from govoplan_core.core.postbox import (
PostboxTargetRef,
PostboxWrappedKeyRef,
)
from govoplan_core.core.files import (
PostboxFileReferenceRequest,
postbox_file_reference_provider,
)
from govoplan_core.core.encryption import encryption_content_cipher
from govoplan_core.db.session import get_session
from govoplan_postbox.backend.manifest import (
ACKNOWLEDGE_SCOPE,
BINDING_ADMIN_SCOPE,
CONFIDENTIAL_SCOPE,
DELIVERY_SCOPE,
READ_SCOPE,
REPLY_SCOPE,
RESTRICTED_SCOPE,
SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE,
)
from govoplan_postbox.backend.runtime import get_service
from govoplan_postbox.backend.runtime import get_registry, get_service
from govoplan_postbox.backend.schemas import (
PostboxAccessDecisionResponse,
PostboxAttachmentResolutionItem,
PostboxAttachmentResolutionResponse,
PostboxDeliveryCreateRequest,
PostboxDeliveryResponse,
PostboxDirectoryItem,
@@ -46,6 +52,7 @@ from govoplan_postbox.backend.schemas import (
PostboxGroupingItem,
PostboxGroupingListResponse,
PostboxGroupingPayload,
PostboxGroupingPolicyUpdateRequest,
PostboxGroupingUpdateRequest,
PostboxMaterializeRequest,
PostboxMessageItem,
@@ -55,15 +62,37 @@ from govoplan_postbox.backend.schemas import (
PostboxMessageStateRequest,
PostboxOrganizationTargetsResponse,
PostboxMutationRequest,
PostboxProtectionProfileItem,
PostboxProtectionProfileListResponse,
PostboxProtectionPolicyUpdateRequest,
PostboxProtectionTransformRequest,
PostboxProtectionTransitionCreateRequest,
PostboxProtectionTransitionItemResponse,
PostboxProtectionTransitionListResponse,
PostboxProtectionTransitionResponse,
PostboxRouteDryRunRequest,
PostboxRouteDryRunResponse,
PostboxTemplateCreateRequest,
PostboxTemplateItem,
PostboxTemplateListResponse,
PostboxTemplatePreviewRequest,
PostboxTemplatePreviewResponse,
PostboxTemplatePublishRequest,
PostboxTemplateReviseRequest,
)
from govoplan_postbox.backend.service import PostboxError
from govoplan_postbox.backend.grouping_policies import (
grouping_policy_conflicts,
normalize_postbox_grouping_policy,
)
from govoplan_postbox.backend.protection_profiles import (
POSTBOX_PROTECTION_PROFILE_DEFINITIONS,
POSTBOX_STANDARD_PROFILE,
)
from govoplan_postbox.backend.principals import (
PostboxPrincipalError,
actor_from_principal,
)
router = APIRouter(prefix="/postbox", tags=["postbox"])
@@ -91,44 +120,16 @@ def _actor(
*,
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, REPLY_SCOPE):
actions.add("reply")
if has_scope(principal, ACKNOWLEDGE_SCOPE):
actions.add("acknowledge")
if has_scope(principal, BINDING_ADMIN_SCOPE) or has_scope(
try:
return actor_from_principal(
principal,
TEMPLATE_ADMIN_SCOPE,
):
actions.add("administer")
if (
assignment_context_id is not None
and assignment_context_id not in principal.function_assignment_ids
):
assignment_context_id=assignment_context_id,
)
except PostboxPrincipalError as exc:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="The selected assignment context is not active for this principal.",
)
selected = assignment_context_id or principal.acting_assignment_id
if selected is None and len(principal.function_assignment_ids) == 1:
selected = next(iter(principal.function_assignment_ids))
classifications = {"public", "internal"}
if has_scope(principal, CONFIDENTIAL_SCOPE):
classifications.add("confidential")
if has_scope(principal, RESTRICTED_SCOPE):
classifications.update(("confidential", "restricted"))
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]
authorized_classifications=frozenset(classifications), # type: ignore[arg-type]
)
detail=str(exc),
) from exc
def _http_error(exc: PostboxError) -> HTTPException:
@@ -139,6 +140,8 @@ def _http_error(exc: PostboxError) -> HTTPException:
"revision_not_found",
"grouping_not_found",
"target_not_found",
"protection_transition_not_found",
"transition_item_not_found",
}:
code = status.HTTP_404_NOT_FOUND
elif exc.code in {"access_denied", "grouping_source_denied"}:
@@ -147,6 +150,7 @@ def _http_error(exc: PostboxError) -> HTTPException:
"template_slug_exists",
"address_collision",
"idempotency_conflict",
"grouping_policy_conflict",
}:
code = status.HTTP_409_CONFLICT
else:
@@ -212,6 +216,11 @@ def _authoring_request(
idempotency_key=payload.idempotency_key,
subject=payload.subject,
body_text=payload.body_text,
ciphertext_ref=payload.ciphertext_ref,
signed_manifest_ref=payload.signed_manifest_ref,
wrapped_keys=tuple(
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
),
classification=payload.classification,
participants=tuple(
PostboxParticipantRef(**participant.model_dump())
@@ -252,12 +261,17 @@ def _template_item(template) -> PostboxTemplateItem:
"function_type_id": revision.function_type_id,
"scope_kind": revision.scope_kind,
"scope_id": revision.scope_id,
"scope_structure_id": revision.scope_structure_id,
"scope_relation_type_ids": list(revision.scope_relation_type_ids or []),
"name_pattern": revision.name_pattern,
"address_pattern": revision.address_pattern,
"classification": revision.classification,
"allow_vacant_delivery": revision.allow_vacant_delivery,
"portal_visible": revision.portal_visible,
"encryption_profile": revision.encryption_profile,
"encryption_vault_id": revision.encryption_vault_id,
"protection_policy": dict(revision.history_policy or {}),
"grouping_policy": dict(revision.grouping_policy or {}),
"history_policy": dict(revision.history_policy or {}),
"routing_policy": dict(revision.routing_policy or {}),
"retention_policy": dict(revision.retention_policy or {}),
@@ -271,23 +285,123 @@ def _template_item(template) -> PostboxTemplateItem:
)
def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem:
def _grouping_item(
grouping,
*,
visible_ids: set[str],
counts_by_postbox: Mapping[str, Mapping[str, int]] | None = None,
) -> PostboxGroupingItem:
visible_source_ids = [
source.postbox_id
for source in grouping.sources
if source.postbox_id in visible_ids
]
counts = counts_by_postbox or {}
constraints = []
policy_sources = []
for source in grouping.sources:
if source.postbox_id not in visible_ids:
continue
settings = (
source.postbox.settings
if isinstance(source.postbox.settings, Mapping)
else {}
)
policy = normalize_postbox_grouping_policy(
settings.get("grouping_policy")
if isinstance(settings.get("grouping_policy"), Mapping)
else None
)
policy_sources.append(
(
source.postbox_id,
source.postbox.classification,
policy,
)
)
if policy["mode"] == "allow":
continue
constraints.append(
{
"code": (
"source_requires_separation"
if policy["mode"] == "separate"
else "classification_separation_required"
),
"mode": policy["mode"],
"postbox_id": source.postbox_id,
"reason": policy["reason"],
"enforced_by": "postbox_configuration",
}
)
count_source_ids = (
[]
if grouping_policy_conflicts(policy_sources)
else visible_source_ids
)
return PostboxGroupingItem(
id=grouping.id,
name=grouping.name,
is_default=grouping.is_default,
resource_revision=grouping.resource_revision,
etag=grouping.strong_etag,
postbox_ids=[
source.postbox_id
for source in grouping.sources
if source.postbox_id in visible_ids
],
postbox_ids=visible_source_ids,
total_count=sum(
int(counts.get(postbox_id, {}).get("total", 0))
for postbox_id in count_source_ids
),
unread_count=sum(
int(counts.get(postbox_id, {}).get("unread", 0))
for postbox_id in count_source_ids
),
constraints=constraints,
created_at=grouping.created_at,
updated_at=grouping.updated_at,
)
def _protection_transition_item(value) -> PostboxProtectionTransitionResponse:
return PostboxProtectionTransitionResponse(
id=value.id,
postbox_id=value.postbox_id,
source_profile=value.source_profile,
target_profile=value.target_profile,
source_vault_id=value.source_vault_id,
target_vault_id=value.target_vault_id,
history_mode=value.history_mode,
authority_mode=value.authority_mode,
required_quorum=value.required_quorum,
evidence_refs=list(value.evidence_refs or []),
reason=value.reason,
state=value.state,
message_count=value.message_count,
completed_count=value.completed_count,
failed_count=value.failed_count,
requested_by=value.requested_by,
activated_at=value.activated_at,
completed_at=value.completed_at,
resource_revision=value.resource_revision,
etag=value.strong_etag,
configuration_snapshot=dict(value.configuration_snapshot or {}),
items=[
PostboxProtectionTransitionItemResponse(
id=item.id,
message_id=item.message_id,
source_profile=item.source_profile,
target_profile=item.target_profile,
state=item.state,
source_digest=item.source_digest,
target_digest=item.target_digest,
completed_by=item.completed_by,
completed_at=item.completed_at,
error_code=item.error_code,
evidence=dict(item.evidence or {}),
)
for item in value.items
],
)
@router.get("/directory", response_model=PostboxDirectoryResponse)
def api_postbox_directory(
assignment_context_id: str | None = None,
@@ -490,6 +604,118 @@ def api_get_postbox_message(
return _message_item(message)
@router.get(
"/messages/{message_id}/attachment-resolutions",
response_model=PostboxAttachmentResolutionResponse,
)
def api_resolve_postbox_message_attachments(
message_id: str,
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxAttachmentResolutionResponse:
_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.",
)
provider = postbox_file_reference_provider(get_registry())
file_types = {
"file",
"file_asset",
"files:file",
"file_version",
"files:file_version",
}
requests = tuple(
PostboxFileReferenceRequest(
reference_type=attachment.reference_type,
reference_id=attachment.reference_id,
postbox_id=message.postbox_id,
message_id=message.id,
)
for attachment in message.attachments
if attachment.reference_type.strip().casefold() in file_types
)
resolved = (
provider.resolve_postbox_references(
session,
principal,
tenant_id=principal.tenant_id,
requests=requests,
)
if provider is not None and requests
else ()
)
by_reference = {(item.reference_type, item.reference_id): item for item in resolved}
items: list[PostboxAttachmentResolutionItem] = []
for attachment in message.attachments:
resolution = by_reference.get(
(attachment.reference_type, attachment.reference_id)
)
is_file = attachment.reference_type.strip().casefold() in file_types
attachment_payload = asdict(attachment)
attachment_payload.update(
{
"name": (
resolution.filename
if resolution and resolution.filename
else attachment.name
),
"media_type": (
resolution.content_type
if resolution and resolution.content_type
else attachment.media_type
),
"size_bytes": (
resolution.size_bytes
if resolution and resolution.size_bytes is not None
else attachment.size_bytes
),
"digest": (
resolution.sha256
if resolution and resolution.sha256
else attachment.digest
),
}
)
items.append(
PostboxAttachmentResolutionItem(
**attachment_payload,
available=bool(resolution and resolution.available),
reason_code=(
resolution.reason_code
if resolution is not None
else (
"files_provider_unavailable"
if is_file
else "reference_provider_unavailable"
)
),
file_asset_id=resolution.file_asset_id if resolution else None,
file_version_id=resolution.file_version_id if resolution else None,
download_path=resolution.download_path if resolution else None,
provenance=dict(resolution.provenance) if resolution else {},
)
)
session.commit()
return PostboxAttachmentResolutionResponse(attachments=items)
@router.patch(
"/messages/{message_id}/state",
response_model=PostboxMessageItem,
@@ -544,6 +770,7 @@ def api_deliver_to_postbox(
body_text=payload.body_text,
sender_label=payload.sender_label,
classification=payload.classification,
action_required=payload.action_required,
participants=tuple(
PostboxParticipantRef(**participant.model_dump())
for participant in payload.participants
@@ -556,8 +783,7 @@ def api_deliver_to_postbox(
ciphertext_ref=payload.ciphertext_ref,
signed_manifest_ref=payload.signed_manifest_ref,
wrapped_keys=tuple(
PostboxWrappedKeyRef(**item.model_dump())
for item in payload.wrapped_keys
PostboxWrappedKeyRef(**item.model_dump()) for item in payload.wrapped_keys
),
external_recipient_tokens=tuple(
PostboxExternalRecipientTokenRef(**item.model_dump())
@@ -618,9 +844,19 @@ def api_list_postbox_groupings(
tenant_id=principal.tenant_id,
actor=actor,
)
counts_by_postbox = get_service().message_counts_by_postbox(
session,
tenant_id=principal.tenant_id,
postbox_ids=tuple(visible_ids),
actor=actor,
)
return PostboxGroupingListResponse(
groupings=[
_grouping_item(grouping, visible_ids=visible_ids)
_grouping_item(
grouping,
visible_ids=visible_ids,
counts_by_postbox=counts_by_postbox,
)
for grouping in groupings
]
)
@@ -732,13 +968,9 @@ def api_postbox_organization_targets(
) -> PostboxOrganizationTargetsResponse:
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
return PostboxOrganizationTargetsResponse(
units=list(
get_service().organization_targets(tenant_id=principal.tenant_id)
),
units=list(get_service().organization_targets(tenant_id=principal.tenant_id)),
structures=list(
get_service().organization_hierarchy_targets(
tenant_id=principal.tenant_id
)
get_service().organization_hierarchy_targets(tenant_id=principal.tenant_id)
),
)
@@ -760,6 +992,233 @@ def api_admin_postboxes(
)
@router.get(
"/admin/protection-profiles",
response_model=PostboxProtectionProfileListResponse,
)
def api_postbox_protection_profiles(
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionProfileListResponse:
_require_any(principal, BINDING_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE)
managed_available = encryption_content_cipher(get_registry()) is not None
return PostboxProtectionProfileListResponse(
standard_profile=POSTBOX_STANDARD_PROFILE,
profiles=[
PostboxProtectionProfileItem(
**asdict(profile),
available=(
managed_available if profile.requires_encryption_module else True
),
)
for profile in POSTBOX_PROTECTION_PROFILE_DEFINITIONS
],
)
@router.get(
"/admin/postboxes/{postbox_id}/protection-transitions",
response_model=PostboxProtectionTransitionListResponse,
)
def api_list_postbox_protection_transitions(
postbox_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionTransitionListResponse:
_require(principal, BINDING_ADMIN_SCOPE)
try:
values = get_service().list_protection_transitions(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxProtectionTransitionListResponse(
transitions=[_protection_transition_item(value) for value in values]
)
@router.put(
"/admin/postboxes/{postbox_id}/protection-policy",
response_model=PostboxDirectoryItem,
)
def api_update_postbox_protection_policy(
postbox_id: str,
payload: PostboxProtectionPolicyUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox",
resource_id=postbox_id,
base_revision=payload.base_revision,
)
try:
get_service().update_protection_policy(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
protection_policy=payload.protection_policy.model_dump(),
actor_id=principal.account_id,
expected_revision=payload.base_revision,
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox_id),
)
)
_set_etag(response, item.etag)
return item
@router.put(
"/admin/postboxes/{postbox_id}/grouping-policy",
response_model=PostboxDirectoryItem,
)
def api_update_postbox_grouping_policy(
postbox_id: str,
payload: PostboxGroupingPolicyUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox",
resource_id=postbox_id,
base_revision=payload.base_revision,
)
try:
get_service().update_grouping_policy(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
grouping_policy=payload.grouping_policy.model_dump(),
actor_id=principal.account_id,
expected_revision=payload.base_revision,
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _directory_item(
get_service().resolve_postbox(
session,
tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox_id),
)
)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/postboxes/{postbox_id}/protection-transitions",
response_model=PostboxProtectionTransitionResponse,
status_code=status.HTTP_201_CREATED,
)
def api_create_postbox_protection_transition(
postbox_id: str,
payload: PostboxProtectionTransitionCreateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionTransitionResponse:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox",
resource_id=postbox_id,
base_revision=payload.base_revision,
)
try:
value = get_service().create_protection_transition(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
expected_revision=payload.base_revision,
actor_id=principal.account_id,
**payload.model_dump(
exclude={"base_revision", "acknowledge_irreversibility"}
),
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _protection_transition_item(value)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/postboxes/{postbox_id}/protection-transitions/{transition_id}/transform",
response_model=PostboxProtectionTransitionResponse,
)
def api_apply_postbox_protection_transform(
postbox_id: str,
transition_id: str,
payload: PostboxProtectionTransformRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxProtectionTransitionResponse:
_require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_protection_transition",
resource_id=transition_id,
base_revision=payload.base_revision,
)
try:
value = get_service().apply_client_protection_transform(
session,
tenant_id=principal.tenant_id,
postbox_id=postbox_id,
transition_id=transition_id,
expected_revision=payload.base_revision,
actor_id=principal.account_id,
wrapped_keys=tuple(
PostboxWrappedKeyRef(**item.model_dump())
for item in payload.wrapped_keys
),
**payload.model_dump(exclude={"base_revision", "wrapped_keys"}),
)
except PostboxError as exc:
session.rollback()
raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit()
item = _protection_transition_item(value)
_set_etag(response, item.etag)
return item
@router.post(
"/admin/postboxes",
response_model=PostboxDirectoryItem,
@@ -881,6 +1340,27 @@ def api_create_postbox_template(
return item
@router.post(
"/admin/templates/preview",
response_model=PostboxTemplatePreviewResponse,
)
def api_preview_postbox_template(
payload: PostboxTemplatePreviewRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplatePreviewResponse:
_require(principal, TEMPLATE_ADMIN_SCOPE)
try:
preview = get_service().preview_template_targets(
session,
tenant_id=principal.tenant_id,
**payload.model_dump(),
)
except PostboxError as exc:
raise _http_error(exc) from exc
return PostboxTemplatePreviewResponse.model_validate(preview)
@router.post(
"/admin/templates/{template_id}/revisions",
response_model=PostboxTemplateItem,
+409 -31
View File
@@ -5,6 +5,13 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
from govoplan_postbox.backend.grouping_policies import PostboxGroupingPolicyMode
from govoplan_postbox.backend.protection_profiles import (
POSTBOX_MANAGED_ENVELOPE_PROFILE,
POSTBOX_PLAINTEXT_PROFILE,
PostboxProtectionProfile,
)
PostboxClassification = Literal[
"public",
@@ -48,6 +55,11 @@ class PostboxDirectoryItem(BaseModel):
template_revision_id: str | None = None
holder_count: int = 0
vacant: bool = True
encryption_profile: str = POSTBOX_PLAINTEXT_PROFILE
key_epoch: int = Field(default=1, ge=1)
encryption_vault_id: str | None = None
protection_policy: dict[str, Any] = Field(default_factory=dict)
grouping_policy: dict[str, Any] = Field(default_factory=dict)
access: PostboxAccessDecisionResponse | None = None
resource_revision: int = Field(default=1, ge=1)
etag: str | None = None
@@ -75,6 +87,19 @@ class PostboxAttachmentPayload(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict)
class PostboxAttachmentResolutionItem(PostboxAttachmentPayload):
available: bool = False
reason_code: str
file_asset_id: str | None = None
file_version_id: str | None = None
download_path: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class PostboxAttachmentResolutionResponse(BaseModel):
attachments: list[PostboxAttachmentResolutionItem] = Field(default_factory=list)
class PostboxWrappedKeyPayload(BaseModel):
recipient_type: str = Field(min_length=1, max_length=50)
recipient_id: str = Field(min_length=1, max_length=255)
@@ -143,11 +168,32 @@ class PostboxMessageAuthoringPayload(BaseModel):
idempotency_key: str = Field(min_length=1, max_length=255)
subject: str = Field(min_length=1, max_length=1000)
body_text: str | None = None
ciphertext_ref: str | None = Field(default=None, max_length=1000)
signed_manifest_ref: str | None = Field(default=None, max_length=1000)
wrapped_keys: list[PostboxWrappedKeyPayload] = Field(default_factory=list)
classification: PostboxClassification = "internal"
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_content_source(self) -> "PostboxMessageAuthoringPayload":
if self.body_text is not None and self.ciphertext_ref:
raise ValueError(
"Provide plaintext or an external ciphertext envelope, not both."
)
if self.ciphertext_ref and (
not self.signed_manifest_ref or not self.wrapped_keys
):
raise ValueError(
"External E2EE content requires a signed manifest and wrapped keys."
)
if not self.ciphertext_ref and (self.signed_manifest_ref or self.wrapped_keys):
raise ValueError(
"A signed manifest and wrapped keys require an external ciphertext reference."
)
return self
class PostboxMessageCreateRequest(PostboxMessageAuthoringPayload):
postbox_id: str = Field(min_length=1, max_length=36)
@@ -165,9 +211,7 @@ class PostboxTargetPayload(BaseModel):
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
self.template_id and self.organization_unit_id and self.function_id
)
if direct == templated:
raise ValueError(
@@ -186,6 +230,7 @@ class PostboxDeliveryCreateRequest(BaseModel):
body_text: str | None = None
sender_label: str | None = Field(default=None, max_length=500)
classification: PostboxClassification = "internal"
action_required: bool = False
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
expires_at: datetime | None = None
@@ -197,6 +242,24 @@ class PostboxDeliveryCreateRequest(BaseModel):
)
metadata: dict[str, Any] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_content_source(self) -> "PostboxDeliveryCreateRequest":
if self.body_text is not None and self.ciphertext_ref:
raise ValueError(
"Provide plaintext or an external ciphertext envelope, not both."
)
if self.ciphertext_ref and (
not self.signed_manifest_ref or not self.wrapped_keys
):
raise ValueError(
"External E2EE content requires a signed manifest and wrapped keys."
)
if not self.ciphertext_ref and (self.signed_manifest_ref or self.wrapped_keys):
raise ValueError(
"A signed manifest and wrapped keys require an external ciphertext reference."
)
return self
class PostboxDeliveryResponse(BaseModel):
delivery_id: str
@@ -235,14 +298,10 @@ class PostboxLinkedCopyPolicyPayload(BaseModel):
def validate_enabled_policy(self) -> "PostboxLinkedCopyPolicyPayload":
self.relation_type_ids = list(dict.fromkeys(self.relation_type_ids))
self.allowed_classifications = list(
dict.fromkeys(
value.strip() for value in self.allowed_classifications
)
dict.fromkeys(value.strip() for value in self.allowed_classifications)
)
self.allowed_producer_modules = list(
dict.fromkeys(
value.strip() for value in self.allowed_producer_modules
)
dict.fromkeys(value.strip() for value in self.allowed_producer_modules)
)
if any(not value for value in self.relation_type_ids):
raise ValueError("Relation type IDs must not be empty.")
@@ -303,16 +362,10 @@ class PostboxRoutingPolicyPayload(BaseModel):
@model_validator(mode="after")
def validate_semantics(self) -> "PostboxRoutingPolicyPayload":
if (
self.attention.mode == "vacancy_escalation"
and (
not self.linked_copy.enabled
or self.linked_copy.fanout != "nearest"
)
if self.attention.mode == "vacancy_escalation" and (
not self.linked_copy.enabled or self.linked_copy.fanout != "nearest"
):
raise ValueError(
"Vacancy escalation requires nearest linked-copy routing."
)
raise ValueError("Vacancy escalation requires nearest linked-copy routing.")
return self
@@ -348,6 +401,83 @@ class PostboxRouteDryRunResponse(BaseModel):
diagnostics: list[str] = Field(default_factory=list)
class PostboxProtectionPolicyPayload(BaseModel):
new_incumbent_history: Literal[
"all_retained",
"since_assignment",
"bounded_days",
] = "since_assignment"
history_days: int | None = Field(default=None, ge=1, le=36500)
ordinary_rotation: Literal["rewrap", "reencrypt"] = "rewrap"
compromise_rotation: Literal["rewrap", "reencrypt"] = "reencrypt"
recovery_authority: Literal[
"disabled",
"user_consent",
"institutional_key_holders",
"dual_control",
] = "institutional_key_holders"
recovery_quorum: int = Field(default=2, ge=1, le=20)
handover_authority: Literal[
"user_consent",
"institutional_key_holders",
"dual_control",
] = "dual_control"
handover_quorum: int = Field(default=2, ge=1, le=20)
emergency_access: Literal["disabled", "dual_control"] = "dual_control"
emergency_quorum: int = Field(default=2, ge=1, le=20)
export_authority: Literal[
"user_consent",
"institutional_key_holders",
"dual_control",
] = "dual_control"
export_quorum: int = Field(default=2, ge=1, le=20)
destruction_authority: Literal[
"institutional_key_holders",
"dual_control",
] = "dual_control"
destruction_quorum: int = Field(default=2, ge=1, le=20)
external_recipient_assurance: Literal[
"disabled",
"email_otp",
"strong_identity",
] = "strong_identity"
vacancy_escalation_content_access: Literal["metadata_only"] = "metadata_only"
@model_validator(mode="after")
def validate_history_policy(self) -> "PostboxProtectionPolicyPayload":
if self.new_incumbent_history == "bounded_days" and self.history_days is None:
raise ValueError("Bounded incumbent history requires a day limit.")
if self.new_incumbent_history != "bounded_days":
self.history_days = None
if self.handover_authority == "dual_control" and self.handover_quorum < 2:
raise ValueError(
"Dual-control hand-over requires a quorum of at least two."
)
if self.emergency_access == "dual_control" and self.emergency_quorum < 2:
raise ValueError(
"Emergency dual control requires a quorum of at least two."
)
if self.recovery_authority == "dual_control" and self.recovery_quorum < 2:
raise ValueError("Dual-control recovery requires a quorum of at least two.")
if self.export_authority == "dual_control" and self.export_quorum < 2:
raise ValueError("Dual-control export requires a quorum of at least two.")
if self.destruction_authority == "dual_control" and self.destruction_quorum < 2:
raise ValueError(
"Dual-control destruction requires a quorum of at least two."
)
return self
class PostboxGroupingPolicyPayload(BaseModel):
mode: PostboxGroupingPolicyMode = "allow"
reason: str | None = Field(default=None, max_length=1000)
@model_validator(mode="after")
def normalize_reason(self) -> "PostboxGroupingPolicyPayload":
self.reason = self.reason.strip() if self.reason else None
return self
class PostboxExactCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=500)
description: str | None = None
@@ -355,29 +485,46 @@ class PostboxExactCreateRequest(BaseModel):
function_id: str = Field(min_length=1, max_length=36)
address_key: str | None = Field(default=None, max_length=120)
classification: PostboxClassification = "internal"
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
"plaintext_v1"
)
portal_visible: bool = False
encryption_profile: PostboxProtectionProfile = POSTBOX_PLAINTEXT_PROFILE
encryption_vault_id: str | None = Field(default=None, max_length=255)
protection_policy: PostboxProtectionPolicyPayload = Field(
default_factory=PostboxProtectionPolicyPayload
)
grouping_policy: PostboxGroupingPolicyPayload = Field(
default_factory=PostboxGroupingPolicyPayload
)
@model_validator(mode="after")
def validate_encryption(self) -> "PostboxExactCreateRequest":
if self.encryption_profile == "server_envelope_v1":
if self.encryption_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE:
if not str(self.encryption_vault_id or "").strip():
raise ValueError(
"Server-envelope Postboxes require an encryption vault."
)
elif self.encryption_vault_id:
raise ValueError(
"A plaintext Postbox cannot select an encryption vault."
"Only an institution-managed Postbox can select an encryption vault."
)
return self
class PostboxProtectionPolicyUpdateRequest(BaseModel):
base_revision: int = Field(ge=1)
protection_policy: PostboxProtectionPolicyPayload
class PostboxGroupingPolicyUpdateRequest(BaseModel):
base_revision: int = Field(ge=1)
grouping_policy: PostboxGroupingPolicyPayload
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)
scope_structure_id: str | None = Field(default=None, max_length=36)
scope_relation_type_ids: list[str] = Field(default_factory=list, max_length=20)
name_pattern: str = Field(
default="{unit_name} / {function_name}",
min_length=1,
@@ -390,37 +537,256 @@ class PostboxTemplateRevisionPayload(BaseModel):
)
classification: PostboxClassification = "internal"
allow_vacant_delivery: bool = True
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
"plaintext_v1"
)
portal_visible: bool = False
encryption_profile: PostboxProtectionProfile = POSTBOX_PLAINTEXT_PROFILE
encryption_vault_id: str | None = Field(default=None, max_length=255)
protection_policy: PostboxProtectionPolicyPayload = Field(
default_factory=PostboxProtectionPolicyPayload
)
grouping_policy: PostboxGroupingPolicyPayload = Field(
default_factory=PostboxGroupingPolicyPayload
)
routing_policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload
)
@model_validator(mode="after")
def normalize_scope(self) -> "PostboxTemplateRevisionPayload":
self.scope_relation_type_ids = list(
dict.fromkeys(
value.strip() for value in self.scope_relation_type_ids if value.strip()
)
)
return self
@model_validator(mode="after")
def validate_encryption(self) -> "PostboxTemplateRevisionPayload":
if self.encryption_profile == "server_envelope_v1":
if self.encryption_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE:
if not str(self.encryption_vault_id or "").strip():
raise ValueError(
"Server-envelope Postbox templates require an encryption vault."
)
elif self.encryption_vault_id:
raise ValueError(
"A plaintext Postbox template cannot select an encryption vault."
"Only an institution-managed Postbox template can select an encryption vault."
)
return self
class PostboxProtectionProfileItem(BaseModel):
id: PostboxProtectionProfile
label: str
description: str
server_can_decrypt: bool
requires_encryption_module: bool
requires_external_client: bool
available: bool
standard: bool = False
class PostboxProtectionProfileListResponse(BaseModel):
standard_profile: PostboxProtectionProfile
profiles: list[PostboxProtectionProfileItem]
class PostboxProtectionTransitionCreateRequest(BaseModel):
idempotency_key: str = Field(min_length=1, max_length=255)
base_revision: int = Field(ge=1)
target_profile: PostboxProtectionProfile
target_vault_id: str | None = Field(default=None, max_length=255)
history_mode: Literal["future_only", "migrate_history"] = "future_only"
authority_mode: Literal[
"user_consent",
"institutional_key_holders",
"dual_control",
]
required_quorum: int = Field(default=1, ge=1, le=20)
user_consent_refs: list[str] = Field(default_factory=list, max_length=50)
institutional_authorization_refs: list[str] = Field(
default_factory=list, max_length=50
)
reason: str = Field(min_length=1, max_length=2000)
acknowledge_irreversibility: bool
@model_validator(mode="after")
def validate_transition(self) -> "PostboxProtectionTransitionCreateRequest":
self.user_consent_refs = list(
dict.fromkeys(
item.strip() for item in self.user_consent_refs if item.strip()
)
)
self.institutional_authorization_refs = list(
dict.fromkeys(
item.strip()
for item in self.institutional_authorization_refs
if item.strip()
)
)
evidence_count = len(
set(self.user_consent_refs + self.institutional_authorization_refs)
)
if evidence_count < self.required_quorum:
raise ValueError("The evidence set does not satisfy the selected quorum.")
if self.authority_mode in {"user_consent", "dual_control"} and not (
self.user_consent_refs
):
raise ValueError(
"The selected authority mode requires user consent evidence."
)
if (
self.authority_mode
in {
"institutional_key_holders",
"dual_control",
}
and not self.institutional_authorization_refs
):
raise ValueError(
"The selected authority mode requires institutional authorization evidence."
)
if self.authority_mode == "dual_control" and self.required_quorum < 2:
raise ValueError("Dual control requires a quorum of at least two.")
if not self.acknowledge_irreversibility:
raise ValueError(
"Confirm that previously decrypted, copied, or exported content cannot be recalled."
)
if self.target_profile == POSTBOX_MANAGED_ENVELOPE_PROFILE:
if not str(self.target_vault_id or "").strip():
raise ValueError("Institution-managed envelopes require a vault.")
elif self.target_vault_id:
raise ValueError("Only institution-managed envelopes select a vault.")
return self
class PostboxProtectionTransformRequest(BaseModel):
base_revision: int = Field(ge=1)
message_id: str = Field(min_length=1, max_length=36)
plaintext: str | None = None
ciphertext_ref: str | None = Field(default=None, max_length=1000)
signed_manifest_ref: str | None = Field(default=None, max_length=1000)
wrapped_keys: list[PostboxWrappedKeyPayload] = Field(default_factory=list)
content_digest: str = Field(pattern=r"^sha256:[0-9a-f]{64}$")
transformation_evidence_ref: str = Field(min_length=1, max_length=1000)
@model_validator(mode="after")
def validate_target_payload(self) -> "PostboxProtectionTransformRequest":
if self.plaintext is not None and self.ciphertext_ref:
raise ValueError("Provide transformed plaintext or ciphertext, not both.")
if self.ciphertext_ref and (
not self.signed_manifest_ref or not self.wrapped_keys
):
raise ValueError(
"E2EE transformation requires a signed manifest and wrapped keys."
)
return self
class PostboxProtectionTransitionItemResponse(BaseModel):
id: str
message_id: str
source_profile: str
target_profile: str
state: str
source_digest: str | None = None
target_digest: str | None = None
completed_by: str | None = None
completed_at: datetime | None = None
error_code: str | None = None
evidence: dict[str, Any] = Field(default_factory=dict)
class PostboxProtectionTransitionResponse(BaseModel):
id: str
postbox_id: str
source_profile: str
target_profile: str
source_vault_id: str | None = None
target_vault_id: str | None = None
history_mode: str
authority_mode: str
required_quorum: int
evidence_refs: list[str]
reason: str
state: str
message_count: int
completed_count: int
failed_count: int
requested_by: str | None = None
activated_at: datetime | None = None
completed_at: datetime | None = None
resource_revision: int = Field(ge=1)
etag: str
configuration_snapshot: dict[str, Any] = Field(default_factory=dict)
items: list[PostboxProtectionTransitionItemResponse] = Field(default_factory=list)
class PostboxProtectionTransitionListResponse(BaseModel):
transitions: list[PostboxProtectionTransitionResponse]
def _validate_template_write_scope(
payload: PostboxTemplateRevisionPayload,
) -> None:
if payload.scope_kind == "subtree" and not payload.scope_structure_id:
raise ValueError("A subtree scope requires an organization structure.")
if payload.scope_kind != "subtree" and (
payload.scope_structure_id or payload.scope_relation_type_ids
):
raise ValueError(
"Hierarchy structure and relation filters apply only to subtree scopes."
)
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
@model_validator(mode="after")
def validate_write_scope(self) -> "PostboxTemplateCreateRequest":
_validate_template_write_scope(self)
return self
class PostboxTemplatePreviewRequest(PostboxTemplateCreateRequest):
template_id: str | None = Field(default=None, max_length=36)
context_key: str | None = Field(default=None, max_length=255)
limit: int = Field(default=200, ge=1, le=500)
class PostboxTemplatePreviewTarget(BaseModel):
organization_unit_id: str
organization_unit_name: str
function_id: str
function_name: str
address: str
name: str
holder_count: int = Field(ge=0)
vacant: bool
status: str
existing_postbox_id: str | None = None
diagnostics: list[str] = Field(default_factory=list)
class PostboxTemplatePreviewResponse(BaseModel):
targets: list[PostboxTemplatePreviewTarget] = Field(default_factory=list)
total: int = Field(ge=0)
ready_count: int = Field(ge=0)
existing_count: int = Field(ge=0)
vacant_count: int = Field(ge=0)
blocked_count: int = Field(ge=0)
truncated: bool = False
diagnostics: list[str] = Field(default_factory=list)
class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload):
base_revision: int = Field(ge=1)
@model_validator(mode="after")
def validate_write_scope(self) -> "PostboxTemplateReviseRequest":
_validate_template_write_scope(self)
return self
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
id: str
@@ -506,9 +872,7 @@ class PostboxOrganizationStructureItem(BaseModel):
class PostboxOrganizationTargetsResponse(BaseModel):
units: list[PostboxOrganizationUnitItem]
structures: list[PostboxOrganizationStructureItem] = Field(
default_factory=list
)
structures: list[PostboxOrganizationStructureItem] = Field(default_factory=list)
class PostboxGroupingPayload(BaseModel):
@@ -521,8 +885,22 @@ class PostboxGroupingUpdateRequest(PostboxGroupingPayload):
base_revision: int = Field(ge=1)
class PostboxGroupingConstraintItem(BaseModel):
code: Literal[
"source_requires_separation",
"classification_separation_required",
]
mode: PostboxGroupingPolicyMode
postbox_id: str
reason: str | None = None
enforced_by: Literal["postbox_configuration"] = "postbox_configuration"
class PostboxGroupingItem(PostboxGroupingPayload):
id: str
total_count: int = Field(default=0, ge=0)
unread_count: int = Field(default=0, ge=0)
constraints: list[PostboxGroupingConstraintItem] = Field(default_factory=list)
resource_revision: int = Field(ge=1)
etag: str
created_at: datetime
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
from urllib.parse import quote
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.postbox import PostboxDirectoryEntryRef, PostboxMessageRef
from govoplan_core.core.tasks import (
WorkAssignmentRef,
WorkItem,
WorkItemPage,
WorkItemQuery,
WorkSourceRef,
)
from govoplan_postbox.backend.permissions import READ_SCOPE
from govoplan_postbox.backend.principals import actor_from_principal
from govoplan_postbox.backend.service import PostboxService
PROVIDER_ID = "postbox.unread"
class PostboxWorkItemProvider:
def __init__(
self,
*,
registry: object | None = None,
service: PostboxService | None = None,
) -> None:
self.registry = registry
self.service = service
def list_items(
self,
session: object,
principal: object,
*,
query: WorkItemQuery,
) -> WorkItemPage:
if not isinstance(session, Session):
raise TypeError("Postbox work aggregation requires a SQLAlchemy Session.")
if not isinstance(principal, ApiPrincipal):
return WorkItemPage(items=(), total=0)
if principal.tenant_id != query.tenant_id or not has_scope(
principal, READ_SCOPE
):
return WorkItemPage(items=(), total=0)
if query.statuses and "open" not in query.statuses:
return WorkItemPage(items=(), total=0)
if query.priorities and "normal" not in query.priorities:
return WorkItemPage(items=(), total=0)
if query.due_before is not None:
return WorkItemPage(items=(), total=0)
actor = actor_from_principal(principal)
service = self._service()
postboxes = service.list_visible_postboxes(
session,
tenant_id=query.tenant_id,
actor=actor,
)
by_id = {postbox.id: postbox for postbox in postboxes}
messages, total = service.list_available_unread_messages(
session,
tenant_id=query.tenant_id,
postbox_ids=tuple(by_id),
actor=actor,
limit=query.limit,
query=query.text,
)
return WorkItemPage(
items=tuple(
_work_item(message, by_id[message.postbox_id], principal)
for message in messages
),
total=total,
truncated=total > len(messages),
)
def _service(self) -> PostboxService:
if self.service is not None:
return self.service
if self.registry is None:
raise RuntimeError("Postbox work aggregation requires a registry.")
return PostboxService.from_registry(self.registry) # type: ignore[arg-type]
def _work_item(
message: PostboxMessageRef,
postbox: PostboxDirectoryEntryRef,
principal: ApiPrincipal,
) -> WorkItem:
action_url = (
f"/postbox?postbox={quote(message.postbox_id, safe='')}"
f"&message={quote(message.id, safe='')}"
)
sources = [
WorkSourceRef(
module_id="postbox",
resource_type="postbox_message",
resource_id=message.id,
revision=message.delivered_at.isoformat(),
url=action_url,
label=message.subject,
)
]
if (
message.producer_module
and message.producer_resource_type
and message.producer_resource_id
):
sources.append(
WorkSourceRef(
module_id=message.producer_module,
resource_type=message.producer_resource_type,
resource_id=message.producer_resource_id,
)
)
return WorkItem(
id=message.id,
provider_id=PROVIDER_ID,
owner_module="postbox",
tenant_id=message.tenant_id,
title=message.subject,
summary=(
f"{postbox.name} · {message.sender_label}"
if message.sender_label
else postbox.name
),
status="open",
priority="normal",
required_action="Read the Postbox message.",
action_url=action_url,
assignments=_assignments(postbox, principal),
sources=tuple(sources),
provenance={
"postbox_id": postbox.id,
"address_key": postbox.address_key,
"producer_module": message.producer_module,
},
metadata={
"classification": message.classification,
"attachment_count": len(message.attachments),
"encrypted": message.encryption_profile != "plaintext_v1",
},
revision=f"{message.status}:{message.delivered_at.isoformat()}",
created_at=message.delivered_at,
updated_at=message.delivered_at,
)
def _assignments(
postbox: PostboxDirectoryEntryRef,
principal: ApiPrincipal,
) -> tuple[WorkAssignmentRef, ...]:
access = postbox.access
assignment_ids = tuple(access.assignment_ids) if access is not None else ()
if access is not None and access.selected_assignment_id:
assignment_ids = (access.selected_assignment_id,)
assignments = tuple(
WorkAssignmentRef(
kind="function_assignment",
id=assignment_id,
label=postbox.function_name,
)
for assignment_id in assignment_ids
)
if assignments:
return assignments
return (WorkAssignmentRef(kind="account", id=principal.account_id),)
__all__ = ["PROVIDER_ID", "PostboxWorkItemProvider"]
+605
View File
@@ -0,0 +1,605 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
DataSubjectRequest,
create_data_subject_request,
plan_data_subject_erasure,
search_data_subject_request,
)
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAccessEvent,
PostboxAddress,
PostboxAttachmentReference,
PostboxDelivery,
PostboxGrouping,
PostboxGroupingSource,
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxProtectionTransition,
PostboxProtectionTransitionItem,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
)
from govoplan_postbox.backend.dsar_provider import (
POSTBOX_DSAR_CAPABILITY,
PostboxDsarProvider,
)
from govoplan_postbox.backend.manifest import manifest
class _Registry:
def __init__(self, provider: object, *, active: bool = True):
self.provider = provider
self.active = active
def capability_names(self):
return (POSTBOX_DSAR_CAPABILITY,)
def capability_owner(self, name):
assert name == POSTBOX_DSAR_CAPABILITY
return "postbox"
def tenant_entitlement_resolver(self):
active = self.active
class Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("postbox",) if active else ()},
)()
return Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
assert name == POSTBOX_DSAR_CAPABILITY
return self.provider
class PostboxDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(
self.engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
ChangeSequenceEntry.__table__,
DataSubjectRequest.__table__,
PostboxTemplate.__table__,
PostboxTemplateRevision.__table__,
PostboxAddress.__table__,
Postbox.__table__,
PostboxMessage.__table__,
PostboxParticipant.__table__,
PostboxAttachmentReference.__table__,
PostboxDelivery.__table__,
PostboxRoute.__table__,
PostboxMessageReceipt.__table__,
PostboxGrouping.__table__,
PostboxGroupingSource.__table__,
PostboxAccessEvent.__table__,
PostboxProtectionTransition.__table__,
PostboxProtectionTransitionItem.__table__,
],
)
self.session = sessionmaker(bind=self.engine, future=True)()
now = datetime.now(timezone.utc)
account = Account(
id="account-subject",
email="subject@example.test",
normalized_email="subject@example.test",
display_name="Subject",
)
user = User(
id="membership-subject",
tenant_id="tenant-1",
account_id=account.id,
email="subject@example.test",
display_name="Subject",
)
template = PostboxTemplate(
id="template-subject",
tenant_id="tenant-1",
slug="subject-template",
name="Subject configured template",
status="published",
created_by=account.id,
updated_by=account.id,
)
revision = PostboxTemplateRevision(
id="template-revision-subject",
tenant_id="tenant-1",
template_id=template.id,
revision=1,
created_by=account.id,
published_at=now,
)
address = PostboxAddress(
id="address-1",
tenant_id="tenant-1",
address_key="office",
address="office.postbox",
status="active",
)
postbox = Postbox(
id="postbox-1",
tenant_id="tenant-1",
address_id=address.id,
name="Office Postbox",
classification="confidential",
)
message = PostboxMessage(
id="message-subject",
tenant_id="tenant-1",
postbox_id=postbox.id,
subject="Subject request context",
body_text="Bounded plaintext concerning the subject",
status="delivered",
classification="personal",
producer_module="postbox",
producer_resource_type="account_authored_message",
producer_resource_id=account.id,
authoring_key="authoring-secret-do-not-export",
delivered_at=now,
metadata_={"secret": "message-metadata-do-not-export"},
)
matching_participant = PostboxParticipant(
id="participant-subject",
tenant_id="tenant-1",
message_id=message.id,
kind="recipient",
reference_type="account",
reference_id=account.id,
label="Subject Person",
address="Subject@Example.Test",
position=1,
metadata_={"secret": "participant-metadata-do-not-export"},
)
unrelated_participant = PostboxParticipant(
id="participant-other",
tenant_id="tenant-1",
message_id=message.id,
kind="recipient",
reference_type="account",
reference_id="account-other",
label="Unrelated Person Do Not Export",
address="other@example.test",
position=2,
)
attachment = PostboxAttachmentReference(
id="attachment-subject",
tenant_id="tenant-1",
message_id=message.id,
reference_type="file_version",
reference_id="file-version-1",
name="subject-evidence.pdf",
digest="digest-do-not-export",
metadata_={"secret": "attachment-metadata-do-not-export"},
)
encrypted_message = PostboxMessage(
id="message-encrypted",
tenant_id="tenant-1",
postbox_id=postbox.id,
subject="Encrypted subject context",
body_ciphertext=b"ciphertext-do-not-export",
status="delivered",
classification="personal",
encryption_profile="server_envelope_v1",
encryption_envelope_id="envelope-do-not-export",
encryption_resource_id="resource-do-not-export",
wrapped_keys=[{"wrapped_key_ref": "wrapped-key-do-not-export"}],
external_recipient_tokens=[{"token_id": "external-token-do-not-export"}],
delivered_at=now,
)
encrypted_participant = PostboxParticipant(
id="participant-encrypted-subject",
tenant_id="tenant-1",
message_id=encrypted_message.id,
kind="recipient",
reference_type="identity",
reference_id="identity-subject",
position=1,
)
unrelated_message = PostboxMessage(
id="message-other",
tenant_id="tenant-1",
postbox_id=postbox.id,
subject="Unrelated message do not export",
body_text="Unrelated body do not export",
status="delivered",
delivered_at=now,
)
tenant_two_address = PostboxAddress(
id="address-tenant-2",
tenant_id="tenant-2",
address_key="other",
address="other.postbox",
)
tenant_two_postbox = Postbox(
id="postbox-tenant-2",
tenant_id="tenant-2",
address_id=tenant_two_address.id,
name="Tenant two Postbox",
)
tenant_two_message = PostboxMessage(
id="message-tenant-2",
tenant_id="tenant-2",
postbox_id=tenant_two_postbox.id,
subject="Tenant two message do not export",
body_text="Tenant two body do not export",
delivered_at=now,
)
tenant_two_participant = PostboxParticipant(
id="participant-tenant-2",
tenant_id="tenant-2",
message_id=tenant_two_message.id,
kind="recipient",
reference_type="account",
reference_id=account.id,
)
delivery = PostboxDelivery(
id="delivery-subject",
tenant_id="tenant-1",
postbox_id=postbox.id,
message_id=message.id,
producer_module="postbox",
producer_resource_type="account_authored_message",
producer_resource_id=account.id,
idempotency_key="delivery-idempotency-do-not-export",
status="accepted",
holder_count=1,
target_snapshot={"secret": "target-snapshot-do-not-export"},
accepted_at=now,
metadata_={"secret": "delivery-metadata-do-not-export"},
)
route = PostboxRoute(
id="route-subject",
tenant_id="tenant-1",
delivery_id=delivery.id,
source_postbox_id=postbox.id,
source_message_id=message.id,
target_postbox_id=postbox.id,
target_message_id=message.id,
route_kind="linked_copy",
status="completed",
depth=1,
processed_at=now,
policy_snapshot={"secret": "route-policy-do-not-export"},
)
receipt = PostboxMessageReceipt(
id="receipt-subject",
tenant_id="tenant-1",
message_id=message.id,
account_id=account.id,
identity_id="identity-subject",
assignment_id="assignment-subject",
read_at=now,
acknowledged_at=now,
metadata_={"secret": "receipt-metadata-do-not-export"},
)
grouping = PostboxGrouping(
id="grouping-subject",
tenant_id="tenant-1",
account_id=account.id,
name="My work",
is_default=True,
settings={"secret": "grouping-settings-do-not-export"},
)
grouping.sources.append(
PostboxGroupingSource(
id="grouping-source-subject",
tenant_id="tenant-1",
postbox_id=postbox.id,
position=0,
)
)
access_event = PostboxAccessEvent(
id="access-event-subject",
tenant_id="tenant-1",
postbox_id=postbox.id,
message_id=message.id,
account_id=account.id,
identity_id="identity-subject",
assignment_id="assignment-subject",
action="read_message",
outcome="allowed",
reason_code="assigned",
occurred_at=now,
details={"secret": "access-details-do-not-export"},
)
transition = PostboxProtectionTransition(
id="transition-subject",
tenant_id="tenant-1",
postbox_id=postbox.id,
idempotency_key="transition-idempotency-do-not-export",
source_profile="plaintext_v1",
target_profile="server_envelope_v1",
history_mode="migrate",
authority_mode="institutional",
required_quorum=1,
evidence_refs=["evidence-ref-do-not-export"],
reason="private transition reason do not export",
state="completed",
message_count=1,
completed_count=1,
requested_by=account.id,
activated_at=now,
completed_at=now,
configuration_snapshot={"secret": "transition-config-do-not-export"},
)
transition_item = PostboxProtectionTransitionItem(
id="transition-item-subject",
tenant_id="tenant-1",
transition_id=transition.id,
message_id=message.id,
source_profile="plaintext_v1",
target_profile="server_envelope_v1",
state="completed",
source_digest="source-digest-do-not-export",
target_digest="target-digest-do-not-export",
completed_by=account.id,
completed_at=now,
evidence={"secret": "transition-item-evidence-do-not-export"},
)
self.session.add_all(
[
account,
user,
template,
revision,
address,
postbox,
message,
matching_participant,
unrelated_participant,
attachment,
encrypted_message,
encrypted_participant,
unrelated_message,
tenant_two_address,
tenant_two_postbox,
tenant_two_message,
tenant_two_participant,
delivery,
route,
receipt,
grouping,
access_event,
transition,
transition_item,
]
)
self.session.commit()
self.provider = PostboxDsarProvider()
self.subject = DsarSubjectRef(
account_id=account.id,
identity_id="identity-subject",
membership_id=user.id,
email="subject@example.test",
external_references={"postbox.assignment": "assignment-subject"},
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_manifest_and_minimized_tenant_scoped_search(self) -> None:
self.assertIn(
POSTBOX_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertIsInstance(
manifest.capability_factories[POSTBOX_DSAR_CAPABILITY](None),
DsarProvider,
)
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
self.assertTrue(
{
"postbox_message",
"postbox_participant",
"postbox_attachment_reference",
"postbox_delivery",
"postbox_route",
"postbox_message_receipt",
"postbox_grouping",
"postbox_access_event",
"postbox_template",
"postbox_template_revision",
"postbox_protection_transition",
"postbox_protection_transition_item",
}.issubset({record.resource_type for record in records})
)
encrypted = next(
record
for record in records
if record.resource_type == "postbox_message"
and record.resource_id == "message-encrypted"
)
self.assertEqual(
"institution_managed_envelope", encrypted.data["content_state"]
)
serialized = repr([record.to_dict() for record in records])
for hidden in (
"Unrelated Person Do Not Export",
"other@example.test",
"participant-other",
"Unrelated message do not export",
"Unrelated body do not export",
"message-tenant-2",
"Tenant two message do not export",
"ciphertext-do-not-export",
"envelope-do-not-export",
"resource-do-not-export",
"wrapped-key-do-not-export",
"external-token-do-not-export",
"authoring-secret-do-not-export",
"message-metadata-do-not-export",
"participant-metadata-do-not-export",
"digest-do-not-export",
"attachment-metadata-do-not-export",
"delivery-idempotency-do-not-export",
"target-snapshot-do-not-export",
"delivery-metadata-do-not-export",
"route-policy-do-not-export",
"receipt-metadata-do-not-export",
"grouping-settings-do-not-export",
"access-details-do-not-export",
"transition-idempotency-do-not-export",
"evidence-ref-do-not-export",
"private transition reason do not export",
"transition-config-do-not-export",
"source-digest-do-not-export",
"target-digest-do-not-export",
"transition-item-evidence-do-not-export",
):
self.assertNotIn(hidden, serialized)
def test_conflicting_selectors_fail_closed(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-subject",
external_references={"postbox.account": "account-other"},
),
)
self.assertEqual((), records)
def test_grouping_erasure_is_revalidated_and_idempotent(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self.subject
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=records,
)
self.assertTrue(
{"retain", "manual_review", "delete"}.issubset(
{action.kind for action in actions}
)
)
delete = next(action for action in actions if action.kind == "delete")
grouping = self.session.get(PostboxGrouping, "grouping-subject")
assert grouping is not None
grouping.resource_revision += 1
self.session.commit()
stale = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(delete,),
request_id="request-1",
)
self.assertEqual("blocked", stale[0].status)
refreshed_records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self.subject
)
refreshed_actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=refreshed_records,
)
refreshed_delete = next(
action for action in refreshed_actions if action.kind == "delete"
)
executed = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(refreshed_delete,),
request_id="request-1",
)
self.assertEqual("executed", executed[0].status)
self.assertIsNone(self.session.get(PostboxGrouping, "grouping-subject"))
self.assertIsNotNone(self.session.get(PostboxMessage, "message-subject"))
replay = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(refreshed_delete,),
request_id="request-1",
)
self.assertEqual("unchanged", replay[0].status)
def test_core_workflow_discovers_active_and_skips_disabled_provider(self) -> None:
request = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-POSTBOX-1",
request_kind="access_and_erasure",
subject=self.subject,
purpose="Authorized request",
legal_basis="GDPR",
due_at=datetime.now(timezone.utc) + timedelta(days=30),
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=request,
expected_revision=1,
)
self.assertEqual(["postbox"], request.coverage["covered_modules"])
plan_data_subject_erasure(
self.session,
registry=_Registry(self.provider),
row=request,
expected_revision=2,
)
self.assertTrue(
any(action["executable"] for action in request.erasure_plan["actions"])
)
disabled = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-POSTBOX-OFF",
request_kind="access",
subject=self.subject,
purpose="Coverage",
legal_basis=None,
due_at=None,
requested_by_account_id="privacy-officer",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=disabled,
expected_revision=1,
)
self.assertEqual(
[POSTBOX_DSAR_CAPABILITY],
disabled.coverage["inactive_provider_capabilities"],
)
if __name__ == "__main__":
unittest.main()
+37 -1
View File
@@ -1,11 +1,26 @@
from __future__ import annotations
import unittest
from pathlib import Path
from govoplan_postbox.backend.manifest import manifest
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
class PostboxInterfaceDocumentationContractTests(unittest.TestCase):
def test_public_topics_have_complete_german_coverage(self) -> None:
topics = manifest.documentation
self.assertEqual(9, len(topics))
for topic in topics:
translation = topic.translations["de"]
self.assertEqual({"title", "summary", "body"}, set(translation))
self.assertTrue(
all(str(translation[field]).strip() for field in translation)
)
def test_route_and_contributed_surfaces_remain_declared(self) -> None:
frontend = manifest.frontend
self.assertIsNotNone(frontend)
@@ -18,6 +33,7 @@ class PostboxInterfaceDocumentationContractTests(unittest.TestCase):
"postbox.admin.templates",
"postbox.inbox.directory",
"postbox.inbox.messages",
"postbox.quick_access.messages",
"postbox.widget.inbox",
},
{surface.id for surface in frontend.view_surfaces}, # type: ignore[union-attr]
@@ -40,7 +56,27 @@ class PostboxInterfaceDocumentationContractTests(unittest.TestCase):
self.assertIn("archive_postbox", reference.metadata["consequence_classes"])
self.assertIn("withdraw_or_expire", reference.metadata["consequence_classes"])
quick_tool = manifest.frontend.quick_access_tools[0]
self.assertEqual(("postbox.message",), quick_tool.returned_reference_kinds)
self.assertEqual("postbox.quick_access.messages", quick_tool.help_context_id)
self.assertEqual("/postbox", quick_tool.full_page_path)
def test_quick_access_is_bounded_and_owner_launched(self) -> None:
quick_access = (
REPOSITORY_ROOT / "webui/src/features/postbox/PostboxQuickAccess.tsx"
).read_text(encoding="utf-8")
page = (
REPOSITORY_ROOT / "webui/src/features/postbox/PostboxPage.tsx"
).read_text(encoding="utf-8")
self.assertIn("const MESSAGE_LIMIT = 7", quick_access)
self.assertIn('"unread"', quick_access)
self.assertIn('kind: "message"', quick_access)
self.assertIn("launchContext.actingContext", quick_access)
self.assertIn("quickAccessLaunchState(launchContext)", quick_access)
self.assertIn('parameters.get("quickAction") !== "compose"', page)
self.assertIn("openComposeFor(postbox)", page)
if __name__ == "__main__":
unittest.main()
+8 -1
View File
@@ -8,9 +8,11 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_PORTAL,
CAPABILITY_POSTBOX_ROUTING,
)
from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
from govoplan_postbox.backend.dsar_provider import POSTBOX_DSAR_CAPABILITY
from govoplan_postbox.backend.manifest import get_manifest
@@ -31,16 +33,21 @@ class PostboxManifestTests(unittest.TestCase):
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_ROUTING,
CAPABILITY_POSTBOX_PORTAL,
POSTBOX_DSAR_CAPABILITY,
},
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.assertEqual(
["/postbox"], [route.path for route in manifest.frontend.routes]
)
self.assertIn(
"idm.function_assignments",
manifest.required_capabilities,
)
self.assertIn("encryption", manifest.optional_dependencies)
self.assertEqual("postbox.unread", manifest.work_item_providers[0].id)
self.assertTrue(
any(
requirement.name == CAPABILITY_ENCRYPTION_CONTENT_CIPHER
+50 -10
View File
@@ -30,6 +30,22 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions."
"d8e3f6a9b2c5_postbox_content_protection"
)
scope_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"e9f4a7b2c5d8_v014_template_scope_preview"
)
portal_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"f2a5c8e1b4d7_v015_portal_visibility"
)
transition_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"a7c1e4f8b2d6_v016_protection_transitions"
)
grouping_policy_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"d8b4f1a6c9e2_v017_grouping_policy"
)
engine = create_engine("sqlite:///:memory:")
try:
with engine.begin() as connection:
@@ -39,22 +55,36 @@ class PostboxMigrationTests(unittest.TestCase):
occ_original = occ_migration.op
envelope_original = envelope_migration.op
protection_original = protection_migration.op
scope_original = scope_migration.op
portal_original = portal_migration.op
transition_original = transition_migration.op
grouping_policy_original = grouping_policy_migration.op
migration.op = operations
route_migration.op = operations
occ_migration.op = operations
envelope_migration.op = operations
protection_migration.op = operations
scope_migration.op = operations
portal_migration.op = operations
transition_migration.op = operations
grouping_policy_migration.op = operations
try:
migration.upgrade()
route_migration.upgrade()
occ_migration.upgrade()
envelope_migration.upgrade()
protection_migration.upgrade()
scope_migration.upgrade()
portal_migration.upgrade()
transition_migration.upgrade()
grouping_policy_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)
self.assertIn("postbox_protection_transitions", tables)
self.assertIn("postbox_protection_transition_items", tables)
message_columns = {
column["name"]
for column in inspect(connection).get_columns(
@@ -75,14 +105,20 @@ class PostboxMigrationTests(unittest.TestCase):
"encryption_resource_id",
}.issubset(message_columns)
)
self.assertIn(
"encryption_vault_id",
{
template_revision_columns = {
column["name"]
for column in inspect(connection).get_columns(
"postbox_template_revisions"
)
},
}
self.assertTrue(
{
"encryption_vault_id",
"scope_structure_id",
"scope_relation_type_ids",
"portal_visible",
"grouping_policy",
}.issubset(template_revision_columns)
)
self.assertIn("authoring_key", message_columns)
for table_name in (
@@ -101,15 +137,15 @@ class PostboxMigrationTests(unittest.TestCase):
)
route_columns = {
column["name"]
for column in inspect(connection).get_columns(
"postbox_routes"
)
for column in inspect(connection).get_columns("postbox_routes")
}
self.assertTrue(
{"execute_after", "processed_at"}.issubset(
route_columns
)
{"execute_after", "processed_at"}.issubset(route_columns)
)
grouping_policy_migration.downgrade()
transition_migration.downgrade()
portal_migration.downgrade()
scope_migration.downgrade()
protection_migration.downgrade()
envelope_migration.downgrade()
occ_migration.downgrade()
@@ -128,6 +164,10 @@ class PostboxMigrationTests(unittest.TestCase):
occ_migration.op = occ_original
envelope_migration.op = envelope_original
protection_migration.op = protection_original
scope_migration.op = scope_original
portal_migration.op = portal_original
transition_migration.op = transition_original
grouping_policy_migration.op = grouping_policy_original
finally:
engine.dispose()
+66
View File
@@ -6,7 +6,10 @@ from datetime import timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.postbox import PostboxActorRef
from govoplan_core.core.tasks import WorkItemQuery
from govoplan_core.db.base import Base
from govoplan_core.db.session import (
DatabaseHandle,
@@ -41,6 +44,8 @@ from govoplan_postbox.backend.db.models import (
PostboxTemplateRevision,
)
from govoplan_postbox.backend.service import PostboxService
from govoplan_postbox.backend.principals import actor_from_principal
from govoplan_postbox.backend.work_items import PostboxWorkItemProvider
TABLES = (
@@ -269,6 +274,67 @@ class PostboxRealDirectoryAccessTests(unittest.TestCase):
self.assertFalse(expired.allowed)
self.assertEqual(expired.reason_code, "effective_assignment_missing")
def test_unread_message_is_projected_as_current_work_until_read(self) -> None:
self._add_assignment(
assignment_id="owner-assignment",
identity_id="identity-owner",
account_id="account-owner",
)
principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-owner",
membership_id="membership-owner",
tenant_id="tenant-1",
identity_id="identity-owner",
scopes=frozenset({"postbox:postbox:read"}),
function_assignment_ids=frozenset({"owner-assignment"}),
),
account=object(),
user=object(),
)
provider = PostboxWorkItemProvider(service=self.service)
with self.database.SessionLocal() as session:
message = PostboxMessage(
tenant_id="tenant-1",
postbox_id=self.postbox_id,
subject="Review the submitted evidence",
status="delivered",
classification="internal",
sender_label="Permit service",
delivered_at=utc_now(),
wrapped_keys=[],
external_recipient_tokens=[],
metadata_={},
)
session.add(message)
session.commit()
page = provider.list_items(
session,
principal,
query=WorkItemQuery(tenant_id="tenant-1"),
)
self.assertEqual(1, page.total)
self.assertEqual(message.id, page.items[0].id)
self.assertEqual("owner-assignment", page.items[0].assignments[0].id)
self.service.mark_message(
session,
tenant_id="tenant-1",
message_id=message.id,
actor=actor_from_principal(principal),
state="read",
)
session.commit()
self.assertEqual(
0,
provider.list_items(
session,
principal,
query=WorkItemQuery(tenant_id="tenant-1"),
).total,
)
def test_real_organization_state_and_function_move_fail_closed(self) -> None:
self._add_assignment(
assignment_id="owner-assignment",
+106
View File
@@ -40,6 +40,7 @@ from govoplan_postbox.backend.db.models import (
PostboxTemplateRevision,
)
from govoplan_postbox.backend.router import router
from govoplan_postbox.backend.portal_projection import PortalProjection
from govoplan_postbox.backend.service import PostboxService
@@ -220,6 +221,7 @@ class PostboxRouterTests(unittest.TestCase):
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="membership-1"),
)
self.principal = principal
app = FastAPI()
app.include_router(router, prefix="/api/v1")
@@ -250,6 +252,92 @@ class PostboxRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 403)
self.assertIn("not active for this principal", response.text)
def test_portal_projection_requires_explicit_visibility_and_keeps_postbox_access(self) -> None:
projection = PortalProjection()
with Session(self.engine) as session, patch(
"govoplan_postbox.backend.portal_projection.get_service",
return_value=self.service,
):
self.assertEqual(
(),
projection.list_portal_entries(
session,
self.principal,
tenant_id="tenant-1",
),
)
postbox = session.get(Postbox, self.postbox_id)
assert postbox is not None
postbox.settings = {**postbox.settings, "portal_visible": True}
session.flush()
entries = projection.list_portal_entries(
session,
self.principal,
tenant_id="tenant-1",
)
self.assertEqual(1, len(entries))
self.assertEqual(self.postbox_id, entries[0].postbox.id)
self.assertEqual(f"/postbox?postbox={self.postbox_id}", entries[0].route_path)
def test_template_impact_preview_is_available_without_writes(self) -> None:
with Session(self.engine) as session:
before = session.query(Postbox).count()
response = self.client.post(
"/api/v1/postbox/admin/templates/preview",
json={
"slug": "case-intake",
"name": "Case intake",
"scope_kind": "tenant",
"name_pattern": "{unit_name} / {function_name}",
"address_pattern": "{template_slug}.{unit_slug}.{function_slug}",
"classification": "internal",
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual(1, response.json()["total"])
self.assertEqual(1, response.json()["ready_count"])
with Session(self.engine) as session:
self.assertEqual(before, session.query(Postbox).count())
def test_legacy_subtree_template_remains_readable_but_cannot_be_created_by_api(
self,
) -> None:
with Session(self.engine) as session:
self.service.create_template(
session,
tenant_id="tenant-1",
slug="legacy-subtree",
name="Legacy subtree",
description=None,
function_type_id="clerk-type",
scope_kind="subtree",
scope_id="unit-1",
name_pattern="{unit_name} / {function_name}",
address_pattern="{template_slug}.{unit_slug}.{function_slug}",
classification="internal",
allow_vacant_delivery=True,
actor_id="account-1",
)
session.commit()
listing = self.client.get("/api/v1/postbox/admin/templates")
self.assertEqual(200, listing.status_code, listing.text)
revision = listing.json()["templates"][0]["revisions"][0]
self.assertIsNone(revision["scope_structure_id"])
rejected = self.client.post(
"/api/v1/postbox/admin/templates",
json={
"slug": "new-subtree",
"name": "New subtree",
"scope_kind": "subtree",
"scope_id": "unit-1",
},
)
self.assertEqual(422, rejected.status_code, rejected.text)
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)
@@ -265,6 +353,7 @@ class PostboxRouterTests(unittest.TestCase):
"idempotency_key": "campaign-1:recipient-1",
"subject": "Decision",
"body_text": "The decision is ready.",
"action_required": True,
},
)
self.assertEqual(201, delivery.status_code, delivery.text)
@@ -277,6 +366,7 @@ class PostboxRouterTests(unittest.TestCase):
self.assertEqual(200, messages.status_code, messages.text)
self.assertEqual(1, messages.json()["total"])
self.assertEqual(message_id, messages.json()["messages"][0]["id"])
self.assertTrue(messages.json()["messages"][0]["metadata"]["action_required"])
filtered = self.client.get(
"/api/v1/postbox/messages",
@@ -289,6 +379,20 @@ class PostboxRouterTests(unittest.TestCase):
self.assertEqual(200, filtered.status_code, filtered.text)
self.assertEqual(1, filtered.json()["total"])
grouping = self.client.post(
"/api/v1/postbox/groupings",
json={
"name": "Assigned work",
"is_default": True,
"postbox_ids": [self.postbox_id],
},
)
self.assertEqual(201, grouping.status_code, grouping.text)
grouped_before_read = self.client.get("/api/v1/postbox/groupings")
self.assertEqual(200, grouped_before_read.status_code)
self.assertEqual(1, grouped_before_read.json()["groupings"][0]["total_count"])
self.assertEqual(1, grouped_before_read.json()["groupings"][0]["unread_count"])
acknowledged = self.client.patch(
f"/api/v1/postbox/messages/{message_id}/state",
json={"state": "acknowledged"},
@@ -306,6 +410,8 @@ class PostboxRouterTests(unittest.TestCase):
)
self.assertEqual(200, unread.status_code, unread.text)
self.assertEqual(0, unread.json()["total"])
grouped_after_read = self.client.get("/api/v1/postbox/groupings")
self.assertEqual(0, grouped_after_read.json()["groupings"][0]["unread_count"])
def test_routing_dry_run_explains_default_disabled_state(self) -> None:
response = self.client.post(
+918 -52
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@govoplan/postbox-webui",
"version": "0.1.17",
"version": "0.1.23",
"private": true,
"type": "module",
"main": "src/index.ts",
@@ -18,7 +18,7 @@
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.17",
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
@@ -22,10 +22,9 @@ assert(page.includes("useUnsavedDraftGuard") && admin.includes("useUnsavedDraftG
assert(page.includes("delete_grouping_confirmation") && page.includes("ConfirmDialog"), "Deleting a unified view confirms that source records remain unchanged");
assert(admin.includes("archive_confirmation") && admin.includes("retire_template_confirmation"), "Address and template lifecycle actions use shared destructive confirmation");
assert(patterns.includes('topicId: "postbox.function-bound-containers"') && patterns.includes('topicId: "postbox.reference.fields-and-consequences"'), "Postbox uses manifest-backed help references");
assert(moduleSource.includes('version: "0.1.2"') && moduleSource.includes("generatedTranslations"), "WebUI metadata matches the module release and registers translations");
assert(moduleSource.includes('version: "0.1.19"') && moduleSource.includes("generatedTranslations"), "WebUI metadata matches the module release and registers translations");
assert(translations.includes('"i18n:govoplan-postbox.unavailable_message_reason"'), "Access-sensitive unavailable states are localized");
assert(widget.includes("usePlatformLanguage") && widget.includes("i18nMessage"), "Widget dates and dynamic accessible labels follow the platform locale");
assert(!page.includes("window.confirm") && !admin.includes("window.confirm"), "Postbox does not use browser-native consequential confirmation");
console.log("Postbox surfaces satisfy the recorded interface pattern-language contract.");
+275 -1
View File
@@ -2,6 +2,8 @@ import {
apiFetch,
apiPath,
apiPostJson,
apiUrl,
authHeaders,
type ApiSettings
} from "@govoplan/core-webui";
@@ -39,11 +41,90 @@ export type PostboxDirectoryItem = {
template_revision_id?: string | null;
holder_count: number;
vacant: boolean;
encryption_profile: PostboxProtectionProfileId;
key_epoch: number;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
grouping_policy: PostboxGroupingPolicy;
access?: PostboxAccessDecision | null;
resource_revision: number;
etag: string;
};
export type PostboxProtectionProfileId =
| "plaintext_v1"
| "server_envelope_v1"
| "external_e2ee_v1";
export type PostboxProtectionPolicy = {
new_incumbent_history: "all_retained" | "since_assignment" | "bounded_days";
history_days?: number | null;
ordinary_rotation: "rewrap" | "reencrypt";
compromise_rotation: "rewrap" | "reencrypt";
recovery_authority: "disabled" | "user_consent" | "institutional_key_holders" | "dual_control";
recovery_quorum: number;
handover_authority: "user_consent" | "institutional_key_holders" | "dual_control";
handover_quorum: number;
emergency_access: "disabled" | "dual_control";
emergency_quorum: number;
export_authority: "user_consent" | "institutional_key_holders" | "dual_control";
export_quorum: number;
destruction_authority: "institutional_key_holders" | "dual_control";
destruction_quorum: number;
external_recipient_assurance: "disabled" | "email_otp" | "strong_identity";
vacancy_escalation_content_access: "metadata_only";
};
export type PostboxGroupingPolicy = {
mode: "allow" | "same_classification" | "separate";
reason?: string | null;
};
export type PostboxProtectionProfile = {
id: PostboxProtectionProfileId;
label: string;
description: string;
server_can_decrypt: boolean;
requires_encryption_module: boolean;
requires_external_client: boolean;
available: boolean;
standard: boolean;
};
export type PostboxProtectionTransition = {
id: string;
postbox_id: string;
source_profile: string;
target_profile: string;
source_vault_id?: string | null;
target_vault_id?: string | null;
history_mode: string;
authority_mode: string;
required_quorum: number;
evidence_refs: string[];
reason: string;
state: string;
message_count: number;
completed_count: number;
failed_count: number;
requested_by?: string | null;
activated_at?: string | null;
completed_at?: string | null;
resource_revision: number;
etag: string;
configuration_snapshot: Record<string, unknown>;
items: Array<{
id: string;
message_id: string;
source_profile: string;
target_profile: string;
state: string;
source_digest?: string | null;
target_digest?: string | null;
error_code?: string | null;
}>;
};
export type PostboxParticipant = {
kind: string;
reference_type: string;
@@ -62,6 +143,15 @@ export type PostboxAttachment = {
metadata: Record<string, unknown>;
};
export type PostboxAttachmentResolution = PostboxAttachment & {
available: boolean;
reason_code: string;
file_asset_id?: string | null;
file_version_id?: string | null;
download_path?: string | null;
provenance: Record<string, unknown>;
};
export type PostboxMessage = {
id: string;
tenant_id: string;
@@ -82,7 +172,7 @@ export type PostboxMessage = {
producer_resource_id?: string | null;
in_reply_to_message_id?: string | null;
replaces_message_id?: string | null;
encryption_profile: string;
encryption_profile: PostboxProtectionProfileId;
key_epoch: number;
ciphertext_ref?: string | null;
signed_manifest_ref?: string | null;
@@ -114,6 +204,15 @@ export type PostboxGrouping = {
name: string;
is_default: boolean;
postbox_ids: string[];
total_count: number;
unread_count: number;
constraints: Array<{
code: "source_requires_separation" | "classification_separation_required";
mode: PostboxGroupingPolicy["mode"];
postbox_id: string;
reason?: string | null;
enforced_by: "postbox_configuration";
}>;
resource_revision: number;
etag: string;
created_at: string;
@@ -192,11 +291,17 @@ export type PostboxTemplateRevision = {
function_type_id?: string | null;
scope_kind: "tenant" | "unit" | "subtree" | "unit_type";
scope_id?: string | null;
scope_structure_id?: string | null;
scope_relation_type_ids: string[];
name_pattern: string;
address_pattern: string;
classification: string;
allow_vacant_delivery: boolean;
portal_visible: boolean;
encryption_profile: string;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
grouping_policy: PostboxGroupingPolicy;
history_policy: Record<string, unknown>;
routing_policy: PostboxRoutingPolicy;
retention_policy: Record<string, unknown>;
@@ -225,10 +330,17 @@ export type PostboxTemplateRevisionPayload = Pick<
| "function_type_id"
| "scope_kind"
| "scope_id"
| "scope_structure_id"
| "scope_relation_type_ids"
| "name_pattern"
| "address_pattern"
| "classification"
| "allow_vacant_delivery"
| "portal_visible"
| "encryption_profile"
| "encryption_vault_id"
| "protection_policy"
| "grouping_policy"
| "routing_policy"
>;
@@ -238,6 +350,31 @@ export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
description?: string | null;
};
export type PostboxTemplatePreviewTarget = {
organization_unit_id: string;
organization_unit_name: string;
function_id: string;
function_name: string;
address: string;
name: string;
holder_count: number;
vacant: boolean;
status: string;
existing_postbox_id?: string | null;
diagnostics: string[];
};
export type PostboxTemplatePreview = {
targets: PostboxTemplatePreviewTarget[];
total: number;
ready_count: number;
existing_count: number;
vacant_count: number;
blocked_count: number;
truncated: boolean;
diagnostics: string[];
};
export type PostboxExactCreatePayload = {
name: string;
description?: string | null;
@@ -245,12 +382,20 @@ export type PostboxExactCreatePayload = {
function_id: string;
address_key?: string | null;
classification: string;
portal_visible: boolean;
encryption_profile: PostboxProtectionProfileId;
encryption_vault_id?: string | null;
protection_policy: PostboxProtectionPolicy;
grouping_policy: PostboxGroupingPolicy;
};
export type PostboxMessageAuthoringPayload = {
idempotency_key: string;
subject: string;
body_text?: string | null;
ciphertext_ref?: string | null;
signed_manifest_ref?: string | null;
wrapped_keys?: PostboxMessage["wrapped_keys"];
classification: string;
participants: PostboxParticipant[];
attachments: PostboxAttachment[];
@@ -297,6 +442,41 @@ export function getPostboxMessage(
return apiFetch(settings, `/api/v1/postbox/messages/${encodeURIComponent(messageId)}`);
}
export async function resolvePostboxAttachments(
settings: ApiSettings,
messageId: string
): Promise<PostboxAttachmentResolution[]> {
const response = await apiFetch<{ attachments: PostboxAttachmentResolution[] }>(
settings,
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/attachment-resolutions`
);
return response.attachments;
}
export async function downloadPostboxAttachment(
settings: ApiSettings,
attachment: PostboxAttachmentResolution
): Promise<void> {
if (!attachment.available || !attachment.download_path) {
throw new Error("This attachment payload is not available.");
}
const response = await fetch(apiUrl(settings, attachment.download_path), {
headers: authHeaders(settings),
credentials: "include"
});
if (!response.ok) {
throw new Error(`Attachment download failed (${response.status}).`);
}
const objectUrl = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = objectUrl;
link.download = attachment.name || attachment.reference_id;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);
}
export function markPostboxMessage(
settings: ApiSettings,
messageId: string,
@@ -389,6 +569,85 @@ export async function listAdminPostboxes(settings: ApiSettings): Promise<Postbox
return response.postboxes;
}
export async function listPostboxProtectionProfiles(
settings: ApiSettings
): Promise<{ standard_profile: PostboxProtectionProfileId; profiles: PostboxProtectionProfile[] }> {
return apiFetch(settings, "/api/v1/postbox/admin/protection-profiles");
}
export async function listPostboxProtectionTransitions(
settings: ApiSettings,
postboxId: string
): Promise<PostboxProtectionTransition[]> {
const response = await apiFetch<{ transitions: PostboxProtectionTransition[] }>(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}/protection-transitions`
);
return response.transitions;
}
export function createPostboxProtectionTransition(
settings: ApiSettings,
postbox: PostboxDirectoryItem,
payload: {
idempotency_key: string;
target_profile: PostboxProtectionProfileId;
target_vault_id?: string | null;
history_mode: "future_only" | "migrate_history";
authority_mode: "user_consent" | "institutional_key_holders" | "dual_control";
required_quorum: number;
user_consent_refs: string[];
institutional_authorization_refs: string[];
reason: string;
acknowledge_irreversibility: boolean;
}
): Promise<PostboxProtectionTransition> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}/protection-transitions`,
{ ...payload, base_revision: postbox.resource_revision },
{ headers: { "If-Match": postbox.etag } }
);
}
export function updatePostboxProtectionPolicy(
settings: ApiSettings,
postbox: PostboxDirectoryItem,
protectionPolicy: PostboxProtectionPolicy
): Promise<PostboxDirectoryItem> {
return apiFetch(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}/protection-policy`,
{
method: "PUT",
headers: { "If-Match": postbox.etag },
body: JSON.stringify({
base_revision: postbox.resource_revision,
protection_policy: protectionPolicy
})
}
);
}
export function updatePostboxGroupingPolicy(
settings: ApiSettings,
postbox: PostboxDirectoryItem,
groupingPolicy: PostboxGroupingPolicy
): Promise<PostboxDirectoryItem> {
return apiFetch(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}/grouping-policy`,
{
method: "PUT",
headers: { "If-Match": postbox.etag },
body: JSON.stringify({
base_revision: postbox.resource_revision,
grouping_policy: groupingPolicy
})
}
);
}
export async function listPostboxOrganizationTargets(
settings: ApiSettings
): Promise<PostboxOrganizationTargets> {
@@ -435,6 +694,21 @@ export function createPostboxTemplate(
return apiPostJson(settings, "/api/v1/postbox/admin/templates", payload);
}
export function previewPostboxTemplate(
settings: ApiSettings,
payload: PostboxTemplateCreatePayload & {
template_id?: string | null;
context_key?: string | null;
limit?: number;
}
): Promise<PostboxTemplatePreview> {
return apiPostJson(
settings,
"/api/v1/postbox/admin/templates/preview",
payload
);
}
export function revisePostboxTemplate(
settings: ApiSettings,
template: PostboxTemplate,
File diff suppressed because it is too large Load Diff
@@ -29,17 +29,22 @@ export default function PostboxInboxWidget({
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
const load = useCallback(async () => {
const postboxes = await listPostboxes(settings);
if (!postboxes.length) {
return { messages: [], total: 0 };
const eligible = postboxes.filter(
(postbox) => postbox.grouping_policy.mode === "allow"
);
const separatedCount = postboxes.length - eligible.length;
if (!eligible.length) {
return { messages: [], total: 0, separatedCount };
}
return listPostboxMessages(
const response = await listPostboxMessages(
settings,
postboxes.map((postbox) => postbox.id),
eligible.map((postbox) => postbox.id),
maxItems,
0,
"",
"unread"
);
return { ...response, separatedCount };
}, [maxItems, settings]);
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
@@ -50,6 +55,11 @@ export default function PostboxInboxWidget({
{error}
</DismissibleAlert>
)}
{data?.separatedCount ? (
<DismissibleAlert tone="info" dismissible={false}>
{data.separatedCount} governed Postbox source{data.separatedCount === 1 ? " is" : "s are"} shown only in separated inbox views.
</DismissibleAlert>
) : null}
<DashboardWidgetList
emptyText="No unread Postbox messages."
items={(data?.messages ?? []).map((message) => ({
+293 -84
View File
@@ -3,13 +3,13 @@ import {
Archive,
Building2,
CheckCheck,
Download,
Inbox,
Layers3,
MailOpen,
Paperclip,
Pencil,
Plus,
RefreshCw,
Reply,
Search,
Send,
@@ -17,19 +17,22 @@ import {
UserRoundCheck,
X
} from "lucide-react";
import {
import { FormGrid,
ActionBlockerHint,
Button,
ConfirmDialog,
CountBadge,
DataGridPaginationBar,
Dialog,
DismissibleAlert,
DocumentationHelpLink,
FormField,
FilterBar,
IconButton,
SegmentedControl,
SelectionList,
SelectionListItem,
StatePanel,
StatusBadge,
ToggleSwitch,
hasScope,
@@ -38,22 +41,27 @@ import {
usePlatformLanguage,
useUnsavedChanges,
useUnsavedDraftGuard,
WorkspaceActionBar,
type ApiSettings,
type AuthInfo
} from "@govoplan/core-webui";
import { useLocation, useNavigate } from "react-router";
import {
createPostboxGrouping,
createPostboxMessage,
deletePostboxGrouping,
downloadPostboxAttachment,
getPostboxMessage,
listPostboxGroupings,
listPostboxMessages,
listPostboxes,
markPostboxMessage,
resolvePostboxAttachments,
replyToPostboxMessage,
updatePostboxGrouping,
type PostboxDirectoryItem,
type PostboxGrouping,
type PostboxAttachmentResolution,
type PostboxMessage
} from "../../api/postbox";
import {
@@ -103,10 +111,19 @@ export default function PostboxPage({
settings: ApiSettings;
auth: AuthInfo;
}) {
const location = useLocation();
const navigate = useNavigate();
const requestedMessageId = useRef(
new URLSearchParams(window.location.search).get("message") ?? ""
new URLSearchParams(location.search).get("message") ?? ""
);
const requestedPostboxId = useRef(
new URLSearchParams(location.search).get("postbox") ?? ""
);
const requestedGroupingId = useRef(
new URLSearchParams(location.search).get("grouping") ?? ""
);
const requestedMessageLoaded = useRef(false);
const requestedComposeLoaded = useRef(false);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
const [selectedScope, setSelectedScope] = useState("all");
@@ -114,6 +131,7 @@ export default function PostboxPage({
const [messages, setMessages] = useState<PostboxMessage[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState("");
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
const [attachmentResolutions, setAttachmentResolutions] = useState<PostboxAttachmentResolution[]>([]);
const [unavailableSelection, setUnavailableSelection] = useState("");
const [total, setTotal] = useState(0);
const [messageState, setMessageState] = useState<MessageStateFilter>("all");
@@ -149,6 +167,10 @@ export default function PostboxPage({
() => groupings.find((grouping) => grouping.id === selectedScope) ?? null,
[groupings, selectedScope]
);
const composeTarget = useMemo(
() => postboxes.find((postbox) => postbox.id === messageDraft.postbox_id) ?? null,
[messageDraft.postbox_id, postboxes]
);
const scopePostboxIds = useMemo(() => {
if (selectedPostboxId) return [selectedPostboxId];
if (selectedGrouping) {
@@ -157,6 +179,14 @@ export default function PostboxPage({
}
return postboxes.map((postbox) => postbox.id);
}, [postboxes, selectedGrouping, selectedPostboxId]);
const scopeSeparationConflict = useMemo(
() => selectedPostboxId ? null : groupingConflict(postboxes, scopePostboxIds),
[postboxes, scopePostboxIds, selectedPostboxId]
);
const groupingDraftConflict = useMemo(
() => groupingConflict(postboxes, groupingDraft.postbox_ids),
[groupingDraft.postbox_ids, postboxes]
);
const scopeKey = scopePostboxIds.join("|");
const composeDisabledReason = postboxBusyReason(false, busy)
?? (!canSend ? POSTBOX_INTERFACE_I18N.noSendReason : undefined)
@@ -164,6 +194,9 @@ export default function PostboxPage({
const replyDisabledReason = postboxBusyReason(false, busy)
?? (!canReply ? POSTBOX_INTERFACE_I18N.noReplyReason : undefined)
?? (!selectedMessage ? POSTBOX_INTERFACE_I18N.noMessage : undefined)
?? (selectedMessage?.encryption_profile === "external_e2ee_v1"
? "Replies to E2EE messages must be created by an approved encryption client."
: undefined)
?? (selectedMessage?.availability !== "available"
? POSTBOX_INTERFACE_I18N.unavailableMessage
: undefined);
@@ -196,6 +229,9 @@ export default function PostboxPage({
setPostboxes(nextPostboxes);
setGroupings(nextGroupings);
setSelectedScope((current) => {
if (nextGroupings.some((grouping) => grouping.id === requestedGroupingId.current)) {
return requestedGroupingId.current;
}
if (current === "all" || nextGroupings.some((grouping) => grouping.id === current)) {
return current;
}
@@ -204,6 +240,8 @@ export default function PostboxPage({
setSelectedPostboxId((current) =>
current && nextPostboxes.some((postbox) => postbox.id === current)
? current
: nextPostboxes.some((postbox) => postbox.id === requestedPostboxId.current)
? requestedPostboxId.current
: ""
);
} catch (loadError) {
@@ -214,7 +252,7 @@ export default function PostboxPage({
}, [settings]);
const loadMessages = useCallback(async () => {
if (!scopePostboxIds.length) {
if (!scopePostboxIds.length || scopeSeparationConflict) {
setMessages([]);
setSelectedMessageId("");
setSelectedMessage(null);
@@ -250,12 +288,50 @@ export default function PostboxPage({
} finally {
setLoadingMessages(false);
}
}, [messageQuery, messageState, page, pageSize, scopeKey, settings]);
}, [messageQuery, messageState, page, pageSize, scopeKey, scopeSeparationConflict, settings]);
useEffect(() => {
void loadDirectory();
}, [loadDirectory]);
useEffect(() => {
const parameters = new URLSearchParams(location.search);
if (
requestedComposeLoaded.current ||
parameters.get("quickAction") !== "compose" ||
loadingDirectory
) {
return;
}
requestedComposeLoaded.current = true;
const requestedId = parameters.get("postbox");
const postbox =
postboxes.find((item) => item.id === requestedId) ?? postboxes[0] ?? null;
if (canSend && postbox) {
setSelectedPostboxId(postbox.id);
openComposeFor(postbox);
} else if (!canSend) {
setError(POSTBOX_INTERFACE_I18N.noSendReason);
} else {
setError(POSTBOX_INTERFACE_I18N.noPostbox);
}
parameters.delete("quickAction");
const search = parameters.toString();
navigate(
{ pathname: location.pathname, search: search ? `?${search}` : "" },
{ replace: true, state: location.state }
);
}, [
canSend,
loadingDirectory,
location.pathname,
location.search,
location.state,
navigate,
postboxes
]);
useEffect(() => {
void loadMessages();
}, [loadMessages]);
@@ -316,14 +392,19 @@ export default function PostboxPage({
if (message.availability === "available" && !message.read_at) {
message = await markPostboxMessage(settings, selectedMessageId, "read");
}
const resolutions = message.attachments.length
? await resolvePostboxAttachments(settings, message.id)
: [];
if (cancelled) return;
setSelectedMessage(message);
setAttachmentResolutions(resolutions);
setMessages((items) =>
items.map((item) => (item.id === message.id ? message : item))
);
} catch (loadError) {
if (!cancelled && isApiError(loadError, 403, 404)) {
setSelectedMessage(null);
setAttachmentResolutions([]);
setUnavailableSelection(
"This message is no longer available or is outside your current Postbox assignments."
);
@@ -373,12 +454,22 @@ export default function PostboxPage({
}
function selectScope(scopeId: string) {
requestedGroupingId.current = scopeId === "all" ? "" : scopeId;
setSelectedScope(scopeId);
setSelectedPostboxId("");
setPage(1);
setSelectedMessageId("");
setSelectedMessage(null);
setUnavailableSelection("");
const parameters = new URLSearchParams(location.search);
if (scopeId === "all") parameters.delete("grouping");
else parameters.set("grouping", scopeId);
parameters.delete("postbox");
const search = parameters.toString();
navigate(
{ pathname: location.pathname, search: search ? `?${search}` : "" },
{ replace: true, state: location.state }
);
}
function openNewGrouping() {
@@ -402,6 +493,10 @@ export default function PostboxPage({
async function saveGrouping(): Promise<boolean> {
if (!groupingDraft.name.trim()) return false;
if (groupingDraftConflict) {
setError(groupingDraftConflict);
return false;
}
setBusy(true);
setError("");
const payload = {
@@ -415,8 +510,7 @@ export default function PostboxPage({
? await updatePostboxGrouping(settings, existing, payload)
: await createPostboxGrouping(settings, payload);
await loadDirectory();
setSelectedScope(saved.id);
setSelectedPostboxId("");
selectScope(saved.id);
setGroupingDialogOpen(false);
setGroupingBaseline(groupingDraft);
return true;
@@ -436,8 +530,7 @@ export default function PostboxPage({
const existing = groupings.find((item) => item.id === deleteGroupingTarget.id);
if (!existing) throw new Error("The grouping is no longer available.");
await deletePostboxGrouping(settings, existing);
setSelectedScope("all");
setSelectedPostboxId("");
selectScope("all");
setGroupingDialogOpen(false);
setDeleteGroupingTarget(null);
await loadDirectory();
@@ -449,8 +542,15 @@ export default function PostboxPage({
}
function openCompose() {
const postbox = selectedPostbox ?? postboxes[0] ?? null;
const postbox = selectedPostbox
?? postboxes.find((item) => item.encryption_profile !== "external_e2ee_v1")
?? postboxes[0]
?? null;
if (!postbox) return;
openComposeFor(postbox);
}
function openComposeFor(postbox: PostboxDirectoryItem) {
setReplyParent(null);
const next = {
...emptyMessageDraft(),
@@ -479,7 +579,11 @@ export default function PostboxPage({
}
async function submitMessage(): Promise<boolean> {
if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return false;
if (
!messageDraft.postbox_id
|| !messageDraft.subject.trim()
|| composeTarget?.encryption_profile === "external_e2ee_v1"
) return false;
setBusy(true);
setError("");
const participants = messageDraft.recipients
@@ -570,29 +674,25 @@ export default function PostboxPage({
<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">
<WorkspaceActionBar
scope="collection-pane"
variant="collection"
refreshable
reloadAction={{ onReload: () => void loadDirectory(), loading: loadingDirectory || busy }}
className="postbox-bar"
title={<span className="postbox-bar-title">
<Inbox size={17} aria-hidden="true" />
<strong>Postbox</strong>
<DocumentationHelpLink reference={POSTBOX_DOCUMENTATION} />
</div>
<div className="postbox-icon-actions">
<IconButton
</span>}
titleHelp={<DocumentationHelpLink reference={POSTBOX_DOCUMENTATION} />}
createAction={<IconButton
label="New unified view"
icon={<Plus size={16} />}
onClick={openNewGrouping}
disabled={busy}
disabledReason={postboxBusyReason(false, busy)}
/>}
/>
<IconButton
label="Refresh"
icon={<RefreshCw size={16} />}
onClick={() => requestDiscard(() => void loadDirectory())}
disabled={loadingDirectory || busy}
disabledReason={postboxBusyReason(loadingDirectory, busy)}
/>
</div>
</div>
<div className="postbox-scope-control">
<label htmlFor="postbox-scope">Inbox view</label>
@@ -605,7 +705,9 @@ export default function PostboxPage({
<option value="all">All postboxes</option>
{groupings.map((grouping) => (
<option key={grouping.id} value={grouping.id}>
{grouping.name}{grouping.is_default ? " (default)" : ""}
{grouping.name}
{grouping.unread_count ? ` (${grouping.unread_count})` : ""}
{grouping.is_default ? " · default" : ""}
</option>
))}
</select>
@@ -617,15 +719,25 @@ export default function PostboxPage({
/>
) : null}
</div>
{scopeSeparationConflict ? (
<DismissibleAlert tone="warning" dismissible={false}>
<strong>Combined view unavailable.</strong>{" "}
{scopeSeparationConflict} Select one source Postbox or edit the unified view.
</DismissibleAlert>
) : selectedGrouping?.constraints.length ? (
<DismissibleAlert tone="info" dismissible={false}>
This projection is governed by {selectedGrouping.constraints.length} source-separation rule{selectedGrouping.constraints.length === 1 ? "" : "s"}.
{selectedGrouping.constraints.find((item) => item.reason)?.reason
? ` ${selectedGrouping.constraints.find((item) => item.reason)?.reason}`
: ""}
</DismissibleAlert>
) : null}
</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>
<StatePanel size="compact" icon={<Archive size={20} />} title="No assigned postboxes" description="Postboxes appear when your account has a current matching function assignment.">
<ActionBlockerHint
reason={{
summary: POSTBOX_INTERFACE_I18N.noPostbox,
@@ -640,10 +752,10 @@ export default function PostboxPage({
}}
documentation={POSTBOX_DOCUMENTATION}
/>
</div>
</StatePanel>
) : null}
{postboxes.length ? (
<SelectionList label="Assigned postboxes">
<SelectionList variant="navigation" label="Assigned postboxes">
{postboxes.map((postbox) => (
<SelectionListItem
key={postbox.id}
@@ -670,8 +782,13 @@ export default function PostboxPage({
</aside>
<section className="postbox-message-list" data-view-surface="postbox.inbox.messages">
<div className="postbox-bar">
<div className="postbox-bar-title">
<WorkspaceActionBar
scope="collection-pane"
variant="collection"
refreshable
reloadAction={{ onReload: () => void loadMessages(), loading: loadingMessages || busy, label: "Refresh messages" }}
className="postbox-bar"
contextActions={<div className="postbox-bar-title">
{selectedPostbox ? (
<>
<Building2 size={17} aria-hidden="true" />
@@ -688,27 +805,18 @@ export default function PostboxPage({
<strong>All postboxes</strong>
</>
)}
<span className="postbox-total">{total}</span>
</div>
<div className="postbox-icon-actions">
<IconButton
<CountBadge tone="neutral">{total}</CountBadge>
</div>}
createAction={<IconButton
label="New message"
icon={<Send size={16} />}
onClick={openCompose}
disabled={Boolean(composeDisabledReason)}
disabledReason={composeDisabledReason}
/>}
/>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => requestDiscard(() => void loadMessages())}
disabled={loadingMessages || busy}
disabledReason={postboxBusyReason(loadingMessages, busy)}
/>
</div>
</div>
<div className="postbox-message-filters">
<div className="postbox-search-row">
<FilterBar surface="control" wrap="never" className="postbox-search-row">
<input
type="search"
value={searchDraft}
@@ -747,7 +855,7 @@ export default function PostboxPage({
setSelectedMessage(null);
}}
/>
</div>
</FilterBar>
<SegmentedControl<MessageStateFilter>
ariaLabel="Message state"
width="fill"
@@ -774,14 +882,10 @@ export default function PostboxPage({
<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>
<StatePanel size="fill" icon={<MailOpen size={24} />} title="No messages" description="This view has no delivered Postbox messages." />
) : null}
{messages.length ? (
<SelectionList label="Postbox messages">
<SelectionList variant="navigation" label="Postbox messages">
{messages.map((message) => (
<SelectionListItem
key={message.id}
@@ -838,12 +942,15 @@ export default function PostboxPage({
</section>
<section className="postbox-detail">
<div className="postbox-bar">
<div className="postbox-bar-title">
<WorkspaceActionBar
scope="detail-pane"
variant="detail"
className="postbox-bar"
contextActions={<div className="postbox-bar-title">
<MailOpen size={17} aria-hidden="true" />
<strong>{selectedMessage?.subject || "Message"}</strong>
</div>
<div className="button-row compact-actions">
</div>}
primaryActions={<div className="button-row compact-actions">
<Button
onClick={openReply}
disabled={Boolean(replyDisabledReason)}
@@ -858,25 +965,23 @@ export default function PostboxPage({
>
<CheckCheck size={16} /> Acknowledge
</Button>
</div>
</div>
</div>}
/>
{selectedMessage ? (
<MessageDetail
message={selectedMessage}
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
attachmentResolutions={attachmentResolutions}
onDownload={(attachment) => {
void downloadPostboxAttachment(settings, attachment).catch((downloadError) => {
setError(errorMessage(downloadError));
});
}}
/>
) : unavailableSelection ? (
<div className="postbox-empty postbox-unavailable-message">
<Archive size={24} />
<strong>Message unavailable</strong>
<p>{unavailableSelection}</p>
</div>
<StatePanel size="fill" tone="warning" icon={<Archive size={24} />} title="Message unavailable" description={unavailableSelection} className="postbox-unavailable-message" />
) : (
<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>
<StatePanel size="fill" icon={<Inbox size={24} />} title="Select a message" description="Source, function context, content, and evidence remain attached to the originating postbox." />
)}
</section>
</div>
@@ -892,6 +997,8 @@ export default function PostboxPage({
{groupingDraft.id ? (
<Button
variant="danger"
helpContextId="postbox.action.delete-grouping"
helpModuleId="postbox"
onClick={() => setDeleteGroupingTarget(groupingDraft)}
disabled={busy}
disabledReason={postboxBusyReason(false, busy)}
@@ -906,8 +1013,11 @@ export default function PostboxPage({
<Button
variant="primary"
onClick={() => void saveGrouping()}
disabled={busy || !groupingDraft.name.trim()}
disabledReason={postboxBusyReason(false, busy) ?? (!groupingDraft.name.trim() ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
disabled={busy || !groupingDraft.name.trim() || Boolean(groupingDraftConflict)}
disabledReason={postboxBusyReason(false, busy)
?? (!groupingDraft.name.trim() ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)
?? groupingDraftConflict
?? undefined}
>
Save
</Button>
@@ -915,7 +1025,7 @@ export default function PostboxPage({
</div>
}
>
<div className="postbox-form-grid">
<FormGrid gap="small" className="postbox-form-grid">
<FormField label="Name" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input
value={groupingDraft.name}
@@ -939,7 +1049,7 @@ export default function PostboxPage({
}
/>
</div>
</div>
</FormGrid>
<fieldset className="postbox-source-selector">
<legend>Source postboxes</legend>
{postboxes.map((postbox) => (
@@ -947,6 +1057,13 @@ export default function PostboxPage({
<input
type="checkbox"
checked={groupingDraft.postbox_ids.includes(postbox.id)}
disabled={
!groupingDraft.postbox_ids.includes(postbox.id)
&& Boolean(groupingConflict(
postboxes,
[...groupingDraft.postbox_ids, postbox.id]
))
}
onChange={(event) =>
setGroupingDraft((current) => ({
...current,
@@ -959,10 +1076,21 @@ export default function PostboxPage({
<span>
<strong>{postbox.name}</strong>
<small>{postbox.organization_unit_name} · {postbox.function_name}</small>
{postbox.grouping_policy.mode !== "allow" ? (
<small>
{postbox.grouping_policy.reason
|| postbox.grouping_policy.mode.replaceAll("_", " ")}
</small>
) : null}
</span>
</label>
))}
</fieldset>
{groupingDraftConflict ? (
<DismissibleAlert tone="warning" dismissible={false}>
{groupingDraftConflict}
</DismissibleAlert>
) : null}
</Dialog>
<Dialog
@@ -978,20 +1106,30 @@ export default function PostboxPage({
</Button>
<Button
variant="primary"
helpContextId="postbox.inbox.messages"
helpModuleId="postbox"
onClick={() => void submitMessage()}
disabled={
busy ||
!messageDraft.postbox_id ||
!messageDraft.subject.trim()
!messageDraft.subject.trim() ||
composeTarget?.encryption_profile === "external_e2ee_v1"
}
disabledReason={postboxBusyReason(false, busy) ?? ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined)}
disabledReason={postboxBusyReason(false, busy) ?? (composeTarget?.encryption_profile === "external_e2ee_v1" ? "This browser editor has no E2EE private-key custody. Use an approved encryption client for this Postbox." : ((!messageDraft.postbox_id || !messageDraft.subject.trim()) ? POSTBOX_INTERFACE_I18N.incompleteDraft : undefined))}
>
<Send size={16} /> Send
</Button>
</div>
}
>
<div className="postbox-compose-grid">
<FormGrid columns={2} gap="small" collapseAt="narrow" className="postbox-compose-grid">
{composeTarget?.encryption_profile === "external_e2ee_v1" ? (
<div className="postbox-compose-wide">
<DismissibleAlert tone="info" compact resetKey={composeTarget.id}>
This Postbox requires externally produced E2EE. Use an approved client that supplies ciphertext, a signed manifest, wrapped keys, and a verified content digest; this browser editor never asks for or stores the private key.
</DismissibleAlert>
</div>
) : null}
<FormField label="Postbox" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<select
value={messageDraft.postbox_id}
@@ -1076,7 +1214,7 @@ export default function PostboxPage({
/>
</FormField>
</div>
</div>
</FormGrid>
</Dialog>
<ConfirmDialog
@@ -1097,10 +1235,14 @@ export default function PostboxPage({
function MessageDetail({
message,
postbox
postbox,
attachmentResolutions,
onDownload
}: {
message: PostboxMessage;
postbox?: PostboxDirectoryItem;
attachmentResolutions: PostboxAttachmentResolution[];
onDownload: (attachment: PostboxAttachmentResolution) => void;
}) {
const { language } = usePlatformLanguage();
return (
@@ -1148,7 +1290,19 @@ function MessageDetail({
</dl>
</section>
<section className="postbox-body">
{message.encryption_profile === "external_e2ee_v1" ? (
<>
<DismissibleAlert tone="info" compact resetKey={message.id}>
This message is end-to-end encrypted. GovOPlaN stores and authorizes its envelope but cannot decrypt the content; open it with the institution's approved client.
</DismissibleAlert>
<dl className="postbox-provenance">
<div><dt>Ciphertext</dt><dd>{message.ciphertext_ref || "Not recorded"}</dd></div>
<div><dt>Signed manifest</dt><dd>{message.signed_manifest_ref || "Not recorded"}</dd></div>
</dl>
</>
) : (
<p>{message.body_text || "No plaintext body is available for this message."}</p>
)}
</section>
{message.participants.length ? (
<section className="postbox-participants">
@@ -1164,15 +1318,30 @@ function MessageDetail({
<section className="postbox-attachments">
<h2>Evidence and attachments</h2>
{!message.attachments.length ? <p>No attachment references.</p> : null}
{message.attachments.map((attachment) => (
{message.attachments.map((attachment) => {
const resolution = attachmentResolutions.find(
(item) => item.reference_type === attachment.reference_type
&& item.reference_id === attachment.reference_id
);
return (
<div key={`${attachment.reference_type}:${attachment.reference_id}`}>
<Paperclip size={15} />
<span>
<strong>{attachment.name || attachment.reference_id}</strong>
<strong>{resolution?.name || attachment.name || attachment.reference_id}</strong>
<small>{attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""}</small>
{resolution && !resolution.available ? (
<small>{attachmentResolutionExplanation(resolution.reason_code)}</small>
) : null}
</span>
{resolution?.available ? (
<IconButton
icon={<Download size={15} />}
label={`Download ${resolution.name || attachment.name || "attachment"}`}
onClick={() => onDownload(resolution)}
/>
) : null}
</div>
))}
)})}
</section>
{postbox?.access ? (
<section className="postbox-access-explanation">
@@ -1187,6 +1356,46 @@ function MessageDetail({
);
}
function attachmentResolutionExplanation(reasonCode: string): string {
const explanations: Record<string, string> = {
download_permission_missing: "Files download permission is required.",
file_access_denied: "The referenced file is outside your current Files access.",
file_not_found: "The referenced file or version no longer exists.",
file_payload_missing: "The referenced file payload is unavailable.",
files_provider_unavailable: "Files is not available in this installation.",
reference_provider_unavailable: "No provider can open this evidence type."
};
return explanations[reasonCode] || "The referenced payload cannot currently be opened.";
}
function groupingConflict(
postboxes: PostboxDirectoryItem[],
postboxIds: string[]
): string | null {
const selected = postboxIds
.map((postboxId) => postboxes.find((postbox) => postbox.id === postboxId))
.filter((postbox): postbox is PostboxDirectoryItem => Boolean(postbox));
if (selected.length <= 1) return null;
const separate = selected.find(
(postbox) => postbox.grouping_policy.mode === "separate"
);
if (separate) {
return separate.grouping_policy.reason
|| `${separate.name} must remain a separate inbox.`;
}
const classificationRule = selected.find(
(postbox) => postbox.grouping_policy.mode === "same_classification"
);
if (
classificationRule
&& new Set(selected.map((postbox) => postbox.classification)).size > 1
) {
return classificationRule.grouping_policy.reason
|| "These Postboxes cannot be combined across classifications.";
}
return null;
}
function sourceName(
postboxes: PostboxDirectoryItem[],
postboxId: string
@@ -0,0 +1,171 @@
import { ExternalLink, Inbox, Pencil, Send } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import {
Button,
DismissibleAlert,
LoadingFrame,
SelectionList,
SelectionListItem,
SelectionListItemContent,
hasScope,
quickAccessLaunchState,
useDashboardWidgetData,
usePlatformLanguage,
type QuickAccessToolRenderContext
} from "@govoplan/core-webui";
import {
listPostboxMessages,
listPostboxes,
type PostboxMessage
} from "../../api/postbox";
const MESSAGE_LIMIT = 7;
type Props = Pick<
QuickAccessToolRenderContext,
"settings" | "auth" | "launchContext" | "complete" | "close"
>;
/** Function-bound projection; Postbox re-evaluates the current acting context. */
export default function PostboxQuickAccess({
settings,
auth,
launchContext,
complete,
close
}: Props) {
const { language } = usePlatformLanguage();
const [selectedId, setSelectedId] = useState("");
const load = useCallback(async () => {
const postboxes = await listPostboxes(settings);
const eligible = postboxes.filter(
(postbox) => postbox.grouping_policy.mode === "allow"
);
const separatedCount = postboxes.length - eligible.length;
if (!eligible.length) {
return { postboxes, messages: [], total: 0, separatedCount };
}
const response = await listPostboxMessages(
settings,
eligible.map((postbox) => postbox.id),
MESSAGE_LIMIT,
0,
"",
"unread"
);
return { postboxes, separatedCount, ...response };
}, [settings]);
const { data, loading, error } = useDashboardWidgetData(load, 0);
const messages = data?.messages ?? [];
const selected = useMemo(
() => messages.find((message) => message.id === selectedId) ?? messages[0] ?? null,
[messages, selectedId]
);
const selectedPostbox = data?.postboxes.find(
(postbox) => postbox.id === selected?.postbox_id
) ?? data?.postboxes[0] ?? null;
const canCompose = hasScope(auth, "postbox:message:write");
useEffect(() => {
if (!selectedId && messages[0]) setSelectedId(messages[0].id);
if (selectedId && !messages.some((message) => message.id === selectedId)) {
setSelectedId(messages[0]?.id ?? "");
}
}, [messages, selectedId]);
function selectForHost(message: PostboxMessage) {
complete({
contractVersion: "1",
outcome: "completed",
action: "selected",
reference: {
ownerModule: "postbox",
kind: "message",
objectId: message.id,
tenantId: message.tenant_id,
label: message.subject,
version: `${message.status}:${message.delivered_at}`,
path: `/postbox?message=${encodeURIComponent(message.id)}`
}
});
}
return (
<LoadingFrame loading={loading} label="i18n:govoplan-postbox.quick_loading_unread">
{launchContext.actingContext?.assignmentId ? (
<p className="muted small-note">
i18n:govoplan-postbox.quick_acting_context
</p>
) : null}
{error ? <DismissibleAlert tone="warning" resetKey={error}>{error}</DismissibleAlert> : null}
{data?.separatedCount ? (
<DismissibleAlert tone="info" dismissible={false}>
{data.separatedCount} Postbox source{data.separatedCount === 1 ? " is" : "s are"} available only in a separated inbox view.
</DismissibleAlert>
) : null}
{messages.length ? (
<SelectionList variant="navigation" label="i18n:govoplan-postbox.quick_unread_messages">
{messages.map((message) => (
<SelectionListItem
key={message.id}
selected={selected?.id === message.id}
onClick={() => setSelectedId(message.id)}
>
<SelectionListItemContent
leading={<Inbox size={16} aria-hidden="true" />}
title={message.subject}
description={`${message.sender_label || message.producer_module || "Postbox"} · ${messageDate(message, language)}`}
/>
</SelectionListItem>
))}
</SelectionList>
) : !loading && !error ? (
<p className="muted">i18n:govoplan-postbox.quick_no_unread</p>
) : null}
{selected ? (
<section className="postbox-quick-detail" aria-label="i18n:govoplan-postbox.quick_message_details">
<strong>{selected.subject}</strong>
<span>{selectedPostbox?.function_name || selectedPostbox?.name}</span>
{selected.body_text ? <p>{selected.body_text}</p> : null}
<div className="button-row compact-actions">
<Button variant="primary" onClick={() => selectForHost(selected)}>
<Send size={15} aria-hidden="true" /> i18n:govoplan-postbox.quick_select_message
</Button>
<Link
className="btn btn-secondary"
to={`/postbox?message=${encodeURIComponent(selected.id)}`}
state={quickAccessLaunchState(launchContext)}
onClick={() => selectForHost(selected)}
>
<ExternalLink size={15} aria-hidden="true" /> i18n:govoplan-postbox.quick_open_message
</Link>
</div>
</section>
) : null}
<div className="dashboard-contribution-footer">
{canCompose && selectedPostbox ? (
<Link
className="btn btn-secondary"
to={`/postbox?quickAction=compose&postbox=${encodeURIComponent(selectedPostbox.id)}`}
state={quickAccessLaunchState(launchContext)}
onClick={close}
>
<Pencil size={15} aria-hidden="true" /> i18n:govoplan-postbox.quick_compose
</Link>
) : null}
<span className="muted small-note">{messages.length} / {data?.total ?? 0}</span>
</div>
</LoadingFrame>
);
}
function messageDate(message: PostboxMessage, language: string): string {
return new Intl.DateTimeFormat(language, {
dateStyle: "medium",
timeStyle: "short"
}).format(new Date(message.delivered_at));
}
+18
View File
@@ -2,6 +2,15 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-postbox.postbox": "Postbox",
"i18n:govoplan-postbox.quick_access_description": "Institutional messages addressed to your functions.",
"i18n:govoplan-postbox.quick_loading_unread": "Loading unread Postbox messages",
"i18n:govoplan-postbox.quick_acting_context": "Showing messages for the current acting assignment.",
"i18n:govoplan-postbox.quick_unread_messages": "Unread Postbox messages",
"i18n:govoplan-postbox.quick_no_unread": "No unread Postbox messages.",
"i18n:govoplan-postbox.quick_message_details": "Postbox message details",
"i18n:govoplan-postbox.quick_select_message": "Select message",
"i18n:govoplan-postbox.quick_open_message": "Open message",
"i18n:govoplan-postbox.quick_compose": "Compose message",
"i18n:govoplan-postbox.postboxes": "Postboxes",
"i18n:govoplan-postbox.postbox_inbox": "Postbox inbox",
"i18n:govoplan-postbox.postbox_inbox_description": "Unread messages across accessible Postboxes.",
@@ -235,6 +244,15 @@ const en = {
const de = {
...en,
"i18n:govoplan-postbox.postbox": "Postfach",
"i18n:govoplan-postbox.quick_access_description": "Institutionelle Nachrichten an Ihre Funktionen.",
"i18n:govoplan-postbox.quick_loading_unread": "Ungelesene Postfachnachrichten werden geladen",
"i18n:govoplan-postbox.quick_acting_context": "Nachrichten der aktuellen Handlungszuweisung werden angezeigt.",
"i18n:govoplan-postbox.quick_unread_messages": "Ungelesene Postfachnachrichten",
"i18n:govoplan-postbox.quick_no_unread": "Keine ungelesenen Postfachnachrichten.",
"i18n:govoplan-postbox.quick_message_details": "Details der Postfachnachricht",
"i18n:govoplan-postbox.quick_select_message": "Nachricht auswählen",
"i18n:govoplan-postbox.quick_open_message": "Nachricht öffnen",
"i18n:govoplan-postbox.quick_compose": "Nachricht verfassen",
"i18n:govoplan-postbox.postboxes": "Postfächer",
"i18n:govoplan-postbox.postbox_inbox": "Postfach-Eingang",
"i18n:govoplan-postbox.postbox_inbox_description": "Ungelesene Nachrichten aus zugänglichen Postfächern.",
+28 -6
View File
@@ -1,12 +1,15 @@
import { createElement, lazy } from "react";
import {
hasScope,
messagesProductSurfaceTranslations,
type AdminSectionsUiCapability,
type DashboardWidgetsUiCapability,
type PlatformWebModule
type PlatformWebModule,
type QuickAccessToolsUiCapability
} from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import PostboxInboxWidget from "./features/postbox/PostboxInboxWidget";
import PostboxQuickAccess from "./features/postbox/PostboxQuickAccess";
import "./styles/postbox.css";
@@ -16,8 +19,8 @@ const PostboxAdminPanel = lazy(
);
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
en: { ...generatedTranslations.en, ...messagesProductSurfaceTranslations.en },
de: { ...generatedTranslations.de, ...messagesProductSurfaceTranslations.de }
};
const readScope = ["postbox:postbox:read"];
@@ -59,6 +62,14 @@ const postboxDashboardWidgets: DashboardWidgetsUiCapability = {
}
]
};
const postboxQuickAccessTools: QuickAccessToolsUiCapability = {
tools: [
{
id: "postbox.messages",
render: (context) => createElement(PostboxQuickAccess, context)
}
]
};
const postboxAdminSections: AdminSectionsUiCapability = {
sections: [
@@ -87,19 +98,22 @@ const postboxAdminSections: AdminSectionsUiCapability = {
export const postboxModule: PlatformWebModule = {
id: "postbox",
label: "i18n:govoplan-postbox.postbox",
version: "0.1.2",
version: "0.1.19",
dependencies: ["identity", "organizations", "idm"],
optionalDependencies: [
"access",
"audit",
"campaigns",
"encryption",
"files",
"mail",
"notifications",
"policy",
"portal",
"search",
"tasks",
"views",
"workflow"
"workflow_engine"
],
translations,
navItems: [
@@ -149,11 +163,19 @@ export const postboxModule: PlatformWebModule = {
kind: "section",
label: "i18n:govoplan-postbox.postbox_templates_bindings",
order: 30
},
{
id: "postbox.quick_access.messages",
moduleId: "postbox",
kind: "quick_access",
label: "Postbox Quick Access",
order: 35
}
],
uiCapabilities: {
"admin.sections": postboxAdminSections,
"dashboard.widgets": postboxDashboardWidgets
"dashboard.widgets": postboxDashboardWidgets,
"quickAccess.tools": postboxQuickAccessTools
}
};
+98 -64
View File
@@ -52,7 +52,6 @@
background: var(--panel-soft);
}
.postbox-bar,
.postbox-bar-title,
.postbox-icon-actions,
.postbox-scope-row,
@@ -68,16 +67,6 @@
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;
@@ -95,18 +84,6 @@
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);
@@ -152,9 +129,7 @@
}
.postbox-search-row {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
}
.postbox-search-row input {
@@ -383,27 +358,6 @@
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;
@@ -420,15 +374,6 @@
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%;
@@ -466,6 +411,79 @@
font-size: 12px;
}
.postbox-relation-options {
min-height: 38px;
display: flex;
flex-wrap: wrap;
align-content: center;
gap: 8px 14px;
}
.postbox-relation-options label {
display: inline-flex;
align-items: center;
gap: 7px;
color: var(--text-strong);
font-size: 13px;
}
.postbox-relation-options input {
width: auto;
}
.postbox-template-preview {
display: grid;
gap: 12px;
border-top: var(--border-line);
margin-top: 18px;
padding-top: 16px;
}
.postbox-preview-targets {
max-height: 280px;
display: grid;
gap: 1px;
overflow: auto;
background: var(--border-color);
border: var(--border-line);
}
.postbox-preview-targets > div {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: var(--surface);
padding: 10px 12px;
}
.postbox-preview-targets > div > div:first-child {
min-width: 0;
display: grid;
gap: 2px;
}
.postbox-preview-targets span,
.postbox-preview-targets small {
color: var(--muted);
font-size: 12px;
}
.postbox-preview-targets code {
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-preview-status {
flex: 0 0 auto;
display: grid;
justify-items: end;
gap: 4px;
}
.postbox-form-note {
margin: 15px 0 0;
color: var(--muted);
@@ -523,9 +541,7 @@
}
.postbox-compose-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(160px, 1fr);
gap: 12px;
}
.postbox-compose-wide {
@@ -538,6 +554,28 @@
resize: vertical;
}
.postbox-quick-detail {
display: grid;
gap: 7px;
margin-top: 12px;
border-top: var(--border-line);
padding-top: 12px;
}
.postbox-quick-detail > span,
.postbox-quick-detail > p {
margin: 0;
color: var(--muted);
font-size: 12px;
}
.postbox-quick-detail > p {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.postbox-dialog-actions.end {
justify-content: flex-end;
gap: 8px;
@@ -630,7 +668,7 @@
color: var(--muted);
}
@media (max-width: 1180px) {
@media (max-width: 1280px) {
.postbox-shell {
grid-template-columns: minmax(230px, 290px) minmax(280px, 340px) minmax(330px, 1fr);
overflow-x: auto;
@@ -677,8 +715,7 @@
}
}
@media (max-width: 640px) {
.postbox-form-grid.two-columns,
@media (max-width: 680px) {
.postbox-provenance dl,
.postbox-admin-properties {
grid-template-columns: 1fr;
@@ -693,7 +730,4 @@
flex-direction: column;
}
.postbox-compose-grid {
grid-template-columns: 1fr;
}
}