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>
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). 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.
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.