Files
galaxy-game/backend/internal/diplomail
Ilia Denisov 57d2286f5e
Tests · Go / test (push) Successful in 2m5s
Tests · Go / test (pull_request) Successful in 2m10s
Tests · Integration / integration (pull_request) Successful in 1m54s
Tests · UI / test (pull_request) Successful in 2m53s
Phase 28 (Step 3a): /sent returns full message detail per recipient
Phase 28's in-game mail UI threads sent messages by the recipient
race name, so the bulk `/sent` endpoint now returns the same
`UserMailMessageDetail` shape as `/inbox` — single sends contribute
one row per message, broadcasts contribute one row per addressee
and the UI collapses them by `message_id` into a stand-alone item.

- `Store.ListSent` / `Service.ListSent` switched from `[]Message`
  to `[]InboxEntry`. SQL grows an INNER JOIN with
  `diplomail_recipients`.
- Handler emits `userMailMessageDetailWire` items; the deprecated
  `userMailSentSummaryWire` is removed.
- `openapi.yaml`: `UserMailSentList.items` now reference
  `UserMailMessageDetail`; the standalone `UserMailSentSummary`
  schema is dropped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:27:39 +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. The sender_race_name column snapshots the sender's race in the game at send time when the sender is a player with an active membership; the in-game UI keys per-race thread grouping on this column.
  • 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).

Recipient selection

POST /messages and POST /admin (when target="user") accept the recipient identifier in one of two shapes:

  • recipient_user_id (uuid) — explicit user lookup; the recipient may be any active member of the game.
  • recipient_race_name (string) — resolves to the active member with this race name in the game. Race names are unique by lobby invariant; lobby-removed and blocked members cannot be reached through the race-name shortcut (they no longer appear in the active scope). Exactly one of the two fields must be supplied; supplying both, or neither, returns invalid_request.

The race-name path lets the in-game UI compose mail directly off the engine's report.races[] view without an extra membership round-trip.

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.