Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6815a4a0a | ||
|
|
e298a7b39d | ||
|
|
873be94059 | ||
|
|
9c23439212 | ||
|
|
c330d54416 | ||
|
|
889b5e61f0 | ||
|
|
a97eb3bcef | ||
|
|
8f8d259a76 | ||
|
|
7d310d5c33 | ||
|
|
e3daf400f3 | ||
|
|
dc6f81dd33 | ||
|
|
0b6ebc3c74 | ||
|
|
cbeaca979d | ||
|
|
18feb9f959 | ||
|
|
eb359158ad | ||
|
|
bdc161e889 | ||
|
|
28b60782de | ||
|
|
d848a9a503 | ||
|
|
19216e5043 | ||
|
|
5d141a0eaa | ||
|
|
a3c809c391 |
@@ -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
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""GovOPlaN Postbox module."""
|
||||||
|
|
||||||
|
__version__ = "0.1.16"
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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."""
|
||||||
+33
@@ -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")
|
||||||
+798
@@ -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")
|
||||||
+55
@@ -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")
|
||||||
+54
@@ -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")
|
||||||
+65
@@ -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
@@ -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
|
||||||
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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.");
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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";
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user