Add Postbox message authoring and concurrency

This commit is contained in:
2026-07-31 18:40:39 +02:00
parent 0b6ebc3c74
commit dc6f81dd33
12 changed files with 1392 additions and 68 deletions
+51
View File
@@ -17,6 +17,7 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship 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 from govoplan_core.db.base import Base, TimestampMixin
@@ -47,6 +48,11 @@ class PostboxTemplate(Base, TimestampMixin):
index=True, index=True,
) )
current_revision: Mapped[int] = mapped_column(Integer, default=1, nullable=False) 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( published_revision_id: Mapped[str | None] = mapped_column(
String(36), String(36),
nullable=True, nullable=True,
@@ -65,6 +71,14 @@ class PostboxTemplate(Base, TimestampMixin):
order_by="PostboxTemplateRevision.revision", 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): class PostboxTemplateRevision(Base, TimestampMixin):
__tablename__ = "postbox_template_revisions" __tablename__ = "postbox_template_revisions"
@@ -264,6 +278,11 @@ class Postbox(Base, TimestampMixin):
nullable=False, nullable=False,
) )
key_epoch: Mapped[int] = mapped_column(Integer, default=1, 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( settings: Mapped[dict[str, Any]] = mapped_column(
JSON, JSON,
default=dict, default=dict,
@@ -286,6 +305,14 @@ class Postbox(Base, TimestampMixin):
cascade="all, delete-orphan", cascade="all, delete-orphan",
) )
@property
def strong_etag(self) -> str:
return strong_resource_etag(
"postbox",
self.id,
self.resource_revision,
)
class PostboxBinding(Base, TimestampMixin): class PostboxBinding(Base, TimestampMixin):
__tablename__ = "postbox_bindings" __tablename__ = "postbox_bindings"
@@ -376,6 +403,12 @@ class PostboxMessage(Base, TimestampMixin):
"producer_resource_type", "producer_resource_type",
"producer_resource_id", "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) id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
@@ -415,6 +448,11 @@ class PostboxMessage(Base, TimestampMixin):
nullable=True, nullable=True,
index=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( in_reply_to_message_id: Mapped[str | None] = mapped_column(
ForeignKey("postbox_messages.id", ondelete="SET NULL"), ForeignKey("postbox_messages.id", ondelete="SET NULL"),
nullable=True, nullable=True,
@@ -774,6 +812,11 @@ class PostboxGrouping(Base, TimestampMixin):
account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) account_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
name: Mapped[str] = mapped_column(String(250), nullable=False) name: Mapped[str] = mapped_column(String(250), nullable=False)
is_default: Mapped[bool] = mapped_column(Boolean, default=False, 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( settings: Mapped[dict[str, Any]] = mapped_column(
JSON, JSON,
default=dict, default=dict,
@@ -786,6 +829,14 @@ class PostboxGrouping(Base, TimestampMixin):
order_by="PostboxGroupingSource.position", 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): class PostboxGroupingSource(Base, TimestampMixin):
__tablename__ = "postbox_grouping_sources" __tablename__ = "postbox_grouping_sources"
@@ -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")
+251 -14
View File
@@ -3,14 +3,21 @@ from __future__ import annotations
from dataclasses import asdict from dataclasses import asdict
from typing import Literal 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 sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal, has_scope 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 ( from govoplan_core.core.postbox import (
PostboxActorRef, PostboxActorRef,
PostboxAttachmentRef, PostboxAttachmentRef,
PostboxDeliveryRequest, PostboxDeliveryRequest,
PostboxMessageAuthoringRequest,
PostboxParticipantRef, PostboxParticipantRef,
PostboxTargetRef, PostboxTargetRef,
) )
@@ -37,18 +44,22 @@ from govoplan_postbox.backend.schemas import (
PostboxGroupingItem, PostboxGroupingItem,
PostboxGroupingListResponse, PostboxGroupingListResponse,
PostboxGroupingPayload, PostboxGroupingPayload,
PostboxGroupingUpdateRequest,
PostboxMaterializeRequest, PostboxMaterializeRequest,
PostboxMessageItem, PostboxMessageItem,
PostboxMessageCreateRequest,
PostboxMessageAuthoringPayload,
PostboxMessageListResponse, PostboxMessageListResponse,
PostboxMessageStateRequest, PostboxMessageStateRequest,
PostboxOrganizationTargetsResponse, PostboxOrganizationTargetsResponse,
PostboxMutationRequest,
PostboxRouteDryRunRequest, PostboxRouteDryRunRequest,
PostboxRouteDryRunResponse, PostboxRouteDryRunResponse,
PostboxTemplateCreateRequest, PostboxTemplateCreateRequest,
PostboxTemplateItem, PostboxTemplateItem,
PostboxTemplateListResponse, PostboxTemplateListResponse,
PostboxTemplatePublishRequest, PostboxTemplatePublishRequest,
PostboxTemplateRevisionPayload, PostboxTemplateReviseRequest,
) )
from govoplan_postbox.backend.service import PostboxError 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: def _directory_item(value) -> PostboxDirectoryItem:
return PostboxDirectoryItem.model_validate(asdict(value)) return PostboxDirectoryItem.model_validate(asdict(value))
@@ -153,6 +232,8 @@ def _template_item(template) -> PostboxTemplateItem:
description=template.description, description=template.description,
status=template.status, status=template.status,
current_revision=template.current_revision, current_revision=template.current_revision,
resource_revision=template.resource_revision,
etag=template.strong_etag,
published_revision_id=template.published_revision_id, published_revision_id=template.published_revision_id,
revisions=[ revisions=[
{ {
@@ -184,6 +265,8 @@ def _grouping_item(grouping, *, visible_ids: set[str]) -> PostboxGroupingItem:
id=grouping.id, id=grouping.id,
name=grouping.name, name=grouping.name,
is_default=grouping.is_default, is_default=grouping.is_default,
resource_revision=grouping.resource_revision,
etag=grouping.strong_etag,
postbox_ids=[ postbox_ids=[
source.postbox_id source.postbox_id
for source in grouping.sources 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) @router.get("/messages/{message_id}", response_model=PostboxMessageItem)
def api_get_postbox_message( def api_get_postbox_message(
message_id: str, message_id: str,
@@ -468,6 +612,7 @@ def api_list_postbox_groupings(
) )
def api_create_postbox_grouping( def api_create_postbox_grouping(
payload: PostboxGroupingPayload, payload: PostboxGroupingPayload,
response: Response,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxGroupingItem: ) -> PostboxGroupingItem:
@@ -484,17 +629,27 @@ def api_create_postbox_grouping(
except PostboxError as exc: except PostboxError as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
session.commit() 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) @router.put("/groupings/{grouping_id}", response_model=PostboxGroupingItem)
def api_update_postbox_grouping( def api_update_postbox_grouping(
grouping_id: str, grouping_id: str,
payload: PostboxGroupingPayload, payload: PostboxGroupingUpdateRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxGroupingItem: ) -> PostboxGroupingItem:
_require(principal, READ_SCOPE) _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) actor = _actor(principal)
try: try:
grouping = get_service().save_grouping( grouping = get_service().save_grouping(
@@ -502,30 +657,48 @@ def api_update_postbox_grouping(
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
actor=actor, actor=actor,
grouping_id=grouping_id, grouping_id=grouping_id,
**payload.model_dump(), expected_revision=payload.base_revision,
**payload.model_dump(exclude={"base_revision"}),
) )
except PostboxError as exc: except PostboxError as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit() 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) @router.delete("/groupings/{grouping_id}", status_code=status.HTTP_204_NO_CONTENT)
def api_delete_postbox_grouping( def api_delete_postbox_grouping(
grouping_id: str, grouping_id: str,
payload: PostboxMutationRequest,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> None: ) -> None:
_require(principal, READ_SCOPE) _require(principal, READ_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_grouping",
resource_id=grouping_id,
base_revision=payload.base_revision,
)
try: try:
get_service().delete_grouping( get_service().delete_grouping(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
actor=_actor(principal), actor=_actor(principal),
grouping_id=grouping_id, grouping_id=grouping_id,
expected_revision=payload.base_revision,
) )
except PostboxError as exc: except PostboxError as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit() session.commit()
@@ -573,6 +746,7 @@ def api_admin_postboxes(
) )
def api_create_exact_postbox( def api_create_exact_postbox(
payload: PostboxExactCreateRequest, payload: PostboxExactCreateRequest,
response: Response,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem: ) -> PostboxDirectoryItem:
@@ -587,13 +761,15 @@ def api_create_exact_postbox(
except PostboxError as exc: except PostboxError as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
session.commit() session.commit()
return _directory_item( item = _directory_item(
get_service().resolve_postbox( get_service().resolve_postbox(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox.id), target=PostboxTargetRef(postbox_id=postbox.id),
) )
) )
_set_etag(response, item.etag)
return item
@router.delete( @router.delete(
@@ -602,27 +778,42 @@ def api_create_exact_postbox(
) )
def api_archive_postbox( def api_archive_postbox(
postbox_id: str, postbox_id: str,
payload: PostboxMutationRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxDirectoryItem: ) -> PostboxDirectoryItem:
_require(principal, BINDING_ADMIN_SCOPE) _require(principal, BINDING_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox",
resource_id=postbox_id,
base_revision=payload.base_revision,
)
try: try:
postbox = get_service().archive_postbox( postbox = get_service().archive_postbox(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
postbox_id=postbox_id, postbox_id=postbox_id,
actor_id=principal.account_id, actor_id=principal.account_id,
expected_revision=payload.base_revision,
) )
except PostboxError as exc: except PostboxError as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit() session.commit()
return _directory_item( item = _directory_item(
get_service().resolve_postbox( get_service().resolve_postbox(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
target=PostboxTargetRef(postbox_id=postbox.id), target=PostboxTargetRef(postbox_id=postbox.id),
) )
) )
_set_etag(response, item.etag)
return item
@router.get("/admin/templates", response_model=PostboxTemplateListResponse) @router.get("/admin/templates", response_model=PostboxTemplateListResponse)
@@ -649,6 +840,7 @@ def api_postbox_templates(
) )
def api_create_postbox_template( def api_create_postbox_template(
payload: PostboxTemplateCreateRequest, payload: PostboxTemplateCreateRequest,
response: Response,
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem: ) -> PostboxTemplateItem:
@@ -663,7 +855,9 @@ def api_create_postbox_template(
except PostboxError as exc: except PostboxError as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
session.commit() session.commit()
return _template_item(template) item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post( @router.post(
@@ -672,23 +866,37 @@ def api_create_postbox_template(
) )
def api_revise_postbox_template( def api_revise_postbox_template(
template_id: str, template_id: str,
payload: PostboxTemplateRevisionPayload, payload: PostboxTemplateReviseRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem: ) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE) _require(principal, TEMPLATE_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_template",
resource_id=template_id,
base_revision=payload.base_revision,
)
try: try:
template = get_service().revise_template( template = get_service().revise_template(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
template_id=template_id, template_id=template_id,
actor_id=principal.account_id, actor_id=principal.account_id,
**payload.model_dump(), expected_revision=payload.base_revision,
**payload.model_dump(exclude={"base_revision"}),
) )
except PostboxError as exc: except PostboxError as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit() session.commit()
return _template_item(template) item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post( @router.post(
@@ -698,10 +906,18 @@ def api_revise_postbox_template(
def api_publish_postbox_template( def api_publish_postbox_template(
template_id: str, template_id: str,
payload: PostboxTemplatePublishRequest, payload: PostboxTemplatePublishRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem: ) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE) _require(principal, TEMPLATE_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_template",
resource_id=template_id,
base_revision=payload.base_revision,
)
try: try:
template = get_service().publish_template( template = get_service().publish_template(
session, session,
@@ -709,11 +925,17 @@ def api_publish_postbox_template(
template_id=template_id, template_id=template_id,
revision_number=payload.revision, revision_number=payload.revision,
actor_id=principal.account_id, actor_id=principal.account_id,
expected_revision=payload.base_revision,
) )
except PostboxError as exc: except PostboxError as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit() session.commit()
return _template_item(template) item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post( @router.post(
@@ -722,21 +944,36 @@ def api_publish_postbox_template(
) )
def api_retire_postbox_template( def api_retire_postbox_template(
template_id: str, template_id: str,
payload: PostboxMutationRequest,
response: Response,
if_match: str | None = Header(default=None, alias="If-Match"),
session: Session = Depends(get_session), session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal), principal: ApiPrincipal = Depends(get_api_principal),
) -> PostboxTemplateItem: ) -> PostboxTemplateItem:
_require(principal, TEMPLATE_ADMIN_SCOPE) _require(principal, TEMPLATE_ADMIN_SCOPE)
_require_mutation_precondition(
if_match,
resource_type="postbox_template",
resource_id=template_id,
base_revision=payload.base_revision,
)
try: try:
template = get_service().retire_template( template = get_service().retire_template(
session, session,
tenant_id=principal.tenant_id, tenant_id=principal.tenant_id,
template_id=template_id, template_id=template_id,
actor_id=principal.account_id, actor_id=principal.account_id,
expected_revision=payload.base_revision,
) )
except PostboxError as exc: except PostboxError as exc:
raise _http_error(exc) from exc raise _http_error(exc) from exc
except ConcurrencyError as exc:
session.rollback()
raise _concurrency_http_error(exc) from exc
session.commit() session.commit()
return _template_item(template) item = _template_item(template)
_set_etag(response, item.etag)
return item
@router.post( @router.post(
+35
View File
@@ -49,6 +49,8 @@ class PostboxDirectoryItem(BaseModel):
holder_count: int = 0 holder_count: int = 0
vacant: bool = True vacant: bool = True
access: PostboxAccessDecisionResponse | None = None access: PostboxAccessDecisionResponse | None = None
resource_revision: int = Field(default=1, ge=1)
etag: str | None = None
class PostboxDirectoryResponse(BaseModel): class PostboxDirectoryResponse(BaseModel):
@@ -91,6 +93,8 @@ class PostboxMessageItem(BaseModel):
producer_module: str | None = None producer_module: str | None = None
producer_resource_type: str | None = None producer_resource_type: str | None = None
producer_resource_id: 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 encryption_profile: str
key_epoch: int key_epoch: int
ciphertext_ref: str | None = None ciphertext_ref: str | None = None
@@ -111,6 +115,20 @@ class PostboxMessageStateRequest(BaseModel):
state: Literal["read", "acknowledged"] 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): class PostboxTargetPayload(BaseModel):
postbox_id: str | None = Field(default=None, max_length=36) postbox_id: str | None = Field(default=None, max_length=36)
address_key: str | None = Field(default=None, max_length=500) address_key: str | None = Field(default=None, max_length=500)
@@ -336,6 +354,10 @@ class PostboxTemplateCreateRequest(PostboxTemplateRevisionPayload):
description: str | None = None description: str | None = None
class PostboxTemplateReviseRequest(PostboxTemplateRevisionPayload):
base_revision: int = Field(ge=1)
class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload): class PostboxTemplateRevisionItem(PostboxTemplateRevisionPayload):
id: str id: str
revision: int revision: int
@@ -354,6 +376,8 @@ class PostboxTemplateItem(BaseModel):
description: str | None = None description: str | None = None
status: str status: str
current_revision: int current_revision: int
resource_revision: int = Field(ge=1)
etag: str
published_revision_id: str | None = None published_revision_id: str | None = None
revisions: list[PostboxTemplateRevisionItem] = Field(default_factory=list) revisions: list[PostboxTemplateRevisionItem] = Field(default_factory=list)
created_at: datetime created_at: datetime
@@ -366,6 +390,11 @@ class PostboxTemplateListResponse(BaseModel):
class PostboxTemplatePublishRequest(BaseModel): class PostboxTemplatePublishRequest(BaseModel):
revision: int | None = Field(default=None, ge=1) 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): class PostboxMaterializeRequest(BaseModel):
@@ -425,8 +454,14 @@ class PostboxGroupingPayload(BaseModel):
postbox_ids: list[str] = Field(default_factory=list, max_length=250) postbox_ids: list[str] = Field(default_factory=list, max_length=250)
class PostboxGroupingUpdateRequest(PostboxGroupingPayload):
base_revision: int = Field(ge=1)
class PostboxGroupingItem(PostboxGroupingPayload): class PostboxGroupingItem(PostboxGroupingPayload):
id: str id: str
resource_revision: int = Field(ge=1)
etag: str
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
+414 -2
View File
@@ -1,12 +1,13 @@
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import logging import logging
import re import re
from collections import Counter from collections import Counter
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Literal from typing import Any, Literal
from sqlalchemy import and_, func, or_ from sqlalchemy import and_, func, or_
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -19,6 +20,7 @@ from govoplan_core.core.events import (
PlatformEvent, PlatformEvent,
emit_platform_event, emit_platform_event,
) )
from govoplan_core.core.concurrency import MissingPreconditionError, claim_revision
from govoplan_core.core.identity import ( from govoplan_core.core.identity import (
CAPABILITY_IDENTITY_DIRECTORY, CAPABILITY_IDENTITY_DIRECTORY,
IdentityDirectory, IdentityDirectory,
@@ -57,6 +59,7 @@ from govoplan_core.core.postbox import (
PostboxDeliveryTemplateRef, PostboxDeliveryTemplateRef,
PostboxDirectoryEntryRef, PostboxDirectoryEntryRef,
PostboxMessageAvailability, PostboxMessageAvailability,
PostboxMessageAuthoringRequest,
PostboxMessageRef, PostboxMessageRef,
PostboxMessageListState, PostboxMessageListState,
PostboxOrganizationFunctionTargetRef, PostboxOrganizationFunctionTargetRef,
@@ -124,6 +127,49 @@ def _mapping(value: Mapping[str, object] | None) -> dict[str, object]:
return dict(value or {}) 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: def _as_utc(value: datetime) -> datetime:
if value.tzinfo is None: if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc) return value.replace(tzinfo=timezone.utc)
@@ -772,6 +818,268 @@ class PostboxService:
db.refresh(message) db.refresh(message)
return self._message_ref(message, account_id=actor.account_id) 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 # Capability: delivery
def deliver( def deliver(
self, self,
@@ -2258,12 +2566,21 @@ class PostboxService:
tenant_id: str, tenant_id: str,
postbox_id: str, postbox_id: str,
actor_id: str | None, actor_id: str | None,
expected_revision: int,
) -> Postbox: ) -> Postbox:
postbox = self._get_postbox( postbox = self._get_postbox(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
postbox_id=postbox_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.status = "archived"
postbox.archived_at = utc_now() postbox.archived_at = utc_now()
postbox.address_record.status = "archived" postbox.address_record.status = "archived"
@@ -2405,6 +2722,7 @@ class PostboxService:
classification: str, classification: str,
allow_vacant_delivery: bool, allow_vacant_delivery: bool,
actor_id: str | None, actor_id: str | None,
expected_revision: int,
routing_policy: Mapping[str, object] | None = None, routing_policy: Mapping[str, object] | None = None,
) -> PostboxTemplate: ) -> PostboxTemplate:
classification = self._validate_classification(classification) classification = self._validate_classification(classification)
@@ -2418,6 +2736,14 @@ class PostboxService:
"template_retired", "template_retired",
"A retired Postbox template cannot be revised.", "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( self._validate_scope(
tenant_id=tenant_id, tenant_id=tenant_id,
scope_kind=scope_kind, scope_kind=scope_kind,
@@ -2470,12 +2796,21 @@ class PostboxService:
template_id: str, template_id: str,
revision_number: int | None, revision_number: int | None,
actor_id: str | None, actor_id: str | None,
expected_revision: int,
) -> PostboxTemplate: ) -> PostboxTemplate:
template = self._get_template( template = self._get_template(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
template_id=template_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( revision = next(
( (
item item
@@ -2519,12 +2854,21 @@ class PostboxService:
tenant_id: str, tenant_id: str,
template_id: str, template_id: str,
actor_id: str | None, actor_id: str | None,
expected_revision: int,
) -> PostboxTemplate: ) -> PostboxTemplate:
template = self._get_template( template = self._get_template(
session, session,
tenant_id=tenant_id, tenant_id=tenant_id,
template_id=template_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.status = "retired"
template.retired_at = utc_now() template.retired_at = utc_now()
template.updated_by = actor_id template.updated_by = actor_id
@@ -2773,6 +3117,7 @@ class PostboxService:
name: str, name: str,
is_default: bool, is_default: bool,
postbox_ids: Sequence[str], postbox_ids: Sequence[str],
expected_revision: int | None = None,
) -> PostboxGrouping: ) -> PostboxGrouping:
grouping = ( grouping = (
session.query(PostboxGrouping) session.query(PostboxGrouping)
@@ -2788,6 +3133,16 @@ class PostboxService:
) )
if grouping_id and grouping is None: if grouping_id and grouping is None:
raise PostboxError("grouping_not_found", "Postbox grouping not found.") 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( existing_ids = tuple(
source.postbox_id for source in grouping.sources source.postbox_id for source in grouping.sources
) if grouping is not None else () ) if grouping is not None else ()
@@ -2822,6 +3177,7 @@ class PostboxService:
settings={}, settings={},
) )
session.add(grouping) session.add(grouping)
session.flush()
else: else:
grouping.name = name.strip() grouping.name = name.strip()
grouping.is_default = is_default grouping.is_default = is_default
@@ -2833,8 +3189,16 @@ class PostboxService:
PostboxGrouping.tenant_id == tenant_id, PostboxGrouping.tenant_id == tenant_id,
PostboxGrouping.account_id == actor.account_id, PostboxGrouping.account_id == actor.account_id,
PostboxGrouping.id != grouping.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): for position, postbox_id in enumerate(saved_ids):
grouping.sources.append( grouping.sources.append(
@@ -2854,6 +3218,7 @@ class PostboxService:
tenant_id: str, tenant_id: str,
actor: PostboxActorRef, actor: PostboxActorRef,
grouping_id: str, grouping_id: str,
expected_revision: int,
) -> None: ) -> None:
grouping = ( grouping = (
session.query(PostboxGrouping) session.query(PostboxGrouping)
@@ -2866,10 +3231,53 @@ class PostboxService:
) )
if grouping is None: if grouping is None:
raise PostboxError("grouping_not_found", "Postbox grouping not found.") 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.delete(grouping)
session.flush() session.flush()
# Internal helpers # 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( def _get_postbox(
self, self,
session: Session, session: Session,
@@ -3282,6 +3690,8 @@ class PostboxService:
holder_count=holder_count, holder_count=holder_count,
vacant=holder_count == 0, vacant=holder_count == 0,
access=decision, access=decision,
resource_revision=postbox.resource_revision,
etag=postbox.strong_etag,
) )
def _message_ref( def _message_ref(
@@ -3318,6 +3728,8 @@ class PostboxService:
producer_module=message.producer_module, producer_module=message.producer_module,
producer_resource_type=message.producer_resource_type, producer_resource_type=message.producer_resource_type,
producer_resource_id=message.producer_resource_id, 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, encryption_profile=message.encryption_profile,
key_epoch=message.key_epoch, key_epoch=message.key_epoch,
ciphertext_ref=message.ciphertext_ref, ciphertext_ref=message.ciphertext_ref,
+24
View File
@@ -18,17 +18,24 @@ class PostboxMigrationTests(unittest.TestCase):
"govoplan_postbox.backend.migrations.versions." "govoplan_postbox.backend.migrations.versions."
"e4b7c9d2a6f1_v011_hierarchy_routes" "e4b7c9d2a6f1_v011_hierarchy_routes"
) )
occ_migration = importlib.import_module(
"govoplan_postbox.backend.migrations.versions."
"f5c8d0e3b7a2_v012_authoring_and_occ"
)
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
try: try:
with engine.begin() as connection: with engine.begin() as connection:
operations = Operations(MigrationContext.configure(connection)) operations = Operations(MigrationContext.configure(connection))
original = migration.op original = migration.op
route_original = route_migration.op route_original = route_migration.op
occ_original = occ_migration.op
migration.op = operations migration.op = operations
route_migration.op = operations route_migration.op = operations
occ_migration.op = operations
try: try:
migration.upgrade() migration.upgrade()
route_migration.upgrade() route_migration.upgrade()
occ_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)
@@ -50,6 +57,21 @@ class PostboxMigrationTests(unittest.TestCase):
"withdrawn_at", "withdrawn_at",
}.issubset(message_columns) }.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 = { route_columns = {
column["name"] column["name"]
for column in inspect(connection).get_columns( for column in inspect(connection).get_columns(
@@ -61,6 +83,7 @@ class PostboxMigrationTests(unittest.TestCase):
route_columns route_columns
) )
) )
occ_migration.downgrade()
route_migration.downgrade() route_migration.downgrade()
migration.downgrade() migration.downgrade()
self.assertFalse( self.assertFalse(
@@ -73,6 +96,7 @@ class PostboxMigrationTests(unittest.TestCase):
finally: finally:
migration.op = original migration.op = original
route_migration.op = route_original route_migration.op = route_original
occ_migration.op = occ_original
finally: finally:
engine.dispose() engine.dispose()
+129
View File
@@ -209,6 +209,7 @@ class PostboxRouterTests(unittest.TestCase):
{ {
"postbox:postbox:read", "postbox:postbox:read",
"postbox:message:write", "postbox:message:write",
"postbox:message:reply",
"postbox:message:acknowledge", "postbox:message:acknowledge",
"postbox:delivery:write", "postbox:delivery:write",
"postbox:binding:admin", "postbox:binding:admin",
@@ -314,6 +315,134 @@ class PostboxRouterTests(unittest.TestCase):
response.json()["diagnostics"], 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__": if __name__ == "__main__":
unittest.main() unittest.main()
+6
View File
@@ -616,6 +616,7 @@ class PostboxServiceTests(unittest.TestCase):
template_id=target_template.id, template_id=target_template.id,
revision_number=None, revision_number=None,
actor_id="admin-1", actor_id="admin-1",
expected_revision=target_template.resource_revision,
) )
source_template = service.create_template( source_template = service.create_template(
session, session,
@@ -644,6 +645,7 @@ class PostboxServiceTests(unittest.TestCase):
template_id=source_template.id, template_id=source_template.id,
revision_number=None, revision_number=None,
actor_id="admin-1", actor_id="admin-1",
expected_revision=source_template.resource_revision,
) )
source = service.materialize_template( source = service.materialize_template(
session, session,
@@ -846,6 +848,7 @@ class PostboxServiceTests(unittest.TestCase):
template_id=template.id, template_id=template.id,
revision_number=1, revision_number=1,
actor_id="admin-1", actor_id="admin-1",
expected_revision=template.resource_revision,
) )
first = self.service.materialize_template( first = self.service.materialize_template(
session, session,
@@ -877,6 +880,7 @@ class PostboxServiceTests(unittest.TestCase):
classification="restricted", classification="restricted",
allow_vacant_delivery=True, allow_vacant_delivery=True,
actor_id="admin-1", actor_id="admin-1",
expected_revision=template.resource_revision,
) )
self.assertEqual(first.id, second.id) self.assertEqual(first.id, second.id)
@@ -916,6 +920,7 @@ class PostboxServiceTests(unittest.TestCase):
template_id=template.id, template_id=template.id,
revision_number=None, revision_number=None,
actor_id="admin-1", actor_id="admin-1",
expected_revision=template.resource_revision,
) )
session.commit() session.commit()
@@ -1474,6 +1479,7 @@ class PostboxServiceTests(unittest.TestCase):
tenant_id="tenant-1", tenant_id="tenant-1",
actor=self.actor, actor=self.actor,
grouping_id=grouping_id, grouping_id=grouping_id,
expected_revision=grouping.resource_revision,
name="Assigned work", name="Assigned work",
is_default=True, is_default=True,
postbox_ids=(parent.id,), postbox_ids=(parent.id,),
+78 -20
View File
@@ -40,6 +40,8 @@ export type PostboxDirectoryItem = {
holder_count: number; holder_count: number;
vacant: boolean; vacant: boolean;
access?: PostboxAccessDecision | null; access?: PostboxAccessDecision | null;
resource_revision: number;
etag: string;
}; };
export type PostboxParticipant = { export type PostboxParticipant = {
@@ -78,6 +80,8 @@ export type PostboxMessage = {
producer_module?: string | null; producer_module?: string | null;
producer_resource_type?: string | null; producer_resource_type?: string | null;
producer_resource_id?: string | null; producer_resource_id?: string | null;
in_reply_to_message_id?: string | null;
replaces_message_id?: string | null;
encryption_profile: string; encryption_profile: string;
key_epoch: number; key_epoch: number;
ciphertext_ref?: string | null; ciphertext_ref?: string | null;
@@ -92,6 +96,8 @@ export type PostboxGrouping = {
name: string; name: string;
is_default: boolean; is_default: boolean;
postbox_ids: string[]; postbox_ids: string[];
resource_revision: number;
etag: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
}; };
@@ -188,6 +194,8 @@ export type PostboxTemplate = {
description?: string | null; description?: string | null;
status: string; status: string;
current_revision: number; current_revision: number;
resource_revision: number;
etag: string;
published_revision_id?: string | null; published_revision_id?: string | null;
revisions: PostboxTemplateRevision[]; revisions: PostboxTemplateRevision[];
created_at: string; created_at: string;
@@ -221,6 +229,21 @@ export type PostboxExactCreatePayload = {
classification: string; classification: string;
}; };
export type PostboxMessageAuthoringPayload = {
idempotency_key: string;
subject: string;
body_text?: string | null;
classification: string;
participants: PostboxParticipant[];
attachments: PostboxAttachment[];
metadata: Record<string, unknown>;
};
type PostboxGroupingPayload = Pick<
PostboxGrouping,
"name" | "is_default" | "postbox_ids"
>;
export async function listPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> { export async function listPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>( const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
settings, settings,
@@ -271,6 +294,29 @@ export function markPostboxMessage(
); );
} }
export function createPostboxMessage(
settings: ApiSettings,
postboxId: string,
payload: PostboxMessageAuthoringPayload
): Promise<PostboxMessage> {
return apiPostJson(settings, "/api/v1/postbox/messages", {
...payload,
postbox_id: postboxId
});
}
export function replyToPostboxMessage(
settings: ApiSettings,
messageId: string,
payload: PostboxMessageAuthoringPayload
): Promise<PostboxMessage> {
return apiPostJson(
settings,
`/api/v1/postbox/messages/${encodeURIComponent(messageId)}/replies`,
payload
);
}
export async function listPostboxGroupings(settings: ApiSettings): Promise<PostboxGrouping[]> { export async function listPostboxGroupings(settings: ApiSettings): Promise<PostboxGrouping[]> {
const response = await apiFetch<{ groupings: PostboxGrouping[] }>( const response = await apiFetch<{ groupings: PostboxGrouping[] }>(
settings, settings,
@@ -281,34 +327,39 @@ export async function listPostboxGroupings(settings: ApiSettings): Promise<Postb
export function createPostboxGrouping( export function createPostboxGrouping(
settings: ApiSettings, settings: ApiSettings,
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at"> payload: PostboxGroupingPayload
): Promise<PostboxGrouping> { ): Promise<PostboxGrouping> {
return apiPostJson(settings, "/api/v1/postbox/groupings", payload); return apiPostJson(settings, "/api/v1/postbox/groupings", payload);
} }
export function updatePostboxGrouping( export function updatePostboxGrouping(
settings: ApiSettings, settings: ApiSettings,
groupingId: string, grouping: PostboxGrouping,
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at"> payload: PostboxGroupingPayload
): Promise<PostboxGrouping> { ): Promise<PostboxGrouping> {
return apiFetch( return apiFetch(
settings, settings,
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`, `/api/v1/postbox/groupings/${encodeURIComponent(grouping.id)}`,
{ {
method: "PUT", method: "PUT",
body: JSON.stringify(payload) headers: { "If-Match": grouping.etag },
body: JSON.stringify({ ...payload, base_revision: grouping.resource_revision })
} }
); );
} }
export function deletePostboxGrouping( export function deletePostboxGrouping(
settings: ApiSettings, settings: ApiSettings,
groupingId: string grouping: PostboxGrouping
): Promise<void> { ): Promise<void> {
return apiFetch( return apiFetch(
settings, settings,
`/api/v1/postbox/groupings/${encodeURIComponent(groupingId)}`, `/api/v1/postbox/groupings/${encodeURIComponent(grouping.id)}`,
{ method: "DELETE" } {
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( export function archivePostbox(
settings: ApiSettings, settings: ApiSettings,
postboxId: string postbox: PostboxDirectoryItem
): Promise<PostboxDirectoryItem> { ): Promise<PostboxDirectoryItem> {
return apiFetch( return apiFetch(
settings, settings,
`/api/v1/postbox/admin/postboxes/${encodeURIComponent(postboxId)}`, `/api/v1/postbox/admin/postboxes/${encodeURIComponent(postbox.id)}`,
{ method: "DELETE" } {
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( export function revisePostboxTemplate(
settings: ApiSettings, settings: ApiSettings,
templateId: string, template: PostboxTemplate,
payload: PostboxTemplateRevisionPayload payload: PostboxTemplateRevisionPayload
): Promise<PostboxTemplate> { ): Promise<PostboxTemplate> {
return apiPostJson( return apiPostJson(
settings, settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/revisions`, `/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/revisions`,
payload { ...payload, base_revision: template.resource_revision },
{ headers: { "If-Match": template.etag } }
); );
} }
export function publishPostboxTemplate( export function publishPostboxTemplate(
settings: ApiSettings, settings: ApiSettings,
templateId: string, template: PostboxTemplate,
revision?: number revision?: number
): Promise<PostboxTemplate> { ): Promise<PostboxTemplate> {
return apiPostJson( return apiPostJson(
settings, settings,
`/api/v1/postbox/admin/templates/${encodeURIComponent(templateId)}/publish`, `/api/v1/postbox/admin/templates/${encodeURIComponent(template.id)}/publish`,
{ revision: revision ?? null } { revision: revision ?? null, base_revision: template.resource_revision },
{ headers: { "If-Match": template.etag } }
); );
} }
export function retirePostboxTemplate( export function retirePostboxTemplate(
settings: ApiSettings, settings: ApiSettings,
templateId: string template: PostboxTemplate
): Promise<PostboxTemplate> { ): Promise<PostboxTemplate> {
return apiPostJson( return apiPostJson(
settings, 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 } }
); );
} }
@@ -236,9 +236,13 @@ export default function PostboxAdminPanel({
setSuccess(""); setSuccess("");
try { try {
if (templateDraft.templateId) { 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( await revisePostboxTemplate(
settings, settings,
templateDraft.templateId, currentTemplate,
revisionPayload(templateDraft) revisionPayload(templateDraft)
); );
setSuccess("A new immutable template revision was created."); setSuccess("A new immutable template revision was created.");
@@ -268,7 +272,7 @@ export default function PostboxAdminPanel({
try { try {
await publishPostboxTemplate( await publishPostboxTemplate(
settings, settings,
selectedTemplate.id, selectedTemplate,
selectedTemplate.current_revision selectedTemplate.current_revision
); );
setSuccess(`Published revision ${selectedTemplate.current_revision}.`); setSuccess(`Published revision ${selectedTemplate.current_revision}.`);
@@ -286,7 +290,7 @@ export default function PostboxAdminPanel({
setError(""); setError("");
setSuccess(""); setSuccess("");
try { try {
await retirePostboxTemplate(settings, selectedTemplate.id); await retirePostboxTemplate(settings, selectedTemplate);
setSuccess("Postbox template retired. Existing addresses remain durable."); setSuccess("Postbox template retired. Existing addresses remain durable.");
await load(); await load();
} catch (actionError) { } catch (actionError) {
@@ -365,7 +369,7 @@ export default function PostboxAdminPanel({
setError(""); setError("");
setSuccess(""); setSuccess("");
try { try {
await archivePostbox(settings, archiveTarget.id); await archivePostbox(settings, archiveTarget);
setSuccess("Postbox archived. Messages and delivery evidence were retained."); setSuccess("Postbox archived. Messages and delivery evidence were retained.");
setArchiveTarget(null); setArchiveTarget(null);
await load(); await load();
+277 -3
View File
@@ -10,7 +10,9 @@ import {
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Reply,
Search, Search,
Send,
Trash2, Trash2,
UserRoundCheck, UserRoundCheck,
X X
@@ -34,12 +36,14 @@ import {
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import { import {
createPostboxGrouping, createPostboxGrouping,
createPostboxMessage,
deletePostboxGrouping, deletePostboxGrouping,
getPostboxMessage, getPostboxMessage,
listPostboxGroupings, listPostboxGroupings,
listPostboxMessages, listPostboxMessages,
listPostboxes, listPostboxes,
markPostboxMessage, markPostboxMessage,
replyToPostboxMessage,
updatePostboxGrouping, updatePostboxGrouping,
type PostboxDirectoryItem, type PostboxDirectoryItem,
type PostboxGrouping, type PostboxGrouping,
@@ -56,6 +60,14 @@ type GroupingDraft = {
type MessageStateFilter = "all" | "unread" | "read" | "acknowledged"; type MessageStateFilter = "all" | "unread" | "read" | "acknowledged";
type MessageDraft = {
postbox_id: string;
subject: string;
body_text: string;
classification: string;
recipients: string;
};
const emptyGrouping = (): GroupingDraft => ({ const emptyGrouping = (): GroupingDraft => ({
id: "", id: "",
name: "", name: "",
@@ -63,6 +75,14 @@ const emptyGrouping = (): GroupingDraft => ({
postbox_ids: [] postbox_ids: []
}); });
const emptyMessageDraft = (): MessageDraft => ({
postbox_id: "",
subject: "",
body_text: "",
classification: "internal",
recipients: ""
});
export default function PostboxPage({ export default function PostboxPage({
settings, settings,
auth auth
@@ -94,8 +114,13 @@ export default function PostboxPage({
const [error, setError] = useState(""); const [error, setError] = useState("");
const [groupingDialogOpen, setGroupingDialogOpen] = useState(false); const [groupingDialogOpen, setGroupingDialogOpen] = useState(false);
const [groupingDraft, setGroupingDraft] = useState<GroupingDraft>(emptyGrouping); const [groupingDraft, setGroupingDraft] = useState<GroupingDraft>(emptyGrouping);
const [messageDialogOpen, setMessageDialogOpen] = useState(false);
const [replyParent, setReplyParent] = useState<PostboxMessage | null>(null);
const [messageDraft, setMessageDraft] = useState<MessageDraft>(emptyMessageDraft);
const canAcknowledge = hasScope(auth, "postbox:message:acknowledge"); const canAcknowledge = hasScope(auth, "postbox:message:acknowledge");
const canSend = hasScope(auth, "postbox:message:write");
const canReply = hasScope(auth, "postbox:message:reply");
const selectedPostbox = useMemo( const selectedPostbox = useMemo(
() => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null, () => postboxes.find((postbox) => postbox.id === selectedPostboxId) ?? null,
[postboxes, selectedPostboxId] [postboxes, selectedPostboxId]
@@ -335,8 +360,9 @@ export default function PostboxPage({
postbox_ids: groupingDraft.postbox_ids postbox_ids: groupingDraft.postbox_ids
}; };
try { try {
const saved = groupingDraft.id const existing = groupings.find((item) => item.id === groupingDraft.id);
? await updatePostboxGrouping(settings, groupingDraft.id, payload) const saved = existing
? await updatePostboxGrouping(settings, existing, payload)
: await createPostboxGrouping(settings, payload); : await createPostboxGrouping(settings, payload);
await loadDirectory(); await loadDirectory();
setSelectedScope(saved.id); setSelectedScope(saved.id);
@@ -354,7 +380,9 @@ export default function PostboxPage({
setBusy(true); setBusy(true);
setError(""); setError("");
try { 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"); setSelectedScope("all");
setSelectedPostboxId(""); setSelectedPostboxId("");
setGroupingDialogOpen(false); 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 ( return (
<main className="workspace-data-page module-entry-page postbox-page"> <main className="workspace-data-page module-entry-page postbox-page">
<div className="postbox-shell"> <div className="postbox-shell">
@@ -472,6 +580,13 @@ export default function PostboxPage({
)} )}
<span className="postbox-total">{total}</span> <span className="postbox-total">{total}</span>
</div> </div>
<div className="postbox-icon-actions">
<IconButton
label="New message"
icon={<Send size={16} />}
onClick={openCompose}
disabled={!canSend || !postboxes.length || busy}
/>
<IconButton <IconButton
label="Refresh messages" label="Refresh messages"
icon={<RefreshCw size={16} />} icon={<RefreshCw size={16} />}
@@ -479,6 +594,7 @@ export default function PostboxPage({
disabled={loadingMessages || busy} disabled={loadingMessages || busy}
/> />
</div> </div>
</div>
<div className="postbox-message-filters"> <div className="postbox-message-filters">
<div className="postbox-search-row"> <div className="postbox-search-row">
<input <input
@@ -615,6 +731,18 @@ export default function PostboxPage({
<MailOpen size={17} aria-hidden="true" /> <MailOpen size={17} aria-hidden="true" />
<strong>{selectedMessage?.subject || "Message"}</strong> <strong>{selectedMessage?.subject || "Message"}</strong>
</div> </div>
<div className="button-row compact-actions">
<Button
onClick={openReply}
disabled={
!canReply ||
!selectedMessage ||
selectedMessage.availability !== "available" ||
busy
}
>
<Reply size={16} /> Reply
</Button>
<Button <Button
onClick={() => void acknowledgeSelected()} onClick={() => void acknowledgeSelected()}
disabled={ disabled={
@@ -635,6 +763,7 @@ export default function PostboxPage({
<CheckCheck size={16} /> Acknowledge <CheckCheck size={16} /> Acknowledge
</Button> </Button>
</div> </div>
</div>
{selectedMessage ? ( {selectedMessage ? (
<MessageDetail <MessageDetail
message={selectedMessage} message={selectedMessage}
@@ -737,6 +866,119 @@ export default function PostboxPage({
))} ))}
</fieldset> </fieldset>
</Dialog> </Dialog>
<Dialog
open={messageDialogOpen}
title={replyParent ? "Reply" : "New message"}
className="postbox-dialog postbox-message-dialog"
onClose={() => setMessageDialogOpen(false)}
closeDisabled={busy}
footer={
<div className="postbox-dialog-actions end">
<Button onClick={() => setMessageDialogOpen(false)} disabled={busy}>
Cancel
</Button>
<Button
variant="primary"
onClick={() => void submitMessage()}
disabled={
busy ||
!messageDraft.postbox_id ||
!messageDraft.subject.trim()
}
>
<Send size={16} /> Send
</Button>
</div>
}
>
<div className="postbox-compose-grid">
<FormField label="Postbox">
<select
value={messageDraft.postbox_id}
disabled={Boolean(replyParent)}
onChange={(event) => {
const postbox = postboxes.find(
(item) => item.id === event.target.value
);
setMessageDraft((current) => ({
...current,
postbox_id: event.target.value,
classification: postbox?.classification ?? "internal"
}));
}}
>
{postboxes.map((postbox) => (
<option key={postbox.id} value={postbox.id}>
{postbox.name}
</option>
))}
</select>
</FormField>
<FormField label="Classification">
<select
value={messageDraft.classification}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
classification: event.target.value
}))
}
>
{classificationOptions(
postboxes.find((item) => item.id === messageDraft.postbox_id)
?.classification ?? "internal",
replyParent?.classification
).map((classification) => (
<option key={classification} value={classification}>
{classificationLabel(classification)}
</option>
))}
</select>
</FormField>
<div className="postbox-compose-wide">
<FormField label="Recipients">
<input
value={messageDraft.recipients}
placeholder="Separate addresses with commas"
onChange={(event) =>
setMessageDraft((current) => ({
...current,
recipients: event.target.value
}))
}
/>
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Subject">
<input
value={messageDraft.subject}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
subject: event.target.value
}))
}
/>
</FormField>
</div>
<div className="postbox-compose-wide">
<FormField label="Message">
<textarea
rows={10}
value={messageDraft.body_text}
onChange={(event) =>
setMessageDraft((current) => ({
...current,
body_text: event.target.value
}))
}
/>
</FormField>
</div>
</div>
</Dialog>
</main> </main>
); );
} }
@@ -842,6 +1084,38 @@ function producerLabel(message: PostboxMessage): string {
return resource.length ? resource.join(" / ") : "Platform-native message"; return resource.length ? resource.join(" / ") : "Platform-native message";
} }
const CLASSIFICATIONS = [
"public",
"internal",
"confidential",
"restricted"
] as const;
function classificationOptions(
postboxClassification: string,
minimumClassification?: string
): string[] {
const maximum = Math.max(
0,
CLASSIFICATIONS.indexOf(
postboxClassification as (typeof CLASSIFICATIONS)[number]
)
);
const minimum = minimumClassification
? Math.max(
0,
CLASSIFICATIONS.indexOf(
minimumClassification as (typeof CLASSIFICATIONS)[number]
)
)
: 0;
return CLASSIFICATIONS.slice(minimum, maximum + 1);
}
function classificationLabel(value: string): string {
return `${value.charAt(0).toUpperCase()}${value.slice(1)}`;
}
function formatDate(value: string): string { function formatDate(value: string): string {
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return value; if (Number.isNaN(date.getTime())) return value;
+29
View File
@@ -518,6 +518,31 @@
color: var(--muted); color: var(--muted);
} }
.postbox-message-dialog {
width: min(720px, calc(100vw - 32px));
}
.postbox-compose-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(160px, 1fr);
gap: 12px;
}
.postbox-compose-wide {
grid-column: 1 / -1;
}
.postbox-compose-grid textarea {
width: 100%;
min-height: 180px;
resize: vertical;
}
.postbox-dialog-actions.end {
justify-content: flex-end;
gap: 8px;
}
.postbox-admin-page > .segmented-control { .postbox-admin-page > .segmented-control {
margin-bottom: 14px; margin-bottom: 14px;
} }
@@ -667,4 +692,8 @@
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.postbox-compose-grid {
grid-template-columns: 1fr;
}
} }