feat: integrate files and portal surfaces

This commit is contained in:
2026-08-07 14:53:46 +02:00
parent 60f50f7906
commit 11f175cbb3
13 changed files with 512 additions and 13 deletions
@@ -151,6 +151,11 @@ class PostboxTemplateRevision(Base, TimestampMixin):
default=True, default=True,
nullable=False, nullable=False,
) )
portal_visible: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
)
encryption_profile: Mapped[str] = mapped_column( encryption_profile: Mapped[str] = mapped_column(
String(80), String(80),
default="plaintext_v1", default="plaintext_v1",
+45
View File
@@ -8,6 +8,7 @@ from govoplan_core.core.access import (
) )
from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY
from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
from govoplan_core.core.files import CAPABILITY_FILES_POSTBOX_REFERENCES
from govoplan_core.core.idm import ( from govoplan_core.core.idm import (
CAPABILITY_IDM_DIRECTORY, CAPABILITY_IDM_DIRECTORY,
CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, CAPABILITY_IDM_FUNCTION_ASSIGNMENTS,
@@ -44,6 +45,7 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY, CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE, CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES, CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_PORTAL,
CAPABILITY_POSTBOX_ROUTING, CAPABILITY_POSTBOX_ROUTING,
) )
from govoplan_core.core.search import SearchSourceProviderRegistration from govoplan_core.core.search import SearchSourceProviderRegistration
@@ -254,6 +256,7 @@ manifest = ModuleManifest(
CAPABILITY_POSTBOX_DELIVERY, CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE, CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_ROUTING, CAPABILITY_POSTBOX_ROUTING,
CAPABILITY_POSTBOX_PORTAL,
) )
), ),
requires_interfaces=( requires_interfaces=(
@@ -285,6 +288,12 @@ manifest = ModuleManifest(
version_max_exclusive="2.0.0", version_max_exclusive="2.0.0",
optional=True, optional=True,
), ),
ModuleInterfaceRequirement(
name=CAPABILITY_FILES_POSTBOX_REFERENCES,
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
@@ -426,8 +435,44 @@ manifest = ModuleManifest(
CAPABILITY_POSTBOX_DELIVERY: _configure, CAPABILITY_POSTBOX_DELIVERY: _configure,
CAPABILITY_POSTBOX_EVIDENCE: _configure, CAPABILITY_POSTBOX_EVIDENCE: _configure,
CAPABILITY_POSTBOX_ROUTING: _configure, CAPABILITY_POSTBOX_ROUTING: _configure,
CAPABILITY_POSTBOX_PORTAL: lambda context: __import__(
"govoplan_postbox.backend.portal_projection",
fromlist=["create_portal_projection"],
).create_portal_projection(context),
}, },
documentation=( documentation=(
DocumentationTopic(
id="postbox.files.evidence-references",
title="Open permitted Files evidence from Postbox",
summary="Resolve exact Files versions from a Postbox message without merging Postbox and Files permissions.",
body=(
"A Postbox message retains typed attachment references even when Files is absent or the current user cannot read the payload. "
"When Files is installed, Postbox asks its public capability to resolve the exact referenced version. The download is exposed only "
"when the user also has Files download permission and resource access; Postbox access never silently grants Files access. Missing, "
"deleted, forbidden, and provider-unavailable payloads remain visible as explained evidence references."
),
layer="configured",
documentation_types=("user", "admin"),
audience=("administrator", "user", "campaign_manager"),
related_modules=("files", "audit", "campaigns"),
translations={
"de": {
"title": "Zulässige Files-Nachweise aus dem Postfach öffnen",
"summary": "Exakte Files-Versionen aus einer Postfachnachricht auflösen, ohne Postfach- und Dateiberechtigungen zu vermischen.",
"body": (
"Eine Postfachnachricht bewahrt typisierte Anlagenverweise auch dann, wenn Files fehlt oder die aktuelle Person die Nutzdaten nicht lesen darf. "
"Ist Files installiert, lässt Postbox die exakt referenzierte Version über dessen öffentliche Capability auflösen. Ein Download wird nur angeboten, "
"wenn zusätzlich die Files-Downloadberechtigung und der Ressourcenzugriff bestehen; Postfachzugriff erteilt niemals stillschweigend Dateizugriff. "
"Fehlende, gelöschte, nicht erlaubte oder mangels Anbieter nicht auflösbare Nutzdaten bleiben als erklärte Nachweisverweise sichtbar."
),
}
},
metadata={
"kind": "guide",
"help_contexts": ["postbox.message.attachments"],
},
order=32,
),
DocumentationTopic( DocumentationTopic(
id="postbox.quick-access-and-product-area", id="postbox.quick-access-and-product-area",
title="Postbox in Communication and Messages", title="Postbox in Communication and Messages",
@@ -0,0 +1,33 @@
"""Add explicit portal visibility to Postbox template revisions.
Revision ID: f2a5c8e1b4d7
Revises: e9f4a7b2c5d8
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "f2a5c8e1b4d7"
down_revision = "e9f4a7b2c5d8"
branch_labels = None
depends_on = None
def upgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.add_column(
sa.Column(
"portal_visible",
sa.Boolean(),
nullable=False,
server_default=sa.false(),
)
)
def downgrade() -> None:
with op.batch_alter_table("postbox_template_revisions") as batch:
batch.drop_column("portal_visible")
@@ -0,0 +1,106 @@
from __future__ import annotations
from sqlalchemy import func
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal
from govoplan_core.core.postbox import (
PostboxPortalEntryRef,
PostboxPortalProjectionProvider,
)
from govoplan_core.core.modules import ModuleContext
from govoplan_postbox.backend.db.models import Postbox, PostboxMessage
from govoplan_postbox.backend.principals import actor_from_principal
from govoplan_postbox.backend.runtime import configure_runtime, get_service
class PortalProjection(PostboxPortalProjectionProvider):
"""Read-only Portal projection; Postbox remains the access authority."""
def list_portal_entries(
self,
session: object,
principal: object,
*,
tenant_id: str,
limit: int = 100,
) -> tuple[PostboxPortalEntryRef, ...]:
if not isinstance(session, Session):
raise TypeError("Postbox Portal projection requires a SQLAlchemy session.")
if not isinstance(principal, ApiPrincipal):
raise TypeError("Postbox Portal projection requires an API principal.")
if principal.tenant_id != tenant_id:
return ()
actor = actor_from_principal(principal)
visible = tuple(
get_service().list_visible_postboxes(
session,
tenant_id=tenant_id,
actor=actor,
)
)
if not visible:
return ()
visible_by_id = {entry.id: entry for entry in visible}
rows = (
session.query(Postbox)
.filter(
Postbox.tenant_id == tenant_id,
Postbox.id.in_(tuple(visible_by_id)),
Postbox.status == "active",
)
.all()
)
enabled_ids = {
row.id
for row in rows
if bool((row.settings or {}).get("portal_visible"))
}
if not enabled_ids:
return ()
ordered_ids = tuple(
entry.id
for entry in sorted(
(visible_by_id[item_id] for item_id in enabled_ids),
key=lambda item: (item.name.casefold(), item.id),
)[: max(1, min(limit, 500))]
)
counts = get_service().message_counts_by_postbox(
session,
tenant_id=tenant_id,
postbox_ids=ordered_ids,
actor=actor,
)
latest_rows = (
session.query(
PostboxMessage.postbox_id,
func.max(PostboxMessage.delivered_at),
)
.filter(
PostboxMessage.tenant_id == tenant_id,
PostboxMessage.postbox_id.in_(ordered_ids),
PostboxMessage.classification.in_(
tuple(actor.authorized_classifications)
),
)
.group_by(PostboxMessage.postbox_id)
.all()
)
latest = {str(postbox_id): delivered_at for postbox_id, delivered_at in latest_rows}
return tuple(
PostboxPortalEntryRef(
postbox=visible_by_id[postbox_id],
unread_count=int(counts.get(postbox_id, {}).get("unread", 0)),
latest_message_at=latest.get(postbox_id),
route_path=f"/postbox?postbox={postbox_id}",
)
for postbox_id in ordered_ids
)
def create_portal_projection(context: ModuleContext) -> PortalProjection:
configure_runtime(registry=context.registry)
return PortalProjection()
__all__ = ["PortalProjection", "create_portal_projection"]
+116 -1
View File
@@ -24,6 +24,10 @@ from govoplan_core.core.postbox import (
PostboxTargetRef, PostboxTargetRef,
PostboxWrappedKeyRef, PostboxWrappedKeyRef,
) )
from govoplan_core.core.files import (
PostboxFileReferenceRequest,
postbox_file_reference_provider,
)
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_postbox.backend.manifest import ( from govoplan_postbox.backend.manifest import (
ACKNOWLEDGE_SCOPE, ACKNOWLEDGE_SCOPE,
@@ -34,9 +38,11 @@ from govoplan_postbox.backend.manifest import (
SEND_SCOPE, SEND_SCOPE,
TEMPLATE_ADMIN_SCOPE, TEMPLATE_ADMIN_SCOPE,
) )
from govoplan_postbox.backend.runtime import get_service from govoplan_postbox.backend.runtime import get_registry, get_service
from govoplan_postbox.backend.schemas import ( from govoplan_postbox.backend.schemas import (
PostboxAccessDecisionResponse, PostboxAccessDecisionResponse,
PostboxAttachmentResolutionItem,
PostboxAttachmentResolutionResponse,
PostboxDeliveryCreateRequest, PostboxDeliveryCreateRequest,
PostboxDeliveryResponse, PostboxDeliveryResponse,
PostboxDirectoryItem, PostboxDirectoryItem,
@@ -237,6 +243,7 @@ def _template_item(template) -> PostboxTemplateItem:
"address_pattern": revision.address_pattern, "address_pattern": revision.address_pattern,
"classification": revision.classification, "classification": revision.classification,
"allow_vacant_delivery": revision.allow_vacant_delivery, "allow_vacant_delivery": revision.allow_vacant_delivery,
"portal_visible": revision.portal_visible,
"encryption_profile": revision.encryption_profile, "encryption_profile": revision.encryption_profile,
"encryption_vault_id": revision.encryption_vault_id, "encryption_vault_id": revision.encryption_vault_id,
"history_policy": dict(revision.history_policy or {}), "history_policy": dict(revision.history_policy or {}),
@@ -486,6 +493,114 @@ def api_get_postbox_message(
return _message_item(message) return _message_item(message)
@router.get(
"/messages/{message_id}/attachment-resolutions",
response_model=PostboxAttachmentResolutionResponse,
)
def api_resolve_postbox_message_attachments(
message_id: str,
assignment_context_id: str | None = None,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxAttachmentResolutionResponse:
_require(principal, READ_SCOPE)
try:
message = get_service().get_message(
session,
tenant_id=principal.tenant_id,
message_id=message_id,
actor=_actor(
principal,
assignment_context_id=assignment_context_id,
),
)
except PostboxError as exc:
raise _http_error(exc) from exc
if message is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Postbox message not found.",
)
provider = postbox_file_reference_provider(get_registry())
file_types = {"file", "file_asset", "files:file", "file_version", "files:file_version"}
requests = tuple(
PostboxFileReferenceRequest(
reference_type=attachment.reference_type,
reference_id=attachment.reference_id,
postbox_id=message.postbox_id,
message_id=message.id,
)
for attachment in message.attachments
if attachment.reference_type.strip().casefold() in file_types
)
resolved = (
provider.resolve_postbox_references(
session,
principal,
tenant_id=principal.tenant_id,
requests=requests,
)
if provider is not None and requests
else ()
)
by_reference = {
(item.reference_type, item.reference_id): item for item in resolved
}
items: list[PostboxAttachmentResolutionItem] = []
for attachment in message.attachments:
resolution = by_reference.get(
(attachment.reference_type, attachment.reference_id)
)
is_file = attachment.reference_type.strip().casefold() in file_types
attachment_payload = asdict(attachment)
attachment_payload.update(
{
"name": (
resolution.filename
if resolution and resolution.filename
else attachment.name
),
"media_type": (
resolution.content_type
if resolution and resolution.content_type
else attachment.media_type
),
"size_bytes": (
resolution.size_bytes
if resolution and resolution.size_bytes is not None
else attachment.size_bytes
),
"digest": (
resolution.sha256
if resolution and resolution.sha256
else attachment.digest
),
}
)
items.append(
PostboxAttachmentResolutionItem(
**attachment_payload,
available=bool(resolution and resolution.available),
reason_code=(
resolution.reason_code
if resolution is not None
else (
"files_provider_unavailable"
if is_file
else "reference_provider_unavailable"
)
),
file_asset_id=resolution.file_asset_id if resolution else None,
file_version_id=resolution.file_version_id if resolution else None,
download_path=resolution.download_path if resolution else None,
provenance=dict(resolution.provenance) if resolution else {},
)
)
session.commit()
return PostboxAttachmentResolutionResponse(attachments=items)
@router.patch( @router.patch(
"/messages/{message_id}/state", "/messages/{message_id}/state",
response_model=PostboxMessageItem, response_model=PostboxMessageItem,
+15
View File
@@ -75,6 +75,19 @@ class PostboxAttachmentPayload(BaseModel):
metadata: dict[str, Any] = Field(default_factory=dict) metadata: dict[str, Any] = Field(default_factory=dict)
class PostboxAttachmentResolutionItem(PostboxAttachmentPayload):
available: bool = False
reason_code: str
file_asset_id: str | None = None
file_version_id: str | None = None
download_path: str | None = None
provenance: dict[str, Any] = Field(default_factory=dict)
class PostboxAttachmentResolutionResponse(BaseModel):
attachments: list[PostboxAttachmentResolutionItem] = Field(default_factory=list)
class PostboxWrappedKeyPayload(BaseModel): class PostboxWrappedKeyPayload(BaseModel):
recipient_type: str = Field(min_length=1, max_length=50) recipient_type: str = Field(min_length=1, max_length=50)
recipient_id: str = Field(min_length=1, max_length=255) recipient_id: str = Field(min_length=1, max_length=255)
@@ -355,6 +368,7 @@ class PostboxExactCreateRequest(BaseModel):
function_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) address_key: str | None = Field(default=None, max_length=120)
classification: PostboxClassification = "internal" classification: PostboxClassification = "internal"
portal_visible: bool = False
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = ( encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
"plaintext_v1" "plaintext_v1"
) )
@@ -392,6 +406,7 @@ class PostboxTemplateRevisionPayload(BaseModel):
) )
classification: PostboxClassification = "internal" classification: PostboxClassification = "internal"
allow_vacant_delivery: bool = True allow_vacant_delivery: bool = True
portal_visible: bool = False
encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = ( encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = (
"plaintext_v1" "plaintext_v1"
) )
+18 -2
View File
@@ -2869,6 +2869,7 @@ class PostboxService:
address_key: str | None, address_key: str | None,
description: str | None, description: str | None,
classification: str, classification: str,
portal_visible: bool = False,
actor_id: str | None, actor_id: str | None,
encryption_profile: str = "plaintext_v1", encryption_profile: str = "plaintext_v1",
encryption_vault_id: str | None = None, encryption_vault_id: str | None = None,
@@ -2916,6 +2917,7 @@ class PostboxService:
actor_id=actor_id, actor_id=actor_id,
encryption_profile=encryption_profile, encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id, encryption_vault_id=encryption_vault_id,
portal_visible=portal_visible,
) )
def archive_postbox( def archive_postbox(
@@ -3005,6 +3007,7 @@ class PostboxService:
address_pattern: str, address_pattern: str,
classification: str, classification: str,
allow_vacant_delivery: bool, allow_vacant_delivery: bool,
portal_visible: bool = False,
routing_policy: Mapping[str, object] | None, routing_policy: Mapping[str, object] | None,
encryption_profile: str, encryption_profile: str,
encryption_vault_id: str | None, encryption_vault_id: str | None,
@@ -3012,7 +3015,7 @@ class PostboxService:
context_key: str | None, context_key: str | None,
limit: int, limit: int,
) -> dict[str, object]: ) -> dict[str, object]:
del description del description, portal_visible
self._validate_classification(classification) self._validate_classification(classification)
_validate_encryption_configuration( _validate_encryption_configuration(
encryption_profile, encryption_profile,
@@ -3205,6 +3208,7 @@ class PostboxService:
address_pattern: str, address_pattern: str,
classification: str, classification: str,
allow_vacant_delivery: bool, allow_vacant_delivery: bool,
portal_visible: bool = False,
actor_id: str | None, actor_id: str | None,
scope_structure_id: str | None = None, scope_structure_id: str | None = None,
scope_relation_type_ids: Sequence[str] = (), scope_relation_type_ids: Sequence[str] = (),
@@ -3260,6 +3264,7 @@ class PostboxService:
address_pattern=address_pattern, address_pattern=address_pattern,
classification=classification, classification=classification,
allow_vacant_delivery=allow_vacant_delivery, allow_vacant_delivery=allow_vacant_delivery,
portal_visible=portal_visible,
encryption_profile=encryption_profile, encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id, encryption_vault_id=encryption_vault_id,
history_policy={}, history_policy={},
@@ -3295,6 +3300,7 @@ class PostboxService:
address_pattern: str, address_pattern: str,
classification: str, classification: str,
allow_vacant_delivery: bool, allow_vacant_delivery: bool,
portal_visible: bool = False,
actor_id: str | None, actor_id: str | None,
expected_revision: int, expected_revision: int,
scope_structure_id: str | None = None, scope_structure_id: str | None = None,
@@ -3351,6 +3357,7 @@ class PostboxService:
address_pattern=address_pattern, address_pattern=address_pattern,
classification=classification, classification=classification,
allow_vacant_delivery=allow_vacant_delivery, allow_vacant_delivery=allow_vacant_delivery,
portal_visible=portal_visible,
encryption_profile=encryption_profile, encryption_profile=encryption_profile,
encryption_vault_id=encryption_vault_id, encryption_vault_id=encryption_vault_id,
history_policy={}, history_policy={},
@@ -4492,6 +4499,7 @@ class PostboxService:
actor_id: str | None, actor_id: str | None,
encryption_profile: str | None = None, encryption_profile: str | None = None,
encryption_vault_id: str | None = None, encryption_vault_id: str | None = None,
portal_visible: bool = False,
) -> Postbox: ) -> Postbox:
effective_profile = ( effective_profile = (
revision.encryption_profile revision.encryption_profile
@@ -4503,6 +4511,11 @@ class PostboxService:
if revision is not None if revision is not None
else encryption_vault_id else encryption_vault_id
) )
effective_portal_visible = (
bool(revision.portal_visible)
if revision is not None
else bool(portal_visible)
)
if effective_profile == "server_envelope_v1" and not str( if effective_profile == "server_envelope_v1" and not str(
effective_vault_id or "" effective_vault_id or ""
).strip(): ).strip():
@@ -4538,11 +4551,14 @@ class PostboxService:
classification=classification, classification=classification,
encryption_profile=effective_profile, encryption_profile=effective_profile,
key_epoch=1, key_epoch=1,
settings=( settings={
**(
{"encryption_vault_id": str(effective_vault_id)} {"encryption_vault_id": str(effective_vault_id)}
if effective_vault_id if effective_vault_id
else {} else {}
), ),
"portal_visible": effective_portal_visible,
},
) )
postbox.bindings.append( postbox.bindings.append(
PostboxBinding( PostboxBinding(
+2
View File
@@ -8,6 +8,7 @@ from govoplan_core.core.postbox import (
CAPABILITY_POSTBOX_DIRECTORY, CAPABILITY_POSTBOX_DIRECTORY,
CAPABILITY_POSTBOX_EVIDENCE, CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_MESSAGES, CAPABILITY_POSTBOX_MESSAGES,
CAPABILITY_POSTBOX_PORTAL,
CAPABILITY_POSTBOX_ROUTING, CAPABILITY_POSTBOX_ROUTING,
) )
from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER
@@ -31,6 +32,7 @@ class PostboxManifestTests(unittest.TestCase):
CAPABILITY_POSTBOX_DELIVERY, CAPABILITY_POSTBOX_DELIVERY,
CAPABILITY_POSTBOX_EVIDENCE, CAPABILITY_POSTBOX_EVIDENCE,
CAPABILITY_POSTBOX_ROUTING, CAPABILITY_POSTBOX_ROUTING,
CAPABILITY_POSTBOX_PORTAL,
}, },
set(manifest.capability_factories), set(manifest.capability_factories),
) )
+10
View File
@@ -34,6 +34,10 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions." "govoplan_postbox.backend.migrations.versions."
"e9f4a7b2c5d8_v014_template_scope_preview" "e9f4a7b2c5d8_v014_template_scope_preview"
) )
portal_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"f2a5c8e1b4d7_v015_portal_visibility"
)
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
try: try:
with engine.begin() as connection: with engine.begin() as connection:
@@ -44,12 +48,14 @@ class PostboxMigrationTests(unittest.TestCase):
envelope_original = envelope_migration.op envelope_original = envelope_migration.op
protection_original = protection_migration.op protection_original = protection_migration.op
scope_original = scope_migration.op scope_original = scope_migration.op
portal_original = portal_migration.op
migration.op = operations migration.op = operations
route_migration.op = operations route_migration.op = operations
occ_migration.op = operations occ_migration.op = operations
envelope_migration.op = operations envelope_migration.op = operations
protection_migration.op = operations protection_migration.op = operations
scope_migration.op = operations scope_migration.op = operations
portal_migration.op = operations
try: try:
migration.upgrade() migration.upgrade()
route_migration.upgrade() route_migration.upgrade()
@@ -57,6 +63,7 @@ class PostboxMigrationTests(unittest.TestCase):
envelope_migration.upgrade() envelope_migration.upgrade()
protection_migration.upgrade() protection_migration.upgrade()
scope_migration.upgrade() scope_migration.upgrade()
portal_migration.upgrade()
tables = set(inspect(connection).get_table_names()) tables = set(inspect(connection).get_table_names())
self.assertIn("postboxes", tables) self.assertIn("postboxes", tables)
self.assertIn("postbox_messages", tables) self.assertIn("postbox_messages", tables)
@@ -93,6 +100,7 @@ class PostboxMigrationTests(unittest.TestCase):
"encryption_vault_id", "encryption_vault_id",
"scope_structure_id", "scope_structure_id",
"scope_relation_type_ids", "scope_relation_type_ids",
"portal_visible",
}.issubset(template_revision_columns) }.issubset(template_revision_columns)
) )
self.assertIn("authoring_key", message_columns) self.assertIn("authoring_key", message_columns)
@@ -121,6 +129,7 @@ class PostboxMigrationTests(unittest.TestCase):
route_columns route_columns
) )
) )
portal_migration.downgrade()
scope_migration.downgrade() scope_migration.downgrade()
protection_migration.downgrade() protection_migration.downgrade()
envelope_migration.downgrade() envelope_migration.downgrade()
@@ -141,6 +150,7 @@ class PostboxMigrationTests(unittest.TestCase):
envelope_migration.op = envelope_original envelope_migration.op = envelope_original
protection_migration.op = protection_original protection_migration.op = protection_original
scope_migration.op = scope_original scope_migration.op = scope_original
portal_migration.op = portal_original
finally: finally:
engine.dispose() engine.dispose()
+31
View File
@@ -40,6 +40,7 @@ from govoplan_postbox.backend.db.models import (
PostboxTemplateRevision, PostboxTemplateRevision,
) )
from govoplan_postbox.backend.router import router from govoplan_postbox.backend.router import router
from govoplan_postbox.backend.portal_projection import PortalProjection
from govoplan_postbox.backend.service import PostboxService from govoplan_postbox.backend.service import PostboxService
@@ -220,6 +221,7 @@ class PostboxRouterTests(unittest.TestCase):
account=SimpleNamespace(id="account-1"), account=SimpleNamespace(id="account-1"),
user=SimpleNamespace(id="membership-1"), user=SimpleNamespace(id="membership-1"),
) )
self.principal = principal
app = FastAPI() app = FastAPI()
app.include_router(router, prefix="/api/v1") app.include_router(router, prefix="/api/v1")
@@ -250,6 +252,35 @@ class PostboxRouterTests(unittest.TestCase):
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
self.assertIn("not active for this principal", response.text) self.assertIn("not active for this principal", response.text)
def test_portal_projection_requires_explicit_visibility_and_keeps_postbox_access(self) -> None:
projection = PortalProjection()
with Session(self.engine) as session, patch(
"govoplan_postbox.backend.portal_projection.get_service",
return_value=self.service,
):
self.assertEqual(
(),
projection.list_portal_entries(
session,
self.principal,
tenant_id="tenant-1",
),
)
postbox = session.get(Postbox, self.postbox_id)
assert postbox is not None
postbox.settings = {**postbox.settings, "portal_visible": True}
session.flush()
entries = projection.list_portal_entries(
session,
self.principal,
tenant_id="tenant-1",
)
self.assertEqual(1, len(entries))
self.assertEqual(self.postbox_id, entries[0].postbox.id)
self.assertEqual(f"/postbox?postbox={self.postbox_id}", entries[0].route_path)
def test_template_impact_preview_is_available_without_writes(self) -> None: def test_template_impact_preview_is_available_without_writes(self) -> None:
with Session(self.engine) as session: with Session(self.engine) as session:
before = session.query(Postbox).count() before = session.query(Postbox).count()
+49
View File
@@ -2,6 +2,8 @@ import {
apiFetch, apiFetch,
apiPath, apiPath,
apiPostJson, apiPostJson,
apiUrl,
authHeaders,
type ApiSettings type ApiSettings
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
@@ -62,6 +64,15 @@ export type PostboxAttachment = {
metadata: Record<string, unknown>; metadata: Record<string, unknown>;
}; };
export type PostboxAttachmentResolution = PostboxAttachment & {
available: boolean;
reason_code: string;
file_asset_id?: string | null;
file_version_id?: string | null;
download_path?: string | null;
provenance: Record<string, unknown>;
};
export type PostboxMessage = { export type PostboxMessage = {
id: string; id: string;
tenant_id: string; tenant_id: string;
@@ -200,6 +211,7 @@ export type PostboxTemplateRevision = {
address_pattern: string; address_pattern: string;
classification: string; classification: string;
allow_vacant_delivery: boolean; allow_vacant_delivery: boolean;
portal_visible: boolean;
encryption_profile: string; encryption_profile: string;
history_policy: Record<string, unknown>; history_policy: Record<string, unknown>;
routing_policy: PostboxRoutingPolicy; routing_policy: PostboxRoutingPolicy;
@@ -235,6 +247,7 @@ export type PostboxTemplateRevisionPayload = Pick<
| "address_pattern" | "address_pattern"
| "classification" | "classification"
| "allow_vacant_delivery" | "allow_vacant_delivery"
| "portal_visible"
| "routing_policy" | "routing_policy"
>; >;
@@ -276,6 +289,7 @@ export type PostboxExactCreatePayload = {
function_id: string; function_id: string;
address_key?: string | null; address_key?: string | null;
classification: string; classification: string;
portal_visible: boolean;
}; };
export type PostboxMessageAuthoringPayload = { export type PostboxMessageAuthoringPayload = {
@@ -328,6 +342,41 @@ export function getPostboxMessage(
return apiFetch(settings, `/api/v1/postbox/messages/${encodeURIComponent(messageId)}`); return apiFetch(settings, `/api/v1/postbox/messages/${encodeURIComponent(messageId)}`);
} }
export async function resolvePostboxAttachments(
settings: ApiSettings,
messageId: string
): Promise<PostboxAttachmentResolution[]> {
const response = await apiFetch<{ attachments: PostboxAttachmentResolution[] }>(
settings,
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/attachment-resolutions`
);
return response.attachments;
}
export async function downloadPostboxAttachment(
settings: ApiSettings,
attachment: PostboxAttachmentResolution
): Promise<void> {
if (!attachment.available || !attachment.download_path) {
throw new Error("This attachment payload is not available.");
}
const response = await fetch(apiUrl(settings, attachment.download_path), {
headers: authHeaders(settings),
credentials: "include"
});
if (!response.ok) {
throw new Error(`Attachment download failed (${response.status}).`);
}
const objectUrl = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = objectUrl;
link.download = attachment.name || attachment.reference_id;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(objectUrl);
}
export function markPostboxMessage( export function markPostboxMessage(
settings: ApiSettings, settings: ApiSettings,
messageId: string, messageId: string,
@@ -112,6 +112,7 @@ const templateDefaults = (): TemplateDraft => ({
address_pattern: "{template_slug}.{unit_slug}.{function_slug}", address_pattern: "{template_slug}.{unit_slug}.{function_slug}",
classification: "internal", classification: "internal",
allow_vacant_delivery: true, allow_vacant_delivery: true,
portal_visible: false,
routing_policy: routingDefaults() routing_policy: routingDefaults()
}); });
@@ -121,7 +122,8 @@ const exactDefaults = (): ExactDraft => ({
organization_unit_id: "", organization_unit_id: "",
function_id: "", function_id: "",
address_key: "", address_key: "",
classification: "internal" classification: "internal",
portal_visible: false
}); });
export default function PostboxAdminPanel({ export default function PostboxAdminPanel({
@@ -270,6 +272,7 @@ export default function PostboxAdminPanel({
address_pattern: revision.address_pattern, address_pattern: revision.address_pattern,
classification: revision.classification, classification: revision.classification,
allow_vacant_delivery: revision.allow_vacant_delivery, allow_vacant_delivery: revision.allow_vacant_delivery,
portal_visible: revision.portal_visible,
routing_policy: revision.routing_policy ?? routingDefaults() routing_policy: revision.routing_policy ?? routingDefaults()
}; };
setTemplatePreview(null); setTemplatePreview(null);
@@ -1126,6 +1129,14 @@ function TemplateDialog({
onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })} onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })}
/> />
</div> </div>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Show in Portal"
help="Portal lists this Postbox only for users whose current function assignment already grants Postbox access."
checked={draft.portal_visible}
onChange={(checked) => onChange({ ...draft, portal_visible: checked })}
/>
</div>
<div className="postbox-routing-section"> <div className="postbox-routing-section">
<div className="postbox-routing-heading"> <div className="postbox-routing-heading">
<div> <div>
@@ -1472,6 +1483,14 @@ function ExactPostboxDialog({
<FormField label="Description" documentation={POSTBOX_FIELD_DOCUMENTATION}> <FormField label="Description" documentation={POSTBOX_FIELD_DOCUMENTATION}>
<input value={draft.description || ""} onChange={(event) => onChange({ ...draft, description: event.target.value })} /> <input value={draft.description || ""} onChange={(event) => onChange({ ...draft, description: event.target.value })} />
</FormField> </FormField>
<div className="postbox-toggle-field">
<ToggleSwitch
label="Show in Portal"
help="Portal lists this Postbox only when the current user's function assignment grants access."
checked={draft.portal_visible}
onChange={(checked) => onChange({ ...draft, portal_visible: checked })}
/>
</div>
</div> </div>
</Dialog> </Dialog>
); );
@@ -1578,6 +1597,7 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload {
address_pattern: draft.address_pattern, address_pattern: draft.address_pattern,
classification: draft.classification, classification: draft.classification,
allow_vacant_delivery: draft.allow_vacant_delivery, allow_vacant_delivery: draft.allow_vacant_delivery,
portal_visible: draft.portal_visible,
routing_policy: draft.routing_policy routing_policy: draft.routing_policy
}; };
} }
+56 -4
View File
@@ -3,6 +3,7 @@ import {
Archive, Archive,
Building2, Building2,
CheckCheck, CheckCheck,
Download,
Inbox, Inbox,
Layers3, Layers3,
MailOpen, MailOpen,
@@ -45,15 +46,18 @@ import {
createPostboxGrouping, createPostboxGrouping,
createPostboxMessage, createPostboxMessage,
deletePostboxGrouping, deletePostboxGrouping,
downloadPostboxAttachment,
getPostboxMessage, getPostboxMessage,
listPostboxGroupings, listPostboxGroupings,
listPostboxMessages, listPostboxMessages,
listPostboxes, listPostboxes,
markPostboxMessage, markPostboxMessage,
resolvePostboxAttachments,
replyToPostboxMessage, replyToPostboxMessage,
updatePostboxGrouping, updatePostboxGrouping,
type PostboxDirectoryItem, type PostboxDirectoryItem,
type PostboxGrouping, type PostboxGrouping,
type PostboxAttachmentResolution,
type PostboxMessage type PostboxMessage
} from "../../api/postbox"; } from "../../api/postbox";
import { import {
@@ -106,6 +110,9 @@ export default function PostboxPage({
const requestedMessageId = useRef( const requestedMessageId = useRef(
new URLSearchParams(window.location.search).get("message") ?? "" new URLSearchParams(window.location.search).get("message") ?? ""
); );
const requestedPostboxId = useRef(
new URLSearchParams(window.location.search).get("postbox") ?? ""
);
const requestedMessageLoaded = useRef(false); const requestedMessageLoaded = useRef(false);
const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]); const [postboxes, setPostboxes] = useState<PostboxDirectoryItem[]>([]);
const [groupings, setGroupings] = useState<PostboxGrouping[]>([]); const [groupings, setGroupings] = useState<PostboxGrouping[]>([]);
@@ -114,6 +121,7 @@ export default function PostboxPage({
const [messages, setMessages] = useState<PostboxMessage[]>([]); const [messages, setMessages] = useState<PostboxMessage[]>([]);
const [selectedMessageId, setSelectedMessageId] = useState(""); const [selectedMessageId, setSelectedMessageId] = useState("");
const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null); const [selectedMessage, setSelectedMessage] = useState<PostboxMessage | null>(null);
const [attachmentResolutions, setAttachmentResolutions] = useState<PostboxAttachmentResolution[]>([]);
const [unavailableSelection, setUnavailableSelection] = useState(""); const [unavailableSelection, setUnavailableSelection] = useState("");
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [messageState, setMessageState] = useState<MessageStateFilter>("all"); const [messageState, setMessageState] = useState<MessageStateFilter>("all");
@@ -204,6 +212,8 @@ export default function PostboxPage({
setSelectedPostboxId((current) => setSelectedPostboxId((current) =>
current && nextPostboxes.some((postbox) => postbox.id === current) current && nextPostboxes.some((postbox) => postbox.id === current)
? current ? current
: nextPostboxes.some((postbox) => postbox.id === requestedPostboxId.current)
? requestedPostboxId.current
: "" : ""
); );
} catch (loadError) { } catch (loadError) {
@@ -316,14 +326,19 @@ export default function PostboxPage({
if (message.availability === "available" && !message.read_at) { if (message.availability === "available" && !message.read_at) {
message = await markPostboxMessage(settings, selectedMessageId, "read"); message = await markPostboxMessage(settings, selectedMessageId, "read");
} }
const resolutions = message.attachments.length
? await resolvePostboxAttachments(settings, message.id)
: [];
if (cancelled) return; if (cancelled) return;
setSelectedMessage(message); setSelectedMessage(message);
setAttachmentResolutions(resolutions);
setMessages((items) => setMessages((items) =>
items.map((item) => (item.id === message.id ? message : item)) items.map((item) => (item.id === message.id ? message : item))
); );
} catch (loadError) { } catch (loadError) {
if (!cancelled && isApiError(loadError, 403, 404)) { if (!cancelled && isApiError(loadError, 403, 404)) {
setSelectedMessage(null); setSelectedMessage(null);
setAttachmentResolutions([]);
setUnavailableSelection( setUnavailableSelection(
"This message is no longer available or is outside your current Postbox assignments." "This message is no longer available or is outside your current Postbox assignments."
); );
@@ -866,6 +881,12 @@ export default function PostboxPage({
<MessageDetail <MessageDetail
message={selectedMessage} message={selectedMessage}
postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)} postbox={postboxes.find((item) => item.id === selectedMessage.postbox_id)}
attachmentResolutions={attachmentResolutions}
onDownload={(attachment) => {
void downloadPostboxAttachment(settings, attachment).catch((downloadError) => {
setError(errorMessage(downloadError));
});
}}
/> />
) : unavailableSelection ? ( ) : unavailableSelection ? (
<div className="postbox-empty postbox-unavailable-message"> <div className="postbox-empty postbox-unavailable-message">
@@ -1099,10 +1120,14 @@ export default function PostboxPage({
function MessageDetail({ function MessageDetail({
message, message,
postbox postbox,
attachmentResolutions,
onDownload
}: { }: {
message: PostboxMessage; message: PostboxMessage;
postbox?: PostboxDirectoryItem; postbox?: PostboxDirectoryItem;
attachmentResolutions: PostboxAttachmentResolution[];
onDownload: (attachment: PostboxAttachmentResolution) => void;
}) { }) {
const { language } = usePlatformLanguage(); const { language } = usePlatformLanguage();
return ( return (
@@ -1166,15 +1191,30 @@ function MessageDetail({
<section className="postbox-attachments"> <section className="postbox-attachments">
<h2>Evidence and attachments</h2> <h2>Evidence and attachments</h2>
{!message.attachments.length ? <p>No attachment references.</p> : null} {!message.attachments.length ? <p>No attachment references.</p> : null}
{message.attachments.map((attachment) => ( {message.attachments.map((attachment) => {
const resolution = attachmentResolutions.find(
(item) => item.reference_type === attachment.reference_type
&& item.reference_id === attachment.reference_id
);
return (
<div key={`${attachment.reference_type}:${attachment.reference_id}`}> <div key={`${attachment.reference_type}:${attachment.reference_id}`}>
<Paperclip size={15} /> <Paperclip size={15} />
<span> <span>
<strong>{attachment.name || attachment.reference_id}</strong> <strong>{resolution?.name || attachment.name || attachment.reference_id}</strong>
<small>{attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""}</small> <small>{attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""}</small>
{resolution && !resolution.available ? (
<small>{attachmentResolutionExplanation(resolution.reason_code)}</small>
) : null}
</span> </span>
{resolution?.available ? (
<IconButton
icon={<Download size={15} />}
label={`Download ${resolution.name || attachment.name || "attachment"}`}
onClick={() => onDownload(resolution)}
/>
) : null}
</div> </div>
))} )})}
</section> </section>
{postbox?.access ? ( {postbox?.access ? (
<section className="postbox-access-explanation"> <section className="postbox-access-explanation">
@@ -1189,6 +1229,18 @@ function MessageDetail({
); );
} }
function attachmentResolutionExplanation(reasonCode: string): string {
const explanations: Record<string, string> = {
download_permission_missing: "Files download permission is required.",
file_access_denied: "The referenced file is outside your current Files access.",
file_not_found: "The referenced file or version no longer exists.",
file_payload_missing: "The referenced file payload is unavailable.",
files_provider_unavailable: "Files is not available in this installation.",
reference_provider_unavailable: "No provider can open this evidence type."
};
return explanations[reasonCode] || "The referenced payload cannot currently be opened.";
}
function sourceName( function sourceName(
postboxes: PostboxDirectoryItem[], postboxes: PostboxDirectoryItem[],
postboxId: string postboxId: string