# 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`.