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 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"
@@ -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 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(
+35
View File
@@ -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
+414 -2
View File
@@ -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,
+24
View File
@@ -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()
+129
View File
@@ -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()
+6
View File
@@ -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,),
+78 -20
View File
@@ -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<string, unknown>;
};
type PostboxGroupingPayload = Pick<
PostboxGrouping,
"name" | "is_default" | "postbox_ids"
>;
export async function listPostboxes(settings: ApiSettings): Promise<PostboxDirectoryItem[]> {
const response = await apiFetch<{ postboxes: PostboxDirectoryItem[] }>(
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[]> {
const response = await apiFetch<{ groupings: PostboxGrouping[] }>(
settings,
@@ -281,34 +327,39 @@ export async function listPostboxGroupings(settings: ApiSettings): Promise<Postb
export function createPostboxGrouping(
settings: ApiSettings,
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
payload: PostboxGroupingPayload
): Promise<PostboxGrouping> {
return apiPostJson(settings, "/api/v1/postbox/groupings", payload);
}
export function updatePostboxGrouping(
settings: ApiSettings,
groupingId: string,
payload: Omit<PostboxGrouping, "id" | "created_at" | "updated_at">
grouping: PostboxGrouping,
payload: PostboxGroupingPayload
): Promise<PostboxGrouping> {
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<void> {
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<PostboxDirectoryItem> {
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<PostboxTemplate> {
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<PostboxTemplate> {
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<PostboxTemplate> {
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 } }
);
}
@@ -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();
+302 -28
View File
@@ -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<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 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 (
<main className="workspace-data-page module-entry-page postbox-page">
<div className="postbox-shell">
@@ -472,12 +580,20 @@ export default function PostboxPage({
)}
<span className="postbox-total">{total}</span>
</div>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => void loadMessages()}
disabled={loadingMessages || busy}
/>
<div className="postbox-icon-actions">
<IconButton
label="New message"
icon={<Send size={16} />}
onClick={openCompose}
disabled={!canSend || !postboxes.length || busy}
/>
<IconButton
label="Refresh messages"
icon={<RefreshCw size={16} />}
onClick={() => void loadMessages()}
disabled={loadingMessages || busy}
/>
</div>
</div>
<div className="postbox-message-filters">
<div className="postbox-search-row">
@@ -615,25 +731,38 @@ export default function PostboxPage({
<MailOpen size={17} aria-hidden="true" />
<strong>{selectedMessage?.subject || "Message"}</strong>
</div>
<Button
onClick={() => void acknowledgeSelected()}
disabled={
!selectedMessage ||
selectedMessage.availability !== "available" ||
Boolean(selectedMessage.acknowledged_at) ||
busy
}
disabledReason={
!canAcknowledge
? "You cannot acknowledge Postbox messages."
: selectedMessage &&
selectedMessage.availability !== "available"
? "Withdrawn or expired messages cannot be acknowledged."
: undefined
}
>
<CheckCheck size={16} /> Acknowledge
</Button>
<div className="button-row compact-actions">
<Button
onClick={openReply}
disabled={
!canReply ||
!selectedMessage ||
selectedMessage.availability !== "available" ||
busy
}
>
<Reply size={16} /> Reply
</Button>
<Button
onClick={() => void acknowledgeSelected()}
disabled={
!selectedMessage ||
selectedMessage.availability !== "available" ||
Boolean(selectedMessage.acknowledged_at) ||
busy
}
disabledReason={
!canAcknowledge
? "You cannot acknowledge Postbox messages."
: selectedMessage &&
selectedMessage.availability !== "available"
? "Withdrawn or expired messages cannot be acknowledged."
: undefined
}
>
<CheckCheck size={16} /> Acknowledge
</Button>
</div>
</div>
{selectedMessage ? (
<MessageDetail
@@ -737,6 +866,119 @@ export default function PostboxPage({
))}
</fieldset>
</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>
);
}
@@ -842,6 +1084,38 @@ function producerLabel(message: PostboxMessage): string {
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 {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
+29
View File
@@ -518,6 +518,31 @@
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 {
margin-bottom: 14px;
}
@@ -667,4 +692,8 @@
align-items: flex-start;
flex-direction: column;
}
.postbox-compose-grid {
grid-template-columns: 1fr;
}
}