7b43ce5844
Phase 28's in-game mail UI groups personal threads by the other party's race. To support that without an extra membership-listing RPC, the diplomail subsystem now: - accepts `recipient_race_name` on `POST /messages` and `POST /admin` (target=user) as an alternative to `recipient_user_id`; the service resolves it via the existing `Memberships.ListMembers(gameID, "active")` and rejects with `forbidden` when the matching member is no longer active; - snapshots `diplomail_messages.sender_race_name` at send time for every player sender (admin / system rows stay NULL). The UI keys per-race threading on this column. Schema, openapi, README, and a focused e2e test for the new path (happy path + dual / missing / unknown / kicked errors) land in this commit; the gateway + UI legs follow in subsequent commits on this branch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
218 lines
10 KiB
Markdown
218 lines
10 KiB
Markdown
# 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`](../../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`.
|