diplomail (Stage A): add in-game personal mail subsystem
Tests · Go / test (push) Successful in 1m44s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · Go / test (pull_request) Successful in 2m45s

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:
Ilia Denisov
2026-05-15 18:28:55 +02:00
parent 77cb7c78b6
commit 535e27008f
28 changed files with 3069 additions and 12 deletions
+390
View File
@@ -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.