Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b24d291cc8 | ||
|
|
e5da713d5f | ||
|
|
8a21876634 | ||
|
|
41ea8d8e23 | ||
|
|
174ee97719 | ||
|
|
15d93aaa25 | ||
|
|
d005065e50 | ||
|
|
ce334fee7d | ||
|
|
6b0e3ae37d | ||
|
|
225a3a233b | ||
|
|
570709b0bd | ||
|
|
11f175cbb3 | ||
|
|
60f50f7906 | ||
|
|
36530b6dce | ||
|
|
d107c09f74 | ||
|
|
c53646a8aa | ||
|
|
ae3bbfa34e | ||
|
|
a6815a4a0a |
@@ -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:
|
||||
|
||||
|
||||
+132
-45
@@ -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.
|
||||
|
||||
+2
-2
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "govoplan-postbox"
|
||||
version = "0.1.15"
|
||||
version = "0.1.19"
|
||||
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.15"]
|
||||
dependencies = ["govoplan-core>=0.1.18"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""GovOPlaN Postbox module."""
|
||||
|
||||
__version__ = "0.1.15"
|
||||
__version__ = "0.1.19"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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",
|
||||
]
|
||||
@@ -8,6 +8,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 +18,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 +31,8 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
PermissionDefinition,
|
||||
ProductAreaContribution,
|
||||
QuickAccessTool,
|
||||
RoleTemplate,
|
||||
)
|
||||
from govoplan_core.core.provider_governance import declared_module_architecture
|
||||
@@ -42,28 +47,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.15"
|
||||
|
||||
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.19"
|
||||
|
||||
|
||||
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
|
||||
@@ -169,6 +178,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 +220,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 +249,7 @@ manifest = ModuleManifest(
|
||||
"views",
|
||||
"workflow_engine",
|
||||
"search",
|
||||
"tasks",
|
||||
),
|
||||
required_capabilities=(
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
@@ -244,6 +269,8 @@ manifest = ModuleManifest(
|
||||
CAPABILITY_POSTBOX_DELIVERY,
|
||||
CAPABILITY_POSTBOX_EVIDENCE,
|
||||
CAPABILITY_POSTBOX_ROUTING,
|
||||
CAPABILITY_POSTBOX_PORTAL,
|
||||
POSTBOX_DSAR_CAPABILITY,
|
||||
)
|
||||
),
|
||||
requires_interfaces=(
|
||||
@@ -275,6 +302,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 +317,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 +382,41 @@ 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,
|
||||
),
|
||||
),
|
||||
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 +451,205 @@ 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. 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. "
|
||||
"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 +665,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",
|
||||
@@ -408,12 +857,13 @@ manifest = ModuleManifest(
|
||||
"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 +875,7 @@ manifest = ModuleManifest(
|
||||
"campaigns",
|
||||
"files",
|
||||
"notifications",
|
||||
"tasks",
|
||||
),
|
||||
links=(
|
||||
DocumentationLink(
|
||||
@@ -438,6 +889,22 @@ manifest = ModuleManifest(
|
||||
kind="api",
|
||||
),
|
||||
),
|
||||
translations={
|
||||
"de": {
|
||||
"title": "Funktionsgebundene Postfächer",
|
||||
"summary": "Dauerhafte institutionelle Nachrichtencontainer, deren Zugriff den wirksamen Zuweisungen zu Organisationsfunktionen folgt.",
|
||||
"body": (
|
||||
"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 +921,7 @@ manifest = ModuleManifest(
|
||||
"Subjects, participants, routing facts, and attachment references remain observable metadata.",
|
||||
],
|
||||
},
|
||||
order=35,
|
||||
order=37,
|
||||
),
|
||||
DocumentationTopic(
|
||||
id="postbox.reference.fields-and-consequences",
|
||||
@@ -473,7 +940,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 +969,25 @@ 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": (
|
||||
"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 +997,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 +1005,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 +1013,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 +1025,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",),
|
||||
|
||||
+112
@@ -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")
|
||||
+43
@@ -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")
|
||||
+33
@@ -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,
|
||||
}
|
||||
@@ -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(
|
||||
principal,
|
||||
TEMPLATE_ADMIN_SCOPE,
|
||||
):
|
||||
actions.add("administer")
|
||||
if (
|
||||
assignment_context_id is not None
|
||||
and assignment_context_id not in principal.function_assignment_ids
|
||||
):
|
||||
try:
|
||||
return actor_from_principal(
|
||||
principal,
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
+2665
-198
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -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,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
|
||||
|
||||
+53
-13
@@ -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(
|
||||
{
|
||||
column["name"]
|
||||
for column in inspect(connection).get_columns(
|
||||
"postbox_template_revisions"
|
||||
)
|
||||
},
|
||||
"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()
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@govoplan/postbox-webui",
|
||||
"version": "0.1.15",
|
||||
"version": "0.1.19",
|
||||
"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.15",
|
||||
"@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
@@ -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) => ({
|
||||
|
||||
@@ -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,7 +240,9 @@ 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) {
|
||||
setError(errorMessage(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"
|
||||
contextActions={<div className="postbox-bar-title">
|
||||
<Inbox size={17} aria-hidden="true" />
|
||||
<strong>Postbox</strong>
|
||||
<DocumentationHelpLink reference={POSTBOX_DOCUMENTATION} />
|
||||
</div>
|
||||
<div className="postbox-icon-actions">
|
||||
<IconButton
|
||||
</div>}
|
||||
helpAction={<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>
|
||||
@@ -906,8 +1011,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 +1023,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 +1047,7 @@ export default function PostboxPage({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
<fieldset className="postbox-source-selector">
|
||||
<legend>Source postboxes</legend>
|
||||
{postboxes.map((postbox) => (
|
||||
@@ -947,6 +1055,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 +1074,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
|
||||
@@ -982,16 +1108,24 @@ export default function PostboxPage({
|
||||
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 +1210,7 @@ export default function PostboxPage({
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</FormGrid>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -1097,10 +1231,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 +1286,19 @@ function MessageDetail({
|
||||
</dl>
|
||||
</section>
|
||||
<section className="postbox-body">
|
||||
<p>{message.body_text || "No plaintext body is available for this message."}</p>
|
||||
{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 +1314,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 +1352,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));
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
+25
-4
@@ -3,10 +3,12 @@ import {
|
||||
hasScope,
|
||||
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";
|
||||
|
||||
|
||||
@@ -59,6 +61,14 @@ const postboxDashboardWidgets: DashboardWidgetsUiCapability = {
|
||||
}
|
||||
]
|
||||
};
|
||||
const postboxQuickAccessTools: QuickAccessToolsUiCapability = {
|
||||
tools: [
|
||||
{
|
||||
id: "postbox.messages",
|
||||
render: (context) => createElement(PostboxQuickAccess, context)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const postboxAdminSections: AdminSectionsUiCapability = {
|
||||
sections: [
|
||||
@@ -87,19 +97,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 +162,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
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user