diplomail (Stage F): docs + edge-case tests + LibreTranslate recipe
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>
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
# LibreTranslate setup for diplomatic mail
|
||||||
|
|
||||||
|
This document describes how to run the LibreTranslate backend that the
|
||||||
|
diplomatic-mail subsystem uses for body translation. The instructions
|
||||||
|
target three audiences: developers spinning up LibreTranslate
|
||||||
|
alongside `tools/local-dev`, operators preparing a real deployment,
|
||||||
|
and reviewers verifying the end-to-end translation flow by hand.
|
||||||
|
|
||||||
|
## When you need LibreTranslate
|
||||||
|
|
||||||
|
The diplomatic-mail worker runs unconditionally — `make up` and `make
|
||||||
|
test` both work without any translator. With
|
||||||
|
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` unset, the noop translator
|
||||||
|
short-circuits the pipeline: messages are delivered in the original
|
||||||
|
language, and the inbox handler returns the original body to every
|
||||||
|
reader.
|
||||||
|
|
||||||
|
You only need LibreTranslate when you want to exercise the cross-
|
||||||
|
language path: sender writes in language X, recipient's
|
||||||
|
`accounts.preferred_language` is Y, the worker is expected to fetch
|
||||||
|
a Y rendering. The pipeline is otherwise identical and unaware of
|
||||||
|
which engine is producing translations.
|
||||||
|
|
||||||
|
## Running a local instance
|
||||||
|
|
||||||
|
LibreTranslate ships a public Docker image at
|
||||||
|
`libretranslate/libretranslate`. The image is ~3 GB on first pull
|
||||||
|
because it bundles every supported language model; subsequent runs
|
||||||
|
reuse the layer cache.
|
||||||
|
|
||||||
|
The simplest setup is a one-shot container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -d --name libretranslate \
|
||||||
|
-p 5000:5000 \
|
||||||
|
-e LT_LOAD_ONLY=en,ru \
|
||||||
|
libretranslate/libretranslate:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
The `LT_LOAD_ONLY` whitelist trims the loaded model set so the
|
||||||
|
container fits in ~600 MB of RAM. Drop the variable to load every
|
||||||
|
language pair LibreTranslate ships.
|
||||||
|
|
||||||
|
LibreTranslate boots in ~30 seconds (cold) or ~5 seconds (warm
|
||||||
|
model cache). Wait until `curl -s http://localhost:5000/languages`
|
||||||
|
returns a JSON array before pointing backend at it.
|
||||||
|
|
||||||
|
## Wiring backend at it
|
||||||
|
|
||||||
|
Add three env vars to the backend process:
|
||||||
|
|
||||||
|
```
|
||||||
|
BACKEND_DIPLOMAIL_TRANSLATOR_URL=http://localhost:5000
|
||||||
|
BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT=10s
|
||||||
|
BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS=5
|
||||||
|
```
|
||||||
|
|
||||||
|
When backend lives inside the `tools/local-dev` Docker network and
|
||||||
|
LibreTranslate runs on the host, replace `localhost` with the host's
|
||||||
|
docker-bridge address (`http://host.docker.internal:5000` on
|
||||||
|
Docker Desktop; `http://172.17.0.1:5000` on a Linux bridge by
|
||||||
|
default).
|
||||||
|
|
||||||
|
For a stack-internal deployment, drop LibreTranslate into the same
|
||||||
|
Docker compose file alongside backend and reach it by its service
|
||||||
|
name:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
libretranslate:
|
||||||
|
image: libretranslate/libretranslate:latest
|
||||||
|
environment:
|
||||||
|
LT_LOAD_ONLY: "en,ru"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:5000/languages"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 2s
|
||||||
|
retries: 12
|
||||||
|
|
||||||
|
backend:
|
||||||
|
environment:
|
||||||
|
BACKEND_DIPLOMAIL_TRANSLATOR_URL: "http://libretranslate:5000"
|
||||||
|
depends_on:
|
||||||
|
libretranslate:
|
||||||
|
condition: service_healthy
|
||||||
|
```
|
||||||
|
|
||||||
|
## Manual smoke test
|
||||||
|
|
||||||
|
Once both services are up:
|
||||||
|
|
||||||
|
1. Register two accounts via the public auth flow. Set the second
|
||||||
|
account's `preferred_language` to a value that differs from the
|
||||||
|
sender's writing language (e.g. sender writes in English, second
|
||||||
|
account is `ru`).
|
||||||
|
2. Create a private game with the first account, invite the second,
|
||||||
|
land both as active members.
|
||||||
|
3. Send a personal message: `POST /api/v1/user/games/{id}/mail/messages`
|
||||||
|
with the body in English.
|
||||||
|
4. Watch backend logs for the diplomail worker. After ~2 seconds you
|
||||||
|
should see `translator attempt succeeded` (or equivalent INFO
|
||||||
|
line) and the recipient flipped to `available_at`.
|
||||||
|
5. As the second account, fetch
|
||||||
|
`GET /api/v1/user/games/{id}/mail/messages/{message_id}`. The
|
||||||
|
response should carry both `body` (English original) and
|
||||||
|
`translated_body` (Russian) along with the `translation_lang`
|
||||||
|
and `translator` fields.
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- **Resource budget.** With `LT_LOAD_ONLY=en,ru` the container peaks
|
||||||
|
around 800 MB resident; with all languages, ~3 GB. Plan accordingly.
|
||||||
|
- **CPU.** LibreTranslate is CPU-bound. One translation of a 200-
|
||||||
|
word body takes ~200 ms on a modern x86 core; the diplomail worker
|
||||||
|
is single-threaded by design, so steady-state throughput is
|
||||||
|
`1 / avg_latency` per backend instance.
|
||||||
|
- **Outage behaviour.** A LibreTranslate outage stalls delivery of
|
||||||
|
pending pairs by at most ~31 seconds per pair (the worker's
|
||||||
|
exponential backoff schedule), then falls back to the original
|
||||||
|
body. Inbox listings never depend on the translator's
|
||||||
|
availability.
|
||||||
|
- **API key.** Backend does not send an API key. Self-hosted
|
||||||
|
deployments without `LT_API_KEYS` configured accept anonymous
|
||||||
|
POSTs by default, which matches our deployment posture
|
||||||
|
(LibreTranslate sits on the internal docker network, not
|
||||||
|
reachable from outside).
|
||||||
|
- **Models.** Adding a new target language is an operator-side
|
||||||
|
task: install the corresponding Argos model into the
|
||||||
|
LibreTranslate container (`argospm install …`) and either restart
|
||||||
|
the container or send a SIGHUP. The diplomail pipeline notices
|
||||||
|
the new language pair automatically — there is no allow-list
|
||||||
|
inside backend.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- **`translator: do request: dial tcp ...: connect: connection refused`.**
|
||||||
|
LibreTranslate is not listening on the configured address. Verify
|
||||||
|
with `curl http://${URL}/languages`. On Docker setups, double-
|
||||||
|
check the bridge address discussion above.
|
||||||
|
- **`translator: libretranslate http 400`** in worker logs but the
|
||||||
|
language pair clearly exists.
|
||||||
|
Make sure the request used the two-letter codes (`en`, not
|
||||||
|
`en-US`). Backend normalises before sending; if you see a region
|
||||||
|
subtag in the log, file an issue against `internal/diplomail` —
|
||||||
|
the normalisation should be unconditional.
|
||||||
|
- **`translator: libretranslate http 503`.**
|
||||||
|
Container is still loading models. Wait for `/languages` to
|
||||||
|
respond `200`. The worker retries with backoff, so steady-state
|
||||||
|
recovers automatically.
|
||||||
|
- **Worker logs only "noop translator returned, delivering
|
||||||
|
fallback".**
|
||||||
|
`BACKEND_DIPLOMAIL_TRANSLATOR_URL` is empty in the backend
|
||||||
|
process. Confirm with `docker compose exec backend env | grep
|
||||||
|
DIPLOMAIL`.
|
||||||
|
|
||||||
|
## Future work
|
||||||
|
|
||||||
|
- Adding an OpenTelemetry counter and histogram for translator
|
||||||
|
outcomes is tracked in the diplomail package README; the metrics
|
||||||
|
will surface in Grafana once LibreTranslate is deployed.
|
||||||
|
- Email-alerting on prolonged outage (e.g. ≥ N consecutive failures
|
||||||
|
in M minutes) is planned through a new
|
||||||
|
`diplomail.translator.unhealthy` notification kind. Not wired
|
||||||
|
yet — current monitoring lives in zap logs.
|
||||||
@@ -136,6 +136,29 @@ through standard OpenTelemetry export — translation outcomes
|
|||||||
surface in `diplomail.worker` logs at Info / Warn levels;
|
surface in `diplomail.worker` logs at Info / Warn levels;
|
||||||
Grafana / Prometheus dashboards live outside this package.
|
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
|
## Push integration
|
||||||
|
|
||||||
Every successful send emits a `diplomail.message.received` push
|
Every successful send emits a `diplomail.message.received` push
|
||||||
|
|||||||
@@ -808,6 +808,36 @@ func TestDiplomailLifecycleMembershipKick(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDiplomailWorkerTickOnEmptyQueueIsNoop confirms the async
|
||||||
|
// worker tolerates an empty pending queue: no error, no panic, no
|
||||||
|
// publisher events. Belt-and-suspenders for the case where backend
|
||||||
|
// starts, mounts the worker as an `app.Component`, and ticks before
|
||||||
|
// any user has sent mail.
|
||||||
|
func TestDiplomailWorkerTickOnEmptyQueueIsNoop(t *testing.T) {
|
||||||
|
db := startPostgres(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
publisher := &recordingPublisher{}
|
||||||
|
svc := diplomail.NewService(diplomail.Deps{
|
||||||
|
Store: diplomail.NewStore(db),
|
||||||
|
Memberships: &staticMembershipLookup{},
|
||||||
|
Notification: publisher,
|
||||||
|
Config: config.DiplomailConfig{
|
||||||
|
MaxBodyBytes: 4096,
|
||||||
|
MaxSubjectBytes: 256,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
worker := diplomail.NewWorker(svc)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := worker.Tick(ctx); err != nil {
|
||||||
|
t.Fatalf("tick %d on empty queue: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := publisher.snapshot(); len(got) != 0 {
|
||||||
|
t.Fatalf("publisher fired %d events on empty queue", len(got))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestDiplomailAsyncTranslationDelivery covers the Stage E flow:
|
// TestDiplomailAsyncTranslationDelivery covers the Stage E flow:
|
||||||
// 1. SendPersonal where recipient.preferred_language != body_lang
|
// 1. SendPersonal where recipient.preferred_language != body_lang
|
||||||
// materialises a recipient with `AvailableAt == nil`; the inbox
|
// materialises a recipient with `AvailableAt == nil`; the inbox
|
||||||
|
|||||||
@@ -139,3 +139,35 @@ func TestLibreTranslateRequiresURL(t *testing.T) {
|
|||||||
t.Fatalf("expected error for empty URL")
|
t.Fatalf("expected error for empty URL")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLibreTranslateRejectsMalformedArray defends against a server
|
||||||
|
// that returns a partial / unexpected `translatedText` payload. The
|
||||||
|
// client must surface an error (not panic, not return a half-empty
|
||||||
|
// Result) so the worker can decide between retry and fallback.
|
||||||
|
func TestLibreTranslateRejectsMalformedArray(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"single string", `{"translatedText": "only one"}`},
|
||||||
|
{"array of one", `{"translatedText": ["only one"]}`},
|
||||||
|
{"empty array", `{"translatedText": []}`},
|
||||||
|
{"missing field", `{"foo":"bar"}`},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
body := tc.body
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL})
|
||||||
|
res, err := tr.Translate(context.Background(), "en", "ru", "subject", "body")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error for malformed body %q, got %+v", body, res)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ same scenario when they participate in the same business flow.
|
|||||||
8. [Notifications and mail](#8-notifications-and-mail)
|
8. [Notifications and mail](#8-notifications-and-mail)
|
||||||
9. [Geo signal](#9-geo-signal)
|
9. [Geo signal](#9-geo-signal)
|
||||||
10. [Administration](#10-administration)
|
10. [Administration](#10-administration)
|
||||||
|
11. [Diplomatic mail](#11-diplomatic-mail)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1153,3 +1154,223 @@ counters are populated by the runtime, and operators can only read.
|
|||||||
- Mail outbox and notification dispatcher:
|
- Mail outbox and notification dispatcher:
|
||||||
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
||||||
[§12](ARCHITECTURE.md#12-notification-pipeline) and [Section 8](#8-notifications-and-mail).
|
[§12](ARCHITECTURE.md#12-notification-pipeline) and [Section 8](#8-notifications-and-mail).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Diplomatic mail
|
||||||
|
|
||||||
|
This scenario covers the player-to-player and admin-to-player
|
||||||
|
messaging system exposed inside a game. The system is conceptually
|
||||||
|
part of the lobby (messages outlive game runtime restarts), but
|
||||||
|
they are surfaced exclusively inside the in-game UI; the lobby
|
||||||
|
surfaces only an unread counter.
|
||||||
|
|
||||||
|
### 11.1 Scope
|
||||||
|
|
||||||
|
In scope: sending personal mail between active members of the same
|
||||||
|
game; replying to personal mail; reading and marking-read /
|
||||||
|
soft-deleting one's own incoming mail; admin / owner notifications
|
||||||
|
addressed to one player or broadcast to a game; paid-tier player
|
||||||
|
broadcasts; site-admin multi-game broadcasts; bulk purge of
|
||||||
|
messages tied to terminated games; auto-translation of the body
|
||||||
|
into the recipient's `preferred_language` with a cached rendering.
|
||||||
|
|
||||||
|
Out of scope: out-of-game chat, group chats spanning multiple
|
||||||
|
games, file attachments, message editing or unsend, end-to-end
|
||||||
|
encryption.
|
||||||
|
|
||||||
|
### 11.2 The message model
|
||||||
|
|
||||||
|
Every send produces exactly one row in `diplomail_messages` plus
|
||||||
|
one row per recipient in `diplomail_recipients`. A broadcast to N
|
||||||
|
recipients is one message + N recipient rows; the translation row,
|
||||||
|
when materialised, is shared across every recipient with the same
|
||||||
|
target language.
|
||||||
|
|
||||||
|
`diplomail_messages.kind` is the closed set
|
||||||
|
`{personal, admin}`. Personal messages are replyable (the
|
||||||
|
recipient sends back a new personal message); admin messages are
|
||||||
|
non-replyable acknowledgements of a state change or operator
|
||||||
|
action. `sender_kind` is `{player, admin, system}` and identifies
|
||||||
|
the originator's role: a player owns the game (admin notification
|
||||||
|
from owner), a site administrator pushed it (admin notification
|
||||||
|
from operator), or the lobby state machine produced it
|
||||||
|
(`game.paused`, `game.cancelled`, `membership.removed`,
|
||||||
|
`membership.blocked`).
|
||||||
|
|
||||||
|
`broadcast_scope` records whether the send was a single-recipient
|
||||||
|
delivery (`single`), a one-game broadcast (`game_broadcast`), or a
|
||||||
|
cross-game admin broadcast (`multi_game_broadcast`). Recipients of
|
||||||
|
a multi-game broadcast see one independently-deletable inbox entry
|
||||||
|
per game they were addressed in.
|
||||||
|
|
||||||
|
Per-row snapshots travel with each message: `game_name`,
|
||||||
|
`sender_username`, `sender_ip`, plus on the recipient row
|
||||||
|
`recipient_user_name`, `recipient_race_name`, and
|
||||||
|
`recipient_preferred_language`. These survive game-name changes,
|
||||||
|
membership revocation, account soft-delete, and the eventual
|
||||||
|
bulk-purge cascade — they let the admin observability surface
|
||||||
|
render correctly long after the live rows have moved on.
|
||||||
|
|
||||||
|
Bodies and subjects are plain UTF-8 text. The server does not
|
||||||
|
parse, sanitise, or escape HTML; the client renders bodies through
|
||||||
|
`textContent`. Maximum body size is
|
||||||
|
`BACKEND_DIPLOMAIL_MAX_BODY_BYTES` (default `4096`); maximum
|
||||||
|
subject size is `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default
|
||||||
|
`256`).
|
||||||
|
|
||||||
|
### 11.3 Sending mail
|
||||||
|
|
||||||
|
Personal sends require active membership in the game for both the
|
||||||
|
sender and the recipient. Free-tier players send one personal
|
||||||
|
message per request. Paid-tier players additionally have access to
|
||||||
|
a game-scoped broadcast that addresses every other active member
|
||||||
|
in one call; replies fan back to the broadcast author.
|
||||||
|
|
||||||
|
Game owners (of private games) and site administrators send admin
|
||||||
|
notifications. The owner endpoint lives under the user surface
|
||||||
|
(authenticated by `X-User-ID`, owner check enforced); the admin
|
||||||
|
endpoint lives under the admin surface (HTTP Basic). Both accept
|
||||||
|
`target=user` (single recipient) or `target=all` (game broadcast).
|
||||||
|
Site administrators additionally have a multi-game endpoint that
|
||||||
|
accepts `scope=selected` with a list of game ids or
|
||||||
|
`scope=all_running` that enumerates every game with non-terminal
|
||||||
|
status.
|
||||||
|
|
||||||
|
Broadcast composition is parameterised by `recipients`: `active`
|
||||||
|
(default), `active_and_removed`, or `all_members` (includes
|
||||||
|
blocked rows for audit-style mail). The broadcast author's own
|
||||||
|
recipient row is never created.
|
||||||
|
|
||||||
|
A paid-tier broadcast is rejected with `403 forbidden` when the
|
||||||
|
caller's entitlement tier is `free`.
|
||||||
|
|
||||||
|
### 11.4 Receiving mail
|
||||||
|
|
||||||
|
The recipient sees the message in their in-game inbox once the
|
||||||
|
async translation worker has finished processing it (see
|
||||||
|
[§11.6](#116-translation)). Until then the row stays invisible:
|
||||||
|
absent from the inbox listing, not counted in the unread badge, no
|
||||||
|
push event delivered. This avoids a surprise where the inbox shows
|
||||||
|
a row with no translation and an outdated unread count.
|
||||||
|
|
||||||
|
The unread badge in the lobby aggregates by game. The
|
||||||
|
`/api/v1/user/lobby/mail/unread-counts` endpoint returns one entry
|
||||||
|
per game with non-zero unread plus the global total; the lobby UI
|
||||||
|
renders the total badge and a per-game tile counter without
|
||||||
|
exposing the messages themselves.
|
||||||
|
|
||||||
|
Marking a message as read is idempotent. Soft-deletion requires the
|
||||||
|
message to already be marked read — a client cannot erase an
|
||||||
|
unopened message. Soft-deletion is per-recipient: the underlying
|
||||||
|
message row survives until the admin bulk-purge endpoint removes
|
||||||
|
the entire game's mail tree.
|
||||||
|
|
||||||
|
The message detail response includes both the original body and,
|
||||||
|
when available, the cached translation; the client UI defaults to
|
||||||
|
the translated text and offers a "show original" toggle.
|
||||||
|
|
||||||
|
### 11.5 Lifecycle hooks
|
||||||
|
|
||||||
|
Three lobby transitions land as system mail in the affected
|
||||||
|
players' inboxes:
|
||||||
|
|
||||||
|
- **Game paused / cancelled.** When the game state machine moves
|
||||||
|
through `paused` or `cancelled`, the lobby emits a system mail
|
||||||
|
addressed to every active member. The message explains the
|
||||||
|
transition with a server-rendered template, so even an offline
|
||||||
|
player finds the context the next time they open the inbox.
|
||||||
|
- **Membership removed / blocked.** Manual self-leave, owner-driven
|
||||||
|
removal, and admin ban each emit a system mail addressed to the
|
||||||
|
affected player only. This mail survives the membership going
|
||||||
|
to `removed` / `blocked`, so a kicked player keeps read access
|
||||||
|
to the explanation forever (soft-access rule).
|
||||||
|
|
||||||
|
Future inactivity-driven removal must call the same publisher so
|
||||||
|
the explanation reaches the affected player; the lobby package
|
||||||
|
README pins this contract for the next implementer.
|
||||||
|
|
||||||
|
### 11.6 Translation
|
||||||
|
|
||||||
|
`diplomail_messages.body_lang` is filled at send time by an
|
||||||
|
in-process language detector that operates on the body only.
|
||||||
|
Subject inherits the body's detected language for the translation
|
||||||
|
cache lookup. When detection cannot confidently label the body
|
||||||
|
(too short, empty, mixed scripts) the value is the BCP 47
|
||||||
|
`und` ("undetermined") sentinel and the translation pipeline is
|
||||||
|
short-circuited — recipients receive the original.
|
||||||
|
|
||||||
|
Translation happens asynchronously. Every recipient row stores a
|
||||||
|
snapshot of the addressee's `preferred_language` plus an
|
||||||
|
`available_at` timestamp. A recipient whose language matches the
|
||||||
|
detected `body_lang` (or whose preferred language is empty / the
|
||||||
|
body language is `und`) gets `available_at = now()` on insert and
|
||||||
|
the push event fires immediately. A recipient whose language
|
||||||
|
differs is inserted with `available_at IS NULL` and waits for the
|
||||||
|
translation worker.
|
||||||
|
|
||||||
|
The worker (`internal/diplomail.Worker`) ticks every
|
||||||
|
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (default `2s`) and processes
|
||||||
|
one `(message_id, target_lang)` pair per tick. It consults the
|
||||||
|
translation cache first; on miss it asks the configured
|
||||||
|
`Translator`. The default deployment ships the LibreTranslate HTTP
|
||||||
|
client; an empty `BACKEND_DIPLOMAIL_TRANSLATOR_URL` falls back to
|
||||||
|
the noop translator that delivers every message in the original
|
||||||
|
language.
|
||||||
|
|
||||||
|
Translation outcomes:
|
||||||
|
|
||||||
|
- **Success.** A row in `diplomail_translations` is inserted (or
|
||||||
|
reused if another worker won the race), every pending recipient
|
||||||
|
of the pair is flipped to `available_at = now()`, and one push
|
||||||
|
event per recipient is published.
|
||||||
|
- **Unsupported language pair** (HTTP 400 from LibreTranslate).
|
||||||
|
No translation row is persisted; recipients are delivered with
|
||||||
|
the original body. Subsequent reads return the original.
|
||||||
|
- **Transient failure** (timeout, 5xx, network error). The
|
||||||
|
attempt counter is bumped and the next attempt is scheduled via
|
||||||
|
exponential backoff `1s → 2s → 4s → 8s → 16s` (capped at 60s).
|
||||||
|
After `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (default `5`)
|
||||||
|
the worker falls back to delivering the original body. A
|
||||||
|
prolonged translator outage therefore stalls delivery by at
|
||||||
|
most ~30 seconds per pair before the receiver sees the
|
||||||
|
original.
|
||||||
|
|
||||||
|
The translation cache is shared: a broadcast to N recipients with
|
||||||
|
the same preferred language produces one cache row and one
|
||||||
|
translator call, not N.
|
||||||
|
|
||||||
|
### 11.7 Storage and purge
|
||||||
|
|
||||||
|
Messages live in `diplomail_messages`; per-recipient state lives
|
||||||
|
in `diplomail_recipients` with a foreign-key cascade to the
|
||||||
|
message; translations live in `diplomail_translations` also with a
|
||||||
|
cascade. The sender IP is captured at insert time from
|
||||||
|
`X-Forwarded-For` (forwarded by gateway) for evidence preservation.
|
||||||
|
|
||||||
|
There is no automatic retention. The admin bulk-purge endpoint
|
||||||
|
removes every message whose game finished more than
|
||||||
|
`older_than_years` years ago (minimum `1`); the cascade drops the
|
||||||
|
recipient and translation rows in the same transaction.
|
||||||
|
|
||||||
|
### 11.8 Operator visibility
|
||||||
|
|
||||||
|
The admin surface exposes a paginated listing of every persisted
|
||||||
|
message (`/api/v1/admin/mail/messages`) filterable by `game_id`,
|
||||||
|
`kind`, and `sender_kind`. The bulk-purge endpoint
|
||||||
|
(`/api/v1/admin/mail/cleanup`) accepts the `older_than_years`
|
||||||
|
threshold. Per-game admin sends and multi-game broadcasts live
|
||||||
|
under `/api/v1/admin/games/{game_id}/mail` and
|
||||||
|
`/api/v1/admin/mail/broadcast`.
|
||||||
|
|
||||||
|
### 11.9 Cross-references
|
||||||
|
|
||||||
|
- Package overview and stage map:
|
||||||
|
[`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md).
|
||||||
|
- LibreTranslate setup recipe for local development:
|
||||||
|
[`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md).
|
||||||
|
- Storage detail:
|
||||||
|
[ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem).
|
||||||
|
- Push transport for delivery events: [Section 7](#7-push-channel).
|
||||||
|
- Notification catalog kind `diplomail.message.received`:
|
||||||
|
[`backend/README.md` §10](../backend/README.md#10-notification-catalog).
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ field-level-валидация — всё это лежит в нижнеуро
|
|||||||
8. [Уведомления и почта](#8-уведомления-и-почта)
|
8. [Уведомления и почта](#8-уведомления-и-почта)
|
||||||
9. [Гео-сигнал](#9-гео-сигнал)
|
9. [Гео-сигнал](#9-гео-сигнал)
|
||||||
10. [Администрирование](#10-администрирование)
|
10. [Администрирование](#10-администрирование)
|
||||||
|
11. [Дипломатическая почта](#11-дипломатическая-почта)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1193,3 +1194,220 @@ dead-letters и malformed notification-намерения. Они также м
|
|||||||
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
[ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox),
|
||||||
[§12](ARCHITECTURE.md#12-notification-pipeline) и
|
[§12](ARCHITECTURE.md#12-notification-pipeline) и
|
||||||
[Раздел 8](#8-уведомления-и-почта).
|
[Раздел 8](#8-уведомления-и-почта).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Дипломатическая почта
|
||||||
|
|
||||||
|
Сценарий описывает обмен сообщениями между игроками одной партии и
|
||||||
|
адресные / широковещательные уведомления от администрации и
|
||||||
|
владельца партии. Подсистема концептуально часть лобби (сообщения
|
||||||
|
переживают рестарты движка), но видна только внутри игрового UI;
|
||||||
|
в лобби виден лишь счётчик непрочитанного.
|
||||||
|
|
||||||
|
### 11.1 Состав
|
||||||
|
|
||||||
|
В составе: отправка персональной почты между активными участниками
|
||||||
|
одной партии; ответы на персональную почту; чтение, отметка
|
||||||
|
«прочитано» и soft-удаление своей входящей почты; адресные и
|
||||||
|
широковещательные уведомления от админов и владельцев; платный
|
||||||
|
broadcast от игроков; мультигеймовая admin-рассылка; ручная
|
||||||
|
массовая чистка почты завершённых партий; авто-перевод тела
|
||||||
|
сообщения на `preferred_language` получателя с кэшированием.
|
||||||
|
|
||||||
|
Вне состава: чат вне партии, групповые чаты с участниками разных
|
||||||
|
партий, вложения, редактирование / отзыв сообщения,
|
||||||
|
end-to-end-шифрование.
|
||||||
|
|
||||||
|
### 11.2 Модель сообщения
|
||||||
|
|
||||||
|
Каждая отправка порождает ровно одну строку в `diplomail_messages`
|
||||||
|
плюс по одной строке на получателя в `diplomail_recipients`.
|
||||||
|
Broadcast на N получателей — одно сообщение и N recipient-строк;
|
||||||
|
строка перевода, если материализована, общая для всех получателей
|
||||||
|
с одинаковым целевым языком.
|
||||||
|
|
||||||
|
`diplomail_messages.kind` — закрытое множество
|
||||||
|
`{personal, admin}`. Персональные сообщения допускают ответ
|
||||||
|
(получатель отправляет новое персональное сообщение);
|
||||||
|
admin-сообщения не предполагают ответа — это уведомления о смене
|
||||||
|
состояния или операторском действии. `sender_kind` — это
|
||||||
|
`{player, admin, system}` и определяет роль отправителя: игрок-
|
||||||
|
владелец партии (admin-уведомление от owner), site-администратор
|
||||||
|
(admin-уведомление от оператора) или собственно автомат лобби
|
||||||
|
(`game.paused`, `game.cancelled`, `membership.removed`,
|
||||||
|
`membership.blocked`).
|
||||||
|
|
||||||
|
`broadcast_scope` фиксирует тип отправки: одному получателю
|
||||||
|
(`single`), рассылка по одной партии (`game_broadcast`) или
|
||||||
|
admin-рассылка по нескольким партиям (`multi_game_broadcast`).
|
||||||
|
Получатели multi_game-рассылки видят отдельную, независимо
|
||||||
|
удаляемую запись inbox в каждой адресованной партии.
|
||||||
|
|
||||||
|
Снимки сохраняются прямо в строках сообщения и получателя:
|
||||||
|
`game_name`, `sender_username`, `sender_ip` и на стороне
|
||||||
|
получателя — `recipient_user_name`, `recipient_race_name` и
|
||||||
|
`recipient_preferred_language`. Они переживают переименование
|
||||||
|
партии, отзыв членства, soft-delete аккаунта и итоговый
|
||||||
|
bulk-purge — admin observability отрисовывается корректно даже
|
||||||
|
после исчезновения «живых» строк.
|
||||||
|
|
||||||
|
Тела и subject — plain UTF-8 текст. Сервер не парсит, не санитайзит
|
||||||
|
и не экранирует HTML; клиент рендерит тело через `textContent`.
|
||||||
|
Максимум размера тела — `BACKEND_DIPLOMAIL_MAX_BODY_BYTES`
|
||||||
|
(по умолчанию `4096`); максимум для subject —
|
||||||
|
`BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (по умолчанию `256`).
|
||||||
|
|
||||||
|
### 11.3 Отправка почты
|
||||||
|
|
||||||
|
Персональная отправка требует активного членства в партии и от
|
||||||
|
отправителя, и от получателя. Игроки free-tier отправляют одно
|
||||||
|
персональное сообщение за запрос. Игрокам платных тиров доступен
|
||||||
|
и игровой broadcast — одна отправка на всех остальных активных
|
||||||
|
участников партии; ответы возвращаются автору broadcast.
|
||||||
|
|
||||||
|
Владельцы (приватных партий) и site-администраторы отправляют
|
||||||
|
admin-уведомления. Endpoint владельца находится на user-поверхности
|
||||||
|
(аутентификация по `X-User-ID`, проверка владельца в обработчике);
|
||||||
|
endpoint администратора — на admin-поверхности (HTTP Basic). Оба
|
||||||
|
принимают `target=user` (один получатель) или `target=all`
|
||||||
|
(broadcast в одной партии). Site-администратору доступен
|
||||||
|
дополнительный multi-game endpoint, принимающий
|
||||||
|
`scope=selected` со списком game_id или `scope=all_running` —
|
||||||
|
перебор всех партий в нетерминальных состояниях.
|
||||||
|
|
||||||
|
Состав получателей broadcast параметризуется полем `recipients`:
|
||||||
|
`active` (по умолчанию), `active_and_removed` или `all_members`
|
||||||
|
(включает блокированных, для аудит-уведомлений). Собственная
|
||||||
|
recipient-строка автора broadcast не создаётся.
|
||||||
|
|
||||||
|
Player-broadcast от free-tier пользователя отклоняется кодом
|
||||||
|
`403 forbidden`.
|
||||||
|
|
||||||
|
### 11.4 Получение почты
|
||||||
|
|
||||||
|
Получатель видит сообщение в своём inbox только после того, как
|
||||||
|
асинхронный worker перевода обработал его (см.
|
||||||
|
[§11.6](#116-перевод)). До этого строка невидима: не выводится в
|
||||||
|
inbox-листинге, не учитывается в badge непрочитанного, push-событие
|
||||||
|
не доставляется. Это исключает ситуацию «строка появилась, перевод
|
||||||
|
не подъехал, badge мигает».
|
||||||
|
|
||||||
|
Badge непрочитанного в лобби агрегируется по партиям. Endpoint
|
||||||
|
`/api/v1/user/lobby/mail/unread-counts` возвращает по одной записи
|
||||||
|
на каждую партию с ненулевым unread плюс общий total; UI лобби
|
||||||
|
отображает общий badge и плитки по партиям, не раскрывая самих
|
||||||
|
сообщений.
|
||||||
|
|
||||||
|
Mark-read идемпотентен. Soft-удаление требует, чтобы сообщение уже
|
||||||
|
было помечено прочитанным — клиент не может стереть неоткрытое
|
||||||
|
сообщение. Soft-удаление действует только для одного получателя:
|
||||||
|
строка самого сообщения переживает удаление вплоть до admin
|
||||||
|
bulk-purge всей почты соответствующей партии.
|
||||||
|
|
||||||
|
Ответ message-detail содержит и оригинальное тело, и (если есть
|
||||||
|
кэш) перевод; UI по умолчанию показывает перевод и предлагает
|
||||||
|
переключение «показать оригинал».
|
||||||
|
|
||||||
|
### 11.5 Хуки жизненного цикла
|
||||||
|
|
||||||
|
Три транзитных перехода в лобби порождают system mail в inbox
|
||||||
|
затронутых игроков:
|
||||||
|
|
||||||
|
- **Пауза / отмена игры.** Когда автомат партии проходит через
|
||||||
|
`paused` или `cancelled`, лобби эмитит system-сообщение всем
|
||||||
|
активным членам. Текст рендерится сервером по шаблону, чтобы
|
||||||
|
игрок, открывший inbox позже, нашёл объяснение даже без
|
||||||
|
одновременной push-сессии.
|
||||||
|
- **Удаление / блокировка членства.** Сам-выход, удаление
|
||||||
|
владельцем и admin-бан порождают system-сообщение только для
|
||||||
|
затронутого игрока. Это письмо переживает переход членства в
|
||||||
|
`removed` / `blocked` — игрок сохраняет к нему read-доступ
|
||||||
|
навсегда (правило soft-доступа).
|
||||||
|
|
||||||
|
Будущее удаление по неактивности должно вызывать тот же publisher,
|
||||||
|
чтобы объяснение дошло до затронутого игрока; README пакета
|
||||||
|
прибивает этот контракт для следующего реализатора.
|
||||||
|
|
||||||
|
### 11.6 Перевод
|
||||||
|
|
||||||
|
`diplomail_messages.body_lang` заполняется на стороне сервера в
|
||||||
|
момент отправки внутрипроцессным детектором языка, работающим
|
||||||
|
только по телу. Subject наследует язык тела для ключа кэша
|
||||||
|
перевода. Когда детектор не может уверенно классифицировать тело
|
||||||
|
(слишком короткое, пустое, смешанные скрипты), значение —
|
||||||
|
плейсхолдер BCP 47 `und` ("неопределённый"), и pipeline перевода
|
||||||
|
обходится стороной — получатели видят оригинал.
|
||||||
|
|
||||||
|
Перевод выполняется асинхронно. Каждая recipient-строка содержит
|
||||||
|
снимок `preferred_language` получателя плюс метку `available_at`.
|
||||||
|
Получатель, чей язык совпадает с детектированным `body_lang` (или
|
||||||
|
чей preferred_language пуст / язык тела — `und`), получает
|
||||||
|
`available_at = now()` сразу при вставке, и push-событие
|
||||||
|
отправляется в момент `POST`. Получатель с отличающимся языком
|
||||||
|
вставляется с `available_at IS NULL` и ждёт worker.
|
||||||
|
|
||||||
|
Worker (`internal/diplomail.Worker`) тикает каждые
|
||||||
|
`BACKEND_DIPLOMAIL_WORKER_INTERVAL` (по умолчанию `2s`) и
|
||||||
|
обрабатывает по одной паре `(message_id, target_lang)` за тик. Он
|
||||||
|
сначала смотрит в кэш переводов; на miss дёргает настроенный
|
||||||
|
`Translator`. Дефолт production-сборки — LibreTranslate HTTP
|
||||||
|
клиент; пустой `BACKEND_DIPLOMAIL_TRANSLATOR_URL` оставляет
|
||||||
|
noop-translator, который доставляет сообщение в оригинале.
|
||||||
|
|
||||||
|
Исходы перевода:
|
||||||
|
|
||||||
|
- **Успех.** Строка в `diplomail_translations` создаётся (или
|
||||||
|
переиспользуется, если параллельная попытка успела раньше),
|
||||||
|
все pending-получатели пары переключаются на
|
||||||
|
`available_at = now()`, и по каждому отправляется push.
|
||||||
|
- **Неподдерживаемая пара языков** (HTTP 400 от LibreTranslate).
|
||||||
|
Строка перевода не сохраняется; получатели доставляются с
|
||||||
|
оригинальным телом. Последующие чтения возвращают оригинал.
|
||||||
|
- **Транзитный сбой** (timeout, 5xx, network error). Счётчик
|
||||||
|
попыток увеличивается, следующая попытка планируется по
|
||||||
|
экспоненциальному backoff `1s → 2s → 4s → 8s → 16s`
|
||||||
|
(с потолком 60s). После
|
||||||
|
`BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` (по умолчанию `5`)
|
||||||
|
worker fallback'ит на оригинальное тело. Длительный отказ
|
||||||
|
переводчика тормозит доставку максимум на ~30 секунд на пару
|
||||||
|
до того, как получатель увидит оригинал.
|
||||||
|
|
||||||
|
Кэш переводов общий: broadcast на N получателей с одинаковым
|
||||||
|
preferred_language порождает одну строку кэша и один вызов
|
||||||
|
переводчика, не N.
|
||||||
|
|
||||||
|
### 11.7 Хранение и purge
|
||||||
|
|
||||||
|
Сообщения живут в `diplomail_messages`; per-recipient state — в
|
||||||
|
`diplomail_recipients` с FK-каскадом на сообщение; переводы — в
|
||||||
|
`diplomail_translations` тоже с каскадом. IP-адрес отправителя
|
||||||
|
снимается из `X-Forwarded-For` (форвардит gateway) и хранится в
|
||||||
|
сообщении для сохранения доказательств.
|
||||||
|
|
||||||
|
Автоматического retention нет. Admin bulk-purge endpoint удаляет
|
||||||
|
все сообщения, чья партия завершилась более `older_than_years`
|
||||||
|
лет назад (минимум `1`); каскад удаляет recipient- и
|
||||||
|
translation-строки той же транзакцией.
|
||||||
|
|
||||||
|
### 11.8 Видимость для оператора
|
||||||
|
|
||||||
|
Admin-поверхность экспонирует постраничный листинг всех сообщений
|
||||||
|
(`/api/v1/admin/mail/messages`) с фильтрами по `game_id`, `kind`
|
||||||
|
и `sender_kind`. Bulk-purge endpoint
|
||||||
|
(`/api/v1/admin/mail/cleanup`) принимает порог
|
||||||
|
`older_than_years`. Per-game admin-отправки и multi-game
|
||||||
|
broadcast'ы доступны через `/api/v1/admin/games/{game_id}/mail`
|
||||||
|
и `/api/v1/admin/mail/broadcast`.
|
||||||
|
|
||||||
|
### 11.9 Перекрёстные ссылки
|
||||||
|
|
||||||
|
- Обзор пакета и карта стадий:
|
||||||
|
[`backend/internal/diplomail/README.md`](../backend/internal/diplomail/README.md).
|
||||||
|
- Рецепт развёртывания LibreTranslate для локальной разработки:
|
||||||
|
[`backend/docs/diplomail-translator-setup.md`](../backend/docs/diplomail-translator-setup.md).
|
||||||
|
- Детали хранения:
|
||||||
|
[ARCHITECTURE.md §12.1](ARCHITECTURE.md#121-diplomatic-mail-subsystem).
|
||||||
|
- Push-транспорт для событий доставки: [Раздел 7](#7-канал-push).
|
||||||
|
- Notification-каталог: kind `diplomail.message.received`:
|
||||||
|
[`backend/README.md` §10](../backend/README.md#10-notification-catalog).
|
||||||
|
|||||||
Reference in New Issue
Block a user