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>
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). Capturesgame_nameand IP at insert time so audit rendering survives renames and purges.diplomail_recipients— one row per (message, recipient). Holds per-userread_at,deleted_at,delivered_at,notified_atstate. 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 byBACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES(default 256). Both limits live in the service layer so they can be tuned without a schema migration. body_langis filled at send time by the configureddetector.LanguageDetector(default:whatlanggo, body-only, ≥ 25 runes; shorter bodies stayund).
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 undertranslated_subject/translated_body; - on cache miss, the configured
translator.Translatoris invoked. A non-noop result is persisted and returned to the caller; the noop translator that ships with Stage D returnsengine == "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_languagematches the detectedbody_lang(or whose body language isund) getavailable_at = now()straight away and the push event fires during the request. - Recipients whose
preferred_languagediffers are inserted withavailable_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:
- Picks one distinct
(message_id, recipient_preferred_language)pair fromdiplomail_recipientswhereavailable_at IS NULLandnext_translation_attempt_atis unset or due. - Loads the source message, checks the translation cache.
- On cache hit → marks every pending recipient of the pair delivered and emits push.
- 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 vianext_translation_attempt_at, leaves pending.
- After
BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS(default5) 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:
*Storeover the shared Postgres pool;MembershipLookupadapter that walks the lobby cache for the active(game_id, user_id)row and stitches in the immutableaccounts.user_name;NotificationPublisheradapter that translates eachDiplomailNotificationinto anotification.Intentand routes it through*notification.Service.Submit.