feat: integrate files and portal surfaces
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+33
@@ -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"]
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user