39 Commits
Author SHA1 Message Date
zemion b24d291cc8 docs(postbox): complete German reference coverage
Module Package Release / publish-packages (push) Successful in 12s
2026-08-22 06:50:34 +02:00
zemion e5da713d5f feat(postbox): add governed DSAR coverage 2026-08-21 00:46:06 +02:00
zemion 8a21876634 feat(postbox): reconcile lifecycle notifications 2026-08-20 04:58:34 +02:00
zemion 41ea8d8e23 feat(postbox): enforce unified inbox separation 2026-08-20 04:22:29 +02:00
zemion 174ee97719 feat(postbox): add governed content protection profiles 2026-08-20 03:42:58 +02:00
zemion 15d93aaa25 feat(webui): complete postbox quick access 2026-08-19 19:49:00 +02:00
zemion d005065e50 refactor(webui): adopt semantic workspace actions 2026-08-19 18:47:46 +02:00
zemion ce334fee7d style: use shared WebUI foundation tokens 2026-08-18 21:32:50 +02:00
zemion 6b0e3ae37d Adopt shared WebUI structural primitives 2026-08-18 13:17:32 +02:00
zemion 225a3a233b Adopt shared WebUI layout primitives 2026-08-18 11:30:40 +02:00
zemion 570709b0bd Adopt shared WebUI layout primitives 2026-08-18 10:42:53 +02:00
zemion 11f175cbb3 feat: integrate files and portal surfaces 2026-08-07 14:53:46 +02:00
zemion 60f50f7906 Contribute Postbox to Quick Access 2026-08-06 19:02:55 +02:00
zemion 36530b6dce Project unread postbox work with shared authorization 2026-08-06 16:06:17 +02:00
zemion d107c09f74 Add scoped postbox template previews 2026-08-06 12:42:20 +02:00
zemion c53646a8aa Release v0.1.18
Module Package Release / publish-packages (push) Successful in 13s
2026-08-05 21:07:52 +02:00
zemion ae3bbfa34e Release v0.1.17
Module Package Release / publish-packages (push) Successful in 11s
2026-08-05 20:34:13 +02:00
zemion a6815a4a0a Release v0.1.16
Module Package Release / publish-packages (push) Successful in 12s
2026-08-05 19:52:30 +02:00
zemion e298a7b39d Release v0.1.15
Module Package Release / publish-packages (push) Successful in 11s
2026-08-04 15:10:27 +02:00
zemion 873be94059 Make package publication retries hash-safe 2026-08-04 14:32:20 +02:00
zemion 9c23439212 Harden module package publication 2026-08-04 14:02:41 +02:00
zemion c330d54416 Add protected package release workflow 2026-08-04 04:14:07 +02:00
zemion 889b5e61f0 Add ciphertext-safe Postbox search source 2026-08-04 03:03:27 +02:00
zemion a97eb3bcef Migrate Postbox interface patterns 2026-08-03 12:26:20 +02:00
zemion 8f8d259a76 feat: protect postbox message content 2026-08-02 03:40:57 +02:00
zemion 7d310d5c33 docs: declare institutional architecture boundary 2026-08-01 17:48:38 +02:00
zemion e3daf400f3 Add encrypted envelope recipient state 2026-07-31 22:48:07 +02:00
zemion dc6f81dd33 Add Postbox message authoring and concurrency 2026-07-31 18:40:39 +02:00
zemion 0b6ebc3c74 Complete Postbox access transition matrix 2026-07-31 18:21:36 +02:00
zemion cbeaca979d refactor: target workflow engine runtime 2026-07-31 16:59:22 +02:00
zemion 18feb9f959 Align Postbox WebUI runtime dependencies 2026-07-31 02:48:56 +02:00
zemion eb359158ad feat: classify postbox administration surfaces 2026-07-30 17:42:07 +02:00
zemion bdc161e889 feat: complete postbox access evidence 2026-07-30 14:26:59 +02:00
zemion 28b60782de feat: route bounded hierarchy postbox copies 2026-07-30 04:00:31 +02:00
zemion d848a9a503 feat: initialize governed postbox module 2026-07-29 14:16:29 +02:00
zemion 19216e5043 Refine function-bound postbox architecture 2026-07-28 19:05:41 +02:00
zemion 5d141a0eaa docs(postbox): define function-bound encrypted vault semantics 2026-07-21 20:49:20 +02:00
zemion a3c809c391 intermittent commit 2026-07-14 13:22:12 +02:00
zemion b39fbde7f8 Release v0.1.8 2026-07-11 16:49:04 +02:00
63 changed files with 27890 additions and 61 deletions
+270
View File
@@ -0,0 +1,270 @@
name: Module Package Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
release_tag:
description: Existing protected version tag to publish
required: true
type: string
jobs:
publish-packages:
runs-on: ubuntu-latest
env:
GITEA_REPOSITORY: ${{ gitea.repository }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
fetch-depth: 0
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: "3.12"
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
with:
node-version: "22"
- name: Select and validate protected release tag
shell: bash
env:
REQUESTED_TAG: ${{ inputs.release_tag }}
TRIGGER_TAG: ${{ gitea.ref_name }}
run: |
set -euo pipefail
tag="${REQUESTED_TAG:-$TRIGGER_TAG}"
case "$tag" in
v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Release tag must start with a SemVer-shaped vX.Y.Z value" >&2; exit 1 ;;
esac
git fetch --force origin "refs/tags/$tag:refs/tags/$tag" refs/heads/main:refs/remotes/origin/main
tag_commit="$(git rev-list -n 1 "$tag")"
git merge-base --is-ancestor "$tag_commit" refs/remotes/origin/main || {
echo "Release tag is not contained in main" >&2
exit 1
}
git checkout --detach "$tag"
printf 'RELEASE_TAG=%s\n' "$tag" >> "$GITEA_ENV"
printf 'SOURCE_DATE_EPOCH=%s\n' "$(git show -s --format=%ct HEAD)" >> "$GITEA_ENV"
- name: Validate package versions
run: |
python - <<'PY'
import json
from pathlib import Path
import os
import re
import tomllib
tag = os.environ["RELEASE_TAG"]
expected = tag.removeprefix("v")
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
if project.get("version") != expected:
raise SystemExit(f"pyproject version {project.get('version')!r} does not match {tag}")
if re.fullmatch(r"govoplan-[a-z0-9-]+", str(project.get("name", ""))) is None:
raise SystemExit("Python distribution name must use the govoplan-* namespace")
webui = Path("webui/package.json")
if webui.is_file():
package = json.loads(webui.read_text(encoding="utf-8"))
if package.get("version") != expected:
raise SystemExit(f"WebUI version {package.get('version')!r} does not match {tag}")
if re.fullmatch(r"@govoplan/[a-z0-9-]+-webui", str(package.get("name", ""))) is None:
raise SystemExit("WebUI package name must use the @govoplan/*-webui namespace")
release = Path("webui/package.release.json")
if release.is_file():
release_package = json.loads(release.read_text(encoding="utf-8"))
if (
release_package.get("name") != package.get("name")
or release_package.get("version") != expected
):
raise SystemExit("WebUI release package identity does not match package.json and the release tag")
PY
- name: Build immutable package artifacts
shell: bash
run: |
set -euo pipefail
python -m pip install --disable-pip-version-check build==1.5.0 twine==7.0.0
rm -rf dist .package-webui
python -m build --wheel --outdir dist
python -m twine check dist/*.whl
if [[ -f webui/package.json ]]; then
mkdir .package-webui
cp -a webui/. .package-webui/
rm -rf .package-webui/node_modules .package-webui/dist
if [[ -f .package-webui/package.release.json ]]; then
cp .package-webui/package.release.json .package-webui/package.json
fi
node <<'NODE'
const fs = require("node:fs");
const path = ".package-webui/package.json";
const packageJson = JSON.parse(fs.readFileSync(path, "utf8"));
const groups = ["dependencies", "optionalDependencies", "peerDependencies"];
for (const group of groups) {
for (const [name, specifier] of Object.entries(packageJson[group] || {})) {
if (!name.startsWith("@govoplan/")) continue;
if (typeof specifier !== "string") {
throw new Error(`${group}.${name} must use a string version`);
}
const packageSlug = name.slice("@govoplan/".length);
if (!packageSlug.endsWith("-webui")) {
throw new Error(`${group}.${name} is outside the WebUI package namespace`);
}
const repository = `govoplan-${packageSlug.slice(0, -"-webui".length)}`;
const escapedRepository = repository.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const gitTag = specifier.match(
new RegExp(
`^git\\+(?:ssh://git@|https://)git\\.add-ideas\\.de/(?:GovOPlaN|add-ideas)/${escapedRepository}\\.git#v([0-9]+\\.[0-9]+\\.[0-9]+)$`,
),
);
if (gitTag) {
packageJson[group][name] = gitTag[1];
continue;
}
if (specifier.startsWith("file:") || specifier.startsWith("git+")) {
throw new Error(
`${group}.${name} must resolve to an exact registry version for publication`,
);
}
}
}
delete packageJson.private;
fs.writeFileSync(path, `${JSON.stringify(packageJson, null, 2)}\n`);
NODE
npm pkg delete private --prefix .package-webui
(cd .package-webui && npm pack --ignore-scripts --pack-destination ../dist)
fi
python - <<'PY'
import hashlib
import json
from pathlib import Path
import os
import subprocess
artifacts = []
for path in sorted(Path("dist").iterdir()):
if path.suffix not in {".whl", ".tgz"}:
continue
digest = hashlib.sha256(path.read_bytes()).hexdigest()
artifacts.append({"filename": path.name, "sha256": digest, "size": path.stat().st_size})
payload = {
"schema_version": "1",
"repository": os.environ["GITEA_REPOSITORY"],
"tag": os.environ["RELEASE_TAG"],
"commit": subprocess.check_output(["git", "rev-parse", "HEAD"], text=True).strip(),
"artifacts": artifacts,
}
Path("dist/package-artifacts.json").write_text(
json.dumps(payload, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
PY
- name: Retain package hash evidence
uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32
with:
name: module-packages-${{ gitea.ref_name }}
path: dist/package-artifacts.json
- name: Check immutable registry state
shell: bash
env:
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_TOKEN"
python - <<'PY'
import hashlib
import json
import os
from pathlib import Path
import tomllib
from urllib.error import HTTPError
from urllib.parse import quote
from urllib.request import Request, urlopen
api_root = "https://git.add-ideas.de/api/v1/packages/GovOPlaN"
token = os.environ["PACKAGE_TOKEN"]
def should_publish(kind, name, version, path):
package_url = "/".join(
(api_root, kind, quote(name, safe=""), quote(version, safe=""), "files")
)
request = Request(
package_url,
headers={"Accept": "application/json", "Authorization": f"token {token}"},
)
try:
with urlopen(request, timeout=30) as response:
files = json.load(response)
except HTTPError as exc:
if exc.code == 404:
print(f"{kind} package {name}=={version} is not published yet")
return True
raise
if not isinstance(files, list) or len(files) != 1:
raise SystemExit(
f"immutable {kind} package {name}=={version} has an unexpected file set"
)
expected_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
if files[0].get("sha256") != expected_sha256:
raise SystemExit(
f"immutable {kind} package {name}=={version} already exists with a different SHA-256"
)
print(f"verified existing {kind} package {name}=={version} ({expected_sha256})")
return False
project = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"]
wheels = tuple(Path("dist").glob("*.whl"))
if len(wheels) != 1:
raise SystemExit("release build must contain exactly one wheel")
publish_pypi = should_publish(
"pypi", str(project["name"]), str(project["version"]), wheels[0]
)
tarballs = tuple(Path("dist").glob("*.tgz"))
if len(tarballs) > 1:
raise SystemExit("release build must contain at most one npm package")
publish_npm = False
if tarballs:
webui = json.loads(
Path(".package-webui/package.json").read_text(encoding="utf-8")
)
publish_npm = should_publish(
"npm", str(webui["name"]), str(webui["version"]), tarballs[0]
)
with Path(os.environ["GITEA_ENV"]).open("a", encoding="utf-8") as env_file:
env_file.write(f"PUBLISH_PYPI={int(publish_pypi)}\n")
env_file.write(f"PUBLISH_NPM={int(publish_npm)}\n")
PY
- name: Publish wheel and WebUI package
shell: bash
env:
PACKAGE_USERNAME: ${{ secrets.GOVOPLAN_PACKAGE_USERNAME }}
PACKAGE_TOKEN: ${{ secrets.GOVOPLAN_PACKAGE_TOKEN }}
run: |
set -euo pipefail
test -n "$PACKAGE_USERNAME"
test -n "$PACKAGE_TOKEN"
if [[ "$PUBLISH_PYPI" == 1 ]]; then
TWINE_USERNAME="$PACKAGE_USERNAME" TWINE_PASSWORD="$PACKAGE_TOKEN" \
python -m twine upload --non-interactive \
--repository-url https://git.add-ideas.de/api/packages/GovOPlaN/pypi \
dist/*.whl
else
echo "Exact wheel is already present; skipping immutable retry."
fi
shopt -s nullglob
webui_packages=(dist/*.tgz)
if (( ${#webui_packages[@]} )) && [[ "$PUBLISH_NPM" == 1 ]]; then
npmrc="$(mktemp)"
trap 'rm -f "$npmrc"' EXIT
chmod 600 "$npmrc"
printf '%s\n' \
'@govoplan:registry=https://git.add-ideas.de/api/packages/GovOPlaN/npm/' \
"//git.add-ideas.de/api/packages/GovOPlaN/npm/:_authToken=$PACKAGE_TOKEN" \
> "$npmrc"
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "./${webui_packages[0]}" \
--ignore-scripts --access public \
--registry https://git.add-ideas.de/api/packages/GovOPlaN/npm/
elif (( ${#webui_packages[@]} )); then
echo "Exact WebUI package is already present; skipping immutable retry."
fi
+13 -6
View File
@@ -1,10 +1,16 @@
# GovOPlaN Postbox Codex Guide # GovOPlaN Postbox Codex Guide
## Documentation Contract
- Treat documentation as part of every behavior change. Update this module's manifest-driven `DocumentationTopic` contributions for affected user and administrator behavior.
- Keep feature content here; `govoplan-docs` projects it without importing Postbox internals.
- Maintain a static user/admin baseline and run `/mnt/DATA/git/govoplan/tools/checks/check-manifest-shapes.py` after behavior or manifest changes.
## Scope ## Scope
This repository owns the `postbox` module: in-platform postboxes, role-organization-bound access, postbox messages, postbox directory APIs, internal and portal postbox surfaces, campaign postbox integration, postbox-owned migrations, and future `@govoplan/postbox-webui`. This repository owns the `postbox` module: in-platform postboxes, function-organization-bound access, postbox messages, postbox directory APIs, internal and portal postbox surfaces, campaign postbox integration, postbox-owned migrations, and future `@govoplan/postbox-webui`.
Postboxes are not login-bound mailboxes. They are platform-owned communication and access containers whose visibility is derived from organizational role assignments, explicit postbox bindings, and capability contracts. Postboxes are not login-bound mailboxes. They are platform-owned communication and access containers whose visibility is derived primarily from effective identity-to-organization-function assignments, explicit postbox bindings, and capability contracts.
## Local Commands ## Local Commands
@@ -18,14 +24,15 @@ cd /mnt/DATA/git/govoplan-core
For combined checks once implementation starts, run: For combined checks once implementation starts, run:
```bash ```bash
cd /mnt/DATA/git/govoplan-core cd /mnt/DATA/git/govoplan
./scripts/check-focused.sh tools/checks/check-focused.sh
``` ```
## Working Rules ## Working Rules
- Keep postbox behavior in this module, not core. - Keep postbox behavior in this module, not core.
- Derive role-organization access through core/access contracts; do not duplicate RBAC membership logic locally. - Resolve units and functions through Organizations and effective identity-to-function assignments through IDM. Use Core/Access for generic Postbox action authorization; do not turn a function assignment into an RBAC role merely to open its function postbox.
- Do not duplicate identity, organization, assignment, hierarchy, or RBAC logic locally.
- Do not import mail, files, campaign, or portal internals. Use manifests, capabilities, events, API routes, and typed DTOs. - Do not import mail, files, campaign, or portal internals. Use manifests, capabilities, events, API routes, and typed DTOs.
- Treat postbox access changes as auditable security events, especially when access changes because a role assignment changes. - Treat postbox access changes as auditable security events, especially when access changes because a function assignment, delegation, acting context, or generic Postbox permission changes.
- Keep campaign delivery, file evidence, and portal usage optional behind capability boundaries. - Keep campaign delivery, file evidence, and portal usage optional behind capability boundaries.
+127 -7
View File
@@ -1,6 +1,11 @@
# govoplan-postbox # govoplan-postbox
GovOPlaN Postbox provides platform-owned postboxes for internal work, portals, campaign flows, and role-bound organizational communication. <!-- govoplan-repository-type:start -->
**Repository type:** module (domain).
<!-- govoplan-repository-type:end -->
GovOPlaN Postbox provides platform-owned postboxes for internal work, portals,
campaign flows, and function-bound organizational communication.
## Ownership ## Ownership
@@ -9,18 +14,58 @@ This repository owns:
- backend module manifest `postbox` - backend module manifest `postbox`
- postbox permissions and policy checks - postbox permissions and policy checks
- postbox, binding, message, participant, attachment-reference, and audit-facing data models - postbox, binding, message, participant, attachment-reference, and audit-facing data models
- role-organization-bound access resolution for postboxes - function-organization-bound access resolution for postboxes through
normalized Identity, IDM, Organizations, and Core/Access contracts
- API routes for postbox directory, messages, access checks, and administration - API routes for postbox directory, messages, access checks, and administration
- optional integration capabilities for campaign, files, portal, notification, and mail-facing workflows - optional integration capabilities for campaign, files, portal, notification, and mail-facing workflows
- future WebUI package `@govoplan/postbox-webui` - inbox and tenant administration WebUI package `@govoplan/postbox-webui`
Core owns auth, tenants, RBAC evaluation, database/session primitives, module discovery, migrations, CSRF/API helpers, and shell layout. Access owns identities, users, groups, roles, memberships, and administrative RBAC surfaces. Core owns auth, tenants, RBAC evaluation, database/session primitives, module
discovery, migrations, CSRF/API helpers, and shell layout. Identity owns
identities and account links. Organizations owns units, structures, function
types, and concrete functions. IDM owns effective identity-to-function
assignments, delegation, and acting-for facts. Access owns generic application
roles, permissions, and administrative RBAC surfaces.
## Role-bound postboxes ## Function-bound postboxes
A role-bound postbox is linked to an organizational unit and one or more roles. A person can access that postbox while their identity has an effective matching role in that organizational unit. Access is not tied to a login mailbox, personal email address, or static user assignment. A function-bound postbox has a stable institutional address linked to an
organizational unit and one or more functions. It can exist with zero, one, or
several current incumbents. A person can access it only while their identity
has an effective assignment or time-bounded delegation in that organizational
context and their account may perform the relevant Postbox action. Access is
not tied to a login mailbox, personal email address, static user assignment, or
function-to-RBAC-role mapping.
When the role assignment changes, postbox access changes with it. The postbox keeps durable message and evidence history, while authorization remains derived from current platform role state. When the assignment changes, postbox access and encrypted key grants change
with it. The postbox keeps durable content and evidence history through
vacancy, hand-over, and delegation; multiple incumbents receive independently
auditable access. Revocation prevents future platform key access but cannot
erase plaintext already fetched or exported.
Reusable Postbox templates may target a function type and organization scope.
Unit-specific addresses are resolved lazily and remain stable through vacancy
and reassignment. Exact postboxes remain available for exceptional
responsibilities or case/service contexts.
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. 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
snapshotted when a message is delivered.
## Module integration ## Module integration
@@ -38,3 +83,78 @@ Frontend package:
``` ```
Platform RBAC, module capability contracts, and governance rules are documented in `govoplan-core/docs/`. Platform RBAC, module capability contracts, and governance rules are documented in `govoplan-core/docs/`.
The module's interface archetypes, consequence classes, contextual-help
contracts, and accessibility evidence are recorded in
[`docs/INTERFACE_PATTERN_MIGRATION.md`](docs/INTERFACE_PATTERN_MIGRATION.md).
## Current implementation
The first usable slice includes immutable template revisions, stable lazy
addresses, exact function-bound Postboxes, current IDM assignment access
decisions, vacancy status, idempotent producer delivery, source-preserving
message and attachment references, personal read/acknowledgement receipts,
unified inbox projections, access evidence, an inbox route, and tenant
administration. 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
API.
Vacancy escalation is a separate attention policy. It creates no personal
account grant: a durable route waits for its configured delay and then creates
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.
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 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:
```bash
/mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests
cd webui && npm run test:ui-structure
```
+54
View File
@@ -0,0 +1,54 @@
# Postbox Interface Pattern Migration
This document records the bounded migration of Postbox-owned WebUI surfaces to
the GovOPlaN interface pattern language. Core owns shared controls and host
shells. Postbox owns function-bound addresses, messages, receipts, delivery
evidence, personal inbox projections, and reusable address templates.
## Surface Inventory
| Surface | Archetype | Consequence class | Contract |
| --- | --- | --- | --- |
| `/postbox` directory | Directory and hierarchy-context selector | Change query projection | Shared selection list, explicit assignment blocker, contextual help, guarded reload |
| `/postbox` messages | Searchable work queue | Read, acknowledge, reply, or author | Shared filters/pagination/status/alerts, explained unavailable actions, privacy-safe retained states |
| Message detail and composer | Record detail and consequential editor | Deliver institutional message | Source/function provenance, classification boundary, guarded draft, stable field help |
| Unified-view dialog | Personal configuration editor | Change or delete query projection | Shared dialog/toggle/confirmation; deletion never changes source messages or evidence |
| `postbox.admin.templates` | Versioned definition library | Publish or retire immutable template revision | Shared admin layout, durable consequence explanations, guarded editor, contextual help |
| Materialized Postbox administration | Repeated administration | Create or archive durable address | Organization/function selector, prerequisite blocker, destructive confirmation, retained-evidence explanation |
| `postbox.widget.inbox` | Dashboard summary | Navigate to unread work | Shared widget loading/error/list contract and localized accessible attachment metadata |
## Consequence And Availability Rules
- A Postbox address belongs to an organization function, not to an account.
Current effective IDM assignments determine access without changing the
durable message or address record.
- Publishing freezes an immutable template revision. Retiring a template stops
future revision and materialization work but leaves existing addresses
intact.
- Archiving an address stops new delivery while preserving messages, receipts,
access events, and delivery evidence.
- Unified inbox views are personal projections. Deleting one does not move or
delete source Postboxes, messages, acknowledgements, or evidence.
- Hierarchy copies are separate, bounded deliveries. Vacancy escalation is a
separately scheduled and auditable route, not an implicit personal grant.
- Withdrawal and expiry prevent future content access while retaining only the
metadata the current actor may inspect. Already exported or printed plaintext
cannot be retracted.
- Disabled actions state whether the blocker is loading, another operation, a
missing permission, a missing assignment, an unavailable message, or a
lifecycle constraint.
## State And Accessibility Evidence
The module uses Core admin/page shells, selection lists, pagination, dialogs,
confirmations, alerts, status badges, action blockers, contextual field help,
disabled reasons, and unsaved-change guards. Shared dialogs retain keyboard
focus and return behavior. Existing responsive workspace CSS keeps directory,
message list, and detail regions bounded and independently scrollable.
English and German catalogues cover module metadata, accessible attributes,
workspaces, dialogs, fields, lifecycle states, and dashboard output. Dates use
the selected platform locale. Manifest topics publish stable route, surface,
field, blocker, privacy, and consequence references. Focused backend and WebUI
tests pin these contracts without importing optional sibling modules.
+369 -48
View File
@@ -4,54 +4,107 @@
GovOPlaN Postbox provides in-platform postboxes that are addressable containers for messages, files, workflow evidence, and operational handoff. They can be used internally, exposed through portals, and connected to campaign workflows. GovOPlaN Postbox provides in-platform postboxes that are addressable containers for messages, files, workflow evidence, and operational handoff. They can be used internally, exposed through portals, and connected to campaign workflows.
The key distinction from a mailbox is ownership. A mailbox is usually bound to a login, user credential, or external mail account. A GovOPlaN postbox is bound to platform context: organization, role, process, portal, campaign, or service responsibility. The key distinction from a mailbox is ownership. A mailbox is usually bound to
a login, user credential, or external mail account. A GovOPlaN postbox is a
durable communication and content-access container bound to institutional
context: primarily a function in an organizational unit, and where explicitly
needed a role, process, portal, campaign, or service responsibility. It may look
like an inbox for a message task or like a vault for content shared with the
current holders of that responsibility; neither form is owned by one account.
The strategic target is an encrypted administrative postbox. The first The strategic target is a policy-selectable administrative postbox. The current
implementation may start with ordinary persisted messages, but the model must implementation offers unencrypted content, an institution-managed Encryption
not prevent later end-to-end encryption, role/function key epochs, signed envelope, and a strict external E2EE boundary. E2EE messages contain only an
manifests, external-recipient tokens, or honest retraction semantics. The external ciphertext reference, signed-manifest reference, wrapped recipient
cross-module target architecture is recorded in 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`. `govoplan-core/docs/POSTBOX_E2EE_ARCHITECTURE.md`.
## Function/Role-Organization-Bound Access ## Function-Organization-Bound Access
The special access pattern is a postbox linked to an organizational unit and The primary access pattern is a postbox linked to an organizational unit and
one or more roles or functions. A person can access that postbox while their one or more institutional functions. A person can access that postbox while
account has an effective matching function assignment in that organizational their identity/account has an effective matching function assignment in that
unit, or while a matching function maps to one of the required roles. organizational unit and their account may perform the requested Postbox action.
Opening a function postbox does not require mapping the function to an RBAC
role.
Example: Example:
- Organizational unit: `District Office North` - Organizational unit: `District Office North`
- Required role: `Case Clerk` - Required function: `Case Clerk`
- Postbox: `District Office North / Case Clerk Intake` - Postbox: `District Office North / Case Clerk Intake`
Any identity/account currently holding the `Case Clerk` function or a mapped Any identity/account currently holding the `Case Clerk` function for `District
role for `District Office North` can see the postbox. When the function, Office North` can see the postbox if it also satisfies the generic Postbox
delegation, or role mapping is removed or expires, access disappears without permission and applicable policy. When the function assignment or delegation
moving messages or reassigning a mailbox. is removed or expires, access disappears without moving messages or
reassigning a mailbox.
The postbox exists independently of its holders. It remains addressable while
the function is vacant and may accept durable deliveries according to policy;
the UI must then show that no current human holder can open or act on the
content. Assigning one or several incumbents grants access in their explicit
function context. It does not transfer ownership of the container or rewrite
its history.
This makes postboxes useful for responsibilities that outlive individuals: This makes postboxes useful for responsibilities that outlive individuals:
- intake desks - intake desks
- role-based service queues - function-based service queues
- campaign sender or response desks - campaign sender or response desks
- portal message inboxes for organizational responsibilities - portal message inboxes for organizational responsibilities
- file or evidence drops linked to a role in an organization - file or evidence drops linked to a function in an organization
## Authorization Model ### Incumbency, hand-over, and delegation
Postbox authorization should be derived from access-owned identity and role data through core/access contracts. The postbox module stores postbox bindings and postbox-specific permissions, but it should not duplicate membership, group, or role resolution. - Zero, one, or several people may hold a function at the same time.
- A postbox can remain vacant without being deleted, redirected to a personal
account, or losing content.
- A new assignment can grant access to the function's permitted history through
a current key epoch. Which historical epochs a new incumbent receives is an
explicit postbox policy, not an accidental consequence of account creation.
- A delegation is a time-bounded access grant in the represented function
context. Expiry removes future platform key access and action authority; it
cannot destroy plaintext or exports already obtained.
- Hand-over and revocation rotate the function/postbox key epoch. Per-content
data keys should normally be rewrapped; policy may require content
re-encryption for a stronger rotation event.
- Stored content is not silently substituted or overwritten. A correction,
replacement, or new version is a new linked object with provenance while the
previous signed/ciphertext manifest remains governed by retention policy.
## Directory And Authorization Model
The normalized ownership boundary is:
- Organizations owns units, structures, function types, and concrete
functions.
- Identity owns identities and account links.
- IDM owns effective identity-to-function assignments, validity, delegation,
acting-for context, and assignment lifecycle facts.
- Core/Access authorizes generic Postbox actions.
- Postbox owns templates, stable addresses, containers, messages, routing,
visibility decisions, grouping preferences, and retention.
The Postbox module stores postbox bindings and postbox-specific decisions, but
it must not duplicate identity, organization, assignment, hierarchy, or RBAC
resolution.
The minimum authorization inputs are: The minimum authorization inputs are:
- postbox id - postbox id
- tenant id - tenant id
- organizational unit id - organizational unit id
- required function id, role id, or role key - required function id or function type id
- actor identity id - actor identity id
- current effective function assignments, delegations, and roles from the - current effective function assignments, delegations, and acting context from
access module IDM
- generic Postbox permissions from Core/Access
- optional explicit administrative grants for postbox administration - optional explicit administrative grants for postbox administration
The expected result is a narrow access decision: The expected result is a narrow access decision:
@@ -62,27 +115,153 @@ The expected result is a narrow access decision:
- can attach or link files - can attach or link files
- can administer bindings - can administer bindings
Access changes must be auditable because a person can gain or lose postbox visibility through role assignment changes rather than direct postbox membership edits. Access changes must be auditable because a person can gain or lose postbox
visibility through function-assignment changes rather than direct postbox
membership edits.
Runtime integration must use the access kernel capabilities: Runtime integration must use the kernel capabilities:
- `access.semanticDirectory` to inspect identity/account/function facts. - `identity.directory` to resolve identities and account links.
- `access.explanation` to attach identity/account/function/role/right - `idm.directory` to resolve effective function assignments.
provenance to access decisions. - `organizations.directory` to resolve function, function-type, unit, and
hierarchy facts.
- the Core/Access permission evaluator for generic Postbox actions.
- access explanation/audit contracts to attach permission and acting-context
provenance where available.
Postbox must not import access ORM models or duplicate function/role Postbox must not import Identity, IDM, Organizations, or Access ORM models.
resolution. Acting-in-place access should require an explicit selected acting Acting-in-place access requires an explicit selected acting context. A function
context once Access exposes that runtime selector. assignment is an organizational responsibility fact; it does not grant
unrelated application permissions.
## Templates And Stable Addresses
A reusable Postbox template can target a function type and an organization
scope, such as a unit type, structure, or subtree. Postbox resolves a stable
unit-specific address from the tenant, template revision, concrete unit,
concrete function, and optional case/service context.
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
organization/function references so later hierarchy changes do not rewrite
history.
Exact postboxes remain useful for exceptional responsibilities that do not
belong to a reusable function type.
## Unified Inbox Projections
A user with several functions can group selected visible postboxes into named
unified inbox views and keep other responsibilities separate. Grouping is a
query projection only. It never merges source containers, messages, read or
acknowledgement state, retention, encryption keys, or audit evidence.
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. 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
Hierarchy behavior is disabled by default. The system distinguishes:
- a linked copy delivered to a parent function postbox
- attention or escalation metadata sent to a parent responsibility
- shared visibility over the original message
These have different privacy, retention, acknowledgement, and audit effects
and must not be treated as synonyms.
The first production slice should implement explicit linked-copy routing with
a selected structure, target function mapping, maximum depth, stop condition,
classification gate, loop protection, and delivery-time route snapshot.
Organization changes do not retroactively expose old messages.
Vacancy is a visible delivery/attention state rather than an automatic grant
to an unrelated personal account. Policy may trigger a bounded escalation
after a delay.
The implemented policy keeps the three semantics separate:
- `linked_copy` can target the nearest matching ancestor or every bounded
matching ancestor in one selected structure.
- `attention` currently supports delayed vacancy escalation over the remaining
delivery-time target snapshot.
- `shared_visibility` remains explicitly disabled until its access and
encryption semantics are implemented.
Routing is off unless an immutable template revision enables it and supplies a
target template, target function type, producer allowlist, classification
allowlist, depth, and structure. Optional relation, stop-unit, stop-unit-type,
expiry, and maximum-retention gates narrow the route further. The dry-run API
returns blocked and unavailable candidates without materializing addresses.
Delivery materializes only frozen candidates, stores path-edge provenance, and
creates source-preserving copies with independent read and acknowledgement
receipts.
## Campaign Distribution
Campaign can use Postbox as an explicit delivery channel through
`postbox.delivery`. A campaign may select Mail, Postbox, both, or a configured
fallback order for a target. It must never switch channels silently.
Validation and build preview stable function/unit/context destinations,
vacancies, hierarchy-copy effects, classifications, and duplicates. Delivery
uses idempotency keys and returns per-target evidence. Postbox owns acceptance,
routing, message state, read/acknowledgement state, and postbox ids; Campaign
owns campaign preparation, jobs, recipient reports, and channel-attempt
evidence.
## Domain Objects ## Domain Objects
The initial domain model should stay small: The initial domain model should stay small:
- `Postbox`: the addressable container. - `PostboxTemplate` and immutable revisions: reusable function/scope
configuration.
- `PostboxAddress`: the stable tenant/function/unit/context destination.
- `Postbox`: the addressable container, materialized when needed.
- `PostboxBinding`: the binding to organization, role, portal, campaign, service, or explicit context. - `PostboxBinding`: the binding to organization, role, portal, campaign, service, or explicit context.
- `PostboxMessage`: a platform-native message or message reference. - `PostboxMessage`: a platform-native message or message reference.
- `PostboxParticipant`: normalized sender, recipient, author, or actor reference. - `PostboxParticipant`: normalized sender, recipient, author, or actor reference.
- `PostboxAttachmentRef`: reference to a file, evidence item, generated campaign artifact, or external attachment. - `PostboxAttachmentRef`: reference to a file, evidence item, generated campaign artifact, or external attachment.
- `PostboxDelivery` and `PostboxRoute`: idempotent producer acceptance and
linked copy/escalation provenance.
- `PostboxGrouping`: a per-user source-preserving inbox projection.
- `PostboxAccessEvent`: auditable record of access-affecting changes and sensitive actions. - `PostboxAccessEvent`: auditable record of access-affecting changes and sensitive actions.
Messages and files should be linked by stable ids and typed references. The postbox module should not import file, mail, or campaign internals. Messages and files should be linked by stable ids and typed references. The postbox module should not import file, mail, or campaign internals.
@@ -99,15 +278,40 @@ Postbox should expose narrow capabilities through core:
Optional consumers: Optional consumers:
- Campaign can use postboxes for campaign sender context, reply intake, review queues, and role-bound access to campaign artifacts. - Campaign can use postboxes for function-targeted delivery, sender context,
- Files can expose file references to a postbox when the actor's role grants access. reply intake, review queues, and access to campaign artifacts.
- Files can expose file references to a postbox when the actor has effective
source-postbox access.
- Portal can show portal-facing postboxes without owning the postbox access model. - 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. - 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 ## Operational Rules
- Current role state controls current access. - Current function assignment and delegation state controls current access.
- Historical message records remain durable even when no current person holds the role. - Historical message records remain durable even when no current person holds
the function; vacancy is a visible attention/access state, not a missing
postbox.
- Multiple incumbents receive independent device-bound key grants and remain
distinguishable in access and action evidence.
- Delegation start, expiry, withdrawal, key grant, and key-epoch rotation are
separate auditable events.
- 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. - 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. - 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. - Retention rules should be postbox-owned but able to reference campaign, file, and portal provenance.
@@ -117,6 +321,58 @@ Optional consumers:
key epochs, recipient device references, and external capability tokens even key epochs, recipient device references, and external capability tokens even
before full E2EE ships. before full E2EE ships.
### Retention, Audit, And Privacy
Postbox retention is owned by the postbox module because postbox messages are
platform-native communication records, not mailbox folders and not ordinary file
shares. Retention policies may reference provenance from campaign, file, portal,
mail, or workflow modules, but those modules should pass stable ids and typed
evidence references through capabilities instead of giving postbox direct access
to their internals.
The postbox module should emit audit events for:
- postbox creation, archival, and destructive retirement
- binding creation, changes, expiry, and removal
- sensitive access checks when an actor gains or loses visibility
- message creation, read/download of sensitive content, attachment linking, and
delivery handoff
- retention holds, retention expiry, export, and destruction decisions
Privacy behavior must separate current access from historical evidence. Losing
a function assignment or generic Postbox permission removes future visibility,
but it does not rewrite the fact that a person previously accessed a message or
that a message existed. Deletion and destructive retention actions must
preserve legally required audit/evidence records while removing or redacting
content according to the effective policy.
When E2EE is enabled later, retention and audit metadata must remain operable
without decrypting message content. UI copy should be honest: expiry,
withdrawal, or revocation can prevent future platform access, but it cannot
guarantee removal of plaintext already fetched, exported, printed, or delivered
outside the platform.
### Migration And Compatibility Ownership
Postbox-owned tables, DTOs, migrations, and capability names belong in
`govoplan-postbox`. Core may temporarily contain compatibility imports or
legacy migration references only when needed to keep existing installations
upgradable while code is being extracted.
Compatibility code must be narrow and documented:
- new postbox behavior is implemented in `govoplan-postbox`
- old import paths may re-export postbox DTOs or helpers during a transition,
but must not become active owners of postbox logic
- migrations that move tables to postbox ownership must preserve existing data
and have explicit downgrade/retirement notes
- optional integrations with campaign, files, portal, or mail remain capability
contracts, not direct imports
Once supported release migrations have crossed the compatibility window, legacy
core import aliases and old table ownership comments should be removed through
a normal cleanup issue.
## First Implementation Shape ## First Implementation Shape
The first implementation should define the backend manifest, permissions, DTOs, and migrations before building rich UI. A minimal API can then support directory lookup, access checks, message creation, message listing, and binding administration. The first implementation should define the backend manifest, permissions, DTOs, and migrations before building rich UI. A minimal API can then support directory lookup, access checks, message creation, message listing, and binding administration.
@@ -124,22 +380,87 @@ The first implementation should define the backend manifest, permissions, DTOs,
The WebUI should start as an administration and inbox surface: The WebUI should start as an administration and inbox surface:
- postbox directory - postbox directory
- role-bound access explanation - function-bound access explanation
- message list and message detail - message list and message detail
- binding editor for organization and role links - template and binding editor for organization/function links
- audit-visible administrative actions - audit-visible administrative actions
Campaign, files, portal, and mail behavior should arrive as optional integrations after the core postbox model is stable. Campaign, files, portal, and mail behavior should arrive as optional integrations after the core postbox model is stable.
## E2EE Readiness Checklist ### Configurable content-protection profiles
Before the data model is considered stable, verify that it can represent: 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.
- message or attachment ciphertext references - `server_envelope_v1` is the recommended standard. New local message bodies
- signed manifest references are encrypted through the optional `encryption.content_cipher` capability,
- recipient, role, or function key wrapping records stored in `body_ciphertext`, and linked to an owner-bound envelope in the
- key epoch and device-key references institution's selected vault. Authorized reads ask that capability to open
- key-fetch/access audit events the exact tenant, message, and envelope tuple. Missing Encryption, unavailable
- external recipient token state or destroyed keys, tampering, and resource mismatch fail closed.
- expiry and withdrawal state separate from deletion - `external_e2ee_v1` is a server-blind storage contract. Clear bodies are
- retention state that can operate without decrypting content 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.
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.
### Protection and hand-over policy
Each Postbox snapshots policy for the choices that cannot safely be inferred:
- 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 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.
### 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.
+22
View File
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-postbox"
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.18"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
govoplan_postbox = ["py.typed"]
[project.entry-points."govoplan.modules"]
postbox = "govoplan_postbox.backend.manifest:get_manifest"
+3
View File
@@ -0,0 +1,3 @@
"""GovOPlaN Postbox module."""
__version__ = "0.1.19"
+1
View File
@@ -0,0 +1 @@
"""Backend implementation for GovOPlaN Postbox."""
@@ -0,0 +1,325 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, Sequence
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
from govoplan_core.core.postbox import (
PostboxAccessDecisionRef,
PostboxAction,
PostboxActorRef,
PostboxBindingStatus,
normalize_postbox_classification,
)
@dataclass(frozen=True, slots=True)
class PostboxAccessContext:
postbox_id: str
postbox_active: bool
action: PostboxAction
actor: PostboxActorRef
organization_unit_id: str | None
function_id: str | None
holder_count: int
binding_status: PostboxBindingStatus
classification: str
binding_assignments: tuple[OrganizationFunctionAssignmentRef, ...]
@property
def classification_allowed(self) -> bool:
classification = normalize_postbox_classification(self.classification)
return (
classification is not None
and classification in self.actor.authorized_classifications
)
@property
def base(self) -> dict[str, object]:
return {
"action": self.action,
"postbox_id": self.postbox_id,
"organization_unit_id": self.organization_unit_id,
"function_id": self.function_id,
"holder_count": self.holder_count,
"vacant": self.holder_count == 0,
"classification": self.classification,
"classification_allowed": self.classification_allowed,
"binding_status": self.binding_status,
}
@dataclass(frozen=True, slots=True)
class AccessRule:
name: str
matches: Callable[[PostboxAccessContext], bool]
decision: Callable[[PostboxAccessContext], PostboxAccessDecisionRef]
def evaluate_postbox_access(
*,
postbox_id: str,
postbox_active: bool,
action: PostboxAction,
actor: PostboxActorRef,
organization_unit_id: str | None,
function_id: str | None,
holder_count: int,
binding_available: bool,
binding_assignments: Sequence[OrganizationFunctionAssignmentRef],
binding_status: PostboxBindingStatus | None = None,
classification: str = "internal",
) -> PostboxAccessDecisionRef:
context = PostboxAccessContext(
postbox_id=postbox_id,
postbox_active=postbox_active,
action=action,
actor=actor,
organization_unit_id=organization_unit_id,
function_id=function_id,
holder_count=holder_count,
binding_status=(
binding_status
if binding_status is not None
else "active" if binding_available else "missing"
),
classification=classification,
binding_assignments=tuple(binding_assignments),
)
for rule in ACCESS_DECISION_TABLE:
if rule.matches(context):
return rule.decision(context)
return _assignment_decision(context)
def _deny(
context: PostboxAccessContext,
*,
reason_code: str,
explanation: str,
) -> PostboxAccessDecisionRef:
return PostboxAccessDecisionRef(
allowed=False,
reason_code=reason_code,
explanation=explanation,
**context.base,
)
def _inactive(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
return _deny(
context,
reason_code="postbox_inactive",
explanation="The Postbox is not active.",
)
def _permission_missing(
context: PostboxAccessContext,
) -> PostboxAccessDecisionRef:
return _deny(
context,
reason_code="generic_permission_missing",
explanation=(
"The account lacks the generic Postbox permission for this action."
),
)
def _administrator(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
return PostboxAccessDecisionRef(
allowed=True,
reason_code="generic_administrator",
explanation="The account has Postbox administration permission.",
**context.base,
)
def _binding_missing(context: PostboxAccessContext) -> PostboxAccessDecisionRef:
reasons = {
"missing": (
"function_binding_missing",
"This Postbox has no current organization-function binding.",
),
"not_effective": (
"function_binding_not_effective",
"The organization-function binding is not currently effective.",
),
"unit_missing": (
"organization_unit_missing",
"The organization unit bound to this Postbox no longer exists.",
),
"unit_inactive": (
"organization_unit_inactive",
"The organization unit bound to this Postbox is inactive.",
),
"unit_tenant_mismatch": (
"organization_unit_tenant_mismatch",
"The bound organization unit belongs to another tenant.",
),
"function_missing": (
"organization_function_missing",
"The organization function bound to this Postbox no longer exists.",
),
"function_inactive": (
"organization_function_inactive",
"The organization function bound to this Postbox is inactive.",
),
"function_tenant_mismatch": (
"organization_function_tenant_mismatch",
"The bound organization function belongs to another tenant.",
),
"function_reassigned": (
"organization_function_reassigned",
"The bound function no longer belongs to the bound organization unit.",
),
"directory_unavailable": (
"organization_directory_unavailable",
"The organization facts required for this access decision are unavailable.",
),
}
reason_code, explanation = reasons.get(
context.binding_status,
reasons["missing"],
)
return _deny(
context,
reason_code=reason_code,
explanation=explanation,
)
def _classification_denied(
context: PostboxAccessContext,
) -> PostboxAccessDecisionRef:
if normalize_postbox_classification(context.classification) is None:
return _deny(
context,
reason_code="classification_unsupported",
explanation="The Postbox uses an unsupported classification.",
)
return _deny(
context,
reason_code="classification_clearance_missing",
explanation=(
"The account is not authorized for this Postbox classification."
),
)
def _assignment_decision(
context: PostboxAccessContext,
) -> PostboxAccessDecisionRef:
eligible: list[OrganizationFunctionAssignmentRef] = []
acting_candidates: list[OrganizationFunctionAssignmentRef] = []
for assignment in context.binding_assignments:
if assignment.source != "acting_for":
eligible.append(assignment)
continue
acting_candidates.append(assignment)
if context.actor.selected_assignment_id != assignment.id:
continue
if (
assignment.acting_for_account_id
and context.actor.acting_for_account_id
!= assignment.acting_for_account_id
):
return _deny(
context,
reason_code="acting_account_mismatch",
explanation=(
"The selected acting assignment belongs to another "
"represented account context."
),
)
eligible.append(assignment)
if not eligible:
if acting_candidates and not context.actor.selected_assignment_id:
return _deny(
context,
reason_code="acting_context_required",
explanation=(
"Select the acting assignment context before opening this "
"Postbox."
),
)
if acting_candidates:
return _deny(
context,
reason_code="acting_assignment_not_selected",
explanation=(
"The selected assignment context does not grant access to "
"this Postbox."
),
)
return _deny(
context,
reason_code="effective_assignment_missing",
explanation=(
"No current function assignment grants this account access "
"to the Postbox."
),
)
selected = next(
(
assignment
for assignment in eligible
if assignment.id == context.actor.selected_assignment_id
),
eligible[0],
)
return PostboxAccessDecisionRef(
allowed=True,
reason_code=f"effective_{selected.source}_assignment",
explanation=(
"Access follows the current effective organization-function "
f"assignment ({selected.source})."
),
assignment_ids=tuple(item.id for item in eligible),
assignment_sources=tuple(item.source for item in eligible),
selected_assignment_id=selected.id,
**context.base,
)
ACCESS_DECISION_TABLE = (
AccessRule(
name="inactive_postbox",
matches=lambda context: not context.postbox_active,
decision=_inactive,
),
AccessRule(
name="generic_permission",
matches=lambda context: context.action not in context.actor.authorized_actions,
decision=_permission_missing,
),
AccessRule(
name="administrator",
matches=lambda context: context.action == "administer",
decision=_administrator,
),
AccessRule(
name="classification_clearance",
matches=lambda context: not context.classification_allowed,
decision=_classification_denied,
),
AccessRule(
name="active_function_binding",
matches=lambda context: (
context.binding_status != "active"
or not context.function_id
or not context.organization_unit_id
),
decision=_binding_missing,
),
)
__all__ = [
"ACCESS_DECISION_TABLE",
"AccessRule",
"PostboxAccessContext",
"evaluate_postbox_access",
]
@@ -0,0 +1,98 @@
from __future__ import annotations
from govoplan_core.core.encryption import (
ContentProtectionRequest,
ContentUnprotectionRequest,
ProtectedContent,
encryption_content_cipher,
)
from govoplan_postbox.backend.runtime import get_registry
POSTBOX_PLAINTEXT_PROFILE = "plaintext_v1"
POSTBOX_SERVER_ENVELOPE_PROFILE = "server_envelope_v1"
class PostboxContentProtectionError(ValueError):
pass
def protect_message_body(
session: object,
*,
tenant_id: str,
message_id: str,
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:
raise PostboxContentProtectionError(
"Postbox encryption was requested, but Encryption is unavailable."
)
try:
return capability.protect_content(
session,
request=ContentProtectionRequest(
tenant_id=tenant_id,
owner_module="postbox",
resource_type="postbox_message_body",
resource_id=message_id,
profile_id=POSTBOX_SERVER_ENVELOPE_PROFILE,
vault_id=vault_id,
ciphertext_ref=f"postbox-db://messages/{message_id}/body",
plaintext=plaintext.encode("utf-8"),
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"},
),
)
except Exception as exc:
raise PostboxContentProtectionError(
"Postbox message content could not be protected by its configured vault."
) from exc
def unprotect_message_body(
session: object,
*,
tenant_id: str,
message_id: str,
envelope_id: str,
ciphertext: bytes,
) -> str:
capability = encryption_content_cipher(get_registry())
if capability is None:
raise PostboxContentProtectionError(
"This Postbox message is encrypted and Encryption is unavailable."
)
try:
plaintext = capability.unprotect_content(
session,
request=ContentUnprotectionRequest(
tenant_id=tenant_id,
owner_module="postbox",
resource_type="postbox_message_body",
resource_id=message_id,
envelope_id=envelope_id,
ciphertext=ciphertext,
),
)
return plaintext.decode("utf-8")
except Exception as exc:
raise PostboxContentProtectionError(
"Postbox message content could not be opened with its protection envelope."
) from exc
__all__ = [
"POSTBOX_PLAINTEXT_PROFILE",
"POSTBOX_SERVER_ENVELOPE_PROFILE",
"PostboxContentProtectionError",
"protect_message_body",
"unprotect_message_body",
]
@@ -0,0 +1,37 @@
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAccessEvent,
PostboxAddress,
PostboxAttachmentReference,
PostboxBinding,
PostboxDelivery,
PostboxGrouping,
PostboxGroupingSource,
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxProtectionTransition,
PostboxProtectionTransitionItem,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
)
__all__ = [
"Postbox",
"PostboxAccessEvent",
"PostboxAddress",
"PostboxAttachmentReference",
"PostboxBinding",
"PostboxDelivery",
"PostboxGrouping",
"PostboxGroupingSource",
"PostboxMessage",
"PostboxMessageReceipt",
"PostboxParticipant",
"PostboxProtectionTransition",
"PostboxProtectionTransitionItem",
"PostboxRoute",
"PostboxTemplate",
"PostboxTemplateRevision",
]
File diff suppressed because it is too large Load Diff
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",
]
@@ -0,0 +1,348 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Mapping
from govoplan_core.core.idm import (
IdmFunctionAssignmentDirectory,
OrganizationFunctionAssignmentRef,
)
from govoplan_core.core.organizations import (
OrganizationFunctionRef,
OrganizationHierarchyDirectory,
OrganizationHierarchyEdgeRef,
OrganizationUnitRef,
)
from govoplan_postbox.backend.schemas import PostboxRoutingPolicyPayload
@dataclass(frozen=True, slots=True)
class HierarchyRouteCandidate:
depth: int
unit: OrganizationUnitRef
function: OrganizationFunctionRef | None
holders: tuple[OrganizationFunctionAssignmentRef, ...]
status: str
path: tuple[Mapping[str, object], ...]
diagnostics: tuple[str, ...] = ()
@property
def holder_count(self) -> int:
return len(
{
holder.identity_id or holder.account_id or holder.id
for holder in self.holders
}
)
@dataclass(frozen=True, slots=True)
class HierarchyRoutePlan:
status: str
policy: Mapping[str, object]
candidates: tuple[HierarchyRouteCandidate, ...] = ()
diagnostics: tuple[str, ...] = ()
def normalized_routing_policy(
value: Mapping[str, object] | None,
) -> dict[str, object]:
return PostboxRoutingPolicyPayload.model_validate(value or {}).model_dump(
mode="json"
)
def plan_hierarchy_routes(
*,
hierarchy: OrganizationHierarchyDirectory | None,
incumbencies: IdmFunctionAssignmentDirectory,
tenant_id: str,
source_unit_id: str | None,
routing_policy: Mapping[str, object] | None,
producer_module: str,
classification: str,
expires_at: datetime | None,
now: datetime,
) -> HierarchyRoutePlan:
policy = normalized_routing_policy(routing_policy)
linked_copy = _mapping(policy.get("linked_copy"))
if not linked_copy.get("enabled"):
return HierarchyRoutePlan(
status="disabled",
policy=policy,
diagnostics=("hierarchy_routing_disabled",),
)
if hierarchy is None:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=("organization_hierarchy_unavailable",),
)
if not source_unit_id:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=("source_organization_unit_missing",),
)
diagnostics = list(
_policy_gate_diagnostics(
linked_copy,
producer_module=producer_module,
classification=classification,
expires_at=expires_at,
now=now,
)
)
if diagnostics:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=tuple(diagnostics),
)
structure_id = str(linked_copy["structure_id"])
relation_type_ids = tuple(
str(value) for value in linked_copy.get("relation_type_ids", ())
)
max_depth = int(linked_copy["max_depth"])
try:
resolutions = hierarchy.resolve_hierarchy_relatives(
tenant_id,
(source_unit_id,),
structure_id=structure_id,
relation_type_ids=relation_type_ids,
direction="ancestors",
max_depth=max_depth,
)
except ValueError as exc:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=(f"hierarchy_request_invalid:{exc}",),
)
if not resolutions:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=("hierarchy_resolution_missing",),
)
resolution = resolutions[0]
diagnostics.extend(resolution.diagnostics)
if resolution.cycle_detected:
diagnostics.append("hierarchy_cycle_bounded")
if resolution.depth_limited:
diagnostics.append("hierarchy_depth_limited")
if resolution.status != "active":
diagnostics.append(f"hierarchy_{resolution.status}")
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=tuple(dict.fromkeys(diagnostics)),
)
matches = []
stop_unit_id = linked_copy.get("stop_unit_id")
stop_unit_type_id = linked_copy.get("stop_unit_type_id")
for match in sorted(
resolution.matches,
key=lambda item: (item.depth, item.unit.name, item.unit.id),
):
matches.append(match)
if (
stop_unit_id
and match.unit.id == stop_unit_id
or stop_unit_type_id
and match.unit.unit_type_id == stop_unit_type_id
):
diagnostics.append("hierarchy_stop_reached")
break
if not matches:
return HierarchyRoutePlan(
status="no_route",
policy=policy,
diagnostics=tuple(
dict.fromkeys((*diagnostics, "no_hierarchy_ancestor"))
),
)
unit_ids = tuple(dict.fromkeys(match.unit.id for match in matches))
target_function_type_id = str(linked_copy["target_function_type_id"])
try:
function_resolution = hierarchy.resolve_functions_by_type(
tenant_id,
target_function_type_id,
organization_unit_ids=unit_ids,
)
except ValueError as exc:
return HierarchyRoutePlan(
status="blocked",
policy=policy,
diagnostics=tuple(
dict.fromkeys(
(*diagnostics, f"function_resolution_invalid:{exc}")
)
),
)
diagnostics.extend(function_resolution.diagnostics)
if function_resolution.status != "active":
diagnostics.append(
f"target_function_type_{function_resolution.status}"
)
functions_by_unit: dict[str, list[OrganizationFunctionRef]] = {}
for function in function_resolution.matches:
if function.status != "active":
continue
functions_by_unit.setdefault(
function.organization_unit_id,
[],
).append(function)
function_ids = tuple(
function.id
for functions in functions_by_unit.values()
if len(functions) == 1
for function in functions
)
try:
holder_map = (
incumbencies.organization_function_incumbencies(
function_ids,
tenant_id=tenant_id,
)
if function_ids
else {}
)
except ValueError:
holder_map = {}
diagnostics.append("target_incumbency_unavailable")
candidates: list[HierarchyRouteCandidate] = []
seen_targets: set[tuple[str, str]] = set()
for match in matches:
functions = functions_by_unit.get(match.unit.id, [])
candidate_diagnostics: list[str] = []
function = functions[0] if len(functions) == 1 else None
if match.unit.status != "active":
status = "unit_inactive"
candidate_diagnostics.append("target_unit_inactive")
elif not functions:
status = "function_missing"
candidate_diagnostics.append("target_function_missing")
elif len(functions) > 1:
status = "function_ambiguous"
candidate_diagnostics.append("target_function_ambiguous")
elif (match.unit.id, function.id) in seen_targets:
status = "duplicate"
candidate_diagnostics.append("duplicate_target_suppressed")
else:
seen_targets.add((match.unit.id, function.id))
holders = tuple(
holder_map.get(function.id).assignments
if function.id in holder_map
else ()
)
status = "available" if holders else "vacant"
candidates.append(
HierarchyRouteCandidate(
depth=match.depth,
unit=match.unit,
function=function,
holders=holders,
status=status,
path=tuple(_edge_snapshot(edge) for edge in match.path),
)
)
continue
candidates.append(
HierarchyRouteCandidate(
depth=match.depth,
unit=match.unit,
function=function,
holders=(),
status=status,
path=tuple(_edge_snapshot(edge) for edge in match.path),
diagnostics=tuple(candidate_diagnostics),
)
)
routable = [
candidate
for candidate in candidates
if candidate.status in {"available", "vacant"}
]
status = "planned" if routable else "no_route"
if not routable:
diagnostics.append("no_available_hierarchy_target")
return HierarchyRoutePlan(
status=status,
policy=policy,
candidates=tuple(candidates),
diagnostics=tuple(dict.fromkeys(diagnostics)),
)
def _policy_gate_diagnostics(
linked_copy: Mapping[str, object],
*,
producer_module: str,
classification: str,
expires_at: datetime | None,
now: datetime,
) -> tuple[str, ...]:
diagnostics: list[str] = []
classifications = {
str(value) for value in linked_copy.get("allowed_classifications", ())
}
if classification not in classifications:
diagnostics.append("classification_not_allowed")
producers = {
str(value) for value in linked_copy.get("allowed_producer_modules", ())
}
if producer_module not in producers and "*" not in producers:
diagnostics.append("producer_not_authorized")
normalized_expiry = _as_utc(expires_at) if expires_at else None
if linked_copy.get("require_expiry") and normalized_expiry is None:
diagnostics.append("expiry_required")
max_retention_days = linked_copy.get("max_retention_days")
if (
normalized_expiry is not None
and max_retention_days is not None
and normalized_expiry
> _as_utc(now) + timedelta(days=int(max_retention_days))
):
diagnostics.append("retention_limit_exceeded")
if normalized_expiry is not None and normalized_expiry <= _as_utc(now):
diagnostics.append("message_already_expired")
return tuple(diagnostics)
def _edge_snapshot(edge: OrganizationHierarchyEdgeRef) -> dict[str, object]:
return {
"edge_id": edge.id,
"structure_id": edge.structure.id,
"structure_slug": edge.structure.slug,
"relation_type_id": edge.relation_type.id,
"relation_type_slug": edge.relation_type.slug,
"source_unit_id": edge.source_unit_id,
"target_unit_id": edge.target_unit_id,
}
def _mapping(value: object) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
__all__ = [
"HierarchyRouteCandidate",
"HierarchyRoutePlan",
"normalized_routing_policy",
"plan_hierarchy_routes",
]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1 @@
"""Postbox-owned Alembic migrations."""
@@ -0,0 +1 @@
"""Postbox release migration lineage."""
@@ -0,0 +1,33 @@
"""v0.1.3 external recipient token state
Revision ID: a6d9e1f4c8b3
Revises: f5c8d0e3b7a2
Create Date: 2026-07-31 18:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a6d9e1f4c8b3"
down_revision = "f5c8d0e3b7a2"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_messages") as batch_op:
batch_op.add_column(
sa.Column(
"external_recipient_tokens",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'"),
)
)
def downgrade() -> None:
with op.batch_alter_table("postbox_messages") as batch_op:
batch_op.drop_column("external_recipient_tokens")
@@ -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,798 @@
"""v0.1.0 Postbox baseline
Revision ID: c7d2e5f8a1b4
Revises: None
Depends on: 8f9a0b1c2d3e
Create Date: 2026-07-28 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "c7d2e5f8a1b4"
down_revision = None
branch_labels = None
depends_on = "8f9a0b1c2d3e"
def _timestamps() -> tuple[sa.Column, sa.Column]:
return (
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
)
def upgrade() -> None:
op.create_table(
"postbox_templates",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("slug", sa.String(length=120), nullable=False),
sa.Column("name", sa.String(length=250), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False),
sa.Column("current_revision", sa.Integer(), nullable=False),
sa.Column("published_revision_id", sa.String(length=36), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("retired_at", sa.DateTime(timezone=True), nullable=True),
*_timestamps(),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_templates")),
sa.UniqueConstraint(
"tenant_id",
"slug",
name="uq_postbox_templates_tenant_slug",
),
)
op.create_index(
op.f("ix_postbox_templates_tenant_id"),
"postbox_templates",
["tenant_id"],
)
op.create_index(
op.f("ix_postbox_templates_status"),
"postbox_templates",
["status"],
)
op.create_index(
op.f("ix_postbox_templates_published_revision_id"),
"postbox_templates",
["published_revision_id"],
)
op.create_index(
"ix_postbox_templates_tenant_status",
"postbox_templates",
["tenant_id", "status"],
)
op.create_table(
"postbox_template_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("template_id", sa.String(length=36), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("function_type_id", sa.String(length=36), nullable=True),
sa.Column("scope_kind", sa.String(length=30), nullable=False),
sa.Column("scope_id", sa.String(length=255), nullable=True),
sa.Column("name_pattern", sa.String(length=500), nullable=False),
sa.Column("address_pattern", sa.String(length=500), nullable=False),
sa.Column("classification", sa.String(length=50), nullable=False),
sa.Column("allow_vacant_delivery", sa.Boolean(), nullable=False),
sa.Column("encryption_profile", sa.String(length=80), nullable=False),
sa.Column("history_policy", sa.JSON(), nullable=False),
sa.Column("routing_policy", sa.JSON(), nullable=False),
sa.Column("retention_policy", sa.JSON(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
*_timestamps(),
sa.ForeignKeyConstraint(
["template_id"],
["postbox_templates.id"],
name=op.f(
"fk_postbox_template_revisions_template_id_postbox_templates"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_postbox_template_revisions"),
),
sa.UniqueConstraint(
"template_id",
"revision",
name="uq_postbox_template_revision_number",
),
)
for column in (
"tenant_id",
"template_id",
"function_type_id",
"scope_id",
"published_at",
):
op.create_index(
op.f(f"ix_postbox_template_revisions_{column}"),
"postbox_template_revisions",
[column],
)
op.create_index(
"ix_postbox_template_revisions_function_scope",
"postbox_template_revisions",
["tenant_id", "function_type_id", "scope_kind", "scope_id"],
)
op.create_table(
"postbox_addresses",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("address_key", sa.String(length=500), nullable=False),
sa.Column("address", sa.String(length=500), nullable=False),
sa.Column("template_id", sa.String(length=36), nullable=True),
sa.Column("template_revision_id", sa.String(length=36), nullable=True),
sa.Column("organization_unit_id", sa.String(length=36), nullable=True),
sa.Column("organization_unit_name", sa.String(length=500), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=True),
sa.Column("function_name", sa.String(length=500), nullable=True),
sa.Column("function_type_id", sa.String(length=36), nullable=True),
sa.Column("context_key", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["template_id"],
["postbox_templates.id"],
name=op.f("fk_postbox_addresses_template_id_postbox_templates"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["template_revision_id"],
["postbox_template_revisions.id"],
name=op.f(
"fk_postbox_addresses_template_revision_id_postbox_template_revisions"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_addresses")),
sa.UniqueConstraint(
"tenant_id",
"address_key",
name="uq_postbox_addresses_tenant_key",
),
sa.UniqueConstraint(
"tenant_id",
"address",
name="uq_postbox_addresses_tenant_address",
),
)
for column in (
"tenant_id",
"template_id",
"template_revision_id",
"organization_unit_id",
"function_id",
"function_type_id",
"context_key",
"status",
):
op.create_index(
op.f(f"ix_postbox_addresses_{column}"),
"postbox_addresses",
[column],
)
op.create_index(
"ix_postbox_addresses_function_scope",
"postbox_addresses",
[
"tenant_id",
"organization_unit_id",
"function_id",
"context_key",
],
)
op.create_table(
"postboxes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("address_id", sa.String(length=36), nullable=False),
sa.Column("name", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=24), nullable=False),
sa.Column("classification", sa.String(length=50), nullable=False),
sa.Column("encryption_profile", sa.String(length=80), nullable=False),
sa.Column("key_epoch", sa.Integer(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
*_timestamps(),
sa.ForeignKeyConstraint(
["address_id"],
["postbox_addresses.id"],
name=op.f("fk_postboxes_address_id_postbox_addresses"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postboxes")),
sa.UniqueConstraint("address_id", name="uq_postboxes_address"),
)
for column in ("tenant_id", "address_id", "status", "classification"):
op.create_index(
op.f(f"ix_postboxes_{column}"),
"postboxes",
[column],
)
op.create_index(
"ix_postboxes_tenant_status",
"postboxes",
["tenant_id", "status"],
)
op.create_table(
"postbox_bindings",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("postbox_id", sa.String(length=36), nullable=False),
sa.Column("binding_type", sa.String(length=30), nullable=False),
sa.Column("organization_unit_id", sa.String(length=36), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=True),
sa.Column("function_type_id", sa.String(length=36), nullable=True),
sa.Column("applies_to_subunits", sa.Boolean(), nullable=False),
sa.Column("source", sa.String(length=30), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False),
sa.Column("valid_from", sa.DateTime(timezone=True), nullable=True),
sa.Column("valid_until", sa.DateTime(timezone=True), nullable=True),
sa.Column("settings", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_bindings_postbox_id_postboxes"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_bindings")),
)
for column in (
"tenant_id",
"postbox_id",
"organization_unit_id",
"function_id",
"function_type_id",
"is_active",
):
op.create_index(
op.f(f"ix_postbox_bindings_{column}"),
"postbox_bindings",
[column],
)
op.create_index(
"ix_postbox_bindings_function_scope",
"postbox_bindings",
[
"tenant_id",
"organization_unit_id",
"function_id",
"is_active",
],
)
op.create_index(
"ix_postbox_bindings_postbox_active",
"postbox_bindings",
["postbox_id", "is_active"],
)
op.create_table(
"postbox_messages",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("postbox_id", sa.String(length=36), nullable=False),
sa.Column("subject", sa.String(length=1000), nullable=False),
sa.Column("body_text", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=30), nullable=False),
sa.Column("classification", sa.String(length=50), nullable=False),
sa.Column("sender_label", sa.String(length=500), nullable=True),
sa.Column("producer_module", sa.String(length=100), nullable=True),
sa.Column("producer_resource_type", sa.String(length=100), nullable=True),
sa.Column("producer_resource_id", sa.String(length=255), nullable=True),
sa.Column("in_reply_to_message_id", sa.String(length=36), nullable=True),
sa.Column("replaces_message_id", sa.String(length=36), nullable=True),
sa.Column("encryption_profile", sa.String(length=80), nullable=False),
sa.Column("key_epoch", sa.Integer(), nullable=False),
sa.Column("ciphertext_ref", sa.String(length=1000), nullable=True),
sa.Column("signed_manifest_ref", sa.String(length=1000), nullable=True),
sa.Column("wrapped_keys", sa.JSON(), nullable=False),
sa.Column("delivered_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("withdrawn_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"retention_hold_until",
sa.DateTime(timezone=True),
nullable=True,
),
sa.Column("metadata", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_messages_postbox_id_postboxes"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["in_reply_to_message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_messages_in_reply_to_message_id_postbox_messages"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["replaces_message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_messages_replaces_message_id_postbox_messages"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_messages")),
)
for column in (
"tenant_id",
"postbox_id",
"status",
"classification",
"producer_module",
"producer_resource_type",
"producer_resource_id",
"in_reply_to_message_id",
"replaces_message_id",
"delivered_at",
"expires_at",
"withdrawn_at",
):
op.create_index(
op.f(f"ix_postbox_messages_{column}"),
"postbox_messages",
[column],
)
op.create_index(
"ix_postbox_messages_postbox_delivered",
"postbox_messages",
["postbox_id", "delivered_at"],
)
op.create_index(
"ix_postbox_messages_tenant_status",
"postbox_messages",
["tenant_id", "status"],
)
op.create_index(
"ix_postbox_messages_producer",
"postbox_messages",
[
"tenant_id",
"producer_module",
"producer_resource_type",
"producer_resource_id",
],
)
op.create_table(
"postbox_participants",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("message_id", sa.String(length=36), nullable=False),
sa.Column("kind", sa.String(length=30), nullable=False),
sa.Column("reference_type", sa.String(length=50), nullable=False),
sa.Column("reference_id", sa.String(length=255), nullable=True),
sa.Column("label", sa.String(length=500), nullable=True),
sa.Column("address", sa.String(length=500), nullable=True),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_participants_message_id_postbox_messages"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_participants")),
)
for column in ("tenant_id", "message_id", "reference_id"):
op.create_index(
op.f(f"ix_postbox_participants_{column}"),
"postbox_participants",
[column],
)
op.create_index(
"ix_postbox_participants_message_kind",
"postbox_participants",
["message_id", "kind"],
)
op.create_table(
"postbox_attachment_references",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("message_id", sa.String(length=36), nullable=False),
sa.Column("reference_type", sa.String(length=50), nullable=False),
sa.Column("reference_id", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=1000), nullable=True),
sa.Column("media_type", sa.String(length=255), nullable=True),
sa.Column("size_bytes", sa.Integer(), nullable=True),
sa.Column("digest", sa.String(length=255), nullable=True),
sa.Column("ciphertext_ref", sa.String(length=1000), nullable=True),
sa.Column("position", sa.Integer(), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_attachment_references_message_id_postbox_messages"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_postbox_attachment_references"),
),
)
for column in ("tenant_id", "message_id"):
op.create_index(
op.f(f"ix_postbox_attachment_references_{column}"),
"postbox_attachment_references",
[column],
)
op.create_index(
"ix_postbox_attachment_references_target",
"postbox_attachment_references",
["tenant_id", "reference_type", "reference_id"],
)
op.create_table(
"postbox_deliveries",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("postbox_id", sa.String(length=36), nullable=False),
sa.Column("message_id", sa.String(length=36), nullable=False),
sa.Column("producer_module", sa.String(length=100), nullable=False),
sa.Column("producer_resource_type", sa.String(length=100), nullable=False),
sa.Column("producer_resource_id", sa.String(length=255), nullable=True),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("template_revision_id", sa.String(length=36), nullable=True),
sa.Column("organization_unit_id", sa.String(length=36), nullable=True),
sa.Column("function_id", sa.String(length=36), nullable=True),
sa.Column("holder_count", sa.Integer(), nullable=False),
sa.Column("target_snapshot", sa.JSON(), nullable=False),
sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("metadata", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_deliveries_postbox_id_postboxes"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_deliveries_message_id_postbox_messages"
),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_deliveries")),
sa.UniqueConstraint(
"tenant_id",
"producer_module",
"idempotency_key",
name="uq_postbox_deliveries_producer_idempotency",
),
)
for column in (
"tenant_id",
"postbox_id",
"message_id",
"producer_resource_id",
"status",
"template_revision_id",
"organization_unit_id",
"function_id",
"accepted_at",
):
op.create_index(
op.f(f"ix_postbox_deliveries_{column}"),
"postbox_deliveries",
[column],
)
op.create_index(
"ix_postbox_deliveries_postbox_status",
"postbox_deliveries",
["postbox_id", "status"],
)
op.create_index(
"ix_postbox_deliveries_producer",
"postbox_deliveries",
[
"tenant_id",
"producer_module",
"producer_resource_type",
"producer_resource_id",
],
)
op.create_table(
"postbox_routes",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("delivery_id", sa.String(length=36), nullable=False),
sa.Column("source_postbox_id", sa.String(length=36), nullable=False),
sa.Column("source_message_id", sa.String(length=36), nullable=False),
sa.Column("target_postbox_id", sa.String(length=36), nullable=True),
sa.Column("target_message_id", sa.String(length=36), nullable=True),
sa.Column("route_kind", sa.String(length=40), nullable=False),
sa.Column("status", sa.String(length=40), nullable=False),
sa.Column("depth", sa.Integer(), nullable=False),
sa.Column("source_route_id", sa.String(length=36), nullable=True),
sa.Column("policy_snapshot", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["delivery_id"],
["postbox_deliveries.id"],
name=op.f("fk_postbox_routes_delivery_id_postbox_deliveries"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["source_postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_routes_source_postbox_id_postboxes"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["source_message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_routes_source_message_id_postbox_messages"
),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["target_postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_routes_target_postbox_id_postboxes"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["target_message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_routes_target_message_id_postbox_messages"
),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["source_route_id"],
["postbox_routes.id"],
name=op.f("fk_postbox_routes_source_route_id_postbox_routes"),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_routes")),
)
for column in ("tenant_id", "delivery_id", "target_postbox_id"):
op.create_index(
op.f(f"ix_postbox_routes_{column}"),
"postbox_routes",
[column],
)
op.create_index(
"ix_postbox_routes_delivery_kind",
"postbox_routes",
["delivery_id", "route_kind"],
)
op.create_index(
"ix_postbox_routes_target",
"postbox_routes",
["tenant_id", "target_postbox_id"],
)
op.create_table(
"postbox_message_receipts",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("message_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=255), nullable=False),
sa.Column("identity_id", sa.String(length=255), nullable=True),
sa.Column("assignment_id", sa.String(length=36), nullable=True),
sa.Column("read_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("acknowledged_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("metadata", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_message_receipts_message_id_postbox_messages"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_postbox_message_receipts"),
),
sa.UniqueConstraint(
"tenant_id",
"message_id",
"account_id",
name="uq_postbox_receipts_message_account",
),
)
for column in (
"tenant_id",
"message_id",
"account_id",
"identity_id",
"assignment_id",
):
op.create_index(
op.f(f"ix_postbox_message_receipts_{column}"),
"postbox_message_receipts",
[column],
)
op.create_index(
"ix_postbox_receipts_account_state",
"postbox_message_receipts",
[
"tenant_id",
"account_id",
"read_at",
"acknowledged_at",
],
)
op.create_table(
"postbox_groupings",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("account_id", sa.String(length=255), nullable=False),
sa.Column("name", sa.String(length=250), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
*_timestamps(),
sa.PrimaryKeyConstraint("id", name=op.f("pk_postbox_groupings")),
sa.UniqueConstraint(
"tenant_id",
"account_id",
"name",
name="uq_postbox_groupings_account_name",
),
)
for column in ("tenant_id", "account_id"):
op.create_index(
op.f(f"ix_postbox_groupings_{column}"),
"postbox_groupings",
[column],
)
op.create_index(
"ix_postbox_groupings_account",
"postbox_groupings",
["tenant_id", "account_id"],
)
op.create_table(
"postbox_grouping_sources",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("grouping_id", sa.String(length=36), nullable=False),
sa.Column("postbox_id", sa.String(length=36), nullable=False),
sa.Column("position", sa.Integer(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["grouping_id"],
["postbox_groupings.id"],
name=op.f(
"fk_postbox_grouping_sources_grouping_id_postbox_groupings"
),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["postbox_id"],
["postboxes.id"],
name=op.f(
"fk_postbox_grouping_sources_postbox_id_postboxes"
),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_postbox_grouping_sources"),
),
sa.UniqueConstraint(
"grouping_id",
"postbox_id",
name="uq_postbox_grouping_sources_postbox",
),
)
for column in ("tenant_id", "grouping_id", "postbox_id"):
op.create_index(
op.f(f"ix_postbox_grouping_sources_{column}"),
"postbox_grouping_sources",
[column],
)
op.create_table(
"postbox_access_events",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=36), nullable=False),
sa.Column("postbox_id", sa.String(length=36), nullable=True),
sa.Column("message_id", sa.String(length=36), nullable=True),
sa.Column("account_id", sa.String(length=255), nullable=True),
sa.Column("identity_id", sa.String(length=255), nullable=True),
sa.Column("assignment_id", sa.String(length=36), nullable=True),
sa.Column("action", sa.String(length=80), nullable=False),
sa.Column("outcome", sa.String(length=30), nullable=False),
sa.Column("reason_code", sa.String(length=80), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("details", sa.JSON(), nullable=False),
*_timestamps(),
sa.ForeignKeyConstraint(
["postbox_id"],
["postboxes.id"],
name=op.f("fk_postbox_access_events_postbox_id_postboxes"),
ondelete="SET NULL",
),
sa.ForeignKeyConstraint(
["message_id"],
["postbox_messages.id"],
name=op.f(
"fk_postbox_access_events_message_id_postbox_messages"
),
ondelete="SET NULL",
),
sa.PrimaryKeyConstraint(
"id",
name=op.f("pk_postbox_access_events"),
),
)
for column in (
"tenant_id",
"postbox_id",
"message_id",
"account_id",
"identity_id",
"assignment_id",
"action",
"outcome",
"occurred_at",
):
op.create_index(
op.f(f"ix_postbox_access_events_{column}"),
"postbox_access_events",
[column],
)
op.create_index(
"ix_postbox_access_events_resource",
"postbox_access_events",
["tenant_id", "postbox_id", "message_id", "occurred_at"],
)
op.create_index(
"ix_postbox_access_events_actor",
"postbox_access_events",
["tenant_id", "account_id", "occurred_at"],
)
def downgrade() -> None:
op.drop_table("postbox_access_events")
op.drop_table("postbox_grouping_sources")
op.drop_table("postbox_groupings")
op.drop_table("postbox_message_receipts")
op.drop_table("postbox_routes")
op.drop_table("postbox_deliveries")
op.drop_table("postbox_attachment_references")
op.drop_table("postbox_participants")
op.drop_table("postbox_messages")
op.drop_table("postbox_bindings")
op.drop_table("postboxes")
op.drop_table("postbox_addresses")
op.drop_table("postbox_template_revisions")
op.drop_table("postbox_templates")
@@ -0,0 +1,31 @@
"""v0.1.18 governed unified-Postbox grouping policy.
Revision ID: d8b4f1a6c9e2
Revises: a7c1e4f8b2d6
"""
from alembic import op
import sqlalchemy as sa
revision = "d8b4f1a6c9e2"
down_revision = "a7c1e4f8b2d6"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch_op:
batch_op.add_column(
sa.Column(
"grouping_policy",
sa.JSON(),
nullable=False,
server_default=sa.text("'{}'"),
)
)
def downgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch_op:
batch_op.drop_column("grouping_policy")
@@ -0,0 +1,55 @@
"""postbox content-protection state
Revision ID: d8e3f6a9b2c5
Revises: a6d9e1f4c8b3
Create Date: 2026-08-02 00:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "d8e3f6a9b2c5"
down_revision = "a6d9e1f4c8b3"
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("encryption_vault_id", sa.String(length=255), nullable=True)
)
with op.batch_alter_table("postbox_messages") as batch_op:
batch_op.add_column(
sa.Column("body_ciphertext", sa.LargeBinary(), nullable=True)
)
batch_op.add_column(
sa.Column("encryption_envelope_id", sa.String(length=255), nullable=True)
)
batch_op.add_column(
sa.Column("encryption_resource_id", sa.String(length=36), nullable=True)
)
batch_op.create_index(
op.f("ix_postbox_messages_encryption_envelope_id"),
["encryption_envelope_id"],
unique=False,
)
batch_op.create_index(
op.f("ix_postbox_messages_encryption_resource_id"),
["encryption_resource_id"],
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("postbox_messages") as batch_op:
batch_op.drop_index(op.f("ix_postbox_messages_encryption_resource_id"))
batch_op.drop_index(op.f("ix_postbox_messages_encryption_envelope_id"))
batch_op.drop_column("encryption_resource_id")
batch_op.drop_column("encryption_envelope_id")
batch_op.drop_column("body_ciphertext")
with op.batch_alter_table("postbox_template_revisions") as batch_op:
batch_op.drop_column("encryption_vault_id")
@@ -0,0 +1,54 @@
"""v0.1.1 durable hierarchy routes
Revision ID: e4b7c9d2a6f1
Revises: c7d2e5f8a1b4
Create Date: 2026-07-30 04:00:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e4b7c9d2a6f1"
down_revision = "c7d2e5f8a1b4"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_routes") as batch_op:
batch_op.add_column(
sa.Column(
"execute_after",
sa.DateTime(timezone=True),
nullable=True,
)
)
batch_op.add_column(
sa.Column(
"processed_at",
sa.DateTime(timezone=True),
nullable=True,
)
)
batch_op.create_unique_constraint(
"uq_postbox_routes_delivery_target_kind_depth",
("delivery_id", "target_postbox_id", "route_kind", "depth"),
)
batch_op.create_index(
"ix_postbox_routes_due",
("status", "execute_after"),
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("postbox_routes") as batch_op:
batch_op.drop_index("ix_postbox_routes_due")
batch_op.drop_constraint(
"uq_postbox_routes_delivery_target_kind_depth",
type_="unique",
)
batch_op.drop_column("processed_at")
batch_op.drop_column("execute_after")
@@ -0,0 +1,43 @@
"""Add explicit Postbox template hierarchy scope.
Revision ID: e9f4a7b2c5d8
Revises: d8e3f6a9b2c5
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "e9f4a7b2c5d8"
down_revision = "d8e3f6a9b2c5"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.add_column(
sa.Column("scope_structure_id", sa.String(length=36), nullable=True)
)
batch.add_column(
sa.Column(
"scope_relation_type_ids",
sa.JSON(),
nullable=False,
server_default=sa.text("'[]'"),
)
)
batch.create_index(
"ix_postbox_template_revisions_scope_structure",
["scope_structure_id"],
unique=False,
)
def downgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.drop_index("ix_postbox_template_revisions_scope_structure")
batch.drop_column("scope_relation_type_ids")
batch.drop_column("scope_structure_id")
@@ -0,0 +1,33 @@
"""Add explicit portal visibility to Postbox template revisions.
Revision ID: f2a5c8e1b4d7
Revises: e9f4a7b2c5d8
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f2a5c8e1b4d7"
down_revision = "e9f4a7b2c5d8"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.add_column(
sa.Column(
"portal_visible",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.drop_column("portal_visible")
@@ -0,0 +1,65 @@
"""v0.1.2 message authoring and optimistic concurrency
Revision ID: f5c8d0e3b7a2
Revises: e4b7c9d2a6f1
Create Date: 2026-07-31 16:30:00.000000
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f5c8d0e3b7a2"
down_revision = "e4b7c9d2a6f1"
branch_labels = None
depends_on = None
def upgrade() -> None:
for table_name in (
"postbox_templates",
"postboxes",
"postbox_groupings",
):
with op.batch_alter_table(table_name) as batch_op:
batch_op.add_column(
sa.Column(
"resource_revision",
sa.Integer(),
nullable=False,
server_default=sa.text("1"),
)
)
with op.batch_alter_table("postbox_messages") as batch_op:
batch_op.add_column(
sa.Column("authoring_key", sa.String(length=255), nullable=True)
)
batch_op.create_index(
"ix_postbox_messages_authoring_key",
("authoring_key",),
unique=False,
)
batch_op.create_unique_constraint(
"uq_postbox_messages_authoring_key",
("tenant_id", "postbox_id", "authoring_key"),
)
def downgrade() -> None:
with op.batch_alter_table("postbox_messages") as batch_op:
batch_op.drop_constraint(
"uq_postbox_messages_authoring_key",
type_="unique",
)
batch_op.drop_index("ix_postbox_messages_authoring_key")
batch_op.drop_column("authoring_key")
for table_name in (
"postbox_groupings",
"postboxes",
"postbox_templates",
):
with op.batch_alter_table(table_name) as batch_op:
batch_op.drop_column("resource_revision")
@@ -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,
}
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
from __future__ import annotations
from govoplan_core.core.registry import PlatformRegistry
_registry: PlatformRegistry | None = None
_service: object | None = None
def configure_runtime(*, registry: object) -> None:
global _registry, _service
if not isinstance(registry, PlatformRegistry):
raise RuntimeError("Postbox requires a platform registry")
if registry is not _registry:
_registry = registry
_service = None
def get_registry() -> PlatformRegistry:
if _registry is None:
raise RuntimeError("Postbox runtime has not been configured")
return _registry
def get_service():
global _service
if _service is None:
from govoplan_postbox.backend.service import PostboxService
_service = PostboxService.from_registry(get_registry())
return _service
+911
View File
@@ -0,0 +1,911 @@
from __future__ import annotations
from datetime import datetime
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",
"internal",
"confidential",
"restricted",
]
class PostboxAccessDecisionResponse(BaseModel):
allowed: bool
action: str
postbox_id: str
reason_code: str
explanation: str
organization_unit_id: str | None = None
function_id: str | None = None
assignment_ids: list[str] = Field(default_factory=list)
assignment_sources: list[str] = Field(default_factory=list)
selected_assignment_id: str | None = None
holder_count: int = 0
vacant: bool = True
classification: str = "internal"
classification_allowed: bool = True
binding_status: str = "active"
class PostboxDirectoryItem(BaseModel):
id: str
tenant_id: str
address: str
address_key: str
name: str
status: str
classification: str
organization_unit_id: str | None = None
organization_unit_name: str | None = None
function_id: str | None = None
function_name: str | None = None
context_key: str | None = None
template_revision_id: str | None = None
holder_count: int = 0
vacant: bool = True
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
class PostboxDirectoryResponse(BaseModel):
postboxes: list[PostboxDirectoryItem]
class PostboxParticipantPayload(BaseModel):
kind: str = Field(min_length=1, max_length=30)
reference_type: str = Field(min_length=1, max_length=50)
reference_id: str | None = Field(default=None, max_length=255)
label: str | None = Field(default=None, max_length=500)
address: str | None = Field(default=None, max_length=500)
class PostboxAttachmentPayload(BaseModel):
reference_type: str = Field(min_length=1, max_length=50)
reference_id: str = Field(min_length=1, max_length=255)
name: str | None = Field(default=None, max_length=1000)
media_type: str | None = Field(default=None, max_length=255)
size_bytes: int | None = Field(default=None, ge=0)
digest: str | None = Field(default=None, max_length=255)
metadata: dict[str, Any] = Field(default_factory=dict)
class 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)
key_epoch: int = Field(ge=1)
wrapped_key_ref: str = Field(min_length=1, max_length=2000)
algorithm: str | None = Field(default=None, max_length=100)
metadata: dict[str, Any] = Field(default_factory=dict)
class PostboxExternalRecipientTokenPayload(BaseModel):
token_id: str = Field(min_length=1, max_length=255)
state: Literal["pending", "available", "fetched", "expired", "revoked"]
expires_at: datetime | None = None
one_time: bool = False
key_fetched_at: datetime | None = None
revoked_at: datetime | None = None
assurance_profile: str | None = Field(default=None, max_length=100)
metadata: dict[str, Any] = Field(default_factory=dict)
class PostboxMessageItem(BaseModel):
id: str
tenant_id: str
postbox_id: str
subject: str
body_text: str | None = None
status: str
availability: Literal["available", "withdrawn", "expired"]
classification: str
sender_label: str | None = None
delivered_at: datetime
read_at: datetime | None = None
acknowledged_at: datetime | None = None
expires_at: datetime | None = None
withdrawn_at: datetime | None = None
producer_module: str | None = None
producer_resource_type: str | None = None
producer_resource_id: str | None = None
in_reply_to_message_id: str | None = None
replaces_message_id: str | None = None
encryption_profile: str
key_epoch: int
ciphertext_ref: str | None = None
signed_manifest_ref: str | None = None
wrapped_keys: list[PostboxWrappedKeyPayload] = Field(default_factory=list)
external_recipient_tokens: list[PostboxExternalRecipientTokenPayload] = Field(
default_factory=list
)
participants: list[PostboxParticipantPayload] = Field(default_factory=list)
attachments: list[PostboxAttachmentPayload] = Field(default_factory=list)
metadata: dict[str, Any] = Field(default_factory=dict)
class PostboxMessageListResponse(BaseModel):
messages: list[PostboxMessageItem]
total: int
limit: int
offset: int
class PostboxMessageStateRequest(BaseModel):
state: Literal["read", "acknowledged"]
class 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)
class PostboxTargetPayload(BaseModel):
postbox_id: str | None = Field(default=None, max_length=36)
address_key: str | None = Field(default=None, max_length=500)
template_id: str | None = Field(default=None, max_length=36)
organization_unit_id: str | None = Field(default=None, max_length=36)
function_id: str | None = Field(default=None, max_length=36)
context_key: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_target(self) -> "PostboxTargetPayload":
direct = bool(self.postbox_id or self.address_key)
templated = bool(
self.template_id and self.organization_unit_id and self.function_id
)
if direct == templated:
raise ValueError(
"Specify one direct Postbox target or one complete template target."
)
return self
class PostboxDeliveryCreateRequest(BaseModel):
target: PostboxTargetPayload
producer_module: str = Field(min_length=1, max_length=100)
producer_resource_type: str = Field(min_length=1, max_length=100)
producer_resource_id: str | None = Field(default=None, max_length=255)
idempotency_key: str = Field(min_length=1, max_length=255)
subject: str = Field(min_length=1, max_length=1000)
body_text: str | None = None
sender_label: str | None = Field(default=None, max_length=500)
classification: 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
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)
external_recipient_tokens: list[PostboxExternalRecipientTokenPayload] = Field(
default_factory=list
)
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
postbox_id: str
message_id: str
address: str
status: str
vacant: bool
holder_count: int
duplicate: bool = False
evidence: dict[str, Any] = Field(default_factory=dict)
class PostboxLinkedCopyPolicyPayload(BaseModel):
enabled: bool = False
structure_id: str | None = Field(default=None, max_length=36)
relation_type_ids: list[str] = Field(default_factory=list, max_length=20)
max_depth: int = Field(default=1, ge=1, le=20)
stop_unit_id: str | None = Field(default=None, max_length=36)
stop_unit_type_id: str | None = Field(default=None, max_length=36)
target_function_type_id: str | None = Field(default=None, max_length=36)
target_template_id: str | None = Field(default=None, max_length=36)
fanout: Literal["nearest", "all"] = "nearest"
allowed_classifications: list[PostboxClassification] = Field(
default_factory=lambda: ["internal"],
max_length=20,
)
allowed_producer_modules: list[str] = Field(
default_factory=list,
max_length=50,
)
require_expiry: bool = False
max_retention_days: int | None = Field(default=None, ge=1, le=36500)
@model_validator(mode="after")
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)
)
self.allowed_producer_modules = list(
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.")
if any(not value for value in self.allowed_classifications):
raise ValueError("Allowed classifications must not be empty.")
if any(not value for value in self.allowed_producer_modules):
raise ValueError("Allowed producer modules must not be empty.")
if self.enabled and not all(
(
self.structure_id,
self.target_function_type_id,
self.target_template_id,
self.allowed_classifications,
self.allowed_producer_modules,
)
):
raise ValueError(
"Enabled hierarchy copy requires a structure, target function "
"type, target template, classification gate, and producer allowlist."
)
return self
class PostboxAttentionPolicyPayload(BaseModel):
mode: Literal["none", "vacancy_escalation"] = "none"
delay_minutes: int | None = Field(default=None, ge=1, le=43200)
@model_validator(mode="after")
def validate_delay(self) -> "PostboxAttentionPolicyPayload":
if self.mode == "vacancy_escalation" and self.delay_minutes is None:
raise ValueError("Vacancy escalation requires a delay.")
if self.mode == "none":
self.delay_minutes = None
return self
class PostboxSharedVisibilityPolicyPayload(BaseModel):
mode: Literal["none"] = "none"
class PostboxRoutingPolicyPayload(BaseModel):
linked_copy: PostboxLinkedCopyPolicyPayload = Field(
default_factory=PostboxLinkedCopyPolicyPayload
)
attention: PostboxAttentionPolicyPayload = Field(
default_factory=PostboxAttentionPolicyPayload
)
shared_visibility: PostboxSharedVisibilityPolicyPayload = Field(
default_factory=PostboxSharedVisibilityPolicyPayload
)
@model_validator(mode="before")
@classmethod
def normalize_legacy_policy(cls, value: Any) -> Any:
if value in (None, {}, {"mode": "none"}):
return {}
return value
@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"
):
raise ValueError("Vacancy escalation requires nearest linked-copy routing.")
return self
class PostboxRoutePreviewTarget(BaseModel):
depth: int
organization_unit_id: str
organization_unit_name: str
function_id: str | None = None
function_name: str | None = None
target_postbox_id: str | None = None
target_address: str | None = None
status: str
vacant: bool = True
holder_count: int = 0
path: list[dict[str, Any]] = Field(default_factory=list)
diagnostics: list[str] = Field(default_factory=list)
class PostboxRouteDryRunRequest(BaseModel):
target: PostboxTargetPayload
producer_module: str = Field(min_length=1, max_length=100)
classification: PostboxClassification = "internal"
expires_at: datetime | None = None
class PostboxRouteDryRunResponse(BaseModel):
status: str
source_postbox_id: str | None = None
policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload
)
routes: list[PostboxRoutePreviewTarget] = Field(default_factory=list)
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
organization_unit_id: str = Field(min_length=1, max_length=36)
function_id: str = Field(min_length=1, max_length=36)
address_key: str | None = Field(default=None, max_length=120)
classification: PostboxClassification = "internal"
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 == 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(
"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,
max_length=500,
)
address_pattern: str = Field(
default="{template_slug}.{unit_slug}.{function_slug}",
min_length=1,
max_length=500,
)
classification: PostboxClassification = "internal"
allow_vacant_delivery: bool = True
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 == 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(
"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
revision: int
history_policy: dict[str, Any] = Field(default_factory=dict)
retention_policy: dict[str, Any] = Field(default_factory=dict)
published_at: datetime | None = None
created_at: datetime
class PostboxTemplateItem(BaseModel):
id: str
tenant_id: str
slug: str
name: str
description: str | None = None
status: str
current_revision: int
resource_revision: int = Field(ge=1)
etag: str
published_revision_id: str | None = None
revisions: list[PostboxTemplateRevisionItem] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class PostboxTemplateListResponse(BaseModel):
templates: list[PostboxTemplateItem]
class PostboxTemplatePublishRequest(BaseModel):
revision: int | None = Field(default=None, ge=1)
base_revision: int = Field(ge=1)
class PostboxMutationRequest(BaseModel):
base_revision: int = Field(ge=1)
class PostboxMaterializeRequest(BaseModel):
organization_unit_id: str = Field(min_length=1, max_length=36)
function_id: str = Field(min_length=1, max_length=36)
context_key: str | None = Field(default=None, max_length=255)
class PostboxOrganizationFunctionItem(BaseModel):
id: str
slug: str
name: str
function_type_id: str | None = None
delegable: bool = False
act_in_place_allowed: bool = False
class PostboxOrganizationUnitItem(BaseModel):
id: str
slug: str
name: str
unit_type_id: str | None = None
parent_id: str | None = None
functions: list[PostboxOrganizationFunctionItem] = Field(default_factory=list)
class PostboxOrganizationRelationTypeItem(BaseModel):
id: str
slug: str
name: str
structure_id: str | None = None
is_hierarchical: bool = True
status: str = "active"
class PostboxOrganizationStructureItem(BaseModel):
id: str
slug: str
name: str
structure_kind: str
status: str = "active"
relation_types: list[PostboxOrganizationRelationTypeItem] = Field(
default_factory=list
)
class PostboxOrganizationTargetsResponse(BaseModel):
units: list[PostboxOrganizationUnitItem]
structures: list[PostboxOrganizationStructureItem] = Field(default_factory=list)
class PostboxGroupingPayload(BaseModel):
name: str = Field(min_length=1, max_length=250)
is_default: bool = False
postbox_ids: list[str] = Field(default_factory=list, max_length=250)
class 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
updated_at: datetime
class PostboxGroupingListResponse(BaseModel):
groupings: list[PostboxGroupingItem]
@@ -0,0 +1,296 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.postbox import PostboxActorRef
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchIndexChange,
SearchResourceReference,
SearchResourceType,
)
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxMessage,
PostboxRoute,
)
from govoplan_postbox.backend.service import PostboxService
PROVIDER_ID = "postbox.messages"
RESOURCE_TYPE = "postbox_message"
READ_SCOPE = "postbox:postbox:read"
CONFIDENTIAL_SCOPE = "postbox:classification:confidential"
RESTRICTED_SCOPE = "postbox:classification:restricted"
class PostboxSearchSource:
def __init__(self, service: PostboxService) -> None:
self.service = service
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="postbox",
resource_type=RESOURCE_TYPE,
label="Postbox messages",
requires_authorization_recheck=True,
),
)
def backfill(
self,
session: object,
*,
request: SearchBackfillRequest,
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
statement = (
select(PostboxMessage, Postbox)
.join(Postbox, Postbox.id == PostboxMessage.postbox_id)
.where(
PostboxMessage.tenant_id == request.tenant_id,
Postbox.tenant_id == request.tenant_id,
)
)
if request.cursor:
statement = statement.where(PostboxMessage.id > request.cursor)
rows = list(
db.execute(
statement.order_by(PostboxMessage.id).limit(request.limit + 1)
).all()
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
high_watermark = db.scalar(
select(func.max(PostboxMessage.updated_at)).where(
PostboxMessage.tenant_id == request.tenant_id
)
)
return SearchBackfillPage(
documents=tuple(
_document(message, postbox=postbox)
for message, postbox in selected
),
next_cursor=(
selected[-1][0].id if has_more and selected else None
),
complete=not has_more,
high_watermark=(
high_watermark.isoformat()
if high_watermark is not None
else None
),
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
decisions = {item.reference.key: False for item in requests}
if not isinstance(principal, ApiPrincipal) or not principal.has(READ_SCOPE):
return decisions
actor = _actor(principal)
db = _session(session)
for item in requests:
reference = item.reference
if (
reference.tenant_id != principal.tenant_id
or reference.module_id != "postbox"
or reference.resource_type != RESOURCE_TYPE
):
continue
try:
decisions[reference.key] = self.service.can_read_message(
db,
tenant_id=principal.tenant_id,
message_id=reference.resource_id,
actor=actor,
)
except (RuntimeError, ValueError):
decisions[reference.key] = False
return decisions
def index_changes_for_event(
self,
session: object,
*,
event: PlatformEvent,
delivery_key: str,
) -> Sequence[SearchIndexChange]:
if (
event.module_id != "postbox"
or event.tenant is None
or event.resource is None
or event.resource.id is None
or event.resource.type not in {RESOURCE_TYPE, "postbox_route"}
):
return ()
db = _session(session)
message_id = event.resource.id
if event.resource.type == "postbox_route":
route = db.get(PostboxRoute, event.resource.id)
if (
route is None
or route.tenant_id != event.tenant.id
or route.target_message_id is None
):
return ()
message_id = route.target_message_id
row = db.get(PostboxMessage, message_id)
postbox = (
db.get(Postbox, row.postbox_id)
if row is not None and row.tenant_id == event.tenant.id
else None
)
deleted = (
row is None
or row.tenant_id != event.tenant.id
or postbox is None
)
cursor = event.event_id
document = (
None
if deleted
else _document(row, postbox=postbox, change_cursor=cursor)
)
reference = SearchResourceReference(
tenant_id=event.tenant.id,
module_id="postbox",
resource_type=RESOURCE_TYPE,
resource_id=message_id,
)
return (
SearchIndexChange(
change_id=f"{delivery_key}:{PROVIDER_ID}:{message_id}",
provider_id=PROVIDER_ID,
kind="delete" if deleted else "upsert",
reference=reference,
source_revision=(
document.source_revision if document is not None else cursor
),
cursor=cursor,
document=document,
occurred_at=event.occurred_at,
),
)
def create_postbox_search_source(
context: ModuleContext,
) -> PostboxSearchSource:
return PostboxSearchSource(PostboxService.from_registry(context.registry))
def _actor(principal: ApiPrincipal) -> PostboxActorRef:
selected = 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 principal.has(CONFIDENTIAL_SCOPE):
classifications.add("confidential")
if principal.has(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({"discover", "read"}),
authorized_classifications=frozenset(classifications),
)
def _document(
message: PostboxMessage,
*,
postbox: Postbox,
change_cursor: str | None = None,
) -> SearchDocument:
updated_at = message.updated_at or message.created_at
body = (
message.body_text
if message.encryption_profile == "plaintext_v1"
and message.body_ciphertext is None
else None
)
tokens = [f"scope:{READ_SCOPE}"]
if message.classification == "confidential":
tokens.append(f"scope:{CONFIDENTIAL_SCOPE}")
elif message.classification == "restricted":
tokens.append(f"scope:{RESTRICTED_SCOPE}")
return SearchDocument(
tenant_id=message.tenant_id,
module_id="postbox",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=message.id,
title=message.subject[:500],
url=f"/postbox?messageId={quote(message.id, safe='')}",
summary=(message.sender_label or postbox.name)[:4000],
body=body[:200_000] if body else None,
keywords=tuple(
item[:200]
for item in (
postbox.name,
message.status,
message.classification,
message.producer_module or "",
)
if item
),
visibility="restricted",
acl_tokens=tuple(dict.fromkeys(tokens)),
metadata={
"postbox_id": message.postbox_id,
"postbox_name": postbox.name,
"status": message.status,
"classification": message.classification,
"sender_label": message.sender_label,
"delivered_at": message.delivered_at.isoformat(),
"withdrawn_at": (
message.withdrawn_at.isoformat()
if message.withdrawn_at
else None
),
"encrypted": body is None and message.body_ciphertext is not None,
},
source_revision=f"{message.status}:{updated_at.isoformat()}",
change_cursor=change_cursor,
source_updated_at=updated_at,
requires_authorization_recheck=True,
)
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Postbox search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Postbox search requires a SQLAlchemy session.")
return value
__all__ = [
"PROVIDER_ID",
"PostboxSearchSource",
"RESOURCE_TYPE",
"create_postbox_search_source",
]
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
from __future__ import annotations
from urllib.parse import quote
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, has_scope
from govoplan_core.core.postbox import PostboxDirectoryEntryRef, PostboxMessageRef
from govoplan_core.core.tasks import (
WorkAssignmentRef,
WorkItem,
WorkItemPage,
WorkItemQuery,
WorkSourceRef,
)
from govoplan_postbox.backend.permissions import READ_SCOPE
from govoplan_postbox.backend.principals import actor_from_principal
from govoplan_postbox.backend.service import PostboxService
PROVIDER_ID = "postbox.unread"
class PostboxWorkItemProvider:
def __init__(
self,
*,
registry: object | None = None,
service: PostboxService | None = None,
) -> None:
self.registry = registry
self.service = service
def list_items(
self,
session: object,
principal: object,
*,
query: WorkItemQuery,
) -> WorkItemPage:
if not isinstance(session, Session):
raise TypeError("Postbox work aggregation requires a SQLAlchemy Session.")
if not isinstance(principal, ApiPrincipal):
return WorkItemPage(items=(), total=0)
if principal.tenant_id != query.tenant_id or not has_scope(
principal, READ_SCOPE
):
return WorkItemPage(items=(), total=0)
if query.statuses and "open" not in query.statuses:
return WorkItemPage(items=(), total=0)
if query.priorities and "normal" not in query.priorities:
return WorkItemPage(items=(), total=0)
if query.due_before is not None:
return WorkItemPage(items=(), total=0)
actor = actor_from_principal(principal)
service = self._service()
postboxes = service.list_visible_postboxes(
session,
tenant_id=query.tenant_id,
actor=actor,
)
by_id = {postbox.id: postbox for postbox in postboxes}
messages, total = service.list_available_unread_messages(
session,
tenant_id=query.tenant_id,
postbox_ids=tuple(by_id),
actor=actor,
limit=query.limit,
query=query.text,
)
return WorkItemPage(
items=tuple(
_work_item(message, by_id[message.postbox_id], principal)
for message in messages
),
total=total,
truncated=total > len(messages),
)
def _service(self) -> PostboxService:
if self.service is not None:
return self.service
if self.registry is None:
raise RuntimeError("Postbox work aggregation requires a registry.")
return PostboxService.from_registry(self.registry) # type: ignore[arg-type]
def _work_item(
message: PostboxMessageRef,
postbox: PostboxDirectoryEntryRef,
principal: ApiPrincipal,
) -> WorkItem:
action_url = (
f"/postbox?postbox={quote(message.postbox_id, safe='')}"
f"&message={quote(message.id, safe='')}"
)
sources = [
WorkSourceRef(
module_id="postbox",
resource_type="postbox_message",
resource_id=message.id,
revision=message.delivered_at.isoformat(),
url=action_url,
label=message.subject,
)
]
if (
message.producer_module
and message.producer_resource_type
and message.producer_resource_id
):
sources.append(
WorkSourceRef(
module_id=message.producer_module,
resource_type=message.producer_resource_type,
resource_id=message.producer_resource_id,
)
)
return WorkItem(
id=message.id,
provider_id=PROVIDER_ID,
owner_module="postbox",
tenant_id=message.tenant_id,
title=message.subject,
summary=(
f"{postbox.name} · {message.sender_label}"
if message.sender_label
else postbox.name
),
status="open",
priority="normal",
required_action="Read the Postbox message.",
action_url=action_url,
assignments=_assignments(postbox, principal),
sources=tuple(sources),
provenance={
"postbox_id": postbox.id,
"address_key": postbox.address_key,
"producer_module": message.producer_module,
},
metadata={
"classification": message.classification,
"attachment_count": len(message.attachments),
"encrypted": message.encryption_profile != "plaintext_v1",
},
revision=f"{message.status}:{message.delivered_at.isoformat()}",
created_at=message.delivered_at,
updated_at=message.delivered_at,
)
def _assignments(
postbox: PostboxDirectoryEntryRef,
principal: ApiPrincipal,
) -> tuple[WorkAssignmentRef, ...]:
access = postbox.access
assignment_ids = tuple(access.assignment_ids) if access is not None else ()
if access is not None and access.selected_assignment_id:
assignment_ids = (access.selected_assignment_id,)
assignments = tuple(
WorkAssignmentRef(
kind="function_assignment",
id=assignment_id,
label=postbox.function_name,
)
for assignment_id in assignment_ids
)
if assignments:
return assignments
return (WorkAssignmentRef(kind="account", id=principal.account_id),)
__all__ = ["PROVIDER_ID", "PostboxWorkItemProvider"]
+1
View File
@@ -0,0 +1 @@
+255
View File
@@ -0,0 +1,255 @@
from __future__ import annotations
import unittest
from govoplan_core.core.idm import OrganizationFunctionAssignmentRef
from govoplan_core.core.postbox import PostboxActorRef
from govoplan_postbox.backend.access_decisions import (
ACCESS_DECISION_TABLE,
evaluate_postbox_access,
)
def assignment(
source: str = "direct",
*,
assignment_id: str | None = None,
tenant_id: str = "tenant-1",
acting_for_account_id: str | None = None,
) -> OrganizationFunctionAssignmentRef:
return OrganizationFunctionAssignmentRef(
id=assignment_id or f"{source}-assignment",
tenant_id=tenant_id,
identity_id="identity-1",
account_id="account-1",
function_id="function-1",
organization_unit_id="unit-1",
source=source, # type: ignore[arg-type]
acting_for_account_id=acting_for_account_id,
)
def decide(
*,
status: str = "active",
action: str = "read",
authorized_actions: frozenset[str] = frozenset({"read"}),
binding_available: bool = True,
assignments=(),
selected_assignment_id: str | None = None,
acting_for_account_id: str | None = None,
classification: str = "internal",
authorized_classifications: frozenset[str] = frozenset(
{"public", "internal"}
),
binding_status: str | None = None,
):
return evaluate_postbox_access(
postbox_id="postbox-1",
postbox_active=status == "active",
action=action, # type: ignore[arg-type]
actor=PostboxActorRef(
account_id="account-1",
identity_id="identity-1",
selected_assignment_id=selected_assignment_id,
acting_for_account_id=acting_for_account_id,
authorized_actions=authorized_actions, # type: ignore[arg-type]
authorized_classifications=authorized_classifications, # type: ignore[arg-type]
),
organization_unit_id="unit-1" if binding_available else None,
function_id="function-1" if binding_available else None,
holder_count=len(assignments),
binding_available=binding_available,
binding_assignments=assignments,
binding_status=binding_status, # type: ignore[arg-type]
classification=classification,
)
class PostboxAccessDecisionTableTests(unittest.TestCase):
def test_rule_order_is_fail_closed(self) -> None:
self.assertEqual(
[rule.name for rule in ACCESS_DECISION_TABLE],
[
"inactive_postbox",
"generic_permission",
"administrator",
"classification_clearance",
"active_function_binding",
],
)
inactive_admin = decide(
status="archived",
action="administer",
authorized_actions=frozenset({"administer"}),
)
self.assertFalse(inactive_admin.allowed)
self.assertEqual(inactive_admin.reason_code, "postbox_inactive")
def test_permission_and_binding_denials_have_stable_provenance(self) -> None:
missing_permission = decide(
assignments=(assignment(),),
authorized_actions=frozenset(),
)
missing_binding = decide(binding_available=False)
self.assertEqual(
missing_permission.reason_code,
"generic_permission_missing",
)
self.assertEqual(
missing_binding.reason_code,
"function_binding_missing",
)
def test_administration_is_generic_but_still_requires_active_postbox(self) -> None:
decision = decide(
action="administer",
authorized_actions=frozenset({"administer"}),
binding_available=False,
)
self.assertTrue(decision.allowed)
self.assertEqual(decision.reason_code, "generic_administrator")
def test_reply_is_a_distinct_permission_decision(self) -> None:
missing = decide(
action="reply",
authorized_actions=frozenset({"send"}),
assignments=(assignment(),),
)
allowed = decide(
action="reply",
authorized_actions=frozenset({"reply"}),
assignments=(assignment(),),
)
self.assertEqual(missing.reason_code, "generic_permission_missing")
self.assertTrue(allowed.allowed)
def test_classification_is_fail_closed_and_explained(self) -> None:
missing_clearance = decide(
classification="confidential",
assignments=(assignment(),),
)
allowed = decide(
classification="confidential",
authorized_classifications=frozenset(
{"public", "internal", "confidential"}
),
assignments=(assignment(),),
)
unsupported = decide(
classification="secret",
authorized_classifications=frozenset(
{"public", "internal", "confidential", "restricted"}
),
assignments=(assignment(),),
)
self.assertEqual(
missing_clearance.reason_code,
"classification_clearance_missing",
)
self.assertFalse(missing_clearance.classification_allowed)
self.assertTrue(allowed.allowed)
self.assertEqual(unsupported.reason_code, "classification_unsupported")
def test_binding_failures_have_stable_transition_reasons(self) -> None:
expected = {
"unit_inactive": "organization_unit_inactive",
"function_inactive": "organization_function_inactive",
"function_reassigned": "organization_function_reassigned",
"directory_unavailable": "organization_directory_unavailable",
}
for binding_status, reason_code in expected.items():
with self.subTest(binding_status=binding_status):
decision = decide(
assignments=(assignment(),),
binding_status=binding_status,
)
self.assertFalse(decision.allowed)
self.assertEqual(decision.reason_code, reason_code)
def test_direct_delegated_directory_governance_and_system_sources_are_allowed(self) -> None:
for source in (
"direct",
"delegated",
"directory",
"governance",
"system",
):
with self.subTest(source=source):
decision = decide(assignments=(assignment(source),))
self.assertTrue(decision.allowed)
self.assertEqual(
decision.reason_code,
f"effective_{source}_assignment",
)
def test_selected_direct_context_is_preferred_without_hiding_other_matches(self) -> None:
direct = assignment("direct", assignment_id="direct-1")
delegated = assignment("delegated", assignment_id="delegated-1")
decision = decide(
assignments=(direct, delegated),
selected_assignment_id=delegated.id,
)
self.assertTrue(decision.allowed)
self.assertEqual(decision.selected_assignment_id, delegated.id)
self.assertEqual(
decision.assignment_ids,
("direct-1", "delegated-1"),
)
def test_acting_access_requires_exact_assignment_and_represented_account(self) -> None:
acting = assignment(
"acting_for",
assignment_id="acting-1",
acting_for_account_id="represented-1",
)
missing_context = decide(assignments=(acting,))
wrong_assignment = decide(
assignments=(acting,),
selected_assignment_id="acting-other",
)
wrong_account = decide(
assignments=(acting,),
selected_assignment_id=acting.id,
acting_for_account_id="represented-other",
)
allowed = decide(
assignments=(acting,),
selected_assignment_id=acting.id,
acting_for_account_id="represented-1",
)
self.assertEqual(
missing_context.reason_code,
"acting_context_required",
)
self.assertEqual(
wrong_assignment.reason_code,
"acting_assignment_not_selected",
)
self.assertEqual(
wrong_account.reason_code,
"acting_account_mismatch",
)
self.assertTrue(allowed.allowed)
self.assertEqual(
allowed.reason_code,
"effective_acting_for_assignment",
)
def test_vacancy_is_provenance_not_an_implicit_access_override(self) -> None:
denied = decide(assignments=())
self.assertFalse(denied.allowed)
self.assertTrue(denied.vacant)
self.assertEqual(
denied.reason_code,
"effective_assignment_missing",
)
if __name__ == "__main__":
unittest.main()
+605
View File
@@ -0,0 +1,605 @@
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from govoplan_access.backend.db.models import Account, Group, User
from govoplan_core.core.change_sequence import ChangeSequenceEntry
from govoplan_core.core.dsar import DsarProvider, DsarSubjectRef
from govoplan_core.db.base import Base
from govoplan_core.privacy.dsar_workflow import (
DataSubjectRequest,
create_data_subject_request,
plan_data_subject_erasure,
search_data_subject_request,
)
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAccessEvent,
PostboxAddress,
PostboxAttachmentReference,
PostboxDelivery,
PostboxGrouping,
PostboxGroupingSource,
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxProtectionTransition,
PostboxProtectionTransitionItem,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
)
from govoplan_postbox.backend.dsar_provider import (
POSTBOX_DSAR_CAPABILITY,
PostboxDsarProvider,
)
from govoplan_postbox.backend.manifest import manifest
class _Registry:
def __init__(self, provider: object, *, active: bool = True):
self.provider = provider
self.active = active
def capability_names(self):
return (POSTBOX_DSAR_CAPABILITY,)
def capability_owner(self, name):
assert name == POSTBOX_DSAR_CAPABILITY
return "postbox"
def tenant_entitlement_resolver(self):
active = self.active
class Resolver:
@staticmethod
def resolve(session, tenant_id):
del session, tenant_id
return type(
"State",
(),
{"effective_modules": ("postbox",) if active else ()},
)()
return Resolver()
def require_tenant_capability(self, name, session, **kwargs):
del session, kwargs
assert name == POSTBOX_DSAR_CAPABILITY
return self.provider
class PostboxDsarProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite:///:memory:", future=True)
Base.metadata.create_all(
self.engine,
tables=[
Account.__table__,
User.__table__,
Group.__table__,
ChangeSequenceEntry.__table__,
DataSubjectRequest.__table__,
PostboxTemplate.__table__,
PostboxTemplateRevision.__table__,
PostboxAddress.__table__,
Postbox.__table__,
PostboxMessage.__table__,
PostboxParticipant.__table__,
PostboxAttachmentReference.__table__,
PostboxDelivery.__table__,
PostboxRoute.__table__,
PostboxMessageReceipt.__table__,
PostboxGrouping.__table__,
PostboxGroupingSource.__table__,
PostboxAccessEvent.__table__,
PostboxProtectionTransition.__table__,
PostboxProtectionTransitionItem.__table__,
],
)
self.session = sessionmaker(bind=self.engine, future=True)()
now = datetime.now(timezone.utc)
account = Account(
id="account-subject",
email="subject@example.test",
normalized_email="subject@example.test",
display_name="Subject",
)
user = User(
id="membership-subject",
tenant_id="tenant-1",
account_id=account.id,
email="subject@example.test",
display_name="Subject",
)
template = PostboxTemplate(
id="template-subject",
tenant_id="tenant-1",
slug="subject-template",
name="Subject configured template",
status="published",
created_by=account.id,
updated_by=account.id,
)
revision = PostboxTemplateRevision(
id="template-revision-subject",
tenant_id="tenant-1",
template_id=template.id,
revision=1,
created_by=account.id,
published_at=now,
)
address = PostboxAddress(
id="address-1",
tenant_id="tenant-1",
address_key="office",
address="office.postbox",
status="active",
)
postbox = Postbox(
id="postbox-1",
tenant_id="tenant-1",
address_id=address.id,
name="Office Postbox",
classification="confidential",
)
message = PostboxMessage(
id="message-subject",
tenant_id="tenant-1",
postbox_id=postbox.id,
subject="Subject request context",
body_text="Bounded plaintext concerning the subject",
status="delivered",
classification="personal",
producer_module="postbox",
producer_resource_type="account_authored_message",
producer_resource_id=account.id,
authoring_key="authoring-secret-do-not-export",
delivered_at=now,
metadata_={"secret": "message-metadata-do-not-export"},
)
matching_participant = PostboxParticipant(
id="participant-subject",
tenant_id="tenant-1",
message_id=message.id,
kind="recipient",
reference_type="account",
reference_id=account.id,
label="Subject Person",
address="Subject@Example.Test",
position=1,
metadata_={"secret": "participant-metadata-do-not-export"},
)
unrelated_participant = PostboxParticipant(
id="participant-other",
tenant_id="tenant-1",
message_id=message.id,
kind="recipient",
reference_type="account",
reference_id="account-other",
label="Unrelated Person Do Not Export",
address="other@example.test",
position=2,
)
attachment = PostboxAttachmentReference(
id="attachment-subject",
tenant_id="tenant-1",
message_id=message.id,
reference_type="file_version",
reference_id="file-version-1",
name="subject-evidence.pdf",
digest="digest-do-not-export",
metadata_={"secret": "attachment-metadata-do-not-export"},
)
encrypted_message = PostboxMessage(
id="message-encrypted",
tenant_id="tenant-1",
postbox_id=postbox.id,
subject="Encrypted subject context",
body_ciphertext=b"ciphertext-do-not-export",
status="delivered",
classification="personal",
encryption_profile="server_envelope_v1",
encryption_envelope_id="envelope-do-not-export",
encryption_resource_id="resource-do-not-export",
wrapped_keys=[{"wrapped_key_ref": "wrapped-key-do-not-export"}],
external_recipient_tokens=[{"token_id": "external-token-do-not-export"}],
delivered_at=now,
)
encrypted_participant = PostboxParticipant(
id="participant-encrypted-subject",
tenant_id="tenant-1",
message_id=encrypted_message.id,
kind="recipient",
reference_type="identity",
reference_id="identity-subject",
position=1,
)
unrelated_message = PostboxMessage(
id="message-other",
tenant_id="tenant-1",
postbox_id=postbox.id,
subject="Unrelated message do not export",
body_text="Unrelated body do not export",
status="delivered",
delivered_at=now,
)
tenant_two_address = PostboxAddress(
id="address-tenant-2",
tenant_id="tenant-2",
address_key="other",
address="other.postbox",
)
tenant_two_postbox = Postbox(
id="postbox-tenant-2",
tenant_id="tenant-2",
address_id=tenant_two_address.id,
name="Tenant two Postbox",
)
tenant_two_message = PostboxMessage(
id="message-tenant-2",
tenant_id="tenant-2",
postbox_id=tenant_two_postbox.id,
subject="Tenant two message do not export",
body_text="Tenant two body do not export",
delivered_at=now,
)
tenant_two_participant = PostboxParticipant(
id="participant-tenant-2",
tenant_id="tenant-2",
message_id=tenant_two_message.id,
kind="recipient",
reference_type="account",
reference_id=account.id,
)
delivery = PostboxDelivery(
id="delivery-subject",
tenant_id="tenant-1",
postbox_id=postbox.id,
message_id=message.id,
producer_module="postbox",
producer_resource_type="account_authored_message",
producer_resource_id=account.id,
idempotency_key="delivery-idempotency-do-not-export",
status="accepted",
holder_count=1,
target_snapshot={"secret": "target-snapshot-do-not-export"},
accepted_at=now,
metadata_={"secret": "delivery-metadata-do-not-export"},
)
route = PostboxRoute(
id="route-subject",
tenant_id="tenant-1",
delivery_id=delivery.id,
source_postbox_id=postbox.id,
source_message_id=message.id,
target_postbox_id=postbox.id,
target_message_id=message.id,
route_kind="linked_copy",
status="completed",
depth=1,
processed_at=now,
policy_snapshot={"secret": "route-policy-do-not-export"},
)
receipt = PostboxMessageReceipt(
id="receipt-subject",
tenant_id="tenant-1",
message_id=message.id,
account_id=account.id,
identity_id="identity-subject",
assignment_id="assignment-subject",
read_at=now,
acknowledged_at=now,
metadata_={"secret": "receipt-metadata-do-not-export"},
)
grouping = PostboxGrouping(
id="grouping-subject",
tenant_id="tenant-1",
account_id=account.id,
name="My work",
is_default=True,
settings={"secret": "grouping-settings-do-not-export"},
)
grouping.sources.append(
PostboxGroupingSource(
id="grouping-source-subject",
tenant_id="tenant-1",
postbox_id=postbox.id,
position=0,
)
)
access_event = PostboxAccessEvent(
id="access-event-subject",
tenant_id="tenant-1",
postbox_id=postbox.id,
message_id=message.id,
account_id=account.id,
identity_id="identity-subject",
assignment_id="assignment-subject",
action="read_message",
outcome="allowed",
reason_code="assigned",
occurred_at=now,
details={"secret": "access-details-do-not-export"},
)
transition = PostboxProtectionTransition(
id="transition-subject",
tenant_id="tenant-1",
postbox_id=postbox.id,
idempotency_key="transition-idempotency-do-not-export",
source_profile="plaintext_v1",
target_profile="server_envelope_v1",
history_mode="migrate",
authority_mode="institutional",
required_quorum=1,
evidence_refs=["evidence-ref-do-not-export"],
reason="private transition reason do not export",
state="completed",
message_count=1,
completed_count=1,
requested_by=account.id,
activated_at=now,
completed_at=now,
configuration_snapshot={"secret": "transition-config-do-not-export"},
)
transition_item = PostboxProtectionTransitionItem(
id="transition-item-subject",
tenant_id="tenant-1",
transition_id=transition.id,
message_id=message.id,
source_profile="plaintext_v1",
target_profile="server_envelope_v1",
state="completed",
source_digest="source-digest-do-not-export",
target_digest="target-digest-do-not-export",
completed_by=account.id,
completed_at=now,
evidence={"secret": "transition-item-evidence-do-not-export"},
)
self.session.add_all(
[
account,
user,
template,
revision,
address,
postbox,
message,
matching_participant,
unrelated_participant,
attachment,
encrypted_message,
encrypted_participant,
unrelated_message,
tenant_two_address,
tenant_two_postbox,
tenant_two_message,
tenant_two_participant,
delivery,
route,
receipt,
grouping,
access_event,
transition,
transition_item,
]
)
self.session.commit()
self.provider = PostboxDsarProvider()
self.subject = DsarSubjectRef(
account_id=account.id,
identity_id="identity-subject",
membership_id=user.id,
email="subject@example.test",
external_references={"postbox.assignment": "assignment-subject"},
)
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_manifest_and_minimized_tenant_scoped_search(self) -> None:
self.assertIn(
POSTBOX_DSAR_CAPABILITY,
{item.name for item in manifest.provides_interfaces},
)
self.assertIsInstance(
manifest.capability_factories[POSTBOX_DSAR_CAPABILITY](None),
DsarProvider,
)
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=self.subject,
)
self.assertTrue(
{
"postbox_message",
"postbox_participant",
"postbox_attachment_reference",
"postbox_delivery",
"postbox_route",
"postbox_message_receipt",
"postbox_grouping",
"postbox_access_event",
"postbox_template",
"postbox_template_revision",
"postbox_protection_transition",
"postbox_protection_transition_item",
}.issubset({record.resource_type for record in records})
)
encrypted = next(
record
for record in records
if record.resource_type == "postbox_message"
and record.resource_id == "message-encrypted"
)
self.assertEqual(
"institution_managed_envelope", encrypted.data["content_state"]
)
serialized = repr([record.to_dict() for record in records])
for hidden in (
"Unrelated Person Do Not Export",
"other@example.test",
"participant-other",
"Unrelated message do not export",
"Unrelated body do not export",
"message-tenant-2",
"Tenant two message do not export",
"ciphertext-do-not-export",
"envelope-do-not-export",
"resource-do-not-export",
"wrapped-key-do-not-export",
"external-token-do-not-export",
"authoring-secret-do-not-export",
"message-metadata-do-not-export",
"participant-metadata-do-not-export",
"digest-do-not-export",
"attachment-metadata-do-not-export",
"delivery-idempotency-do-not-export",
"target-snapshot-do-not-export",
"delivery-metadata-do-not-export",
"route-policy-do-not-export",
"receipt-metadata-do-not-export",
"grouping-settings-do-not-export",
"access-details-do-not-export",
"transition-idempotency-do-not-export",
"evidence-ref-do-not-export",
"private transition reason do not export",
"transition-config-do-not-export",
"source-digest-do-not-export",
"target-digest-do-not-export",
"transition-item-evidence-do-not-export",
):
self.assertNotIn(hidden, serialized)
def test_conflicting_selectors_fail_closed(self) -> None:
records = self.provider.search_subject(
self.session,
tenant_id="tenant-1",
subject=DsarSubjectRef(
account_id="account-subject",
external_references={"postbox.account": "account-other"},
),
)
self.assertEqual((), records)
def test_grouping_erasure_is_revalidated_and_idempotent(self) -> None:
records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self.subject
)
actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=records,
)
self.assertTrue(
{"retain", "manual_review", "delete"}.issubset(
{action.kind for action in actions}
)
)
delete = next(action for action in actions if action.kind == "delete")
grouping = self.session.get(PostboxGrouping, "grouping-subject")
assert grouping is not None
grouping.resource_revision += 1
self.session.commit()
stale = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(delete,),
request_id="request-1",
)
self.assertEqual("blocked", stale[0].status)
refreshed_records = self.provider.search_subject(
self.session, tenant_id="tenant-1", subject=self.subject
)
refreshed_actions = self.provider.plan_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
records=refreshed_records,
)
refreshed_delete = next(
action for action in refreshed_actions if action.kind == "delete"
)
executed = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(refreshed_delete,),
request_id="request-1",
)
self.assertEqual("executed", executed[0].status)
self.assertIsNone(self.session.get(PostboxGrouping, "grouping-subject"))
self.assertIsNotNone(self.session.get(PostboxMessage, "message-subject"))
replay = self.provider.execute_erasure(
self.session,
tenant_id="tenant-1",
subject=self.subject,
actions=(refreshed_delete,),
request_id="request-1",
)
self.assertEqual("unchanged", replay[0].status)
def test_core_workflow_discovers_active_and_skips_disabled_provider(self) -> None:
request = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-POSTBOX-1",
request_kind="access_and_erasure",
subject=self.subject,
purpose="Authorized request",
legal_basis="GDPR",
due_at=datetime.now(timezone.utc) + timedelta(days=30),
requested_by_account_id="privacy-officer",
)
self.session.commit()
search_data_subject_request(
self.session,
registry=_Registry(self.provider),
row=request,
expected_revision=1,
)
self.assertEqual(["postbox"], request.coverage["covered_modules"])
plan_data_subject_erasure(
self.session,
registry=_Registry(self.provider),
row=request,
expected_revision=2,
)
self.assertTrue(
any(action["executable"] for action in request.erasure_plan["actions"])
)
disabled = create_data_subject_request(
self.session,
tenant_id="tenant-1",
reference="DSAR-POSTBOX-OFF",
request_kind="access",
subject=self.subject,
purpose="Coverage",
legal_basis=None,
due_at=None,
requested_by_account_id="privacy-officer",
)
search_data_subject_request(
self.session,
registry=_Registry(self.provider, active=False),
row=disabled,
expected_revision=1,
)
self.assertEqual(
[POSTBOX_DSAR_CAPABILITY],
disabled.coverage["inactive_provider_capabilities"],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,82 @@
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)
self.assertEqual(
{"/postbox"},
{route.path for route in frontend.routes}, # type: ignore[union-attr]
)
self.assertEqual(
{
"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]
)
self.assertTrue(all(item.icon == "inbox" for item in manifest.nav_items))
def test_topics_publish_stable_help_privacy_and_consequence_metadata(self) -> None:
topics = {topic.id: topic for topic in manifest.documentation}
self.assertIn("postbox.function-bound-containers", topics)
self.assertIn("postbox.reference.fields-and-consequences", topics)
guide = topics["postbox.function-bound-containers"]
self.assertIn("postbox.inbox.messages", guide.metadata["help_contexts"])
self.assertIn("postbox.state.unavailable", guide.metadata["help_contexts"])
self.assertGreaterEqual(len(guide.metadata["privacy_notes"]), 3)
reference = topics["postbox.reference.fields-and-consequences"]
self.assertIn("postbox.field.classification", reference.metadata["help_contexts"])
self.assertIn("postbox.action.delete-grouping", reference.metadata["help_contexts"])
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()
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import unittest
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
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
class PostboxManifestTests(unittest.TestCase):
def test_manifest_announces_owned_contracts_and_dependencies(self) -> None:
manifest = get_manifest()
self.assertEqual(manifest.id, "postbox")
self.assertEqual(
{"identity", "organizations", "idm"},
set(manifest.dependencies),
)
self.assertEqual(
{
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
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.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
and requirement.optional
for requirement in manifest.requires_interfaces
)
)
if __name__ == "__main__":
unittest.main()
+176
View File
@@ -0,0 +1,176 @@
from __future__ import annotations
import importlib
import unittest
from alembic.migration import MigrationContext
from alembic.operations import Operations
from sqlalchemy import create_engine, inspect
class PostboxMigrationTests(unittest.TestCase):
def test_baseline_creates_and_drops_owned_tables(self) -> None:
migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"c7d2e5f8a1b4_v010_postbox_baseline"
)
route_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"e4b7c9d2a6f1_v011_hierarchy_routes"
)
occ_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"f5c8d0e3b7a2_v012_authoring_and_occ"
)
envelope_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"a6d9e1f4c8b3_v013_external_recipient_tokens"
)
protection_migration = importlib.import_module(
"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:
operations = Operations(MigrationContext.configure(connection))
original = migration.op
route_original = route_migration.op
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(
"postbox_messages"
)
}
self.assertTrue(
{
"ciphertext_ref",
"signed_manifest_ref",
"wrapped_keys",
"external_recipient_tokens",
"key_epoch",
"expires_at",
"withdrawn_at",
"body_ciphertext",
"encryption_envelope_id",
"encryption_resource_id",
}.issubset(message_columns)
)
template_revision_columns = {
column["name"]
for column in inspect(connection).get_columns(
"postbox_template_revisions"
)
}
self.assertTrue(
{
"encryption_vault_id",
"scope_structure_id",
"scope_relation_type_ids",
"portal_visible",
"grouping_policy",
}.issubset(template_revision_columns)
)
self.assertIn("authoring_key", message_columns)
for table_name in (
"postbox_templates",
"postboxes",
"postbox_groupings",
):
self.assertIn(
"resource_revision",
{
column["name"]
for column in inspect(connection).get_columns(
table_name
)
},
)
route_columns = {
column["name"]
for column in inspect(connection).get_columns("postbox_routes")
}
self.assertTrue(
{"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()
route_migration.downgrade()
migration.downgrade()
self.assertFalse(
{
table
for table in inspect(connection).get_table_names()
if table.startswith("postbox")
}
)
finally:
migration.op = original
route_migration.op = route_original
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()
if __name__ == "__main__":
unittest.main()
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import ast
import unittest
from pathlib import Path
class PostboxModuleBoundaryTests(unittest.TestCase):
def test_backend_does_not_import_sibling_module_implementations(self) -> None:
root = Path(__file__).parents[1] / "src" / "govoplan_postbox"
forbidden = (
"govoplan_access",
"govoplan_campaign",
"govoplan_files",
"govoplan_identity",
"govoplan_idm",
"govoplan_mail",
"govoplan_organizations",
"govoplan_portal",
)
violations: list[str] = []
for path in root.rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
names: list[str] = []
if isinstance(node, ast.Import):
names.extend(alias.name for alias in node.names)
elif isinstance(node, ast.ImportFrom) and node.module:
names.append(node.module)
for name in names:
if name.startswith(forbidden):
violations.append(f"{path.relative_to(root)}: {name}")
self.assertEqual([], violations)
if __name__ == "__main__":
unittest.main()
+376
View File
@@ -0,0 +1,376 @@
from __future__ import annotations
import unittest
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,
get_database,
reset_database,
set_database,
)
from govoplan_core.security.time import utc_now
from govoplan_identity.backend.db.models import Identity, IdentityAccountLink
from govoplan_identity.backend.directory import SqlIdentityDirectory
from govoplan_idm.backend.db.models import IdmOrganizationFunctionAssignment
from govoplan_idm.backend.directory import SqlIdmDirectory
from govoplan_organizations.backend.db.models import (
OrganizationFunction,
OrganizationUnit,
)
from govoplan_organizations.backend.directory import SqlOrganizationDirectory
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAccessEvent,
PostboxAddress,
PostboxAttachmentReference,
PostboxBinding,
PostboxDelivery,
PostboxGrouping,
PostboxGroupingSource,
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
)
from govoplan_postbox.backend.service import PostboxService
from govoplan_postbox.backend.principals import actor_from_principal
from govoplan_postbox.backend.work_items import PostboxWorkItemProvider
TABLES = (
Identity.__table__,
IdentityAccountLink.__table__,
OrganizationUnit.__table__,
OrganizationFunction.__table__,
IdmOrganizationFunctionAssignment.__table__,
PostboxTemplate.__table__,
PostboxTemplateRevision.__table__,
PostboxAddress.__table__,
Postbox.__table__,
PostboxBinding.__table__,
PostboxMessage.__table__,
PostboxParticipant.__table__,
PostboxAttachmentReference.__table__,
PostboxDelivery.__table__,
PostboxRoute.__table__,
PostboxMessageReceipt.__table__,
PostboxGrouping.__table__,
PostboxGroupingSource.__table__,
PostboxAccessEvent.__table__,
)
class PostboxRealDirectoryAccessTests(unittest.TestCase):
def setUp(self) -> None:
try:
self.previous_database = get_database()
except RuntimeError:
self.previous_database = None
self.engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(self.engine, tables=TABLES)
self.database = DatabaseHandle("sqlite:///:memory:", engine=self.engine)
set_database(self.database)
self.organizations = SqlOrganizationDirectory(
session_factory=self.database.SessionLocal
)
self.identities = SqlIdentityDirectory()
self.idm = SqlIdmDirectory(
identities=self.identities,
organizations=self.organizations,
)
self.service = PostboxService(
identities=self.identities,
idm=self.idm,
incumbencies=self.idm,
organizations=self.organizations,
)
with self.database.SessionLocal() as session:
session.add_all(
(
Identity(
id="identity-owner",
display_name="Owner",
source="test",
is_active=True,
settings={},
),
IdentityAccountLink(
id="link-owner",
identity_id="identity-owner",
account_id="account-owner",
is_primary=True,
source="test",
),
Identity(
id="identity-delegate",
display_name="Delegate",
source="test",
is_active=True,
settings={},
),
IdentityAccountLink(
id="link-delegate",
identity_id="identity-delegate",
account_id="account-delegate",
is_primary=True,
source="test",
),
OrganizationUnit(
id="unit-one",
tenant_id="tenant-1",
slug="unit-one",
name="Unit One",
is_active=True,
settings={},
),
OrganizationUnit(
id="unit-two",
tenant_id="tenant-1",
slug="unit-two",
name="Unit Two",
is_active=True,
settings={},
),
OrganizationFunction(
id="function-one",
tenant_id="tenant-1",
organization_unit_id="unit-one",
slug="clerk",
name="Clerk",
delegable=True,
is_active=True,
settings={},
),
)
)
session.commit()
postbox = self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="Unit One / Clerk",
organization_unit_id="unit-one",
function_id="function-one",
address_key=None,
description=None,
classification="internal",
actor_id="admin-1",
)
session.commit()
self.postbox_id = postbox.id
def tearDown(self) -> None:
if self.previous_database is None:
reset_database()
else:
set_database(self.previous_database)
self.database.dispose()
def _actor(self, account_id: str) -> PostboxActorRef:
return PostboxActorRef(
account_id=account_id,
authorized_actions=frozenset({"discover", "read", "reply"}),
)
def _decision(self, account_id: str):
with Session(self.engine) as session:
return self.service.explain_access(
session,
tenant_id="tenant-1",
postbox_id=self.postbox_id,
actor=self._actor(account_id),
action="read",
)
def _add_assignment(
self,
*,
assignment_id: str,
identity_id: str,
account_id: str,
source: str = "direct",
delegated_from_assignment_id: str | None = None,
valid_until=None,
) -> None:
with self.database.SessionLocal() as session:
session.add(
IdmOrganizationFunctionAssignment(
id=assignment_id,
tenant_id="tenant-1",
identity_id=identity_id,
account_id=account_id,
function_id="function-one",
organization_unit_id="unit-one",
source=source,
delegated_from_assignment_id=delegated_from_assignment_id,
valid_until=valid_until,
is_active=True,
settings={},
)
)
session.commit()
def test_real_directory_reassignment_changes_current_holder_only(self) -> None:
self._add_assignment(
assignment_id="owner-assignment",
identity_id="identity-owner",
account_id="account-owner",
)
self.assertTrue(self._decision("account-owner").allowed)
with self.database.SessionLocal() as session:
assignment = session.get(
IdmOrganizationFunctionAssignment,
"owner-assignment",
)
assignment.is_active = False
session.commit()
self._add_assignment(
assignment_id="delegate-assignment",
identity_id="identity-delegate",
account_id="account-delegate",
)
self.assertFalse(self._decision("account-owner").allowed)
replacement = self._decision("account-delegate")
self.assertTrue(replacement.allowed)
self.assertEqual(replacement.assignment_ids, ("delegate-assignment",))
def test_real_directory_delegation_expires_with_its_source(self) -> None:
self._add_assignment(
assignment_id="owner-assignment",
identity_id="identity-owner",
account_id="account-owner",
)
self._add_assignment(
assignment_id="delegated-assignment",
identity_id="identity-delegate",
account_id="account-delegate",
source="delegated",
delegated_from_assignment_id="owner-assignment",
valid_until=utc_now() + timedelta(hours=1),
)
self.assertTrue(self._decision("account-delegate").allowed)
with self.database.SessionLocal() as session:
delegated = session.get(
IdmOrganizationFunctionAssignment,
"delegated-assignment",
)
delegated.valid_until = utc_now() - timedelta(seconds=1)
session.commit()
expired = self._decision("account-delegate")
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",
identity_id="identity-owner",
account_id="account-owner",
)
self.assertTrue(self._decision("account-owner").allowed)
with self.database.SessionLocal() as session:
function = session.get(OrganizationFunction, "function-one")
function.is_active = False
session.commit()
inactive_function = self._decision("account-owner")
self.assertEqual(
inactive_function.reason_code,
"organization_function_inactive",
)
with self.database.SessionLocal() as session:
function = session.get(OrganizationFunction, "function-one")
function.is_active = True
unit = session.get(OrganizationUnit, "unit-one")
unit.is_active = False
session.commit()
inactive_unit = self._decision("account-owner")
self.assertEqual(inactive_unit.reason_code, "organization_unit_inactive")
with self.database.SessionLocal() as session:
unit = session.get(OrganizationUnit, "unit-one")
unit.is_active = True
function = session.get(OrganizationFunction, "function-one")
function.organization_unit_id = "unit-two"
session.commit()
moved = self._decision("account-owner")
self.assertEqual(moved.reason_code, "organization_function_reassigned")
if __name__ == "__main__":
unittest.main()
+563
View File
@@ -0,0 +1,563 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.identity import IdentityRef
from govoplan_core.core.idm import (
OrganizationFunctionAssignmentRef,
OrganizationFunctionIncumbencyRef,
)
from govoplan_core.core.organizations import (
OrganizationFunctionRef,
OrganizationUnitRef,
)
from govoplan_core.db.base import Base
from govoplan_core.db.session import get_session
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAccessEvent,
PostboxAddress,
PostboxAttachmentReference,
PostboxBinding,
PostboxDelivery,
PostboxGrouping,
PostboxGroupingSource,
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
)
from govoplan_postbox.backend.router import router
from govoplan_postbox.backend.portal_projection import PortalProjection
from govoplan_postbox.backend.service import PostboxService
TABLES = (
PostboxTemplate.__table__,
PostboxTemplateRevision.__table__,
PostboxAddress.__table__,
Postbox.__table__,
PostboxBinding.__table__,
PostboxMessage.__table__,
PostboxParticipant.__table__,
PostboxAttachmentReference.__table__,
PostboxDelivery.__table__,
PostboxRoute.__table__,
PostboxMessageReceipt.__table__,
PostboxGrouping.__table__,
PostboxGroupingSource.__table__,
PostboxAccessEvent.__table__,
)
class FakeIdentityDirectory:
def get_identity(self, identity_id: str):
return IdentityRef(id=identity_id, primary_account_id="account-1")
def identity_for_account(self, account_id: str):
return IdentityRef(id="identity-1", primary_account_id=account_id)
def identities_for_accounts(self, account_ids):
return tuple(self.identity_for_account(account_id) for account_id in account_ids)
def accounts_for_identity(self, identity_id: str):
return ()
class FakeIdmDirectory:
def __init__(self, assignment: OrganizationFunctionAssignmentRef) -> None:
self.assignment = assignment
def get_organization_function_assignment(self, assignment_id: str):
return self.assignment if assignment_id == self.assignment.id else None
def organization_function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
):
return (self.assignment,) if identity_id == self.assignment.identity_id else ()
def organization_function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
):
return (self.assignment,) if account_id == self.assignment.account_id else ()
def organization_function_assignments_for_function(
self,
function_id: str,
*,
tenant_id: str | None = None,
):
return (self.assignment,) if function_id == self.assignment.function_id else ()
def organization_function_incumbencies(
self,
function_ids,
*,
tenant_id: str,
effective_at=None,
):
del effective_at
return {
function_id: OrganizationFunctionIncumbencyRef(
tenant_id=tenant_id,
function_id=function_id,
assignments=self.organization_function_assignments_for_function(
function_id,
tenant_id=tenant_id,
),
)
for function_id in function_ids
}
class FakeOrganizationDirectory:
unit = OrganizationUnitRef(
id="unit-1",
tenant_id="tenant-1",
slug="district",
name="District",
)
function = OrganizationFunctionRef(
id="function-1",
tenant_id="tenant-1",
organization_unit_id="unit-1",
slug="clerk",
name="Clerk",
function_type_id="clerk-type",
)
def get_organization_unit(self, organization_unit_id: str):
return self.unit if organization_unit_id == self.unit.id else None
def organization_units_for_tenant(self, tenant_id: str):
return (self.unit,) if tenant_id == self.unit.tenant_id else ()
def get_function(self, function_id: str):
return self.function if function_id == self.function.id else None
def functions_for_organization_unit(
self,
organization_unit_id: str,
*,
include_subunits: bool = False,
):
return (self.function,) if organization_unit_id == self.unit.id else ()
class PostboxRouterTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(self.engine, tables=TABLES)
assignment = OrganizationFunctionAssignmentRef(
id="assignment-1",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id="function-1",
organization_unit_id="unit-1",
)
idm = FakeIdmDirectory(assignment)
self.service = PostboxService(
identities=FakeIdentityDirectory(), # type: ignore[arg-type]
idm=idm, # type: ignore[arg-type]
incumbencies=idm, # type: ignore[arg-type]
organizations=FakeOrganizationDirectory(), # type: ignore[arg-type]
)
with Session(self.engine) as session:
self.postbox = self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="District / Clerk",
organization_unit_id="unit-1",
function_id="function-1",
address_key=None,
description=None,
classification="internal",
actor_id="account-1",
)
session.commit()
self.postbox_id = self.postbox.id
principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
identity_id="identity-1",
scopes=frozenset(
{
"postbox:postbox:read",
"postbox:message:write",
"postbox:message:reply",
"postbox:message:acknowledge",
"postbox:delivery:write",
"postbox:binding:admin",
"postbox:template:admin",
}
),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="membership-1"),
)
self.principal = principal
app = FastAPI()
app.include_router(router, prefix="/api/v1")
def session_dependency():
with Session(self.engine) as session:
yield session
app.dependency_overrides[get_session] = session_dependency
app.dependency_overrides[get_api_principal] = lambda: principal
self.patch = patch(
"govoplan_postbox.backend.router.get_service",
return_value=self.service,
)
self.patch.start()
self.client = TestClient(app)
def tearDown(self) -> None:
self.client.close()
self.patch.stop()
self.engine.dispose()
def test_assignment_context_override_must_belong_to_principal(self) -> None:
response = self.client.get(
"/api/v1/postbox/directory",
params={"assignment_context_id": "assignment-not-granted"},
)
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)
self.assertEqual(self.postbox_id, directory.json()["postboxes"][0]["id"])
delivery = self.client.post(
"/api/v1/postbox/deliveries",
json={
"target": {"postbox_id": self.postbox_id},
"producer_module": "campaigns",
"producer_resource_type": "campaign_recipient",
"producer_resource_id": "recipient-1",
"idempotency_key": "campaign-1:recipient-1",
"subject": "Decision",
"body_text": "The decision is ready.",
"action_required": True,
},
)
self.assertEqual(201, delivery.status_code, delivery.text)
message_id = delivery.json()["message_id"]
messages = self.client.get(
"/api/v1/postbox/messages",
params={"postbox_id": self.postbox_id},
)
self.assertEqual(200, messages.status_code, messages.text)
self.assertEqual(1, messages.json()["total"])
self.assertEqual(message_id, messages.json()["messages"][0]["id"])
self.assertTrue(messages.json()["messages"][0]["metadata"]["action_required"])
filtered = self.client.get(
"/api/v1/postbox/messages",
params={
"postbox_id": self.postbox_id,
"q": "decision",
"state": "unread",
},
)
self.assertEqual(200, filtered.status_code, filtered.text)
self.assertEqual(1, filtered.json()["total"])
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"},
)
self.assertEqual(200, acknowledged.status_code, acknowledged.text)
self.assertIsNotNone(acknowledged.json()["read_at"])
self.assertIsNotNone(acknowledged.json()["acknowledged_at"])
unread = self.client.get(
"/api/v1/postbox/messages",
params={
"postbox_id": self.postbox_id,
"state": "unread",
},
)
self.assertEqual(200, unread.status_code, unread.text)
self.assertEqual(0, unread.json()["total"])
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(
"/api/v1/postbox/routing/dry-run",
json={
"target": {"postbox_id": self.postbox_id},
"producer_module": "campaigns",
"classification": "internal",
},
)
self.assertEqual(200, response.status_code, response.text)
self.assertEqual("disabled", response.json()["status"])
self.assertEqual(
["hierarchy_routing_disabled"],
response.json()["diagnostics"],
)
def test_message_authoring_and_reply_are_idempotent_and_linked(self) -> None:
authored_payload = {
"postbox_id": self.postbox_id,
"idempotency_key": "compose-1",
"subject": "Status request",
"body_text": "Please provide a status update.",
"participants": [
{
"kind": "to",
"reference_type": "address",
"address": "team@example.invalid",
}
],
}
authored = self.client.post(
"/api/v1/postbox/messages",
json=authored_payload,
)
duplicate = self.client.post(
"/api/v1/postbox/messages",
json=authored_payload,
)
conflict = self.client.post(
"/api/v1/postbox/messages",
json={**authored_payload, "subject": "Different request"},
)
self.assertEqual(201, authored.status_code, authored.text)
self.assertEqual(201, duplicate.status_code, duplicate.text)
self.assertEqual(authored.json()["id"], duplicate.json()["id"])
self.assertEqual(409, conflict.status_code, conflict.text)
self.assertEqual("author", authored.json()["participants"][0]["kind"])
reply_payload = {
"idempotency_key": "reply-1",
"subject": "Re: Status request",
"body_text": "The work is complete.",
}
reply = self.client.post(
f"/api/v1/postbox/messages/{authored.json()['id']}/replies",
json=reply_payload,
)
duplicate_reply = self.client.post(
f"/api/v1/postbox/messages/{authored.json()['id']}/replies",
json=reply_payload,
)
self.assertEqual(201, reply.status_code, reply.text)
self.assertEqual(reply.json()["id"], duplicate_reply.json()["id"])
self.assertEqual(
authored.json()["id"],
reply.json()["in_reply_to_message_id"],
)
def test_mutable_admin_resources_require_strong_preconditions(self) -> None:
grouping = self.client.post(
"/api/v1/postbox/groupings",
json={
"name": "Work",
"is_default": True,
"postbox_ids": [self.postbox_id],
},
)
self.assertEqual(201, grouping.status_code, grouping.text)
grouping_data = grouping.json()
update_payload = {
"name": "Current work",
"is_default": True,
"postbox_ids": [self.postbox_id],
"base_revision": grouping_data["resource_revision"],
}
updated = self.client.put(
f"/api/v1/postbox/groupings/{grouping_data['id']}",
json=update_payload,
headers={"If-Match": grouping_data["etag"]},
)
stale = self.client.put(
f"/api/v1/postbox/groupings/{grouping_data['id']}",
json=update_payload,
headers={"If-Match": grouping_data["etag"]},
)
self.assertEqual(200, updated.status_code, updated.text)
self.assertEqual(2, updated.json()["resource_revision"])
self.assertEqual(412, stale.status_code, stale.text)
template = self.client.post(
"/api/v1/postbox/admin/templates",
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(201, template.status_code, template.text)
template_data = template.json()
published = self.client.post(
f"/api/v1/postbox/admin/templates/{template_data['id']}/publish",
json={"base_revision": template_data["resource_revision"]},
headers={"If-Match": template_data["etag"]},
)
stale_retire = self.client.post(
f"/api/v1/postbox/admin/templates/{template_data['id']}/retire",
json={"base_revision": template_data["resource_revision"]},
headers={"If-Match": template_data["etag"]},
)
self.assertEqual(200, published.status_code, published.text)
self.assertEqual(412, stale_retire.status_code, stale_retire.text)
directory = self.client.get("/api/v1/postbox/admin/postboxes").json()
postbox = directory["postboxes"][0]
missing = self.client.request(
"DELETE",
f"/api/v1/postbox/admin/postboxes/{self.postbox_id}",
json={"base_revision": postbox["resource_revision"]},
)
archived = self.client.request(
"DELETE",
f"/api/v1/postbox/admin/postboxes/{self.postbox_id}",
json={"base_revision": postbox["resource_revision"]},
headers={"If-Match": postbox["etag"]},
)
self.assertEqual(428, missing.status_code, missing.text)
self.assertEqual(200, archived.status_code, archived.text)
self.assertEqual(2, archived.json()["resource_revision"])
if __name__ == "__main__":
unittest.main()
+136
View File
@@ -0,0 +1,136 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
import unittest
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.search import (
SearchAuthorizationRequest,
SearchBackfillRequest,
SearchResourceReference,
)
from govoplan_core.db.base import Base
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAddress,
PostboxMessage,
)
from govoplan_postbox.backend.search_source import (
PROVIDER_ID,
RESOURCE_TYPE,
PostboxSearchSource,
)
class _AccessService:
def __init__(self, allowed: bool = True) -> None:
self.allowed = allowed
def can_read_message(self, session, **kwargs):
del session, kwargs
return self.allowed
class PostboxSearchSourceTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine("sqlite://")
Base.metadata.create_all(
self.engine,
tables=(
PostboxAddress.__table__,
Postbox.__table__,
PostboxMessage.__table__,
),
)
self.session = Session(self.engine)
self.session.add_all(
(
PostboxAddress(
id="address-1",
tenant_id="tenant-1",
address_key="permits",
address="permits@example.test",
),
Postbox(
id="postbox-1",
tenant_id="tenant-1",
address_id="address-1",
name="Permit office",
),
PostboxMessage(
id="message-1",
tenant_id="tenant-1",
postbox_id="postbox-1",
subject="Permit decision",
body_text=None,
body_ciphertext=b"ciphertext",
encryption_profile="server_envelope_v1",
delivered_at=datetime(2026, 8, 5, tzinfo=timezone.utc),
),
)
)
self.session.commit()
def tearDown(self) -> None:
self.session.close()
self.engine.dispose()
def test_encrypted_content_is_not_indexed_and_access_is_rechecked(self) -> None:
source = PostboxSearchSource(_AccessService()) # type: ignore[arg-type]
page = source.backfill(
self.session,
request=SearchBackfillRequest(
tenant_id="tenant-1",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
rebuild_id="rebuild-1",
),
)
self.assertEqual(1, len(page.documents))
self.assertIsNone(page.documents[0].body)
self.assertNotIn("ciphertext", str(page.documents[0].metadata).casefold())
reference = SearchResourceReference(
tenant_id="tenant-1",
module_id="postbox",
resource_type=RESOURCE_TYPE,
resource_id="message-1",
)
request = SearchAuthorizationRequest(reference=reference, source_revision="1")
self.assertTrue(
source.authorize(
self.session,
_principal(),
requests=(request,),
)[reference.key]
)
denied = PostboxSearchSource(_AccessService(False)) # type: ignore[arg-type]
self.assertFalse(
denied.authorize(
self.session,
_principal(),
requests=(request,),
)[reference.key]
)
def _principal() -> ApiPrincipal:
return ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="user-1",
identity_id="identity-1",
tenant_id="tenant-1",
scopes=frozenset({"postbox:postbox:read"}),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="user-1"),
)
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@govoplan/postbox-webui",
"version": "0.1.19",
"private": true,
"type": "module",
"main": "src/index.ts",
"module": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./styles/postbox.css": "./src/styles/postbox.css"
},
"scripts": {
"test:ui-structure": "node scripts/test-postbox-page-structure.mjs",
"test:interface-patterns": "node scripts/test-interface-pattern-language.mjs"
},
"peerDependencies": {
"@govoplan/core-webui": "^0.1.18",
"lucide-react": "^1.23.0",
"react": ">=19.2.7 <20",
"react-dom": ">=19.2.7 <20",
"react-router": ">=8.3.0 <9"
},
"peerDependenciesMeta": {
"@govoplan/core-webui": {
"optional": true
}
}
}
@@ -0,0 +1,30 @@
import { readFileSync } from "node:fs";
function source(path) {
return readFileSync(new URL(path, import.meta.url), "utf8");
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const page = source("../src/features/postbox/PostboxPage.tsx");
const admin = source("../src/features/postbox/PostboxAdminPanel.tsx");
const widget = source("../src/features/postbox/PostboxInboxWidget.tsx");
const patterns = source("../src/features/postbox/interfacePatterns.ts");
const moduleSource = source("../src/module.ts");
const translations = source("../src/i18n/generatedTranslations.ts");
assert(page.includes("DocumentationHelpLink") && admin.includes("DocumentationHelpLink"), "Inbox and administration expose contextual documentation");
assert(page.includes("ActionBlockerHint") && admin.includes("ActionBlockerHint"), "Assignment and organization prerequisites identify action, actor, and destination");
assert(page.includes("disabledReason") && admin.includes("disabledReason"), "Unavailable Postbox actions explain their state");
assert(page.includes("useUnsavedDraftGuard") && admin.includes("useUnsavedDraftGuard"), "Message, grouping, template, and address drafts use the shared guard");
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.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.");
@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const page = readFileSync(
new URL("../src/features/postbox/PostboxPage.tsx", import.meta.url),
"utf8"
);
const admin = readFileSync(
new URL("../src/features/postbox/PostboxAdminPanel.tsx", import.meta.url),
"utf8"
);
const styles = readFileSync(
new URL("../src/styles/postbox.css", import.meta.url),
"utf8"
);
assert.match(page, /postbox-shell/);
assert.match(page, /postbox-directory/);
assert.match(page, /postbox-message-list/);
assert.match(page, /postbox-detail/);
assert.match(page, /postbox\.inbox\.directory/);
assert.match(page, /postbox\.inbox\.messages/);
assert.match(admin, /AdminPageLayout/);
assert.match(admin, /Postbox templates/);
assert.match(admin, /Exact postbox/);
assert.match(styles, /\.postbox-shell\s*\{/);
assert.match(styles, /grid-template-columns:/);
console.log("Postbox WebUI structure checks passed.");
+764
View File
@@ -0,0 +1,764 @@
import {
apiFetch,
apiPath,
apiPostJson,
apiUrl,
authHeaders,
type ApiSettings
} from "@govoplan/core-webui";
export type PostboxAccessDecision = {
allowed: boolean;
action: string;
postbox_id: string;
reason_code: string;
explanation: string;
organization_unit_id?: string | null;
function_id?: string | null;
assignment_ids: string[];
assignment_sources: string[];
selected_assignment_id?: string | null;
holder_count: number;
vacant: boolean;
classification: string;
classification_allowed: boolean;
binding_status: string;
};
export type PostboxDirectoryItem = {
id: string;
tenant_id: string;
address: string;
address_key: string;
name: string;
status: string;
classification: string;
organization_unit_id?: string | null;
organization_unit_name?: string | null;
function_id?: string | null;
function_name?: string | null;
context_key?: string | null;
template_revision_id?: string | null;
holder_count: number;
vacant: boolean;
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;
reference_id?: string | null;
label?: string | null;
address?: string | null;
};
export type PostboxAttachment = {
reference_type: string;
reference_id: string;
name?: string | null;
media_type?: string | null;
size_bytes?: number | null;
digest?: string | null;
metadata: Record<string, unknown>;
};
export type 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;
postbox_id: string;
subject: string;
body_text?: string | null;
status: string;
availability: "available" | "withdrawn" | "expired";
classification: string;
sender_label?: string | null;
delivered_at: string;
read_at?: string | null;
acknowledged_at?: string | null;
expires_at?: string | null;
withdrawn_at?: string | null;
producer_module?: string | null;
producer_resource_type?: string | null;
producer_resource_id?: string | null;
in_reply_to_message_id?: string | null;
replaces_message_id?: string | null;
encryption_profile: PostboxProtectionProfileId;
key_epoch: number;
ciphertext_ref?: string | null;
signed_manifest_ref?: string | null;
wrapped_keys: Array<{
recipient_type: string;
recipient_id: string;
key_epoch: number;
wrapped_key_ref: string;
algorithm?: string | null;
metadata: Record<string, unknown>;
}>;
external_recipient_tokens: Array<{
token_id: string;
state: "pending" | "available" | "fetched" | "expired" | "revoked";
expires_at?: string | null;
one_time: boolean;
key_fetched_at?: string | null;
revoked_at?: string | null;
assurance_profile?: string | null;
metadata: Record<string, unknown>;
}>;
participants: PostboxParticipant[];
attachments: PostboxAttachment[];
metadata: Record<string, unknown>;
};
export type PostboxGrouping = {
id: string;
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;
updated_at: string;
};
export type PostboxOrganizationFunction = {
id: string;
slug: string;
name: string;
function_type_id?: string | null;
delegable: boolean;
act_in_place_allowed: boolean;
};
export type PostboxOrganizationUnit = {
id: string;
slug: string;
name: string;
unit_type_id?: string | null;
parent_id?: string | null;
functions: PostboxOrganizationFunction[];
};
export type PostboxOrganizationRelationType = {
id: string;
slug: string;
name: string;
structure_id?: string | null;
is_hierarchical: boolean;
status: string;
};
export type PostboxOrganizationStructure = {
id: string;
slug: string;
name: string;
structure_kind: string;
status: string;
relation_types: PostboxOrganizationRelationType[];
};
export type PostboxOrganizationTargets = {
units: PostboxOrganizationUnit[];
structures: PostboxOrganizationStructure[];
};
export type PostboxRoutingPolicy = {
linked_copy: {
enabled: boolean;
structure_id?: string | null;
relation_type_ids: string[];
max_depth: number;
stop_unit_id?: string | null;
stop_unit_type_id?: string | null;
target_function_type_id?: string | null;
target_template_id?: string | null;
fanout: "nearest" | "all";
allowed_classifications: string[];
allowed_producer_modules: string[];
require_expiry: boolean;
max_retention_days?: number | null;
};
attention: {
mode: "none" | "vacancy_escalation";
delay_minutes?: number | null;
};
shared_visibility: {
mode: "none";
};
};
export type PostboxTemplateRevision = {
id: string;
revision: number;
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>;
published_at?: string | null;
created_at: string;
};
export type PostboxTemplate = {
id: string;
tenant_id: string;
slug: string;
name: string;
description?: string | null;
status: string;
current_revision: number;
resource_revision: number;
etag: string;
published_revision_id?: string | null;
revisions: PostboxTemplateRevision[];
created_at: string;
updated_at: string;
};
export type PostboxTemplateRevisionPayload = Pick<
PostboxTemplateRevision,
| "function_type_id"
| "scope_kind"
| "scope_id"
| "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"
>;
export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
slug: string;
name: string;
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;
organization_unit_id: string;
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[];
metadata: Record<string, unknown>;
};
type PostboxGroupingPayload = Pick<
PostboxGrouping,
"name" | "is_default" | "postbox_ids"
>;
export async function listPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
settings,
"/api/v1/postbox/directory"
);
return response.postboxes;
}
export async function listPostboxMessages(
settings: ApiSettings,
postboxIds: string[],
limit = 100,
offset = 0,
query = "",
state: "all" | "unread" | "read" | "acknowledged" = "all"
): Promise<{ messages: PostboxMessage[]; total: number; limit: number; offset: number }> {
return apiFetch(
settings,
apiPath("/api/v1/postbox/messages", {
postbox_id: postboxIds,
limit,
offset,
q: query || undefined,
state
})
);
}
export function getPostboxMessage(
settings: ApiSettings,
messageId: string
): Promise<PostboxMessage> {
return apiFetch(settings, `/api/v1/postbox/messages/${encodeURIComponent(messageId)}`);
}
export 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,
state: "read" | "acknowledged"
): Promise<PostboxMessage> {
return apiFetch(
settings,
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/state`,
{
method: "PATCH",
body: JSON.stringify({ state })
}
);
}
export function createPostboxMessage(
settings: ApiSettings,
postboxId: string,
payload: PostboxMessageAuthoringPayload
): Promise<PostboxMessage> {
return apiPostJson(settings, "/api/v1/postbox/messages", {
...payload,
postbox_id: postboxId
});
}
export function replyToPostboxMessage(
settings: ApiSettings,
messageId: string,
payload: PostboxMessageAuthoringPayload
): Promise<PostboxMessage> {
return apiPostJson(
settings,
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/replies`,
payload
);
}
export async function listPostboxGroupings(settings: ApiSettings): Promise<PostboxGrouping[]> {
const response = await apiFetch<{ groupings: PostboxGrouping[] }>(
settings,
"/api/v1/postbox/groupings"
);
return response.groupings;
}
export function createPostboxGrouping(
settings: ApiSettings,
payload: PostboxGroupingPayload
): Promise<PostboxGrouping> {
return apiPostJson(settings, "/api/v1/postbox/groupings", payload);
}
export function updatePostboxGrouping(
settings: ApiSettings,
grouping: PostboxGrouping,
payload: PostboxGroupingPayload
): Promise<PostboxGrouping> {
return apiFetch(
settings,
`/api/v1/postbox/groupings/${encodeURIComponent(grouping.id)}`,
{
method: "PUT",
headers: { "If-Match": grouping.etag },
body: JSON.stringify({ ...payload, base_revision: grouping.resource_revision })
}
);
}
export function deletePostboxGrouping(
settings: ApiSettings,
grouping: PostboxGrouping
): Promise<void> {
return apiFetch(
settings,
`/api/v1/postbox/groupings/${encodeURIComponent(grouping.id)}`,
{
method: "DELETE",
headers: { "If-Match": grouping.etag },
body: JSON.stringify({ base_revision: grouping.resource_revision })
}
);
}
export async function listAdminPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
settings,
"/api/v1/postbox/admin/postboxes"
);
return response.postboxes;
}
export async function 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> {
return apiFetch<PostboxOrganizationTargets>(
settings,
"/api/v1/postbox/admin/organization-targets"
);
}
export function createExactPostbox(
settings: ApiSettings,
payload: PostboxExactCreatePayload
): Promise<PostboxDirectoryItem> {
return apiPostJson(settings, "/api/v1/postbox/admin/postboxes", payload);
}
export function archivePostbox(
settings: ApiSettings,
postbox: PostboxDirectoryItem
): Promise<PostboxDirectoryItem> {
return apiFetch(
settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}`,
{
method: "DELETE",
headers: { "If-Match": postbox.etag },
body: JSON.stringify({ base_revision: postbox.resource_revision })
}
);
}
export async function listPostboxTemplates(settings: ApiSettings): Promise<PostboxTemplate[]> {
const response = await apiFetch<{ templates: PostboxTemplate[] }>(
settings,
"/api/v1/postbox/admin/templates"
);
return response.templates;
}
export function createPostboxTemplate(
settings: ApiSettings,
payload: PostboxTemplateCreatePayload
): Promise<PostboxTemplate> {
return apiPostJson(settings, "/api/v1/postbox/admin/templates", payload);
}
export function 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,
payload: PostboxTemplateRevisionPayload
): Promise<PostboxTemplate> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/revisions`,
{ ...payload, base_revision: template.resource_revision },
{ headers: { "If-Match": template.etag } }
);
}
export function publishPostboxTemplate(
settings: ApiSettings,
template: PostboxTemplate,
revision?: number
): Promise<PostboxTemplate> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/publish`,
{ revision: revision ?? null, base_revision: template.resource_revision },
{ headers: { "If-Match": template.etag } }
);
}
export function retirePostboxTemplate(
settings: ApiSettings,
template: PostboxTemplate
): Promise<PostboxTemplate> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/retire`,
{ base_revision: template.resource_revision },
{ headers: { "If-Match": template.etag } }
);
}
export function materializePostboxTemplate(
settings: ApiSettings,
templateId: string,
payload: {
organization_unit_id: string;
function_id: string;
context_key?: string | null;
}
): Promise<PostboxDirectoryItem> {
return apiPostJson(
settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/materialize`,
payload
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,105 @@
import { useCallback } from "react";
import { Inbox, Paperclip } from "lucide-react";
import { Link } from "react-router";
import {
DashboardWidgetList,
DismissibleAlert,
LoadingFrame,
i18nMessage,
useDashboardWidgetData,
usePlatformLanguage,
type ApiSettings,
type DashboardWidgetConfiguration
} from "@govoplan/core-webui";
import {
listPostboxMessages,
listPostboxes
} from "../../api/postbox";
export default function PostboxInboxWidget({
settings,
refreshKey,
configuration
}: {
settings: ApiSettings;
refreshKey: number;
configuration: DashboardWidgetConfiguration;
}) {
const { language } = usePlatformLanguage();
const maxItems = numberSetting(configuration.maxItems, 5, 1, 12);
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 { messages: [], total: 0, separatedCount };
}
const response = await listPostboxMessages(
settings,
eligible.map((postbox) => postbox.id),
maxItems,
0,
"",
"unread"
);
return { ...response, separatedCount };
}, [maxItems, settings]);
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
return (
<LoadingFrame loading={loading} label="Loading unread Postbox messages">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
{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) => ({
id: message.id,
title: message.subject,
detail: message.sender_label || message.producer_module || "Postbox",
meta: new Intl.DateTimeFormat(language, {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit"
}).format(new Date(message.delivered_at)),
leading: <Inbox size={17} aria-hidden="true" />,
trailing: message.attachments.length ? (
<span title={i18nMessage("i18n:govoplan-postbox.attachments_label", { count: message.attachments.length })}>
<Paperclip size={15} aria-hidden="true" />
</span>
) : undefined,
to: `/postbox?message=${encodeURIComponent(message.id)}`
}))}
/>
<div className="dashboard-contribution-footer">
<Link className="btn btn-secondary" to="/postbox">
{data?.total
? i18nMessage("i18n:govoplan-postbox.open_unread", { count: data.total })
: "Open Postbox"}
</Link>
</div>
</LoadingFrame>
);
}
function numberSetting(
value: unknown,
fallback: number,
minimum: number,
maximum: number
): number {
const numeric = typeof value === "number" ? value : Number(value);
return Number.isFinite(numeric)
? Math.max(minimum, Math.min(maximum, Math.floor(numeric)))
: fallback;
}
File diff suppressed because it is too large Load Diff
@@ -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));
}
@@ -0,0 +1,52 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const POSTBOX_DOCUMENTATION = {
topicId: "postbox.function-bound-containers",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const POSTBOX_ADMIN_DOCUMENTATION = {
topicId: "postbox.function-bound-containers",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const POSTBOX_FIELD_DOCUMENTATION = {
topicId: "postbox.reference.fields-and-consequences",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const POSTBOX_INTERFACE_I18N = {
loading: "i18n:govoplan-postbox.loading_reason",
busy: "i18n:govoplan-postbox.busy_reason",
noPostbox: "i18n:govoplan-postbox.no_postbox_reason",
noMessage: "i18n:govoplan-postbox.no_message_reason",
noSendReason: "i18n:govoplan-postbox.no_send_permission_reason",
noReplyReason: "i18n:govoplan-postbox.no_reply_permission_reason",
noAcknowledgeReason: "i18n:govoplan-postbox.no_acknowledge_permission_reason",
unavailableMessage: "i18n:govoplan-postbox.unavailable_message_reason",
alreadyAcknowledged: "i18n:govoplan-postbox.already_acknowledged_reason",
retiredTemplate: "i18n:govoplan-postbox.retired_template_reason",
publishedRevision: "i18n:govoplan-postbox.published_revision_reason",
unpublishedTemplate: "i18n:govoplan-postbox.unpublished_template_reason",
archivedPostbox: "i18n:govoplan-postbox.archived_postbox_reason",
incompleteDraft: "i18n:govoplan-postbox.incomplete_draft_reason",
requiredAction: "i18n:govoplan-postbox.required_action",
actor: "i18n:govoplan-postbox.responsible_actor",
destination: "i18n:govoplan-postbox.destination",
assignmentAction: "i18n:govoplan-postbox.assignment_required_action",
assignmentActor: "i18n:govoplan-postbox.assignment_responsible_actor",
assignmentDestination: "i18n:govoplan-postbox.assignment_destination",
organizationTargetSummary: "i18n:govoplan-postbox.organization_target_summary",
organizationTargetAction: "i18n:govoplan-postbox.organization_target_action",
organizationTargetActor: "i18n:govoplan-postbox.organization_target_actor",
organizationTargetDestination: "i18n:govoplan-postbox.organization_target_destination"
} as const;
export function postboxBusyReason(
loading: boolean,
busy: boolean
): string | undefined {
if (loading) return POSTBOX_INTERFACE_I18N.loading;
if (busy) return POSTBOX_INTERFACE_I18N.busy;
return undefined;
}
+486
View File
@@ -0,0 +1,486 @@
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.",
"i18n:govoplan-postbox.communication": "Communication",
"i18n:govoplan-postbox.maximum_messages": "Maximum messages",
"i18n:govoplan-postbox.postbox_directory": "Postbox directory",
"i18n:govoplan-postbox.postbox_messages": "Postbox messages",
"i18n:govoplan-postbox.postbox_inbox_widget": "Postbox inbox widget",
"i18n:govoplan-postbox.postbox_templates_bindings": "Postbox templates and bindings",
"i18n:govoplan-postbox.loading_reason": "The Postbox data is still loading.",
"i18n:govoplan-postbox.busy_reason": "Another Postbox action is still running.",
"i18n:govoplan-postbox.no_postbox_reason": "No accessible Postbox is available for this action.",
"i18n:govoplan-postbox.no_message_reason": "Select a message before using this action.",
"i18n:govoplan-postbox.no_send_permission_reason": "Your account may not create Postbox messages.",
"i18n:govoplan-postbox.no_reply_permission_reason": "Your account may not reply to Postbox messages.",
"i18n:govoplan-postbox.no_acknowledge_permission_reason": "Your account may not acknowledge Postbox messages.",
"i18n:govoplan-postbox.unavailable_message_reason": "Withdrawn or expired messages cannot be changed.",
"i18n:govoplan-postbox.already_acknowledged_reason": "This message is already acknowledged.",
"i18n:govoplan-postbox.retired_template_reason": "Retired templates cannot receive new revisions or addresses.",
"i18n:govoplan-postbox.published_revision_reason": "This immutable revision is already published.",
"i18n:govoplan-postbox.unpublished_template_reason": "Publish the template before resolving an address.",
"i18n:govoplan-postbox.archived_postbox_reason": "Only active Postboxes can be archived.",
"i18n:govoplan-postbox.incomplete_draft_reason": "Complete all required fields before saving.",
"i18n:govoplan-postbox.required_action": "Required action",
"i18n:govoplan-postbox.responsible_actor": "Responsible actor",
"i18n:govoplan-postbox.destination": "Destination",
"i18n:govoplan-postbox.assignment_required_action": "Obtain an effective assignment to a function with a Postbox.",
"i18n:govoplan-postbox.assignment_responsible_actor": "An IDM or organization administrator",
"i18n:govoplan-postbox.assignment_destination": "IDM function assignments or Organizations",
"i18n:govoplan-postbox.organization_target_summary": "No organization function is available for a concrete Postbox address.",
"i18n:govoplan-postbox.organization_target_action": "Create or activate an organization unit and function first.",
"i18n:govoplan-postbox.organization_target_actor": "An organization administrator",
"i18n:govoplan-postbox.organization_target_destination": "Organizations",
"i18n:govoplan-postbox.archive_confirmation": "Archive \"{name}\"? Its messages and delivery evidence remain retained, but the address stops accepting new delivery.",
"i18n:govoplan-postbox.delete_grouping_confirmation": "Delete the unified view \"{name}\"? Only this personal projection is removed. Source Postboxes, messages, receipts, and evidence remain unchanged.",
"i18n:govoplan-postbox.retire_template_confirmation": "Retire \"{name}\"? Published addresses remain durable, but this template can no longer create new revisions or addresses.",
"i18n:govoplan-postbox.published_revision_message": "Published revision {revision}.",
"i18n:govoplan-postbox.resolve_address_title": "Resolve address · {name}",
"i18n:govoplan-postbox.revision_label": "Revision {revision}",
"i18n:govoplan-postbox.depth_label": "{fanout} · depth {depth}",
"i18n:govoplan-postbox.minutes_label": "{minutes} minutes",
"i18n:govoplan-postbox.attachments_label": "{count} attachments",
"i18n:govoplan-postbox.open_unread": "Open Postbox ({count} unread)",
"Refresh": "Refresh",
"New template": "New template",
"Exact postbox": "Exact postbox",
"Templates": "Templates",
"Postboxes": "Postboxes",
"Postbox administration section": "Postbox administration section",
"Archive Postbox": "Archive Postbox",
"Archive": "Archive",
"No Postbox templates.": "No Postbox templates.",
"Postbox templates": "Postbox templates",
"Template": "Template",
"No description.": "No description.",
"New revision": "New revision",
"Publish": "Publish",
"Resolve address": "Resolve address",
"Retire": "Retire",
"Retire Postbox template": "Retire Postbox template",
"Revision": "Revision",
"Function type": "Function type",
"Any function type": "Any function type",
"Scope": "Scope",
"Classification": "Classification",
"Vacant delivery": "Vacant delivery",
"Accepted": "Accepted",
"Blocked": "Blocked",
"Hierarchy copies": "Hierarchy copies",
"Disabled": "Disabled",
"Vacancy escalation": "Vacancy escalation",
"Encryption": "Encryption",
"Name pattern": "Name pattern",
"Address pattern": "Address pattern",
"Immutable revisions": "Immutable revisions",
"Published": "Published",
"Draft": "Draft",
"Select a template": "Select a template",
"Published revisions lazily resolve stable unit-specific addresses.": "Published revisions lazily resolve stable unit-specific addresses.",
"No materialized Postboxes.": "No materialized Postboxes.",
"Materialized Postboxes": "Materialized Postboxes",
"Organization unit": "Organization unit",
"Function": "Function",
"Address key": "Address key",
"Current holders": "Current holders",
"Vacancy": "Vacancy",
"Vacant": "Vacant",
"Staffed": "Staffed",
"Context": "Context",
"None": "None",
"Template revision": "Template revision",
"Exact Postbox": "Exact Postbox",
"Select a Postbox": "Select a Postbox",
"Materialized Postboxes remain durable through vacancy and reassignment.": "Materialized Postboxes remain durable through vacancy and reassignment.",
"Create template revision": "Create template revision",
"New Postbox template": "New Postbox template",
"Cancel": "Cancel",
"Create revision": "Create revision",
"Create draft": "Create draft",
"Name": "Name",
"Slug": "Slug",
"Description": "Description",
"Tenant": "Tenant",
"One unit": "One unit",
"Unit subtree": "Unit subtree",
"Unit type": "Unit type",
"Scope target": "Scope target",
"Select target": "Select target",
"Accept delivery while vacant": "Accept delivery while vacant",
"Hierarchy linked copies": "Hierarchy linked copies",
"Copy to explicitly bounded function Postboxes in one selected structure.": "Copy to explicitly bounded function Postboxes in one selected structure.",
"Enable hierarchy linked copies": "Enable hierarchy linked copies",
"Organization structure": "Organization structure",
"Select structure": "Select structure",
"Relation type": "Relation type",
"All hierarchical relations": "All hierarchical relations",
"Target function type": "Target function type",
"Select function type": "Select function type",
"Target Postbox template": "Target Postbox template",
"Select published template": "Select published template",
"Maximum hierarchy depth": "Maximum hierarchy depth",
"Copy behavior": "Copy behavior",
"Nearest matching ancestor": "Nearest matching ancestor",
"All matching ancestors": "All matching ancestors",
"Optional stop unit": "Optional stop unit",
"No unit stop": "No unit stop",
"Optional stop unit type": "Optional stop unit type",
"No unit-type stop": "No unit-type stop",
"Allowed classifications": "Allowed classifications",
"Authorized producer modules": "Authorized producer modules",
"Require message expiry": "Require message expiry",
"Maximum retention days": "Maximum retention days",
"Escalate when the nearest target remains vacant": "Escalate when the nearest target remains vacant",
"Vacancy escalation delay (minutes)": "Vacancy escalation delay (minutes)",
"Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.": "Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.",
"New exact Postbox": "New exact Postbox",
"Create Postbox": "Create Postbox",
"Generated when empty": "Generated when empty",
"Select unit": "Select unit",
"Select function": "Select function",
"Optional case or service context": "Optional case or service context",
"Manage durable organization-function addresses and reusable templates.": "Manage durable organization-function addresses and reusable templates.",
"A new immutable template revision was created.": "A new immutable template revision was created.",
"Postbox template created as a draft.": "Postbox template created as a draft.",
"Postbox template retired. Existing addresses remain durable.": "Postbox template retired. Existing addresses remain durable.",
"Exact function-bound Postbox created.": "Exact function-bound Postbox created.",
"Stable Postbox address resolved and materialized.": "Stable Postbox address resolved and materialized.",
"Postbox archived. Messages and delivery evidence were retained.": "Postbox archived. Messages and delivery evidence were retained.",
"The template is no longer available.": "The template is no longer available.",
"Postbox request failed": "Postbox request failed",
"Unsaved Postbox administration draft": "Unsaved Postbox administration draft",
"Save or discard the open Postbox administration draft before leaving this surface.": "Save or discard the open Postbox administration draft before leaving this surface.",
"Messages remain institutionally retained even when no incumbent currently has access.": "Messages remain institutionally retained even when no incumbent currently has access.",
"Copies create independent deliveries and evidence at explicitly bounded hierarchy targets.": "Copies create independent deliveries and evidence at explicitly bounded hierarchy targets.",
"Reject copied deliveries that do not carry an explicit expiry boundary.": "Reject copied deliveries that do not carry an explicit expiry boundary.",
"Schedules a separate, auditable delivery only after the configured vacancy delay.": "Schedules a separate, auditable delivery only after the configured vacancy delay.",
"New unified view": "New unified view",
"Delete unified view": "Delete unified view",
"Unsaved Postbox draft": "Unsaved Postbox draft",
"Save or discard the open Postbox draft before leaving this surface.": "Save or discard the open Postbox draft before leaving this surface.",
"The grouping is no longer available.": "The grouping is no longer available.",
"Inbox view": "Inbox view",
"All postboxes": "All postboxes",
"Edit unified view": "Edit unified view",
"Loading postboxes": "Loading postboxes",
"No assigned postboxes": "No assigned postboxes",
"Postboxes appear when your account has a current matching function assignment.": "Postboxes appear when your account has a current matching function assignment.",
"Assigned postboxes": "Assigned postboxes",
"No organization": "No organization",
"No function": "No function",
"New message": "New message",
"Refresh messages": "Refresh messages",
"Search messages": "Search messages",
"Search Postbox messages": "Search Postbox messages",
"Clear message search": "Clear message search",
"Message state": "Message state",
"All": "All",
"Unread": "Unread",
"Read": "Read",
"Acknowledged": "Acknowledged",
"Loading messages": "Loading messages",
"No messages": "No messages",
"This view has no delivered Postbox messages.": "This view has no delivered Postbox messages.",
"Postbox messages": "Postbox messages",
"Withdrawn": "Withdrawn",
"Expired": "Expired",
"Postbox message pagination": "Postbox message pagination",
"Message": "Message",
"Reply": "Reply",
"Acknowledge": "Acknowledge",
"Message unavailable": "Message unavailable",
"Select a message": "Select a message",
"Source, function context, content, and evidence remain attached to the originating postbox.": "Source, function context, content, and evidence remain attached to the originating postbox.",
"Delete": "Delete",
"Save": "Save",
"Default unified view": "Default unified view",
"Source postboxes": "Source postboxes",
"Recipients": "Recipients",
"Separate addresses with commas": "Separate addresses with commas",
"Subject": "Subject",
"Send": "Send",
"This message was withdrawn. Future access is blocked and audit metadata remains visible. Plaintext already decrypted, copied, exported, or printed cannot be retracted.": "This message was withdrawn. Future access is blocked and audit metadata remains visible. Plaintext already decrypted, copied, exported, or printed cannot be retracted.",
"This message has expired. Its audit metadata remains visible, but its content and actions are unavailable.": "This message has expired. Its audit metadata remains visible, but its content and actions are unavailable.",
"Platform": "Platform",
"Source and responsibility": "Source and responsibility",
"Organization": "Organization",
"Address": "Address",
"Producer": "Producer",
"Encryption profile": "Encryption profile",
"Key envelopes": "Key envelopes",
"External grants": "External grants",
"Not recorded": "Not recorded",
"Not loaded": "Not loaded",
"No plaintext body is available for this message.": "No plaintext body is available for this message.",
"Participants": "Participants",
"Evidence and attachments": "Evidence and attachments",
"No attachment references.": "No attachment references.",
"Current access": "Current access",
"Platform-native message": "Platform-native message",
"Public": "Public",
"Internal": "Internal",
"Confidential": "Confidential",
"Restricted": "Restricted",
"No unread Postbox messages.": "No unread Postbox messages.",
"Loading unread Postbox messages": "Loading unread Postbox messages",
"Open Postbox": "Open Postbox"
} satisfies Record<string, string>;
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.",
"i18n:govoplan-postbox.communication": "Kommunikation",
"i18n:govoplan-postbox.maximum_messages": "Maximale Nachrichtenanzahl",
"i18n:govoplan-postbox.postbox_directory": "Postfachverzeichnis",
"i18n:govoplan-postbox.postbox_messages": "Postfachnachrichten",
"i18n:govoplan-postbox.postbox_inbox_widget": "Postfach-Eingangs-Widget",
"i18n:govoplan-postbox.postbox_templates_bindings": "Postfachvorlagen und Zuordnungen",
"i18n:govoplan-postbox.loading_reason": "Die Postfachdaten werden noch geladen.",
"i18n:govoplan-postbox.busy_reason": "Eine andere Postfachaktion läuft noch.",
"i18n:govoplan-postbox.no_postbox_reason": "Für diese Aktion ist kein zugängliches Postfach verfügbar.",
"i18n:govoplan-postbox.no_message_reason": "Wählen Sie zuerst eine Nachricht aus.",
"i18n:govoplan-postbox.no_send_permission_reason": "Ihr Konto darf keine Postfachnachrichten erstellen.",
"i18n:govoplan-postbox.no_reply_permission_reason": "Ihr Konto darf nicht auf Postfachnachrichten antworten.",
"i18n:govoplan-postbox.no_acknowledge_permission_reason": "Ihr Konto darf Postfachnachrichten nicht bestätigen.",
"i18n:govoplan-postbox.unavailable_message_reason": "Zurückgezogene oder abgelaufene Nachrichten können nicht geändert werden.",
"i18n:govoplan-postbox.already_acknowledged_reason": "Diese Nachricht wurde bereits bestätigt.",
"i18n:govoplan-postbox.retired_template_reason": "Stillgelegte Vorlagen können keine neuen Revisionen oder Adressen erhalten.",
"i18n:govoplan-postbox.published_revision_reason": "Diese unveränderliche Revision ist bereits veröffentlicht.",
"i18n:govoplan-postbox.unpublished_template_reason": "Veröffentlichen Sie die Vorlage, bevor Sie eine Adresse auflösen.",
"i18n:govoplan-postbox.archived_postbox_reason": "Nur aktive Postfächer können archiviert werden.",
"i18n:govoplan-postbox.incomplete_draft_reason": "Füllen Sie vor dem Speichern alle Pflichtfelder aus.",
"i18n:govoplan-postbox.required_action": "Erforderliche Aktion",
"i18n:govoplan-postbox.responsible_actor": "Verantwortliche Stelle",
"i18n:govoplan-postbox.destination": "Ziel",
"i18n:govoplan-postbox.assignment_required_action": "Eine wirksame Zuordnung zu einer Funktion mit Postfach erhalten.",
"i18n:govoplan-postbox.assignment_responsible_actor": "IDM- oder Organisationsadministration",
"i18n:govoplan-postbox.assignment_destination": "IDM-Funktionszuordnungen oder Organisationen",
"i18n:govoplan-postbox.organization_target_summary": "Für eine konkrete Postfachadresse ist keine Organisationsfunktion verfügbar.",
"i18n:govoplan-postbox.organization_target_action": "Erstellen oder aktivieren Sie zuerst eine Organisationseinheit und Funktion.",
"i18n:govoplan-postbox.organization_target_actor": "Organisationsadministration",
"i18n:govoplan-postbox.organization_target_destination": "Organisationen",
"i18n:govoplan-postbox.archive_confirmation": "\"{name}\" archivieren? Nachrichten und Zustellnachweise bleiben erhalten, die Adresse nimmt jedoch keine neuen Zustellungen an.",
"i18n:govoplan-postbox.delete_grouping_confirmation": "Die zusammengeführte Ansicht \"{name}\" löschen? Nur diese persönliche Projektion wird entfernt. Quellpostfächer, Nachrichten, Bestätigungen und Nachweise bleiben unverändert.",
"i18n:govoplan-postbox.retire_template_confirmation": "\"{name}\" stilllegen? Veröffentlichte Adressen bleiben dauerhaft, diese Vorlage kann jedoch keine neuen Revisionen oder Adressen mehr erstellen.",
"i18n:govoplan-postbox.published_revision_message": "Revision {revision} veröffentlicht.",
"i18n:govoplan-postbox.resolve_address_title": "Adresse auflösen · {name}",
"i18n:govoplan-postbox.revision_label": "Revision {revision}",
"i18n:govoplan-postbox.depth_label": "{fanout} · Tiefe {depth}",
"i18n:govoplan-postbox.minutes_label": "{minutes} Minuten",
"i18n:govoplan-postbox.attachments_label": "{count} Anhänge",
"i18n:govoplan-postbox.open_unread": "Postfach öffnen ({count} ungelesen)",
"Refresh": "Aktualisieren",
"New template": "Neue Vorlage",
"Exact postbox": "Konkretes Postfach",
"Templates": "Vorlagen",
"Postboxes": "Postfächer",
"Postbox administration section": "Postfach-Verwaltungsbereich",
"Archive Postbox": "Postfach archivieren",
"Archive": "Archivieren",
"No Postbox templates.": "Keine Postfachvorlagen vorhanden.",
"Postbox templates": "Postfachvorlagen",
"Template": "Vorlage",
"No description.": "Keine Beschreibung.",
"New revision": "Neue Revision",
"Publish": "Veröffentlichen",
"Resolve address": "Adresse auflösen",
"Retire": "Stilllegen",
"Retire Postbox template": "Postfachvorlage stilllegen",
"Revision": "Revision",
"Function type": "Funktionstyp",
"Any function type": "Beliebiger Funktionstyp",
"Scope": "Geltungsbereich",
"Classification": "Klassifizierung",
"Vacant delivery": "Zustellung bei Vakanz",
"Accepted": "Angenommen",
"Blocked": "Blockiert",
"Hierarchy copies": "Hierarchiekopien",
"Disabled": "Deaktiviert",
"Vacancy escalation": "Vakanzeskalation",
"Encryption": "Verschlüsselung",
"Name pattern": "Namensmuster",
"Address pattern": "Adressmuster",
"Immutable revisions": "Unveränderliche Revisionen",
"Published": "Veröffentlicht",
"Draft": "Entwurf",
"Select a template": "Vorlage auswählen",
"Published revisions lazily resolve stable unit-specific addresses.": "Veröffentlichte Revisionen lösen stabile einheitsspezifische Adressen bei Bedarf auf.",
"No materialized Postboxes.": "Keine materialisierten Postfächer.",
"Materialized Postboxes": "Materialisierte Postfächer",
"Organization unit": "Organisationseinheit",
"Function": "Funktion",
"Address key": "Adressschlüssel",
"Current holders": "Aktuelle Inhaber",
"Vacancy": "Vakanz",
"Vacant": "Unbesetzt",
"Staffed": "Besetzt",
"Context": "Kontext",
"None": "Keiner",
"Template revision": "Vorlagenrevision",
"Exact Postbox": "Konkretes Postfach",
"Select a Postbox": "Postfach auswählen",
"Materialized Postboxes remain durable through vacancy and reassignment.": "Materialisierte Postfächer bleiben bei Vakanz und Neuzuordnung dauerhaft bestehen.",
"Create template revision": "Vorlagenrevision erstellen",
"New Postbox template": "Neue Postfachvorlage",
"Cancel": "Abbrechen",
"Create revision": "Revision erstellen",
"Create draft": "Entwurf erstellen",
"Name": "Name",
"Slug": "Kurzname",
"Description": "Beschreibung",
"Tenant": "Mandant",
"One unit": "Eine Einheit",
"Unit subtree": "Teilbaum einer Einheit",
"Unit type": "Einheitstyp",
"Scope target": "Ziel des Geltungsbereichs",
"Select target": "Ziel auswählen",
"Accept delivery while vacant": "Zustellung bei Vakanz annehmen",
"Hierarchy linked copies": "Verknüpfte Hierarchiekopien",
"Copy to explicitly bounded function Postboxes in one selected structure.": "In ausdrücklich begrenzte Funktionspostfächer einer ausgewählten Struktur kopieren.",
"Enable hierarchy linked copies": "Verknüpfte Hierarchiekopien aktivieren",
"Organization structure": "Organisationsstruktur",
"Select structure": "Struktur auswählen",
"Relation type": "Beziehungstyp",
"All hierarchical relations": "Alle hierarchischen Beziehungen",
"Target function type": "Zielfunktionstyp",
"Select function type": "Funktionstyp auswählen",
"Target Postbox template": "Ziel-Postfachvorlage",
"Select published template": "Veröffentlichte Vorlage auswählen",
"Maximum hierarchy depth": "Maximale Hierarchietiefe",
"Copy behavior": "Kopierverhalten",
"Nearest matching ancestor": "Nächster passender Vorfahr",
"All matching ancestors": "Alle passenden Vorfahren",
"Optional stop unit": "Optionale Stoppeinheit",
"No unit stop": "Keine Stoppeinheit",
"Optional stop unit type": "Optionaler Stoppeinheitstyp",
"No unit-type stop": "Kein Einheitstyp-Stopp",
"Allowed classifications": "Zulässige Klassifizierungen",
"Authorized producer modules": "Autorisierte Erzeugermodule",
"Require message expiry": "Ablauf der Nachricht verlangen",
"Maximum retention days": "Maximale Aufbewahrungstage",
"Escalate when the nearest target remains vacant": "Eskalieren, wenn das nächste Ziel unbesetzt bleibt",
"Vacancy escalation delay (minutes)": "Verzögerung der Vakanzeskalation (Minuten)",
"Available pattern variables include template, unit, function, and optional context names or slugs. Published revisions are immutable.": "Verfügbare Mustervariablen umfassen Vorlage, Einheit, Funktion und optionale Kontextnamen oder Kurznamen. Veröffentlichte Revisionen sind unveränderlich.",
"New exact Postbox": "Neues konkretes Postfach",
"Create Postbox": "Postfach erstellen",
"Generated when empty": "Wird leer automatisch erzeugt",
"Select unit": "Einheit auswählen",
"Select function": "Funktion auswählen",
"Optional case or service context": "Optionaler Fall- oder Dienstkontext",
"Manage durable organization-function addresses and reusable templates.": "Dauerhafte Organisationsfunktionsadressen und wiederverwendbare Vorlagen verwalten.",
"A new immutable template revision was created.": "Eine neue unveränderliche Vorlagenrevision wurde erstellt.",
"Postbox template created as a draft.": "Postfachvorlage wurde als Entwurf erstellt.",
"Postbox template retired. Existing addresses remain durable.": "Postfachvorlage wurde stillgelegt. Bestehende Adressen bleiben dauerhaft.",
"Exact function-bound Postbox created.": "Konkretes funktionsgebundenes Postfach wurde erstellt.",
"Stable Postbox address resolved and materialized.": "Stabile Postfachadresse wurde aufgelöst und materialisiert.",
"Postbox archived. Messages and delivery evidence were retained.": "Postfach wurde archiviert. Nachrichten und Zustellnachweise blieben erhalten.",
"The template is no longer available.": "Die Vorlage ist nicht mehr verfügbar.",
"Postbox request failed": "Postfachanfrage fehlgeschlagen",
"Unsaved Postbox administration draft": "Ungespeicherter Postfach-Verwaltungsentwurf",
"Save or discard the open Postbox administration draft before leaving this surface.": "Speichern oder verwerfen Sie den offenen Postfach-Verwaltungsentwurf, bevor Sie diesen Bereich verlassen.",
"Messages remain institutionally retained even when no incumbent currently has access.": "Nachrichten bleiben institutionell erhalten, auch wenn aktuell kein Funktionsinhaber Zugriff hat.",
"Copies create independent deliveries and evidence at explicitly bounded hierarchy targets.": "Kopien erzeugen eigenständige Zustellungen und Nachweise an ausdrücklich begrenzten Hierarchiezielen.",
"Reject copied deliveries that do not carry an explicit expiry boundary.": "Kopierte Zustellungen ohne ausdrückliche Ablaufgrenze ablehnen.",
"Schedules a separate, auditable delivery only after the configured vacancy delay.": "Plant erst nach der konfigurierten Vakanzverzögerung eine separate, prüfbare Zustellung.",
"New unified view": "Neue zusammengeführte Ansicht",
"Delete unified view": "Zusammengeführte Ansicht löschen",
"Unsaved Postbox draft": "Ungespeicherter Postfachentwurf",
"Save or discard the open Postbox draft before leaving this surface.": "Speichern oder verwerfen Sie den offenen Postfachentwurf, bevor Sie diesen Bereich verlassen.",
"The grouping is no longer available.": "Die zusammengeführte Ansicht ist nicht mehr verfügbar.",
"Inbox view": "Eingangsansicht",
"All postboxes": "Alle Postfächer",
"Edit unified view": "Zusammengeführte Ansicht bearbeiten",
"Loading postboxes": "Postfächer werden geladen",
"No assigned postboxes": "Keine zugewiesenen Postfächer",
"Postboxes appear when your account has a current matching function assignment.": "Postfächer erscheinen, wenn Ihr Konto eine aktuelle passende Funktionszuordnung hat.",
"Assigned postboxes": "Zugewiesene Postfächer",
"No organization": "Keine Organisation",
"No function": "Keine Funktion",
"New message": "Neue Nachricht",
"Refresh messages": "Nachrichten aktualisieren",
"Search messages": "Nachrichten suchen",
"Search Postbox messages": "Postfachnachrichten suchen",
"Clear message search": "Nachrichtensuche leeren",
"Message state": "Nachrichtenstatus",
"All": "Alle",
"Unread": "Ungelesen",
"Read": "Gelesen",
"Acknowledged": "Bestätigt",
"Loading messages": "Nachrichten werden geladen",
"No messages": "Keine Nachrichten",
"This view has no delivered Postbox messages.": "Diese Ansicht enthält keine zugestellten Postfachnachrichten.",
"Postbox messages": "Postfachnachrichten",
"Withdrawn": "Zurückgezogen",
"Expired": "Abgelaufen",
"Postbox message pagination": "Seitennavigation der Postfachnachrichten",
"Message": "Nachricht",
"Reply": "Antworten",
"Acknowledge": "Bestätigen",
"Message unavailable": "Nachricht nicht verfügbar",
"Select a message": "Nachricht auswählen",
"Source, function context, content, and evidence remain attached to the originating postbox.": "Quelle, Funktionskontext, Inhalt und Nachweise bleiben dem Ursprungspostfach zugeordnet.",
"Delete": "Löschen",
"Save": "Speichern",
"Default unified view": "Standardansicht",
"Source postboxes": "Quellpostfächer",
"Recipients": "Empfänger",
"Separate addresses with commas": "Adressen durch Kommas trennen",
"Subject": "Betreff",
"Send": "Senden",
"This message was withdrawn. Future access is blocked and audit metadata remains visible. Plaintext already decrypted, copied, exported, or printed cannot be retracted.": "Diese Nachricht wurde zurückgezogen. Künftiger Zugriff ist gesperrt und Prüfmetadaten bleiben sichtbar. Bereits entschlüsselter, kopierter, exportierter oder gedruckter Klartext kann nicht zurückgerufen werden.",
"This message has expired. Its audit metadata remains visible, but its content and actions are unavailable.": "Diese Nachricht ist abgelaufen. Ihre Prüfmetadaten bleiben sichtbar, Inhalt und Aktionen sind jedoch nicht verfügbar.",
"Platform": "Plattform",
"Source and responsibility": "Quelle und Verantwortung",
"Organization": "Organisation",
"Address": "Adresse",
"Producer": "Erzeuger",
"Encryption profile": "Verschlüsselungsprofil",
"Key envelopes": "Schlüsselumschläge",
"External grants": "Externe Freigaben",
"Not recorded": "Nicht erfasst",
"Not loaded": "Nicht geladen",
"No plaintext body is available for this message.": "Für diese Nachricht ist kein Klartextinhalt verfügbar.",
"Participants": "Beteiligte",
"Evidence and attachments": "Nachweise und Anhänge",
"No attachment references.": "Keine Anhangsreferenzen.",
"Current access": "Aktueller Zugriff",
"Platform-native message": "Plattformeigene Nachricht",
"Public": "Öffentlich",
"Internal": "Intern",
"Confidential": "Vertraulich",
"Restricted": "Beschränkt",
"No unread Postbox messages.": "Keine ungelesenen Postfachnachrichten.",
"Loading unread Postbox messages": "Ungelesene Postfachnachrichten werden geladen",
"Open Postbox": "Postfach öffnen"
} satisfies Record<keyof typeof en, string>;
export const generatedTranslations: PlatformTranslations = { en, de };
+3
View File
@@ -0,0 +1,3 @@
export { postboxModule as default, postboxModule } from "./module";
export { default as PostboxPage } from "./features/postbox/PostboxPage";
export { default as PostboxAdminPanel } from "./features/postbox/PostboxAdminPanel";
+181
View File
@@ -0,0 +1,181 @@
import { createElement, lazy } from "react";
import {
hasScope,
type AdminSectionsUiCapability,
type DashboardWidgetsUiCapability,
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";
const PostboxPage = lazy(() => import("./features/postbox/PostboxPage"));
const PostboxAdminPanel = lazy(
() => import("./features/postbox/PostboxAdminPanel")
);
const translations = {
en: generatedTranslations.en,
de: generatedTranslations.de
};
const readScope = ["postbox:postbox:read"];
const postboxDashboardWidgets: DashboardWidgetsUiCapability = {
widgets: [
{
id: "postbox.inbox",
surfaceId: "postbox.widget.inbox",
title: "i18n:govoplan-postbox.postbox_inbox",
description: "i18n:govoplan-postbox.postbox_inbox_description",
moduleId: "postbox",
category: "i18n:govoplan-postbox.communication",
order: 55,
defaultVisible: false,
defaultSize: "medium",
supportedSizes: ["medium", "wide"],
anyOf: readScope,
refreshIntervalMs: 30_000,
defaultConfiguration: {
maxItems: 5
},
configurationFields: [
{
id: "maxItems",
label: "i18n:govoplan-postbox.maximum_messages",
kind: "number",
min: 1,
max: 12,
step: 1,
required: true
}
],
render: ({ settings, refreshKey, configuration }) =>
createElement(PostboxInboxWidget, {
settings,
refreshKey,
configuration
})
}
]
};
const postboxQuickAccessTools: QuickAccessToolsUiCapability = {
tools: [
{
id: "postbox.messages",
render: (context) => createElement(PostboxQuickAccess, context)
}
]
};
const postboxAdminSections: AdminSectionsUiCapability = {
sections: [
{
id: "postbox",
moduleId: "postbox",
kind: "management",
label: "i18n:govoplan-postbox.postboxes",
group: "TENANT",
order: 45,
surfaceId: "postbox.admin.templates",
anyOf: [
"postbox:binding:admin",
"postbox:template:admin"
],
render: ({ settings, auth }) =>
createElement(PostboxAdminPanel, {
settings,
canManageBindings: hasScope(auth, "postbox:binding:admin"),
canManageTemplates: hasScope(auth, "postbox:template:admin")
})
}
]
};
export const postboxModule: PlatformWebModule = {
id: "postbox",
label: "i18n:govoplan-postbox.postbox",
version: "0.1.19",
dependencies: ["identity", "organizations", "idm"],
optionalDependencies: [
"access",
"audit",
"campaigns",
"encryption",
"files",
"mail",
"notifications",
"policy",
"portal",
"search",
"tasks",
"views",
"workflow_engine"
],
translations,
navItems: [
{
to: "/postbox",
label: "i18n:govoplan-postbox.postbox",
iconName: "inbox",
anyOf: readScope,
order: 58
}
],
routes: [
{
path: "/postbox",
anyOf: readScope,
order: 58,
surfaceId: "postbox.inbox.messages",
render: ({ settings, auth }) =>
createElement(PostboxPage, { settings, auth })
}
],
viewSurfaces: [
{
id: "postbox.inbox.directory",
moduleId: "postbox",
kind: "section",
label: "i18n:govoplan-postbox.postbox_directory",
order: 10
},
{
id: "postbox.inbox.messages",
moduleId: "postbox",
kind: "section",
label: "i18n:govoplan-postbox.postbox_messages",
order: 20
},
{
id: "postbox.widget.inbox",
moduleId: "postbox",
kind: "section",
label: "i18n:govoplan-postbox.postbox_inbox_widget",
order: 25
},
{
id: "postbox.admin.templates",
moduleId: "postbox",
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,
"quickAccess.tools": postboxQuickAccessTools
}
};
export default postboxModule;
+733
View File
@@ -0,0 +1,733 @@
.postbox-page {
position: relative;
display: grid;
grid-template-rows: minmax(0, 1fr);
height: calc(100vh - 115px);
min-height: 0;
overflow: hidden;
padding: 0;
color: var(--text);
background: var(--bg);
}
.postbox-page *,
.postbox-page *::before,
.postbox-page *::after,
.postbox-admin-page *,
.postbox-admin-page *::before,
.postbox-admin-page *::after {
box-sizing: border-box;
}
.postbox-shell {
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns:
minmax(250px, 310px)
minmax(290px, 370px)
minmax(360px, 1fr);
height: 100%;
overflow: hidden;
background: var(--panel);
}
.postbox-directory,
.postbox-message-list,
.postbox-detail {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--panel);
}
.postbox-directory,
.postbox-message-list {
border-right: var(--border-line);
}
.postbox-directory {
background: var(--panel-soft);
}
.postbox-bar-title,
.postbox-icon-actions,
.postbox-scope-row,
.postbox-item-title,
.postbox-message-heading,
.postbox-message-meta,
.postbox-detail-status,
.postbox-detail-byline,
.postbox-access-explanation,
.postbox-attachments > div,
.postbox-participants > div {
display: flex;
align-items: center;
}
.postbox-bar-title {
min-width: 0;
gap: 8px;
color: var(--text-strong);
}
.postbox-bar-title strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-icon-actions {
gap: 5px;
}
.postbox-scope-control {
flex: 0 0 auto;
border-bottom: var(--border-line);
padding: 9px 10px;
}
.postbox-scope-control > label {
display: block;
margin-bottom: 5px;
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.postbox-scope-row {
gap: 6px;
}
.postbox-scope-row select {
min-width: 0;
flex: 1;
}
.postbox-directory-list,
.postbox-messages {
min-height: 0;
overflow: auto;
padding: 7px;
}
.postbox-directory-list,
.postbox-messages {
flex: 1 1 auto;
}
.postbox-message-filters {
flex: 0 0 auto;
display: grid;
gap: 7px;
border-bottom: var(--border-line);
padding: 8px 9px;
}
.postbox-search-row {
width: 100%;
}
.postbox-search-row input {
min-width: 0;
flex: 1;
}
.postbox-message-list > .data-grid-pagination {
flex: 0 0 auto;
flex-wrap: wrap;
gap: 7px 12px;
border-top: var(--border-line);
}
.postbox-message-list > .data-grid-pagination .data-grid-page-controls {
width: 100%;
justify-content: center;
}
.postbox-directory-list .selection-list,
.postbox-messages .selection-list,
.postbox-admin-list .selection-list {
gap: 4px;
}
.postbox-directory-item,
.postbox-message-item,
.postbox-admin-list .selection-list-item {
display: grid;
min-height: 64px;
gap: 4px;
align-content: center;
text-align: left;
}
.postbox-item-title,
.postbox-message-heading {
min-width: 0;
justify-content: space-between;
gap: 10px;
}
.postbox-item-title strong,
.postbox-message-heading strong {
min-width: 0;
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-item-context,
.postbox-item-address,
.postbox-message-preview,
.postbox-message-meta {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-message-item.is-read strong {
font-weight: 550;
}
.postbox-message-item.is-unread strong {
font-weight: 800;
}
.postbox-message-heading time {
flex: 0 0 auto;
color: var(--muted);
font-size: 11px;
}
.postbox-message-meta {
justify-content: flex-start;
gap: 10px;
}
.postbox-message-meta span {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 3px;
}
.postbox-message-list > .alert {
flex: 0 0 auto;
margin: 8px 10px 0;
}
.postbox-message-detail {
min-height: 0;
overflow: auto;
padding: 20px 24px 28px;
}
.postbox-message-detail > header,
.postbox-provenance,
.postbox-body,
.postbox-participants,
.postbox-attachments {
border-bottom: var(--border-line);
padding-bottom: 18px;
margin-bottom: 18px;
}
.postbox-detail-status {
flex-wrap: wrap;
gap: 6px;
}
.postbox-message-detail h1 {
max-width: 900px;
margin: 12px 0 9px;
color: var(--text-strong);
font-size: 24px;
font-weight: 650;
overflow-wrap: anywhere;
}
.postbox-detail-byline {
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 12px;
}
.postbox-provenance h2,
.postbox-participants h2,
.postbox-attachments h2 {
margin: 0 0 10px;
color: var(--text-strong);
font-size: 14px;
}
.postbox-provenance dl,
.postbox-admin-properties {
display: grid;
grid-template-columns: repeat(2, minmax(170px, 1fr));
gap: 10px 18px;
margin: 0;
}
.postbox-provenance dt,
.postbox-admin-properties dt {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.postbox-provenance dd,
.postbox-admin-properties dd {
min-width: 0;
margin: 3px 0 0;
overflow-wrap: anywhere;
}
.postbox-body p {
max-width: 900px;
margin: 0;
line-height: 1.6;
white-space: pre-wrap;
}
.postbox-participants,
.postbox-attachments {
display: grid;
gap: 7px;
}
.postbox-participants > div,
.postbox-attachments > div {
justify-content: flex-start;
gap: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
padding: 9px 10px;
}
.postbox-participants > div strong {
min-width: 90px;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
}
.postbox-attachments > p {
margin: 0;
color: var(--muted);
}
.postbox-attachments > div span {
min-width: 0;
display: grid;
gap: 2px;
}
.postbox-attachments > div strong,
.postbox-attachments > div small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-attachments > div small {
color: var(--muted);
}
.postbox-access-explanation {
align-items: flex-start;
gap: 10px;
border-left: 3px solid var(--green);
background: var(--success-soft);
padding: 10px 12px;
}
.postbox-access-explanation p {
margin: 3px 0 0;
color: var(--muted);
line-height: 1.4;
}
.postbox-note {
max-width: 470px;
margin: 0;
color: var(--muted);
font-size: 13px;
}
.postbox-dialog {
width: min(720px, calc(100vw - 32px));
max-width: none;
}
.postbox-template-dialog {
width: min(900px, calc(100vw - 32px));
}
.postbox-form-grid input,
.postbox-form-grid select {
width: 100%;
}
.postbox-toggle-field {
min-height: 62px;
display: flex;
align-items: flex-end;
padding-bottom: 7px;
}
.postbox-routing-section {
grid-column: 1 / -1;
display: grid;
gap: 14px;
border-top: var(--border-line);
padding-top: 16px;
}
.postbox-routing-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
}
.postbox-routing-heading > div:first-child {
display: grid;
gap: 3px;
}
.postbox-routing-heading span {
color: var(--muted);
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);
font-size: 12px;
line-height: 1.5;
}
.postbox-dialog-actions {
width: 100%;
display: flex;
justify-content: space-between;
gap: 12px;
}
.postbox-source-selector {
max-height: 310px;
display: grid;
gap: 5px;
overflow: auto;
border: var(--border-line);
margin: 16px 0 0;
padding: 10px;
}
.postbox-source-selector legend {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.postbox-source-selector label {
display: flex;
align-items: center;
gap: 9px;
border-radius: var(--radius-sm);
padding: 7px 8px;
}
.postbox-source-selector label:hover {
background: var(--sidebar-hover-bg);
}
.postbox-source-selector label span {
min-width: 0;
display: grid;
gap: 2px;
}
.postbox-source-selector small {
color: var(--muted);
}
.postbox-message-dialog {
width: min(720px, calc(100vw - 32px));
}
.postbox-compose-grid {
grid-template-columns: minmax(0, 2fr) minmax(160px, 1fr);
}
.postbox-compose-wide {
grid-column: 1 / -1;
}
.postbox-compose-grid textarea {
width: 100%;
min-height: 180px;
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;
}
.postbox-admin-page > .segmented-control {
margin-bottom: 14px;
}
.postbox-admin-workspace {
min-height: 520px;
display: grid;
grid-template-columns: minmax(250px, 320px) minmax(0, 1fr);
border: var(--border-line);
background: var(--panel);
overflow: hidden;
}
.postbox-admin-list {
min-width: 0;
min-height: 0;
max-height: 680px;
overflow: auto;
border-right: var(--border-line);
background: var(--panel-soft);
padding: 8px;
}
.postbox-admin-detail {
min-width: 0;
min-height: 0;
max-height: 680px;
overflow: auto;
padding: 20px 24px 26px;
}
.postbox-admin-detail-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
border-bottom: var(--border-line);
padding-bottom: 17px;
margin-bottom: 18px;
}
.postbox-admin-detail-heading h2 {
margin: 2px 0 4px;
color: var(--text-strong);
font-size: 20px;
}
.postbox-admin-detail-heading p {
margin: 0;
color: var(--muted);
}
.postbox-detail-kicker {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.postbox-admin-properties {
border-bottom: var(--border-line);
padding-bottom: 18px;
}
.postbox-revision-history {
margin-top: 18px;
}
.postbox-revision-history h3 {
margin: 0 0 9px;
color: var(--text-strong);
font-size: 14px;
}
.postbox-revision-history > div {
display: grid;
grid-template-columns: minmax(120px, 1fr) minmax(160px, 2fr) auto;
align-items: center;
gap: 10px;
border-top: var(--border-line);
padding: 9px 0;
}
.postbox-revision-history > div span:not(.status-badge) {
color: var(--muted);
}
@media (max-width: 1280px) {
.postbox-shell {
grid-template-columns: minmax(230px, 290px) minmax(280px, 340px) minmax(330px, 1fr);
overflow-x: auto;
}
}
@media (max-width: 900px) {
.postbox-page {
height: auto;
min-height: calc(100vh - 115px);
overflow: auto;
}
.postbox-shell {
grid-template-columns: 1fr;
height: auto;
overflow: visible;
}
.postbox-directory,
.postbox-message-list {
min-height: 260px;
max-height: 42vh;
border-right: 0;
border-bottom: var(--border-line);
}
.postbox-detail {
min-height: 440px;
}
.postbox-admin-workspace {
grid-template-columns: 1fr;
}
.postbox-admin-list {
max-height: 260px;
border-right: 0;
border-bottom: var(--border-line);
}
.postbox-admin-detail-heading {
flex-direction: column;
}
}
@media (max-width: 680px) {
.postbox-provenance dl,
.postbox-admin-properties {
grid-template-columns: 1fr;
}
.postbox-message-detail {
padding: 16px;
}
.postbox-routing-heading {
align-items: flex-start;
flex-direction: column;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "tests"]
}