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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user