diff --git a/backend/docs/diplomail-translator-setup.md b/backend/docs/diplomail-translator-setup.md new file mode 100644 index 0000000..ecb05a7 --- /dev/null +++ b/backend/docs/diplomail-translator-setup.md @@ -0,0 +1,164 @@ +# LibreTranslate setup for diplomatic mail + +This document describes how to run the LibreTranslate backend that the +diplomatic-mail subsystem uses for body translation. The instructions +target three audiences: developers spinning up LibreTranslate +alongside `tools/local-dev`, operators preparing a real deployment, +and reviewers verifying the end-to-end translation flow by hand. + +## When you need LibreTranslate + +The diplomatic-mail worker runs unconditionally — `make up` and `make +test` both work without any translator. With +`BACKEND_DIPLOMAIL_TRANSLATOR_URL` unset, the noop translator +short-circuits the pipeline: messages are delivered in the original +language, and the inbox handler returns the original body to every +reader. + +You only need LibreTranslate when you want to exercise the cross- +language path: sender writes in language X, recipient's +`accounts.preferred_language` is Y, the worker is expected to fetch +a Y rendering. The pipeline is otherwise identical and unaware of +which engine is producing translations. + +## Running a local instance + +LibreTranslate ships a public Docker image at +`libretranslate/libretranslate`. The image is ~3 GB on first pull +because it bundles every supported language model; subsequent runs +reuse the layer cache. + +The simplest setup is a one-shot container: + +```bash +docker run --rm -d --name libretranslate \ + -p 5000:5000 \ + -e LT_LOAD_ONLY=en,ru \ + libretranslate/libretranslate:latest +``` + +The `LT_LOAD_ONLY` whitelist trims the loaded model set so the +container fits in ~600 MB of RAM. Drop the variable to load every +language pair LibreTranslate ships. + +LibreTranslate boots in ~30 seconds (cold) or ~5 seconds (warm +model cache). Wait until `curl -s http://localhost:5000/languages` +returns a JSON array before pointing backend at it. + +## Wiring backend at it + +Add three env vars to the backend process: + +``` +BACKEND_DIPLOMAIL_TRANSLATOR_URL=http://localhost:5000 +BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT=10s +BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS=5 +``` + +When backend lives inside the `tools/local-dev` Docker network and +LibreTranslate runs on the host, replace `localhost` with the host's +docker-bridge address (`http://host.docker.internal:5000` on +Docker Desktop; `http://172.17.0.1:5000` on a Linux bridge by +default). + +For a stack-internal deployment, drop LibreTranslate into the same +Docker compose file alongside backend and reach it by its service +name: + +```yaml +services: + libretranslate: + image: libretranslate/libretranslate:latest + environment: + LT_LOAD_ONLY: "en,ru" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:5000/languages"] + interval: 5s + timeout: 2s + retries: 12 + + backend: + environment: + BACKEND_DIPLOMAIL_TRANSLATOR_URL: "http://libretranslate:5000" + depends_on: + libretranslate: + condition: service_healthy +``` + +## Manual smoke test + +Once both services are up: + +1. Register two accounts via the public auth flow. Set the second + account's `preferred_language` to a value that differs from the + sender's writing language (e.g. sender writes in English, second + account is `ru`). +2. Create a private game with the first account, invite the second, + land both as active members. +3. Send a personal message: `POST /api/v1/user/games/{id}/mail/messages` + with the body in English. +4. Watch backend logs for the diplomail worker. After ~2 seconds you + should see `translator attempt succeeded` (or equivalent INFO + line) and the recipient flipped to `available_at`. +5. As the second account, fetch + `GET /api/v1/user/games/{id}/mail/messages/{message_id}`. The + response should carry both `body` (English original) and + `translated_body` (Russian) along with the `translation_lang` + and `translator` fields. + +## Operational notes + +- **Resource budget.** With `LT_LOAD_ONLY=en,ru` the container peaks + around 800 MB resident; with all languages, ~3 GB. Plan accordingly. +- **CPU.** LibreTranslate is CPU-bound. One translation of a 200- + word body takes ~200 ms on a modern x86 core; the diplomail worker + is single-threaded by design, so steady-state throughput is + `1 / avg_latency` per backend instance. +- **Outage behaviour.** A LibreTranslate outage stalls delivery of + pending pairs by at most ~31 seconds per pair (the worker's + exponential backoff schedule), then falls back to the original + body. Inbox listings never depend on the translator's + availability. +- **API key.** Backend does not send an API key. Self-hosted + deployments without `LT_API_KEYS` configured accept anonymous + POSTs by default, which matches our deployment posture + (LibreTranslate sits on the internal docker network, not + reachable from outside). +- **Models.** Adding a new target language is an operator-side + task: install the corresponding Argos model into the + LibreTranslate container (`argospm install …`) and either restart + the container or send a SIGHUP. The diplomail pipeline notices + the new language pair automatically — there is no allow-list + inside backend. + +## Troubleshooting + +- **`translator: do request: dial tcp ...: connect: connection refused`.** + LibreTranslate is not listening on the configured address. Verify + with `curl http://${URL}/languages`. On Docker setups, double- + check the bridge address discussion above. +- **`translator: libretranslate http 400`** in worker logs but the + language pair clearly exists. + Make sure the request used the two-letter codes (`en`, not + `en-US`). Backend normalises before sending; if you see a region + subtag in the log, file an issue against `internal/diplomail` — + the normalisation should be unconditional. +- **`translator: libretranslate http 503`.** + Container is still loading models. Wait for `/languages` to + respond `200`. The worker retries with backoff, so steady-state + recovers automatically. +- **Worker logs only "noop translator returned, delivering + fallback".** + `BACKEND_DIPLOMAIL_TRANSLATOR_URL` is empty in the backend + process. Confirm with `docker compose exec backend env | grep + DIPLOMAIL`. + +## Future work + +- Adding an OpenTelemetry counter and histogram for translator + outcomes is tracked in the diplomail package README; the metrics + will surface in Grafana once LibreTranslate is deployed. +- Email-alerting on prolonged outage (e.g. ≥ N consecutive failures + in M minutes) is planned through a new + `diplomail.translator.unhealthy` notification kind. Not wired + yet — current monitoring lives in zap logs. diff --git a/backend/internal/diplomail/README.md b/backend/internal/diplomail/README.md index 1af0733..581d673 100644 --- a/backend/internal/diplomail/README.md +++ b/backend/internal/diplomail/README.md @@ -136,6 +136,29 @@ through standard OpenTelemetry export — translation outcomes surface in `diplomail.worker` logs at Info / Warn levels; Grafana / Prometheus dashboards live outside this package. +### Multi-instance posture (known limitation) + +`PickPendingTranslationPair` intentionally drops `FOR UPDATE`: the +worker is single-threaded per process, and we did not want a slow +LibreTranslate HTTP call to keep a row-lock open. The cost is a +small window where two backend instances pulling at the same +moment can both claim the same pair: the cache-write side stays +clean (`INSERT … ON CONFLICT DO NOTHING`), but each instance will +publish its own push event to every recipient of the pair, so the +duplicate push is the visible failure mode. + +The current deployment runs a single backend instance and the +window does not exist. When the platform scales to multiple +instances, we will revisit the pickup query — either by holding +the lock through the HTTP call (with a short timeout to bound the +worst case) or by introducing a `claimed_at` column and a +short-lived advisory lease. The change is local to this package +and does not affect callers. + +For the LibreTranslate operational recipe — installing, wiring, +manual smoke test — see +[`backend/docs/diplomail-translator-setup.md`](../../docs/diplomail-translator-setup.md). + ## Push integration Every successful send emits a `diplomail.message.received` push diff --git a/backend/internal/diplomail/diplomail_e2e_test.go b/backend/internal/diplomail/diplomail_e2e_test.go index 074c9d9..3f85830 100644 --- a/backend/internal/diplomail/diplomail_e2e_test.go +++ b/backend/internal/diplomail/diplomail_e2e_test.go @@ -808,6 +808,36 @@ func TestDiplomailLifecycleMembershipKick(t *testing.T) { } } +// TestDiplomailWorkerTickOnEmptyQueueIsNoop confirms the async +// worker tolerates an empty pending queue: no error, no panic, no +// publisher events. Belt-and-suspenders for the case where backend +// starts, mounts the worker as an `app.Component`, and ticks before +// any user has sent mail. +func TestDiplomailWorkerTickOnEmptyQueueIsNoop(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + publisher := &recordingPublisher{} + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: &staticMembershipLookup{}, + Notification: publisher, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + worker := diplomail.NewWorker(svc) + for i := 0; i < 3; i++ { + if err := worker.Tick(ctx); err != nil { + t.Fatalf("tick %d on empty queue: %v", i, err) + } + } + if got := publisher.snapshot(); len(got) != 0 { + t.Fatalf("publisher fired %d events on empty queue", len(got)) + } +} + // TestDiplomailAsyncTranslationDelivery covers the Stage E flow: // 1. SendPersonal where recipient.preferred_language != body_lang // materialises a recipient with `AvailableAt == nil`; the inbox diff --git a/backend/internal/diplomail/translator/libretranslate_test.go b/backend/internal/diplomail/translator/libretranslate_test.go index efba08a..85bbe67 100644 --- a/backend/internal/diplomail/translator/libretranslate_test.go +++ b/backend/internal/diplomail/translator/libretranslate_test.go @@ -139,3 +139,35 @@ func TestLibreTranslateRequiresURL(t *testing.T) { t.Fatalf("expected error for empty URL") } } + +// TestLibreTranslateRejectsMalformedArray defends against a server +// that returns a partial / unexpected `translatedText` payload. The +// client must surface an error (not panic, not return a half-empty +// Result) so the worker can decide between retry and fallback. +func TestLibreTranslateRejectsMalformedArray(t *testing.T) { + t.Parallel() + cases := []struct { + name string + body string + }{ + {"single string", `{"translatedText": "only one"}`}, + {"array of one", `{"translatedText": ["only one"]}`}, + {"empty array", `{"translatedText": []}`}, + {"missing field", `{"foo":"bar"}`}, + } + for _, tc := range cases { + body := tc.body + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL}) + res, err := tr.Translate(context.Background(), "en", "ru", "subject", "body") + if err == nil { + t.Fatalf("expected error for malformed body %q, got %+v", body, res) + } + }) + } +} diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1a2eb36..054317b 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -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). diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 2a48ba0..77460dd 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -47,6 +47,7 @@ field-level-валидация — всё это лежит в нижнеуро 8. [Уведомления и почта](#8-уведомления-и-почта) 9. [Гео-сигнал](#9-гео-сигнал) 10. [Администрирование](#10-администрирование) +11. [Дипломатическая почта](#11-дипломатическая-почта) --- @@ -1193,3 +1194,220 @@ dead-letters и malformed notification-намерения. Они также м [ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox), [§12](ARCHITECTURE.md#12-notification-pipeline) и [Раздел 8](#8-уведомления-и-почта). + +--- + +## 11. Дипломатическая почта + +Сценарий описывает обмен сообщениями между игроками одной партии и +адресные / широковещательные уведомления от администрации и +владельца партии. Подсистема концептуально часть лобби (сообщения +переживают рестарты движка), но видна только внутри игрового UI; +в лобби виден лишь счётчик непрочитанного. + +### 11.1 Состав + +В составе: отправка персональной почты между активными участниками +одной партии; ответы на персональную почту; чтение, отметка +«прочитано» и soft-удаление своей входящей почты; адресные и +широковещательные уведомления от админов и владельцев; платный +broadcast от игроков; мультигеймовая admin-рассылка; ручная +массовая чистка почты завершённых партий; авто-перевод тела +сообщения на `preferred_language` получателя с кэшированием. + +Вне состава: чат вне партии, групповые чаты с участниками разных +партий, вложения, редактирование / отзыв сообщения, +end-to-end-шифрование. + +### 11.2 Модель сообщения + +Каждая отправка порождает ровно одну строку в `diplomail_messages` +плюс по одной строке на получателя в `diplomail_recipients`. +Broadcast на N получателей — одно сообщение и N recipient-строк; +строка перевода, если материализована, общая для всех получателей +с одинаковым целевым языком. + +`diplomail_messages.kind` — закрытое множество +`{personal, admin}`. Персональные сообщения допускают ответ +(получатель отправляет новое персональное сообщение); +admin-сообщения не предполагают ответа — это уведомления о смене +состояния или операторском действии. `sender_kind` — это +`{player, admin, system}` и определяет роль отправителя: игрок- +владелец партии (admin-уведомление от owner), site-администратор +(admin-уведомление от оператора) или собственно автомат лобби +(`game.paused`, `game.cancelled`, `membership.removed`, +`membership.blocked`). + +`broadcast_scope` фиксирует тип отправки: одному получателю +(`single`), рассылка по одной партии (`game_broadcast`) или +admin-рассылка по нескольким партиям (`multi_game_broadcast`). +Получатели multi_game-рассылки видят отдельную, независимо +удаляемую запись inbox в каждой адресованной партии. + +Снимки сохраняются прямо в строках сообщения и получателя: +`game_name`, `sender_username`, `sender_ip` и на стороне +получателя — `recipient_user_name`, `recipient_race_name` и +`recipient_preferred_language`. Они переживают переименование +партии, отзыв членства, soft-delete аккаунта и итоговый +bulk-purge — admin observability отрисовывается корректно даже +после исчезновения «живых» строк. + +Тела и subject — plain UTF-8 текст. Сервер не парсит, не санитайзит +и не экранирует HTML; клиент рендерит тело через `textContent`. +Максимум размера тела — `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` +(по умолчанию `4096`); максимум для subject — +`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (по умолчанию `256`). + +### 11.3 Отправка почты + +Персональная отправка требует активного членства в партии и от +отправителя, и от получателя. Игроки free-tier отправляют одно +персональное сообщение за запрос. Игрокам платных тиров доступен +и игровой broadcast — одна отправка на всех остальных активных +участников партии; ответы возвращаются автору broadcast. + +Владельцы (приватных партий) и site-администраторы отправляют +admin-уведомления. Endpoint владельца находится на user-поверхности +(аутентификация по `X-User-ID`, проверка владельца в обработчике); +endpoint администратора — на admin-поверхности (HTTP Basic). Оба +принимают `target=user` (один получатель) или `target=all` +(broadcast в одной партии). Site-администратору доступен +дополнительный multi-game endpoint, принимающий +`scope=selected` со списком game_id или `scope=all_running` — +перебор всех партий в нетерминальных состояниях. + +Состав получателей broadcast параметризуется полем `recipients`: +`active` (по умолчанию), `active_and_removed` или `all_members` +(включает блокированных, для аудит-уведомлений). Собственная +recipient-строка автора broadcast не создаётся. + +Player-broadcast от free-tier пользователя отклоняется кодом +`403 forbidden`. + +### 11.4 Получение почты + +Получатель видит сообщение в своём inbox только после того, как +асинхронный worker перевода обработал его (см. +[§11.6](#116-перевод)). До этого строка невидима: не выводится в +inbox-листинге, не учитывается в badge непрочитанного, push-событие +не доставляется. Это исключает ситуацию «строка появилась, перевод +не подъехал, badge мигает». + +Badge непрочитанного в лобби агрегируется по партиям. Endpoint +`/api/v1/user/lobby/mail/unread-counts` возвращает по одной записи +на каждую партию с ненулевым unread плюс общий total; UI лобби +отображает общий badge и плитки по партиям, не раскрывая самих +сообщений. + +Mark-read идемпотентен. Soft-удаление требует, чтобы сообщение уже +было помечено прочитанным — клиент не может стереть неоткрытое +сообщение. Soft-удаление действует только для одного получателя: +строка самого сообщения переживает удаление вплоть до admin +bulk-purge всей почты соответствующей партии. + +Ответ message-detail содержит и оригинальное тело, и (если есть +кэш) перевод; UI по умолчанию показывает перевод и предлагает +переключение «показать оригинал». + +### 11.5 Хуки жизненного цикла + +Три транзитных перехода в лобби порождают system mail в inbox +затронутых игроков: + +- **Пауза / отмена игры.** Когда автомат партии проходит через + `paused` или `cancelled`, лобби эмитит system-сообщение всем + активным членам. Текст рендерится сервером по шаблону, чтобы + игрок, открывший inbox позже, нашёл объяснение даже без + одновременной push-сессии. +- **Удаление / блокировка членства.** Сам-выход, удаление + владельцем и admin-бан порождают system-сообщение только для + затронутого игрока. Это письмо переживает переход членства в + `removed` / `blocked` — игрок сохраняет к нему read-доступ + навсегда (правило soft-доступа). + +Будущее удаление по неактивности должно вызывать тот же publisher, +чтобы объяснение дошло до затронутого игрока; README пакета +прибивает этот контракт для следующего реализатора. + +### 11.6 Перевод + +`diplomail_messages.body_lang` заполняется на стороне сервера в +момент отправки внутрипроцессным детектором языка, работающим +только по телу. Subject наследует язык тела для ключа кэша +перевода. Когда детектор не может уверенно классифицировать тело +(слишком короткое, пустое, смешанные скрипты), значение — +плейсхолдер BCP 47 `und` ("неопределённый"), и pipeline перевода +обходится стороной — получатели видят оригинал. + +Перевод выполняется асинхронно. Каждая recipient-строка содержит +снимок `preferred_language` получателя плюс метку `available_at`. +Получатель, чей язык совпадает с детектированным `body_lang` (или +чей preferred_language пуст / язык тела — `und`), получает +`available_at = now()` сразу при вставке, и push-событие +отправляется в момент `POST`. Получатель с отличающимся языком +вставляется с `available_at IS NULL` и ждёт worker. + +Worker (`internal/diplomail.Worker`) тикает каждые +`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (по умолчанию `2s`) и +обрабатывает по одной паре `(message_id, target_lang)` за тик. Он +сначала смотрит в кэш переводов; на miss дёргает настроенный +`Translator`. Дефолт production-сборки — LibreTranslate HTTP +клиент; пустой `BACKEND_DIPLOMAIL_TRANSLATOR_URL` оставляет +noop-translator, который доставляет сообщение в оригинале. + +Исходы перевода: + +- **Успех.** Строка в `diplomail_translations` создаётся (или + переиспользуется, если параллельная попытка успела раньше), + все pending-получатели пары переключаются на + `available_at = now()`, и по каждому отправляется push. +- **Неподдерживаемая пара языков** (HTTP 400 от LibreTranslate). + Строка перевода не сохраняется; получатели доставляются с + оригинальным телом. Последующие чтения возвращают оригинал. +- **Транзитный сбой** (timeout, 5xx, network error). Счётчик + попыток увеличивается, следующая попытка планируется по + экспоненциальному backoff `1s → 2s → 4s → 8s → 16s` + (с потолком 60s). После + `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (по умолчанию `5`) + worker fallback'ит на оригинальное тело. Длительный отказ + переводчика тормозит доставку максимум на ~30 секунд на пару + до того, как получатель увидит оригинал. + +Кэш переводов общий: broadcast на N получателей с одинаковым +preferred_language порождает одну строку кэша и один вызов +переводчика, не N. + +### 11.7 Хранение и purge + +Сообщения живут в `diplomail_messages`; per-recipient state — в +`diplomail_recipients` с FK-каскадом на сообщение; переводы — в +`diplomail_translations` тоже с каскадом. IP-адрес отправителя +снимается из `X-Forwarded-For` (форвардит gateway) и хранится в +сообщении для сохранения доказательств. + +Автоматического retention нет. Admin bulk-purge endpoint удаляет +все сообщения, чья партия завершилась более `older_than_years` +лет назад (минимум `1`); каскад удаляет recipient- и +translation-строки той же транзакцией. + +### 11.8 Видимость для оператора + +Admin-поверхность экспонирует постраничный листинг всех сообщений +(`/api/v1/admin/mail/messages`) с фильтрами по `game_id`, `kind` +и `sender_kind`. Bulk-purge endpoint +(`/api/v1/admin/mail/cleanup`) принимает порог +`older_than_years`. Per-game admin-отправки и multi-game +broadcast'ы доступны через `/api/v1/admin/games/{game_id}/mail` +и `/api/v1/admin/mail/broadcast`. + +### 11.9 Перекрёстные ссылки + +- Обзор пакета и карта стадий: + [`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md). +- Рецепт развёртывания LibreTranslate для локальной разработки: + [`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md). +- Детали хранения: + [ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem). +- Push-транспорт для событий доставки: [Раздел 7](#7-канал-push). +- Notification-каталог: kind `diplomail.message.received`: + [`backend/README.md` §10](../backend/README.md#10-notification-catalog).