diplomail (Stage F): docs + edge-case tests + LibreTranslate recipe
Tests · Integration / integration (pull_request) Successful in 1m37s
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 2m4s

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:
Ilia Denisov
2026-05-15 20:35:36 +02:00
parent 9f7c9099bc
commit 2d36b54b8d
6 changed files with 688 additions and 0 deletions
+221
View File
@@ -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).