diff --git a/src/govoplan_postbox/backend/db/models.py b/src/govoplan_postbox/backend/db/models.py index e94e5b7..d96e76f 100644 --- a/src/govoplan_postbox/backend/db/models.py +++ b/src/govoplan_postbox/backend/db/models.py @@ -151,6 +151,11 @@ class PostboxTemplateRevision(Base, TimestampMixin): default=True, nullable=False, ) + portal_visible: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + ) encryption_profile: Mapped[str] = mapped_column( String(80), default="plaintext_v1", diff --git a/src/govoplan_postbox/backend/manifest.py b/src/govoplan_postbox/backend/manifest.py index 03f807d..b1ca349 100644 --- a/src/govoplan_postbox/backend/manifest.py +++ b/src/govoplan_postbox/backend/manifest.py @@ -8,6 +8,7 @@ from govoplan_core.core.access import ( ) from govoplan_core.core.identity import CAPABILITY_IDENTITY_DIRECTORY from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER +from govoplan_core.core.files import CAPABILITY_FILES_POSTBOX_REFERENCES from govoplan_core.core.idm import ( CAPABILITY_IDM_DIRECTORY, CAPABILITY_IDM_FUNCTION_ASSIGNMENTS, @@ -44,6 +45,7 @@ from govoplan_core.core.postbox import ( CAPABILITY_POSTBOX_DIRECTORY, CAPABILITY_POSTBOX_EVIDENCE, CAPABILITY_POSTBOX_MESSAGES, + CAPABILITY_POSTBOX_PORTAL, CAPABILITY_POSTBOX_ROUTING, ) from govoplan_core.core.search import SearchSourceProviderRegistration @@ -254,6 +256,7 @@ manifest = ModuleManifest( CAPABILITY_POSTBOX_DELIVERY, CAPABILITY_POSTBOX_EVIDENCE, CAPABILITY_POSTBOX_ROUTING, + CAPABILITY_POSTBOX_PORTAL, ) ), requires_interfaces=( @@ -285,6 +288,12 @@ manifest = ModuleManifest( version_max_exclusive="2.0.0", optional=True, ), + ModuleInterfaceRequirement( + name=CAPABILITY_FILES_POSTBOX_REFERENCES, + version_min="1.0.0", + version_max_exclusive="2.0.0", + optional=True, + ), ), permissions=PERMISSIONS, role_templates=ROLE_TEMPLATES, @@ -426,8 +435,44 @@ manifest = ModuleManifest( CAPABILITY_POSTBOX_DELIVERY: _configure, CAPABILITY_POSTBOX_EVIDENCE: _configure, CAPABILITY_POSTBOX_ROUTING: _configure, + CAPABILITY_POSTBOX_PORTAL: lambda context: __import__( + "govoplan_postbox.backend.portal_projection", + fromlist=["create_portal_projection"], + ).create_portal_projection(context), }, 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( id="postbox.quick-access-and-product-area", title="Postbox in Communication and Messages", diff --git a/src/govoplan_postbox/backend/migrations/versions/f2a5c8e1b4d7_v015_portal_visibility.py b/src/govoplan_postbox/backend/migrations/versions/f2a5c8e1b4d7_v015_portal_visibility.py new file mode 100644 index 0000000..6940d43 --- /dev/null +++ b/src/govoplan_postbox/backend/migrations/versions/f2a5c8e1b4d7_v015_portal_visibility.py @@ -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") diff --git a/src/govoplan_postbox/backend/portal_projection.py b/src/govoplan_postbox/backend/portal_projection.py new file mode 100644 index 0000000..125665b --- /dev/null +++ b/src/govoplan_postbox/backend/portal_projection.py @@ -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"] diff --git a/src/govoplan_postbox/backend/router.py b/src/govoplan_postbox/backend/router.py index 8ca9985..f85b655 100644 --- a/src/govoplan_postbox/backend/router.py +++ b/src/govoplan_postbox/backend/router.py @@ -24,6 +24,10 @@ from govoplan_core.core.postbox import ( PostboxTargetRef, PostboxWrappedKeyRef, ) +from govoplan_core.core.files import ( + PostboxFileReferenceRequest, + postbox_file_reference_provider, +) from govoplan_core.db.session import get_session from govoplan_postbox.backend.manifest import ( ACKNOWLEDGE_SCOPE, @@ -34,9 +38,11 @@ from govoplan_postbox.backend.manifest import ( SEND_SCOPE, TEMPLATE_ADMIN_SCOPE, ) -from govoplan_postbox.backend.runtime import get_service +from govoplan_postbox.backend.runtime import get_registry, get_service from govoplan_postbox.backend.schemas import ( PostboxAccessDecisionResponse, + PostboxAttachmentResolutionItem, + PostboxAttachmentResolutionResponse, PostboxDeliveryCreateRequest, PostboxDeliveryResponse, PostboxDirectoryItem, @@ -237,6 +243,7 @@ def _template_item(template) -> PostboxTemplateItem: "address_pattern": revision.address_pattern, "classification": revision.classification, "allow_vacant_delivery": revision.allow_vacant_delivery, + "portal_visible": revision.portal_visible, "encryption_profile": revision.encryption_profile, "encryption_vault_id": revision.encryption_vault_id, "history_policy": dict(revision.history_policy or {}), @@ -486,6 +493,114 @@ def api_get_postbox_message( return _message_item(message) +@router.get( + "/messages/{message_id}/attachment-resolutions", + response_model=PostboxAttachmentResolutionResponse, +) +def api_resolve_postbox_message_attachments( + message_id: str, + assignment_context_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxAttachmentResolutionResponse: + _require(principal, READ_SCOPE) + try: + message = get_service().get_message( + session, + tenant_id=principal.tenant_id, + message_id=message_id, + actor=_actor( + principal, + assignment_context_id=assignment_context_id, + ), + ) + except PostboxError as exc: + raise _http_error(exc) from exc + if message is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Postbox message not found.", + ) + + provider = postbox_file_reference_provider(get_registry()) + file_types = {"file", "file_asset", "files:file", "file_version", "files:file_version"} + requests = tuple( + PostboxFileReferenceRequest( + reference_type=attachment.reference_type, + reference_id=attachment.reference_id, + postbox_id=message.postbox_id, + message_id=message.id, + ) + for attachment in message.attachments + if attachment.reference_type.strip().casefold() in file_types + ) + resolved = ( + provider.resolve_postbox_references( + session, + principal, + tenant_id=principal.tenant_id, + requests=requests, + ) + if provider is not None and requests + else () + ) + by_reference = { + (item.reference_type, item.reference_id): item for item in resolved + } + items: list[PostboxAttachmentResolutionItem] = [] + for attachment in message.attachments: + resolution = by_reference.get( + (attachment.reference_type, attachment.reference_id) + ) + is_file = attachment.reference_type.strip().casefold() in file_types + attachment_payload = asdict(attachment) + attachment_payload.update( + { + "name": ( + resolution.filename + if resolution and resolution.filename + else attachment.name + ), + "media_type": ( + resolution.content_type + if resolution and resolution.content_type + else attachment.media_type + ), + "size_bytes": ( + resolution.size_bytes + if resolution and resolution.size_bytes is not None + else attachment.size_bytes + ), + "digest": ( + resolution.sha256 + if resolution and resolution.sha256 + else attachment.digest + ), + } + ) + items.append( + PostboxAttachmentResolutionItem( + **attachment_payload, + available=bool(resolution and resolution.available), + reason_code=( + resolution.reason_code + if resolution is not None + else ( + "files_provider_unavailable" + if is_file + else "reference_provider_unavailable" + ) + ), + file_asset_id=resolution.file_asset_id if resolution else None, + file_version_id=resolution.file_version_id if resolution else None, + download_path=resolution.download_path if resolution else None, + provenance=dict(resolution.provenance) if resolution else {}, + ) + ) + session.commit() + return PostboxAttachmentResolutionResponse(attachments=items) + + @router.patch( "/messages/{message_id}/state", response_model=PostboxMessageItem, diff --git a/src/govoplan_postbox/backend/schemas.py b/src/govoplan_postbox/backend/schemas.py index d7af9ed..087fa99 100644 --- a/src/govoplan_postbox/backend/schemas.py +++ b/src/govoplan_postbox/backend/schemas.py @@ -75,6 +75,19 @@ class PostboxAttachmentPayload(BaseModel): metadata: dict[str, Any] = Field(default_factory=dict) +class PostboxAttachmentResolutionItem(PostboxAttachmentPayload): + available: bool = False + reason_code: str + file_asset_id: str | None = None + file_version_id: str | None = None + download_path: str | None = None + provenance: dict[str, Any] = Field(default_factory=dict) + + +class PostboxAttachmentResolutionResponse(BaseModel): + attachments: list[PostboxAttachmentResolutionItem] = Field(default_factory=list) + + class PostboxWrappedKeyPayload(BaseModel): recipient_type: str = Field(min_length=1, max_length=50) recipient_id: str = Field(min_length=1, max_length=255) @@ -355,6 +368,7 @@ class PostboxExactCreateRequest(BaseModel): function_id: str = Field(min_length=1, max_length=36) address_key: str | None = Field(default=None, max_length=120) classification: PostboxClassification = "internal" + portal_visible: bool = False encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = ( "plaintext_v1" ) @@ -392,6 +406,7 @@ class PostboxTemplateRevisionPayload(BaseModel): ) classification: PostboxClassification = "internal" allow_vacant_delivery: bool = True + portal_visible: bool = False encryption_profile: Literal["plaintext_v1", "server_envelope_v1"] = ( "plaintext_v1" ) diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py index 047dfa5..0aa32ac 100644 --- a/src/govoplan_postbox/backend/service.py +++ b/src/govoplan_postbox/backend/service.py @@ -2869,6 +2869,7 @@ class PostboxService: address_key: str | None, description: str | None, classification: str, + portal_visible: bool = False, actor_id: str | None, encryption_profile: str = "plaintext_v1", encryption_vault_id: str | None = None, @@ -2916,6 +2917,7 @@ class PostboxService: actor_id=actor_id, encryption_profile=encryption_profile, encryption_vault_id=encryption_vault_id, + portal_visible=portal_visible, ) def archive_postbox( @@ -3005,6 +3007,7 @@ class PostboxService: address_pattern: str, classification: str, allow_vacant_delivery: bool, + portal_visible: bool = False, routing_policy: Mapping[str, object] | None, encryption_profile: str, encryption_vault_id: str | None, @@ -3012,7 +3015,7 @@ class PostboxService: context_key: str | None, limit: int, ) -> dict[str, object]: - del description + del description, portal_visible self._validate_classification(classification) _validate_encryption_configuration( encryption_profile, @@ -3205,6 +3208,7 @@ class PostboxService: address_pattern: str, classification: str, allow_vacant_delivery: bool, + portal_visible: bool = False, actor_id: str | None, scope_structure_id: str | None = None, scope_relation_type_ids: Sequence[str] = (), @@ -3260,6 +3264,7 @@ class PostboxService: address_pattern=address_pattern, classification=classification, allow_vacant_delivery=allow_vacant_delivery, + portal_visible=portal_visible, encryption_profile=encryption_profile, encryption_vault_id=encryption_vault_id, history_policy={}, @@ -3295,6 +3300,7 @@ class PostboxService: address_pattern: str, classification: str, allow_vacant_delivery: bool, + portal_visible: bool = False, actor_id: str | None, expected_revision: int, scope_structure_id: str | None = None, @@ -3351,6 +3357,7 @@ class PostboxService: address_pattern=address_pattern, classification=classification, allow_vacant_delivery=allow_vacant_delivery, + portal_visible=portal_visible, encryption_profile=encryption_profile, encryption_vault_id=encryption_vault_id, history_policy={}, @@ -4492,6 +4499,7 @@ class PostboxService: actor_id: str | None, encryption_profile: str | None = None, encryption_vault_id: str | None = None, + portal_visible: bool = False, ) -> Postbox: effective_profile = ( revision.encryption_profile @@ -4503,6 +4511,11 @@ class PostboxService: if revision is not None 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( effective_vault_id or "" ).strip(): @@ -4538,11 +4551,14 @@ class PostboxService: classification=classification, encryption_profile=effective_profile, key_epoch=1, - settings=( - {"encryption_vault_id": str(effective_vault_id)} - if effective_vault_id - else {} - ), + settings={ + **( + {"encryption_vault_id": str(effective_vault_id)} + if effective_vault_id + else {} + ), + "portal_visible": effective_portal_visible, + }, ) postbox.bindings.append( PostboxBinding( diff --git a/tests/test_manifest.py b/tests/test_manifest.py index cf490bd..28212c7 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -8,6 +8,7 @@ from govoplan_core.core.postbox import ( CAPABILITY_POSTBOX_DIRECTORY, CAPABILITY_POSTBOX_EVIDENCE, CAPABILITY_POSTBOX_MESSAGES, + CAPABILITY_POSTBOX_PORTAL, CAPABILITY_POSTBOX_ROUTING, ) from govoplan_core.core.encryption import CAPABILITY_ENCRYPTION_CONTENT_CIPHER @@ -31,6 +32,7 @@ class PostboxManifestTests(unittest.TestCase): CAPABILITY_POSTBOX_DELIVERY, CAPABILITY_POSTBOX_EVIDENCE, CAPABILITY_POSTBOX_ROUTING, + CAPABILITY_POSTBOX_PORTAL, }, set(manifest.capability_factories), ) diff --git a/tests/test_migration.py b/tests/test_migration.py index 5998980..e768d56 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -34,6 +34,10 @@ class PostboxMigrationTests(unittest.TestCase): "govoplan_postbox.backend.migrations.versions." "e9f4a7b2c5d8_v014_template_scope_preview" ) + portal_migration = importlib.import_module( + "govoplan_postbox.backend.migrations.versions." + "f2a5c8e1b4d7_v015_portal_visibility" + ) engine = create_engine("sqlite:///:memory:") try: with engine.begin() as connection: @@ -44,12 +48,14 @@ class PostboxMigrationTests(unittest.TestCase): envelope_original = envelope_migration.op protection_original = protection_migration.op scope_original = scope_migration.op + portal_original = portal_migration.op migration.op = operations route_migration.op = operations occ_migration.op = operations envelope_migration.op = operations protection_migration.op = operations scope_migration.op = operations + portal_migration.op = operations try: migration.upgrade() route_migration.upgrade() @@ -57,6 +63,7 @@ class PostboxMigrationTests(unittest.TestCase): envelope_migration.upgrade() protection_migration.upgrade() scope_migration.upgrade() + portal_migration.upgrade() tables = set(inspect(connection).get_table_names()) self.assertIn("postboxes", tables) self.assertIn("postbox_messages", tables) @@ -93,6 +100,7 @@ class PostboxMigrationTests(unittest.TestCase): "encryption_vault_id", "scope_structure_id", "scope_relation_type_ids", + "portal_visible", }.issubset(template_revision_columns) ) self.assertIn("authoring_key", message_columns) @@ -121,6 +129,7 @@ class PostboxMigrationTests(unittest.TestCase): route_columns ) ) + portal_migration.downgrade() scope_migration.downgrade() protection_migration.downgrade() envelope_migration.downgrade() @@ -141,6 +150,7 @@ class PostboxMigrationTests(unittest.TestCase): envelope_migration.op = envelope_original protection_migration.op = protection_original scope_migration.op = scope_original + portal_migration.op = portal_original finally: engine.dispose() diff --git a/tests/test_router.py b/tests/test_router.py index fecce15..fe8558e 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -40,6 +40,7 @@ from govoplan_postbox.backend.db.models import ( PostboxTemplateRevision, ) from govoplan_postbox.backend.router import router +from govoplan_postbox.backend.portal_projection import PortalProjection from govoplan_postbox.backend.service import PostboxService @@ -220,6 +221,7 @@ class PostboxRouterTests(unittest.TestCase): account=SimpleNamespace(id="account-1"), user=SimpleNamespace(id="membership-1"), ) + self.principal = principal app = FastAPI() app.include_router(router, prefix="/api/v1") @@ -250,6 +252,35 @@ class PostboxRouterTests(unittest.TestCase): self.assertEqual(response.status_code, 403) self.assertIn("not active for this principal", response.text) + def test_portal_projection_requires_explicit_visibility_and_keeps_postbox_access(self) -> None: + projection = PortalProjection() + with Session(self.engine) as session, patch( + "govoplan_postbox.backend.portal_projection.get_service", + return_value=self.service, + ): + self.assertEqual( + (), + projection.list_portal_entries( + session, + self.principal, + tenant_id="tenant-1", + ), + ) + postbox = session.get(Postbox, self.postbox_id) + assert postbox is not None + postbox.settings = {**postbox.settings, "portal_visible": True} + session.flush() + + entries = projection.list_portal_entries( + session, + self.principal, + tenant_id="tenant-1", + ) + + self.assertEqual(1, len(entries)) + self.assertEqual(self.postbox_id, entries[0].postbox.id) + self.assertEqual(f"/postbox?postbox={self.postbox_id}", entries[0].route_path) + def test_template_impact_preview_is_available_without_writes(self) -> None: with Session(self.engine) as session: before = session.query(Postbox).count() diff --git a/webui/src/api/postbox.ts b/webui/src/api/postbox.ts index 3948e85..e9a323e 100644 --- a/webui/src/api/postbox.ts +++ b/webui/src/api/postbox.ts @@ -2,6 +2,8 @@ import { apiFetch, apiPath, apiPostJson, + apiUrl, + authHeaders, type ApiSettings } from "@govoplan/core-webui"; @@ -62,6 +64,15 @@ export type PostboxAttachment = { metadata: Record; }; +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; +}; + export type PostboxMessage = { id: string; tenant_id: string; @@ -200,6 +211,7 @@ export type PostboxTemplateRevision = { address_pattern: string; classification: string; allow_vacant_delivery: boolean; + portal_visible: boolean; encryption_profile: string; history_policy: Record; routing_policy: PostboxRoutingPolicy; @@ -235,6 +247,7 @@ export type PostboxTemplateRevisionPayload = Pick< | "address_pattern" | "classification" | "allow_vacant_delivery" + | "portal_visible" | "routing_policy" >; @@ -276,6 +289,7 @@ export type PostboxExactCreatePayload = { function_id: string; address_key?: string | null; classification: string; + portal_visible: boolean; }; export type PostboxMessageAuthoringPayload = { @@ -328,6 +342,41 @@ export function getPostboxMessage( return apiFetch(settings, `/api/v1/postbox/messages/${encodeURIComponent(messageId)}`); } +export async function resolvePostboxAttachments( + settings: ApiSettings, + messageId: string +): Promise { + 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 { + if (!attachment.available || !attachment.download_path) { + throw new Error("This attachment payload is not available."); + } + const response = await fetch(apiUrl(settings, attachment.download_path), { + headers: authHeaders(settings), + credentials: "include" + }); + if (!response.ok) { + throw new Error(`Attachment download failed (${response.status}).`); + } + const objectUrl = URL.createObjectURL(await response.blob()); + const link = document.createElement("a"); + link.href = objectUrl; + link.download = attachment.name || attachment.reference_id; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(objectUrl); +} + export function markPostboxMessage( settings: ApiSettings, messageId: string, diff --git a/webui/src/features/postbox/PostboxAdminPanel.tsx b/webui/src/features/postbox/PostboxAdminPanel.tsx index 83227be..e79688a 100644 --- a/webui/src/features/postbox/PostboxAdminPanel.tsx +++ b/webui/src/features/postbox/PostboxAdminPanel.tsx @@ -112,6 +112,7 @@ const templateDefaults = (): TemplateDraft => ({ address_pattern: "{template_slug}.{unit_slug}.{function_slug}", classification: "internal", allow_vacant_delivery: true, + portal_visible: false, routing_policy: routingDefaults() }); @@ -121,7 +122,8 @@ const exactDefaults = (): ExactDraft => ({ organization_unit_id: "", function_id: "", address_key: "", - classification: "internal" + classification: "internal", + portal_visible: false }); export default function PostboxAdminPanel({ @@ -270,6 +272,7 @@ export default function PostboxAdminPanel({ address_pattern: revision.address_pattern, classification: revision.classification, allow_vacant_delivery: revision.allow_vacant_delivery, + portal_visible: revision.portal_visible, routing_policy: revision.routing_policy ?? routingDefaults() }; setTemplatePreview(null); @@ -1126,6 +1129,14 @@ function TemplateDialog({ onChange={(checked) => onChange({ ...draft, allow_vacant_delivery: checked })} /> +
+ onChange({ ...draft, portal_visible: checked })} + /> +
@@ -1472,6 +1483,14 @@ function ExactPostboxDialog({ onChange({ ...draft, description: event.target.value })} /> +
+ onChange({ ...draft, portal_visible: checked })} + /> +
); @@ -1578,6 +1597,7 @@ function revisionPayload(draft: TemplateDraft): PostboxTemplateRevisionPayload { address_pattern: draft.address_pattern, classification: draft.classification, allow_vacant_delivery: draft.allow_vacant_delivery, + portal_visible: draft.portal_visible, routing_policy: draft.routing_policy }; } diff --git a/webui/src/features/postbox/PostboxPage.tsx b/webui/src/features/postbox/PostboxPage.tsx index d5bbf57..dfc5ddb 100644 --- a/webui/src/features/postbox/PostboxPage.tsx +++ b/webui/src/features/postbox/PostboxPage.tsx @@ -3,6 +3,7 @@ import { Archive, Building2, CheckCheck, + Download, Inbox, Layers3, MailOpen, @@ -45,15 +46,18 @@ import { createPostboxGrouping, createPostboxMessage, deletePostboxGrouping, + downloadPostboxAttachment, getPostboxMessage, listPostboxGroupings, listPostboxMessages, listPostboxes, markPostboxMessage, + resolvePostboxAttachments, replyToPostboxMessage, updatePostboxGrouping, type PostboxDirectoryItem, type PostboxGrouping, + type PostboxAttachmentResolution, type PostboxMessage } from "../../api/postbox"; import { @@ -106,6 +110,9 @@ export default function PostboxPage({ const requestedMessageId = useRef( new URLSearchParams(window.location.search).get("message") ?? "" ); + const requestedPostboxId = useRef( + new URLSearchParams(window.location.search).get("postbox") ?? "" + ); const requestedMessageLoaded = useRef(false); const [postboxes, setPostboxes] = useState([]); const [groupings, setGroupings] = useState([]); @@ -114,6 +121,7 @@ export default function PostboxPage({ const [messages, setMessages] = useState([]); const [selectedMessageId, setSelectedMessageId] = useState(""); const [selectedMessage, setSelectedMessage] = useState(null); + const [attachmentResolutions, setAttachmentResolutions] = useState([]); const [unavailableSelection, setUnavailableSelection] = useState(""); const [total, setTotal] = useState(0); const [messageState, setMessageState] = useState("all"); @@ -204,7 +212,9 @@ export default function PostboxPage({ setSelectedPostboxId((current) => current && nextPostboxes.some((postbox) => postbox.id === current) ? current - : "" + : nextPostboxes.some((postbox) => postbox.id === requestedPostboxId.current) + ? requestedPostboxId.current + : "" ); } catch (loadError) { setError(errorMessage(loadError)); @@ -316,14 +326,19 @@ export default function PostboxPage({ if (message.availability === "available" && !message.read_at) { message = await markPostboxMessage(settings, selectedMessageId, "read"); } + const resolutions = message.attachments.length + ? await resolvePostboxAttachments(settings, message.id) + : []; if (cancelled) return; setSelectedMessage(message); + setAttachmentResolutions(resolutions); setMessages((items) => items.map((item) => (item.id === message.id ? message : item)) ); } catch (loadError) { if (!cancelled && isApiError(loadError, 403, 404)) { setSelectedMessage(null); + setAttachmentResolutions([]); setUnavailableSelection( "This message is no longer available or is outside your current Postbox assignments." ); @@ -866,6 +881,12 @@ export default function PostboxPage({ item.id === selectedMessage.postbox_id)} + attachmentResolutions={attachmentResolutions} + onDownload={(attachment) => { + void downloadPostboxAttachment(settings, attachment).catch((downloadError) => { + setError(errorMessage(downloadError)); + }); + }} /> ) : unavailableSelection ? (
@@ -1099,10 +1120,14 @@ export default function PostboxPage({ function MessageDetail({ message, - postbox + postbox, + attachmentResolutions, + onDownload }: { message: PostboxMessage; postbox?: PostboxDirectoryItem; + attachmentResolutions: PostboxAttachmentResolution[]; + onDownload: (attachment: PostboxAttachmentResolution) => void; }) { const { language } = usePlatformLanguage(); return ( @@ -1166,15 +1191,30 @@ function MessageDetail({

Evidence and attachments

{!message.attachments.length ?

No attachment references.

: 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 (
- {attachment.name || attachment.reference_id} + {resolution?.name || attachment.name || attachment.reference_id} {attachment.reference_type}{attachment.media_type ? ` · ${attachment.media_type}` : ""} + {resolution && !resolution.available ? ( + {attachmentResolutionExplanation(resolution.reason_code)} + ) : null} + {resolution?.available ? ( + } + label={`Download ${resolution.name || attachment.name || "attachment"}`} + onClick={() => onDownload(resolution)} + /> + ) : null}
- ))} + )})}
{postbox?.access ? (
@@ -1189,6 +1229,18 @@ function MessageDetail({ ); } +function attachmentResolutionExplanation(reasonCode: string): string { + const explanations: Record = { + 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( postboxes: PostboxDirectoryItem[], postboxId: string