From dc6f81dd3396d0fa498d34015e24171ca4dc08b5 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Fri, 31 Jul 2026 18:40:39 +0200 Subject: [PATCH] Add Postbox message authoring and concurrency --- src/govoplan_postbox/backend/db/models.py | 51 +++ .../f5c8d0e3b7a2_v012_authoring_and_occ.py | 65 +++ src/govoplan_postbox/backend/router.py | 265 ++++++++++- src/govoplan_postbox/backend/schemas.py | 35 ++ src/govoplan_postbox/backend/service.py | 416 +++++++++++++++++- tests/test_migration.py | 24 + tests/test_router.py | 129 ++++++ tests/test_service.py | 6 + webui/src/api/postbox.ts | 98 ++++- .../features/postbox/PostboxAdminPanel.tsx | 12 +- webui/src/features/postbox/PostboxPage.tsx | 330 ++++++++++++-- webui/src/styles/postbox.css | 29 ++ 12 files changed, 1392 insertions(+), 68 deletions(-) create mode 100644 src/govoplan_postbox/backend/migrations/versions/f5c8d0e3b7a2_v012_authoring_and_occ.py diff --git a/src/govoplan_postbox/backend/db/models.py b/src/govoplan_postbox/backend/db/models.py index 4a96c1b..9f02229 100644 --- a/src/govoplan_postbox/backend/db/models.py +++ b/src/govoplan_postbox/backend/db/models.py @@ -17,6 +17,7 @@ from sqlalchemy import ( ) from sqlalchemy.orm import Mapped, mapped_column, relationship +from govoplan_core.core.concurrency import strong_resource_etag from govoplan_core.db.base import Base, TimestampMixin @@ -47,6 +48,11 @@ class PostboxTemplate(Base, TimestampMixin): index=True, ) current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + resource_revision: Mapped[int] = mapped_column( + Integer, + default=1, + nullable=False, + ) published_revision_id: Mapped[str | None] = mapped_column( String(36), nullable=True, @@ -65,6 +71,14 @@ class PostboxTemplate(Base, TimestampMixin): order_by="PostboxTemplateRevision.revision", ) + @property + def strong_etag(self) -> str: + return strong_resource_etag( + "postbox_template", + self.id, + self.resource_revision, + ) + class PostboxTemplateRevision(Base, TimestampMixin): __tablename__ = "postbox_template_revisions" @@ -264,6 +278,11 @@ class Postbox(Base, TimestampMixin): nullable=False, ) key_epoch: Mapped[int] = mapped_column(Integer, default=1, nullable=False) + resource_revision: Mapped[int] = mapped_column( + Integer, + default=1, + nullable=False, + ) settings: Mapped[dict[str, Any]] = mapped_column( JSON, default=dict, @@ -286,6 +305,14 @@ class Postbox(Base, TimestampMixin): cascade="all, delete-orphan", ) + @property + def strong_etag(self) -> str: + return strong_resource_etag( + "postbox", + self.id, + self.resource_revision, + ) + class PostboxBinding(Base, TimestampMixin): __tablename__ = "postbox_bindings" @@ -376,6 +403,12 @@ class PostboxMessage(Base, TimestampMixin): "producer_resource_type", "producer_resource_id", ), + UniqueConstraint( + "tenant_id", + "postbox_id", + "authoring_key", + name="uq_postbox_messages_authoring_key", + ), ) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) @@ -415,6 +448,11 @@ class PostboxMessage(Base, TimestampMixin): nullable=True, index=True, ) + authoring_key: Mapped[str | None] = mapped_column( + String(255), + nullable=True, + index=True, + ) in_reply_to_message_id: Mapped[str | None] = mapped_column( ForeignKey("postbox_messages.id", ondelete="SET NULL"), nullable=True, @@ -774,6 +812,11 @@ class PostboxGrouping(Base, TimestampMixin): account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) name: Mapped[str] = mapped_column(String(250), nullable=False) is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + resource_revision: Mapped[int] = mapped_column( + Integer, + default=1, + nullable=False, + ) settings: Mapped[dict[str, Any]] = mapped_column( JSON, default=dict, @@ -786,6 +829,14 @@ class PostboxGrouping(Base, TimestampMixin): order_by="PostboxGroupingSource.position", ) + @property + def strong_etag(self) -> str: + return strong_resource_etag( + "postbox_grouping", + self.id, + self.resource_revision, + ) + class PostboxGroupingSource(Base, TimestampMixin): __tablename__ = "postbox_grouping_sources" diff --git a/src/govoplan_postbox/backend/migrations/versions/f5c8d0e3b7a2_v012_authoring_and_occ.py b/src/govoplan_postbox/backend/migrations/versions/f5c8d0e3b7a2_v012_authoring_and_occ.py new file mode 100644 index 0000000..0c122ad --- /dev/null +++ b/src/govoplan_postbox/backend/migrations/versions/f5c8d0e3b7a2_v012_authoring_and_occ.py @@ -0,0 +1,65 @@ +"""v0.1.2 message authoring and optimistic concurrency + +Revision ID: f5c8d0e3b7a2 +Revises: e4b7c9d2a6f1 +Create Date: 2026-07-31 16:30:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "f5c8d0e3b7a2" +down_revision = "e4b7c9d2a6f1" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + for table_name in ( + "postbox_templates", + "postboxes", + "postbox_groupings", + ): + with op.batch_alter_table(table_name) as batch_op: + batch_op.add_column( + sa.Column( + "resource_revision", + sa.Integer(), + nullable=False, + server_default=sa.text("1"), + ) + ) + + with op.batch_alter_table("postbox_messages") as batch_op: + batch_op.add_column( + sa.Column("authoring_key", sa.String(length=255), nullable=True) + ) + batch_op.create_index( + "ix_postbox_messages_authoring_key", + ("authoring_key",), + unique=False, + ) + batch_op.create_unique_constraint( + "uq_postbox_messages_authoring_key", + ("tenant_id", "postbox_id", "authoring_key"), + ) + + +def downgrade() -> None: + with op.batch_alter_table("postbox_messages") as batch_op: + batch_op.drop_constraint( + "uq_postbox_messages_authoring_key", + type_="unique", + ) + batch_op.drop_index("ix_postbox_messages_authoring_key") + batch_op.drop_column("authoring_key") + + for table_name in ( + "postbox_groupings", + "postboxes", + "postbox_templates", + ): + with op.batch_alter_table(table_name) as batch_op: + batch_op.drop_column("resource_revision") diff --git a/src/govoplan_postbox/backend/router.py b/src/govoplan_postbox/backend/router.py index e16b977..8b8ed3b 100644 --- a/src/govoplan_postbox/backend/router.py +++ b/src/govoplan_postbox/backend/router.py @@ -3,14 +3,21 @@ from __future__ import annotations from dataclasses import asdict from typing import Literal -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response, status from sqlalchemy.orm import Session from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope +from govoplan_core.core.concurrency import ( + ConcurrencyError, + MissingPreconditionError, + RevisionConflictError, + assert_revision_precondition, +) from govoplan_core.core.postbox import ( PostboxActorRef, PostboxAttachmentRef, PostboxDeliveryRequest, + PostboxMessageAuthoringRequest, PostboxParticipantRef, PostboxTargetRef, ) @@ -37,18 +44,22 @@ from govoplan_postbox.backend.schemas import ( PostboxGroupingItem, PostboxGroupingListResponse, PostboxGroupingPayload, + PostboxGroupingUpdateRequest, PostboxMaterializeRequest, PostboxMessageItem, + PostboxMessageCreateRequest, + PostboxMessageAuthoringPayload, PostboxMessageListResponse, PostboxMessageStateRequest, PostboxOrganizationTargetsResponse, + PostboxMutationRequest, PostboxRouteDryRunRequest, PostboxRouteDryRunResponse, PostboxTemplateCreateRequest, PostboxTemplateItem, PostboxTemplateListResponse, PostboxTemplatePublishRequest, - PostboxTemplateRevisionPayload, + PostboxTemplateReviseRequest, ) from govoplan_postbox.backend.service import PostboxError @@ -136,6 +147,74 @@ def _http_error(exc: PostboxError) -> HTTPException: ) +def _require_mutation_precondition( + if_match: str | None, + *, + resource_type: str, + resource_id: str, + base_revision: int, +) -> None: + try: + assert_revision_precondition( + if_match, + resource_type=resource_type, + resource_id=resource_id, + submitted_base_revision=base_revision, + ) + except MissingPreconditionError as exc: + raise HTTPException( + status_code=status.HTTP_428_PRECONDITION_REQUIRED, + detail=exc.as_dict(), + ) from exc + except ConcurrencyError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"code": "invalid_precondition", "message": str(exc)}, + ) from exc + + +def _concurrency_http_error(exc: ConcurrencyError) -> HTTPException: + if isinstance(exc, MissingPreconditionError): + return HTTPException( + status_code=status.HTTP_428_PRECONDITION_REQUIRED, + detail=exc.as_dict(), + ) + if isinstance(exc, RevisionConflictError): + return HTTPException( + status_code=status.HTTP_412_PRECONDITION_FAILED, + detail=exc.as_dict(), + ) + return HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail={"code": "concurrency_conflict", "message": str(exc)}, + ) + + +def _set_etag(response: Response, etag: str | None) -> None: + if etag: + response.headers["ETag"] = etag + + +def _authoring_request( + payload: PostboxMessageAuthoringPayload, +) -> PostboxMessageAuthoringRequest: + return PostboxMessageAuthoringRequest( + idempotency_key=payload.idempotency_key, + subject=payload.subject, + body_text=payload.body_text, + classification=payload.classification, + participants=tuple( + PostboxParticipantRef(**participant.model_dump()) + for participant in payload.participants + ), + attachments=tuple( + PostboxAttachmentRef(**attachment.model_dump()) + for attachment in payload.attachments + ), + metadata=payload.metadata, + ) + + def _directory_item(value) -> PostboxDirectoryItem: return PostboxDirectoryItem.model_validate(asdict(value)) @@ -153,6 +232,8 @@ def _template_item(template) -> PostboxTemplateItem: description=template.description, status=template.status, current_revision=template.current_revision, + resource_revision=template.resource_revision, + etag=template.strong_etag, published_revision_id=template.published_revision_id, revisions=[ { @@ -184,6 +265,8 @@ def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem: id=grouping.id, name=grouping.name, is_default=grouping.is_default, + resource_revision=grouping.resource_revision, + etag=grouping.strong_etag, postbox_ids=[ source.postbox_id for source in grouping.sources @@ -306,6 +389,67 @@ def api_list_postbox_messages( ) +@router.post( + "/messages", + response_model=PostboxMessageItem, + status_code=status.HTTP_201_CREATED, +) +def api_create_postbox_message( + payload: PostboxMessageCreateRequest, + assignment_context_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxMessageItem: + _require(principal, SEND_SCOPE) + try: + message = get_service().create_message( + session, + tenant_id=principal.tenant_id, + postbox_id=payload.postbox_id, + actor=_actor( + principal, + assignment_context_id=assignment_context_id, + ), + request=_authoring_request(payload), + ) + except PostboxError as exc: + session.rollback() + raise _http_error(exc) from exc + session.commit() + return _message_item(message) + + +@router.post( + "/messages/{message_id}/replies", + response_model=PostboxMessageItem, + status_code=status.HTTP_201_CREATED, +) +def api_reply_to_postbox_message( + message_id: str, + payload: PostboxMessageAuthoringPayload, + assignment_context_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(get_api_principal), +) -> PostboxMessageItem: + _require(principal, REPLY_SCOPE) + try: + message = get_service().reply_to_message( + session, + tenant_id=principal.tenant_id, + message_id=message_id, + actor=_actor( + principal, + assignment_context_id=assignment_context_id, + ), + request=_authoring_request(payload), + ) + except PostboxError as exc: + session.rollback() + raise _http_error(exc) from exc + session.commit() + return _message_item(message) + + @router.get("/messages/{message_id}", response_model=PostboxMessageItem) def api_get_postbox_message( message_id: str, @@ -468,6 +612,7 @@ def api_list_postbox_groupings( ) def api_create_postbox_grouping( payload: PostboxGroupingPayload, + response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxGroupingItem: @@ -484,17 +629,27 @@ def api_create_postbox_grouping( except PostboxError as exc: raise _http_error(exc) from exc session.commit() - return _grouping_item(grouping, visible_ids=set(payload.postbox_ids)) + item = _grouping_item(grouping, visible_ids=set(payload.postbox_ids)) + _set_etag(response, item.etag) + return item @router.put("/groupings/{grouping_id}", response_model=PostboxGroupingItem) def api_update_postbox_grouping( grouping_id: str, - payload: PostboxGroupingPayload, + payload: PostboxGroupingUpdateRequest, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxGroupingItem: _require(principal, READ_SCOPE) + _require_mutation_precondition( + if_match, + resource_type="postbox_grouping", + resource_id=grouping_id, + base_revision=payload.base_revision, + ) actor = _actor(principal) try: grouping = get_service().save_grouping( @@ -502,30 +657,48 @@ def api_update_postbox_grouping( tenant_id=principal.tenant_id, actor=actor, grouping_id=grouping_id, - **payload.model_dump(), + expected_revision=payload.base_revision, + **payload.model_dump(exclude={"base_revision"}), ) except PostboxError as exc: raise _http_error(exc) from exc + except ConcurrencyError as exc: + session.rollback() + raise _concurrency_http_error(exc) from exc session.commit() - return _grouping_item(grouping, visible_ids=set(payload.postbox_ids)) + item = _grouping_item(grouping, visible_ids=set(payload.postbox_ids)) + _set_etag(response, item.etag) + return item @router.delete("/groupings/{grouping_id}", status_code=status.HTTP_204_NO_CONTENT) def api_delete_postbox_grouping( grouping_id: str, + payload: PostboxMutationRequest, + if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> None: _require(principal, READ_SCOPE) + _require_mutation_precondition( + if_match, + resource_type="postbox_grouping", + resource_id=grouping_id, + base_revision=payload.base_revision, + ) try: get_service().delete_grouping( session, tenant_id=principal.tenant_id, actor=_actor(principal), grouping_id=grouping_id, + expected_revision=payload.base_revision, ) except PostboxError as exc: raise _http_error(exc) from exc + except ConcurrencyError as exc: + session.rollback() + raise _concurrency_http_error(exc) from exc session.commit() @@ -573,6 +746,7 @@ def api_admin_postboxes( ) def api_create_exact_postbox( payload: PostboxExactCreateRequest, + response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxDirectoryItem: @@ -587,13 +761,15 @@ def api_create_exact_postbox( except PostboxError as exc: raise _http_error(exc) from exc session.commit() - return _directory_item( + item = _directory_item( get_service().resolve_postbox( session, tenant_id=principal.tenant_id, target=PostboxTargetRef(postbox_id=postbox.id), ) ) + _set_etag(response, item.etag) + return item @router.delete( @@ -602,27 +778,42 @@ def api_create_exact_postbox( ) def api_archive_postbox( postbox_id: str, + payload: PostboxMutationRequest, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxDirectoryItem: _require(principal, BINDING_ADMIN_SCOPE) + _require_mutation_precondition( + if_match, + resource_type="postbox", + resource_id=postbox_id, + base_revision=payload.base_revision, + ) try: postbox = get_service().archive_postbox( session, tenant_id=principal.tenant_id, postbox_id=postbox_id, actor_id=principal.account_id, + expected_revision=payload.base_revision, ) except PostboxError as exc: raise _http_error(exc) from exc + except ConcurrencyError as exc: + session.rollback() + raise _concurrency_http_error(exc) from exc session.commit() - return _directory_item( + item = _directory_item( get_service().resolve_postbox( session, tenant_id=principal.tenant_id, target=PostboxTargetRef(postbox_id=postbox.id), ) ) + _set_etag(response, item.etag) + return item @router.get("/admin/templates", response_model=PostboxTemplateListResponse) @@ -649,6 +840,7 @@ def api_postbox_templates( ) def api_create_postbox_template( payload: PostboxTemplateCreateRequest, + response: Response, session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxTemplateItem: @@ -663,7 +855,9 @@ def api_create_postbox_template( except PostboxError as exc: raise _http_error(exc) from exc session.commit() - return _template_item(template) + item = _template_item(template) + _set_etag(response, item.etag) + return item @router.post( @@ -672,23 +866,37 @@ def api_create_postbox_template( ) def api_revise_postbox_template( template_id: str, - payload: PostboxTemplateRevisionPayload, + payload: PostboxTemplateReviseRequest, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxTemplateItem: _require(principal, TEMPLATE_ADMIN_SCOPE) + _require_mutation_precondition( + if_match, + resource_type="postbox_template", + resource_id=template_id, + base_revision=payload.base_revision, + ) try: template = get_service().revise_template( session, tenant_id=principal.tenant_id, template_id=template_id, actor_id=principal.account_id, - **payload.model_dump(), + expected_revision=payload.base_revision, + **payload.model_dump(exclude={"base_revision"}), ) except PostboxError as exc: raise _http_error(exc) from exc + except ConcurrencyError as exc: + session.rollback() + raise _concurrency_http_error(exc) from exc session.commit() - return _template_item(template) + item = _template_item(template) + _set_etag(response, item.etag) + return item @router.post( @@ -698,10 +906,18 @@ def api_revise_postbox_template( def api_publish_postbox_template( template_id: str, payload: PostboxTemplatePublishRequest, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxTemplateItem: _require(principal, TEMPLATE_ADMIN_SCOPE) + _require_mutation_precondition( + if_match, + resource_type="postbox_template", + resource_id=template_id, + base_revision=payload.base_revision, + ) try: template = get_service().publish_template( session, @@ -709,11 +925,17 @@ def api_publish_postbox_template( template_id=template_id, revision_number=payload.revision, actor_id=principal.account_id, + expected_revision=payload.base_revision, ) except PostboxError as exc: raise _http_error(exc) from exc + except ConcurrencyError as exc: + session.rollback() + raise _concurrency_http_error(exc) from exc session.commit() - return _template_item(template) + item = _template_item(template) + _set_etag(response, item.etag) + return item @router.post( @@ -722,21 +944,36 @@ def api_publish_postbox_template( ) def api_retire_postbox_template( template_id: str, + payload: PostboxMutationRequest, + response: Response, + if_match: str | None = Header(default=None, alias="If-Match"), session: Session = Depends(get_session), principal: ApiPrincipal = Depends(get_api_principal), ) -> PostboxTemplateItem: _require(principal, TEMPLATE_ADMIN_SCOPE) + _require_mutation_precondition( + if_match, + resource_type="postbox_template", + resource_id=template_id, + base_revision=payload.base_revision, + ) try: template = get_service().retire_template( session, tenant_id=principal.tenant_id, template_id=template_id, actor_id=principal.account_id, + expected_revision=payload.base_revision, ) except PostboxError as exc: raise _http_error(exc) from exc + except ConcurrencyError as exc: + session.rollback() + raise _concurrency_http_error(exc) from exc session.commit() - return _template_item(template) + item = _template_item(template) + _set_etag(response, item.etag) + return item @router.post( diff --git a/src/govoplan_postbox/backend/schemas.py b/src/govoplan_postbox/backend/schemas.py index 6e9c740..3833091 100644 --- a/src/govoplan_postbox/backend/schemas.py +++ b/src/govoplan_postbox/backend/schemas.py @@ -49,6 +49,8 @@ class PostboxDirectoryItem(BaseModel): holder_count: int = 0 vacant: bool = True access: PostboxAccessDecisionResponse | None = None + resource_revision: int = Field(default=1, ge=1) + etag: str | None = None class PostboxDirectoryResponse(BaseModel): @@ -91,6 +93,8 @@ class PostboxMessageItem(BaseModel): producer_module: str | None = None producer_resource_type: str | None = None producer_resource_id: str | None = None + in_reply_to_message_id: str | None = None + replaces_message_id: str | None = None encryption_profile: str key_epoch: int ciphertext_ref: str | None = None @@ -111,6 +115,20 @@ class PostboxMessageStateRequest(BaseModel): state: Literal["read", "acknowledged"] +class PostboxMessageAuthoringPayload(BaseModel): + idempotency_key: str = Field(min_length=1, max_length=255) + subject: str = Field(min_length=1, max_length=1000) + body_text: str | None = None + classification: PostboxClassification = "internal" + participants: list[PostboxParticipantPayload] = Field(default_factory=list) + attachments: list[PostboxAttachmentPayload] = Field(default_factory=list) + metadata: dict[str, Any] = Field(default_factory=dict) + + +class PostboxMessageCreateRequest(PostboxMessageAuthoringPayload): + postbox_id: str = Field(min_length=1, max_length=36) + + class PostboxTargetPayload(BaseModel): postbox_id: str | None = Field(default=None, max_length=36) address_key: str | None = Field(default=None, max_length=500) @@ -336,6 +354,10 @@ class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload): description: str | None = None +class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload): + base_revision: int = Field(ge=1) + + class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload): id: str revision: int @@ -354,6 +376,8 @@ class PostboxTemplateItem(BaseModel): description: str | None = None status: str current_revision: int + resource_revision: int = Field(ge=1) + etag: str published_revision_id: str | None = None revisions: list[PostboxTemplateRevisionItem] = Field(default_factory=list) created_at: datetime @@ -366,6 +390,11 @@ class PostboxTemplateListResponse(BaseModel): class PostboxTemplatePublishRequest(BaseModel): revision: int | None = Field(default=None, ge=1) + base_revision: int = Field(ge=1) + + +class PostboxMutationRequest(BaseModel): + base_revision: int = Field(ge=1) class PostboxMaterializeRequest(BaseModel): @@ -425,8 +454,14 @@ class PostboxGroupingPayload(BaseModel): postbox_ids: list[str] = Field(default_factory=list, max_length=250) +class PostboxGroupingUpdateRequest(PostboxGroupingPayload): + base_revision: int = Field(ge=1) + + class PostboxGroupingItem(PostboxGroupingPayload): id: str + resource_revision: int = Field(ge=1) + etag: str created_at: datetime updated_at: datetime diff --git a/src/govoplan_postbox/backend/service.py b/src/govoplan_postbox/backend/service.py index e1c464c..acd7e68 100644 --- a/src/govoplan_postbox/backend/service.py +++ b/src/govoplan_postbox/backend/service.py @@ -1,12 +1,13 @@ from __future__ import annotations import hashlib +import json import logging import re from collections import Counter from collections.abc import Mapping, Sequence from datetime import datetime, timedelta, timezone -from typing import Literal +from typing import Any, Literal from sqlalchemy import and_, func, or_ from sqlalchemy.exc import IntegrityError @@ -19,6 +20,7 @@ from govoplan_core.core.events import ( PlatformEvent, emit_platform_event, ) +from govoplan_core.core.concurrency import MissingPreconditionError, claim_revision from govoplan_core.core.identity import ( CAPABILITY_IDENTITY_DIRECTORY, IdentityDirectory, @@ -57,6 +59,7 @@ from govoplan_core.core.postbox import ( PostboxDeliveryTemplateRef, PostboxDirectoryEntryRef, PostboxMessageAvailability, + PostboxMessageAuthoringRequest, PostboxMessageRef, PostboxMessageListState, PostboxOrganizationFunctionTargetRef, @@ -124,6 +127,49 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]: return dict(value or {}) +def _authoring_digest( + request: PostboxMessageAuthoringRequest, + *, + in_reply_to: PostboxMessage | None, +) -> str: + payload = { + "subject": request.subject.strip() or "(No subject)", + "body_text": request.body_text, + "classification": request.classification.strip().casefold(), + "in_reply_to_message_id": in_reply_to.id if in_reply_to else None, + "participants": [ + { + "kind": item.kind, + "reference_type": item.reference_type, + "reference_id": item.reference_id, + "label": item.label, + "address": item.address, + } + for item in request.participants + ], + "attachments": [ + { + "reference_type": item.reference_type, + "reference_id": item.reference_id, + "name": item.name, + "media_type": item.media_type, + "size_bytes": item.size_bytes, + "digest": item.digest, + "metadata": dict(item.metadata), + } + for item in request.attachments + ], + "metadata": dict(request.metadata), + } + encoded = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + def _as_utc(value: datetime) -> datetime: if value.tzinfo is None: return value.replace(tzinfo=timezone.utc) @@ -772,6 +818,268 @@ class PostboxService: db.refresh(message) return self._message_ref(message, account_id=actor.account_id) + def create_message( + self, + session: object, + *, + tenant_id: str, + postbox_id: str, + actor: PostboxActorRef, + request: PostboxMessageAuthoringRequest, + ) -> PostboxMessageRef: + db = _session(session) + postbox = self._get_postbox( + db, + tenant_id=tenant_id, + postbox_id=postbox_id, + ) + return self._author_message( + db, + postbox=postbox, + actor=actor, + request=request, + action="send", + in_reply_to=None, + ) + + def reply_to_message( + self, + session: object, + *, + tenant_id: str, + message_id: str, + actor: PostboxActorRef, + request: PostboxMessageAuthoringRequest, + ) -> PostboxMessageRef: + db = _session(session) + parent = self._get_message( + db, + tenant_id=tenant_id, + message_id=message_id, + ) + availability = _message_availability(parent) + if availability != "available": + raise PostboxError( + f"message_{availability}", + f"This Postbox message is {availability} and cannot be replied to.", + ) + postbox = self._get_postbox( + db, + tenant_id=tenant_id, + postbox_id=parent.postbox_id, + ) + classification = self._validate_classification(request.classification) + if not postbox_classification_allows( + classification, + parent.classification, + ): + raise PostboxError( + "reply_classification_too_low", + "A reply cannot be classified below its parent message.", + ) + return self._author_message( + db, + postbox=postbox, + actor=actor, + request=request, + action="reply", + in_reply_to=parent, + ) + + def _author_message( + self, + session: Session, + *, + postbox: Postbox, + actor: PostboxActorRef, + request: PostboxMessageAuthoringRequest, + action: Literal["send", "reply"], + in_reply_to: PostboxMessage | None, + ) -> PostboxMessageRef: + classification = self._validate_classification(request.classification) + if not postbox_classification_allows( + postbox.classification, + classification, + ): + raise PostboxError( + "classification_not_allowed", + "The message classification exceeds the Postbox classification.", + ) + decision = self._access_decision( + postbox, + actor=actor, + action=action, + assignments=self._assignments_for_actor( + actor, + tenant_id=postbox.tenant_id, + ), + holder_cache={}, + classification=classification, + ) + if not decision.allowed: + self._record_access_event( + session, + tenant_id=postbox.tenant_id, + postbox_id=postbox.id, + message_id=in_reply_to.id if in_reply_to else None, + actor=actor, + action=f"message.{action}", + outcome="denied", + reason_code=decision.reason_code, + assignment_id=decision.selected_assignment_id, + ) + raise PostboxError("access_denied", decision.explanation) + + authoring_key = request.idempotency_key.strip() + if not authoring_key: + raise PostboxError( + "idempotency_key_required", + "Message authoring requires an idempotency key.", + ) + digest = _authoring_digest(request, in_reply_to=in_reply_to) + existing = ( + session.query(PostboxMessage) + .options( + selectinload(PostboxMessage.participants), + selectinload(PostboxMessage.attachments), + selectinload(PostboxMessage.receipts), + ) + .filter( + PostboxMessage.tenant_id == postbox.tenant_id, + PostboxMessage.postbox_id == postbox.id, + PostboxMessage.authoring_key == authoring_key, + ) + .one_or_none() + ) + if existing is not None: + authoring = _mapping(existing.metadata_).get("authoring") + existing_digest = ( + str(authoring.get("digest")) + if isinstance(authoring, Mapping) and authoring.get("digest") + else None + ) + if existing_digest != digest: + raise PostboxError( + "idempotency_conflict", + "The message idempotency key was already used for different content.", + ) + return self._message_ref(existing, account_id=actor.account_id) + + identity = ( + self._identities.get_identity(actor.identity_id) + if actor.identity_id + else self._identities.identity_for_account(actor.account_id) + ) + sender_label = ( + identity.display_name + if identity is not None and identity.display_name + else actor.account_id + ) + metadata = _mapping(request.metadata) + metadata["authoring"] = { + "digest": digest, + "account_id": actor.account_id, + "identity_id": actor.identity_id, + "assignment_id": decision.selected_assignment_id, + "action": action, + } + message = PostboxMessage( + tenant_id=postbox.tenant_id, + postbox_id=postbox.id, + subject=request.subject.strip() or "(No subject)", + body_text=request.body_text, + status="sent", + classification=classification, + sender_label=sender_label, + producer_module="postbox", + producer_resource_type="account_authored_message", + producer_resource_id=actor.account_id, + authoring_key=authoring_key, + in_reply_to_message_id=(in_reply_to.id if in_reply_to else None), + encryption_profile=postbox.encryption_profile, + key_epoch=postbox.key_epoch, + delivered_at=utc_now(), + metadata_=metadata, + ) + message.participants.append( + PostboxParticipant( + tenant_id=postbox.tenant_id, + kind="author", + reference_type="account", + reference_id=actor.account_id, + label=sender_label, + position=0, + metadata_={"assignment_id": decision.selected_assignment_id}, + ) + ) + for position, participant in enumerate(request.participants, start=1): + message.participants.append( + PostboxParticipant( + tenant_id=postbox.tenant_id, + kind=participant.kind, + reference_type=participant.reference_type, + reference_id=participant.reference_id, + label=participant.label, + address=participant.address, + position=position, + metadata_={}, + ) + ) + for position, attachment in enumerate(request.attachments): + message.attachments.append( + PostboxAttachmentReference( + tenant_id=postbox.tenant_id, + reference_type=attachment.reference_type, + reference_id=attachment.reference_id, + name=attachment.name, + media_type=attachment.media_type, + size_bytes=attachment.size_bytes, + digest=attachment.digest, + position=position, + metadata_=_mapping(attachment.metadata), + ) + ) + session.add(message) + try: + session.flush() + except IntegrityError as exc: + raise PostboxError( + "idempotency_conflict", + "The message idempotency key was accepted concurrently.", + ) from exc + self._record_access_event( + session, + tenant_id=postbox.tenant_id, + postbox_id=postbox.id, + message_id=message.id, + actor=actor, + action=f"message.{action}", + outcome="allowed", + reason_code=decision.reason_code, + assignment_id=decision.selected_assignment_id, + details={"in_reply_to_message_id": message.in_reply_to_message_id}, + ) + _publish_postbox_event( + session, + ( + "postbox.message.replied.v1" + if in_reply_to is not None + else "postbox.message.authored.v1" + ), + tenant_id=postbox.tenant_id, + resource_type="postbox_message", + resource_id=message.id, + postbox_id=postbox.id, + actor_type="account", + actor_id=actor.account_id, + payload={ + "in_reply_to_message_id": message.in_reply_to_message_id, + "classification": classification, + "assignment_id": decision.selected_assignment_id, + }, + ) + return self._message_ref(message, account_id=actor.account_id) + # Capability: delivery def deliver( self, @@ -2258,12 +2566,21 @@ class PostboxService: tenant_id: str, postbox_id: str, actor_id: str | None, + expected_revision: int, ) -> Postbox: postbox = self._get_postbox( session, tenant_id=tenant_id, postbox_id=postbox_id, ) + self._claim_resource_revision( + session, + model=Postbox, + resource=postbox, + resource_type="postbox", + tenant_id=tenant_id, + expected_revision=expected_revision, + ) postbox.status = "archived" postbox.archived_at = utc_now() postbox.address_record.status = "archived" @@ -2405,6 +2722,7 @@ class PostboxService: classification: str, allow_vacant_delivery: bool, actor_id: str | None, + expected_revision: int, routing_policy: Mapping[str, object] | None = None, ) -> PostboxTemplate: classification = self._validate_classification(classification) @@ -2418,6 +2736,14 @@ class PostboxService: "template_retired", "A retired Postbox template cannot be revised.", ) + self._claim_resource_revision( + session, + model=PostboxTemplate, + resource=template, + resource_type="postbox_template", + tenant_id=tenant_id, + expected_revision=expected_revision, + ) self._validate_scope( tenant_id=tenant_id, scope_kind=scope_kind, @@ -2470,12 +2796,21 @@ class PostboxService: template_id: str, revision_number: int | None, actor_id: str | None, + expected_revision: int, ) -> PostboxTemplate: template = self._get_template( session, tenant_id=tenant_id, template_id=template_id, ) + self._claim_resource_revision( + session, + model=PostboxTemplate, + resource=template, + resource_type="postbox_template", + tenant_id=tenant_id, + expected_revision=expected_revision, + ) revision = next( ( item @@ -2519,12 +2854,21 @@ class PostboxService: tenant_id: str, template_id: str, actor_id: str | None, + expected_revision: int, ) -> PostboxTemplate: template = self._get_template( session, tenant_id=tenant_id, template_id=template_id, ) + self._claim_resource_revision( + session, + model=PostboxTemplate, + resource=template, + resource_type="postbox_template", + tenant_id=tenant_id, + expected_revision=expected_revision, + ) template.status = "retired" template.retired_at = utc_now() template.updated_by = actor_id @@ -2773,6 +3117,7 @@ class PostboxService: name: str, is_default: bool, postbox_ids: Sequence[str], + expected_revision: int | None = None, ) -> PostboxGrouping: grouping = ( session.query(PostboxGrouping) @@ -2788,6 +3133,16 @@ class PostboxService: ) if grouping_id and grouping is None: raise PostboxError("grouping_not_found", "Postbox grouping not found.") + if grouping is not None: + self._claim_resource_revision( + session, + model=PostboxGrouping, + resource=grouping, + resource_type="postbox_grouping", + tenant_id=tenant_id, + expected_revision=expected_revision, + account_id=actor.account_id, + ) existing_ids = tuple( source.postbox_id for source in grouping.sources ) if grouping is not None else () @@ -2822,6 +3177,7 @@ class PostboxService: settings={}, ) session.add(grouping) + session.flush() else: grouping.name = name.strip() grouping.is_default = is_default @@ -2833,8 +3189,16 @@ class PostboxService: PostboxGrouping.tenant_id == tenant_id, PostboxGrouping.account_id == actor.account_id, PostboxGrouping.id != grouping.id, + PostboxGrouping.is_default.is_(True), + ) + .update( + { + PostboxGrouping.is_default: False, + PostboxGrouping.resource_revision: ( + PostboxGrouping.resource_revision + 1 + ), + } ) - .update({PostboxGrouping.is_default: False}) ) for position, postbox_id in enumerate(saved_ids): grouping.sources.append( @@ -2854,6 +3218,7 @@ class PostboxService: tenant_id: str, actor: PostboxActorRef, grouping_id: str, + expected_revision: int, ) -> None: grouping = ( session.query(PostboxGrouping) @@ -2866,10 +3231,53 @@ class PostboxService: ) if grouping is None: raise PostboxError("grouping_not_found", "Postbox grouping not found.") + self._claim_resource_revision( + session, + model=PostboxGrouping, + resource=grouping, + resource_type="postbox_grouping", + tenant_id=tenant_id, + expected_revision=expected_revision, + account_id=actor.account_id, + ) session.delete(grouping) session.flush() # Internal helpers + def _claim_resource_revision( + self, + session: Session, + *, + model: type[Any], + resource: Any, + resource_type: str, + tenant_id: str, + expected_revision: int | None, + account_id: str | None = None, + ) -> int: + if expected_revision is None: + raise MissingPreconditionError( + resource_type=resource_type, + resource_id=resource.id, + ) + filters = [ + model.id == resource.id, + model.tenant_id == tenant_id, + ] + if account_id is not None: + filters.append(model.account_id == account_id) + next_revision = claim_revision( + session, + model=model, + filters=filters, + revision_attribute="resource_revision", + expected_revision=expected_revision, + resource_type=resource_type, + resource_id=resource.id, + ) + resource.resource_revision = next_revision + return next_revision + def _get_postbox( self, session: Session, @@ -3282,6 +3690,8 @@ class PostboxService: holder_count=holder_count, vacant=holder_count == 0, access=decision, + resource_revision=postbox.resource_revision, + etag=postbox.strong_etag, ) def _message_ref( @@ -3318,6 +3728,8 @@ class PostboxService: producer_module=message.producer_module, producer_resource_type=message.producer_resource_type, producer_resource_id=message.producer_resource_id, + in_reply_to_message_id=message.in_reply_to_message_id, + replaces_message_id=message.replaces_message_id, encryption_profile=message.encryption_profile, key_epoch=message.key_epoch, ciphertext_ref=message.ciphertext_ref, diff --git a/tests/test_migration.py b/tests/test_migration.py index f1ae553..d36ded6 100644 --- a/tests/test_migration.py +++ b/tests/test_migration.py @@ -18,17 +18,24 @@ class PostboxMigrationTests(unittest.TestCase): "govoplan_postbox.backend.migrations.versions." "e4b7c9d2a6f1_v011_hierarchy_routes" ) + occ_migration = importlib.import_module( + "govoplan_postbox.backend.migrations.versions." + "f5c8d0e3b7a2_v012_authoring_and_occ" + ) engine = create_engine("sqlite:///:memory:") try: with engine.begin() as connection: operations = Operations(MigrationContext.configure(connection)) original = migration.op route_original = route_migration.op + occ_original = occ_migration.op migration.op = operations route_migration.op = operations + occ_migration.op = operations try: migration.upgrade() route_migration.upgrade() + occ_migration.upgrade() tables = set(inspect(connection).get_table_names()) self.assertIn("postboxes", tables) self.assertIn("postbox_messages", tables) @@ -50,6 +57,21 @@ class PostboxMigrationTests(unittest.TestCase): "withdrawn_at", }.issubset(message_columns) ) + self.assertIn("authoring_key", message_columns) + for table_name in ( + "postbox_templates", + "postboxes", + "postbox_groupings", + ): + self.assertIn( + "resource_revision", + { + column["name"] + for column in inspect(connection).get_columns( + table_name + ) + }, + ) route_columns = { column["name"] for column in inspect(connection).get_columns( @@ -61,6 +83,7 @@ class PostboxMigrationTests(unittest.TestCase): route_columns ) ) + occ_migration.downgrade() route_migration.downgrade() migration.downgrade() self.assertFalse( @@ -73,6 +96,7 @@ class PostboxMigrationTests(unittest.TestCase): finally: migration.op = original route_migration.op = route_original + occ_migration.op = occ_original finally: engine.dispose() diff --git a/tests/test_router.py b/tests/test_router.py index c96379e..1679fd4 100644 --- a/tests/test_router.py +++ b/tests/test_router.py @@ -209,6 +209,7 @@ class PostboxRouterTests(unittest.TestCase): { "postbox:postbox:read", "postbox:message:write", + "postbox:message:reply", "postbox:message:acknowledge", "postbox:delivery:write", "postbox:binding:admin", @@ -314,6 +315,134 @@ class PostboxRouterTests(unittest.TestCase): response.json()["diagnostics"], ) + def test_message_authoring_and_reply_are_idempotent_and_linked(self) -> None: + authored_payload = { + "postbox_id": self.postbox_id, + "idempotency_key": "compose-1", + "subject": "Status request", + "body_text": "Please provide a status update.", + "participants": [ + { + "kind": "to", + "reference_type": "address", + "address": "team@example.invalid", + } + ], + } + authored = self.client.post( + "/api/v1/postbox/messages", + json=authored_payload, + ) + duplicate = self.client.post( + "/api/v1/postbox/messages", + json=authored_payload, + ) + conflict = self.client.post( + "/api/v1/postbox/messages", + json={**authored_payload, "subject": "Different request"}, + ) + + self.assertEqual(201, authored.status_code, authored.text) + self.assertEqual(201, duplicate.status_code, duplicate.text) + self.assertEqual(authored.json()["id"], duplicate.json()["id"]) + self.assertEqual(409, conflict.status_code, conflict.text) + self.assertEqual("author", authored.json()["participants"][0]["kind"]) + + reply_payload = { + "idempotency_key": "reply-1", + "subject": "Re: Status request", + "body_text": "The work is complete.", + } + reply = self.client.post( + f"/api/v1/postbox/messages/{authored.json()['id']}/replies", + json=reply_payload, + ) + duplicate_reply = self.client.post( + f"/api/v1/postbox/messages/{authored.json()['id']}/replies", + json=reply_payload, + ) + + self.assertEqual(201, reply.status_code, reply.text) + self.assertEqual(reply.json()["id"], duplicate_reply.json()["id"]) + self.assertEqual( + authored.json()["id"], + reply.json()["in_reply_to_message_id"], + ) + + def test_mutable_admin_resources_require_strong_preconditions(self) -> None: + grouping = self.client.post( + "/api/v1/postbox/groupings", + json={ + "name": "Work", + "is_default": True, + "postbox_ids": [self.postbox_id], + }, + ) + self.assertEqual(201, grouping.status_code, grouping.text) + grouping_data = grouping.json() + update_payload = { + "name": "Current work", + "is_default": True, + "postbox_ids": [self.postbox_id], + "base_revision": grouping_data["resource_revision"], + } + updated = self.client.put( + f"/api/v1/postbox/groupings/{grouping_data['id']}", + json=update_payload, + headers={"If-Match": grouping_data["etag"]}, + ) + stale = self.client.put( + f"/api/v1/postbox/groupings/{grouping_data['id']}", + json=update_payload, + headers={"If-Match": grouping_data["etag"]}, + ) + self.assertEqual(200, updated.status_code, updated.text) + self.assertEqual(2, updated.json()["resource_revision"]) + self.assertEqual(412, stale.status_code, stale.text) + + template = self.client.post( + "/api/v1/postbox/admin/templates", + json={ + "slug": "case-intake", + "name": "Case intake", + "scope_kind": "tenant", + "name_pattern": "{unit_name} / {function_name}", + "address_pattern": "{template_slug}.{unit_slug}.{function_slug}", + "classification": "internal", + }, + ) + self.assertEqual(201, template.status_code, template.text) + template_data = template.json() + published = self.client.post( + f"/api/v1/postbox/admin/templates/{template_data['id']}/publish", + json={"base_revision": template_data["resource_revision"]}, + headers={"If-Match": template_data["etag"]}, + ) + stale_retire = self.client.post( + f"/api/v1/postbox/admin/templates/{template_data['id']}/retire", + json={"base_revision": template_data["resource_revision"]}, + headers={"If-Match": template_data["etag"]}, + ) + self.assertEqual(200, published.status_code, published.text) + self.assertEqual(412, stale_retire.status_code, stale_retire.text) + + directory = self.client.get("/api/v1/postbox/admin/postboxes").json() + postbox = directory["postboxes"][0] + missing = self.client.request( + "DELETE", + f"/api/v1/postbox/admin/postboxes/{self.postbox_id}", + json={"base_revision": postbox["resource_revision"]}, + ) + archived = self.client.request( + "DELETE", + f"/api/v1/postbox/admin/postboxes/{self.postbox_id}", + json={"base_revision": postbox["resource_revision"]}, + headers={"If-Match": postbox["etag"]}, + ) + self.assertEqual(428, missing.status_code, missing.text) + self.assertEqual(200, archived.status_code, archived.text) + self.assertEqual(2, archived.json()["resource_revision"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_service.py b/tests/test_service.py index 4b01bfe..40f636e 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -616,6 +616,7 @@ class PostboxServiceTests(unittest.TestCase): template_id=target_template.id, revision_number=None, actor_id="admin-1", + expected_revision=target_template.resource_revision, ) source_template = service.create_template( session, @@ -644,6 +645,7 @@ class PostboxServiceTests(unittest.TestCase): template_id=source_template.id, revision_number=None, actor_id="admin-1", + expected_revision=source_template.resource_revision, ) source = service.materialize_template( session, @@ -846,6 +848,7 @@ class PostboxServiceTests(unittest.TestCase): template_id=template.id, revision_number=1, actor_id="admin-1", + expected_revision=template.resource_revision, ) first = self.service.materialize_template( session, @@ -877,6 +880,7 @@ class PostboxServiceTests(unittest.TestCase): classification="restricted", allow_vacant_delivery=True, actor_id="admin-1", + expected_revision=template.resource_revision, ) self.assertEqual(first.id, second.id) @@ -916,6 +920,7 @@ class PostboxServiceTests(unittest.TestCase): template_id=template.id, revision_number=None, actor_id="admin-1", + expected_revision=template.resource_revision, ) session.commit() @@ -1474,6 +1479,7 @@ class PostboxServiceTests(unittest.TestCase): tenant_id="tenant-1", actor=self.actor, grouping_id=grouping_id, + expected_revision=grouping.resource_revision, name="Assigned work", is_default=True, postbox_ids=(parent.id,), diff --git a/webui/src/api/postbox.ts b/webui/src/api/postbox.ts index e3bf6f8..d5224b2 100644 --- a/webui/src/api/postbox.ts +++ b/webui/src/api/postbox.ts @@ -40,6 +40,8 @@ export type PostboxDirectoryItem = { holder_count: number; vacant: boolean; access?: PostboxAccessDecision | null; + resource_revision: number; + etag: string; }; export type PostboxParticipant = { @@ -78,6 +80,8 @@ export type PostboxMessage = { producer_module?: string | null; producer_resource_type?: string | null; producer_resource_id?: string | null; + in_reply_to_message_id?: string | null; + replaces_message_id?: string | null; encryption_profile: string; key_epoch: number; ciphertext_ref?: string | null; @@ -92,6 +96,8 @@ export type PostboxGrouping = { name: string; is_default: boolean; postbox_ids: string[]; + resource_revision: number; + etag: string; created_at: string; updated_at: string; }; @@ -188,6 +194,8 @@ export type PostboxTemplate = { description?: string | null; status: string; current_revision: number; + resource_revision: number; + etag: string; published_revision_id?: string | null; revisions: PostboxTemplateRevision[]; created_at: string; @@ -221,6 +229,21 @@ export type PostboxExactCreatePayload = { classification: string; }; +export type PostboxMessageAuthoringPayload = { + idempotency_key: string; + subject: string; + body_text?: string | null; + classification: string; + participants: PostboxParticipant[]; + attachments: PostboxAttachment[]; + metadata: Record; +}; + +type PostboxGroupingPayload = Pick< + PostboxGrouping, + "name" | "is_default" | "postbox_ids" +>; + export async function listPostboxes(settings: ApiSettings): Promise { const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>( settings, @@ -271,6 +294,29 @@ export function markPostboxMessage( ); } +export function createPostboxMessage( + settings: ApiSettings, + postboxId: string, + payload: PostboxMessageAuthoringPayload +): Promise { + return apiPostJson(settings, "/api/v1/postbox/messages", { + ...payload, + postbox_id: postboxId + }); +} + +export function replyToPostboxMessage( + settings: ApiSettings, + messageId: string, + payload: PostboxMessageAuthoringPayload +): Promise { + return apiPostJson( + settings, + `/api/v1/postbox/messages/${encodeURIComponent(messageId)}/replies`, + payload + ); +} + export async function listPostboxGroupings(settings: ApiSettings): Promise { const response = await apiFetch<{ groupings: PostboxGrouping[] }>( settings, @@ -281,34 +327,39 @@ export async function listPostboxGroupings(settings: ApiSettings): Promise + payload: PostboxGroupingPayload ): Promise { return apiPostJson(settings, "/api/v1/postbox/groupings", payload); } export function updatePostboxGrouping( settings: ApiSettings, - groupingId: string, - payload: Omit + grouping: PostboxGrouping, + payload: PostboxGroupingPayload ): Promise { return apiFetch( settings, - `/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`, + `/api/v1/postbox/groupings/${encodeURIComponent(grouping.id)}`, { method: "PUT", - body: JSON.stringify(payload) + headers: { "If-Match": grouping.etag }, + body: JSON.stringify({ ...payload, base_revision: grouping.resource_revision }) } ); } export function deletePostboxGrouping( settings: ApiSettings, - groupingId: string + grouping: PostboxGrouping ): Promise { return apiFetch( settings, - `/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`, - { method: "DELETE" } + `/api/v1/postbox/groupings/${encodeURIComponent(grouping.id)}`, + { + method: "DELETE", + headers: { "If-Match": grouping.etag }, + body: JSON.stringify({ base_revision: grouping.resource_revision }) + } ); } @@ -338,12 +389,16 @@ export function createExactPostbox( export function archivePostbox( settings: ApiSettings, - postboxId: string + postbox: PostboxDirectoryItem ): Promise { return apiFetch( settings, - `/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}`, - { method: "DELETE" } + `/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}`, + { + method: "DELETE", + headers: { "If-Match": postbox.etag }, + body: JSON.stringify({ base_revision: postbox.resource_revision }) + } ); } @@ -364,36 +419,39 @@ export function createPostboxTemplate( export function revisePostboxTemplate( settings: ApiSettings, - templateId: string, + template: PostboxTemplate, payload: PostboxTemplateRevisionPayload ): Promise { return apiPostJson( settings, - `/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/revisions`, - payload + `/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/revisions`, + { ...payload, base_revision: template.resource_revision }, + { headers: { "If-Match": template.etag } } ); } export function publishPostboxTemplate( settings: ApiSettings, - templateId: string, + template: PostboxTemplate, revision?: number ): Promise { return apiPostJson( settings, - `/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/publish`, - { revision: revision ?? null } + `/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/publish`, + { revision: revision ?? null, base_revision: template.resource_revision }, + { headers: { "If-Match": template.etag } } ); } export function retirePostboxTemplate( settings: ApiSettings, - templateId: string + template: PostboxTemplate ): Promise { return apiPostJson( settings, - `/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/retire`, - {} + `/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/retire`, + { base_revision: template.resource_revision }, + { headers: { "If-Match": template.etag } } ); } diff --git a/webui/src/features/postbox/PostboxAdminPanel.tsx b/webui/src/features/postbox/PostboxAdminPanel.tsx index 0555634..86f03a7 100644 --- a/webui/src/features/postbox/PostboxAdminPanel.tsx +++ b/webui/src/features/postbox/PostboxAdminPanel.tsx @@ -236,9 +236,13 @@ export default function PostboxAdminPanel({ setSuccess(""); try { if (templateDraft.templateId) { + const currentTemplate = templates.find( + (item) => item.id === templateDraft.templateId + ); + if (!currentTemplate) throw new Error("The template is no longer available."); await revisePostboxTemplate( settings, - templateDraft.templateId, + currentTemplate, revisionPayload(templateDraft) ); setSuccess("A new immutable template revision was created."); @@ -268,7 +272,7 @@ export default function PostboxAdminPanel({ try { await publishPostboxTemplate( settings, - selectedTemplate.id, + selectedTemplate, selectedTemplate.current_revision ); setSuccess(`Published revision ${selectedTemplate.current_revision}.`); @@ -286,7 +290,7 @@ export default function PostboxAdminPanel({ setError(""); setSuccess(""); try { - await retirePostboxTemplate(settings, selectedTemplate.id); + await retirePostboxTemplate(settings, selectedTemplate); setSuccess("Postbox template retired. Existing addresses remain durable."); await load(); } catch (actionError) { @@ -365,7 +369,7 @@ export default function PostboxAdminPanel({ setError(""); setSuccess(""); try { - await archivePostbox(settings, archiveTarget.id); + await archivePostbox(settings, archiveTarget); setSuccess("Postbox archived. Messages and delivery evidence were retained."); setArchiveTarget(null); await load(); diff --git a/webui/src/features/postbox/PostboxPage.tsx b/webui/src/features/postbox/PostboxPage.tsx index a0fbbf6..7d61293 100644 --- a/webui/src/features/postbox/PostboxPage.tsx +++ b/webui/src/features/postbox/PostboxPage.tsx @@ -10,7 +10,9 @@ import { Pencil, Plus, RefreshCw, + Reply, Search, + Send, Trash2, UserRoundCheck, X @@ -34,12 +36,14 @@ import { } from "@govoplan/core-webui"; import { createPostboxGrouping, + createPostboxMessage, deletePostboxGrouping, getPostboxMessage, listPostboxGroupings, listPostboxMessages, listPostboxes, markPostboxMessage, + replyToPostboxMessage, updatePostboxGrouping, type PostboxDirectoryItem, type PostboxGrouping, @@ -56,6 +60,14 @@ type GroupingDraft = { type MessageStateFilter = "all" | "unread" | "read" | "acknowledged"; +type MessageDraft = { + postbox_id: string; + subject: string; + body_text: string; + classification: string; + recipients: string; +}; + const emptyGrouping = (): GroupingDraft => ({ id: "", name: "", @@ -63,6 +75,14 @@ const emptyGrouping = (): GroupingDraft => ({ postbox_ids: [] }); +const emptyMessageDraft = (): MessageDraft => ({ + postbox_id: "", + subject: "", + body_text: "", + classification: "internal", + recipients: "" +}); + export default function PostboxPage({ settings, auth @@ -94,8 +114,13 @@ export default function PostboxPage({ const [error, setError] = useState(""); const [groupingDialogOpen, setGroupingDialogOpen] = useState(false); const [groupingDraft, setGroupingDraft] = useState(emptyGrouping); + const [messageDialogOpen, setMessageDialogOpen] = useState(false); + const [replyParent, setReplyParent] = useState(null); + const [messageDraft, setMessageDraft] = useState(emptyMessageDraft); const canAcknowledge = hasScope(auth, "postbox:message:acknowledge"); + const canSend = hasScope(auth, "postbox:message:write"); + const canReply = hasScope(auth, "postbox:message:reply"); const selectedPostbox = useMemo( () => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null, [postboxes, selectedPostboxId] @@ -335,8 +360,9 @@ export default function PostboxPage({ postbox_ids: groupingDraft.postbox_ids }; try { - const saved = groupingDraft.id - ? await updatePostboxGrouping(settings, groupingDraft.id, payload) + const existing = groupings.find((item) => item.id === groupingDraft.id); + const saved = existing + ? await updatePostboxGrouping(settings, existing, payload) : await createPostboxGrouping(settings, payload); await loadDirectory(); setSelectedScope(saved.id); @@ -354,7 +380,9 @@ export default function PostboxPage({ setBusy(true); setError(""); try { - await deletePostboxGrouping(settings, groupingDraft.id); + const existing = groupings.find((item) => item.id === groupingDraft.id); + if (!existing) throw new Error("The grouping is no longer available."); + await deletePostboxGrouping(settings, existing); setSelectedScope("all"); setSelectedPostboxId(""); setGroupingDialogOpen(false); @@ -366,6 +394,86 @@ export default function PostboxPage({ } } + function openCompose() { + const postbox = selectedPostbox ?? postboxes[0] ?? null; + if (!postbox) return; + setReplyParent(null); + setMessageDraft({ + ...emptyMessageDraft(), + postbox_id: postbox.id, + classification: postbox.classification + }); + setMessageDialogOpen(true); + } + + function openReply() { + if (!selectedMessage) return; + setReplyParent(selectedMessage); + setMessageDraft({ + ...emptyMessageDraft(), + postbox_id: selectedMessage.postbox_id, + subject: selectedMessage.subject.toLowerCase().startsWith("re:") + ? selectedMessage.subject + : `Re: ${selectedMessage.subject}`, + classification: selectedMessage.classification + }); + setMessageDialogOpen(true); + } + + async function submitMessage() { + if (!messageDraft.postbox_id || !messageDraft.subject.trim()) return; + setBusy(true); + setError(""); + const participants = messageDraft.recipients + .split(/[;,\n]/) + .map((address) => address.trim()) + .filter(Boolean) + .map((address) => ({ + kind: "to", + reference_type: "address", + address + })); + const payload = { + idempotency_key: crypto.randomUUID(), + subject: messageDraft.subject.trim(), + body_text: messageDraft.body_text || null, + classification: messageDraft.classification, + participants, + attachments: [], + metadata: {} + }; + try { + const message = replyParent + ? await replyToPostboxMessage(settings, replyParent.id, payload) + : await createPostboxMessage( + settings, + messageDraft.postbox_id, + payload + ); + setMessageDialogOpen(false); + setReplyParent(null); + setSelectedPostboxId(message.postbox_id); + setSelectedScope("all"); + setSelectedMessageId(message.id); + setSelectedMessage(message); + setPage(1); + const refreshed = await listPostboxMessages( + settings, + [message.postbox_id], + pageSize, + 0, + messageQuery, + messageState + ); + setMessages(refreshed.messages); + setTotal(refreshed.total); + } catch (actionError) { + setError(errorMessage(actionError)); + } finally { + setBusy(false); + } + } + return (
@@ -472,12 +580,20 @@ export default function PostboxPage({ )} {total}
- } - onClick={() => void loadMessages()} - disabled={loadingMessages || busy} - /> +
+ } + onClick={openCompose} + disabled={!canSend || !postboxes.length || busy} + /> + } + onClick={() => void loadMessages()} + disabled={loadingMessages || busy} + /> +
@@ -615,25 +731,38 @@ export default function PostboxPage({
- +
+ + +
{selectedMessage ? ( + + setMessageDialogOpen(false)} + closeDisabled={busy} + footer={ +
+ + +
+ } + > +
+ + + + + + +
+ + + setMessageDraft((current) => ({ + ...current, + recipients: event.target.value + })) + } + /> + +
+
+ + + setMessageDraft((current) => ({ + ...current, + subject: event.target.value + })) + } + /> + +
+
+ +