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,