Files
galaxy-game/backend/internal/diplomail/README.md
T
Ilia Denisov 9f7c9099bc
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · Integration / integration (pull_request) Successful in 1m37s
diplomail (Stage E): LibreTranslate client + async translation worker
Synchronous translation on read (Stage D) blocks the HTTP handler on
translator I/O. Stage E switches to "send moments-fast, deliver
when translated": recipients whose preferred_language differs from
the detected body_lang are inserted with available_at=NULL, and an
async worker turns them on once a LibreTranslate call materialises
the cache row (or fails terminally after 5 retries).

Schema delta on diplomail_recipients: available_at,
translation_attempts, next_translation_attempt_at, plus a snapshot
recipient_preferred_language so the worker queries do not need a
join. Read paths (ListInbox, GetMessage, UnreadCount) filter on
available_at IS NOT NULL. Push fan-out is moved from Service to the
worker so the recipient only sees the toast when the inbox row is
actually visible.

Translator backend is now a configurable choice: empty
BACKEND_DIPLOMAIL_TRANSLATOR_URL → noop (deliver original);
populated → LibreTranslate HTTP client. Per-attempt timeout, max
attempts, and worker interval all live in DiplomailConfig. The HTTP
client itself is unit-tested via httptest (happy path, BCP47
normalisation, unsupported pair, 5xx, identical src/dst, missing
URL); worker delivery + fallback paths are covered by the
testcontainers-backed e2e tests in diplomail_e2e_test.go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 20:15:28 +02:00

8.3 KiB

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.

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.