Files
Ilia Denisov 2d36b54b8d
Tests · Integration / integration (pull_request) Successful in 1m37s
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 2m4s
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>
2026-05-15 20:35:36 +02:00
..

diplomail

diplomail owns the diplomatic-mail subsystem of the Galaxy backend service. Messages live in the lobby-side domain (their storage and lifecycle are tied to a game), but they are surfaced inside the game UI — the lobby exposes only an unread-count badge per game.

Stages

The package ships in four staged increments. Stage A is the surface described below; the remaining stages add admin / system mail, lifecycle hooks, paid-tier broadcast, multi-game broadcast, bulk purge, and the language-detection / translation cache.

Stage Scope Status
A Schema, personal single-recipient send / read / delete, unread badge, push event with body-language und shipped
B Owner / admin sends + lifecycle hooks (paused, cancelled, kick); strict soft-access for kicked players shipped
C Paid-tier personal broadcast + admin multi-game broadcast + bulk purge + admin observability shipped
D Body-language detection (whatlanggo) + translation cache + lazy per-read translator dispatch shipped
E LibreTranslate HTTP client + async translation worker with exponential backoff + delivery gating on translation completion shipped

Tables

Three Postgres tables in the backend schema:

  • diplomail_messages — one row per send (personal, admin, or system). Captures game_name and IP at insert time so audit rendering survives renames and purges.
  • diplomail_recipients — one row per (message, recipient). Holds per-user read_at, deleted_at, delivered_at, notified_at state. Snapshot fields (recipient_user_name, recipient_race_name) are captured at insert time and survive membership revocation.
  • diplomail_translations — cached per (message, target_lang) rendering. One translation is reused across every recipient that asks for that language.

Permissions

Action Caller Pre-conditions
Send personal user active membership in game; recipient is active member
Paid-tier broadcast paid-tier user active membership; recipients = every other active member
Send admin (single user) game owner OR site admin recipient is any-status member of the game
Send admin (broadcast) game owner OR site admin recipient scope ∈ active / active_and_removed / all_members; sender excluded
Multi-game admin broadcast site admin scope selected (with game_ids) or all_running
Bulk purge site admin older_than_years >= 1; targets games with terminal status finished more than N years ago
Read message the recipient row exists in diplomail_recipients(message_id, user_id); non-active members see admin-kind only
Mark read the recipient row exists; idempotent if already marked
Soft delete the recipient read_at IS NOT NULL (open-then-delete, item 10)

Stage D will add body-language detection (whatlanggo) and the translation cache + async worker.

System mail is produced internally by lobby lifecycle hooks: Service.transition() emits game.paused / game.cancelled system mail to every active member; Service.changeMembershipStatus / Service.AdminBanMember emit membership.removed / membership.blocked system mail addressed to the affected user.

Content rules

  • Body is plain UTF-8 text. The server does not parse, sanitise, or escape HTML — the UI renders messages via textContent.
  • Body length is capped by BACKEND_DIPLOMAIL_MAX_BODY_BYTES (default 4096). Subject length is capped by BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES (default 256). Both limits live in the service layer so they can be tuned without a schema migration.
  • body_lang is filled at send time by the configured detector.LanguageDetector (default: whatlanggo, body-only, ≥ 25 runes; shorter bodies stay und).

Translation

Stage D adds a lazy translation cache. When a recipient reads a message through GET /api/v1/user/games/{game_id}/mail/messages/{id}, the handler resolves the caller's accounts.preferred_language and asks Service.GetMessage(…, targetLang) to attach a translation:

  • on cache hit (row in diplomail_translations), the rendering is returned directly under translated_subject / translated_body;
  • on cache miss, the configured translator.Translator is invoked. A non-noop result is persisted and returned to the caller; the noop translator that ships with Stage D returns engine == "noop", which is treated as "translation unavailable" and the caller falls back to the original body.

The inbox listing (/inbox) reuses cached translations but never calls the translator on miss — bulk listings stay fast even when a real translator (LibreTranslate, SaaS engine) introduces I/O cost.

Future work plugs a real translator.Translator (LibreTranslate HTTP client is the documented next step) without touching the rest of the system.

Async translation (Stage E)

Stage E switches the translation pipeline from "lazy at read" to "async at send". The send path stays synchronous from the caller's perspective: the message and recipient rows are inserted in one transaction. What changes is delivery semantics:

  • Recipients whose preferred_language matches the detected body_lang (or whose body language is und) get available_at = now() straight away and the push event fires during the request.
  • Recipients whose preferred_language differs are inserted with available_at IS NULL. They are not visible in inbox, unread count, or push events until the worker translates the message.

The worker (internal/diplomail.Worker, started as an app.Component in cmd/backend/main) ticks once every BACKEND_DIPLOMAIL_WORKER_INTERVAL (default 2s). Each tick:

  1. Picks one distinct (message_id, recipient_preferred_language) pair from diplomail_recipients where available_at IS NULL and next_translation_attempt_at is unset or due.
  2. Loads the source message, checks the translation cache.
  3. On cache hit → marks every pending recipient of the pair delivered and emits push.
  4. On cache miss → asks the configured Translator:
    • success → caches the translation, marks delivered, push;
    • HTTP 400 (unsupported pair) → marks delivered without a translation (fallback to original);
    • other failure → bumps translation_attempts, schedules the retry via next_translation_attempt_at, leaves pending.
  5. After BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS (default 5) the worker falls back to delivering the original body so a prolonged LibreTranslate outage does not strand messages.

Retry backoff is exponential 1s → 2s → 4s → 8s → 16s (capped at 60s) per pair. Operators monitor the LibreTranslate dependency 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.

Push integration

Every successful send emits a diplomail.message.received push intent through the existing notification pipeline. The catalog entry limits delivery to the push channel — email is intentionally absent; the inbox endpoint is the durable fallback for offline users. The payload includes the recipient's freshly recomputed unread count for the lobby badge and for the in-game header.

Lifecycle hooks (Stage B)

The lobby module is the producer of system mail. Stage B will add a DiplomailPublisher collaborator on lobby.Service and call it on paused / cancelled transitions and on BlockMembership / AdminBanMember. The publisher constructs a kind='admin', sender_kind='system' message with a templated body; the recipient receives the durable copy in their inbox even after the membership is revoked.

If a future stage adds inactivity-based player removal at the lobby sweeper, that path must call the same publisher so the kicked player has the explanation in their inbox.

Wiring

cmd/backend/main.go constructs *diplomail.Service with three collaborators:

  • *Store over the shared Postgres pool;
  • MembershipLookup adapter that walks the lobby cache for the active (game_id, user_id) row and stitches in the immutable accounts.user_name;
  • NotificationPublisher adapter that translates each DiplomailNotification into a notification.Intent and routes it through *notification.Service.Submit.