diplomail (Stage A): add in-game personal mail subsystem
Phase 28 of ui/PLAN.md needs a persistent player-to-player mail channel; the existing `mail` package is a transactional email outbox and the `notification` catalog is one-way platform events. Stage A lands the schema (diplomail_messages / _recipients / _translations), a single-recipient personal send/read/delete service path, a `diplomail.message.received` push kind plumbed through the notification pipeline, and an unread-counts endpoint that drives the lobby badge. Admin / system mail, lifecycle hooks, paid-tier broadcast, multi-game broadcast, bulk purge and language detection / translation cache come in stages B–D. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1144,6 +1144,215 @@ paths:
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/messages:
|
||||
post:
|
||||
tags: [User]
|
||||
operationId: userMailSendPersonal
|
||||
summary: Send a personal diplomatic mail message
|
||||
description: |
|
||||
Sends a replyable personal message from the authenticated user
|
||||
to another active member of the same game. Both sender and
|
||||
recipient must be active members. Body is plain UTF-8 text
|
||||
(no HTML processing on the server); `subject` is optional.
|
||||
Body length is capped at `BACKEND_DIPLOMAIL_MAX_BODY_BYTES`
|
||||
(default 4096) and subject length at
|
||||
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256).
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSendRequest"
|
||||
responses:
|
||||
"201":
|
||||
description: Personal message accepted and persisted.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"403":
|
||||
$ref: "#/components/responses/ForbiddenError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/messages/{message_id}:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailGet
|
||||
summary: Read one diplomatic mail message
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/MessageID"
|
||||
responses:
|
||||
"200":
|
||||
description: Message addressed to the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
delete:
|
||||
tags: [User]
|
||||
operationId: userMailDelete
|
||||
summary: Soft-delete a previously-read message
|
||||
description: |
|
||||
Marks the caller's recipient row for the message as deleted.
|
||||
The underlying message stays persisted (admin / system mail is
|
||||
retained for the lifetime of the game). The recipient row must
|
||||
have `read_at` set first; otherwise the call returns 409.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/MessageID"
|
||||
responses:
|
||||
"200":
|
||||
description: Message soft-deleted for the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailRecipientState"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"409":
|
||||
$ref: "#/components/responses/ConflictError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/messages/{message_id}/read:
|
||||
post:
|
||||
tags: [User]
|
||||
operationId: userMailMarkRead
|
||||
summary: Mark a diplomatic mail message as read
|
||||
description: |
|
||||
Idempotent. Sets `read_at` on the caller's recipient row when
|
||||
it is still unread; a second call on an already-read row is a
|
||||
no-op and the existing state is returned.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
- $ref: "#/components/parameters/MessageID"
|
||||
responses:
|
||||
"200":
|
||||
description: Recipient state after the mark-read.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailRecipientState"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"404":
|
||||
$ref: "#/components/responses/NotFoundError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/inbox:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailInbox
|
||||
summary: List the caller's inbox for a game
|
||||
description: |
|
||||
Returns every non-soft-deleted mail row addressed to the
|
||||
caller in the given game, newest first. Includes the
|
||||
per-recipient read state. Soft access: the caller may not be
|
||||
an active member if every visible row carries
|
||||
`kind="admin"`.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
responses:
|
||||
"200":
|
||||
description: Inbox entries for the caller in the given game.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailInboxList"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/games/{game_id}/mail/sent:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailSent
|
||||
summary: List the caller's sent personal messages in a game
|
||||
description: |
|
||||
Returns personal messages authored by the caller in the given
|
||||
game, newest first. Admin / system messages are not listed
|
||||
(they have no `sender_user_id`).
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
- $ref: "#/components/parameters/GameID"
|
||||
responses:
|
||||
"200":
|
||||
description: Sent personal messages by the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailSentList"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/lobby/mail/unread-counts:
|
||||
get:
|
||||
tags: [User]
|
||||
operationId: userMailUnreadCounts
|
||||
summary: Per-game and total unread mail counts for the caller
|
||||
description: |
|
||||
Drives the lobby badge: returns one entry per game the caller
|
||||
has any unread mail in, plus the global total. The response
|
||||
is empty (and `total == 0`) when there is nothing unread.
|
||||
security:
|
||||
- UserHeader: []
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/XUserID"
|
||||
responses:
|
||||
"200":
|
||||
description: Per-game unread counts addressed to the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UserMailUnreadCountsResponse"
|
||||
"400":
|
||||
$ref: "#/components/responses/InvalidRequestError"
|
||||
"501":
|
||||
$ref: "#/components/responses/NotImplementedError"
|
||||
"500":
|
||||
$ref: "#/components/responses/InternalError"
|
||||
/api/v1/user/sessions:
|
||||
get:
|
||||
tags: [User]
|
||||
@@ -2247,6 +2456,13 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
MessageID:
|
||||
name: message_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
NotificationID:
|
||||
name: notification_id
|
||||
in: path
|
||||
@@ -3599,6 +3815,180 @@ components:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/DeviceSession"
|
||||
UserMailSendRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [recipient_user_id, body]
|
||||
properties:
|
||||
recipient_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
subject:
|
||||
type: string
|
||||
description: |
|
||||
Optional subject. Empty string and missing field are
|
||||
treated the same.
|
||||
body:
|
||||
type: string
|
||||
description: Plain UTF-8 body. HTML is not parsed on the server.
|
||||
UserMailMessageDetail:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- message_id
|
||||
- game_id
|
||||
- kind
|
||||
- sender_kind
|
||||
- body
|
||||
- body_lang
|
||||
- broadcast_scope
|
||||
- created_at
|
||||
- recipient_user_id
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
sender_kind:
|
||||
type: string
|
||||
enum: [player, admin, system]
|
||||
sender_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
nullable: true
|
||||
sender_username:
|
||||
type: string
|
||||
nullable: true
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
body_lang:
|
||||
type: string
|
||||
description: BCP 47 tag. `und` until Stage D adds detection.
|
||||
broadcast_scope:
|
||||
type: string
|
||||
enum: [single, game_broadcast, multi_game_broadcast]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
recipient_user_id:
|
||||
type: string
|
||||
format: uuid
|
||||
recipient_user_name:
|
||||
type: string
|
||||
recipient_race_name:
|
||||
type: string
|
||||
nullable: true
|
||||
read_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
deleted_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
UserMailSentSummary:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required:
|
||||
- message_id
|
||||
- game_id
|
||||
- kind
|
||||
- body
|
||||
- body_lang
|
||||
- broadcast_scope
|
||||
- created_at
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
kind:
|
||||
type: string
|
||||
enum: [personal, admin]
|
||||
subject:
|
||||
type: string
|
||||
body:
|
||||
type: string
|
||||
body_lang:
|
||||
type: string
|
||||
broadcast_scope:
|
||||
type: string
|
||||
enum: [single, game_broadcast, multi_game_broadcast]
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
UserMailInboxList:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserMailMessageDetail"
|
||||
UserMailSentList:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [items]
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserMailSentSummary"
|
||||
UserMailUnreadCount:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [game_id, unread]
|
||||
properties:
|
||||
game_id:
|
||||
type: string
|
||||
format: uuid
|
||||
game_name:
|
||||
type: string
|
||||
unread:
|
||||
type: integer
|
||||
minimum: 0
|
||||
UserMailUnreadCountsResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [total, items]
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
minimum: 0
|
||||
items:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/UserMailUnreadCount"
|
||||
UserMailRecipientState:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [message_id]
|
||||
properties:
|
||||
message_id:
|
||||
type: string
|
||||
format: uuid
|
||||
read_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
deleted_at:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
responses:
|
||||
NotImplementedError:
|
||||
description: Endpoint is documented but not implemented yet.
|
||||
|
||||
Reference in New Issue
Block a user