diplomail (Stage F): docs + edge-case tests + LibreTranslate recipe
Closes the documentation gaps from the freshly-audited diplomail implementation. FUNCTIONAL.md gains a §11 "Diplomatic mail" with the full user-facing story across all five stages, mirrored into FUNCTIONAL_ru.md as the project conventions require. A new backend/docs/diplomail-translator-setup.md captures the LibreTranslate operational recipe (Docker image, env wiring, manual smoke test, troubleshooting). The package README gains a "Multi-instance posture" note documenting the deliberate absence of FOR UPDATE in the worker pickup query — single-instance is safe today; multi-instance scaling will revisit the claim mechanism. Two small edge-case tests round things out: malformed LibreTranslate response bodies (single string, short array, empty array, missing field) must surface as errors so the worker falls back instead of crashing; and an empty translation queue must produce zero events on three consecutive Worker.Tick calls. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,7 @@ same scenario when they participate in the same business flow.
|
||||
8. [Notifications and mail](#8-notifications-and-mail)
|
||||
9. [Geo signal](#9-geo-signal)
|
||||
10. [Administration](#10-administration)
|
||||
11. [Diplomatic mail](#11-diplomatic-mail)
|
||||
|
||||
---
|
||||
|
||||
@@ -1153,3 +1154,223 @@ counters are populated by the runtime, and operators can only read.
|
||||
- Mail outbox and notification dispatcher:
|
||||
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
||||
[§12](ARCHITECTURE.md#12-notification-pipeline) and [Section 8](#8-notifications-and-mail).
|
||||
|
||||
---
|
||||
|
||||
## 11. Diplomatic mail
|
||||
|
||||
This scenario covers the player-to-player and admin-to-player
|
||||
messaging system exposed inside a game. The system is conceptually
|
||||
part of the lobby (messages outlive game runtime restarts), but
|
||||
they are surfaced exclusively inside the in-game UI; the lobby
|
||||
surfaces only an unread counter.
|
||||
|
||||
### 11.1 Scope
|
||||
|
||||
In scope: sending personal mail between active members of the same
|
||||
game; replying to personal mail; reading and marking-read /
|
||||
soft-deleting one's own incoming mail; admin / owner notifications
|
||||
addressed to one player or broadcast to a game; paid-tier player
|
||||
broadcasts; site-admin multi-game broadcasts; bulk purge of
|
||||
messages tied to terminated games; auto-translation of the body
|
||||
into the recipient's `preferred_language` with a cached rendering.
|
||||
|
||||
Out of scope: out-of-game chat, group chats spanning multiple
|
||||
games, file attachments, message editing or unsend, end-to-end
|
||||
encryption.
|
||||
|
||||
### 11.2 The message model
|
||||
|
||||
Every send produces exactly one row in `diplomail_messages` plus
|
||||
one row per recipient in `diplomail_recipients`. A broadcast to N
|
||||
recipients is one message + N recipient rows; the translation row,
|
||||
when materialised, is shared across every recipient with the same
|
||||
target language.
|
||||
|
||||
`diplomail_messages.kind` is the closed set
|
||||
`{personal, admin}`. Personal messages are replyable (the
|
||||
recipient sends back a new personal message); admin messages are
|
||||
non-replyable acknowledgements of a state change or operator
|
||||
action. `sender_kind` is `{player, admin, system}` and identifies
|
||||
the originator's role: a player owns the game (admin notification
|
||||
from owner), a site administrator pushed it (admin notification
|
||||
from operator), or the lobby state machine produced it
|
||||
(`game.paused`, `game.cancelled`, `membership.removed`,
|
||||
`membership.blocked`).
|
||||
|
||||
`broadcast_scope` records whether the send was a single-recipient
|
||||
delivery (`single`), a one-game broadcast (`game_broadcast`), or a
|
||||
cross-game admin broadcast (`multi_game_broadcast`). Recipients of
|
||||
a multi-game broadcast see one independently-deletable inbox entry
|
||||
per game they were addressed in.
|
||||
|
||||
Per-row snapshots travel with each message: `game_name`,
|
||||
`sender_username`, `sender_ip`, plus on the recipient row
|
||||
`recipient_user_name`, `recipient_race_name`, and
|
||||
`recipient_preferred_language`. These survive game-name changes,
|
||||
membership revocation, account soft-delete, and the eventual
|
||||
bulk-purge cascade — they let the admin observability surface
|
||||
render correctly long after the live rows have moved on.
|
||||
|
||||
Bodies and subjects are plain UTF-8 text. The server does not
|
||||
parse, sanitise, or escape HTML; the client renders bodies through
|
||||
`textContent`. Maximum body size is
|
||||
`BACKEND_DIPLOMAIL_MAX_BODY_BYTES` (default `4096`); maximum
|
||||
subject size is `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default
|
||||
`256`).
|
||||
|
||||
### 11.3 Sending mail
|
||||
|
||||
Personal sends require active membership in the game for both the
|
||||
sender and the recipient. Free-tier players send one personal
|
||||
message per request. Paid-tier players additionally have access to
|
||||
a game-scoped broadcast that addresses every other active member
|
||||
in one call; replies fan back to the broadcast author.
|
||||
|
||||
Game owners (of private games) and site administrators send admin
|
||||
notifications. The owner endpoint lives under the user surface
|
||||
(authenticated by `X-User-ID`, owner check enforced); the admin
|
||||
endpoint lives under the admin surface (HTTP Basic). Both accept
|
||||
`target=user` (single recipient) or `target=all` (game broadcast).
|
||||
Site administrators additionally have a multi-game endpoint that
|
||||
accepts `scope=selected` with a list of game ids or
|
||||
`scope=all_running` that enumerates every game with non-terminal
|
||||
status.
|
||||
|
||||
Broadcast composition is parameterised by `recipients`: `active`
|
||||
(default), `active_and_removed`, or `all_members` (includes
|
||||
blocked rows for audit-style mail). The broadcast author's own
|
||||
recipient row is never created.
|
||||
|
||||
A paid-tier broadcast is rejected with `403 forbidden` when the
|
||||
caller's entitlement tier is `free`.
|
||||
|
||||
### 11.4 Receiving mail
|
||||
|
||||
The recipient sees the message in their in-game inbox once the
|
||||
async translation worker has finished processing it (see
|
||||
[§11.6](#116-translation)). Until then the row stays invisible:
|
||||
absent from the inbox listing, not counted in the unread badge, no
|
||||
push event delivered. This avoids a surprise where the inbox shows
|
||||
a row with no translation and an outdated unread count.
|
||||
|
||||
The unread badge in the lobby aggregates by game. The
|
||||
`/api/v1/user/lobby/mail/unread-counts` endpoint returns one entry
|
||||
per game with non-zero unread plus the global total; the lobby UI
|
||||
renders the total badge and a per-game tile counter without
|
||||
exposing the messages themselves.
|
||||
|
||||
Marking a message as read is idempotent. Soft-deletion requires the
|
||||
message to already be marked read — a client cannot erase an
|
||||
unopened message. Soft-deletion is per-recipient: the underlying
|
||||
message row survives until the admin bulk-purge endpoint removes
|
||||
the entire game's mail tree.
|
||||
|
||||
The message detail response includes both the original body and,
|
||||
when available, the cached translation; the client UI defaults to
|
||||
the translated text and offers a "show original" toggle.
|
||||
|
||||
### 11.5 Lifecycle hooks
|
||||
|
||||
Three lobby transitions land as system mail in the affected
|
||||
players' inboxes:
|
||||
|
||||
- **Game paused / cancelled.** When the game state machine moves
|
||||
through `paused` or `cancelled`, the lobby emits a system mail
|
||||
addressed to every active member. The message explains the
|
||||
transition with a server-rendered template, so even an offline
|
||||
player finds the context the next time they open the inbox.
|
||||
- **Membership removed / blocked.** Manual self-leave, owner-driven
|
||||
removal, and admin ban each emit a system mail addressed to the
|
||||
affected player only. This mail survives the membership going
|
||||
to `removed` / `blocked`, so a kicked player keeps read access
|
||||
to the explanation forever (soft-access rule).
|
||||
|
||||
Future inactivity-driven removal must call the same publisher so
|
||||
the explanation reaches the affected player; the lobby package
|
||||
README pins this contract for the next implementer.
|
||||
|
||||
### 11.6 Translation
|
||||
|
||||
`diplomail_messages.body_lang` is filled at send time by an
|
||||
in-process language detector that operates on the body only.
|
||||
Subject inherits the body's detected language for the translation
|
||||
cache lookup. When detection cannot confidently label the body
|
||||
(too short, empty, mixed scripts) the value is the BCP 47
|
||||
`und` ("undetermined") sentinel and the translation pipeline is
|
||||
short-circuited — recipients receive the original.
|
||||
|
||||
Translation happens asynchronously. Every recipient row stores a
|
||||
snapshot of the addressee's `preferred_language` plus an
|
||||
`available_at` timestamp. A recipient whose language matches the
|
||||
detected `body_lang` (or whose preferred language is empty / the
|
||||
body language is `und`) gets `available_at = now()` on insert and
|
||||
the push event fires immediately. A recipient whose language
|
||||
differs is inserted with `available_at IS NULL` and waits for the
|
||||
translation worker.
|
||||
|
||||
The worker (`internal/diplomail.Worker`) ticks every
|
||||
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (default `2s`) and processes
|
||||
one `(message_id, target_lang)` pair per tick. It consults the
|
||||
translation cache first; on miss it asks the configured
|
||||
`Translator`. The default deployment ships the LibreTranslate HTTP
|
||||
client; an empty `BACKEND_DIPLOMAIL_TRANSLATOR_URL` falls back to
|
||||
the noop translator that delivers every message in the original
|
||||
language.
|
||||
|
||||
Translation outcomes:
|
||||
|
||||
- **Success.** A row in `diplomail_translations` is inserted (or
|
||||
reused if another worker won the race), every pending recipient
|
||||
of the pair is flipped to `available_at = now()`, and one push
|
||||
event per recipient is published.
|
||||
- **Unsupported language pair** (HTTP 400 from LibreTranslate).
|
||||
No translation row is persisted; recipients are delivered with
|
||||
the original body. Subsequent reads return the original.
|
||||
- **Transient failure** (timeout, 5xx, network error). The
|
||||
attempt counter is bumped and the next attempt is scheduled via
|
||||
exponential backoff `1s → 2s → 4s → 8s → 16s` (capped at 60s).
|
||||
After `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (default `5`)
|
||||
the worker falls back to delivering the original body. A
|
||||
prolonged translator outage therefore stalls delivery by at
|
||||
most ~30 seconds per pair before the receiver sees the
|
||||
original.
|
||||
|
||||
The translation cache is shared: a broadcast to N recipients with
|
||||
the same preferred language produces one cache row and one
|
||||
translator call, not N.
|
||||
|
||||
### 11.7 Storage and purge
|
||||
|
||||
Messages live in `diplomail_messages`; per-recipient state lives
|
||||
in `diplomail_recipients` with a foreign-key cascade to the
|
||||
message; translations live in `diplomail_translations` also with a
|
||||
cascade. The sender IP is captured at insert time from
|
||||
`X-Forwarded-For` (forwarded by gateway) for evidence preservation.
|
||||
|
||||
There is no automatic retention. The admin bulk-purge endpoint
|
||||
removes every message whose game finished more than
|
||||
`older_than_years` years ago (minimum `1`); the cascade drops the
|
||||
recipient and translation rows in the same transaction.
|
||||
|
||||
### 11.8 Operator visibility
|
||||
|
||||
The admin surface exposes a paginated listing of every persisted
|
||||
message (`/api/v1/admin/mail/messages`) filterable by `game_id`,
|
||||
`kind`, and `sender_kind`. The bulk-purge endpoint
|
||||
(`/api/v1/admin/mail/cleanup`) accepts the `older_than_years`
|
||||
threshold. Per-game admin sends and multi-game broadcasts live
|
||||
under `/api/v1/admin/games/{game_id}/mail` and
|
||||
`/api/v1/admin/mail/broadcast`.
|
||||
|
||||
### 11.9 Cross-references
|
||||
|
||||
- Package overview and stage map:
|
||||
[`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md).
|
||||
- LibreTranslate setup recipe for local development:
|
||||
[`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md).
|
||||
- Storage detail:
|
||||
[ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem).
|
||||
- Push transport for delivery events: [Section 7](#7-push-channel).
|
||||
- Notification catalog kind `diplomail.message.received`:
|
||||
[`backend/README.md` §10](../backend/README.md#10-notification-catalog).
|
||||
|
||||
Reference in New Issue
Block a user