21 Commits
Author SHA1 Message Date
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
50 changed files with 18243 additions and 48 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
+11 -4
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
@@ -25,7 +31,8 @@ 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.
+74 -7
View File
@@ -4,7 +4,8 @@
**Repository type:** module (domain). **Repository type:** module (domain).
<!-- govoplan-repository-type:end --> <!-- govoplan-repository-type:end -->
GovOPlaN Postbox provides platform-owned postboxes for internal work, portals, campaign flows, and role-bound organizational communication. GovOPlaN Postbox provides platform-owned postboxes for internal work, portals,
campaign flows, and function-bound organizational communication.
## Ownership ## Ownership
@@ -13,18 +14,47 @@ 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.
Users holding several functions may group selected postboxes into unified
inbox views. These are query projections only: messages, address, read state,
retention, and evidence remain attached to their source postboxes.
Hierarchy propagation is off by default. Explicit copy, attention/escalation,
and shared-visibility rules are distinct, bounded, classification-aware, and
snapshotted when a message is delivered.
## Module integration ## Module integration
@@ -42,3 +72,40 @@ 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. 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.
Postboxes support `plaintext_v1` and an optional `server_envelope_v1` profile.
The latter stores message bodies as ciphertext through the Encryption
capability and fails closed on reads if that capability or key is unavailable.
Subjects, participants, routing, attachment references, and lifecycle metadata
remain observable. Existing externally produced ciphertext references remain
supported, but neither path is described as end-to-end encryption.
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.
+271 -37
View File
@@ -4,54 +4,105 @@
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 an encrypted administrative postbox. The current
implementation may start with ordinary persisted messages, but the model must implementation supports ordinary persisted messages and an optional
not prevent later end-to-end encryption, role/function key epochs, signed server-readable Encryption envelope for message bodies. The model also retains
manifests, external-recipient tokens, or honest retraction semantics. The external ciphertext, wrapped-key, signed-manifest, external-recipient-token,
and key-epoch metadata needed for later independently reviewed E2EE profiles.
The server-envelope profile is not E2EE, and subjects, routing, participants,
and attachment references remain visible. The
cross-module target architecture is recorded in 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 +113,118 @@ 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.
Addresses should be resolved lazily and idempotently rather than eagerly
creating empty containers for every unit. They remain durable through vacancy
and reassignment. A delivery snapshots the template revision and normalized
organization/function references so later hierarchy changes do not rewrite
history.
Exact postboxes remain useful for exceptional responsibilities that do not
belong to a reusable function type.
## Unified Inbox Projections
A user with several functions can group selected visible postboxes into named
unified inbox views and keep other responsibilities separate. Grouping is a
query projection only. It never merges source containers, messages, read or
acknowledgement state, retention, encryption keys, or audit evidence.
Every item and action continues to show the source function, unit, postbox,
assignment/delegation context, and classification. Policy may require some
postboxes to remain separate.
## Hierarchy Routing
Hierarchy behavior is disabled by default. The system distinguishes:
- a linked copy delivered to a parent function postbox
- attention or escalation metadata sent to a parent responsibility
- shared visibility over the original message
These have different privacy, retention, acknowledgement, and audit effects
and must not be treated as synonyms.
The first production slice should implement explicit linked-copy routing with
a selected structure, target function mapping, maximum depth, stop condition,
classification gate, loop protection, and delivery-time route snapshot.
Organization changes do not retroactively expose old messages.
Vacancy is a visible delivery/attention state rather than an automatic grant
to an unrelated personal account. Policy may trigger a bounded escalation
after a delay.
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 +241,23 @@ 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.
## 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.
- 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 +267,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,13 +326,29 @@ 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.
### Current content-protection profile
An exact Postbox or template revision may select `server_envelope_v1` and an
Encryption vault. New locally authored and delivered message bodies are then
stored in `body_ciphertext` with an owner-bound envelope reference; clear body
text is not persisted. Reads ask the optional `encryption.content_cipher`
capability to open the exact tenant, message, and envelope tuple. Missing
Encryption, a lost deployment key, a destroyed vault key, ciphertext tampering,
or a mismatched resource causes a fail-closed read.
Plaintext Postboxes continue to work without Encryption. A protected Postbox
cannot silently fall back to plaintext. Database recovery of protected messages
requires Postbox and Encryption tables from the same consistency point plus the
matching provider/deployment key. Hierarchy-routed copies retain the source
envelope reference rather than decrypting and re-encrypting during routing.
## E2EE Readiness Checklist ## E2EE Readiness Checklist
Before the data model is considered stable, verify that it can represent: Before the data model is considered stable, verify that it can represent:
@@ -143,3 +361,19 @@ Before the data model is considered stable, verify that it can represent:
- external recipient token state - external recipient token state
- expiry and withdrawal state separate from deletion - expiry and withdrawal state separate from deletion
- retention state that can operate without decrypting content - retention state that can operate without decrypting content
## E2EE decisions still to settle before implementation
The product direction above is selected, but the first trusted profile still
needs bounded decisions on:
- whether a new incumbent receives all retained history, history from a
policy-defined date, or only content delivered during the assignment;
- organizational recovery/escrow and the authority required when every holder
loses all registered device keys;
- whether ordinary rotation only rewraps per-content keys or also re-encrypts
ciphertext, and which events require the stronger path;
- assurance and quorum requirements for delegation, hand-over, emergency
access, export, and destructive retention; and
- how attention/escalation works during a vacancy without granting plaintext
access to an unrelated personal account.
+22
View File
@@ -0,0 +1,22 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "govoplan-postbox"
version = "0.1.16"
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.16"]
[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.16"
+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,96 @@
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,
) -> 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="postbox:configured-server-envelope:v1",
idempotency_key=f"postbox-message:{message_id}:body:v1",
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,33 @@
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAccessEvent,
PostboxAddress,
PostboxAttachmentReference,
PostboxBinding,
PostboxDelivery,
PostboxGrouping,
PostboxGroupingSource,
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
)
__all__ = [
"Postbox",
"PostboxAccessEvent",
"PostboxAddress",
"PostboxAttachmentReference",
"PostboxBinding",
"PostboxDelivery",
"PostboxGrouping",
"PostboxGroupingSource",
"PostboxMessage",
"PostboxMessageReceipt",
"PostboxParticipant",
"PostboxRoute",
"PostboxTemplate",
"PostboxTemplateRevision",
]
+962
View File
@@ -0,0 +1,962 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
JSON,
LargeBinary,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from govoplan_core.core.concurrency import strong_resource_etag
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class PostboxTemplate(Base, TimestampMixin):
__tablename__ = "postbox_templates"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"slug",
name="uq_postbox_templates_tenant_slug",
),
Index("ix_postbox_templates_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
slug: Mapped[str] = mapped_column(String(120), nullable=False)
name: Mapped[str] = mapped_column(String(250), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(24),
default="draft",
nullable=False,
index=True,
)
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
resource_revision: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
)
published_revision_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
retired_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
revisions: Mapped[list["PostboxTemplateRevision"]] = relationship(
back_populates="template",
cascade="all, delete-orphan",
order_by="PostboxTemplateRevision.revision",
)
@property
def strong_etag(self) -> str:
return strong_resource_etag(
"postbox_template",
self.id,
self.resource_revision,
)
class PostboxTemplateRevision(Base, TimestampMixin):
__tablename__ = "postbox_template_revisions"
__table_args__ = (
UniqueConstraint(
"template_id",
"revision",
name="uq_postbox_template_revision_number",
),
Index(
"ix_postbox_template_revisions_function_scope",
"tenant_id",
"function_type_id",
"scope_kind",
"scope_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
template_id: Mapped[str] = mapped_column(
ForeignKey("postbox_templates.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
function_type_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
scope_kind: Mapped[str] = mapped_column(
String(30),
default="tenant",
nullable=False,
)
scope_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
name_pattern: Mapped[str] = mapped_column(
String(500),
default="{unit_name} / {function_name}",
nullable=False,
)
address_pattern: Mapped[str] = mapped_column(
String(500),
default="{template_slug}.{unit_slug}.{function_slug}",
nullable=False,
)
classification: Mapped[str] = mapped_column(
String(50),
default="internal",
nullable=False,
)
allow_vacant_delivery: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
)
encryption_profile: Mapped[str] = mapped_column(
String(80),
default="plaintext_v1",
nullable=False,
)
encryption_vault_id: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
history_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
routing_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
retention_policy: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
created_by: Mapped[str | None] = mapped_column(String(255), nullable=True)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
template: Mapped[PostboxTemplate] = relationship(back_populates="revisions")
class PostboxAddress(Base, TimestampMixin):
__tablename__ = "postbox_addresses"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"address_key",
name="uq_postbox_addresses_tenant_key",
),
UniqueConstraint(
"tenant_id",
"address",
name="uq_postbox_addresses_tenant_address",
),
Index(
"ix_postbox_addresses_function_scope",
"tenant_id",
"organization_unit_id",
"function_id",
"context_key",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
address_key: Mapped[str] = mapped_column(String(500), nullable=False)
address: Mapped[str] = mapped_column(String(500), nullable=False)
template_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_templates.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
template_revision_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_template_revisions.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
organization_unit_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
organization_unit_name: Mapped[str | None] = mapped_column(
String(500),
nullable=True,
)
function_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
function_name: Mapped[str | None] = mapped_column(String(500), nullable=True)
function_type_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
context_key: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
status: Mapped[str] = mapped_column(
String(24),
default="active",
nullable=False,
index=True,
)
postbox: Mapped["Postbox | None"] = relationship(
back_populates="address_record",
uselist=False,
)
class Postbox(Base, TimestampMixin):
__tablename__ = "postboxes"
__table_args__ = (
UniqueConstraint("address_id", name="uq_postboxes_address"),
Index("ix_postboxes_tenant_status", "tenant_id", "status"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
address_id: Mapped[str] = mapped_column(
ForeignKey("postbox_addresses.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
name: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(
String(24),
default="active",
nullable=False,
index=True,
)
classification: Mapped[str] = mapped_column(
String(50),
default="internal",
nullable=False,
index=True,
)
encryption_profile: Mapped[str] = mapped_column(
String(80),
default="plaintext_v1",
nullable=False,
)
key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
resource_revision: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
)
settings: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
address_record: Mapped[PostboxAddress] = relationship(
back_populates="postbox",
)
bindings: Mapped[list["PostboxBinding"]] = relationship(
back_populates="postbox",
cascade="all, delete-orphan",
)
messages: Mapped[list["PostboxMessage"]] = relationship(
back_populates="postbox",
cascade="all, delete-orphan",
)
@property
def strong_etag(self) -> str:
return strong_resource_etag(
"postbox",
self.id,
self.resource_revision,
)
class PostboxBinding(Base, TimestampMixin):
__tablename__ = "postbox_bindings"
__table_args__ = (
Index(
"ix_postbox_bindings_function_scope",
"tenant_id",
"organization_unit_id",
"function_id",
"is_active",
),
Index("ix_postbox_bindings_postbox_active", "postbox_id", "is_active"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
postbox_id: Mapped[str] = mapped_column(
ForeignKey("postboxes.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
binding_type: Mapped[str] = mapped_column(
String(30),
default="function",
nullable=False,
)
organization_unit_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
function_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
function_type_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
applies_to_subunits: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
)
source: Mapped[str] = mapped_column(
String(30),
default="exact",
nullable=False,
)
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
index=True,
)
valid_from: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
valid_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
settings: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
postbox: Mapped[Postbox] = relationship(back_populates="bindings")
class PostboxMessage(Base, TimestampMixin):
__tablename__ = "postbox_messages"
__table_args__ = (
Index(
"ix_postbox_messages_postbox_delivered",
"postbox_id",
"delivered_at",
),
Index("ix_postbox_messages_tenant_status", "tenant_id", "status"),
Index(
"ix_postbox_messages_producer",
"tenant_id",
"producer_module",
"producer_resource_type",
"producer_resource_id",
),
UniqueConstraint(
"tenant_id",
"postbox_id",
"authoring_key",
name="uq_postbox_messages_authoring_key",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
postbox_id: Mapped[str] = mapped_column(
ForeignKey("postboxes.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
subject: Mapped[str] = mapped_column(String(1000), nullable=False)
body_text: Mapped[str | None] = mapped_column(Text, nullable=True)
body_ciphertext: Mapped[bytes | None] = mapped_column(
LargeBinary, nullable=True
)
status: Mapped[str] = mapped_column(
String(30),
default="delivered",
nullable=False,
index=True,
)
classification: Mapped[str] = mapped_column(
String(50),
default="internal",
nullable=False,
index=True,
)
sender_label: Mapped[str | None] = mapped_column(String(500), nullable=True)
producer_module: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
index=True,
)
producer_resource_type: Mapped[str | None] = mapped_column(
String(100),
nullable=True,
index=True,
)
producer_resource_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
authoring_key: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
in_reply_to_message_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
replaces_message_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
encryption_profile: Mapped[str] = mapped_column(
String(80),
default="plaintext_v1",
nullable=False,
)
key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
ciphertext_ref: Mapped[str | None] = mapped_column(
String(1000),
nullable=True,
)
encryption_envelope_id: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
encryption_resource_id: Mapped[str | None] = mapped_column(
String(36), nullable=True, index=True
)
signed_manifest_ref: Mapped[str | None] = mapped_column(
String(1000),
nullable=True,
)
wrapped_keys: Mapped[list[dict[str, Any]]] = mapped_column(
JSON,
default=list,
nullable=False,
)
external_recipient_tokens: Mapped[list[dict[str, Any]]] = mapped_column(
JSON,
default=list,
nullable=False,
)
delivered_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True,
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
withdrawn_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
index=True,
)
retention_hold_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
postbox: Mapped[Postbox] = relationship(back_populates="messages")
participants: Mapped[list["PostboxParticipant"]] = relationship(
back_populates="message",
cascade="all, delete-orphan",
order_by="PostboxParticipant.position",
)
attachments: Mapped[list["PostboxAttachmentReference"]] = relationship(
back_populates="message",
cascade="all, delete-orphan",
order_by="PostboxAttachmentReference.position",
)
receipts: Mapped[list["PostboxMessageReceipt"]] = relationship(
back_populates="message",
cascade="all, delete-orphan",
)
class PostboxParticipant(Base, TimestampMixin):
__tablename__ = "postbox_participants"
__table_args__ = (
Index("ix_postbox_participants_message_kind", "message_id", "kind"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
kind: Mapped[str] = mapped_column(String(30), nullable=False)
reference_type: Mapped[str] = mapped_column(String(50), nullable=False)
reference_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
label: Mapped[str | None] = mapped_column(String(500), nullable=True)
address: Mapped[str | None] = mapped_column(String(500), nullable=True)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
message: Mapped[PostboxMessage] = relationship(back_populates="participants")
class PostboxAttachmentReference(Base, TimestampMixin):
__tablename__ = "postbox_attachment_references"
__table_args__ = (
Index(
"ix_postbox_attachment_references_target",
"tenant_id",
"reference_type",
"reference_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
reference_type: Mapped[str] = mapped_column(String(50), nullable=False)
reference_id: Mapped[str] = mapped_column(String(255), nullable=False)
name: Mapped[str | None] = mapped_column(String(1000), nullable=True)
media_type: Mapped[str | None] = mapped_column(String(255), nullable=True)
size_bytes: Mapped[int | None] = mapped_column(Integer, nullable=True)
digest: Mapped[str | None] = mapped_column(String(255), nullable=True)
ciphertext_ref: Mapped[str | None] = mapped_column(
String(1000),
nullable=True,
)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
message: Mapped[PostboxMessage] = relationship(back_populates="attachments")
class PostboxDelivery(Base, TimestampMixin):
__tablename__ = "postbox_deliveries"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"producer_module",
"idempotency_key",
name="uq_postbox_deliveries_producer_idempotency",
),
Index("ix_postbox_deliveries_postbox_status", "postbox_id", "status"),
Index(
"ix_postbox_deliveries_producer",
"tenant_id",
"producer_module",
"producer_resource_type",
"producer_resource_id",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
postbox_id: Mapped[str] = mapped_column(
ForeignKey("postboxes.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="RESTRICT"),
nullable=False,
index=True,
)
producer_module: Mapped[str] = mapped_column(String(100), nullable=False)
producer_resource_type: Mapped[str] = mapped_column(String(100), nullable=False)
producer_resource_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(
String(40),
default="accepted",
nullable=False,
index=True,
)
template_revision_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
organization_unit_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
function_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
holder_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
target_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
accepted_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True,
)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
class PostboxRoute(Base, TimestampMixin):
__tablename__ = "postbox_routes"
__table_args__ = (
UniqueConstraint(
"delivery_id",
"target_postbox_id",
"route_kind",
"depth",
name="uq_postbox_routes_delivery_target_kind_depth",
),
Index("ix_postbox_routes_delivery_kind", "delivery_id", "route_kind"),
Index("ix_postbox_routes_target", "tenant_id", "target_postbox_id"),
Index(
"ix_postbox_routes_due",
"status",
"execute_after",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
delivery_id: Mapped[str] = mapped_column(
ForeignKey("postbox_deliveries.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
source_postbox_id: Mapped[str] = mapped_column(
ForeignKey("postboxes.id", ondelete="RESTRICT"),
nullable=False,
)
source_message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="RESTRICT"),
nullable=False,
)
target_postbox_id: Mapped[str | None] = mapped_column(
ForeignKey("postboxes.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
target_message_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
nullable=True,
)
route_kind: Mapped[str] = mapped_column(String(40), nullable=False)
status: Mapped[str] = mapped_column(String(40), nullable=False)
depth: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
source_route_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_routes.id", ondelete="SET NULL"),
nullable=True,
)
execute_after: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
processed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
policy_snapshot: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
class PostboxMessageReceipt(Base, TimestampMixin):
__tablename__ = "postbox_message_receipts"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"message_id",
"account_id",
name="uq_postbox_receipts_message_account",
),
Index(
"ix_postbox_receipts_account_state",
"tenant_id",
"account_id",
"read_at",
"acknowledged_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
message_id: Mapped[str] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
identity_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
assignment_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
read_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
acknowledged_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
metadata_: Mapped[dict[str, Any]] = mapped_column(
"metadata",
JSON,
default=dict,
nullable=False,
)
message: Mapped[PostboxMessage] = relationship(back_populates="receipts")
class PostboxGrouping(Base, TimestampMixin):
__tablename__ = "postbox_groupings"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"account_id",
"name",
name="uq_postbox_groupings_account_name",
),
Index("ix_postbox_groupings_account", "tenant_id", "account_id"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(250), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
resource_revision: Mapped[int] = mapped_column(
Integer,
default=1,
nullable=False,
)
settings: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
sources: Mapped[list["PostboxGroupingSource"]] = relationship(
back_populates="grouping",
cascade="all, delete-orphan",
order_by="PostboxGroupingSource.position",
)
@property
def strong_etag(self) -> str:
return strong_resource_etag(
"postbox_grouping",
self.id,
self.resource_revision,
)
class PostboxGroupingSource(Base, TimestampMixin):
__tablename__ = "postbox_grouping_sources"
__table_args__ = (
UniqueConstraint(
"grouping_id",
"postbox_id",
name="uq_postbox_grouping_sources_postbox",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
grouping_id: Mapped[str] = mapped_column(
ForeignKey("postbox_groupings.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
postbox_id: Mapped[str] = mapped_column(
ForeignKey("postboxes.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
position: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
grouping: Mapped[PostboxGrouping] = relationship(back_populates="sources")
class PostboxAccessEvent(Base, TimestampMixin):
__tablename__ = "postbox_access_events"
__table_args__ = (
Index(
"ix_postbox_access_events_resource",
"tenant_id",
"postbox_id",
"message_id",
"occurred_at",
),
Index(
"ix_postbox_access_events_actor",
"tenant_id",
"account_id",
"occurred_at",
),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)
postbox_id: Mapped[str | None] = mapped_column(
ForeignKey("postboxes.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
message_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
account_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
identity_id: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
index=True,
)
assignment_id: Mapped[str | None] = mapped_column(
String(36),
nullable=True,
index=True,
)
action: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
outcome: Mapped[str] = mapped_column(String(30), nullable=False, index=True)
reason_code: Mapped[str] = mapped_column(String(80), nullable=False)
occurred_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True,
)
details: Mapped[dict[str, Any]] = mapped_column(
JSON,
default=dict,
nullable=False,
)
__all__ = [
"Postbox",
"PostboxAccessEvent",
"PostboxAddress",
"PostboxAttachmentReference",
"PostboxBinding",
"PostboxDelivery",
"PostboxGrouping",
"PostboxGroupingSource",
"PostboxMessage",
"PostboxMessageReceipt",
"PostboxParticipant",
"PostboxRoute",
"PostboxTemplate",
"PostboxTemplateRevision",
"new_uuid",
]
@@ -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",
]
+547
View File
@@ -0,0 +1,547 @@
from __future__ import annotations
from pathlib import Path
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY
from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
from govoplan_core.core.idm import (
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleInterfaceRequirement,
ModuleManifest,
NavItem,
PermissionDefinition,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.notifications import CAPABILITY_NOTIFICATIONS_DISPATCH
from govoplan_core.core.organizations import (
CAPABILITY_ORGANIZATION_DIRECTORY,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
)
from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_ROUTING,
)
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_postbox.backend.db import models as postbox_models
from govoplan_postbox.backend.search_source import create_postbox_search_source
MODULE_ID = "postbox"
MODULE_NAME = "Postbox"
MODULE_VERSION = "0.1.16"
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"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
label=label,
description=description,
category="Postbox",
level="tenant",
module_id=module_id,
resource=resource,
action=action,
)
PERMISSIONS = (
_permission(
READ_SCOPE,
"View assigned postboxes",
"Discover and read postboxes for currently effective organization-function assignments.",
),
_permission(
SEND_SCOPE,
"Send through assigned postboxes",
"Create new messages in postboxes available through the current function context.",
),
_permission(
REPLY_SCOPE,
"Reply through assigned postboxes",
"Reply to messages in postboxes available through the current function context.",
),
_permission(
ACKNOWLEDGE_SCOPE,
"Acknowledge postbox messages",
"Record personal read and acknowledgement state for accessible messages.",
),
_permission(
DELIVERY_SCOPE,
"Deliver to postboxes",
"Accept idempotent deliveries from approved platform producers.",
),
_permission(
BINDING_ADMIN_SCOPE,
"Administer postbox bindings",
"Create, archive, and inspect exact organization-function postboxes and bindings.",
),
_permission(
TEMPLATE_ADMIN_SCOPE,
"Administer postbox templates",
"Create, revise, publish, and retire reusable function-scoped postbox templates.",
),
_permission(
CONFIDENTIAL_SCOPE,
"Access confidential Postbox content",
"Discover and use confidential Postboxes and messages when function access also permits it.",
),
_permission(
RESTRICTED_SCOPE,
"Access restricted Postbox content",
"Discover and use restricted Postboxes and messages when function access also permits it.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="postbox_user",
name="Postbox user",
description="Use postboxes available through current function assignments.",
permissions=(READ_SCOPE, SEND_SCOPE, REPLY_SCOPE, ACKNOWLEDGE_SCOPE),
default_authenticated=True,
),
RoleTemplate(
slug="postbox_manager",
name="Postbox manager",
description="Administer postbox templates and concrete function-bound containers.",
permissions=(
READ_SCOPE,
SEND_SCOPE,
REPLY_SCOPE,
ACKNOWLEDGE_SCOPE,
DELIVERY_SCOPE,
BINDING_ADMIN_SCOPE,
TEMPLATE_ADMIN_SCOPE,
CONFIDENTIAL_SCOPE,
RESTRICTED_SCOPE,
),
),
)
def _configure(context: ModuleContext):
from govoplan_postbox.backend.runtime import configure_runtime, get_service
configure_runtime(registry=context.registry)
return get_service()
def _router(context: ModuleContext):
_configure(context)
from govoplan_postbox.backend.router import router
return router
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return {
"postboxes": session.query(postbox_models.Postbox)
.filter(
postbox_models.Postbox.tenant_id == tenant_id,
postbox_models.Postbox.status == "active",
)
.count(),
"postbox_messages": session.query(postbox_models.PostboxMessage)
.filter(postbox_models.PostboxMessage.tenant_id == tenant_id)
.count(),
"vacant_deliveries": session.query(postbox_models.PostboxDelivery)
.filter(
postbox_models.PostboxDelivery.tenant_id == tenant_id,
postbox_models.PostboxDelivery.status == "accepted_vacant",
)
.count(),
}
_OWNED_TABLES = (
postbox_models.PostboxAccessEvent,
postbox_models.PostboxGroupingSource,
postbox_models.PostboxGrouping,
postbox_models.PostboxMessageReceipt,
postbox_models.PostboxRoute,
postbox_models.PostboxDelivery,
postbox_models.PostboxAttachmentReference,
postbox_models.PostboxParticipant,
postbox_models.PostboxMessage,
postbox_models.PostboxBinding,
postbox_models.Postbox,
postbox_models.PostboxAddress,
postbox_models.PostboxTemplateRevision,
postbox_models.PostboxTemplate,
)
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
version=MODULE_VERSION,
dependencies=("identity", "organizations", "idm"),
optional_dependencies=(
"access",
"audit",
"campaigns",
"encryption",
"files",
"mail",
"notifications",
"policy",
"portal",
"views",
"workflow_engine",
"search",
),
required_capabilities=(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_IDENTITY_DIRECTORY,
CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
CAPABILITY_ORGANIZATION_DIRECTORY,
CAPABILITY_ORGANIZATION_HIERARCHY_DIRECTORY,
),
provides_interfaces=tuple(
ModuleInterfaceProvider(name=name, version=MODULE_VERSION)
for name in (
CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_ACCESS,
CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_ROUTING,
)
),
requires_interfaces=(
ModuleInterfaceRequirement(
name=CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
version_min="0.1.8",
version_max_exclusive="0.2.0",
),
ModuleInterfaceRequirement(
name="organizations.hierarchy_directory",
version_min="0.1.0",
version_max_exclusive="0.2.0",
),
ModuleInterfaceRequirement(
name=CAPABILITY_NOTIFICATIONS_DISPATCH,
version_min="0.1.8",
version_max_exclusive="0.2.0",
optional=True,
),
ModuleInterfaceRequirement(
name=CAPABILITY_ENCRYPTION_CONTENT_CIPHER,
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
ModuleInterfaceRequirement(
name="search.source",
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
search_sources=(
SearchSourceProviderRegistration(
id="postbox.messages",
factory=create_postbox_search_source,
),
),
nav_items=(
NavItem(
path="/postbox",
label="Postbox",
icon="inbox",
required_any=(READ_SCOPE,),
order=58,
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/postbox-webui",
routes=(
FrontendRoute(
path="/postbox",
component="PostboxPage",
required_any=(READ_SCOPE,),
order=58,
),
),
nav_items=(
NavItem(
path="/postbox",
label="Postbox",
icon="inbox",
required_any=(READ_SCOPE,),
order=58,
),
),
view_surfaces=(
ViewSurface(
id="postbox.inbox.directory",
module_id=MODULE_ID,
kind="section",
label="Postbox directory",
order=10,
),
ViewSurface(
id="postbox.inbox.messages",
module_id=MODULE_ID,
kind="section",
label="Postbox messages",
order=20,
),
ViewSurface(
id="postbox.widget.inbox",
module_id=MODULE_ID,
kind="section",
label="Postbox inbox widget",
order=25,
),
ViewSurface(
id="postbox.admin.templates",
module_id=MODULE_ID,
kind="section",
label="Postbox templates and bindings",
order=30,
),
),
),
route_factory=_router,
tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
*_OWNED_TABLES,
label="Postbox",
),
retirement_notes=(
"Destructive retirement removes Postbox templates, addresses, "
"messages, delivery evidence, receipts, groupings, and access events "
"after the installer captures a database snapshot."
),
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
postbox_models.Postbox,
postbox_models.PostboxMessage,
postbox_models.PostboxDelivery,
label="Postbox",
),
),
capability_factories={
CAPABILITY_POSTBOX_DIRECTORY: _configure,
CAPABILITY_POSTBOX_ACCESS: _configure,
CAPABILITY_POSTBOX_MESSAGES: _configure,
CAPABILITY_POSTBOX_DELIVERY: _configure,
CAPABILITY_POSTBOX_EVIDENCE: _configure,
CAPABILITY_POSTBOX_ROUTING: _configure,
},
documentation=(
DocumentationTopic(
id="postbox.search.messages",
title="Search authorized Postbox messages",
summary="Expose Postbox subjects and permitted plaintext content to permission-aware platform Search.",
body=(
"When Search is installed, Postbox contributes message subjects, sender labels, and plaintext "
"content only. Ciphertext and key material are never indexed. Every result is tenant-bounded and "
"rechecks current function assignment, Postbox binding, classification, acting context, and generic "
"read authority without recording a message read. Committed deliveries and message changes update "
"the derived index through the durable platform event path."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("administrator", "user", "campaign_manager"),
related_modules=("search", "idm", "encryption"),
order=34,
),
DocumentationTopic(
id="postbox.function-bound-containers",
title="Function-bound Postboxes",
summary=(
"Durable institutional message containers whose access follows "
"effective organization-function assignments."
),
body=(
"Postboxes belong to responsibilities, not individual accounts. "
"A stable postbox remains addressable during vacancy and "
"reassignment. Current access combines a generic Postbox "
"permission with effective IDM assignment context. Templates "
"can lazily materialize unit-specific addresses, while exact "
"postboxes cover exceptional responsibilities. Plaintext "
"Postboxes remain available without Encryption. A "
"server-envelope profile stores message bodies as ciphertext and "
"uses the optional Encryption capability for authorized reads. "
"External ciphertext profiles retain producer-managed references "
"and keys; neither profile is described as end-to-end encryption."
),
layer="available",
documentation_types=("admin", "user"),
audience=("administrator", "user", "campaign_manager"),
related_modules=(
"identity",
"idm",
"organizations",
"campaigns",
"files",
"notifications",
),
links=(
DocumentationLink(
label="Postbox inbox",
href="/postbox",
kind="runtime",
),
DocumentationLink(
label="Postbox directory API",
href="/api/v1/postbox/directory",
kind="api",
),
),
metadata={
"kind": "guide",
"help_contexts": [
"postbox.inbox.directory",
"postbox.inbox.messages",
"postbox.widget.inbox",
"postbox.admin.templates",
"postbox.blocker.assignment",
"postbox.state.unavailable",
],
"privacy_notes": [
"Directory access is derived from current effective function assignments.",
"Unavailable message states reveal only retained audit metadata permitted to the current actor.",
"Subjects, participants, routing facts, and attachment references remain observable metadata.",
],
},
order=35,
),
DocumentationTopic(
id="postbox.reference.fields-and-consequences",
title="Postbox fields and consequences",
summary=(
"Reference for address, template, routing, message, grouping, "
"classification, retention, and lifecycle fields."
),
body=(
"A Postbox address is durable and bound to an organization function. "
"Template revisions are immutable after publication; retiring a template "
"does not remove materialized addresses. Archiving an address stops new "
"delivery while retaining messages and evidence. Unified inbox views are "
"personal projections only and never move or delete source messages. "
"Classification limits eligible delivery and hierarchy-copy targets. "
"Hierarchy copies are independent deliveries with their own evidence; "
"vacancy escalation is delayed and separately auditable. Message expiry "
"or withdrawal blocks future content access but cannot retract plaintext "
"already copied, exported, or printed."
),
layer="configured",
documentation_types=("admin", "user"),
audience=("administrator", "user", "campaign_manager"),
related_modules=(
"organizations",
"idm",
"policy",
"audit",
"encryption",
),
links=(
DocumentationLink(
label="Postbox administration",
href="/admin?section=postbox",
kind="runtime",
),
DocumentationLink(
label="Postbox concept",
href="docs/POSTBOX_CONCEPT.md",
kind="source",
),
),
metadata={
"kind": "reference",
"help_contexts": [
"postbox.field.address",
"postbox.field.function",
"postbox.field.scope",
"postbox.field.classification",
"postbox.field.retention",
"postbox.field.hierarchy-routing",
"postbox.field.recipients",
"postbox.action.archive",
"postbox.action.retire-template",
"postbox.action.delete-grouping",
],
"consequence_classes": {
"publish_template": "Freezes an immutable address and routing revision for future materialization.",
"retire_template": "Stops new revisions and materialization while retaining existing addresses.",
"archive_postbox": "Stops new delivery while retaining messages, receipts, and evidence.",
"delete_grouping": "Deletes only the personal projection; source Postboxes and messages remain unchanged.",
"route_copy": "Creates a separately retained delivery and evidence record at each bounded target.",
"withdraw_or_expire": "Blocks future content access while retaining permitted audit metadata.",
},
},
order=36,
),
),
architecture=declared_module_architecture(
layer="communication_participation",
kind="domain",
maturity="vertical_slice",
documentation_ref="docs/POSTBOX_CONCEPT.md",
test_ref="tests/test_service.py",
known_limits=(
"Subjects, routing metadata, participants, and attachment references remain plaintext metadata.",
"Server-envelope protection is server-decryptable and is not end-to-end encryption.",
"External ciphertext profiles require a separately governed producer and client key-custody profile.",
),
owned_concepts=("postbox", "postbox address", "postbox message", "delivery receipt", "access event"),
non_owned_concepts=("identity", "function assignment", "campaign", "cryptographic key custody"),
recovery_docs=("docs/POSTBOX_CONCEPT.md",),
security_docs=("docs/POSTBOX_CONCEPT.md",),
operations_docs=("README.md",),
),
)
def get_manifest() -> ModuleManifest:
return manifest
@@ -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,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,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,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")
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
+533
View File
@@ -0,0 +1,533 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator
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
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 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
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)
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"
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)
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 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"
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
"plaintext_v1"
)
encryption_vault_id: str | None = Field(default=None, max_length=255)
@model_validator(mode="after")
def validate_encryption(self) -> "PostboxExactCreateRequest":
if self.encryption_profile == "server_envelope_v1":
if not str(self.encryption_vault_id or "").strip():
raise ValueError(
"Server-envelope Postboxes require an encryption vault."
)
elif self.encryption_vault_id:
raise ValueError(
"A plaintext Postbox cannot select an encryption vault."
)
return self
class PostboxTemplateRevisionPayload(BaseModel):
function_type_id: str | None = Field(default=None, max_length=36)
scope_kind: Literal["tenant", "unit", "subtree", "unit_type"] = "tenant"
scope_id: str | None = Field(default=None, max_length=255)
name_pattern: str = Field(
default="{unit_name} / {function_name}",
min_length=1,
max_length=500,
)
address_pattern: str = Field(
default="{template_slug}.{unit_slug}.{function_slug}",
min_length=1,
max_length=500,
)
classification: PostboxClassification = "internal"
allow_vacant_delivery: bool = True
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
"plaintext_v1"
)
encryption_vault_id: str | None = Field(default=None, max_length=255)
routing_policy: PostboxRoutingPolicyPayload = Field(
default_factory=PostboxRoutingPolicyPayload
)
@model_validator(mode="after")
def validate_encryption(self) -> "PostboxTemplateRevisionPayload":
if self.encryption_profile == "server_envelope_v1":
if not str(self.encryption_vault_id or "").strip():
raise ValueError(
"Server-envelope Postbox templates require an encryption vault."
)
elif self.encryption_vault_id:
raise ValueError(
"A plaintext Postbox template cannot select an encryption vault."
)
return self
class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
slug: str = Field(min_length=1, max_length=120)
name: str = Field(min_length=1, max_length=250)
description: str | None = None
class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload):
base_revision: int = Field(ge=1)
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 PostboxGroupingItem(PostboxGroupingPayload):
id: str
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
+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()
@@ -0,0 +1,46 @@
from __future__ import annotations
import unittest
from govoplan_postbox.backend.manifest import manifest
class PostboxInterfaceDocumentationContractTests(unittest.TestCase):
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.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"])
if __name__ == "__main__":
unittest.main()
+54
View File
@@ -0,0 +1,54 @@
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_ROUTING,
)
from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
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,
},
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.assertTrue(
any(
requirement.name == CAPABILITY_ENCRYPTION_CONTENT_CIPHER
and requirement.optional
for requirement in manifest.requires_interfaces
)
)
if __name__ == "__main__":
unittest.main()
+136
View File
@@ -0,0 +1,136 @@
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"
)
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
migration.op = operations
route_migration.op = operations
occ_migration.op = operations
envelope_migration.op = operations
protection_migration.op = operations
try:
migration.upgrade()
route_migration.upgrade()
occ_migration.upgrade()
envelope_migration.upgrade()
protection_migration.upgrade()
tables = set(inspect(connection).get_table_names())
self.assertIn("postboxes", tables)
self.assertIn("postbox_messages", tables)
self.assertIn("postbox_deliveries", tables)
self.assertIn("postbox_access_events", tables)
message_columns = {
column["name"]
for column in inspect(connection).get_columns(
"postbox_messages"
)
}
self.assertTrue(
{
"ciphertext_ref",
"signed_manifest_ref",
"wrapped_keys",
"external_recipient_tokens",
"key_epoch",
"expires_at",
"withdrawn_at",
"body_ciphertext",
"encryption_envelope_id",
"encryption_resource_id",
}.issubset(message_columns)
)
self.assertIn(
"encryption_vault_id",
{
column["name"]
for column in inspect(connection).get_columns(
"postbox_template_revisions"
)
},
)
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
)
)
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
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()
+310
View File
@@ -0,0 +1,310 @@
from __future__ import annotations
import unittest
from datetime import timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from govoplan_core.core.postbox import PostboxActorRef
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
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_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()
+457
View File
@@ -0,0 +1,457 @@
from __future__ import annotations
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.access import PrincipalRef
from govoplan_core.core.identity import IdentityRef
from govoplan_core.core.idm import (
OrganizationFunctionAssignmentRef,
OrganizationFunctionIncumbencyRef,
)
from govoplan_core.core.organizations import (
OrganizationFunctionRef,
OrganizationUnitRef,
)
from govoplan_core.db.base import Base
from govoplan_core.db.session import get_session
from govoplan_postbox.backend.db.models import (
Postbox,
PostboxAccessEvent,
PostboxAddress,
PostboxAttachmentReference,
PostboxBinding,
PostboxDelivery,
PostboxGrouping,
PostboxGroupingSource,
PostboxMessage,
PostboxMessageReceipt,
PostboxParticipant,
PostboxRoute,
PostboxTemplate,
PostboxTemplateRevision,
)
from govoplan_postbox.backend.router import router
from govoplan_postbox.backend.service import PostboxService
TABLES = (
PostboxTemplate.__table__,
PostboxTemplateRevision.__table__,
PostboxAddress.__table__,
Postbox.__table__,
PostboxBinding.__table__,
PostboxMessage.__table__,
PostboxParticipant.__table__,
PostboxAttachmentReference.__table__,
PostboxDelivery.__table__,
PostboxRoute.__table__,
PostboxMessageReceipt.__table__,
PostboxGrouping.__table__,
PostboxGroupingSource.__table__,
PostboxAccessEvent.__table__,
)
class FakeIdentityDirectory:
def get_identity(self, identity_id: str):
return IdentityRef(id=identity_id, primary_account_id="account-1")
def identity_for_account(self, account_id: str):
return IdentityRef(id="identity-1", primary_account_id=account_id)
def identities_for_accounts(self, account_ids):
return tuple(self.identity_for_account(account_id) for account_id in account_ids)
def accounts_for_identity(self, identity_id: str):
return ()
class FakeIdmDirectory:
def __init__(self, assignment: OrganizationFunctionAssignmentRef) -> None:
self.assignment = assignment
def get_organization_function_assignment(self, assignment_id: str):
return self.assignment if assignment_id == self.assignment.id else None
def organization_function_assignments_for_identity(
self,
identity_id: str,
*,
tenant_id: str | None = None,
):
return (self.assignment,) if identity_id == self.assignment.identity_id else ()
def organization_function_assignments_for_account(
self,
account_id: str,
*,
tenant_id: str | None = None,
):
return (self.assignment,) if account_id == self.assignment.account_id else ()
def organization_function_assignments_for_function(
self,
function_id: str,
*,
tenant_id: str | None = None,
):
return (self.assignment,) if function_id == self.assignment.function_id else ()
def organization_function_incumbencies(
self,
function_ids,
*,
tenant_id: str,
effective_at=None,
):
del effective_at
return {
function_id: OrganizationFunctionIncumbencyRef(
tenant_id=tenant_id,
function_id=function_id,
assignments=self.organization_function_assignments_for_function(
function_id,
tenant_id=tenant_id,
),
)
for function_id in function_ids
}
class FakeOrganizationDirectory:
unit = OrganizationUnitRef(
id="unit-1",
tenant_id="tenant-1",
slug="district",
name="District",
)
function = OrganizationFunctionRef(
id="function-1",
tenant_id="tenant-1",
organization_unit_id="unit-1",
slug="clerk",
name="Clerk",
function_type_id="clerk-type",
)
def get_organization_unit(self, organization_unit_id: str):
return self.unit if organization_unit_id == self.unit.id else None
def organization_units_for_tenant(self, tenant_id: str):
return (self.unit,) if tenant_id == self.unit.tenant_id else ()
def get_function(self, function_id: str):
return self.function if function_id == self.function.id else None
def functions_for_organization_unit(
self,
organization_unit_id: str,
*,
include_subunits: bool = False,
):
return (self.function,) if organization_unit_id == self.unit.id else ()
class PostboxRouterTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(self.engine, tables=TABLES)
assignment = OrganizationFunctionAssignmentRef(
id="assignment-1",
tenant_id="tenant-1",
identity_id="identity-1",
account_id="account-1",
function_id="function-1",
organization_unit_id="unit-1",
)
idm = FakeIdmDirectory(assignment)
self.service = PostboxService(
identities=FakeIdentityDirectory(), # type: ignore[arg-type]
idm=idm, # type: ignore[arg-type]
incumbencies=idm, # type: ignore[arg-type]
organizations=FakeOrganizationDirectory(), # type: ignore[arg-type]
)
with Session(self.engine) as session:
self.postbox = self.service.create_exact_postbox(
session,
tenant_id="tenant-1",
name="District / Clerk",
organization_unit_id="unit-1",
function_id="function-1",
address_key=None,
description=None,
classification="internal",
actor_id="account-1",
)
session.commit()
self.postbox_id = self.postbox.id
principal = ApiPrincipal(
principal=PrincipalRef(
account_id="account-1",
membership_id="membership-1",
tenant_id="tenant-1",
identity_id="identity-1",
scopes=frozenset(
{
"postbox:postbox:read",
"postbox:message:write",
"postbox:message:reply",
"postbox:message:acknowledge",
"postbox:delivery:write",
"postbox:binding:admin",
"postbox:template:admin",
}
),
),
account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="membership-1"),
)
app = FastAPI()
app.include_router(router, prefix="/api/v1")
def session_dependency():
with Session(self.engine) as session:
yield session
app.dependency_overrides[get_session] = session_dependency
app.dependency_overrides[get_api_principal] = lambda: principal
self.patch = patch(
"govoplan_postbox.backend.router.get_service",
return_value=self.service,
)
self.patch.start()
self.client = TestClient(app)
def tearDown(self) -> None:
self.client.close()
self.patch.stop()
self.engine.dispose()
def test_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_directory_delivery_message_and_receipt_round_trip(self) -> None:
directory = self.client.get("/api/v1/postbox/directory")
self.assertEqual(200, directory.status_code, directory.text)
self.assertEqual(self.postbox_id, directory.json()["postboxes"][0]["id"])
delivery = self.client.post(
"/api/v1/postbox/deliveries",
json={
"target": {"postbox_id": self.postbox_id},
"producer_module": "campaigns",
"producer_resource_type": "campaign_recipient",
"producer_resource_id": "recipient-1",
"idempotency_key": "campaign-1:recipient-1",
"subject": "Decision",
"body_text": "The decision is ready.",
},
)
self.assertEqual(201, delivery.status_code, delivery.text)
message_id = delivery.json()["message_id"]
messages = self.client.get(
"/api/v1/postbox/messages",
params={"postbox_id": self.postbox_id},
)
self.assertEqual(200, messages.status_code, messages.text)
self.assertEqual(1, messages.json()["total"])
self.assertEqual(message_id, messages.json()["messages"][0]["id"])
filtered = self.client.get(
"/api/v1/postbox/messages",
params={
"postbox_id": self.postbox_id,
"q": "decision",
"state": "unread",
},
)
self.assertEqual(200, filtered.status_code, filtered.text)
self.assertEqual(1, filtered.json()["total"])
acknowledged = self.client.patch(
f"/api/v1/postbox/messages/{message_id}/state",
json={"state": "acknowledged"},
)
self.assertEqual(200, acknowledged.status_code, acknowledged.text)
self.assertIsNotNone(acknowledged.json()["read_at"])
self.assertIsNotNone(acknowledged.json()["acknowledged_at"])
unread = self.client.get(
"/api/v1/postbox/messages",
params={
"postbox_id": self.postbox_id,
"state": "unread",
},
)
self.assertEqual(200, unread.status_code, unread.text)
self.assertEqual(0, unread.json()["total"])
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.16",
"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.16",
"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,31 @@
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.2"') && 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.");
+490
View File
@@ -0,0 +1,490 @@
import {
apiFetch,
apiPath,
apiPostJson,
type ApiSettings
} from "@govoplan/core-webui";
export type PostboxAccessDecision = {
allowed: boolean;
action: string;
postbox_id: string;
reason_code: string;
explanation: string;
organization_unit_id?: string | null;
function_id?: string | null;
assignment_ids: string[];
assignment_sources: string[];
selected_assignment_id?: string | null;
holder_count: number;
vacant: boolean;
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;
access?: PostboxAccessDecision | null;
resource_revision: number;
etag: string;
};
export type PostboxParticipant = {
kind: string;
reference_type: string;
reference_id?: string | null;
label?: string | null;
address?: string | null;
};
export type PostboxAttachment = {
reference_type: string;
reference_id: string;
name?: string | null;
media_type?: string | null;
size_bytes?: number | null;
digest?: string | null;
metadata: Record<string, unknown>;
};
export type PostboxMessage = {
id: string;
tenant_id: string;
postbox_id: string;
subject: string;
body_text?: string | null;
status: string;
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: string;
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[];
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;
name_pattern: string;
address_pattern: string;
classification: string;
allow_vacant_delivery: boolean;
encryption_profile: string;
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"
| "name_pattern"
| "address_pattern"
| "classification"
| "allow_vacant_delivery"
| "routing_policy"
>;
export type PostboxTemplateCreatePayload = PostboxTemplateRevisionPayload & {
slug: string;
name: string;
description?: string | null;
};
export type PostboxExactCreatePayload = {
name: string;
description?: string | null;
organization_unit_id: string;
function_id: string;
address_key?: string | null;
classification: string;
};
export type PostboxMessageAuthoringPayload = {
idempotency_key: string;
subject: string;
body_text?: string | null;
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 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 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 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,95 @@
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);
if (!postboxes.length) {
return { messages: [], total: 0 };
}
return listPostboxMessages(
settings,
postboxes.map((postbox) => postbox.id),
maxItems,
0,
"",
"unread"
);
}, [maxItems, settings]);
const { data, loading, error } = useDashboardWidgetData(load, refreshKey);
return (
<LoadingFrame loading={loading} label="Loading unread Postbox messages">
{error && (
<DismissibleAlert tone="warning" resetKey={error}>
{error}
</DismissibleAlert>
)}
<DashboardWidgetList
emptyText="No unread Postbox messages."
items={(data?.messages ?? []).map((message) => ({
id: message.id,
title: message.subject,
detail: message.sender_label || message.producer_module || "Postbox",
meta: new Intl.DateTimeFormat(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,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;
}
+468
View File
@@ -0,0 +1,468 @@
import type { PlatformTranslations } from "@govoplan/core-webui";
const en = {
"i18n:govoplan-postbox.postbox": "Postbox",
"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.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";
+160
View File
@@ -0,0 +1,160 @@
import { createElement, lazy } from "react";
import {
hasScope,
type AdminSectionsUiCapability,
type DashboardWidgetsUiCapability,
type PlatformWebModule
} from "@govoplan/core-webui";
import { generatedTranslations } from "./i18n/generatedTranslations";
import PostboxInboxWidget from "./features/postbox/PostboxInboxWidget";
import "./styles/postbox.css";
const PostboxPage = lazy(() => import("./features/postbox/PostboxPage"));
const PostboxAdminPanel = lazy(
() => import("./features/postbox/PostboxAdminPanel")
);
const 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 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.2",
dependencies: ["identity", "organizations", "idm"],
optionalDependencies: [
"access",
"audit",
"campaigns",
"files",
"mail",
"notifications",
"policy",
"portal",
"views",
"workflow"
],
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
}
],
uiCapabilities: {
"admin.sections": postboxAdminSections,
"dashboard.widgets": postboxDashboardWidgets
}
};
export default postboxModule;
+699
View File
@@ -0,0 +1,699 @@
.postbox-page {
position: relative;
display: grid;
grid-template-rows: minmax(0, 1fr);
height: calc(100vh - 115px);
min-height: 0;
overflow: hidden;
padding: 0;
color: var(--text);
background: var(--bg);
}
.postbox-page *,
.postbox-page *::before,
.postbox-page *::after,
.postbox-admin-page *,
.postbox-admin-page *::before,
.postbox-admin-page *::after {
box-sizing: border-box;
}
.postbox-shell {
min-width: 0;
min-height: 0;
display: grid;
grid-template-columns:
minmax(250px, 310px)
minmax(290px, 370px)
minmax(360px, 1fr);
height: 100%;
overflow: hidden;
background: var(--panel);
}
.postbox-directory,
.postbox-message-list,
.postbox-detail {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--panel);
}
.postbox-directory,
.postbox-message-list {
border-right: var(--border-line);
}
.postbox-directory {
background: var(--panel-soft);
}
.postbox-bar,
.postbox-bar-title,
.postbox-icon-actions,
.postbox-scope-row,
.postbox-item-title,
.postbox-message-heading,
.postbox-message-meta,
.postbox-detail-status,
.postbox-detail-byline,
.postbox-access-explanation,
.postbox-attachments > div,
.postbox-participants > div {
display: flex;
align-items: center;
}
.postbox-bar {
flex: 0 0 auto;
min-height: 54px;
justify-content: space-between;
gap: 10px;
border-bottom: var(--border-line);
background: var(--panel-header);
padding: 8px 12px;
}
.postbox-bar-title {
min-width: 0;
gap: 8px;
color: var(--text-strong);
}
.postbox-bar-title strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-icon-actions {
gap: 5px;
}
.postbox-total {
min-width: 24px;
height: 22px;
display: inline-grid;
place-items: center;
border-radius: 999px;
background: var(--line);
color: var(--text-strong);
font-size: 12px;
font-weight: 800;
}
.postbox-scope-control {
flex: 0 0 auto;
border-bottom: var(--border-line);
padding: 9px 10px;
}
.postbox-scope-control > label {
display: block;
margin-bottom: 5px;
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.postbox-scope-row {
gap: 6px;
}
.postbox-scope-row select {
min-width: 0;
flex: 1;
}
.postbox-directory-list,
.postbox-messages {
min-height: 0;
overflow: auto;
padding: 7px;
}
.postbox-directory-list,
.postbox-messages {
flex: 1 1 auto;
}
.postbox-message-filters {
flex: 0 0 auto;
display: grid;
gap: 7px;
border-bottom: var(--border-line);
padding: 8px 9px;
}
.postbox-search-row {
display: flex;
align-items: center;
gap: 5px;
}
.postbox-search-row input {
min-width: 0;
flex: 1;
}
.postbox-message-list > .data-grid-pagination {
flex: 0 0 auto;
flex-wrap: wrap;
gap: 7px 12px;
border-top: var(--border-line);
}
.postbox-message-list > .data-grid-pagination .data-grid-page-controls {
width: 100%;
justify-content: center;
}
.postbox-directory-list .selection-list,
.postbox-messages .selection-list,
.postbox-admin-list .selection-list {
gap: 4px;
}
.postbox-directory-item,
.postbox-message-item,
.postbox-admin-list .selection-list-item {
display: grid;
min-height: 64px;
gap: 4px;
align-content: center;
text-align: left;
}
.postbox-item-title,
.postbox-message-heading {
min-width: 0;
justify-content: space-between;
gap: 10px;
}
.postbox-item-title strong,
.postbox-message-heading strong {
min-width: 0;
overflow: hidden;
color: var(--text-strong);
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-item-context,
.postbox-item-address,
.postbox-message-preview,
.postbox-message-meta {
min-width: 0;
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-message-item.is-read strong {
font-weight: 550;
}
.postbox-message-item.is-unread strong {
font-weight: 800;
}
.postbox-message-heading time {
flex: 0 0 auto;
color: var(--muted);
font-size: 11px;
}
.postbox-message-meta {
justify-content: flex-start;
gap: 10px;
}
.postbox-message-meta span {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 3px;
}
.postbox-message-list > .alert {
flex: 0 0 auto;
margin: 8px 10px 0;
}
.postbox-message-detail {
min-height: 0;
overflow: auto;
padding: 20px 24px 28px;
}
.postbox-message-detail > header,
.postbox-provenance,
.postbox-body,
.postbox-participants,
.postbox-attachments {
border-bottom: var(--border-line);
padding-bottom: 18px;
margin-bottom: 18px;
}
.postbox-detail-status {
flex-wrap: wrap;
gap: 6px;
}
.postbox-message-detail h1 {
max-width: 900px;
margin: 12px 0 9px;
color: var(--text-strong);
font-size: 24px;
font-weight: 650;
overflow-wrap: anywhere;
}
.postbox-detail-byline {
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 12px;
}
.postbox-provenance h2,
.postbox-participants h2,
.postbox-attachments h2 {
margin: 0 0 10px;
color: var(--text-strong);
font-size: 14px;
}
.postbox-provenance dl,
.postbox-admin-properties {
display: grid;
grid-template-columns: repeat(2, minmax(170px, 1fr));
gap: 10px 18px;
margin: 0;
}
.postbox-provenance dt,
.postbox-admin-properties dt {
color: var(--muted);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.postbox-provenance dd,
.postbox-admin-properties dd {
min-width: 0;
margin: 3px 0 0;
overflow-wrap: anywhere;
}
.postbox-body p {
max-width: 900px;
margin: 0;
line-height: 1.6;
white-space: pre-wrap;
}
.postbox-participants,
.postbox-attachments {
display: grid;
gap: 7px;
}
.postbox-participants > div,
.postbox-attachments > div {
justify-content: flex-start;
gap: 10px;
border: var(--border-line);
border-radius: var(--radius-sm);
background: var(--surface);
padding: 9px 10px;
}
.postbox-participants > div strong {
min-width: 90px;
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
}
.postbox-attachments > p {
margin: 0;
color: var(--muted);
}
.postbox-attachments > div span {
min-width: 0;
display: grid;
gap: 2px;
}
.postbox-attachments > div strong,
.postbox-attachments > div small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.postbox-attachments > div small {
color: var(--muted);
}
.postbox-access-explanation {
align-items: flex-start;
gap: 10px;
border-left: 3px solid var(--green);
background: var(--success-soft);
padding: 10px 12px;
}
.postbox-access-explanation p {
margin: 3px 0 0;
color: var(--muted);
line-height: 1.4;
}
.postbox-empty {
min-height: 100%;
display: grid;
place-items: center;
align-content: center;
gap: 7px;
padding: 30px;
color: var(--muted);
text-align: center;
}
.postbox-empty.compact {
min-height: 180px;
padding: 20px;
}
.postbox-empty strong {
color: var(--text-strong);
}
.postbox-empty p,
.postbox-note {
max-width: 470px;
margin: 0;
color: var(--muted);
font-size: 13px;
}
.postbox-dialog {
width: min(720px, calc(100vw - 32px));
max-width: none;
}
.postbox-template-dialog {
width: min(900px, calc(100vw - 32px));
}
.postbox-form-grid {
display: grid;
gap: 14px;
}
.postbox-form-grid.two-columns {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.postbox-form-grid input,
.postbox-form-grid select {
width: 100%;
}
.postbox-toggle-field {
min-height: 62px;
display: flex;
align-items: flex-end;
padding-bottom: 7px;
}
.postbox-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-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 {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(160px, 1fr);
gap: 12px;
}
.postbox-compose-wide {
grid-column: 1 / -1;
}
.postbox-compose-grid textarea {
width: 100%;
min-height: 180px;
resize: vertical;
}
.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: 1180px) {
.postbox-shell {
grid-template-columns: minmax(230px, 290px) minmax(280px, 340px) minmax(330px, 1fr);
overflow-x: auto;
}
}
@media (max-width: 900px) {
.postbox-page {
height: auto;
min-height: calc(100vh - 115px);
overflow: auto;
}
.postbox-shell {
grid-template-columns: 1fr;
height: auto;
overflow: visible;
}
.postbox-directory,
.postbox-message-list {
min-height: 260px;
max-height: 42vh;
border-right: 0;
border-bottom: var(--border-line);
}
.postbox-detail {
min-height: 440px;
}
.postbox-admin-workspace {
grid-template-columns: 1fr;
}
.postbox-admin-list {
max-height: 260px;
border-right: 0;
border-bottom: var(--border-line);
}
.postbox-admin-detail-heading {
flex-direction: column;
}
}
@media (max-width: 640px) {
.postbox-form-grid.two-columns,
.postbox-provenance dl,
.postbox-admin-properties {
grid-template-columns: 1fr;
}
.postbox-message-detail {
padding: 16px;
}
.postbox-routing-heading {
align-items: flex-start;
flex-direction: column;
}
.postbox-compose-grid {
grid-template-columns: 1fr;
}
}
+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"]
}