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
+218
View File
@@ -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).