diff --git a/backend/README.md b/backend/README.md index 9e1ddc7..27505cf 100644 --- a/backend/README.md +++ b/backend/README.md @@ -45,6 +45,7 @@ backend/ │ ├── admin/ # admin_accounts, Basic Auth verifier, admin operations │ ├── auth/ # email-code challenges, device sessions, Ed25519 keys │ ├── config/ # env-var loader, Validate +│ ├── diplomail/ # diplomatic-mail messages, recipients, translations │ ├── dockerclient/ # docker/docker wrapper for container ops │ ├── engineclient/ # net/http client to galaxy-game containers │ ├── geo/ # geoip lookup, declared_country, per-user counters @@ -131,6 +132,12 @@ fast. | `BACKEND_NOTIFICATION_ADMIN_EMAIL` | no | — | Recipient address for admin-channel notifications (`runtime.*` kinds). When empty, admin-channel routes are recorded as `skipped` and the catalog is partially silenced. | | `BACKEND_NOTIFICATION_WORKER_INTERVAL` | no | `5s` | Notification route worker scan interval. | | `BACKEND_NOTIFICATION_MAX_ATTEMPTS` | no | `8` | Notification route delivery attempts before dead-lettering. | +| `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` | no | `4096` | Maximum size of `diplomail_messages.body` enforced at send time. Tune at runtime without a migration. | +| `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` | no | `256` | Maximum size of `diplomail_messages.subject`. Subject is optional; empty is always accepted. | +| `BACKEND_DIPLOMAIL_TRANSLATOR_URL` | no | — | Base URL of a LibreTranslate-compatible instance (`http://libretranslate:5000`). Empty → translator falls through to no-op (recipients are delivered with the original body). | +| `BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT` | no | `10s` | Per-request HTTP timeout for the translation worker. | +| `BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS` | no | `5` | Number of failed HTTP attempts before the worker delivers the message with the original body (fallback). | +| `BACKEND_DIPLOMAIL_WORKER_INTERVAL` | no | `2s` | How often the async translation worker scans for pending pairs. The worker processes one pair per tick. | If `BACKEND_ADMIN_BOOTSTRAP_USER` is set without `BACKEND_ADMIN_BOOTSTRAP_PASSWORD`, `Validate()` fails. If neither is diff --git a/backend/cmd/backend/main.go b/backend/cmd/backend/main.go index be1b997..dd21847 100644 --- a/backend/cmd/backend/main.go +++ b/backend/cmd/backend/main.go @@ -12,6 +12,7 @@ import ( "os" "os/signal" "syscall" + "time" // time/tzdata embeds the IANA timezone database so time.LoadLocation // works in container images without /usr/share/zoneinfo (distroless @@ -25,6 +26,9 @@ import ( "galaxy/backend/internal/auth" "galaxy/backend/internal/config" "galaxy/backend/internal/devsandbox" + "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/diplomail/detector" + "galaxy/backend/internal/diplomail/translator" "galaxy/backend/internal/dockerclient" "galaxy/backend/internal/engineclient" "galaxy/backend/internal/geo" @@ -131,6 +135,7 @@ func run(ctx context.Context) (err error) { lobbyCascade := &lobbyCascadeAdapter{} userNotifyCascade := &userNotificationCascadeAdapter{} lobbyNotifyPublisher := &lobbyNotificationPublisherAdapter{} + lobbyDiplomailPublisher := &lobbyDiplomailPublisherAdapter{} runtimeNotifyPublisher := &runtimeNotificationPublisherAdapter{} userSvc := user.NewService(user.Deps{ @@ -197,6 +202,7 @@ func run(ctx context.Context) (err error) { Cache: lobbyCache, Runtime: runtimeGateway, Notification: lobbyNotifyPublisher, + Diplomail: lobbyDiplomailPublisher, Entitlement: &userEntitlementAdapter{svc: userSvc}, Config: cfg.Lobby, Logger: logger, @@ -301,6 +307,25 @@ func run(ctx context.Context) (err error) { userNotifyCascade.svc = notifSvc lobbyNotifyPublisher.svc = notifSvc runtimeNotifyPublisher.svc = notifSvc + + diplomailStore := diplomail.NewStore(db) + diplomailTranslator, err := buildDiplomailTranslator(cfg.Diplomail, logger) + if err != nil { + return fmt.Errorf("build diplomail translator: %w", err) + } + diplomailSvc := diplomail.NewService(diplomail.Deps{ + Store: diplomailStore, + Memberships: &diplomailMembershipAdapter{lobby: lobbySvc, users: userSvc}, + Notification: &diplomailNotificationPublisherAdapter{svc: notifSvc}, + Entitlements: &diplomailEntitlementAdapter{users: userSvc}, + Games: &diplomailGameAdapter{lobby: lobbySvc}, + Detector: detector.New(), + Translator: diplomailTranslator, + Config: cfg.Diplomail, + Logger: logger, + }) + lobbyDiplomailPublisher.svc = diplomailSvc + diplomailWorker := diplomail.NewWorker(diplomailSvc) if email := cfg.Notification.AdminEmail; email == "" { logger.Info("notification admin email not configured (BACKEND_NOTIFICATION_ADMIN_EMAIL); admin-channel routes will be skipped") } else { @@ -325,9 +350,11 @@ func run(ctx context.Context) (err error) { adminEngineVersionsHandlers := backendserver.NewAdminEngineVersionsHandlers(engineVersionSvc, logger) adminRuntimesHandlers := backendserver.NewAdminRuntimesHandlers(runtimeSvc, logger) adminMailHandlers := backendserver.NewAdminMailHandlers(mailSvc, logger) + adminDiplomailHandlers := backendserver.NewAdminDiplomailHandlers(diplomailSvc, logger) adminNotificationsHandlers := backendserver.NewAdminNotificationsHandlers(notifSvc, logger) adminGeoHandlers := backendserver.NewAdminGeoHandlers(geoSvc, logger) userGamesHandlers := backendserver.NewUserGamesHandlers(runtimeSvc, engineCli, logger) + userMailHandlers := backendserver.NewUserMailHandlers(diplomailSvc, lobbySvc, userSvc, logger) ready := func() bool { return authCache.Ready() && userCache.Ready() && adminCache.Ready() && lobbyCache.Ready() && runtimeCache.Ready() @@ -356,9 +383,11 @@ func run(ctx context.Context) (err error) { AdminRuntimes: adminRuntimesHandlers, AdminEngineVersions: adminEngineVersionsHandlers, AdminMail: adminMailHandlers, + AdminDiplomail: adminDiplomailHandlers, AdminNotifications: adminNotificationsHandlers, AdminGeo: adminGeoHandlers, UserGames: userGamesHandlers, + UserMail: userMailHandlers, }) if err != nil { return fmt.Errorf("build backend router: %w", err) @@ -374,7 +403,7 @@ func run(ctx context.Context) (err error) { runtimeScheduler := runtimeSvc.SchedulerComponent() runtimeReconciler := runtimeSvc.Reconciler() - components := []app.Component{httpServer, pushServer, mailWorker, notifWorker, lobbySweeper, runtimeWorkers, runtimeScheduler, runtimeReconciler} + components := []app.Component{httpServer, pushServer, mailWorker, notifWorker, diplomailWorker, lobbySweeper, runtimeWorkers, runtimeScheduler, runtimeReconciler} if metricsServer.Enabled() { components = append(components, metricsServer) } @@ -579,3 +608,288 @@ func (a *runtimeNotificationPublisherAdapter) PublishRuntimeEvent(ctx context.Co } return a.svc.RuntimeAdapter().PublishRuntimeEvent(ctx, kind, idempotencyKey, payload) } + +// diplomailMembershipAdapter implements `diplomail.MembershipLookup` +// by walking the lobby cache (for active rows) and the lobby service +// (for any-status rows) and stitching each membership row to the +// immutable `accounts.user_name` resolved through `*user.Service`. +type diplomailMembershipAdapter struct { + lobby *lobby.Service + users *user.Service +} + +func (a *diplomailMembershipAdapter) GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) { + if a == nil || a.lobby == nil || a.users == nil { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + cache := a.lobby.Cache() + if cache == nil { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + game, ok := cache.GetGame(gameID) + if !ok { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + var found *lobby.Membership + for _, m := range cache.MembershipsForGame(gameID) { + if m.UserID == userID { + mm := m + found = &mm + break + } + } + if found == nil { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + account, err := a.users.GetAccount(ctx, userID) + if err != nil { + return diplomail.ActiveMembership{}, err + } + return diplomail.ActiveMembership{ + UserID: userID, + GameID: gameID, + GameName: game.GameName, + UserName: account.UserName, + RaceName: found.RaceName, + PreferredLanguage: account.PreferredLanguage, + }, nil +} + +func (a *diplomailMembershipAdapter) GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) { + if a == nil || a.lobby == nil || a.users == nil { + return diplomail.MemberSnapshot{}, diplomail.ErrNotFound + } + game, ok := a.lobby.Cache().GetGame(gameID) + if !ok { + return diplomail.MemberSnapshot{}, diplomail.ErrNotFound + } + members, err := a.lobby.ListMembershipsForGame(ctx, gameID) + if err != nil { + return diplomail.MemberSnapshot{}, err + } + var found *lobby.Membership + for _, m := range members { + if m.UserID == userID { + mm := m + found = &mm + break + } + } + if found == nil { + return diplomail.MemberSnapshot{}, diplomail.ErrNotFound + } + account, err := a.users.GetAccount(ctx, userID) + if err != nil { + return diplomail.MemberSnapshot{}, err + } + return diplomail.MemberSnapshot{ + UserID: userID, + GameID: gameID, + GameName: game.GameName, + UserName: account.UserName, + RaceName: found.RaceName, + Status: found.Status, + PreferredLanguage: account.PreferredLanguage, + }, nil +} + +func (a *diplomailMembershipAdapter) ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) { + if a == nil || a.lobby == nil || a.users == nil { + return nil, diplomail.ErrNotFound + } + game, ok := a.lobby.Cache().GetGame(gameID) + if !ok { + return nil, diplomail.ErrNotFound + } + members, err := a.lobby.ListMembershipsForGame(ctx, gameID) + if err != nil { + return nil, err + } + matches := func(status string) bool { + switch scope { + case diplomail.RecipientScopeActive: + return status == lobby.MembershipStatusActive + case diplomail.RecipientScopeActiveAndRemoved: + return status == lobby.MembershipStatusActive || status == lobby.MembershipStatusRemoved + case diplomail.RecipientScopeAllMembers: + return true + default: + return status == lobby.MembershipStatusActive + } + } + out := make([]diplomail.MemberSnapshot, 0, len(members)) + for _, m := range members { + if !matches(m.Status) { + continue + } + account, err := a.users.GetAccount(ctx, m.UserID) + if err != nil { + return nil, fmt.Errorf("resolve user_name for %s: %w", m.UserID, err) + } + out = append(out, diplomail.MemberSnapshot{ + UserID: m.UserID, + GameID: gameID, + GameName: game.GameName, + UserName: account.UserName, + RaceName: m.RaceName, + Status: m.Status, + PreferredLanguage: account.PreferredLanguage, + }) + } + return out, nil +} + +// lobbyDiplomailPublisherAdapter implements `lobby.DiplomailPublisher` +// by translating each lobby.LifecycleEvent into the diplomail +// vocabulary and delegating to `*diplomail.Service.PublishLifecycle`. +// The svc pointer is patched once diplomailSvc exists — diplomail +// depends on lobby through MembershipLookup, so the lobby service +// is constructed first and patched up. +type lobbyDiplomailPublisherAdapter struct { + svc *diplomail.Service +} + +func (a *lobbyDiplomailPublisherAdapter) PublishLifecycle(ctx context.Context, ev lobby.LifecycleEvent) error { + if a == nil || a.svc == nil { + return nil + } + return a.svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{ + GameID: ev.GameID, + Kind: ev.Kind, + Actor: ev.Actor, + Reason: ev.Reason, + TargetUser: ev.TargetUser, + }) +} + +// buildDiplomailTranslator selects the diplomail translator backend +// from configuration: a non-empty `TranslatorURL` constructs the +// LibreTranslate HTTP client; an empty URL falls through to the +// noop translator so deployments without a translation service still +// boot and deliver mail with the fallback path. +func buildDiplomailTranslator(cfg config.DiplomailConfig, logger *zap.Logger) (translator.Translator, error) { + if cfg.TranslatorURL == "" { + logger.Info("diplomail translator URL not configured, using noop translator") + return translator.NewNoop(), nil + } + return translator.NewLibreTranslate(translator.LibreTranslateConfig{ + URL: cfg.TranslatorURL, + Timeout: cfg.TranslatorTimeout, + }) +} + +// diplomailEntitlementAdapter implements +// `diplomail.EntitlementReader` by reading the user-service +// entitlement snapshot. The IsPaid flag mirrors the per-tier policy +// defined in `internal/user`, so updates to the tier set (monthly, +// yearly, permanent, …) flow through without changes here. +type diplomailEntitlementAdapter struct { + users *user.Service +} + +func (a *diplomailEntitlementAdapter) IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error) { + if a == nil || a.users == nil { + return false, nil + } + snap, err := a.users.GetEntitlementSnapshot(ctx, userID) + if err != nil { + return false, err + } + return snap.IsPaid, nil +} + +// diplomailGameAdapter implements `diplomail.GameLookup`. The +// running-games and finished-games queries walk the lobby cache so +// the admin multi-game broadcast and bulk-purge endpoints do not +// fan out a per-game DB query each time. GetGame falls back to the +// cache; an unknown id is surfaced as ErrNotFound (the diplomail +// sentinel). +type diplomailGameAdapter struct { + lobby *lobby.Service +} + +func (a *diplomailGameAdapter) ListRunningGames(_ context.Context) ([]diplomail.GameSnapshot, error) { + if a == nil || a.lobby == nil || a.lobby.Cache() == nil { + return nil, nil + } + var out []diplomail.GameSnapshot + for _, game := range a.lobby.Cache().ListGames() { + if !isRunningStatus(game.Status) { + continue + } + out = append(out, gameSnapshot(game)) + } + return out, nil +} + +func (a *diplomailGameAdapter) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]diplomail.GameSnapshot, error) { + if a == nil || a.lobby == nil { + return nil, nil + } + games, err := a.lobby.ListFinishedGamesBefore(ctx, cutoff) + if err != nil { + return nil, err + } + out := make([]diplomail.GameSnapshot, 0, len(games)) + for _, g := range games { + out = append(out, gameSnapshot(g)) + } + return out, nil +} + +func (a *diplomailGameAdapter) GetGame(_ context.Context, gameID uuid.UUID) (diplomail.GameSnapshot, error) { + if a == nil || a.lobby == nil || a.lobby.Cache() == nil { + return diplomail.GameSnapshot{}, diplomail.ErrNotFound + } + game, ok := a.lobby.Cache().GetGame(gameID) + if !ok { + return diplomail.GameSnapshot{}, diplomail.ErrNotFound + } + return gameSnapshot(game), nil +} + +func gameSnapshot(g lobby.GameRecord) diplomail.GameSnapshot { + out := diplomail.GameSnapshot{ + GameID: g.GameID, + GameName: g.GameName, + Status: g.Status, + } + if g.FinishedAt != nil { + f := *g.FinishedAt + out.FinishedAt = &f + } + return out +} + +func isRunningStatus(status string) bool { + switch status { + case lobby.GameStatusReadyToStart, lobby.GameStatusStarting, lobby.GameStatusRunning, lobby.GameStatusPaused: + return true + default: + return false + } +} + +// diplomailNotificationPublisherAdapter implements +// `diplomail.NotificationPublisher` by translating each +// DiplomailNotification into a notification.Intent and routing it +// through `*notification.Service.Submit`. The publisher leaves the +// `diplomail.message.received` catalog entry to handle channel +// fan-out (push only in Stage A). +type diplomailNotificationPublisherAdapter struct { + svc *notification.Service +} + +func (a *diplomailNotificationPublisherAdapter) PublishDiplomailEvent(ctx context.Context, ev diplomail.DiplomailNotification) error { + if a == nil || a.svc == nil { + return nil + } + intent := notification.Intent{ + Kind: ev.Kind, + IdempotencyKey: ev.IdempotencyKey, + Recipients: []uuid.UUID{ev.Recipient}, + Payload: ev.Payload, + } + _, err := a.svc.Submit(ctx, intent) + return err +} diff --git a/backend/docs/diplomail-translator-setup.md b/backend/docs/diplomail-translator-setup.md new file mode 100644 index 0000000..ecb05a7 --- /dev/null +++ b/backend/docs/diplomail-translator-setup.md @@ -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. diff --git a/backend/go.mod b/backend/go.mod index da040c6..68e062e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -36,6 +36,7 @@ require ( ) require ( + github.com/abadojack/whatlanggo v1.0.1 // indirect github.com/oschwald/geoip2-golang/v2 v2.1.0 // indirect github.com/oschwald/maxminddb-golang/v2 v2.1.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index e39284a..ff077fd 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -10,6 +10,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/XSAM/otelsql v0.42.0 h1:Li0xF4eJUxG2e0x3D4rvRlys1f27yJKvjTh7ljkUP5o= github.com/XSAM/otelsql v0.42.0/go.mod h1:4mOrEv+cS1KmKzrvTktvJnstr5GtKSAK+QHvFR9OcpI= +github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4= +github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 536e206..bd981ab 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -96,6 +96,13 @@ const ( envNotificationWorkerInterval = "BACKEND_NOTIFICATION_WORKER_INTERVAL" envNotificationMaxAttempts = "BACKEND_NOTIFICATION_MAX_ATTEMPTS" + envDiplomailMaxBodyBytes = "BACKEND_DIPLOMAIL_MAX_BODY_BYTES" + envDiplomailMaxSubjectBytes = "BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES" + envDiplomailTranslatorURL = "BACKEND_DIPLOMAIL_TRANSLATOR_URL" + envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT" + envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS" + envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL" + envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL" envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE" envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION" @@ -163,6 +170,12 @@ const ( defaultNotificationWorkerInterval = 5 * time.Second defaultNotificationMaxAttempts = 8 + defaultDiplomailMaxBodyBytes = 4096 + defaultDiplomailMaxSubjectBytes = 256 + defaultDiplomailTranslatorTimeout = 10 * time.Second + defaultDiplomailTranslatorMaxAttempts = 5 + defaultDiplomailWorkerInterval = 2 * time.Second + defaultDevSandboxEngineVersion = "0.1.0" defaultDevSandboxPlayerCount = 20 ) @@ -201,6 +214,7 @@ type Config struct { Engine EngineConfig Runtime RuntimeConfig Notification NotificationConfig + Diplomail DiplomailConfig DevSandbox DevSandboxConfig // FreshnessWindow mirrors the gateway freshness window and is used by the @@ -397,6 +411,42 @@ type RuntimeConfig struct { StopGracePeriod time.Duration } +// DiplomailConfig bounds the diplomatic-mail subsystem. Both limits +// are enforced in the service layer, so they can be tuned at runtime +// without a schema migration. Body and subject are stored as plain +// UTF-8 text; HTML is neither parsed nor sanitised on the server. +type DiplomailConfig struct { + // MaxBodyBytes caps the length of `diplomail_messages.body` in + // bytes (not runes). A send whose body exceeds the limit is + // rejected with ErrInvalidInput. + MaxBodyBytes int + + // MaxSubjectBytes caps the length of `diplomail_messages.subject` + // in bytes. Subjects are optional; the empty-string default + // passes the limit trivially. + MaxSubjectBytes int + + // TranslatorURL is the base URL of the LibreTranslate-compatible + // instance the async translation worker calls. When empty, the + // worker still runs but falls through to "deliver original" + // (the noop translator returns engine=noop). + TranslatorURL string + + // TranslatorTimeout bounds a single HTTP request to the + // translator. Worker retries (exponential backoff up to + // TranslatorMaxAttempts) layer on top. + TranslatorTimeout time.Duration + + // TranslatorMaxAttempts is the number of times the worker tries + // to translate one (message, target_lang) pair before falling + // back to delivering the original body. + TranslatorMaxAttempts int + + // WorkerInterval bounds how often the async translation worker + // scans for pending pairs. The worker handles one pair per tick. + WorkerInterval time.Duration +} + // NotificationConfig configures the notification fan-out module // implemented in `backend/internal/notification`. AdminEmail receives // admin-channel kinds (the `runtime.*` set in `backend/README.md` §10); @@ -494,6 +544,13 @@ func DefaultConfig() Config { WorkerInterval: defaultNotificationWorkerInterval, MaxAttempts: defaultNotificationMaxAttempts, }, + Diplomail: DiplomailConfig{ + MaxBodyBytes: defaultDiplomailMaxBodyBytes, + MaxSubjectBytes: defaultDiplomailMaxSubjectBytes, + TranslatorTimeout: defaultDiplomailTranslatorTimeout, + TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts, + WorkerInterval: defaultDiplomailWorkerInterval, + }, DevSandbox: DevSandboxConfig{ EngineVersion: defaultDevSandboxEngineVersion, PlayerCount: defaultDevSandboxPlayerCount, @@ -657,6 +714,23 @@ func LoadFromEnv() (Config, error) { return Config{}, err } + if cfg.Diplomail.MaxBodyBytes, err = loadInt(envDiplomailMaxBodyBytes, cfg.Diplomail.MaxBodyBytes); err != nil { + return Config{}, err + } + if cfg.Diplomail.MaxSubjectBytes, err = loadInt(envDiplomailMaxSubjectBytes, cfg.Diplomail.MaxSubjectBytes); err != nil { + return Config{}, err + } + cfg.Diplomail.TranslatorURL = loadString(envDiplomailTranslatorURL, cfg.Diplomail.TranslatorURL) + if cfg.Diplomail.TranslatorTimeout, err = loadDuration(envDiplomailTranslatorTimeout, cfg.Diplomail.TranslatorTimeout); err != nil { + return Config{}, err + } + if cfg.Diplomail.TranslatorMaxAttempts, err = loadInt(envDiplomailTranslatorMaxAttempts, cfg.Diplomail.TranslatorMaxAttempts); err != nil { + return Config{}, err + } + if cfg.Diplomail.WorkerInterval, err = loadDuration(envDiplomailWorkerInterval, cfg.Diplomail.WorkerInterval); err != nil { + return Config{}, err + } + cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email)) cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage)) cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion)) @@ -853,6 +927,22 @@ func (c Config) Validate() error { if c.Notification.MaxAttempts <= 0 { return fmt.Errorf("%s must be positive", envNotificationMaxAttempts) } + + if c.Diplomail.MaxBodyBytes <= 0 { + return fmt.Errorf("%s must be positive", envDiplomailMaxBodyBytes) + } + if c.Diplomail.MaxSubjectBytes < 0 { + return fmt.Errorf("%s must not be negative", envDiplomailMaxSubjectBytes) + } + if c.Diplomail.TranslatorTimeout <= 0 { + return fmt.Errorf("%s must be positive", envDiplomailTranslatorTimeout) + } + if c.Diplomail.TranslatorMaxAttempts <= 0 { + return fmt.Errorf("%s must be positive", envDiplomailTranslatorMaxAttempts) + } + if c.Diplomail.WorkerInterval <= 0 { + return fmt.Errorf("%s must be positive", envDiplomailWorkerInterval) + } if email := strings.TrimSpace(c.Notification.AdminEmail); email != "" { if _, err := netmail.ParseAddress(email); err != nil { return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envNotificationAdminEmail, err) diff --git a/backend/internal/diplomail/README.md b/backend/internal/diplomail/README.md new file mode 100644 index 0000000..581d673 --- /dev/null +++ b/backend/internal/diplomail/README.md @@ -0,0 +1,196 @@ +# 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. + +### 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`. diff --git a/backend/internal/diplomail/admin_send.go b/backend/internal/diplomail/admin_send.go new file mode 100644 index 0000000..0b4c5c8 --- /dev/null +++ b/backend/internal/diplomail/admin_send.go @@ -0,0 +1,615 @@ +package diplomail + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// SendAdminPersonal persists an admin-kind message addressed to a +// single recipient and fan-outs the push event. The HTTP layer is +// responsible for the owner-vs-admin authorisation decision; this +// function trusts the caller designation it receives. +// +// The recipient may be in any membership status, so the lookup goes +// through MembershipLookup.GetMembershipAnyStatus. This lets the +// owner / admin reach a kicked player to explain the kick or follow +// up after a removal. +func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInput) (Message, Recipient, error) { + subject, body, err := s.prepareContent(in.Subject, in.Body) + if err != nil { + return Message{}, Recipient{}, err + } + if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil { + return Message{}, Recipient{}, err + } + + recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, in.RecipientUserID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden) + } + return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err) + } + + msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername, + recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle) + if err != nil { + return Message{}, Recipient{}, err + } + rcptInsert := buildRecipientInsert(msgInsert.MessageID, recipient, msgInsert.BodyLang, s.nowUTC()) + + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert}) + if err != nil { + return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: %w", err) + } + if len(recipients) != 1 { + return Message{}, Recipient{}, fmt.Errorf("diplomail: send admin personal: unexpected recipient count %d", len(recipients)) + } + + if recipients[0].AvailableAt != nil { s.publishMessageReceived(ctx, msg, recipients[0]) } + return msg, recipients[0], nil +} + +// SendAdminBroadcast persists an admin-kind broadcast addressed to +// every member matching `RecipientScope`, then emits one push event +// per recipient. The caller's own membership row, when present, is +// excluded from the recipient list — broadcasters do not get a copy +// of their own message. +func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastInput) (Message, []Recipient, error) { + subject, body, err := s.prepareContent(in.Subject, in.Body) + if err != nil { + return Message{}, nil, err + } + if err := validateCaller(in.CallerKind, in.CallerUserID, in.CallerUsername); err != nil { + return Message{}, nil, err + } + scope, err := normaliseScope(in.RecipientScope) + if err != nil { + return Message{}, nil, err + } + + members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, scope) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail: list members for broadcast: %w", err) + } + members = filterOutCaller(members, in.CallerUserID) + if len(members) == 0 { + return Message{}, nil, fmt.Errorf("%w: no recipients for broadcast", ErrInvalidInput) + } + + gameName := members[0].GameName + msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername, + in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast) + if err != nil { + return Message{}, nil, err + } + rcptInserts := make([]RecipientInsert, 0, len(members)) + for _, m := range members { + rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC())) + } + + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail: send admin broadcast: %w", err) + } + for _, r := range recipients { + if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) } + } + return msg, recipients, nil +} + +// SendPlayerBroadcast persists a paid-tier player broadcast and +// fans out the push event to every other active member of the game. +// The send is `kind="personal"`, `sender_kind="player"`, +// `broadcast_scope="game_broadcast"` — recipients reply to it as if +// it were a single-recipient personal send, and the reply targets +// only the broadcaster. The caller's entitlement tier is checked +// against `EntitlementReader`; free-tier callers are rejected with +// ErrForbidden. +func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcastInput) (Message, []Recipient, error) { + subject, body, err := s.prepareContent(in.Subject, in.Body) + if err != nil { + return Message{}, nil, err + } + if s.deps.Entitlements == nil { + return Message{}, nil, fmt.Errorf("%w: entitlement reader is not wired", ErrForbidden) + } + paid, err := s.deps.Entitlements.IsPaidTier(ctx, in.SenderUserID) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail: entitlement lookup: %w", err) + } + if !paid { + return Message{}, nil, fmt.Errorf("%w: in-game broadcast requires a paid tier", ErrForbidden) + } + + sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Message{}, nil, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden) + } + return Message{}, nil, fmt.Errorf("diplomail: load sender membership: %w", err) + } + + members, err := s.deps.Memberships.ListMembers(ctx, in.GameID, RecipientScopeActive) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail: list active members: %w", err) + } + callerID := in.SenderUserID + members = filterOutCaller(members, &callerID) + if len(members) == 0 { + return Message{}, nil, fmt.Errorf("%w: no other active members in this game", ErrInvalidInput) + } + + username := sender.UserName + msgInsert := MessageInsert{ + MessageID: uuid.New(), + GameID: in.GameID, + GameName: sender.GameName, + Kind: KindPersonal, + SenderKind: SenderKindPlayer, + SenderUserID: &callerID, + SenderUsername: &username, + SenderIP: in.SenderIP, + Subject: subject, + Body: body, + BodyLang: s.deps.Detector.Detect(body), + BroadcastScope: BroadcastScopeGameBroadcast, + } + rcptInserts := make([]RecipientInsert, 0, len(members)) + for _, m := range members { + rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC())) + } + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail: send player broadcast: %w", err) + } + for _, r := range recipients { + if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) } + } + return msg, recipients, nil +} + +// SendAdminMultiGameBroadcast emits one admin-kind message per game +// resolved from the input scope and fans out the push events. A +// recipient who plays in multiple addressed games receives one +// independently-deletable inbox entry per game; this avoids cross- +// game leakage of admin context and keeps the per-game unread badge +// honest. +func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiGameBroadcastInput) ([]Message, int, error) { + subject, body, err := s.prepareContent(in.Subject, in.Body) + if err != nil { + return nil, 0, err + } + if err := validateCaller(CallerKindAdmin, nil, in.CallerUsername); err != nil { + return nil, 0, err + } + scope, err := normaliseScope(in.RecipientScope) + if err != nil { + return nil, 0, err + } + if s.deps.Games == nil { + return nil, 0, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput) + } + games, err := s.resolveMultiGameTargets(ctx, in) + if err != nil { + return nil, 0, err + } + if len(games) == 0 { + return nil, 0, fmt.Errorf("%w: no games match the broadcast scope", ErrInvalidInput) + } + + totalRecipients := 0 + out := make([]Message, 0, len(games)) + for _, game := range games { + members, err := s.deps.Memberships.ListMembers(ctx, game.GameID, scope) + if err != nil { + return nil, 0, fmt.Errorf("diplomail: list members for %s: %w", game.GameID, err) + } + if len(members) == 0 { + s.deps.Logger.Debug("multi-game broadcast skips empty game", + zap.String("game_id", game.GameID.String()), + zap.String("scope", scope)) + continue + } + msgInsert, err := s.buildAdminMessageInsert(CallerKindAdmin, nil, in.CallerUsername, + game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast) + if err != nil { + return nil, 0, err + } + rcptInserts := make([]RecipientInsert, 0, len(members)) + for _, m := range members { + rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC())) + } + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts) + if err != nil { + return nil, 0, fmt.Errorf("diplomail: insert multi-game broadcast for %s: %w", game.GameID, err) + } + for _, r := range recipients { + if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) } + } + out = append(out, msg) + totalRecipients += len(recipients) + } + return out, totalRecipients, nil +} + +func (s *Service) resolveMultiGameTargets(ctx context.Context, in SendMultiGameBroadcastInput) ([]GameSnapshot, error) { + switch in.Scope { + case MultiGameScopeAllRunning: + games, err := s.deps.Games.ListRunningGames(ctx) + if err != nil { + return nil, fmt.Errorf("diplomail: list running games: %w", err) + } + return games, nil + case MultiGameScopeSelected, "": + if len(in.GameIDs) == 0 { + return nil, fmt.Errorf("%w: selected scope requires game_ids", ErrInvalidInput) + } + out := make([]GameSnapshot, 0, len(in.GameIDs)) + for _, id := range in.GameIDs { + game, err := s.deps.Games.GetGame(ctx, id) + if err != nil { + if errors.Is(err, ErrNotFound) { + return nil, fmt.Errorf("%w: game %s not found", ErrInvalidInput, id) + } + return nil, fmt.Errorf("diplomail: load game %s: %w", id, err) + } + out = append(out, game) + } + return out, nil + default: + return nil, fmt.Errorf("%w: unknown multi-game scope %q", ErrInvalidInput, in.Scope) + } +} + +// BulkCleanup deletes every diplomail_messages row tied to games that +// finished more than `OlderThanYears` years ago. Returns the affected +// game ids and the count of removed messages. The minimum allowed +// value is 1 year — finer-grained pruning would risk wiping live +// arbitration evidence. +func (s *Service) BulkCleanup(ctx context.Context, in BulkCleanupInput) (CleanupResult, error) { + if in.OlderThanYears < 1 { + return CleanupResult{}, fmt.Errorf("%w: older_than_years must be >= 1", ErrInvalidInput) + } + if s.deps.Games == nil { + return CleanupResult{}, fmt.Errorf("%w: game lookup is not wired", ErrInvalidInput) + } + cutoff := s.nowUTC().AddDate(-in.OlderThanYears, 0, 0) + games, err := s.deps.Games.ListFinishedGamesBefore(ctx, cutoff) + if err != nil { + return CleanupResult{}, fmt.Errorf("diplomail: list finished games: %w", err) + } + if len(games) == 0 { + return CleanupResult{}, nil + } + gameIDs := make([]uuid.UUID, 0, len(games)) + for _, g := range games { + gameIDs = append(gameIDs, g.GameID) + } + deleted, err := s.deps.Store.DeleteMessagesForGames(ctx, gameIDs) + if err != nil { + return CleanupResult{}, fmt.Errorf("diplomail: bulk delete: %w", err) + } + return CleanupResult{GameIDs: gameIDs, MessagesDeleted: deleted}, nil +} + +// ListMessagesForAdmin returns a paginated, optionally-filtered view +// of every persisted message. Used by the admin observability +// endpoint to inspect what has been sent and trace abuse reports. +func (s *Service) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) (AdminMessagePage, error) { + rows, total, err := s.deps.Store.ListMessagesForAdmin(ctx, filter) + if err != nil { + return AdminMessagePage{}, err + } + page := filter.Page + if page < 1 { + page = 1 + } + pageSize := filter.PageSize + if pageSize < 1 { + pageSize = 50 + } + return AdminMessagePage{ + Items: rows, + Total: total, + Page: page, + PageSize: pageSize, + }, nil +} + +// PublishLifecycle persists a system-kind message in response to a +// lobby lifecycle transition and fan-outs push events to the +// affected recipients. Game-scoped transitions (`game.paused`, +// `game.cancelled`) reach every active member; membership-scoped +// transitions (`membership.removed`, `membership.blocked`) reach the +// kicked player only. Failures inside the function are logged at +// Warn level — lifecycle hooks must not block the lobby state +// machine on a downstream mail failure. +func (s *Service) PublishLifecycle(ctx context.Context, ev LifecycleEvent) error { + switch ev.Kind { + case LifecycleKindGamePaused, LifecycleKindGameCancelled: + return s.publishGameLifecycle(ctx, ev) + case LifecycleKindMembershipRemoved, LifecycleKindMembershipBlocked: + return s.publishMembershipLifecycle(ctx, ev) + default: + return fmt.Errorf("%w: unknown lifecycle kind %q", ErrInvalidInput, ev.Kind) + } +} + +func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) error { + members, err := s.deps.Memberships.ListMembers(ctx, ev.GameID, RecipientScopeActive) + if err != nil { + return fmt.Errorf("diplomail lifecycle: list members for %s: %w", ev.GameID, err) + } + if len(members) == 0 { + s.deps.Logger.Debug("lifecycle skip: no active members", + zap.String("game_id", ev.GameID.String()), + zap.String("kind", ev.Kind)) + return nil + } + gameName := members[0].GameName + subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason) + + msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "", + ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast) + if err != nil { + return err + } + rcptInserts := make([]RecipientInsert, 0, len(members)) + for _, m := range members { + rcptInserts = append(rcptInserts, buildRecipientInsert(msgInsert.MessageID, m, msgInsert.BodyLang, s.nowUTC())) + } + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, rcptInserts) + if err != nil { + return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err) + } + for _, r := range recipients { + if r.AvailableAt != nil { s.publishMessageReceived(ctx, msg, r) } + } + return nil +} + +func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEvent) error { + if ev.TargetUser == nil { + return fmt.Errorf("%w: membership lifecycle requires TargetUser", ErrInvalidInput) + } + target, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, ev.GameID, *ev.TargetUser) + if err != nil { + return fmt.Errorf("diplomail lifecycle: load target membership: %w", err) + } + subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason) + + msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "", + ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle) + if err != nil { + return err + } + rcptInsert := buildRecipientInsert(msgInsert.MessageID, target, msgInsert.BodyLang, s.nowUTC()) + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert}) + if err != nil { + return fmt.Errorf("diplomail lifecycle: insert %s system mail: %w", ev.Kind, err) + } + if len(recipients) == 1 && recipients[0].AvailableAt != nil { + s.publishMessageReceived(ctx, msg, recipients[0]) + } + return nil +} + +// prepareContent normalises subject and body the same way SendPersonal +// does. Factored out so admin and lifecycle paths share the +// length-and-utf8 validation rules. +func (s *Service) prepareContent(subject, body string) (string, string, error) { + subj := strings.TrimRight(subject, " \t") + bod := strings.TrimRight(body, " \t\n") + if err := s.validateContent(subj, bod); err != nil { + return "", "", err + } + return subj, bod, nil +} + +// buildAdminMessageInsert encapsulates the message-row construction +// for every admin-kind send. The CHECK constraint maps sender +// shapes: +// +// sender_kind='player' → CallerKind owner; sender_user_id set +// sender_kind='admin' → CallerKind admin; sender_user_id nil +// sender_kind='system' → CallerKind system; sender_username nil +func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.UUID, callerUsername string, + gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) { + out := MessageInsert{ + MessageID: uuid.New(), + GameID: gameID, + GameName: gameName, + Kind: KindAdmin, + SenderIP: senderIP, + Subject: subject, + Body: body, + BodyLang: s.deps.Detector.Detect(body), + BroadcastScope: scope, + } + switch callerKind { + case CallerKindOwner: + if callerUserID == nil { + return MessageInsert{}, fmt.Errorf("%w: owner send requires caller user id", ErrInvalidInput) + } + uid := *callerUserID + uname := callerUsername + out.SenderKind = SenderKindPlayer + out.SenderUserID = &uid + out.SenderUsername = &uname + case CallerKindAdmin: + uname := callerUsername + out.SenderKind = SenderKindAdmin + out.SenderUsername = &uname + case CallerKindSystem: + out.SenderKind = SenderKindSystem + default: + return MessageInsert{}, fmt.Errorf("%w: unknown caller kind %q", ErrInvalidInput, callerKind) + } + return out, nil +} + +// buildRecipientInsert turns a MemberSnapshot into a RecipientInsert. +// The race-name snapshot is nullable so a kicked player with no race +// name on file is still addressable. +// +// `bodyLang` is the detected language of the message body. When the +// recipient's preferred_language matches body_lang (or body_lang is +// undetermined), the function fills AvailableAt with `now` so the +// recipient row is materialised already-delivered; otherwise +// AvailableAt stays nil and the translation worker takes over. +func buildRecipientInsert(messageID uuid.UUID, m MemberSnapshot, bodyLang string, now time.Time) RecipientInsert { + in := RecipientInsert{ + RecipientID: uuid.New(), + MessageID: messageID, + GameID: m.GameID, + UserID: m.UserID, + RecipientUserName: m.UserName, + RecipientPreferredLanguage: normaliseLang(m.PreferredLanguage), + } + if m.RaceName != "" { + race := m.RaceName + in.RecipientRaceName = &race + } + if needsTranslation(bodyLang, in.RecipientPreferredLanguage) { + // AvailableAt left nil → worker will deliver after the + // translation cache is materialised (or after fallback). + } else { + t := now.UTC() + in.AvailableAt = &t + } + return in +} + +// needsTranslation reports whether a recipient with preferredLang +// needs to wait for a translated rendering before the message is +// considered delivered. Undetermined body language and empty +// recipient preferences are short-circuited to "no translation +// needed" so we never block delivery on something the detector +// could not label. +func needsTranslation(bodyLang, preferredLang string) bool { + bodyLang = normaliseLang(bodyLang) + preferredLang = normaliseLang(preferredLang) + if bodyLang == "" || bodyLang == LangUndetermined { + return false + } + if preferredLang == "" || preferredLang == LangUndetermined { + return false + } + return bodyLang != preferredLang +} + +// normaliseLang strips any region subtag and lowercases the result so +// `en-US` and `EN` both collapse to `en`. The diplomail layer uses +// ISO 639-1 codes; whatlanggo and LibreTranslate share that +// vocabulary. +func normaliseLang(tag string) string { + tag = strings.TrimSpace(tag) + if tag == "" { + return "" + } + if i := strings.IndexAny(tag, "-_"); i > 0 { + tag = tag[:i] + } + return strings.ToLower(tag) +} + +func validateCaller(callerKind string, callerUserID *uuid.UUID, callerUsername string) error { + switch callerKind { + case CallerKindOwner: + if callerUserID == nil { + return fmt.Errorf("%w: owner send requires caller_user_id", ErrInvalidInput) + } + if callerUsername == "" { + return fmt.Errorf("%w: owner send requires caller_username", ErrInvalidInput) + } + case CallerKindAdmin: + if callerUsername == "" { + return fmt.Errorf("%w: admin send requires caller_username", ErrInvalidInput) + } + case CallerKindSystem: + // no extra checks + default: + return fmt.Errorf("%w: unknown caller_kind %q", ErrInvalidInput, callerKind) + } + return nil +} + +func normaliseScope(scope string) (string, error) { + switch scope { + case "", RecipientScopeActive: + return RecipientScopeActive, nil + case RecipientScopeActiveAndRemoved, RecipientScopeAllMembers: + return scope, nil + default: + return "", fmt.Errorf("%w: unknown recipient scope %q", ErrInvalidInput, scope) + } +} + +func filterOutCaller(members []MemberSnapshot, callerUserID *uuid.UUID) []MemberSnapshot { + if callerUserID == nil { + return members + } + out := make([]MemberSnapshot, 0, len(members)) + for _, m := range members { + if m.UserID == *callerUserID { + continue + } + out = append(out, m) + } + return out +} + +// renderGameLifecycle returns the (subject, body) pair persisted for +// the `game.paused` / `game.cancelled` system message. Bodies are in +// English; Stage D will translate them on demand into each +// recipient's preferred_language and cache the result. +func renderGameLifecycle(kind, gameName, actor, reason string) (string, string) { + actor = strings.TrimSpace(actor) + if actor == "" { + actor = "the system" + } + reasonTail := "" + if r := strings.TrimSpace(reason); r != "" { + reasonTail = " Reason: " + r + "." + } + switch kind { + case LifecycleKindGamePaused: + return "Game paused", + fmt.Sprintf("The game %q has been paused by %s.%s", gameName, actor, reasonTail) + case LifecycleKindGameCancelled: + return "Game cancelled", + fmt.Sprintf("The game %q has been cancelled by %s.%s", gameName, actor, reasonTail) + } + return "Game lifecycle update", + fmt.Sprintf("The game %q has changed state.%s", gameName, reasonTail) +} + +// renderMembershipLifecycle returns the (subject, body) pair persisted +// for the `membership.removed` / `membership.blocked` system message. +func renderMembershipLifecycle(kind, gameName, actor, reason string) (string, string) { + actor = strings.TrimSpace(actor) + if actor == "" { + actor = "the system" + } + reasonTail := "" + if r := strings.TrimSpace(reason); r != "" { + reasonTail = " Reason: " + r + "." + } + switch kind { + case LifecycleKindMembershipRemoved: + return "Membership removed", + fmt.Sprintf("Your membership in %q has been removed by %s.%s", gameName, actor, reasonTail) + case LifecycleKindMembershipBlocked: + return "Membership blocked", + fmt.Sprintf("Your membership in %q has been blocked by %s.%s", gameName, actor, reasonTail) + } + return "Membership update", + fmt.Sprintf("Your membership in %q has changed.%s", gameName, reasonTail) +} diff --git a/backend/internal/diplomail/deps.go b/backend/internal/diplomail/deps.go new file mode 100644 index 0000000..abc33ab --- /dev/null +++ b/backend/internal/diplomail/deps.go @@ -0,0 +1,181 @@ +package diplomail + +import ( + "context" + "time" + + "galaxy/backend/internal/config" + "galaxy/backend/internal/diplomail/detector" + "galaxy/backend/internal/diplomail/translator" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// Deps aggregates every collaborator the diplomail Service depends on. +// +// Store and Memberships are required. Logger and Now default to +// zap.NewNop / time.Now when nil. Notification falls back to a no-op +// publisher so unit tests can construct a Service with only the +// required collaborators populated. Entitlements and Games are +// optional — they are used by Stage C surfaces (paid-tier player +// broadcast, multi-game admin broadcast, bulk cleanup). Wiring may +// pass nil for tests that do not exercise those paths. +type Deps struct { + Store *Store + Memberships MembershipLookup + Notification NotificationPublisher + Entitlements EntitlementReader + Games GameLookup + Detector detector.LanguageDetector + Translator translator.Translator + Config config.DiplomailConfig + Logger *zap.Logger + Now func() time.Time +} + +// EntitlementReader is the read-only surface diplomail uses to gate +// the paid-tier player broadcast. The canonical implementation in +// `cmd/backend/main` reads +// `*user.Service.GetEntitlementSnapshot(userID).IsPaid`. +type EntitlementReader interface { + IsPaidTier(ctx context.Context, userID uuid.UUID) (bool, error) +} + +// GameLookup exposes the slim view of `games` the multi-game admin +// broadcast and bulk-cleanup paths consume. The canonical +// implementation walks the lobby cache plus an explicit store call +// for finished-game pruning. +type GameLookup interface { + // ListRunningGames returns every game whose `status` is one of + // the still-active values (running, paused, starting, …). The + // admin `all_running` broadcast scope iterates over the result. + ListRunningGames(ctx context.Context) ([]GameSnapshot, error) + + // ListFinishedGamesBefore returns every game whose `finished_at` + // is older than `cutoff`. The bulk-purge admin endpoint reads + // this to compose the cascade-delete IN list. + ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameSnapshot, error) + + // GetGame returns one game snapshot identified by id, or + // ErrNotFound. Used by the multi-game broadcast to verify the + // caller-supplied id list before enqueuing fan-out work. + GetGame(ctx context.Context, gameID uuid.UUID) (GameSnapshot, error) +} + +// GameSnapshot is the trim view of `games` consumed by the multi-game +// admin broadcast and the cleanup paths. The struct intentionally +// avoids the full `lobby.GameRecord` so the diplomail package stays +// decoupled from the lobby domain. +type GameSnapshot struct { + GameID uuid.UUID + GameName string + Status string + FinishedAt *time.Time +} + +// ActiveMembership is the slim view of a single (user, game) roster +// row the diplomail package needs at send time: it confirms the +// participant is active in the game and captures the snapshot fields +// (`game_name`, `user_name`, `race_name`, `preferred_language`) that +// we persist on each new message / recipient row. +type ActiveMembership struct { + UserID uuid.UUID + GameID uuid.UUID + GameName string + UserName string + RaceName string + PreferredLanguage string +} + +// MembershipLookup is the read-only surface diplomail uses to verify +// "is this user an active member of this game" and to snapshot the +// roster metadata. The canonical implementation in `cmd/backend/main` +// adapts the `*lobby.Service` membership cache to this interface. +// +// GetActiveMembership returns ErrNotFound (the diplomail sentinel) +// when the user is not an active member of the game; the service +// boundary maps that to 403 forbidden. +// +// GetMembershipAnyStatus returns the same shape regardless of +// membership status (`active`, `removed`, `blocked`). Used by the +// inbox read path to check whether a kicked recipient still belongs +// to the game's roster; ErrNotFound is surfaced when the user has +// never been a member. +// +// ListMembers returns every roster row matching scope, in stable +// order. Scope values are `active`, `active_and_removed`, and +// `all_members` (the spec calls these out by name). Used by the +// broadcast composition step in admin / owner sends. +type MembershipLookup interface { + GetActiveMembership(ctx context.Context, gameID, userID uuid.UUID) (ActiveMembership, error) + GetMembershipAnyStatus(ctx context.Context, gameID, userID uuid.UUID) (MemberSnapshot, error) + ListMembers(ctx context.Context, gameID uuid.UUID, scope string) ([]MemberSnapshot, error) +} + +// Recipient scope values accepted by ListMembers and by the +// `recipients` request field on admin / owner broadcasts. +const ( + RecipientScopeActive = "active" + RecipientScopeActiveAndRemoved = "active_and_removed" + RecipientScopeAllMembers = "all_members" +) + +// MemberSnapshot is the slim view of a membership row that survives +// all three status values. RaceName is the immutable string captured +// at registration time; an empty value is legal for rare cases where +// the row was inserted without one. PreferredLanguage is included so +// the broadcast and lifecycle paths can decide whether the recipient +// needs to wait for a translation before delivery. +type MemberSnapshot struct { + UserID uuid.UUID + GameID uuid.UUID + GameName string + UserName string + RaceName string + PreferredLanguage string + Status string +} + +// NotificationPublisher is the outbound surface diplomail uses to +// emit the `diplomail.message.received` push event. The canonical +// implementation in `cmd/backend/main` adapts the notification.Service +// the same way it adapts `lobby.NotificationPublisher`; tests pass +// the no-op publisher below to avoid wiring the dispatcher. +type NotificationPublisher interface { + PublishDiplomailEvent(ctx context.Context, ev DiplomailNotification) error +} + +// DiplomailNotification is the open shape carried by a per-recipient +// push intent. The struct lives in the diplomail package so the +// producer vocabulary stays here; the publisher adapter translates it +// into a `notification.Intent` at the wiring boundary. +type DiplomailNotification struct { + Kind string + IdempotencyKey string + Recipient uuid.UUID + Payload map[string]any +} + +// NewNoopNotificationPublisher returns a publisher that logs every +// call at debug level and returns nil. Used by unit tests and as the +// fallback inside NewService when callers leave Deps.Notification nil. +func NewNoopNotificationPublisher(logger *zap.Logger) NotificationPublisher { + if logger == nil { + logger = zap.NewNop() + } + return &noopNotificationPublisher{logger: logger.Named("diplomail.notify.noop")} +} + +type noopNotificationPublisher struct { + logger *zap.Logger +} + +func (p *noopNotificationPublisher) PublishDiplomailEvent(_ context.Context, ev DiplomailNotification) error { + p.logger.Debug("noop notification", + zap.String("kind", ev.Kind), + zap.String("idempotency_key", ev.IdempotencyKey), + zap.String("recipient", ev.Recipient.String()), + ) + return nil +} diff --git a/backend/internal/diplomail/detector/detector.go b/backend/internal/diplomail/detector/detector.go new file mode 100644 index 0000000..e8717eb --- /dev/null +++ b/backend/internal/diplomail/detector/detector.go @@ -0,0 +1,79 @@ +// Package detector wraps the body-language detection used by the +// diplomail subsystem. The package exposes a narrow `LanguageDetector` +// interface so the implementation can be swapped without touching the +// callers; the default backed-by-whatlanggo detector handles 84 +// natural languages and ships with the embedded statistical profiles. +// +// Detection happens only on the body. Subjects are short and +// frequently template-like ("Re: ..."), so detecting on them adds +// noise. The diplomail Service feeds the body, captures the BCP 47 +// tag returned here, and stores it in `diplomail_messages.body_lang`. +package detector + +import ( + "strings" + "unicode/utf8" + + "github.com/abadojack/whatlanggo" +) + +// Undetermined is the BCP 47 placeholder stored when detection cannot +// confidently identify a language (empty body, too-short body, mixed +// scripts the detector refuses to bet on). +const Undetermined = "und" + +// LanguageDetector is the read-only surface diplomail consumes when +// it needs to label a message body. Detect must never panic and +// must never return an error: detection failure simply yields +// `Undetermined`. +type LanguageDetector interface { + Detect(body string) string +} + +// New returns the package-default detector backed by `whatlanggo`. +// The instance is safe for concurrent use; whatlanggo's `Detect` +// reads the embedded profiles without state mutation. Callers that +// want a fixed allow-list can build their own implementation around +// the same interface. +func New() LanguageDetector { + return &whatlangDetector{} +} + +type whatlangDetector struct{} + +// minRunes is the lower bound on body length below which whatlanggo +// can flip between near-synonyms; for shorter bodies we return +// `Undetermined` and let the noop translator skip the slot. The +// value matches whatlanggo's documented "stable above ~25 runes" +// guidance. +const minRunes = 25 + +// Detect returns the BCP 47 tag for body, or `Undetermined` when the +// body is empty / too short / whatlanggo refuses to label it. The +// trim is applied so leading whitespace does not bias the script +// detector toward Latin. We deliberately do not gate on +// `info.IsReliable()` because the gate is too conservative for the +// short sentences typical of in-game mail; a misclassification only +// hurts the translation cache key, never correctness. +func (d *whatlangDetector) Detect(body string) string { + body = strings.TrimSpace(body) + if body == "" { + return Undetermined + } + if utf8.RuneCountInString(body) < minRunes { + return Undetermined + } + info := whatlanggo.Detect(body) + tag := info.Lang.Iso6391() + if tag == "" { + return Undetermined + } + return tag +} + +// NoopDetector returns the placeholder unconditionally. Used by +// tests and by Stage A code paths that predate the real detector. +type NoopDetector struct{} + +// Detect always returns `Undetermined` regardless of input. +func (NoopDetector) Detect(string) string { return Undetermined } diff --git a/backend/internal/diplomail/detector/detector_test.go b/backend/internal/diplomail/detector/detector_test.go new file mode 100644 index 0000000..f7f441c --- /dev/null +++ b/backend/internal/diplomail/detector/detector_test.go @@ -0,0 +1,49 @@ +package detector + +import "testing" + +func TestDetectKnownLanguages(t *testing.T) { + t.Parallel() + d := New() + cases := []struct { + name string + text string + want string + }{ + { + name: "english paragraph", + text: "The trade agreement should be signed before the next turn. " + + "I expect a written response by the time the engine generates the next report.", + want: "en", + }, + { + name: "russian paragraph", + text: "Привет! Я предлагаю заключить дипломатическое соглашение и провести " + + "совместную операцию по освоению гиперпространственных маршрутов. " + + "Жду твоего письменного ответа до конца следующего хода игры, " + + "чтобы мы успели согласовать детали и подписать договор вовремя.", + want: "ru", + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := d.Detect(tc.text) + if got != tc.want { + t.Fatalf("Detect = %q, want %q", got, tc.want) + } + }) + } +} + +func TestDetectShortOrEmpty(t *testing.T) { + t.Parallel() + d := New() + short := []string{"", "hi", " "} + for _, s := range short { + if got := d.Detect(s); got != Undetermined { + t.Errorf("Detect(%q) = %q, want %q", s, got, Undetermined) + } + } +} diff --git a/backend/internal/diplomail/diplomail.go b/backend/internal/diplomail/diplomail.go new file mode 100644 index 0000000..c3655be --- /dev/null +++ b/backend/internal/diplomail/diplomail.go @@ -0,0 +1,135 @@ +// Package 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 +// in-game: lobby exposes only an unread-count badge per game while the +// in-game mail view reads and writes through this package. +// +// Stage A implements the personal single-recipient subset: +// +// - send/read/mark-read/soft-delete handlers for a player addressing +// one other active member of the game; +// - a push event (`diplomail.message.received`) materialised through +// the existing notification pipeline so the recipient gets a live +// toast when online; +// - an unread-counts endpoint that drives the lobby badge. +// +// Later stages add admin/owner/system mail, lifecycle hooks, paid-tier +// player broadcasts, multi-game broadcasts, bulk purge, and the +// language-detection / translation cache. +package diplomail + +import ( + "time" + + "galaxy/backend/internal/config" + "galaxy/backend/internal/diplomail/detector" + "galaxy/backend/internal/diplomail/translator" + + "go.uber.org/zap" +) + +// Kind values stored verbatim in `diplomail_messages.kind`. The schema +// CHECK constraint pins this to the closed set declared below. +const ( + // KindPersonal is a replyable player-to-player message. The + // sender is always a `sender_kind='player'`. + KindPersonal = "personal" + + // KindAdmin is a non-replyable administrative notification. + // The sender is either a human admin (`sender_kind='admin'`) + // or the system itself (`sender_kind='system'`). + KindAdmin = "admin" +) + +// Sender kind values stored verbatim in `diplomail_messages.sender_kind`. +const ( + // SenderKindPlayer marks the sender as an end-user account. + // `sender_user_id` and `sender_username` carry the player's id + // and immutable `accounts.user_name`. + SenderKindPlayer = "player" + + // SenderKindAdmin marks the sender as a site administrator. + // `sender_username` carries `admin_accounts.username`. + SenderKindAdmin = "admin" + + // SenderKindSystem marks the sender as the service itself + // (lifecycle hooks). Both id and username are NULL. + SenderKindSystem = "system" +) + +// Broadcast scope values stored verbatim in +// `diplomail_messages.broadcast_scope`. Stage A only emits `single`; +// Stage B / C add `game_broadcast` and `multi_game_broadcast`. +const ( + BroadcastScopeSingle = "single" + BroadcastScopeGameBroadcast = "game_broadcast" + BroadcastScopeMultiGameBroadcast = "multi_game_broadcast" +) + +// LangUndetermined is the BCP 47 placeholder stored in +// `diplomail_messages.body_lang` when language detection has not yet +// been performed or could not produce a result. Stage A writes this +// value unconditionally; Stage D replaces it with the detected tag. +const LangUndetermined = "und" + +// Service is the diplomatic-mail entry point. Every public method is +// goroutine-safe; concurrency safety is delegated to Postgres for +// persisted state. +type Service struct { + deps Deps +} + +// NewService constructs a Service from deps. Logger and Now are +// defaulted; Store must be non-nil and Memberships must be non-nil +// because every send path queries the active membership roster. +func NewService(deps Deps) *Service { + if deps.Logger == nil { + deps.Logger = zap.NewNop() + } + deps.Logger = deps.Logger.Named("diplomail") + if deps.Now == nil { + deps.Now = time.Now + } + if deps.Notification == nil { + deps.Notification = NewNoopNotificationPublisher(deps.Logger) + } + if deps.Detector == nil { + deps.Detector = detector.NoopDetector{} + } + if deps.Translator == nil { + deps.Translator = translator.NewNoop() + } + if deps.Config.MaxBodyBytes <= 0 { + deps.Config.MaxBodyBytes = 4096 + } + if deps.Config.MaxSubjectBytes < 0 { + deps.Config.MaxSubjectBytes = 256 + } + return &Service{deps: deps} +} + +// Config returns the service's runtime configuration. Tests and the +// HTTP layer occasionally surface the limits to clients (the OpenAPI +// schema documents them too). +func (s *Service) Config() config.DiplomailConfig { + if s == nil { + return config.DiplomailConfig{} + } + return s.deps.Config +} + +// Logger returns the package-named logger. Used by the optional async +// worker and by tests asserting on log output. +func (s *Service) Logger() *zap.Logger { + if s == nil { + return zap.NewNop() + } + return s.deps.Logger +} + +// nowUTC returns the configured clock normalised to UTC. Matches the +// convention used everywhere else in `backend` so persisted +// timestamps compare cleanly regardless of host timezone. +func (s *Service) nowUTC() time.Time { + return s.deps.Now().UTC() +} diff --git a/backend/internal/diplomail/diplomail_e2e_test.go b/backend/internal/diplomail/diplomail_e2e_test.go new file mode 100644 index 0000000..3f85830 --- /dev/null +++ b/backend/internal/diplomail/diplomail_e2e_test.go @@ -0,0 +1,1163 @@ +package diplomail_test + +import ( + "context" + "database/sql" + "errors" + "net/url" + "sync" + "testing" + "time" + + "galaxy/backend/internal/config" + "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/diplomail/translator" + backendpg "galaxy/backend/internal/postgres" + pgshared "galaxy/postgres" + + "github.com/google/uuid" + testcontainers "github.com/testcontainers/testcontainers-go" + tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + testImage = "postgres:16-alpine" + testUser = "galaxy" + testPassword = "galaxy" + testDatabase = "galaxy_backend" + testSchema = "backend" + testStartup = 90 * time.Second + testOpTimeout = 10 * time.Second +) + +// startPostgres mirrors the harness used by `lobby_e2e_test.go`. It +// spins up a postgres:16-alpine container, applies the embedded +// migrations, and returns a ready-to-use `*sql.DB`. The container is +// torn down via t.Cleanup. +func startPostgres(t *testing.T) *sql.DB { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + pgContainer, err := tcpostgres.Run(ctx, testImage, + tcpostgres.WithDatabase(testDatabase), + tcpostgres.WithUsername(testUser), + tcpostgres.WithPassword(testPassword), + testcontainers.WithWaitStrategy( + wait.ForLog("database system is ready to accept connections"). + WithOccurrence(2). + WithStartupTimeout(testStartup), + ), + ) + if err != nil { + t.Skipf("postgres testcontainer unavailable, skipping: %v", err) + } + t.Cleanup(func() { + if termErr := testcontainers.TerminateContainer(pgContainer); termErr != nil { + t.Errorf("terminate postgres container: %v", termErr) + } + }) + + baseDSN, err := pgContainer.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("connection string: %v", err) + } + scopedDSN, err := dsnWithSearchPath(baseDSN, testSchema) + if err != nil { + t.Fatalf("scope dsn: %v", err) + } + cfg := pgshared.DefaultConfig() + cfg.PrimaryDSN = scopedDSN + cfg.OperationTimeout = testOpTimeout + db, err := pgshared.OpenPrimary(ctx, cfg, backendpg.NoObservabilityOptions()...) + if err != nil { + t.Fatalf("open primary: %v", err) + } + t.Cleanup(func() { + if err := db.Close(); err != nil { + t.Errorf("close db: %v", err) + } + }) + if err := pgshared.Ping(ctx, db, cfg.OperationTimeout); err != nil { + t.Fatalf("ping: %v", err) + } + if err := backendpg.ApplyMigrations(ctx, db); err != nil { + t.Fatalf("apply migrations: %v", err) + } + return db +} + +func dsnWithSearchPath(baseDSN, schema string) (string, error) { + parsed, err := url.Parse(baseDSN) + if err != nil { + return "", err + } + values := parsed.Query() + values.Set("search_path", schema) + if values.Get("sslmode") == "" { + values.Set("sslmode", "disable") + } + parsed.RawQuery = values.Encode() + return parsed.String(), nil +} + +// recordingPublisher captures every emitted DiplomailNotification so +// the test can assert push fan-out without booting the real +// notification pipeline. +type recordingPublisher struct { + mu sync.Mutex + captured []diplomail.DiplomailNotification +} + +func (p *recordingPublisher) PublishDiplomailEvent(_ context.Context, ev diplomail.DiplomailNotification) error { + p.mu.Lock() + defer p.mu.Unlock() + p.captured = append(p.captured, ev) + return nil +} + +func (p *recordingPublisher) snapshot() []diplomail.DiplomailNotification { + p.mu.Lock() + defer p.mu.Unlock() + out := make([]diplomail.DiplomailNotification, len(p.captured)) + copy(out, p.captured) + return out +} + +// staticMembershipLookup serves an in-memory fixture. The test seeds +// memberships up-front and the lookup is keyed on (gameID, userID). +// Inactive rows (status != "active") are encoded by populating +// `inactive` instead of `rows`. +type staticMembershipLookup struct { + rows map[[2]uuid.UUID]diplomail.ActiveMembership + inactive map[[2]uuid.UUID]diplomail.MemberSnapshot +} + +func (l *staticMembershipLookup) GetActiveMembership(_ context.Context, gameID, userID uuid.UUID) (diplomail.ActiveMembership, error) { + if l == nil || l.rows == nil { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + row, ok := l.rows[[2]uuid.UUID{gameID, userID}] + if !ok { + return diplomail.ActiveMembership{}, diplomail.ErrNotFound + } + return row, nil +} + +func (l *staticMembershipLookup) GetMembershipAnyStatus(_ context.Context, gameID, userID uuid.UUID) (diplomail.MemberSnapshot, error) { + if l == nil { + return diplomail.MemberSnapshot{}, diplomail.ErrNotFound + } + if row, ok := l.rows[[2]uuid.UUID{gameID, userID}]; ok { + return diplomail.MemberSnapshot{ + UserID: row.UserID, + GameID: row.GameID, + GameName: row.GameName, + UserName: row.UserName, + RaceName: row.RaceName, + Status: "active", + }, nil + } + if row, ok := l.inactive[[2]uuid.UUID{gameID, userID}]; ok { + return row, nil + } + return diplomail.MemberSnapshot{}, diplomail.ErrNotFound +} + +func (l *staticMembershipLookup) ListMembers(_ context.Context, gameID uuid.UUID, scope string) ([]diplomail.MemberSnapshot, error) { + if l == nil { + return nil, nil + } + var out []diplomail.MemberSnapshot + for key, row := range l.rows { + if key[0] != gameID { + continue + } + out = append(out, diplomail.MemberSnapshot{ + UserID: row.UserID, + GameID: row.GameID, + GameName: row.GameName, + UserName: row.UserName, + RaceName: row.RaceName, + Status: "active", + }) + } + if scope == diplomail.RecipientScopeActiveAndRemoved || scope == diplomail.RecipientScopeAllMembers { + for key, row := range l.inactive { + if key[0] != gameID { + continue + } + if scope == diplomail.RecipientScopeActiveAndRemoved && row.Status != "removed" { + continue + } + out = append(out, row) + } + } + return out, nil +} + +// seedAccount inserts a minimal accounts row so memberships and mail +// recipient FKs are satisfiable. +func seedAccount(t *testing.T, db *sql.DB, userID uuid.UUID) { + t.Helper() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO backend.accounts ( + user_id, email, user_name, preferred_language, time_zone + ) VALUES ($1, $2, $3, 'en', 'UTC') + `, userID, userID.String()+"@test.local", "user-"+userID.String()[:8]) + if err != nil { + t.Fatalf("seed account %s: %v", userID, err) + } +} + +// seedGame inserts a minimal games row so the diplomail_messages.game_id +// FK is satisfiable. +func seedGame(t *testing.T, db *sql.DB, gameID uuid.UUID, name string) { + t.Helper() + _, err := db.ExecContext(context.Background(), ` + INSERT INTO backend.games ( + game_id, visibility, status, game_name, + min_players, max_players, start_gap_hours, start_gap_players, + enrollment_ends_at, turn_schedule, target_engine_version, + runtime_snapshot + ) VALUES ( + $1, 'private', 'enrollment_open', $2, + 1, 4, 1, 1, + now() + interval '1 day', '0 0 * * *', '1.0.0', + '{}'::jsonb + ) + `, gameID, name) + if err != nil { + t.Fatalf("seed game %s: %v", gameID, err) + } +} + +func TestDiplomailPersonalFlow(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + other := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedAccount(t, db, other) + seedGame(t, db, gameID, "Stage A Test Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, sender}: { + UserID: sender, GameID: gameID, GameName: "Stage A Test Game", + UserName: "sender", RaceName: "Senders", + }, + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Stage A Test Game", + UserName: "recipient", RaceName: "Receivers", + }, + }, + } + publisher := &recordingPublisher{} + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + // 1. SendPersonal happy path. + msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + Subject: "Trade proposal", + Body: "Care to talk gas mining?", + SenderIP: "203.0.113.4", + }) + if err != nil { + t.Fatalf("send personal: %v", err) + } + if msg.Kind != diplomail.KindPersonal { + t.Fatalf("kind = %q, want personal", msg.Kind) + } + if rcpt.UserID != recipient { + t.Fatalf("recipient.UserID = %s, want %s", rcpt.UserID, recipient) + } + if rcpt.ReadAt != nil { + t.Fatalf("freshly sent message should be unread, read_at=%v", rcpt.ReadAt) + } + if got := publisher.snapshot(); len(got) != 1 { + t.Fatalf("publisher captured %d events, want 1", len(got)) + } else if got[0].Recipient != recipient { + t.Fatalf("push recipient = %s, want %s", got[0].Recipient, recipient) + } + + // 2. ListInbox shows the message for the recipient. + inbox, err := svc.ListInbox(ctx, gameID, recipient, "") + if err != nil { + t.Fatalf("list inbox: %v", err) + } + if len(inbox) != 1 || inbox[0].MessageID != msg.MessageID { + t.Fatalf("inbox = %+v, want one matching entry", inbox) + } + + // 3. ListSent surfaces the message for the sender. + sent, err := svc.ListSent(ctx, gameID, sender) + if err != nil { + t.Fatalf("list sent: %v", err) + } + if len(sent) != 1 || sent[0].MessageID != msg.MessageID { + t.Fatalf("sent = %+v, want one matching entry", sent) + } + + // 4. Non-recipient reads are 404. + if _, err := svc.GetMessage(ctx, other, msg.MessageID, ""); !errors.Is(err, diplomail.ErrNotFound) { + t.Fatalf("non-recipient get: %v, want ErrNotFound", err) + } + + // 5. Delete before read is a conflict. + if _, err := svc.DeleteMessage(ctx, recipient, msg.MessageID); !errors.Is(err, diplomail.ErrConflict) { + t.Fatalf("delete before read: %v, want ErrConflict", err) + } + + // 6. MarkRead sets read_at; second call is a no-op. + read, err := svc.MarkRead(ctx, recipient, msg.MessageID) + if err != nil { + t.Fatalf("mark read: %v", err) + } + if read.ReadAt == nil { + t.Fatalf("mark read returned no read_at") + } + again, err := svc.MarkRead(ctx, recipient, msg.MessageID) + if err != nil { + t.Fatalf("mark read idempotent: %v", err) + } + if !again.ReadAt.Equal(*read.ReadAt) { + t.Fatalf("mark read idempotent shifted read_at: %v -> %v", read.ReadAt, again.ReadAt) + } + + // 7. Unread counts go to zero after the read. + counts, err := svc.UnreadCountsForUser(ctx, recipient) + if err != nil { + t.Fatalf("unread counts: %v", err) + } + if len(counts) != 0 { + t.Fatalf("unread counts = %+v, want empty after read", counts) + } + + // 8. Soft delete now succeeds. + deleted, err := svc.DeleteMessage(ctx, recipient, msg.MessageID) + if err != nil { + t.Fatalf("delete after read: %v", err) + } + if deleted.DeletedAt == nil { + t.Fatalf("delete after read returned no deleted_at") + } + + // 9. Inbox now excludes the soft-deleted message. + inbox, err = svc.ListInbox(ctx, gameID, recipient, "") + if err != nil { + t.Fatalf("list inbox after delete: %v", err) + } + if len(inbox) != 0 { + t.Fatalf("inbox after delete = %+v, want empty", inbox) + } +} + +func TestDiplomailRejectsNonActiveSender(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedGame(t, db, gameID, "Solo Test Game") + + // Only the recipient is on the active roster. + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Solo Test Game", + UserName: "recipient", RaceName: "Receivers", + }, + }, + } + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + Subject: "Hi", + Body: "Trade?", + }) + if !errors.Is(err, diplomail.ErrForbidden) { + t.Fatalf("send from non-member: %v, want ErrForbidden", err) + } +} + +func TestDiplomailAdminBroadcast(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + owner := uuid.New() + alice := uuid.New() + bob := uuid.New() + kickedCharlie := uuid.New() + seedAccount(t, db, owner) + seedAccount(t, db, alice) + seedAccount(t, db, bob) + seedAccount(t, db, kickedCharlie) + seedGame(t, db, gameID, "Broadcast Test Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, alice}: { + UserID: alice, GameID: gameID, GameName: "Broadcast Test Game", + UserName: "alice", RaceName: "AliceRace", + }, + {gameID, bob}: { + UserID: bob, GameID: gameID, GameName: "Broadcast Test Game", + UserName: "bob", RaceName: "BobRace", + }, + }, + inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{ + {gameID, kickedCharlie}: { + UserID: kickedCharlie, GameID: gameID, GameName: "Broadcast Test Game", + UserName: "charlie", RaceName: "CharlieRace", Status: "removed", + }, + }, + } + publisher := &recordingPublisher{} + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + ownerID := owner + msg, recipients, err := svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{ + GameID: gameID, + CallerKind: diplomail.CallerKindOwner, + CallerUserID: &ownerID, + CallerUsername: "owner", + RecipientScope: diplomail.RecipientScopeActive, + Subject: "All hands", + Body: "Welcome to round two.", + SenderIP: "203.0.113.7", + }) + if err != nil { + t.Fatalf("admin broadcast: %v", err) + } + if msg.Kind != diplomail.KindAdmin || msg.SenderKind != diplomail.SenderKindPlayer { + t.Fatalf("kind=%q sender_kind=%q, want admin/player", msg.Kind, msg.SenderKind) + } + if len(recipients) != 2 { + t.Fatalf("broadcast hit %d recipients, want 2 (alice+bob, kicked charlie excluded by active scope)", len(recipients)) + } + if got := publisher.snapshot(); len(got) != 2 { + t.Fatalf("publisher captured %d events, want 2", len(got)) + } + + // active_and_removed should include the kicked recipient too. + msg2, recipients2, err := svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{ + GameID: gameID, + CallerKind: diplomail.CallerKindAdmin, + CallerUsername: "site-admin", + RecipientScope: diplomail.RecipientScopeActiveAndRemoved, + Body: "Post-game retrospective.", + }) + if err != nil { + t.Fatalf("admin broadcast active_and_removed: %v", err) + } + if msg2.SenderKind != diplomail.SenderKindAdmin { + t.Fatalf("sender_kind=%q, want admin", msg2.SenderKind) + } + if len(recipients2) != 3 { + t.Fatalf("active_and_removed broadcast hit %d, want 3", len(recipients2)) + } + + // Kicked charlie sees the admin message but not the personal mail + // that alice might have sent before the kick (none here — the + // store path itself is exercised; the soft-access filter belongs + // to a separate test below). + charlieInbox, err := svc.ListInbox(ctx, gameID, kickedCharlie, "") + if err != nil { + t.Fatalf("kicked inbox: %v", err) + } + if len(charlieInbox) != 1 { + t.Fatalf("kicked inbox = %d entries, want 1 (only the active_and_removed broadcast)", len(charlieInbox)) + } +} + +// staticEntitlement satisfies diplomail.EntitlementReader by reading +// a fixed map keyed on user_id. +type staticEntitlement struct { + paid map[uuid.UUID]bool +} + +func (s *staticEntitlement) IsPaidTier(_ context.Context, userID uuid.UUID) (bool, error) { + if s == nil { + return false, nil + } + return s.paid[userID], nil +} + +// staticGameLookup satisfies diplomail.GameLookup by walking a fixed +// list of GameSnapshot fixtures. Tests prepend rows via the New +// helper. +type staticGameLookup struct { + games map[uuid.UUID]diplomail.GameSnapshot +} + +func (l *staticGameLookup) ListRunningGames(_ context.Context) ([]diplomail.GameSnapshot, error) { + if l == nil { + return nil, nil + } + out := make([]diplomail.GameSnapshot, 0, len(l.games)) + for _, g := range l.games { + switch g.Status { + case "running", "paused", "ready_to_start", "starting": + out = append(out, g) + } + } + return out, nil +} + +func (l *staticGameLookup) ListFinishedGamesBefore(_ context.Context, cutoff time.Time) ([]diplomail.GameSnapshot, error) { + if l == nil { + return nil, nil + } + out := make([]diplomail.GameSnapshot, 0, len(l.games)) + for _, g := range l.games { + if g.Status != "finished" && g.Status != "cancelled" { + continue + } + if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) { + continue + } + out = append(out, g) + } + return out, nil +} + +func (l *staticGameLookup) GetGame(_ context.Context, gameID uuid.UUID) (diplomail.GameSnapshot, error) { + if l == nil { + return diplomail.GameSnapshot{}, diplomail.ErrNotFound + } + g, ok := l.games[gameID] + if !ok { + return diplomail.GameSnapshot{}, diplomail.ErrNotFound + } + return g, nil +} + +func TestDiplomailPaidTierBroadcast(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + paidPlayer := uuid.New() + freePlayer := uuid.New() + alice := uuid.New() + bob := uuid.New() + seedAccount(t, db, paidPlayer) + seedAccount(t, db, freePlayer) + seedAccount(t, db, alice) + seedAccount(t, db, bob) + seedGame(t, db, gameID, "Paid Broadcast Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, paidPlayer}: { + UserID: paidPlayer, GameID: gameID, GameName: "Paid Broadcast Game", + UserName: "paid", RaceName: "PaidRace", + }, + {gameID, freePlayer}: { + UserID: freePlayer, GameID: gameID, GameName: "Paid Broadcast Game", + UserName: "free", RaceName: "FreeRace", + }, + {gameID, alice}: { + UserID: alice, GameID: gameID, GameName: "Paid Broadcast Game", + UserName: "alice", RaceName: "AliceRace", + }, + {gameID, bob}: { + UserID: bob, GameID: gameID, GameName: "Paid Broadcast Game", + UserName: "bob", RaceName: "BobRace", + }, + }, + } + publisher := &recordingPublisher{} + entitlements := &staticEntitlement{paid: map[uuid.UUID]bool{paidPlayer: true}} + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Entitlements: entitlements, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + // Paid sender: broadcast succeeds. + msg, recipients, err := svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{ + GameID: gameID, + SenderUserID: paidPlayer, + Subject: "Alliance", + Body: "Let us form a coalition.", + }) + if err != nil { + t.Fatalf("paid broadcast: %v", err) + } + if msg.Kind != diplomail.KindPersonal || msg.BroadcastScope != diplomail.BroadcastScopeGameBroadcast { + t.Fatalf("kind=%q scope=%q, want personal/game_broadcast", msg.Kind, msg.BroadcastScope) + } + if len(recipients) != 3 { + t.Fatalf("broadcast recipients=%d, want 3 (everyone but sender)", len(recipients)) + } + + // Free-tier sender: 403. + _, _, err = svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{ + GameID: gameID, + SenderUserID: freePlayer, + Body: "Should not be allowed.", + }) + if !errors.Is(err, diplomail.ErrForbidden) { + t.Fatalf("free broadcast: %v, want ErrForbidden", err) + } +} + +func TestDiplomailMultiGameBroadcastAndCleanup(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + game1 := uuid.New() + game2 := uuid.New() + finished := uuid.New() + alice := uuid.New() + bob := uuid.New() + carol := uuid.New() + for _, id := range []uuid.UUID{alice, bob, carol} { + seedAccount(t, db, id) + } + seedGame(t, db, game1, "Active Game 1") + seedGame(t, db, game2, "Active Game 2") + seedGame(t, db, finished, "Finished Game") + // Mark `finished` terminal with a long-past finished_at. + if _, err := db.ExecContext(ctx, ` + UPDATE backend.games + SET status='finished', finished_at = now() - interval '3 years' + WHERE game_id = $1 + `, finished); err != nil { + t.Fatalf("backdate finished: %v", err) + } + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {game1, alice}: {UserID: alice, GameID: game1, GameName: "Active Game 1", UserName: "alice", RaceName: "AliceRace"}, + {game1, bob}: {UserID: bob, GameID: game1, GameName: "Active Game 1", UserName: "bob", RaceName: "BobRace"}, + {game2, carol}: {UserID: carol, GameID: game2, GameName: "Active Game 2", UserName: "carol", RaceName: "CarolRace"}, + {finished, alice}: {UserID: alice, GameID: finished, GameName: "Finished Game", UserName: "alice", RaceName: "AliceRace"}, + }, + } + publisher := &recordingPublisher{} + finAt := time.Now().UTC().AddDate(-3, 0, 0) + games := &staticGameLookup{games: map[uuid.UUID]diplomail.GameSnapshot{ + game1: {GameID: game1, GameName: "Active Game 1", Status: "running"}, + game2: {GameID: game2, GameName: "Active Game 2", Status: "running"}, + finished: {GameID: finished, GameName: "Finished Game", Status: "finished", FinishedAt: &finAt}, + }} + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Games: games, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + // First, drop a personal message into the finished game so cleanup + // has something to remove. + if _, _, err := svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{ + GameID: finished, + CallerKind: diplomail.CallerKindAdmin, + CallerUsername: "ops", + RecipientUserID: alice, + Body: "Audit ping", + }); err != nil { + t.Fatalf("seed finished-game mail: %v", err) + } + + // Multi-game broadcast across all running games. + msgs, total, err := svc.SendAdminMultiGameBroadcast(ctx, diplomail.SendMultiGameBroadcastInput{ + CallerUsername: "ops", + Scope: diplomail.MultiGameScopeAllRunning, + RecipientScope: diplomail.RecipientScopeActive, + Subject: "Maintenance", + Body: "Brief turn-engine restart in 10 minutes.", + }) + if err != nil { + t.Fatalf("multi-game broadcast: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("multi-game messages=%d, want 2 (game1 + game2)", len(msgs)) + } + if total != 3 { + t.Fatalf("multi-game recipient count=%d, want 3 (alice+bob in g1, carol in g2)", total) + } + + // Bulk cleanup with 1-year cutoff should sweep the finished game. + result, err := svc.BulkCleanup(ctx, diplomail.BulkCleanupInput{OlderThanYears: 1}) + if err != nil { + t.Fatalf("bulk cleanup: %v", err) + } + if len(result.GameIDs) != 1 || result.GameIDs[0] != finished { + t.Fatalf("cleanup game_ids=%v, want [%s]", result.GameIDs, finished) + } + if result.MessagesDeleted < 1 { + t.Fatalf("cleanup messages_deleted=%d, want >=1", result.MessagesDeleted) + } + + // Admin listing sees the multi-game messages. + page, err := svc.ListMessagesForAdmin(ctx, diplomail.AdminMessageListing{Page: 1, PageSize: 50}) + if err != nil { + t.Fatalf("list admin messages: %v", err) + } + if page.Total < 2 { + t.Fatalf("list total=%d, want >=2 after cleanup", page.Total) + } +} + +func TestDiplomailLifecycleMembershipKick(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + kicked := uuid.New() + seedAccount(t, db, kicked) + seedGame(t, db, gameID, "Lifecycle Test Game") + + lookup := &staticMembershipLookup{ + inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{ + {gameID, kicked}: { + UserID: kicked, GameID: gameID, GameName: "Lifecycle Test Game", + UserName: "kicked", RaceName: "KickedRace", Status: "blocked", + }, + }, + } + publisher := &recordingPublisher{} + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + target := kicked + if err := svc.PublishLifecycle(ctx, diplomail.LifecycleEvent{ + GameID: gameID, + Kind: diplomail.LifecycleKindMembershipBlocked, + Actor: "an administrator", + Reason: "rule violation", + TargetUser: &target, + }); err != nil { + t.Fatalf("publish lifecycle: %v", err) + } + if got := publisher.snapshot(); len(got) != 1 || got[0].Recipient != kicked { + t.Fatalf("publisher captured %+v, want one event addressed to kicked", got) + } + inbox, err := svc.ListInbox(ctx, gameID, kicked, "") + if err != nil { + t.Fatalf("kicked inbox: %v", err) + } + if len(inbox) != 1 { + t.Fatalf("kicked inbox = %d, want 1 system message", len(inbox)) + } + if inbox[0].Kind != diplomail.KindAdmin || inbox[0].SenderKind != diplomail.SenderKindSystem { + t.Fatalf("kind=%q sender_kind=%q, want admin/system", inbox[0].Kind, inbox[0].SenderKind) + } +} + +// 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: +// 1. SendPersonal where recipient.preferred_language != body_lang +// materialises a recipient with `AvailableAt == nil`; the inbox +// is empty until the worker runs. +// 2. After one Worker.Tick(), the translation cache row exists, +// `AvailableAt` is populated, and the push event fires. +// 3. The inbox now surfaces the message together with the cached +// translation under `Translation`. +func TestDiplomailAsyncTranslationDelivery(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedGame(t, db, gameID, "Async Translation Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, sender}: { + UserID: sender, GameID: gameID, GameName: "Async Translation Game", + UserName: "sender", RaceName: "SenderRace", + PreferredLanguage: "en", + }, + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Async Translation Game", + UserName: "recipient", RaceName: "RecipientRace", + PreferredLanguage: "ru", + }, + }, + } + publisher := &recordingPublisher{} + stub := &staticTranslator{engine: translator.LibreTranslateEngine} + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Detector: detectorFn(func(_ string) string { return "en" }), + Translator: stub, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + TranslatorMaxAttempts: 5, + WorkerInterval: time.Second, + }, + }) + + msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + Subject: "Hello", + Body: "Trade proposal.", + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if rcpt.AvailableAt != nil { + t.Fatalf("recipient marked available_at on send: %v (want NULL — pending translation)", rcpt.AvailableAt) + } + if got := publisher.snapshot(); len(got) != 0 { + t.Fatalf("push fired before worker delivered: %d events", len(got)) + } + inbox, err := svc.ListInbox(ctx, gameID, recipient, "") + if err != nil { + t.Fatalf("inbox before worker: %v", err) + } + if len(inbox) != 0 { + t.Fatalf("inbox before worker = %d, want empty", len(inbox)) + } + + worker := diplomail.NewWorker(svc) + if err := worker.Tick(ctx); err != nil { + t.Fatalf("worker tick: %v", err) + } + + if got := publisher.snapshot(); len(got) != 1 || got[0].Recipient != recipient { + t.Fatalf("publisher after tick = %+v", got) + } + inboxAfter, err := svc.ListInbox(ctx, gameID, recipient, "ru") + if err != nil { + t.Fatalf("inbox after worker: %v", err) + } + if len(inboxAfter) != 1 { + t.Fatalf("inbox after worker = %d, want 1", len(inboxAfter)) + } + if inboxAfter[0].Translation == nil { + t.Fatalf("translation missing on inbox entry") + } + if inboxAfter[0].Translation.TranslatedBody != "[ru] Trade proposal." { + t.Fatalf("translated body = %q", inboxAfter[0].Translation.TranslatedBody) + } + _ = msg +} + +// TestDiplomailAsyncFallbackOnUnsupportedPair covers the terminal +// "translation unavailable" path: the translator returns +// ErrUnsupportedLanguagePair, so the worker delivers the recipient +// with no cached translation. The user sees the original body. +func TestDiplomailAsyncFallbackOnUnsupportedPair(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedGame(t, db, gameID, "Unsupported Pair Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, sender}: { + UserID: sender, GameID: gameID, GameName: "Unsupported Pair Game", + UserName: "sender", PreferredLanguage: "en", + }, + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Unsupported Pair Game", + UserName: "recipient", PreferredLanguage: "xx", + }, + }, + } + publisher := &recordingPublisher{} + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Detector: detectorFn(func(_ string) string { return "en" }), + Translator: &erroringTranslator{err: translator.ErrUnsupportedLanguagePair}, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + TranslatorMaxAttempts: 5, + }, + }) + + if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + Body: "Hello there.", + }); err != nil { + t.Fatalf("send: %v", err) + } + worker := diplomail.NewWorker(svc) + if err := worker.Tick(ctx); err != nil { + t.Fatalf("worker tick: %v", err) + } + inbox, err := svc.ListInbox(ctx, gameID, recipient, "xx") + if err != nil { + t.Fatalf("inbox: %v", err) + } + if len(inbox) != 1 { + t.Fatalf("inbox after fallback = %d, want 1", len(inbox)) + } + if inbox[0].Translation != nil { + t.Fatalf("translation should be nil after fallback, got %+v", inbox[0].Translation) + } +} + +type erroringTranslator struct { + err error +} + +func (e *erroringTranslator) Translate(_ context.Context, _, _, _, _ string) (translator.Result, error) { + return translator.Result{}, e.err +} + +// staticTranslator returns deterministic renderings so the +// translation-cache test can assert against known output. +type staticTranslator struct { + engine string +} + +func (s *staticTranslator) Translate(_ context.Context, srcLang, dstLang, subject, body string) (translator.Result, error) { + return translator.Result{ + Subject: "[" + dstLang + "] " + subject, + Body: "[" + dstLang + "] " + body, + Engine: s.engine, + }, nil +} + +func TestDiplomailTranslationCache(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedGame(t, db, gameID, "Translation Test Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, sender}: { + UserID: sender, GameID: gameID, GameName: "Translation Test Game", + UserName: "sender", RaceName: "SendersRace", + }, + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Translation Test Game", + UserName: "recipient", RaceName: "ReceiversRace", + }, + }, + } + publisher := &recordingPublisher{} + + englishDetector := detectorFn(func(_ string) string { return "en" }) + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Notification: publisher, + Detector: englishDetector, + Translator: &staticTranslator{engine: "stub"}, + Config: config.DiplomailConfig{ + MaxBodyBytes: 4096, + MaxSubjectBytes: 256, + }, + }) + + msg, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + Subject: "Hello", + Body: "Please share the latest map snapshot.", + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if msg.BodyLang != "en" { + t.Fatalf("body_lang=%q, want en (detector returns en)", msg.BodyLang) + } + + // First read materialises a cached translation. + entry, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru") + if err != nil { + t.Fatalf("get message: %v", err) + } + if entry.Translation == nil { + t.Fatalf("translation missing on first read") + } + if entry.Translation.TargetLang != "ru" { + t.Fatalf("translation lang=%q, want ru", entry.Translation.TargetLang) + } + if entry.Translation.TranslatedBody != "[ru] Please share the latest map snapshot." { + t.Fatalf("translated body = %q", entry.Translation.TranslatedBody) + } + + // Second read returns the same cached row (no re-translation). + entry2, err := svc.GetMessage(ctx, recipient, msg.MessageID, "ru") + if err != nil { + t.Fatalf("get message twice: %v", err) + } + if entry2.Translation == nil || entry2.Translation.TranslationID != entry.Translation.TranslationID { + t.Fatalf("second read produced new translation: got %+v, want %s", entry2.Translation, entry.Translation.TranslationID) + } + + // Same language as body: no translation. + entrySame, err := svc.GetMessage(ctx, recipient, msg.MessageID, "en") + if err != nil { + t.Fatalf("get message same lang: %v", err) + } + if entrySame.Translation != nil { + t.Fatalf("translation populated when target == body_lang") + } +} + +// detectorFn lets the test inject deterministic detection without +// dragging the whatlanggo profile into the test fixtures. +type detectorFn func(string) string + +func (f detectorFn) Detect(s string) string { return f(s) } + +func TestDiplomailRejectsOverlongBody(t *testing.T) { + db := startPostgres(t) + ctx := context.Background() + + gameID := uuid.New() + sender := uuid.New() + recipient := uuid.New() + seedAccount(t, db, sender) + seedAccount(t, db, recipient) + seedGame(t, db, gameID, "Length Test Game") + + lookup := &staticMembershipLookup{ + rows: map[[2]uuid.UUID]diplomail.ActiveMembership{ + {gameID, sender}: { + UserID: sender, GameID: gameID, GameName: "Length Test Game", + UserName: "sender", RaceName: "Senders", + }, + {gameID, recipient}: { + UserID: recipient, GameID: gameID, GameName: "Length Test Game", + UserName: "recipient", RaceName: "Receivers", + }, + }, + } + + svc := diplomail.NewService(diplomail.Deps{ + Store: diplomail.NewStore(db), + Memberships: lookup, + Config: config.DiplomailConfig{ + MaxBodyBytes: 32, + MaxSubjectBytes: 256, + }, + }) + + bigBody := make([]byte, 64) + for i := range bigBody { + bigBody[i] = 'a' + } + _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: sender, + RecipientUserID: recipient, + Body: string(bigBody), + }) + if !errors.Is(err, diplomail.ErrInvalidInput) { + t.Fatalf("send overlong: %v, want ErrInvalidInput", err) + } +} diff --git a/backend/internal/diplomail/errors.go b/backend/internal/diplomail/errors.go new file mode 100644 index 0000000..1ec1604 --- /dev/null +++ b/backend/internal/diplomail/errors.go @@ -0,0 +1,32 @@ +package diplomail + +import "errors" + +// Sentinel errors surface common rejection reasons across the +// diplomail package. Handlers map them to HTTP envelopes through +// `respondDiplomailError` in `internal/server/handlers_user_mail.go`. +// +// Adding a new sentinel here is a deliberate API change: it appears in +// the handler error map and may surface as a new wire `code` value. +// Reuse the existing set when the behaviour overlaps. +var ( + // ErrInvalidInput reports request-level validation failures + // (empty body, body or subject over the configured byte limit, + // invalid UUID, non-UTF-8 bytes). Maps to 400 invalid_request. + ErrInvalidInput = errors.New("diplomail: invalid input") + + // ErrNotFound reports that the requested message does not exist + // or is not visible to the caller. Maps to 404 not_found. + ErrNotFound = errors.New("diplomail: not found") + + // ErrForbidden reports that the caller is authenticated but not + // authorised for the requested action (not an active member of + // the game; not a recipient of the message). Maps to 403 + // forbidden. + ErrForbidden = errors.New("diplomail: forbidden") + + // ErrConflict reports that the requested action conflicts with + // the current persisted state (e.g. soft-deleting a message + // that has not been marked read yet). Maps to 409 conflict. + ErrConflict = errors.New("diplomail: conflict") +) diff --git a/backend/internal/diplomail/service.go b/backend/internal/diplomail/service.go new file mode 100644 index 0000000..b6365a7 --- /dev/null +++ b/backend/internal/diplomail/service.go @@ -0,0 +1,389 @@ +package diplomail + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode/utf8" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// previewMaxRunes bounds the body excerpt embedded in the push event +// so the gRPC payload stays small. The value matches the UI's +// "two lines" tease and is intentionally not configurable — clients +// drive their own truncation off the canonical fetch. +const previewMaxRunes = 120 + +// SendPersonal persists a single-recipient personal message and +// fan-outs a `diplomail.message.received` push event to the +// recipient. Validation rules: +// +// - both sender and recipient must be active members of GameID; +// - the recipient must differ from the sender; +// - the body must be non-empty, valid UTF-8, and within the +// configured byte limit; +// - the subject must be valid UTF-8 and within the configured +// byte limit (zero is allowed). +// +// On any rule violation the function returns ErrInvalidInput or +// ErrForbidden; the inserted Message is never persisted in those +// cases. +func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) { + if in.SenderUserID == in.RecipientUserID { + return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput) + } + subject := strings.TrimRight(in.Subject, " \t") + body := strings.TrimRight(in.Body, " \t\n") + if err := s.validateContent(subject, body); err != nil { + return Message{}, Recipient{}, err + } + + sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Message{}, Recipient{}, fmt.Errorf("%w: sender is not an active member of the game", ErrForbidden) + } + return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err) + } + recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.RecipientUserID) + if err != nil { + if errors.Is(err, ErrNotFound) { + return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden) + } + return Message{}, Recipient{}, fmt.Errorf("diplomail: load recipient membership: %w", err) + } + + username := sender.UserName + msgInsert := MessageInsert{ + MessageID: uuid.New(), + GameID: in.GameID, + GameName: sender.GameName, + Kind: KindPersonal, + SenderKind: SenderKindPlayer, + SenderUserID: &in.SenderUserID, + SenderUsername: &username, + SenderIP: in.SenderIP, + Subject: subject, + Body: body, + BodyLang: s.deps.Detector.Detect(body), + BroadcastScope: BroadcastScopeSingle, + } + raceName := recipient.RaceName + rcptInsert := buildRecipientInsert( + msgInsert.MessageID, + MemberSnapshot{ + UserID: in.RecipientUserID, + GameID: in.GameID, + GameName: recipient.GameName, + UserName: recipient.UserName, + RaceName: raceName, + PreferredLanguage: recipient.PreferredLanguage, + Status: "active", + }, + msgInsert.BodyLang, + s.nowUTC(), + ) + + msg, recipients, err := s.deps.Store.InsertMessageWithRecipients(ctx, msgInsert, []RecipientInsert{rcptInsert}) + if err != nil { + return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: %w", err) + } + if len(recipients) != 1 { + return Message{}, Recipient{}, fmt.Errorf("diplomail: send personal: unexpected recipient count %d", len(recipients)) + } + + if recipients[0].AvailableAt != nil { + s.publishMessageReceived(ctx, msg, recipients[0]) + } + return msg, recipients[0], nil +} + +// GetMessage returns the InboxEntry for messageID addressed to +// userID. ErrNotFound is returned when the caller is not a recipient +// of the message — handlers translate that to 404 so the existence +// of the message is not leaked. The same sentinel is returned when +// the caller is no longer an active member of the game and the +// message is personal-kind: post-kick visibility is restricted to +// admin/system mail (item 8 of the spec). +// +// When `targetLang` is non-empty and differs from the message's +// `body_lang`, the function consults the translation cache; on a +// miss it asks the configured Translator to produce a rendering and +// persists the result. The noop translator returns the input +// unchanged with `engine == "noop"`, which is treated as +// "translation unavailable" — the entry comes back with `Translation +// == nil` and the caller renders the original body. +func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID, targetLang string) (InboxEntry, error) { + entry, err := s.deps.Store.LoadInboxEntry(ctx, messageID, userID) + if err != nil { + return InboxEntry{}, err + } + allowed, err := s.allowedKinds(ctx, entry.GameID, userID) + if err != nil { + return InboxEntry{}, err + } + if !allowed[entry.Kind] { + return InboxEntry{}, ErrNotFound + } + if tr := s.resolveTranslation(ctx, entry.Message, targetLang); tr != nil { + entry.Translation = tr + } + return entry, nil +} + +// resolveTranslation returns the cached translation for +// (message, targetLang), lazily computing and persisting one on +// cache miss. Returns nil when no translation is needed (target is +// empty, matches `body_lang`, or the message body is itself +// undetermined) or when the configured translator declares the +// rendering unavailable. +func (s *Service) resolveTranslation(ctx context.Context, msg Message, targetLang string) *Translation { + if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined { + return nil + } + if existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang); err == nil { + t := existing + return &t + } else if !errors.Is(err, ErrNotFound) { + s.deps.Logger.Warn("load translation failed", + zap.String("message_id", msg.MessageID.String()), + zap.String("target_lang", targetLang), + zap.Error(err)) + return nil + } + if s.deps.Translator == nil { + return nil + } + result, err := s.deps.Translator.Translate(ctx, msg.BodyLang, targetLang, msg.Subject, msg.Body) + if err != nil { + s.deps.Logger.Warn("translator call failed", + zap.String("message_id", msg.MessageID.String()), + zap.String("target_lang", targetLang), + zap.Error(err)) + return nil + } + if result.Engine == "" || result.Engine == "noop" { + return nil + } + tr := Translation{ + TranslationID: uuid.New(), + MessageID: msg.MessageID, + TargetLang: targetLang, + TranslatedSubject: result.Subject, + TranslatedBody: result.Body, + Translator: result.Engine, + } + stored, err := s.deps.Store.InsertTranslation(ctx, tr) + if err != nil { + s.deps.Logger.Warn("insert translation failed", + zap.String("message_id", msg.MessageID.String()), + zap.String("target_lang", targetLang), + zap.Error(err)) + return nil + } + return &stored +} + +// ListInbox returns every non-deleted message addressed to userID in +// gameID, newest first. Read state is preserved per entry; the HTTP +// layer renders both the message and the recipient row. Personal +// messages are filtered out when the caller is no longer an active +// member of the game so a kicked player keeps read access to the +// admin/system explanation of the kick but not to historical +// player-to-player threads. +// +// When `targetLang` is non-empty and differs from a row's body +// language, the function consults the translation cache (without +// re-translating on miss; the per-message read endpoint owns that +// path so the bulk listing never blocks on translator I/O). +func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID, targetLang string) ([]InboxEntry, error) { + entries, err := s.deps.Store.ListInbox(ctx, gameID, userID) + if err != nil { + return nil, err + } + allowed, err := s.allowedKinds(ctx, gameID, userID) + if err != nil { + return nil, err + } + out := entries + if !(allowed[KindPersonal] && allowed[KindAdmin]) { + out = make([]InboxEntry, 0, len(entries)) + for _, e := range entries { + if allowed[e.Kind] { + out = append(out, e) + } + } + } + if targetLang == "" { + return out, nil + } + for i := range out { + out[i].Translation = s.lookupCachedTranslation(ctx, out[i].Message, targetLang) + } + return out, nil +} + +// lookupCachedTranslation reads an existing translation row without +// asking the Translator to compute one. The bulk inbox listing uses +// this to avoid per-row translator I/O; GetMessage uses the full +// `resolveTranslation` helper which falls through to the translator +// on cache miss. +func (s *Service) lookupCachedTranslation(ctx context.Context, msg Message, targetLang string) *Translation { + if targetLang == "" || targetLang == msg.BodyLang || msg.BodyLang == LangUndetermined { + return nil + } + existing, err := s.deps.Store.LoadTranslation(ctx, msg.MessageID, targetLang) + if err != nil { + if !errors.Is(err, ErrNotFound) { + s.deps.Logger.Debug("inbox translation lookup failed", + zap.String("message_id", msg.MessageID.String()), + zap.Error(err)) + } + return nil + } + out := existing + return &out +} + +// allowedKinds resolves the set of message kinds the caller may read +// in gameID. An active member can read everything; a former member +// (status removed or blocked) can read admin-kind only. A user who +// has never been a member of the game but is still listed as a +// recipient (legacy / system message) is granted the same admin-only +// view. The function never returns an empty set: even non-members +// keep their read access to admin mail. +func (s *Service) allowedKinds(ctx context.Context, gameID, userID uuid.UUID) (map[string]bool, error) { + if s.deps.Memberships == nil { + return map[string]bool{KindPersonal: true, KindAdmin: true}, nil + } + if _, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, userID); err == nil { + return map[string]bool{KindPersonal: true, KindAdmin: true}, nil + } else if !errors.Is(err, ErrNotFound) { + return nil, err + } + return map[string]bool{KindAdmin: true}, nil +} + +// ListSent returns personal messages authored by senderUserID in +// gameID, newest first. Admin/system rows have no `sender_user_id` +// and are therefore excluded; the user surface does not need them. +func (s *Service) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) { + return s.deps.Store.ListSent(ctx, gameID, senderUserID) +} + +// MarkRead transitions a recipient row to `read`. Idempotent: a +// second call on an already-read row is a no-op. Returns the +// resulting Recipient. ErrNotFound is surfaced when the caller is +// not a recipient of the message. +func (s *Service) MarkRead(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) { + return s.deps.Store.MarkRead(ctx, messageID, userID, s.nowUTC()) +} + +// DeleteMessage soft-deletes the recipient row identified by +// (messageID, userID). The row must already have `read_at` set, or +// the call returns ErrConflict (item 10 of the spec: open-then-delete). +// Returns ErrNotFound when the caller is not a recipient. +func (s *Service) DeleteMessage(ctx context.Context, userID, messageID uuid.UUID) (Recipient, error) { + return s.deps.Store.SoftDelete(ctx, messageID, userID, s.nowUTC()) +} + +// UnreadCountsForUser returns the lobby badge breakdown. +func (s *Service) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) { + return s.deps.Store.UnreadCountsForUser(ctx, userID) +} + +// validateContent enforces the body/subject byte limits and rejects +// non-UTF-8 input. Stage A applies the rules to plain text only; HTML +// is treated as plain text by the server (the UI renders via +// textContent) and gets no special handling. +func (s *Service) validateContent(subject, body string) error { + if body == "" { + return fmt.Errorf("%w: body must not be empty", ErrInvalidInput) + } + if !utf8.ValidString(body) { + return fmt.Errorf("%w: body must be valid UTF-8", ErrInvalidInput) + } + if len(body) > s.deps.Config.MaxBodyBytes { + return fmt.Errorf("%w: body exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxBodyBytes) + } + if subject != "" { + if !utf8.ValidString(subject) { + return fmt.Errorf("%w: subject must be valid UTF-8", ErrInvalidInput) + } + if len(subject) > s.deps.Config.MaxSubjectBytes { + return fmt.Errorf("%w: subject exceeds %d bytes", ErrInvalidInput, s.deps.Config.MaxSubjectBytes) + } + } + return nil +} + +// publishMessageReceived emits the per-recipient push notification. +// Failures are logged at debug level: notifications are best-effort +// over the gRPC stream, and clients always have the unread-counts +// endpoint as the durable fallback. +func (s *Service) publishMessageReceived(ctx context.Context, msg Message, recipient Recipient) { + unreadGame, err := s.deps.Store.UnreadCountForUserGame(ctx, msg.GameID, recipient.UserID) + if err != nil { + s.deps.Logger.Warn("compute unread count for push payload failed", + zap.String("message_id", msg.MessageID.String()), + zap.String("recipient", recipient.UserID.String()), + zap.Error(err)) + unreadGame = 0 + } + unreadTotals, err := s.deps.Store.UnreadCountsForUser(ctx, recipient.UserID) + if err != nil { + s.deps.Logger.Warn("compute unread totals for push payload failed", + zap.String("recipient", recipient.UserID.String()), + zap.Error(err)) + unreadTotals = nil + } + unreadTotal := 0 + for _, u := range unreadTotals { + unreadTotal += u.Unread + } + + payload := map[string]any{ + "message_id": msg.MessageID.String(), + "game_id": msg.GameID.String(), + "kind": msg.Kind, + "sender_kind": msg.SenderKind, + "subject": msg.Subject, + "preview": preview(msg.Body, previewMaxRunes), + "preview_lang": msg.BodyLang, + "unread_total": unreadTotal, + "unread_game": unreadGame, + } + ev := DiplomailNotification{ + Kind: "diplomail.message.received", + IdempotencyKey: "diplomail.message.received:" + msg.MessageID.String() + ":" + recipient.UserID.String(), + Recipient: recipient.UserID, + Payload: payload, + } + if err := s.deps.Notification.PublishDiplomailEvent(ctx, ev); err != nil { + s.deps.Logger.Warn("publish diplomail event failed", + zap.String("message_id", msg.MessageID.String()), + zap.String("recipient", recipient.UserID.String()), + zap.Error(err)) + } +} + +// preview truncates s to at most max runes and appends a horizontal +// ellipsis when truncation actually happened. The function operates +// on runes, not bytes, so multibyte UTF-8 sequences (Cyrillic, +// emoji) survive without corruption. +func preview(s string, max int) string { + if max <= 0 || utf8.RuneCountInString(s) <= max { + return s + } + count := 0 + for i := range s { + if count == max { + return s[:i] + "…" + } + count++ + } + return s +} diff --git a/backend/internal/diplomail/store.go b/backend/internal/diplomail/store.go new file mode 100644 index 0000000..e6364cf --- /dev/null +++ b/backend/internal/diplomail/store.go @@ -0,0 +1,803 @@ +package diplomail + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "galaxy/backend/internal/postgres/jet/backend/model" + "galaxy/backend/internal/postgres/jet/backend/table" + + "github.com/go-jet/jet/v2/postgres" + "github.com/go-jet/jet/v2/qrm" + "github.com/google/uuid" +) + +// Store is the Postgres-backed query surface for the diplomail +// package. All queries are built through go-jet against the generated +// table bindings under `backend/internal/postgres/jet/backend/table`. +type Store struct { + db *sql.DB +} + +// NewStore constructs a Store wrapping db. +func NewStore(db *sql.DB) *Store { return &Store{db: db} } + +// messageColumns is the canonical projection for diplomail_messages +// reads. +func messageColumns() postgres.ColumnList { + m := table.DiplomailMessages + return postgres.ColumnList{ + m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind, + m.SenderUserID, m.SenderUsername, m.SenderIP, + m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt, + } +} + +// recipientColumns is the canonical projection for +// diplomail_recipients reads. +func recipientColumns() postgres.ColumnList { + r := table.DiplomailRecipients + return postgres.ColumnList{ + r.RecipientID, r.MessageID, r.GameID, r.UserID, + r.RecipientUserName, r.RecipientRaceName, r.RecipientPreferredLanguage, + r.AvailableAt, r.TranslationAttempts, r.NextTranslationAttemptAt, + r.DeliveredAt, r.ReadAt, r.DeletedAt, r.NotifiedAt, + } +} + +// MessageInsert carries the immutable per-message fields. The store +// fills MessageID, sets CreatedAt to `now()` via the column default, +// and leaves recipient-side state to InsertRecipient. +type MessageInsert struct { + MessageID uuid.UUID + GameID uuid.UUID + GameName string + Kind string + SenderKind string + SenderUserID *uuid.UUID + SenderUsername *string + SenderIP string + Subject string + Body string + BodyLang string + BroadcastScope string +} + +// RecipientInsert carries the per-recipient snapshot. AvailableAt +// captures the async-delivery contract: when non-nil, the recipient +// row is materialised already-delivered (no translation needed or +// the language matches); when nil, the recipient is queued for the +// translation worker. +type RecipientInsert struct { + RecipientID uuid.UUID + MessageID uuid.UUID + GameID uuid.UUID + UserID uuid.UUID + RecipientUserName string + RecipientRaceName *string + RecipientPreferredLanguage string + AvailableAt *time.Time +} + +// InsertMessageWithRecipients persists a Message together with one or +// more Recipient rows inside a single transaction. The function is +// the canonical write path for every send variant: Stage A passes a +// single-element slice; later stages reuse the same path for +// broadcasts. +func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInsert, recipients []RecipientInsert) (Message, []Recipient, error) { + if len(recipients) == 0 { + return Message{}, nil, errors.New("diplomail store: at least one recipient required") + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Message{}, nil, fmt.Errorf("diplomail store: begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + m := table.DiplomailMessages + msgStmt := m.INSERT( + m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind, + m.SenderUserID, m.SenderUsername, m.SenderIP, + m.Subject, m.Body, m.BodyLang, m.BroadcastScope, + ).VALUES( + msg.MessageID, + msg.GameID, + msg.GameName, + msg.Kind, + msg.SenderKind, + uuidPtrArg(msg.SenderUserID), + stringPtrArg(msg.SenderUsername), + msg.SenderIP, + msg.Subject, + msg.Body, + msg.BodyLang, + msg.BroadcastScope, + ).RETURNING(messageColumns()) + + var msgRow model.DiplomailMessages + if err := msgStmt.QueryContext(ctx, tx, &msgRow); err != nil { + return Message{}, nil, fmt.Errorf("diplomail store: insert message: %w", err) + } + + r := table.DiplomailRecipients + rcptStmt := r.INSERT( + r.RecipientID, r.MessageID, r.GameID, r.UserID, + r.RecipientUserName, r.RecipientRaceName, + r.RecipientPreferredLanguage, r.AvailableAt, + ) + for _, in := range recipients { + rcptStmt = rcptStmt.VALUES( + in.RecipientID, + in.MessageID, + in.GameID, + in.UserID, + in.RecipientUserName, + stringPtrArg(in.RecipientRaceName), + in.RecipientPreferredLanguage, + timePtrArg(in.AvailableAt), + ) + } + rcptStmt = rcptStmt.RETURNING(recipientColumns()) + + var rcptRows []model.DiplomailRecipients + if err := rcptStmt.QueryContext(ctx, tx, &rcptRows); err != nil { + return Message{}, nil, fmt.Errorf("diplomail store: insert recipients: %w", err) + } + + if err := tx.Commit(); err != nil { + return Message{}, nil, fmt.Errorf("diplomail store: commit: %w", err) + } + + return messageFromModel(msgRow), recipientsFromModel(rcptRows), nil +} + +// LoadMessage returns the Message row identified by messageID. The +// function is used by readers that already verified recipient +// authorisation; callers that need both the message and the +// recipient's per-user state should use LoadInboxEntry. +func (s *Store) LoadMessage(ctx context.Context, messageID uuid.UUID) (Message, error) { + m := table.DiplomailMessages + stmt := postgres.SELECT(messageColumns()). + FROM(m). + WHERE(m.MessageID.EQ(postgres.UUID(messageID))). + LIMIT(1) + var row model.DiplomailMessages + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Message{}, ErrNotFound + } + return Message{}, fmt.Errorf("diplomail store: load message %s: %w", messageID, err) + } + return messageFromModel(row), nil +} + +// LoadInboxEntry returns a Message together with the caller's +// Recipient row, both for messageID. Returns ErrNotFound when the +// caller is not a recipient of the message — this is also how the +// service layer enforces "only recipients may read". +func (s *Store) LoadInboxEntry(ctx context.Context, messageID, userID uuid.UUID) (InboxEntry, error) { + m := table.DiplomailMessages + r := table.DiplomailRecipients + cols := append(messageColumns(), recipientColumns()...) + stmt := postgres.SELECT(cols). + FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))). + WHERE( + r.MessageID.EQ(postgres.UUID(messageID)). + AND(r.UserID.EQ(postgres.UUID(userID))), + ). + LIMIT(1) + var dest struct { + model.DiplomailMessages + Recipient model.DiplomailRecipients `alias:"diplomail_recipients"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return InboxEntry{}, ErrNotFound + } + return InboxEntry{}, fmt.Errorf("diplomail store: load inbox entry %s/%s: %w", messageID, userID, err) + } + return InboxEntry{ + Message: messageFromModel(dest.DiplomailMessages), + Recipient: recipientFromModel(dest.Recipient), + }, nil +} + +// ListInbox returns the recipient view of messages addressed to +// userID in gameID, newest first. Soft-deleted rows +// (`deleted_at IS NOT NULL`) are excluded. Rows still waiting for +// the async translation worker (`available_at IS NULL`) are also +// excluded — they will appear once delivery is complete. +func (s *Store) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]InboxEntry, error) { + m := table.DiplomailMessages + r := table.DiplomailRecipients + cols := append(messageColumns(), recipientColumns()...) + stmt := postgres.SELECT(cols). + FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))). + WHERE( + r.UserID.EQ(postgres.UUID(userID)). + AND(r.GameID.EQ(postgres.UUID(gameID))). + AND(r.DeletedAt.IS_NULL()). + AND(r.AvailableAt.IS_NOT_NULL()), + ). + ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()) + var dest []struct { + model.DiplomailMessages + Recipient model.DiplomailRecipients `alias:"diplomail_recipients"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + return nil, fmt.Errorf("diplomail store: list inbox %s/%s: %w", gameID, userID, err) + } + out := make([]InboxEntry, 0, len(dest)) + for _, row := range dest { + out = append(out, InboxEntry{ + Message: messageFromModel(row.DiplomailMessages), + Recipient: recipientFromModel(row.Recipient), + }) + } + return out, nil +} + +// ListSent returns messages authored by senderUserID in gameID, +// newest first. Personal messages only — admin/system rows have +// `sender_user_id IS NULL` and are filtered out by the WHERE clause. +func (s *Store) ListSent(ctx context.Context, gameID, senderUserID uuid.UUID) ([]Message, error) { + m := table.DiplomailMessages + stmt := postgres.SELECT(messageColumns()). + FROM(m). + WHERE( + m.GameID.EQ(postgres.UUID(gameID)). + AND(m.SenderUserID.EQ(postgres.UUID(senderUserID))), + ). + ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()) + var rows []model.DiplomailMessages + if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, fmt.Errorf("diplomail store: list sent %s/%s: %w", gameID, senderUserID, err) + } + out := make([]Message, 0, len(rows)) + for _, row := range rows { + out = append(out, messageFromModel(row)) + } + return out, nil +} + +// MarkRead sets `read_at = at` on the recipient row identified by +// (messageID, userID). Idempotent: a row that is already marked read +// is left untouched but the existing Recipient is returned. +// Returns ErrNotFound when the user is not a recipient of the message. +func (s *Store) MarkRead(ctx context.Context, messageID, userID uuid.UUID, at time.Time) (Recipient, error) { + r := table.DiplomailRecipients + stmt := r.UPDATE(r.ReadAt). + SET(postgres.TimestampzT(at.UTC())). + WHERE( + r.MessageID.EQ(postgres.UUID(messageID)). + AND(r.UserID.EQ(postgres.UUID(userID))). + AND(r.ReadAt.IS_NULL()), + ). + RETURNING(recipientColumns()) + var row model.DiplomailRecipients + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if !errors.Is(err, qrm.ErrNoRows) { + return Recipient{}, fmt.Errorf("diplomail store: mark read %s/%s: %w", messageID, userID, err) + } + // The row exists but read_at was already set, or the row + // does not exist at all. Fetch to disambiguate. + existing, loadErr := s.LoadRecipient(ctx, messageID, userID) + if loadErr != nil { + return Recipient{}, loadErr + } + return existing, nil + } + return recipientFromModel(row), nil +} + +// SoftDelete sets `deleted_at = at` on the recipient row identified by +// (messageID, userID). The row must already have `read_at` set; +// otherwise the call returns ErrConflict so a hostile client cannot +// erase a message before opening it (item 10 of the spec). +// Returns ErrNotFound when the user is not a recipient. +func (s *Store) SoftDelete(ctx context.Context, messageID, userID uuid.UUID, at time.Time) (Recipient, error) { + r := table.DiplomailRecipients + stmt := r.UPDATE(r.DeletedAt). + SET(postgres.TimestampzT(at.UTC())). + WHERE( + r.MessageID.EQ(postgres.UUID(messageID)). + AND(r.UserID.EQ(postgres.UUID(userID))). + AND(r.ReadAt.IS_NOT_NULL()). + AND(r.DeletedAt.IS_NULL()), + ). + RETURNING(recipientColumns()) + var row model.DiplomailRecipients + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if !errors.Is(err, qrm.ErrNoRows) { + return Recipient{}, fmt.Errorf("diplomail store: soft delete %s/%s: %w", messageID, userID, err) + } + existing, loadErr := s.LoadRecipient(ctx, messageID, userID) + if loadErr != nil { + return Recipient{}, loadErr + } + if existing.ReadAt == nil { + return Recipient{}, fmt.Errorf("%w: message must be read before delete", ErrConflict) + } + // Already deleted: return the existing row idempotently. + return existing, nil + } + return recipientFromModel(row), nil +} + +// LoadRecipient fetches the Recipient row keyed on (messageID, userID). +// Returns ErrNotFound when no such recipient exists. +func (s *Store) LoadRecipient(ctx context.Context, messageID, userID uuid.UUID) (Recipient, error) { + r := table.DiplomailRecipients + stmt := postgres.SELECT(recipientColumns()). + FROM(r). + WHERE( + r.MessageID.EQ(postgres.UUID(messageID)). + AND(r.UserID.EQ(postgres.UUID(userID))), + ). + LIMIT(1) + var row model.DiplomailRecipients + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Recipient{}, ErrNotFound + } + return Recipient{}, fmt.Errorf("diplomail store: load recipient %s/%s: %w", messageID, userID, err) + } + return recipientFromModel(row), nil +} + +// UnreadCountForUserGame returns the count of unread, non-deleted, +// delivered messages addressed to userID in gameID. Recipients +// still waiting for translation (`available_at IS NULL`) are +// excluded so the badge does not flicker. +func (s *Store) UnreadCountForUserGame(ctx context.Context, gameID, userID uuid.UUID) (int, error) { + r := table.DiplomailRecipients + stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")). + FROM(r). + WHERE( + r.UserID.EQ(postgres.UUID(userID)). + AND(r.GameID.EQ(postgres.UUID(gameID))). + AND(r.ReadAt.IS_NULL()). + AND(r.DeletedAt.IS_NULL()). + AND(r.AvailableAt.IS_NOT_NULL()), + ) + var dest struct { + Count int64 `alias:"count"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + return 0, fmt.Errorf("diplomail store: unread count %s/%s: %w", gameID, userID, err) + } + return int(dest.Count), nil +} + +// PendingTranslationPair carries one unit of work picked by the +// translation worker. Multiple recipients of the same message that +// share a preferred_language collapse into one pair, because the +// translation is shared via the diplomail_translations cache. +// CurrentAttempts is the highest `translation_attempts` value across +// the matching recipient rows, so the worker can decide whether the +// next attempt is the last one before falling back. +type PendingTranslationPair struct { + MessageID uuid.UUID + TargetLang string + CurrentAttempts int32 +} + +// PickPendingTranslationPair returns one pair eligible for the +// translation worker, or `ok == false` when the queue is empty. The +// pair is the (message, target_lang) of any recipient where +// `available_at IS NULL` and `next_translation_attempt_at` is either +// unset or already due. The query intentionally drops the +// `FOR UPDATE` clause — the worker is single-threaded per process, +// and the optimistic UPDATE in `MarkPairDelivered` / +// `MarkPairFallback` filters by `available_at IS NULL`, so a stale +// pickup never delivers twice. +func (s *Store) PickPendingTranslationPair(ctx context.Context, now time.Time) (PendingTranslationPair, bool, error) { + r := table.DiplomailRecipients + stmt := postgres.SELECT( + r.MessageID.AS("message_id"), + r.RecipientPreferredLanguage.AS("target_lang"), + postgres.MAX(r.TranslationAttempts).AS("attempts"), + ). + FROM(r). + WHERE( + r.AvailableAt.IS_NULL(). + AND(r.RecipientPreferredLanguage.NOT_EQ(postgres.String(""))). + AND(r.NextTranslationAttemptAt.IS_NULL(). + OR(r.NextTranslationAttemptAt.LT_EQ(postgres.TimestampzT(now.UTC())))), + ). + GROUP_BY(r.MessageID, r.RecipientPreferredLanguage). + ORDER_BY(r.MessageID.ASC(), r.RecipientPreferredLanguage.ASC()). + LIMIT(1) + var dest struct { + MessageID uuid.UUID `alias:"message_id"` + TargetLang string `alias:"target_lang"` + Attempts int32 `alias:"attempts"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return PendingTranslationPair{}, false, nil + } + return PendingTranslationPair{}, false, fmt.Errorf("diplomail store: pick pending pair: %w", err) + } + if dest.MessageID == (uuid.UUID{}) { + return PendingTranslationPair{}, false, nil + } + return PendingTranslationPair{ + MessageID: dest.MessageID, + TargetLang: dest.TargetLang, + CurrentAttempts: dest.Attempts, + }, true, nil +} + +// MarkPairDelivered flips every still-pending recipient of (messageID, +// targetLang) to `available_at = at`, optionally persisting the +// translation row alongside in the same transaction. Returns the +// recipients that were just delivered (used by the worker to fan out +// push events). +func (s *Store) MarkPairDelivered(ctx context.Context, messageID uuid.UUID, targetLang string, translation *Translation, at time.Time) ([]Recipient, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("diplomail store: begin deliver tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if translation != nil { + t := table.DiplomailTranslations + ins := t.INSERT( + t.TranslationID, t.MessageID, t.TargetLang, + t.TranslatedSubject, t.TranslatedBody, t.Translator, + ).VALUES( + translation.TranslationID, translation.MessageID, translation.TargetLang, + translation.TranslatedSubject, translation.TranslatedBody, translation.Translator, + ).ON_CONFLICT(t.MessageID, t.TargetLang).DO_NOTHING() + if _, err := ins.ExecContext(ctx, tx); err != nil { + return nil, fmt.Errorf("diplomail store: upsert translation: %w", err) + } + } + + r := table.DiplomailRecipients + upd := r.UPDATE(r.AvailableAt, r.NextTranslationAttemptAt). + SET(postgres.TimestampzT(at.UTC()), postgres.NULL). + WHERE( + r.MessageID.EQ(postgres.UUID(messageID)). + AND(r.RecipientPreferredLanguage.EQ(postgres.String(targetLang))). + AND(r.AvailableAt.IS_NULL()), + ). + RETURNING(recipientColumns()) + + var rows []model.DiplomailRecipients + if err := upd.QueryContext(ctx, tx, &rows); err != nil { + return nil, fmt.Errorf("diplomail store: mark pair delivered: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("diplomail store: commit deliver: %w", err) + } + out := make([]Recipient, 0, len(rows)) + for _, row := range rows { + out = append(out, recipientFromModel(row)) + } + return out, nil +} + +// SchedulePairRetry bumps the attempt counter and schedules the next +// translation attempt for `next`. The recipient rows stay in the +// pending queue (`available_at IS NULL`). Returns the new attempt +// counter so the worker can decide whether to fall back to the +// original on the next pickup. +func (s *Store) SchedulePairRetry(ctx context.Context, messageID uuid.UUID, targetLang string, next time.Time) (int32, error) { + r := table.DiplomailRecipients + upd := r.UPDATE(r.TranslationAttempts, r.NextTranslationAttemptAt). + SET(r.TranslationAttempts.ADD(postgres.Int(1)), postgres.TimestampzT(next.UTC())). + WHERE( + r.MessageID.EQ(postgres.UUID(messageID)). + AND(r.RecipientPreferredLanguage.EQ(postgres.String(targetLang))). + AND(r.AvailableAt.IS_NULL()), + ). + RETURNING(r.TranslationAttempts) + var dest []struct { + TranslationAttempts int32 `alias:"diplomail_recipients.translation_attempts"` + } + if err := upd.QueryContext(ctx, s.db, &dest); err != nil { + return 0, fmt.Errorf("diplomail store: schedule pair retry: %w", err) + } + if len(dest) == 0 { + return 0, nil + } + max := dest[0].TranslationAttempts + for _, d := range dest[1:] { + if d.TranslationAttempts > max { + max = d.TranslationAttempts + } + } + return max, nil +} + +// translationColumns is the canonical projection for +// diplomail_translations reads. +func translationColumns() postgres.ColumnList { + t := table.DiplomailTranslations + return postgres.ColumnList{ + t.TranslationID, t.MessageID, t.TargetLang, + t.TranslatedSubject, t.TranslatedBody, t.Translator, t.TranslatedAt, + } +} + +// LoadTranslation returns the cached translation row for +// (messageID, targetLang). Returns ErrNotFound when no cache row +// exists yet — the caller decides whether to compute and persist +// one. +func (s *Store) LoadTranslation(ctx context.Context, messageID uuid.UUID, targetLang string) (Translation, error) { + t := table.DiplomailTranslations + stmt := postgres.SELECT(translationColumns()). + FROM(t). + WHERE(t.MessageID.EQ(postgres.UUID(messageID)). + AND(t.TargetLang.EQ(postgres.String(targetLang)))). + LIMIT(1) + var row model.DiplomailTranslations + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return Translation{}, ErrNotFound + } + return Translation{}, fmt.Errorf("diplomail store: load translation %s/%s: %w", messageID, targetLang, err) + } + return translationFromModel(row), nil +} + +// InsertTranslation persists a new translation cache row. The unique +// constraint on (message_id, target_lang) prevents duplicate +// renderings. Callers that race on the same (message, lang) pair +// should be prepared for a UNIQUE violation; the second writer can +// fall back to LoadTranslation. +func (s *Store) InsertTranslation(ctx context.Context, in Translation) (Translation, error) { + t := table.DiplomailTranslations + stmt := t.INSERT( + t.TranslationID, t.MessageID, t.TargetLang, + t.TranslatedSubject, t.TranslatedBody, t.Translator, + ).VALUES( + in.TranslationID, in.MessageID, in.TargetLang, + in.TranslatedSubject, in.TranslatedBody, in.Translator, + ).RETURNING(translationColumns()) + + var row model.DiplomailTranslations + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + return Translation{}, fmt.Errorf("diplomail store: insert translation %s/%s: %w", in.MessageID, in.TargetLang, err) + } + return translationFromModel(row), nil +} + +func translationFromModel(row model.DiplomailTranslations) Translation { + return Translation{ + TranslationID: row.TranslationID, + MessageID: row.MessageID, + TargetLang: row.TargetLang, + TranslatedSubject: row.TranslatedSubject, + TranslatedBody: row.TranslatedBody, + Translator: row.Translator, + TranslatedAt: row.TranslatedAt, + } +} + +// DeleteMessagesForGames removes every diplomail_messages row whose +// game_id falls in the supplied set. The cascade defined on the +// `diplomail_recipients` and `diplomail_translations` foreign keys +// removes the per-recipient state and the cached translations in +// the same transaction. Returns the count of messages removed. +// +// Used by the admin bulk-purge endpoint; callers are expected to +// have already filtered the input set to terminal-state games. +func (s *Store) DeleteMessagesForGames(ctx context.Context, gameIDs []uuid.UUID) (int, error) { + if len(gameIDs) == 0 { + return 0, nil + } + args := make([]postgres.Expression, 0, len(gameIDs)) + for _, id := range gameIDs { + args = append(args, postgres.UUID(id)) + } + m := table.DiplomailMessages + stmt := m.DELETE().WHERE(m.GameID.IN(args...)) + res, err := stmt.ExecContext(ctx, s.db) + if err != nil { + return 0, fmt.Errorf("diplomail store: bulk delete messages: %w", err) + } + affected, err := res.RowsAffected() + if err != nil { + return 0, fmt.Errorf("diplomail store: rows affected: %w", err) + } + return int(affected), nil +} + +// ListMessagesForAdmin returns a paginated slice of messages +// matching filter. The result is ordered by created_at DESC, +// message_id DESC. Total is the count without pagination so the +// caller can render a "page X of N" envelope. +func (s *Store) ListMessagesForAdmin(ctx context.Context, filter AdminMessageListing) ([]Message, int, error) { + m := table.DiplomailMessages + page := filter.Page + if page < 1 { + page = 1 + } + pageSize := filter.PageSize + if pageSize < 1 { + pageSize = 50 + } + + conditions := postgres.BoolExpression(nil) + addCondition := func(cond postgres.BoolExpression) { + if conditions == nil { + conditions = cond + return + } + conditions = conditions.AND(cond) + } + if filter.GameID != nil { + addCondition(m.GameID.EQ(postgres.UUID(*filter.GameID))) + } + if filter.Kind != "" { + addCondition(m.Kind.EQ(postgres.String(filter.Kind))) + } + if filter.SenderKind != "" { + addCondition(m.SenderKind.EQ(postgres.String(filter.SenderKind))) + } + + countStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(m) + if conditions != nil { + countStmt = countStmt.WHERE(conditions) + } + var countDest struct { + Count int64 `alias:"count"` + } + if err := countStmt.QueryContext(ctx, s.db, &countDest); err != nil { + return nil, 0, fmt.Errorf("diplomail store: count admin messages: %w", err) + } + + listStmt := postgres.SELECT(messageColumns()).FROM(m) + if conditions != nil { + listStmt = listStmt.WHERE(conditions) + } + listStmt = listStmt. + ORDER_BY(m.CreatedAt.DESC(), m.MessageID.DESC()). + LIMIT(int64(pageSize)). + OFFSET(int64((page - 1) * pageSize)) + + var rows []model.DiplomailMessages + if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil { + return nil, 0, fmt.Errorf("diplomail store: list admin messages: %w", err) + } + out := make([]Message, 0, len(rows)) + for _, row := range rows { + out = append(out, messageFromModel(row)) + } + return out, int(countDest.Count), nil +} + +// UnreadCountsForUser returns a per-game breakdown of unread messages +// addressed to userID, plus the matching game names so the lobby +// badge UI can render entries even after the recipient's membership +// has been revoked. The slice is ordered by game name. +func (s *Store) UnreadCountsForUser(ctx context.Context, userID uuid.UUID) ([]UnreadCount, error) { + r := table.DiplomailRecipients + m := table.DiplomailMessages + stmt := postgres.SELECT( + r.GameID.AS("game_id"), + postgres.MAX(m.GameName).AS("game_name"), + postgres.COUNT(postgres.STAR).AS("count"), + ). + FROM(r.INNER_JOIN(m, m.MessageID.EQ(r.MessageID))). + WHERE( + r.UserID.EQ(postgres.UUID(userID)). + AND(r.ReadAt.IS_NULL()). + AND(r.DeletedAt.IS_NULL()). + AND(r.AvailableAt.IS_NOT_NULL()), + ). + GROUP_BY(r.GameID). + ORDER_BY(postgres.MAX(m.GameName).ASC()) + var dest []struct { + GameID uuid.UUID `alias:"game_id"` + GameName string `alias:"game_name"` + Count int64 `alias:"count"` + } + if err := stmt.QueryContext(ctx, s.db, &dest); err != nil { + return nil, fmt.Errorf("diplomail store: unread counts %s: %w", userID, err) + } + out := make([]UnreadCount, 0, len(dest)) + for _, row := range dest { + out = append(out, UnreadCount{ + GameID: row.GameID, + GameName: row.GameName, + Unread: int(row.Count), + }) + } + return out, nil +} + +// messageFromModel converts a jet-generated row to the domain type. +func messageFromModel(row model.DiplomailMessages) Message { + out := Message{ + MessageID: row.MessageID, + GameID: row.GameID, + GameName: row.GameName, + Kind: row.Kind, + SenderKind: row.SenderKind, + SenderIP: row.SenderIP, + Subject: row.Subject, + Body: row.Body, + BodyLang: row.BodyLang, + BroadcastScope: row.BroadcastScope, + CreatedAt: row.CreatedAt, + } + if row.SenderUserID != nil { + id := *row.SenderUserID + out.SenderUserID = &id + } + if row.SenderUsername != nil { + name := *row.SenderUsername + out.SenderUsername = &name + } + return out +} + +// recipientFromModel converts a jet-generated row to the domain type. +func recipientFromModel(row model.DiplomailRecipients) Recipient { + out := Recipient{ + RecipientID: row.RecipientID, + MessageID: row.MessageID, + GameID: row.GameID, + UserID: row.UserID, + RecipientUserName: row.RecipientUserName, + RecipientPreferredLanguage: row.RecipientPreferredLanguage, + AvailableAt: row.AvailableAt, + TranslationAttempts: row.TranslationAttempts, + NextTranslationAttemptAt: row.NextTranslationAttemptAt, + DeliveredAt: row.DeliveredAt, + ReadAt: row.ReadAt, + DeletedAt: row.DeletedAt, + NotifiedAt: row.NotifiedAt, + } + if row.RecipientRaceName != nil { + name := *row.RecipientRaceName + out.RecipientRaceName = &name + } + return out +} + +// recipientsFromModel converts a slice in place. Used by +// InsertMessageWithRecipients. +func recipientsFromModel(rows []model.DiplomailRecipients) []Recipient { + out := make([]Recipient, 0, len(rows)) + for _, row := range rows { + out = append(out, recipientFromModel(row)) + } + return out +} + +// uuidPtrArg returns the jet argument expression for a nullable UUID. +// Pre-NULL handling here avoids a custom NULL literal at every call +// site. +func uuidPtrArg(v *uuid.UUID) postgres.Expression { + if v == nil { + return postgres.NULL + } + return postgres.UUID(*v) +} + +// stringPtrArg returns the jet argument expression for a nullable +// text column. +func stringPtrArg(v *string) postgres.Expression { + if v == nil { + return postgres.NULL + } + return postgres.String(*v) +} + +// timePtrArg returns the jet argument expression for a nullable +// timestamptz column. +func timePtrArg(v *time.Time) postgres.Expression { + if v == nil { + return postgres.NULL + } + return postgres.TimestampzT(v.UTC()) +} diff --git a/backend/internal/diplomail/translator/libretranslate.go b/backend/internal/diplomail/translator/libretranslate.go new file mode 100644 index 0000000..ec66391 --- /dev/null +++ b/backend/internal/diplomail/translator/libretranslate.go @@ -0,0 +1,154 @@ +package translator + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// LibreTranslateEngine is the engine identifier persisted in +// `diplomail_translations.translator` for cache rows produced by the +// LibreTranslate client. +const LibreTranslateEngine = "libretranslate" + +// LibreTranslateConfig configures the HTTP client. URL is the base +// of the deployed instance (without `/translate`). Timeout bounds a +// single HTTP request; the worker layers retry / backoff on top. +type LibreTranslateConfig struct { + URL string + Timeout time.Duration +} + +// ErrUnsupportedLanguagePair classifies a LibreTranslate 400 response +// that indicates the engine cannot translate between the requested +// source / target codes. The worker treats this as terminal: no +// further retries, deliver the original. +var ErrUnsupportedLanguagePair = errors.New("translator: language pair not supported by libretranslate") + +// NewLibreTranslate constructs a Translator that posts to +// `/translate`. Returns an error when URL is empty so wiring +// catches "translator misconfigured" at startup rather than at +// first-translation-attempt. +func NewLibreTranslate(cfg LibreTranslateConfig) (Translator, error) { + url := strings.TrimRight(strings.TrimSpace(cfg.URL), "/") + if url == "" { + return nil, errors.New("translator: libretranslate URL must be set") + } + timeout := cfg.Timeout + if timeout <= 0 { + timeout = 10 * time.Second + } + return &libreTranslate{ + endpoint: url + "/translate", + client: &http.Client{Timeout: timeout}, + }, nil +} + +type libreTranslate struct { + endpoint string + client *http.Client +} + +// requestBody is the LibreTranslate POST /translate input shape. +// `q` is sent as a two-element array so the engine returns one +// translation per element in the same call (subject + body). +type requestBody struct { + Q []string `json:"q"` + Source string `json:"source"` + Target string `json:"target"` + Format string `json:"format"` +} + +// responseBody is the LibreTranslate output shape when `q` is an +// array. The single-string-q variant is a different shape; we never +// emit a single-q request so the client always sees the array form. +type responseBody struct { + TranslatedText []string `json:"translatedText"` + Error string `json:"error,omitempty"` +} + +// Translate posts subject + body to LibreTranslate, normalising the +// language codes and classifying the response. The 400 / unsupported- +// pair path is signalled by `ErrUnsupportedLanguagePair`. All other +// HTTP errors (timeout, 5xx, network failure) come back as wrapped +// errors so the worker can backoff and retry. +func (l *libreTranslate) Translate(ctx context.Context, srcLang, dstLang, subject, body string) (Result, error) { + src := normaliseLanguageCode(srcLang) + dst := normaliseLanguageCode(dstLang) + if src == "" || dst == "" { + return Result{}, fmt.Errorf("translator: missing source or target language (src=%q dst=%q)", srcLang, dstLang) + } + if src == dst { + return Result{Subject: subject, Body: body, Engine: NoopEngine}, nil + } + + reqBody, err := json.Marshal(requestBody{ + Q: []string{subject, body}, + Source: src, + Target: dst, + Format: "text", + }) + if err != nil { + return Result{}, fmt.Errorf("translator: marshal request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.endpoint, bytes.NewReader(reqBody)) + if err != nil { + return Result{}, fmt.Errorf("translator: build request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + + resp, err := l.client.Do(req) + if err != nil { + return Result{}, fmt.Errorf("translator: do request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return Result{}, fmt.Errorf("translator: read response: %w", err) + } + if resp.StatusCode == http.StatusBadRequest { + return Result{}, fmt.Errorf("%w: %s", ErrUnsupportedLanguagePair, strings.TrimSpace(string(raw))) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return Result{}, fmt.Errorf("translator: libretranslate http %d: %s", resp.StatusCode, strings.TrimSpace(string(raw))) + } + + var out responseBody + if err := json.Unmarshal(raw, &out); err != nil { + return Result{}, fmt.Errorf("translator: unmarshal response: %w", err) + } + if out.Error != "" { + return Result{}, fmt.Errorf("translator: libretranslate error: %s", out.Error) + } + if len(out.TranslatedText) != 2 { + return Result{}, fmt.Errorf("translator: libretranslate returned %d strings, want 2", len(out.TranslatedText)) + } + return Result{ + Subject: out.TranslatedText[0], + Body: out.TranslatedText[1], + Engine: LibreTranslateEngine, + }, nil +} + +// normaliseLanguageCode collapses a BCP 47 tag to the ISO 639-1 base +// that LibreTranslate expects (`en-US` → `en`, `EN` → `en`). The +// helper is mirrored on the diplomail service side; both sides need +// to use the same normalisation so cache keys line up. +func normaliseLanguageCode(tag string) string { + tag = strings.TrimSpace(tag) + if tag == "" { + return "" + } + if i := strings.IndexAny(tag, "-_"); i > 0 { + tag = tag[:i] + } + return strings.ToLower(tag) +} diff --git a/backend/internal/diplomail/translator/libretranslate_test.go b/backend/internal/diplomail/translator/libretranslate_test.go new file mode 100644 index 0000000..85bbe67 --- /dev/null +++ b/backend/internal/diplomail/translator/libretranslate_test.go @@ -0,0 +1,173 @@ +package translator + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestLibreTranslateHappyPath(t *testing.T) { + t.Parallel() + var ( + requestSource string + requestTarget string + requestQ []string + requestFormat string + ) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var in requestBody + if err := json.Unmarshal(body, &in); err != nil { + t.Errorf("unmarshal: %v", err) + } + requestSource = in.Source + requestTarget = in.Target + requestQ = in.Q + requestFormat = in.Format + _ = json.NewEncoder(w).Encode(responseBody{ + TranslatedText: []string{"[ru] " + in.Q[0], "[ru] " + in.Q[1]}, + }) + })) + t.Cleanup(server.Close) + + tr, err := NewLibreTranslate(LibreTranslateConfig{URL: server.URL, Timeout: 2 * time.Second}) + if err != nil { + t.Fatalf("new: %v", err) + } + res, err := tr.Translate(context.Background(), "en", "ru", "Hello", "World") + if err != nil { + t.Fatalf("translate: %v", err) + } + if res.Engine != LibreTranslateEngine { + t.Fatalf("engine = %q, want %q", res.Engine, LibreTranslateEngine) + } + if res.Subject != "[ru] Hello" || res.Body != "[ru] World" { + t.Fatalf("result = %+v", res) + } + if requestSource != "en" || requestTarget != "ru" || requestFormat != "text" { + t.Fatalf("request fields: src=%q dst=%q fmt=%q", requestSource, requestTarget, requestFormat) + } + if len(requestQ) != 2 || requestQ[0] != "Hello" || requestQ[1] != "World" { + t.Fatalf("request q = %v", requestQ) + } +} + +func TestLibreTranslateNormalisesLanguageCodes(t *testing.T) { + t.Parallel() + var src, dst string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var in requestBody + _ = json.Unmarshal(body, &in) + src, dst = in.Source, in.Target + _ = json.NewEncoder(w).Encode(responseBody{TranslatedText: []string{"a", "b"}}) + })) + t.Cleanup(server.Close) + + tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL}) + if _, err := tr.Translate(context.Background(), "EN-US", "ru-RU", "x", "y"); err != nil { + t.Fatalf("translate: %v", err) + } + if src != "en" || dst != "ru" { + t.Fatalf("normalised codes src=%q dst=%q, want en/ru", src, dst) + } +} + +func TestLibreTranslateUnsupportedPair(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"language not supported"}`)) + })) + t.Cleanup(server.Close) + + tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL}) + _, err := tr.Translate(context.Background(), "en", "xx", "subject", "body") + if !errors.Is(err, ErrUnsupportedLanguagePair) { + t.Fatalf("err = %v, want ErrUnsupportedLanguagePair", err) + } +} + +func TestLibreTranslateServerError(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("kaboom")) + })) + t.Cleanup(server.Close) + + tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL}) + _, err := tr.Translate(context.Background(), "en", "ru", "subject", "body") + if err == nil { + t.Fatalf("expected error, got nil") + } + if errors.Is(err, ErrUnsupportedLanguagePair) { + t.Fatalf("err mis-classified as unsupported pair: %v", err) + } + if !strings.Contains(err.Error(), "500") { + t.Fatalf("err = %v, want mention of 500", err) + } +} + +func TestLibreTranslateSameSourceAndTargetIsNoop(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("translator should not call the server for identical src/dst: %s", r.URL.Path) + })) + t.Cleanup(server.Close) + + tr, _ := NewLibreTranslate(LibreTranslateConfig{URL: server.URL}) + res, err := tr.Translate(context.Background(), "en", "EN", "x", "y") + if err != nil { + t.Fatalf("translate: %v", err) + } + if res.Engine != NoopEngine { + t.Fatalf("engine = %q, want %q", res.Engine, NoopEngine) + } +} + +func TestLibreTranslateRequiresURL(t *testing.T) { + t.Parallel() + _, err := NewLibreTranslate(LibreTranslateConfig{URL: ""}) + if err == nil { + 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) + } + }) + } +} diff --git a/backend/internal/diplomail/translator/translator.go b/backend/internal/diplomail/translator/translator.go new file mode 100644 index 0000000..5d0799d --- /dev/null +++ b/backend/internal/diplomail/translator/translator.go @@ -0,0 +1,59 @@ +// Package translator wraps the per-language rendering for the +// diplomail subsystem. The package exposes a narrow `Translator` +// interface so the actual translation backend (LibreTranslate, an +// in-process model, a SaaS engine, …) can be swapped without +// touching the rest of the codebase. +// +// Stage D ships a `NoopTranslator` that returns the input unchanged. +// The diplomail Service treats a `Name == NoopEngine` result as +// "translation unavailable" and refrains from writing a cache row; +// the inbox handler then returns the original body with a +// `translated == false` payload. The contract lets the rest of the +// system ship without a translation backend; future stages can wire +// a real `Translator` without code changes elsewhere. +package translator + +import "context" + +// NoopEngine is the engine identifier returned by `NoopTranslator`. +// The diplomail Service checks for this value to decide whether to +// persist a `diplomail_translations` row. +const NoopEngine = "noop" + +// Result carries one translated rendering plus the engine identifier +// that produced it. The engine name is persisted as +// `diplomail_translations.translator` so an operator can see which +// backend produced each row. +type Result struct { + Subject string + Body string + Engine string +} + +// Translator is the read-only surface diplomail consumes when it +// needs to render a message for a recipient whose +// `preferred_language` differs from `body_lang`. Implementations +// must be safe for concurrent use; `Translate` may be invoked from +// the async worker on many messages at once. +type Translator interface { + // Translate renders `subject` and `body` from `srcLang` into + // `dstLang`. A nil error with `Result.Engine == NoopEngine` + // signals that no real rendering happened. + Translate(ctx context.Context, srcLang, dstLang, subject, body string) (Result, error) +} + +// NewNoop returns a Translator that always returns the input +// unchanged with engine name `NoopEngine`. +func NewNoop() Translator { + return noop{} +} + +type noop struct{} + +func (noop) Translate(_ context.Context, _, _, subject, body string) (Result, error) { + return Result{ + Subject: subject, + Body: body, + Engine: NoopEngine, + }, nil +} diff --git a/backend/internal/diplomail/types.go b/backend/internal/diplomail/types.go new file mode 100644 index 0000000..f1b29f7 --- /dev/null +++ b/backend/internal/diplomail/types.go @@ -0,0 +1,255 @@ +package diplomail + +import ( + "time" + + "github.com/google/uuid" +) + +// Message mirrors a row in `backend.diplomail_messages` enriched with +// the per-message metadata captured at insert time. +// +// SenderUserID and SenderUsername are nullable in the DB so that the +// CHECK constraint can cover the three legal sender shapes: +// +// - player: SenderUserID set, SenderUsername set +// - admin: SenderUserID nil, SenderUsername set +// - system: SenderUserID nil, SenderUsername nil +type Message struct { + MessageID uuid.UUID + GameID uuid.UUID + GameName string + Kind string + SenderKind string + SenderUserID *uuid.UUID + SenderUsername *string + SenderIP string + Subject string + Body string + BodyLang string + BroadcastScope string + CreatedAt time.Time +} + +// Recipient mirrors a row in `backend.diplomail_recipients`. The +// per-recipient state (read/deleted/delivered/notified) lives here. +// RecipientUserName, RecipientRaceName, and +// RecipientPreferredLanguage are snapshots taken at insert time so +// the inbox listing, admin search, and translation worker render +// correctly even after the source rows are renamed or revoked. +// +// AvailableAt encodes the async-translation contract introduced in +// Stage E: +// +// - non-nil → message is visible to the recipient (in inbox / +// unread counts / push events) starting from this timestamp; +// - nil → recipient is waiting for the translation worker to fan +// out the translated rendering. The translation_attempts counter +// tracks the number of failed LibreTranslate calls; the worker +// gives up after `MaxTranslationAttempts` and falls back to the +// original body, flipping AvailableAt to now(). +type Recipient struct { + RecipientID uuid.UUID + MessageID uuid.UUID + GameID uuid.UUID + UserID uuid.UUID + RecipientUserName string + RecipientRaceName *string + RecipientPreferredLanguage string + AvailableAt *time.Time + TranslationAttempts int32 + NextTranslationAttemptAt *time.Time + DeliveredAt *time.Time + ReadAt *time.Time + DeletedAt *time.Time + NotifiedAt *time.Time +} + +// InboxEntry is the read-side projection composed of a Message and the +// caller's own Recipient row. The HTTP layer renders one of these per +// item in the inbox listing. Translation, when non-nil, carries the +// per-recipient rendering returned from +// `Service.GetMessage(ctx, …, targetLang)` and surfaced under the +// `body_translated` payload field; Stage D ships a noop translator, +// so this field stays nil until a real backend is wired. +type InboxEntry struct { + Message + Recipient Recipient + Translation *Translation +} + +// Translation mirrors a row in `backend.diplomail_translations`. The +// engine identifier is preserved so an operator can see which +// backend produced the cached rendering. +type Translation struct { + TranslationID uuid.UUID + MessageID uuid.UUID + TargetLang string + TranslatedSubject string + TranslatedBody string + Translator string + TranslatedAt time.Time +} + +// SendPersonalInput is the request payload for SendPersonal: the +// caller sending a single-recipient personal message. Validation +// (active membership, body length, etc.) is performed inside the +// service. +type SendPersonalInput struct { + GameID uuid.UUID + SenderUserID uuid.UUID + RecipientUserID uuid.UUID + Subject string + Body string + SenderIP string +} + +// CallerKind enumerates the privileged sender roles for admin-kind +// messages. Owners (`CallerKindOwner`) are players who own a private +// game; admins (`CallerKindAdmin`) hit the dedicated admin route; +// `CallerKindSystem` is reserved for internal lifecycle hooks. +const ( + CallerKindOwner = "owner" + CallerKindAdmin = "admin" + CallerKindSystem = "system" +) + +// SendAdminPersonalInput is the request payload for an owner / +// admin / system sending an admin-kind message to a single +// recipient. Authorization (owner-vs-admin distinction) is enforced +// by the HTTP layer; the service trusts the caller designation. +type SendAdminPersonalInput struct { + GameID uuid.UUID + CallerKind string + CallerUserID *uuid.UUID + CallerUsername string + RecipientUserID uuid.UUID + Subject string + Body string + SenderIP string +} + +// SendAdminBroadcastInput is the request payload for an owner / +// admin / system broadcasting an admin-kind message inside a single +// game. RecipientScope selects the address book; the sender's own +// recipient row is never created (a broadcast author does not get a +// copy of their own message). +type SendAdminBroadcastInput struct { + GameID uuid.UUID + CallerKind string + CallerUserID *uuid.UUID + CallerUsername string + RecipientScope string + Subject string + Body string + SenderIP string +} + +// LifecycleEventKind enumerates the producer-side intents the lobby +// emits when a game-state or membership-state transition lands. +const ( + LifecycleKindGamePaused = "game.paused" + LifecycleKindGameCancelled = "game.cancelled" + LifecycleKindMembershipRemoved = "membership.removed" + LifecycleKindMembershipBlocked = "membership.blocked" +) + +// SendPlayerBroadcastInput is the request payload for the paid-tier +// player broadcast. The sender is a player; recipients are the +// active members of the game minus the sender. The resulting message +// is `kind="personal"`, `sender_kind="player"`, +// `broadcast_scope="game_broadcast"` — recipients may reply as if it +// were a personal send, but the reply goes back to the broadcaster +// only. +type SendPlayerBroadcastInput struct { + GameID uuid.UUID + SenderUserID uuid.UUID + Subject string + Body string + SenderIP string +} + +// MultiGameBroadcastScope enumerates the admin multi-game broadcast +// modes. `selected` requires `GameIDs`; `all_running` enumerates +// every game whose status is non-terminal through GameLookup. +const ( + MultiGameScopeSelected = "selected" + MultiGameScopeAllRunning = "all_running" +) + +// SendMultiGameBroadcastInput is the request payload for the admin +// multi-game broadcast. The service materialises one message row per +// addressed game (so a recipient who plays in two games receives two +// independently-deletable inbox entries), then fan-outs the push +// events. +type SendMultiGameBroadcastInput struct { + CallerUsername string + Scope string + GameIDs []uuid.UUID + RecipientScope string + Subject string + Body string + SenderIP string +} + +// BulkCleanupInput selects messages eligible for purge. OlderThanYears +// must be >= 1; the service translates the value into a cutoff +// expressed in years and walks `GameLookup.ListFinishedGamesBefore`. +type BulkCleanupInput struct { + OlderThanYears int +} + +// CleanupResult summarises a bulk-cleanup run for the admin response +// envelope. +type CleanupResult struct { + GameIDs []uuid.UUID + MessagesDeleted int +} + +// AdminMessageListing is the filter passed to ListMessagesForAdmin. +// Pagination uses (Page, PageSize) consistent with the rest of the +// admin surface. Filters are AND-combined; the empty filter returns +// every persisted row. +type AdminMessageListing struct { + Page int + PageSize int + GameID *uuid.UUID + Kind string + SenderKind string +} + +// AdminMessagePage is the canonical pagination envelope. +type AdminMessagePage struct { + Items []Message + Total int + Page int + PageSize int +} + +// LifecycleEvent is the payload lobby hands to PublishLifecycle when +// a transition needs to be reflected as durable system mail. The +// recipient set is derived by the service: +// +// - For game.* events the message fans out to every active member +// of the game except the actor (the actor sees the action in +// their own UI through other channels). +// - For membership.* events the message addresses exactly +// `TargetUser` (the kicked player), regardless of their current +// membership status — this is how a kicked player retains read +// access to the explanation of the kick. +type LifecycleEvent struct { + GameID uuid.UUID + Kind string + Actor string + Reason string + TargetUser *uuid.UUID +} + +// UnreadCount carries a per-game unread-count row returned by +// UnreadCountsForUser. The lobby badge UI consumes the slice plus the +// derived total. +type UnreadCount struct { + GameID uuid.UUID + GameName string + Unread int +} diff --git a/backend/internal/diplomail/worker.go b/backend/internal/diplomail/worker.go new file mode 100644 index 0000000..9cacb43 --- /dev/null +++ b/backend/internal/diplomail/worker.go @@ -0,0 +1,209 @@ +package diplomail + +import ( + "context" + "errors" + "time" + + "galaxy/backend/internal/diplomail/translator" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// translationBackoff returns the sleep applied before retry attempt +// `attempt`. attempt is 1-indexed (the value the row carries AFTER +// the failure is recorded). The schedule mirrors the spec — +// 1s → 2s → 4s → 8s → 16s — so 5 failed attempts span ~31 seconds +// before the worker falls back to delivering the original. +func translationBackoff(attempt int32) time.Duration { + if attempt <= 0 { + return 0 + } + out := time.Second + for i := int32(1); i < attempt; i++ { + out *= 2 + } + const cap = 60 * time.Second + if out > cap { + return cap + } + return out +} + +// Worker drives the async translation pipeline. Each tick picks a +// single (message_id, target_lang) pair from +// `diplomail_recipients` where `available_at IS NULL`, asks the +// configured Translator to render the body, and either delivers the +// pending recipients (success) or schedules a retry (transient +// failure) or delivers them with a fallback to the original body +// (terminal failure / max attempts). +// +// The worker is single-threaded by design: one HTTP call to +// LibreTranslate at a time. This protects the upstream from spikes +// and keeps the implementation reviewable. +// +// Implements `internal/app.Component` so it plugs into the same +// lifecycle as the mail and notification workers. +type Worker struct { + svc *Service +} + +// NewWorker constructs a Worker bound to svc. Returning a non-nil +// Worker even when the translator is the noop fallback is +// intentional — the pickup query still works and falls through to +// fallback delivery, which is the desired behaviour for setups +// without LibreTranslate. +func NewWorker(svc *Service) *Worker { return &Worker{svc: svc} } + +// Run drives the worker loop until ctx is cancelled. +func (w *Worker) Run(ctx context.Context) error { + if w == nil || w.svc == nil { + return nil + } + logger := w.svc.deps.Logger.Named("worker") + interval := w.svc.deps.Config.WorkerInterval + if interval <= 0 { + interval = 2 * time.Second + } + if err := w.tick(ctx); err != nil && !errors.Is(err, context.Canceled) { + logger.Warn("diplomail worker initial tick failed", zap.Error(err)) + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := w.tick(ctx); err != nil && !errors.Is(err, context.Canceled) { + logger.Warn("diplomail worker tick failed", zap.Error(err)) + } + } + } +} + +// Shutdown is a no-op: every translation outcome is committed inside +// tick before returning, so cancelling the parent ctx is enough. +func (w *Worker) Shutdown(_ context.Context) error { return nil } + +// Tick exposes the per-tick work for tests so they can drive the +// worker without depending on the ticker. +func (w *Worker) Tick(ctx context.Context) error { return w.tick(ctx) } + +// tick picks one pair from the queue and applies the result. The +// per-tick budget is one pair on purpose: the worker is single +// threaded and we do not want a fast LibreTranslate instance to +// starve the rest of the backend's I/O behind a long-running batch. +func (w *Worker) tick(ctx context.Context) error { + if ctx.Err() != nil { + return ctx.Err() + } + pair, ok, err := w.svc.deps.Store.PickPendingTranslationPair(ctx, w.svc.nowUTC()) + if err != nil { + return err + } + if !ok { + return nil + } + return w.processPair(ctx, pair) +} + +// processPair runs the full pipeline for one (message, target_lang). +// Steps: +// +// 1. Load the source message. +// 2. Check the translation cache. If a row already exists (another +// worker pre-populated it, or two pairs converged on the same +// target), reuse it and deliver. +// 3. Otherwise call the configured Translator. +// 4. Apply the outcome: success → cache + deliver; unsupported +// pair → deliver fallback (no cache row); other failure → +// schedule retry or deliver fallback after MaxAttempts. +// 5. Fan out push events for every recipient whose `available_at` +// just transitioned. +func (w *Worker) processPair(ctx context.Context, pair PendingTranslationPair) error { + logger := w.svc.deps.Logger.Named("worker").With( + zap.String("message_id", pair.MessageID.String()), + zap.String("target_lang", pair.TargetLang), + ) + msg, err := w.svc.deps.Store.LoadMessage(ctx, pair.MessageID) + if err != nil { + return err + } + + if cached, err := w.svc.deps.Store.LoadTranslation(ctx, pair.MessageID, pair.TargetLang); err == nil { + t := cached + return w.deliverPair(ctx, msg, pair.TargetLang, &t, logger) + } else if !errors.Is(err, ErrNotFound) { + return err + } + + result, callErr := w.svc.deps.Translator.Translate(ctx, msg.BodyLang, pair.TargetLang, msg.Subject, msg.Body) + if callErr == nil && result.Engine != "" && result.Engine != translator.NoopEngine { + tr := Translation{ + TranslationID: uuid.New(), + MessageID: msg.MessageID, + TargetLang: pair.TargetLang, + TranslatedSubject: result.Subject, + TranslatedBody: result.Body, + Translator: result.Engine, + } + return w.deliverPair(ctx, msg, pair.TargetLang, &tr, logger) + } + if callErr == nil { + // Noop translator (or engine returned empty). Treat as + // "translation unavailable" — deliver fallback so users + // see the original. + logger.Debug("translator returned noop, delivering fallback") + return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger) + } + if errors.Is(callErr, translator.ErrUnsupportedLanguagePair) { + logger.Info("language pair unsupported, delivering fallback", zap.Error(callErr)) + return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger) + } + + // Transient failure — bump the attempts counter and schedule a + // retry. The next attempt timestamp is computed from the + // post-increment counter so the spec's 1s→2s→4s→8s→16s schedule + // applies between retries of the same pair. + maxAttempts := w.svc.deps.Config.TranslatorMaxAttempts + if maxAttempts <= 0 { + maxAttempts = 5 + } + nextAttempt := pair.CurrentAttempts + 1 + if int(nextAttempt) >= maxAttempts { + logger.Warn("translator max attempts reached, delivering fallback", + zap.Int32("attempts", nextAttempt), zap.Error(callErr)) + return w.deliverPair(ctx, msg, pair.TargetLang, nil, logger) + } + next := w.svc.nowUTC().Add(translationBackoff(nextAttempt + 1)) + if _, err := w.svc.deps.Store.SchedulePairRetry(ctx, pair.MessageID, pair.TargetLang, next); err != nil { + return err + } + logger.Info("translator attempt failed, scheduled retry", + zap.Int32("attempts", nextAttempt), + zap.Time("next_attempt_at", next), + zap.Error(callErr)) + return nil +} + +// deliverPair flips every still-pending recipient of (messageID, +// targetLang) to delivered, optionally inserting the translation row +// in the same transaction, and emits push events to the recipients +// who were just unblocked. +func (w *Worker) deliverPair(ctx context.Context, msg Message, targetLang string, translation *Translation, logger *zap.Logger) error { + recipients, err := w.svc.deps.Store.MarkPairDelivered(ctx, msg.MessageID, targetLang, translation, w.svc.nowUTC()) + if err != nil { + return err + } + if len(recipients) == 0 { + logger.Debug("deliver yielded no recipients (already delivered)") + return nil + } + for _, r := range recipients { + w.svc.publishMessageReceived(ctx, msg, r) + } + return nil +} + diff --git a/backend/internal/lobby/cache.go b/backend/internal/lobby/cache.go index d155b9e..9523965 100644 --- a/backend/internal/lobby/cache.go +++ b/backend/internal/lobby/cache.go @@ -117,6 +117,24 @@ func (c *Cache) GetGame(gameID uuid.UUID) (GameRecord, bool) { return g, ok } +// ListGames returns a snapshot copy of every cached game. Terminal- +// state games (finished, cancelled) are evicted from the cache on +// `PutGame`, so the result reflects the live roster of running / +// paused / draft / starting / etc. games. The slice is freshly +// allocated and safe for the caller to mutate. +func (c *Cache) ListGames() []GameRecord { + if c == nil { + return nil + } + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]GameRecord, 0, len(c.games)) + for _, g := range c.games { + out = append(out, g) + } + return out +} + // PutGame stores game in the cache when its status is cacheable; // terminal statuses (finished, cancelled) cause the entry to be evicted. func (c *Cache) PutGame(game GameRecord) { diff --git a/backend/internal/lobby/deps.go b/backend/internal/lobby/deps.go index f7622c8..e1c4259 100644 --- a/backend/internal/lobby/deps.go +++ b/backend/internal/lobby/deps.go @@ -51,6 +51,37 @@ type NotificationPublisher interface { PublishLobbyEvent(ctx context.Context, intent LobbyNotification) error } +// DiplomailPublisher is the outbound surface the lobby uses to drop a +// durable system mail entry whenever a game-state or +// membership-state transition needs to land in the affected players' +// inboxes. The real implementation in `cmd/backend/main` adapts the +// `*diplomail.Service.PublishLifecycle` call; tests and partial +// wiring fall back to `NewNoopDiplomailPublisher`. +type DiplomailPublisher interface { + PublishLifecycle(ctx context.Context, event LifecycleEvent) error +} + +// LifecycleEvent is the open shape carried by a system-mail intent. +// `Kind` is one of the lobby-internal constants +// (`LifecycleKindGamePaused`, etc.). `TargetUser` is populated only +// for membership-scoped events; the publisher derives the game-scoped +// recipient set itself. +type LifecycleEvent struct { + GameID uuid.UUID + Kind string + Actor string + Reason string + TargetUser *uuid.UUID +} + +// Lifecycle-event kinds the lobby emits. +const ( + LifecycleKindGamePaused = "game.paused" + LifecycleKindGameCancelled = "game.cancelled" + LifecycleKindMembershipRemoved = "membership.removed" + LifecycleKindMembershipBlocked = "membership.blocked" +) + // LobbyNotification is the open shape carried by a notification intent. // The implementation emits a small set of `Kind` values matching the catalog in // `backend/README.md` §10. The `Payload` map is the kind-specific data @@ -123,3 +154,26 @@ func (p *noopNotificationPublisher) PublishLobbyEvent(_ context.Context, intent ) return nil } + +// NewNoopDiplomailPublisher returns a DiplomailPublisher that logs +// every call at debug level and returns nil. Used by tests and by +// the lobby Service factory when the Deps.Diplomail field is left +// nil. +func NewNoopDiplomailPublisher(logger *zap.Logger) DiplomailPublisher { + if logger == nil { + logger = zap.NewNop() + } + return &noopDiplomailPublisher{logger: logger.Named("lobby.diplomail.noop")} +} + +type noopDiplomailPublisher struct { + logger *zap.Logger +} + +func (p *noopDiplomailPublisher) PublishLifecycle(_ context.Context, event LifecycleEvent) error { + p.logger.Debug("noop diplomail lifecycle", + zap.String("kind", event.Kind), + zap.String("game_id", event.GameID.String()), + ) + return nil +} diff --git a/backend/internal/lobby/games.go b/backend/internal/lobby/games.go index 2ac5241..ad98f4f 100644 --- a/backend/internal/lobby/games.go +++ b/backend/internal/lobby/games.go @@ -10,6 +10,7 @@ import ( "galaxy/cronutil" "github.com/google/uuid" + "go.uber.org/zap" ) // CreateGameInput is the parameter struct for Service.CreateGame. @@ -233,6 +234,41 @@ func (s *Service) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameReco return s.deps.Store.ListMyGames(ctx, userID) } +// ListFinishedGamesBefore returns every game whose status is +// `finished` or `cancelled` and whose `finished_at` is strictly older +// than cutoff. The result walks the store through the admin-paged +// query with a 200-row batch size; the caller is expected to invoke +// this from rare admin workflows (diplomail bulk cleanup) rather +// than hot-path reads. +func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time) ([]GameRecord, error) { + const pageSize = 200 + page := 1 + var out []GameRecord + for { + batch, _, err := s.deps.Store.ListAdminGames(ctx, page, pageSize) + if err != nil { + return nil, fmt.Errorf("lobby: list finished games before %s: %w", cutoff, err) + } + if len(batch) == 0 { + break + } + for _, g := range batch { + if g.Status != GameStatusFinished && g.Status != GameStatusCancelled { + continue + } + if g.FinishedAt == nil || !g.FinishedAt.Before(cutoff) { + continue + } + out = append(out, g) + } + if len(batch) < pageSize { + break + } + page++ + } + return out, nil +} + // DeleteGame removes the game and every referencing row (memberships, // applications, invites, runtime_records, player_mappings) via the // `ON DELETE CASCADE` constraints declared in `00001_init.sql`. @@ -441,9 +477,43 @@ func (s *Service) transition(ctx context.Context, callerUserID *uuid.UUID, calle return updated, fmt.Errorf("post-commit %s: %w", rule.Reason, err) } } + s.emitGameLifecycleMail(ctx, updated, callerIsAdmin, rule) return updated, nil } +// emitGameLifecycleMail asks the diplomail publisher to drop a +// system-mail entry whenever a state change is user-visible. Only +// the `paused` and `cancelled` transitions emit mail today (the spec +// names them explicitly); `running`/`finished`/etc. are signalled by +// other channels and do not need a durable inbox entry. +func (s *Service) emitGameLifecycleMail(ctx context.Context, game GameRecord, callerIsAdmin bool, rule transitionRule) { + var kind string + switch rule.To { + case GameStatusPaused: + kind = LifecycleKindGamePaused + case GameStatusCancelled: + kind = LifecycleKindGameCancelled + default: + return + } + actor := "the game owner" + if callerIsAdmin { + actor = "an administrator" + } + ev := LifecycleEvent{ + GameID: game.GameID, + Kind: kind, + Actor: actor, + Reason: rule.Reason, + } + if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil { + s.deps.Logger.Warn("publish lifecycle mail failed", + zap.String("game_id", game.GameID.String()), + zap.String("kind", kind), + zap.Error(err)) + } +} + // checkOwner enforces ownership semantics: // // - callerIsAdmin == true → always allowed (admin force-start, etc.). diff --git a/backend/internal/lobby/lobby.go b/backend/internal/lobby/lobby.go index 25a0783..a798f0a 100644 --- a/backend/internal/lobby/lobby.go +++ b/backend/internal/lobby/lobby.go @@ -124,6 +124,7 @@ type Deps struct { Cache *Cache Runtime RuntimeGateway Notification NotificationPublisher + Diplomail DiplomailPublisher Entitlement EntitlementProvider Policy *Policy Config config.LobbyConfig @@ -156,6 +157,9 @@ func NewService(deps Deps) (*Service, error) { if deps.Notification == nil { deps.Notification = NewNoopNotificationPublisher(deps.Logger) } + if deps.Diplomail == nil { + deps.Diplomail = NewNoopDiplomailPublisher(deps.Logger) + } if deps.Policy == nil { policy, err := NewPolicy() if err != nil { diff --git a/backend/internal/lobby/memberships.go b/backend/internal/lobby/memberships.go index b6dbc7f..d7f5aa3 100644 --- a/backend/internal/lobby/memberships.go +++ b/backend/internal/lobby/memberships.go @@ -76,6 +76,7 @@ func (s *Service) AdminBanMember(ctx context.Context, gameID, userID uuid.UUID, zap.String("membership_id", updated.MembershipID.String()), zap.Error(pubErr)) } + s.emitMembershipLifecycleMail(ctx, updated, MembershipStatusBlocked, true, reason) _ = game return updated, nil } @@ -142,9 +143,44 @@ func (s *Service) changeMembershipStatus( zap.String("kind", notificationKind), zap.Error(pubErr)) } + s.emitMembershipLifecycleMail(ctx, updated, newStatus, callerIsAdmin, "") return updated, nil } +// emitMembershipLifecycleMail asks the diplomail publisher to drop a +// durable explanation into the kicked player's inbox. The mail +// survives the membership row going to `removed` / `blocked` so the +// player keeps read access to it (soft-access rule, item 8). +func (s *Service) emitMembershipLifecycleMail(ctx context.Context, membership Membership, newStatus string, callerIsAdmin bool, reason string) { + var kind string + switch newStatus { + case MembershipStatusRemoved: + kind = LifecycleKindMembershipRemoved + case MembershipStatusBlocked: + kind = LifecycleKindMembershipBlocked + default: + return + } + actor := "the game owner" + if callerIsAdmin { + actor = "an administrator" + } + target := membership.UserID + ev := LifecycleEvent{ + GameID: membership.GameID, + Kind: kind, + Actor: actor, + Reason: reason, + TargetUser: &target, + } + if err := s.deps.Diplomail.PublishLifecycle(ctx, ev); err != nil { + s.deps.Logger.Warn("publish membership lifecycle mail failed", + zap.String("membership_id", membership.MembershipID.String()), + zap.String("kind", kind), + zap.Error(err)) + } +} + func (s *Service) canManageMembership(game GameRecord, membership Membership, callerUserID *uuid.UUID, allowSelf bool) bool { if game.Visibility == VisibilityPublic { // Public-game membership management is admin-only. diff --git a/backend/internal/notification/catalog.go b/backend/internal/notification/catalog.go index 84666cf..9e4e9c3 100644 --- a/backend/internal/notification/catalog.go +++ b/backend/internal/notification/catalog.go @@ -19,6 +19,7 @@ const ( KindRuntimeStartConfigInvalid = "runtime.start_config_invalid" KindGameTurnReady = "game.turn.ready" KindGamePaused = "game.paused" + KindDiplomailReceived = "diplomail.message.received" ) // CatalogEntry describes the per-kind delivery policy: which channels @@ -103,6 +104,9 @@ var catalog = map[string]CatalogEntry{ KindGamePaused: { Channels: []string{ChannelPush}, }, + KindDiplomailReceived: { + Channels: []string{ChannelPush}, + }, } // LookupCatalog returns the per-kind policy and a boolean reporting @@ -133,5 +137,6 @@ func SupportedKinds() []string { KindRuntimeStartConfigInvalid, KindGameTurnReady, KindGamePaused, + KindDiplomailReceived, } } diff --git a/backend/internal/notification/catalog_test.go b/backend/internal/notification/catalog_test.go index 7dbae5c..3137938 100644 --- a/backend/internal/notification/catalog_test.go +++ b/backend/internal/notification/catalog_test.go @@ -41,6 +41,7 @@ func TestCatalogChannels(t *testing.T) { KindRuntimeStartConfigInvalid: {ChannelEmail}, KindGameTurnReady: {ChannelPush}, KindGamePaused: {ChannelPush}, + KindDiplomailReceived: {ChannelPush}, } for kind, want := range expect { entry, ok := LookupCatalog(kind) diff --git a/backend/internal/notification/events_test.go b/backend/internal/notification/events_test.go index a69f6af..93a1c2d 100644 --- a/backend/internal/notification/events_test.go +++ b/backend/internal/notification/events_test.go @@ -25,9 +25,15 @@ import ( // payload is `{game_id, turn, reason}` consumed by the same in-game // shell layout, so there is no value in dragging a FB schema in for // one consumer. +// +// `diplomail.message.received` (Stage A) carries the message metadata +// plus an unread-count snapshot. Stage A intentionally ships the +// payload as JSON so the diplomail UI can iterate on the contract +// without a FB schema dance; a later stage can promote it. var jsonFriendlyKinds = map[string]bool{ - KindGameTurnReady: true, - KindGamePaused: true, + KindGameTurnReady: true, + KindGamePaused: true, + KindDiplomailReceived: true, } // TestBuildClientPushEventCoversCatalog asserts that every catalog kind @@ -88,6 +94,17 @@ func TestBuildClientPushEventCoversCatalog(t *testing.T) { "turn": int32(7), "reason": "generation_failed", }}, + {"diplomail message received", KindDiplomailReceived, map[string]any{ + "message_id": gameID.String(), + "game_id": gameID.String(), + "kind": "personal", + "sender_kind": "player", + "subject": "Trade deal", + "preview": "Care to talk gas mining?", + "preview_lang": "en", + "unread_total": 3, + "unread_game": 1, + }}, } seenKinds := map[string]bool{} diff --git a/backend/internal/postgres/jet/backend/model/diplomail_messages.go b/backend/internal/postgres/jet/backend/model/diplomail_messages.go new file mode 100644 index 0000000..c2939cc --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/diplomail_messages.go @@ -0,0 +1,29 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type DiplomailMessages struct { + MessageID uuid.UUID `sql:"primary_key"` + GameID uuid.UUID + GameName string + Kind string + SenderKind string + SenderUserID *uuid.UUID + SenderUsername *string + SenderIP string + Subject string + Body string + BodyLang string + BroadcastScope string + CreatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/diplomail_recipients.go b/backend/internal/postgres/jet/backend/model/diplomail_recipients.go new file mode 100644 index 0000000..59b0ef6 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/diplomail_recipients.go @@ -0,0 +1,30 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type DiplomailRecipients struct { + RecipientID uuid.UUID `sql:"primary_key"` + MessageID uuid.UUID + GameID uuid.UUID + UserID uuid.UUID + RecipientUserName string + RecipientRaceName *string + RecipientPreferredLanguage string + AvailableAt *time.Time + TranslationAttempts int32 + NextTranslationAttemptAt *time.Time + DeliveredAt *time.Time + ReadAt *time.Time + DeletedAt *time.Time + NotifiedAt *time.Time +} diff --git a/backend/internal/postgres/jet/backend/model/diplomail_translations.go b/backend/internal/postgres/jet/backend/model/diplomail_translations.go new file mode 100644 index 0000000..08193c1 --- /dev/null +++ b/backend/internal/postgres/jet/backend/model/diplomail_translations.go @@ -0,0 +1,23 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package model + +import ( + "github.com/google/uuid" + "time" +) + +type DiplomailTranslations struct { + TranslationID uuid.UUID `sql:"primary_key"` + MessageID uuid.UUID + TargetLang string + TranslatedSubject string + TranslatedBody string + Translator string + TranslatedAt time.Time +} diff --git a/backend/internal/postgres/jet/backend/table/diplomail_messages.go b/backend/internal/postgres/jet/backend/table/diplomail_messages.go new file mode 100644 index 0000000..902e05c --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/diplomail_messages.go @@ -0,0 +1,114 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var DiplomailMessages = newDiplomailMessagesTable("backend", "diplomail_messages", "") + +type diplomailMessagesTable struct { + postgres.Table + + // Columns + MessageID postgres.ColumnString + GameID postgres.ColumnString + GameName postgres.ColumnString + Kind postgres.ColumnString + SenderKind postgres.ColumnString + SenderUserID postgres.ColumnString + SenderUsername postgres.ColumnString + SenderIP postgres.ColumnString + Subject postgres.ColumnString + Body postgres.ColumnString + BodyLang postgres.ColumnString + BroadcastScope postgres.ColumnString + CreatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type DiplomailMessagesTable struct { + diplomailMessagesTable + + EXCLUDED diplomailMessagesTable +} + +// AS creates new DiplomailMessagesTable with assigned alias +func (a DiplomailMessagesTable) AS(alias string) *DiplomailMessagesTable { + return newDiplomailMessagesTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new DiplomailMessagesTable with assigned schema name +func (a DiplomailMessagesTable) FromSchema(schemaName string) *DiplomailMessagesTable { + return newDiplomailMessagesTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new DiplomailMessagesTable with assigned table prefix +func (a DiplomailMessagesTable) WithPrefix(prefix string) *DiplomailMessagesTable { + return newDiplomailMessagesTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new DiplomailMessagesTable with assigned table suffix +func (a DiplomailMessagesTable) WithSuffix(suffix string) *DiplomailMessagesTable { + return newDiplomailMessagesTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newDiplomailMessagesTable(schemaName, tableName, alias string) *DiplomailMessagesTable { + return &DiplomailMessagesTable{ + diplomailMessagesTable: newDiplomailMessagesTableImpl(schemaName, tableName, alias), + EXCLUDED: newDiplomailMessagesTableImpl("", "excluded", ""), + } +} + +func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomailMessagesTable { + var ( + MessageIDColumn = postgres.StringColumn("message_id") + GameIDColumn = postgres.StringColumn("game_id") + GameNameColumn = postgres.StringColumn("game_name") + KindColumn = postgres.StringColumn("kind") + SenderKindColumn = postgres.StringColumn("sender_kind") + SenderUserIDColumn = postgres.StringColumn("sender_user_id") + SenderUsernameColumn = postgres.StringColumn("sender_username") + SenderIPColumn = postgres.StringColumn("sender_ip") + SubjectColumn = postgres.StringColumn("subject") + BodyColumn = postgres.StringColumn("body") + BodyLangColumn = postgres.StringColumn("body_lang") + BroadcastScopeColumn = postgres.StringColumn("broadcast_scope") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} + mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} + defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} + ) + + return diplomailMessagesTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + MessageID: MessageIDColumn, + GameID: GameIDColumn, + GameName: GameNameColumn, + Kind: KindColumn, + SenderKind: SenderKindColumn, + SenderUserID: SenderUserIDColumn, + SenderUsername: SenderUsernameColumn, + SenderIP: SenderIPColumn, + Subject: SubjectColumn, + Body: BodyColumn, + BodyLang: BodyLangColumn, + BroadcastScope: BroadcastScopeColumn, + CreatedAt: CreatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/diplomail_recipients.go b/backend/internal/postgres/jet/backend/table/diplomail_recipients.go new file mode 100644 index 0000000..024f113 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/diplomail_recipients.go @@ -0,0 +1,117 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var DiplomailRecipients = newDiplomailRecipientsTable("backend", "diplomail_recipients", "") + +type diplomailRecipientsTable struct { + postgres.Table + + // Columns + RecipientID postgres.ColumnString + MessageID postgres.ColumnString + GameID postgres.ColumnString + UserID postgres.ColumnString + RecipientUserName postgres.ColumnString + RecipientRaceName postgres.ColumnString + RecipientPreferredLanguage postgres.ColumnString + AvailableAt postgres.ColumnTimestampz + TranslationAttempts postgres.ColumnInteger + NextTranslationAttemptAt postgres.ColumnTimestampz + DeliveredAt postgres.ColumnTimestampz + ReadAt postgres.ColumnTimestampz + DeletedAt postgres.ColumnTimestampz + NotifiedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type DiplomailRecipientsTable struct { + diplomailRecipientsTable + + EXCLUDED diplomailRecipientsTable +} + +// AS creates new DiplomailRecipientsTable with assigned alias +func (a DiplomailRecipientsTable) AS(alias string) *DiplomailRecipientsTable { + return newDiplomailRecipientsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new DiplomailRecipientsTable with assigned schema name +func (a DiplomailRecipientsTable) FromSchema(schemaName string) *DiplomailRecipientsTable { + return newDiplomailRecipientsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new DiplomailRecipientsTable with assigned table prefix +func (a DiplomailRecipientsTable) WithPrefix(prefix string) *DiplomailRecipientsTable { + return newDiplomailRecipientsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new DiplomailRecipientsTable with assigned table suffix +func (a DiplomailRecipientsTable) WithSuffix(suffix string) *DiplomailRecipientsTable { + return newDiplomailRecipientsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newDiplomailRecipientsTable(schemaName, tableName, alias string) *DiplomailRecipientsTable { + return &DiplomailRecipientsTable{ + diplomailRecipientsTable: newDiplomailRecipientsTableImpl(schemaName, tableName, alias), + EXCLUDED: newDiplomailRecipientsTableImpl("", "excluded", ""), + } +} + +func newDiplomailRecipientsTableImpl(schemaName, tableName, alias string) diplomailRecipientsTable { + var ( + RecipientIDColumn = postgres.StringColumn("recipient_id") + MessageIDColumn = postgres.StringColumn("message_id") + GameIDColumn = postgres.StringColumn("game_id") + UserIDColumn = postgres.StringColumn("user_id") + RecipientUserNameColumn = postgres.StringColumn("recipient_user_name") + RecipientRaceNameColumn = postgres.StringColumn("recipient_race_name") + RecipientPreferredLanguageColumn = postgres.StringColumn("recipient_preferred_language") + AvailableAtColumn = postgres.TimestampzColumn("available_at") + TranslationAttemptsColumn = postgres.IntegerColumn("translation_attempts") + NextTranslationAttemptAtColumn = postgres.TimestampzColumn("next_translation_attempt_at") + DeliveredAtColumn = postgres.TimestampzColumn("delivered_at") + ReadAtColumn = postgres.TimestampzColumn("read_at") + DeletedAtColumn = postgres.TimestampzColumn("deleted_at") + NotifiedAtColumn = postgres.TimestampzColumn("notified_at") + allColumns = postgres.ColumnList{RecipientIDColumn, MessageIDColumn, GameIDColumn, UserIDColumn, RecipientUserNameColumn, RecipientRaceNameColumn, RecipientPreferredLanguageColumn, AvailableAtColumn, TranslationAttemptsColumn, NextTranslationAttemptAtColumn, DeliveredAtColumn, ReadAtColumn, DeletedAtColumn, NotifiedAtColumn} + mutableColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, UserIDColumn, RecipientUserNameColumn, RecipientRaceNameColumn, RecipientPreferredLanguageColumn, AvailableAtColumn, TranslationAttemptsColumn, NextTranslationAttemptAtColumn, DeliveredAtColumn, ReadAtColumn, DeletedAtColumn, NotifiedAtColumn} + defaultColumns = postgres.ColumnList{RecipientPreferredLanguageColumn, TranslationAttemptsColumn} + ) + + return diplomailRecipientsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + RecipientID: RecipientIDColumn, + MessageID: MessageIDColumn, + GameID: GameIDColumn, + UserID: UserIDColumn, + RecipientUserName: RecipientUserNameColumn, + RecipientRaceName: RecipientRaceNameColumn, + RecipientPreferredLanguage: RecipientPreferredLanguageColumn, + AvailableAt: AvailableAtColumn, + TranslationAttempts: TranslationAttemptsColumn, + NextTranslationAttemptAt: NextTranslationAttemptAtColumn, + DeliveredAt: DeliveredAtColumn, + ReadAt: ReadAtColumn, + DeletedAt: DeletedAtColumn, + NotifiedAt: NotifiedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/diplomail_translations.go b/backend/internal/postgres/jet/backend/table/diplomail_translations.go new file mode 100644 index 0000000..a9beea4 --- /dev/null +++ b/backend/internal/postgres/jet/backend/table/diplomail_translations.go @@ -0,0 +1,96 @@ +// +// Code generated by go-jet DO NOT EDIT. +// +// WARNING: Changes to this file may cause incorrect behavior +// and will be lost if the code is regenerated +// + +package table + +import ( + "github.com/go-jet/jet/v2/postgres" +) + +var DiplomailTranslations = newDiplomailTranslationsTable("backend", "diplomail_translations", "") + +type diplomailTranslationsTable struct { + postgres.Table + + // Columns + TranslationID postgres.ColumnString + MessageID postgres.ColumnString + TargetLang postgres.ColumnString + TranslatedSubject postgres.ColumnString + TranslatedBody postgres.ColumnString + Translator postgres.ColumnString + TranslatedAt postgres.ColumnTimestampz + + AllColumns postgres.ColumnList + MutableColumns postgres.ColumnList + DefaultColumns postgres.ColumnList +} + +type DiplomailTranslationsTable struct { + diplomailTranslationsTable + + EXCLUDED diplomailTranslationsTable +} + +// AS creates new DiplomailTranslationsTable with assigned alias +func (a DiplomailTranslationsTable) AS(alias string) *DiplomailTranslationsTable { + return newDiplomailTranslationsTable(a.SchemaName(), a.TableName(), alias) +} + +// Schema creates new DiplomailTranslationsTable with assigned schema name +func (a DiplomailTranslationsTable) FromSchema(schemaName string) *DiplomailTranslationsTable { + return newDiplomailTranslationsTable(schemaName, a.TableName(), a.Alias()) +} + +// WithPrefix creates new DiplomailTranslationsTable with assigned table prefix +func (a DiplomailTranslationsTable) WithPrefix(prefix string) *DiplomailTranslationsTable { + return newDiplomailTranslationsTable(a.SchemaName(), prefix+a.TableName(), a.TableName()) +} + +// WithSuffix creates new DiplomailTranslationsTable with assigned table suffix +func (a DiplomailTranslationsTable) WithSuffix(suffix string) *DiplomailTranslationsTable { + return newDiplomailTranslationsTable(a.SchemaName(), a.TableName()+suffix, a.TableName()) +} + +func newDiplomailTranslationsTable(schemaName, tableName, alias string) *DiplomailTranslationsTable { + return &DiplomailTranslationsTable{ + diplomailTranslationsTable: newDiplomailTranslationsTableImpl(schemaName, tableName, alias), + EXCLUDED: newDiplomailTranslationsTableImpl("", "excluded", ""), + } +} + +func newDiplomailTranslationsTableImpl(schemaName, tableName, alias string) diplomailTranslationsTable { + var ( + TranslationIDColumn = postgres.StringColumn("translation_id") + MessageIDColumn = postgres.StringColumn("message_id") + TargetLangColumn = postgres.StringColumn("target_lang") + TranslatedSubjectColumn = postgres.StringColumn("translated_subject") + TranslatedBodyColumn = postgres.StringColumn("translated_body") + TranslatorColumn = postgres.StringColumn("translator") + TranslatedAtColumn = postgres.TimestampzColumn("translated_at") + allColumns = postgres.ColumnList{TranslationIDColumn, MessageIDColumn, TargetLangColumn, TranslatedSubjectColumn, TranslatedBodyColumn, TranslatorColumn, TranslatedAtColumn} + mutableColumns = postgres.ColumnList{MessageIDColumn, TargetLangColumn, TranslatedSubjectColumn, TranslatedBodyColumn, TranslatorColumn, TranslatedAtColumn} + defaultColumns = postgres.ColumnList{TranslatedSubjectColumn, TranslatedAtColumn} + ) + + return diplomailTranslationsTable{ + Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), + + //Columns + TranslationID: TranslationIDColumn, + MessageID: MessageIDColumn, + TargetLang: TargetLangColumn, + TranslatedSubject: TranslatedSubjectColumn, + TranslatedBody: TranslatedBodyColumn, + Translator: TranslatorColumn, + TranslatedAt: TranslatedAtColumn, + + AllColumns: allColumns, + MutableColumns: mutableColumns, + DefaultColumns: defaultColumns, + } +} diff --git a/backend/internal/postgres/jet/backend/table/table_use_schema.go b/backend/internal/postgres/jet/backend/table/table_use_schema.go index cace8c3..ca6723a 100644 --- a/backend/internal/postgres/jet/backend/table/table_use_schema.go +++ b/backend/internal/postgres/jet/backend/table/table_use_schema.go @@ -16,6 +16,9 @@ func UseSchema(schema string) { AuthChallenges = AuthChallenges.FromSchema(schema) BlockedEmails = BlockedEmails.FromSchema(schema) DeviceSessions = DeviceSessions.FromSchema(schema) + DiplomailMessages = DiplomailMessages.FromSchema(schema) + DiplomailRecipients = DiplomailRecipients.FromSchema(schema) + DiplomailTranslations = DiplomailTranslations.FromSchema(schema) EngineVersions = EngineVersions.FromSchema(schema) EntitlementRecords = EntitlementRecords.FromSchema(schema) EntitlementSnapshots = EntitlementSnapshots.FromSchema(schema) diff --git a/backend/internal/postgres/migrations/00001_init.sql b/backend/internal/postgres/migrations/00001_init.sql index add16b9..c749749 100644 --- a/backend/internal/postgres/migrations/00001_init.sql +++ b/backend/internal/postgres/migrations/00001_init.sql @@ -606,7 +606,8 @@ CREATE TABLE notifications ( 'lobby.race_name.expired', 'runtime.image_pull_failed', 'runtime.container_start_failed', 'runtime.start_config_invalid', - 'game.turn.ready', 'game.paused' + 'game.turn.ready', 'game.paused', + 'diplomail.message.received' )) ); @@ -662,6 +663,114 @@ CREATE TABLE notification_malformed_intents ( CREATE INDEX notification_malformed_intents_listing_idx ON notification_malformed_intents (received_at DESC); +-- ===================================================================== +-- Diplomail domain +-- ===================================================================== + +-- diplomail_messages is the canonical record of every diplomatic-mail +-- send: one row per personal message, owner/admin send, broadcast, or +-- system notification. game_name is captured at insert time so the +-- bulk-purge / rename paths still render correctly. sender_username +-- carries either accounts.user_name (sender_kind='player') or +-- admin_accounts.username (sender_kind='admin'); system senders leave +-- it NULL. body and subject are plain UTF-8; length limits are enforced +-- in the service layer and may be tuned without a migration. +CREATE TABLE diplomail_messages ( + message_id uuid PRIMARY KEY, + game_id uuid NOT NULL REFERENCES games (game_id) ON DELETE CASCADE, + game_name text NOT NULL, + kind text NOT NULL, + sender_kind text NOT NULL, + sender_user_id uuid, + sender_username text, + sender_ip text NOT NULL DEFAULT '', + subject text NOT NULL DEFAULT '', + body text NOT NULL, + body_lang text NOT NULL DEFAULT 'und', + broadcast_scope text NOT NULL DEFAULT 'single', + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT diplomail_messages_kind_chk + CHECK (kind IN ('personal', 'admin')), + CONSTRAINT diplomail_messages_sender_kind_chk + CHECK (sender_kind IN ('player', 'admin', 'system')), + CONSTRAINT diplomail_messages_sender_identity_chk CHECK ( + (sender_kind = 'player' AND sender_user_id IS NOT NULL AND sender_username IS NOT NULL) OR + (sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR + (sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL) + ), + CONSTRAINT diplomail_messages_kind_sender_chk CHECK ( + (kind = 'personal' AND sender_kind = 'player') OR + (kind = 'admin' AND sender_kind IN ('player', 'admin', 'system')) + ), + CONSTRAINT diplomail_messages_broadcast_scope_chk + CHECK (broadcast_scope IN ('single', 'game_broadcast', 'multi_game_broadcast')) +); + +CREATE INDEX diplomail_messages_game_idx + ON diplomail_messages (game_id, created_at DESC); + +CREATE INDEX diplomail_messages_sender_user_idx + ON diplomail_messages (sender_user_id, created_at DESC) + WHERE sender_user_id IS NOT NULL; + +-- diplomail_recipients carries one row per (message, recipient). The +-- per-user read/delete/deliver/notified state lives here. recipient +-- snapshots (user_name, race_name) are captured at insert time so the +-- inbox listing and admin search render without joining accounts / +-- memberships and survive race-name renames, membership revocation, +-- and account soft-delete. recipient_race_name is nullable for the +-- rare admin notifications addressed to a player who no longer has an +-- active membership in the game. +CREATE TABLE diplomail_recipients ( + recipient_id uuid PRIMARY KEY, + message_id uuid NOT NULL REFERENCES diplomail_messages (message_id) ON DELETE CASCADE, + game_id uuid NOT NULL, + user_id uuid NOT NULL, + recipient_user_name text NOT NULL, + recipient_race_name text, + recipient_preferred_language text NOT NULL DEFAULT '', + available_at timestamptz, + translation_attempts integer NOT NULL DEFAULT 0, + next_translation_attempt_at timestamptz, + delivered_at timestamptz, + read_at timestamptz, + deleted_at timestamptz, + notified_at timestamptz, + CONSTRAINT diplomail_recipients_unique UNIQUE (message_id, user_id) +); + +CREATE INDEX diplomail_recipients_inbox_idx + ON diplomail_recipients (user_id, game_id, deleted_at, read_at); + +CREATE INDEX diplomail_recipients_unread_idx + ON diplomail_recipients (user_id, game_id) + WHERE read_at IS NULL AND deleted_at IS NULL AND available_at IS NOT NULL; + +-- Index drives the translation worker's pending-pair pickup. The +-- partial filter keeps the scan tight: terminal-state recipients +-- (with a non-NULL available_at) never appear in this btree. The +-- composite ordering puts the next-attempt clock first so the +-- backoff filter (`next_translation_attempt_at <= now()`) seeks +-- before the secondary cluster on (message_id, lang). +CREATE INDEX diplomail_recipients_pending_translation_idx + ON diplomail_recipients (next_translation_attempt_at, message_id, recipient_preferred_language) + WHERE available_at IS NULL; + +-- diplomail_translations caches one rendered translation per +-- (message, target_lang) so a broadcast addressed to many recipients +-- with the same preferred_language is translated once. translator +-- identifies the backend that produced the row. +CREATE TABLE diplomail_translations ( + translation_id uuid PRIMARY KEY, + message_id uuid NOT NULL REFERENCES diplomail_messages (message_id) ON DELETE CASCADE, + target_lang text NOT NULL, + translated_subject text NOT NULL DEFAULT '', + translated_body text NOT NULL, + translator text NOT NULL, + translated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT diplomail_translations_unique UNIQUE (message_id, target_lang) +); + -- ===================================================================== -- Geo domain -- ===================================================================== diff --git a/backend/internal/postgres/migrations_test.go b/backend/internal/postgres/migrations_test.go index 16dc549..a3940f8 100644 --- a/backend/internal/postgres/migrations_test.go +++ b/backend/internal/postgres/migrations_test.go @@ -68,6 +68,10 @@ var expectedBackendTables = []string{ "notification_malformed_intents", "notification_routes", "notifications", + // Diplomail domain. + "diplomail_messages", + "diplomail_recipients", + "diplomail_translations", // Geo domain. "user_country_counters", } diff --git a/backend/internal/server/contract_test.go b/backend/internal/server/contract_test.go index ba8e82b..17d775a 100644 --- a/backend/internal/server/contract_test.go +++ b/backend/internal/server/contract_test.go @@ -46,6 +46,7 @@ var pathParamStubs = map[string]string{ "user_id": "00000000-0000-0000-0000-000000000007", "device_session_id": "00000000-0000-0000-0000-000000000008", "battle_id": "00000000-0000-0000-0000-000000000009", + "message_id": "00000000-0000-0000-0000-00000000000a", "id": "1.2.3", "username": "alice", "turn": "42", @@ -149,6 +150,35 @@ var requestBodyStubs = map[string]map[string]any{ "user_id": pathParamStubs["user_id"], "reason": "ToS violation", }, + "userMailSendPersonal": { + "recipient_user_id": pathParamStubs["user_id"], + "subject": "Contract test subject", + "body": "Contract test body", + }, + "userMailSendAdmin": { + "target": "user", + "recipient_user_id": pathParamStubs["user_id"], + "subject": "Contract test admin subject", + "body": "Contract test admin body", + }, + "adminDiplomailSend": { + "target": "user", + "recipient_user_id": pathParamStubs["user_id"], + "subject": "Contract test admin subject", + "body": "Contract test admin body", + }, + "userMailSendBroadcast": { + "subject": "Contract test paid broadcast", + "body": "Contract test paid broadcast body", + }, + "adminDiplomailBroadcast": { + "scope": "all_running", + "subject": "Contract test multi-game broadcast", + "body": "Contract test multi-game broadcast body", + }, + "adminDiplomailCleanup": { + "older_than_years": 1, + }, } // TestOpenAPIContract is the top-level OpenAPI contract test. It diff --git a/backend/internal/server/handlers_admin_diplomail.go b/backend/internal/server/handlers_admin_diplomail.go new file mode 100644 index 0000000..0e1dd0a --- /dev/null +++ b/backend/internal/server/handlers_admin_diplomail.go @@ -0,0 +1,326 @@ +package server + +import ( + "net/http" + + "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/server/clientip" + "galaxy/backend/internal/server/handlers" + "galaxy/backend/internal/server/httperr" + "galaxy/backend/internal/server/middleware/basicauth" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// AdminDiplomailHandlers groups the diplomatic-mail handlers exposed +// under `/api/v1/admin/games/{game_id}/mail` (per-game admin send / +// broadcast). The handler is intentionally separate from +// `AdminMailHandlers`, which owns the unrelated email outbox surface +// under `/api/v1/admin/mail/*`. +type AdminDiplomailHandlers struct { + svc *diplomail.Service + logger *zap.Logger +} + +// NewAdminDiplomailHandlers constructs the handler set. svc may be +// nil — in that case every handler returns 501 not_implemented. +func NewAdminDiplomailHandlers(svc *diplomail.Service, logger *zap.Logger) *AdminDiplomailHandlers { + if logger == nil { + logger = zap.NewNop() + } + return &AdminDiplomailHandlers{svc: svc, logger: logger.Named("http.admin.diplomail")} +} + +// Send handles POST /api/v1/admin/games/{game_id}/mail. The body +// shape mirrors the owner route: `target="user"` requires +// `recipient_user_id`; `target="all"` accepts an optional +// `recipients` scope. The authenticated admin username is captured +// from the basicauth context and persisted as `sender_username`. +func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("adminDiplomailSend") + } + return func(c *gin.Context) { + username, ok := basicauth.UsernameFromContext(c.Request.Context()) + if !ok || username == "" { + httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + var req userMailSendAdminRequestWire + if err := c.ShouldBindJSON(&req); err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") + return + } + ctx := c.Request.Context() + switch req.Target { + case "", "user": + recipientID, parseErr := uuid.Parse(req.RecipientUserID) + if parseErr != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") + return + } + msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{ + GameID: gameID, + CallerKind: diplomail.CallerKindAdmin, + CallerUsername: username, + RecipientUserID: recipientID, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if sendErr != nil { + respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr) + return + } + c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true)) + case "all": + msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{ + GameID: gameID, + CallerKind: diplomail.CallerKindAdmin, + CallerUsername: username, + RecipientScope: req.Recipients, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if sendErr != nil { + respondDiplomailError(c, h.logger, "admin mail send broadcast", ctx, sendErr) + return + } + c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients)) + default: + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'") + } + } +} + +// Broadcast handles POST /api/v1/admin/mail/broadcast. Body: +// +// { +// "scope": "selected" | "all_running", +// "game_ids": ["..."], +// "recipients": "active" | "active_and_removed" | "all_members", +// "subject": "...", +// "body": "..." +// } +// +// The handler routes through SendAdminMultiGameBroadcast and returns +// a fan-out receipt describing the message ids created and the +// total recipient count. +func (h *AdminDiplomailHandlers) Broadcast() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("adminDiplomailBroadcast") + } + return func(c *gin.Context) { + username, ok := basicauth.UsernameFromContext(c.Request.Context()) + if !ok || username == "" { + httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required") + return + } + var req adminDiplomailBroadcastRequestWire + if err := c.ShouldBindJSON(&req); err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") + return + } + gameIDs := make([]uuid.UUID, 0, len(req.GameIDs)) + for _, raw := range req.GameIDs { + parsed, err := uuid.Parse(raw) + if err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_ids must be valid UUIDs") + return + } + gameIDs = append(gameIDs, parsed) + } + ctx := c.Request.Context() + msgs, total, err := h.svc.SendAdminMultiGameBroadcast(ctx, diplomail.SendMultiGameBroadcastInput{ + CallerUsername: username, + Scope: req.Scope, + GameIDs: gameIDs, + RecipientScope: req.Recipients, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if err != nil { + respondDiplomailError(c, h.logger, "admin mail broadcast", ctx, err) + return + } + out := adminDiplomailBroadcastResponseWire{ + RecipientCount: total, + Messages: make([]adminDiplomailBroadcastMessageWire, 0, len(msgs)), + } + for _, m := range msgs { + out.Messages = append(out.Messages, adminDiplomailBroadcastMessageWire{ + MessageID: m.MessageID.String(), + GameID: m.GameID.String(), + GameName: m.GameName, + }) + } + c.JSON(http.StatusCreated, out) + } +} + +// Cleanup handles POST /api/v1/admin/mail/cleanup. Body: +// +// { "older_than_years": 1 } +// +// The endpoint removes every diplomail_messages row whose game +// finished more than the supplied number of years ago. The cascade +// on the recipient and translation tables prunes the per-user state +// in the same transaction. Returns a CleanupResult envelope. +func (h *AdminDiplomailHandlers) Cleanup() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("adminDiplomailCleanup") + } + return func(c *gin.Context) { + username, ok := basicauth.UsernameFromContext(c.Request.Context()) + if !ok || username == "" { + httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required") + return + } + _ = username + var req adminDiplomailCleanupRequestWire + if err := c.ShouldBindJSON(&req); err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") + return + } + ctx := c.Request.Context() + result, err := h.svc.BulkCleanup(ctx, diplomail.BulkCleanupInput{OlderThanYears: req.OlderThanYears}) + if err != nil { + respondDiplomailError(c, h.logger, "admin mail cleanup", ctx, err) + return + } + out := adminDiplomailCleanupResponseWire{ + MessagesDeleted: result.MessagesDeleted, + GameIDs: make([]string, 0, len(result.GameIDs)), + } + for _, id := range result.GameIDs { + out.GameIDs = append(out.GameIDs, id.String()) + } + c.JSON(http.StatusOK, out) + } +} + +// List handles GET /api/v1/admin/mail/messages. Supports pagination +// via `page` and `page_size`, plus optional `game_id`, `kind`, and +// `sender_kind` filters. +func (h *AdminDiplomailHandlers) List() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("adminDiplomailList") + } + return func(c *gin.Context) { + username, ok := basicauth.UsernameFromContext(c.Request.Context()) + if !ok || username == "" { + httperr.Abort(c, http.StatusUnauthorized, httperr.CodeUnauthorized, "admin authentication is required") + return + } + filter := diplomail.AdminMessageListing{ + Page: parsePositiveQueryInt(c.Query("page"), 1), + PageSize: parsePositiveQueryInt(c.Query("page_size"), 50), + Kind: c.Query("kind"), + SenderKind: c.Query("sender_kind"), + } + if raw := c.Query("game_id"); raw != "" { + parsed, err := uuid.Parse(raw) + if err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "game_id must be a valid UUID") + return + } + filter.GameID = &parsed + } + ctx := c.Request.Context() + page, err := h.svc.ListMessagesForAdmin(ctx, filter) + if err != nil { + respondDiplomailError(c, h.logger, "admin mail list", ctx, err) + return + } + out := adminDiplomailListResponseWire{ + Total: page.Total, + Page: page.Page, + PageSize: page.PageSize, + Items: make([]adminDiplomailMessageWire, 0, len(page.Items)), + } + for _, m := range page.Items { + entry := adminDiplomailMessageWire{ + MessageID: m.MessageID.String(), + GameID: m.GameID.String(), + GameName: m.GameName, + Kind: m.Kind, + SenderKind: m.SenderKind, + SenderIP: m.SenderIP, + Subject: m.Subject, + Body: m.Body, + BodyLang: m.BodyLang, + BroadcastScope: m.BroadcastScope, + CreatedAt: m.CreatedAt.UTC().Format(timestampLayout), + } + if m.SenderUserID != nil { + s := m.SenderUserID.String() + entry.SenderUserID = &s + } + if m.SenderUsername != nil { + s := *m.SenderUsername + entry.SenderUsername = &s + } + out.Items = append(out.Items, entry) + } + c.JSON(http.StatusOK, out) + } +} + +type adminDiplomailBroadcastRequestWire struct { + Scope string `json:"scope"` + GameIDs []string `json:"game_ids,omitempty"` + Recipients string `json:"recipients,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` +} + +type adminDiplomailBroadcastMessageWire struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` +} + +type adminDiplomailBroadcastResponseWire struct { + RecipientCount int `json:"recipient_count"` + Messages []adminDiplomailBroadcastMessageWire `json:"messages"` +} + +type adminDiplomailCleanupRequestWire struct { + OlderThanYears int `json:"older_than_years"` +} + +type adminDiplomailCleanupResponseWire struct { + MessagesDeleted int `json:"messages_deleted"` + GameIDs []string `json:"game_ids"` +} + +type adminDiplomailMessageWire struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Kind string `json:"kind"` + SenderKind string `json:"sender_kind"` + SenderUserID *string `json:"sender_user_id,omitempty"` + SenderUsername *string `json:"sender_username,omitempty"` + SenderIP string `json:"sender_ip,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + BodyLang string `json:"body_lang"` + BroadcastScope string `json:"broadcast_scope"` + CreatedAt string `json:"created_at"` +} + +type adminDiplomailListResponseWire struct { + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Items []adminDiplomailMessageWire `json:"items"` +} diff --git a/backend/internal/server/handlers_user_mail.go b/backend/internal/server/handlers_user_mail.go new file mode 100644 index 0000000..53960b6 --- /dev/null +++ b/backend/internal/server/handlers_user_mail.go @@ -0,0 +1,663 @@ +package server + +import ( + "context" + "errors" + "net/http" + + "galaxy/backend/internal/diplomail" + "galaxy/backend/internal/lobby" + "galaxy/backend/internal/server/clientip" + "galaxy/backend/internal/server/handlers" + "galaxy/backend/internal/server/httperr" + "galaxy/backend/internal/server/middleware/userid" + "galaxy/backend/internal/telemetry" + "galaxy/backend/internal/user" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" +) + +// UserMailHandlers groups the diplomatic-mail handlers under +// `/api/v1/user/games/{game_id}/mail/*` and the lobby-side +// `/api/v1/user/lobby/mail/unread-counts`. Stage A wires the +// personal subset; Stage B adds the owner-only admin send path, +// which needs `*lobby.Service` to confirm ownership and `*user.Service` +// to resolve the owner's `user_name` for the `sender_username` column. +type UserMailHandlers struct { + svc *diplomail.Service + lobby *lobby.Service + users *user.Service + logger *zap.Logger +} + +// NewUserMailHandlers constructs the handler set. svc may be nil — in +// that case every handler returns 501 not_implemented. lobby and +// users are optional: when either is nil the admin-send handler +// degrades to 501 (the personal-send and read paths stay functional). +func NewUserMailHandlers(svc *diplomail.Service, lobbySvc *lobby.Service, users *user.Service, logger *zap.Logger) *UserMailHandlers { + if logger == nil { + logger = zap.NewNop() + } + return &UserMailHandlers{ + svc: svc, + lobby: lobbySvc, + users: users, + logger: logger.Named("http.user.mail"), + } +} + +// preferredLanguage looks up the caller's `accounts.preferred_language` +// so the per-message read can attach the cached translation when +// available. Failures are logged at debug level and the function +// returns an empty string — translation is best-effort and the +// caller still receives the original body. +func (h *UserMailHandlers) preferredLanguage(ctx context.Context, userID uuid.UUID) string { + if h.users == nil { + return "" + } + account, err := h.users.GetAccount(ctx, userID) + if err != nil { + h.logger.Debug("resolve preferred_language failed", + zap.String("user_id", userID.String()), + zap.Error(err)) + return "" + } + return account.PreferredLanguage +} + +// SendPersonal handles POST /api/v1/user/games/{game_id}/mail/messages. +func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailSendPersonal") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + var req userMailSendRequestWire + if err := c.ShouldBindJSON(&req); err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") + return + } + recipientID, err := uuid.Parse(req.RecipientUserID) + if err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") + return + } + ctx := c.Request.Context() + msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{ + GameID: gameID, + SenderUserID: userID, + RecipientUserID: recipientID, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if err != nil { + respondDiplomailError(c, h.logger, "user mail send personal", ctx, err) + return + } + c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true)) + } +} + +// Get handles GET /api/v1/user/games/{game_id}/mail/messages/{message_id}. +func (h *UserMailHandlers) Get() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailGet") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + if _, ok := parseGameIDParam(c); !ok { + return + } + messageID, ok := parseMessageIDParam(c) + if !ok { + return + } + ctx := c.Request.Context() + targetLang := h.preferredLanguage(ctx, userID) + entry, err := h.svc.GetMessage(ctx, userID, messageID, targetLang) + if err != nil { + respondDiplomailError(c, h.logger, "user mail get", ctx, err) + return + } + c.JSON(http.StatusOK, mailMessageDetailToWire(entry, false)) + } +} + +// Inbox handles GET /api/v1/user/games/{game_id}/mail/inbox. +func (h *UserMailHandlers) Inbox() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailInbox") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + ctx := c.Request.Context() + targetLang := h.preferredLanguage(ctx, userID) + items, err := h.svc.ListInbox(ctx, gameID, userID, targetLang) + if err != nil { + respondDiplomailError(c, h.logger, "user mail inbox", ctx, err) + return + } + out := userMailInboxListWire{Items: make([]userMailMessageDetailWire, 0, len(items))} + for _, e := range items { + out.Items = append(out.Items, mailMessageDetailToWire(e, false)) + } + c.JSON(http.StatusOK, out) + } +} + +// Sent handles GET /api/v1/user/games/{game_id}/mail/sent. +func (h *UserMailHandlers) Sent() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailSent") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + ctx := c.Request.Context() + items, err := h.svc.ListSent(ctx, gameID, userID) + if err != nil { + respondDiplomailError(c, h.logger, "user mail sent", ctx, err) + return + } + out := userMailSentListWire{Items: make([]userMailSentSummaryWire, 0, len(items))} + for _, m := range items { + out.Items = append(out.Items, mailMessageSummaryToWire(m)) + } + c.JSON(http.StatusOK, out) + } +} + +// MarkRead handles POST /api/v1/user/games/{game_id}/mail/messages/{message_id}/read. +func (h *UserMailHandlers) MarkRead() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailMarkRead") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + if _, ok := parseGameIDParam(c); !ok { + return + } + messageID, ok := parseMessageIDParam(c) + if !ok { + return + } + ctx := c.Request.Context() + rcpt, err := h.svc.MarkRead(ctx, userID, messageID) + if err != nil { + respondDiplomailError(c, h.logger, "user mail mark read", ctx, err) + return + } + c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt)) + } +} + +// Delete handles DELETE /api/v1/user/games/{game_id}/mail/messages/{message_id}. +func (h *UserMailHandlers) Delete() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailDelete") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + if _, ok := parseGameIDParam(c); !ok { + return + } + messageID, ok := parseMessageIDParam(c) + if !ok { + return + } + ctx := c.Request.Context() + rcpt, err := h.svc.DeleteMessage(ctx, userID, messageID) + if err != nil { + respondDiplomailError(c, h.logger, "user mail delete", ctx, err) + return + } + c.JSON(http.StatusOK, mailRecipientStateToWire(rcpt)) + } +} + +// SendBroadcast handles POST /api/v1/user/games/{game_id}/mail/broadcast. +// +// The endpoint is the paid-tier player broadcast: any player on a +// non-`free` entitlement tier may send one personal message that +// fans out to every other active member of the game. The result +// rows carry `kind="personal"`, `sender_kind="player"`, +// `broadcast_scope="game_broadcast"`. Free-tier callers see a 403. +func (h *UserMailHandlers) SendBroadcast() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailSendBroadcast") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + var req userMailSendBroadcastRequestWire + if err := c.ShouldBindJSON(&req); err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") + return + } + ctx := c.Request.Context() + msg, recipients, err := h.svc.SendPlayerBroadcast(ctx, diplomail.SendPlayerBroadcastInput{ + GameID: gameID, + SenderUserID: userID, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if err != nil { + respondDiplomailError(c, h.logger, "user mail send broadcast", ctx, err) + return + } + c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients)) + } +} + +// SendAdmin handles POST /api/v1/user/games/{game_id}/mail/admin. +// +// Owner-only: the caller must be the owner of the private game. The +// handler resolves the owner's `user_name` so the +// `sender_username` column carries a useful identity, then routes to +// SendAdminPersonal (for `target="user"`) or SendAdminBroadcast (for +// `target="all"`). Site administrators use the separate admin route +// in `handlers_admin_mail_send.go`. +func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc { + if h.svc == nil || h.lobby == nil || h.users == nil { + return handlers.NotImplemented("userMailSendAdmin") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + gameID, ok := parseGameIDParam(c) + if !ok { + return + } + var req userMailSendAdminRequestWire + if err := c.ShouldBindJSON(&req); err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON") + return + } + ctx := c.Request.Context() + + game, err := h.lobby.GetGame(ctx, gameID) + if err != nil { + respondLobbyError(c, h.logger, "user mail send admin: load game", ctx, err) + return + } + if game.OwnerUserID == nil || *game.OwnerUserID != userID { + httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, "caller is not the owner of this game") + return + } + account, err := h.users.GetAccount(ctx, userID) + if err != nil { + respondAccountError(c, h.logger, "user mail send admin: resolve user_name", ctx, err) + return + } + + switch req.Target { + case "", "user": + recipientID, parseErr := uuid.Parse(req.RecipientUserID) + if parseErr != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") + return + } + callerUserID := userID + msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{ + GameID: gameID, + CallerKind: diplomail.CallerKindOwner, + CallerUserID: &callerUserID, + CallerUsername: account.UserName, + RecipientUserID: recipientID, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if sendErr != nil { + respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr) + return + } + c.JSON(http.StatusCreated, mailMessageDetailToWire(diplomail.InboxEntry{Message: msg, Recipient: rcpt}, true)) + case "all": + callerUserID := userID + msg, recipients, sendErr := h.svc.SendAdminBroadcast(ctx, diplomail.SendAdminBroadcastInput{ + GameID: gameID, + CallerKind: diplomail.CallerKindOwner, + CallerUserID: &callerUserID, + CallerUsername: account.UserName, + RecipientScope: req.Recipients, + Subject: req.Subject, + Body: req.Body, + SenderIP: clientip.ExtractSourceIP(c), + }) + if sendErr != nil { + respondDiplomailError(c, h.logger, "user mail send admin broadcast", ctx, sendErr) + return + } + c.JSON(http.StatusCreated, mailBroadcastReceiptToWire(msg, recipients)) + default: + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "target must be 'user' or 'all'") + } + } +} + +// UnreadCounts handles GET /api/v1/user/lobby/mail/unread-counts. +func (h *UserMailHandlers) UnreadCounts() gin.HandlerFunc { + if h.svc == nil { + return handlers.NotImplemented("userMailUnreadCounts") + } + return func(c *gin.Context) { + userID, ok := userid.FromContext(c.Request.Context()) + if !ok { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "X-User-ID header is required") + return + } + ctx := c.Request.Context() + items, err := h.svc.UnreadCountsForUser(ctx, userID) + if err != nil { + respondDiplomailError(c, h.logger, "user mail unread counts", ctx, err) + return + } + out := userMailUnreadCountsResponseWire{Items: make([]userMailUnreadCountWire, 0, len(items))} + total := 0 + for _, u := range items { + out.Items = append(out.Items, userMailUnreadCountWire{ + GameID: u.GameID.String(), + GameName: u.GameName, + Unread: u.Unread, + }) + total += u.Unread + } + out.Total = total + c.JSON(http.StatusOK, out) + } +} + +// respondDiplomailError maps diplomail-package sentinels to the +// standard JSON error envelope. Unknown errors land on a 500. +func respondDiplomailError(c *gin.Context, logger *zap.Logger, op string, ctx context.Context, err error) { + switch { + case errors.Is(err, diplomail.ErrInvalidInput): + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, err.Error()) + case errors.Is(err, diplomail.ErrNotFound): + httperr.Abort(c, http.StatusNotFound, httperr.CodeNotFound, "resource was not found") + case errors.Is(err, diplomail.ErrForbidden): + httperr.Abort(c, http.StatusForbidden, httperr.CodeForbidden, err.Error()) + case errors.Is(err, diplomail.ErrConflict): + httperr.Abort(c, http.StatusConflict, httperr.CodeConflict, err.Error()) + default: + logger.Error(op+" failed", + append(telemetry.TraceFieldsFromContext(ctx), zap.Error(err))..., + ) + httperr.Abort(c, http.StatusInternalServerError, httperr.CodeInternalError, "service error") + } +} + +// parseMessageIDParam reads `message_id` from the path. Writes a 400 +// envelope on invalid input and returns false in that case. +func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) { + parsed, err := uuid.Parse(c.Param("message_id")) + if err != nil { + httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "message_id must be a valid UUID") + return uuid.Nil, false + } + return parsed, true +} + +// userMailSendRequestWire mirrors the request body for SendPersonal. +type userMailSendRequestWire struct { + RecipientUserID string `json:"recipient_user_id"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` +} + +// userMailSendBroadcastRequestWire mirrors the request body for the +// paid-tier player broadcast. There is no `target` discriminator — +// the recipient set is always "every other active member". +type userMailSendBroadcastRequestWire struct { + Subject string `json:"subject,omitempty"` + Body string `json:"body"` +} + +// userMailSendAdminRequestWire mirrors the request body for the +// owner-only admin send. `target="user"` requires +// `recipient_user_id`; `target="all"` accepts the optional +// `recipients` scope (default `active`). +type userMailSendAdminRequestWire struct { + Target string `json:"target"` + RecipientUserID string `json:"recipient_user_id,omitempty"` + Recipients string `json:"recipients,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` +} + +// userMailBroadcastReceiptWire is the response shape returned after a +// successful broadcast. It carries the canonical message metadata +// together with the count of materialised recipient rows so the +// caller (UI, admin tool) can confirm the fan-out happened. +type userMailBroadcastReceiptWire struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Kind string `json:"kind"` + SenderKind string `json:"sender_kind"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + BodyLang string `json:"body_lang"` + BroadcastScope string `json:"broadcast_scope"` + CreatedAt string `json:"created_at"` + RecipientCount int `json:"recipient_count"` +} + +func mailBroadcastReceiptToWire(m diplomail.Message, recipients []diplomail.Recipient) userMailBroadcastReceiptWire { + return userMailBroadcastReceiptWire{ + MessageID: m.MessageID.String(), + GameID: m.GameID.String(), + GameName: m.GameName, + Kind: m.Kind, + SenderKind: m.SenderKind, + Subject: m.Subject, + Body: m.Body, + BodyLang: m.BodyLang, + BroadcastScope: m.BroadcastScope, + CreatedAt: m.CreatedAt.UTC().Format(timestampLayout), + RecipientCount: len(recipients), + } +} + +// userMailMessageDetailWire mirrors the unified response shape for +// inbox listings and per-message reads. Sender identifiers are +// optional: system messages carry neither user id nor username. +// Translation fields are populated when a cached rendering exists +// for the caller's `preferred_language`; the UI renders +// `body_translated` and surfaces the original through a +// "show original" toggle. +type userMailMessageDetailWire struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Kind string `json:"kind"` + SenderKind string `json:"sender_kind"` + SenderUserID *string `json:"sender_user_id,omitempty"` + SenderUsername *string `json:"sender_username,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + BodyLang string `json:"body_lang"` + BroadcastScope string `json:"broadcast_scope"` + CreatedAt string `json:"created_at"` + RecipientUserID string `json:"recipient_user_id"` + RecipientUserName string `json:"recipient_user_name,omitempty"` + RecipientRaceName *string `json:"recipient_race_name,omitempty"` + ReadAt *string `json:"read_at,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` + TranslatedSubject *string `json:"translated_subject,omitempty"` + TranslatedBody *string `json:"translated_body,omitempty"` + TranslationLang *string `json:"translation_lang,omitempty"` + Translator *string `json:"translator,omitempty"` +} + +// userMailSentSummaryWire mirrors the response shape for the +// sender-side listing. Recipient state is intentionally omitted (one +// author may have N recipients per broadcast in later stages). +type userMailSentSummaryWire struct { + MessageID string `json:"message_id"` + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Kind string `json:"kind"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + BodyLang string `json:"body_lang"` + BroadcastScope string `json:"broadcast_scope"` + CreatedAt string `json:"created_at"` +} + +type userMailInboxListWire struct { + Items []userMailMessageDetailWire `json:"items"` +} + +type userMailSentListWire struct { + Items []userMailSentSummaryWire `json:"items"` +} + +type userMailUnreadCountWire struct { + GameID string `json:"game_id"` + GameName string `json:"game_name,omitempty"` + Unread int `json:"unread"` +} + +type userMailUnreadCountsResponseWire struct { + Total int `json:"total"` + Items []userMailUnreadCountWire `json:"items"` +} + +func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userMailMessageDetailWire { + out := userMailMessageDetailWire{ + MessageID: entry.MessageID.String(), + GameID: entry.GameID.String(), + GameName: entry.GameName, + Kind: entry.Kind, + SenderKind: entry.SenderKind, + Subject: entry.Subject, + Body: entry.Body, + BodyLang: entry.BodyLang, + BroadcastScope: entry.BroadcastScope, + CreatedAt: entry.CreatedAt.UTC().Format(timestampLayout), + RecipientUserID: entry.Recipient.UserID.String(), + RecipientUserName: entry.Recipient.RecipientUserName, + } + if entry.SenderUserID != nil { + s := entry.SenderUserID.String() + out.SenderUserID = &s + } + if entry.SenderUsername != nil { + s := *entry.SenderUsername + out.SenderUsername = &s + } + if entry.Recipient.RecipientRaceName != nil { + s := *entry.Recipient.RecipientRaceName + out.RecipientRaceName = &s + } + if entry.Recipient.ReadAt != nil { + s := entry.Recipient.ReadAt.UTC().Format(timestampLayout) + out.ReadAt = &s + } + if entry.Recipient.DeletedAt != nil { + s := entry.Recipient.DeletedAt.UTC().Format(timestampLayout) + out.DeletedAt = &s + } + if entry.Translation != nil { + tr := entry.Translation + subj := tr.TranslatedSubject + body := tr.TranslatedBody + lang := tr.TargetLang + engine := tr.Translator + out.TranslatedSubject = &subj + out.TranslatedBody = &body + out.TranslationLang = &lang + out.Translator = &engine + } + _ = justCreated + return out +} + +func mailMessageSummaryToWire(m diplomail.Message) userMailSentSummaryWire { + return userMailSentSummaryWire{ + MessageID: m.MessageID.String(), + GameID: m.GameID.String(), + GameName: m.GameName, + Kind: m.Kind, + Subject: m.Subject, + Body: m.Body, + BodyLang: m.BodyLang, + BroadcastScope: m.BroadcastScope, + CreatedAt: m.CreatedAt.UTC().Format(timestampLayout), + } +} + +// mailRecipientStateToWire renders the recipient row after a +// mark-read or soft-delete call. The caller only needs the per-user +// state, not the full message body again. +func mailRecipientStateToWire(r diplomail.Recipient) userMailRecipientStateWire { + out := userMailRecipientStateWire{ + MessageID: r.MessageID.String(), + } + if r.ReadAt != nil { + s := r.ReadAt.UTC().Format(timestampLayout) + out.ReadAt = &s + } + if r.DeletedAt != nil { + s := r.DeletedAt.UTC().Format(timestampLayout) + out.DeletedAt = &s + } + return out +} + +type userMailRecipientStateWire struct { + MessageID string `json:"message_id"` + ReadAt *string `json:"read_at,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` +} diff --git a/backend/internal/server/router.go b/backend/internal/server/router.go index 16dd2d0..bef1433 100644 --- a/backend/internal/server/router.go +++ b/backend/internal/server/router.go @@ -68,6 +68,7 @@ type RouterDependencies struct { UserLobbyMy *UserLobbyMyHandlers UserLobbyRaceNames *UserLobbyRaceNamesHandlers UserGames *UserGamesHandlers + UserMail *UserMailHandlers UserSessions *UserSessionsHandlers AdminAdminAccounts *AdminAdminAccountsHandlers AdminUsers *AdminUsersHandlers @@ -75,6 +76,7 @@ type RouterDependencies struct { AdminRuntimes *AdminRuntimesHandlers AdminEngineVersions *AdminEngineVersionsHandlers AdminMail *AdminMailHandlers + AdminDiplomail *AdminDiplomailHandlers AdminNotifications *AdminNotificationsHandlers AdminGeo *AdminGeoHandlers InternalSessions *InternalSessionsHandlers @@ -163,6 +165,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies { if deps.UserGames == nil { deps.UserGames = NewUserGamesHandlers(nil, nil, deps.Logger) } + if deps.UserMail == nil { + deps.UserMail = NewUserMailHandlers(nil, nil, nil, deps.Logger) + } if deps.UserSessions == nil { deps.UserSessions = NewUserSessionsHandlers(nil, deps.Logger) } @@ -184,6 +189,9 @@ func withDefaultHandlers(deps RouterDependencies) RouterDependencies { if deps.AdminMail == nil { deps.AdminMail = NewAdminMailHandlers(nil, deps.Logger) } + if deps.AdminDiplomail == nil { + deps.AdminDiplomail = NewAdminDiplomailHandlers(nil, deps.Logger) + } if deps.AdminNotifications == nil { deps.AdminNotifications = NewAdminNotificationsHandlers(nil, deps.Logger) } @@ -255,6 +263,9 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de my.GET("/invites", deps.UserLobbyMy.Invites()) my.GET("/race-names", deps.UserLobbyMy.RaceNames()) + lobbyMail := lobbyGroup.Group("/mail") + lobbyMail.GET("/unread-counts", deps.UserMail.UnreadCounts()) + raceNames := lobbyGroup.Group("/race-names") raceNames.POST("/register", deps.UserLobbyRaceNames.Register()) @@ -265,6 +276,16 @@ func registerUserRoutes(router *gin.Engine, instruments *metrics.Instruments, de userGames.GET("/:game_id/reports/:turn", deps.UserGames.Report()) userGames.GET("/:game_id/battles/:turn/:battle_id", deps.UserGames.Battle()) + userMail := userGames.Group("/:game_id/mail") + userMail.POST("/messages", deps.UserMail.SendPersonal()) + userMail.POST("/broadcast", deps.UserMail.SendBroadcast()) + userMail.POST("/admin", deps.UserMail.SendAdmin()) + userMail.GET("/messages/:message_id", deps.UserMail.Get()) + userMail.POST("/messages/:message_id/read", deps.UserMail.MarkRead()) + userMail.DELETE("/messages/:message_id", deps.UserMail.Delete()) + userMail.GET("/inbox", deps.UserMail.Inbox()) + userMail.GET("/sent", deps.UserMail.Sent()) + userSessions := group.Group("/sessions") userSessions.GET("", deps.UserSessions.List()) userSessions.POST("/revoke-all", deps.UserSessions.RevokeAll()) @@ -299,6 +320,7 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d games.POST("/:game_id/force-start", deps.AdminGames.ForceStart()) games.POST("/:game_id/force-stop", deps.AdminGames.ForceStop()) games.POST("/:game_id/ban-member", deps.AdminGames.BanMember()) + games.POST("/:game_id/mail", deps.AdminDiplomail.Send()) runtimes := group.Group("/runtimes") runtimes.GET("/:game_id", deps.AdminRuntimes.Get()) @@ -318,6 +340,9 @@ func registerAdminRoutes(router *gin.Engine, instruments *metrics.Instruments, d mail.GET("/deliveries/:delivery_id/attempts", deps.AdminMail.ListDeliveryAttempts()) mail.POST("/deliveries/:delivery_id/resend", deps.AdminMail.ResendDelivery()) mail.GET("/dead-letters", deps.AdminMail.ListDeadLetters()) + mail.GET("/messages", deps.AdminDiplomail.List()) + mail.POST("/broadcast", deps.AdminDiplomail.Broadcast()) + mail.POST("/cleanup", deps.AdminDiplomail.Cleanup()) notifications := group.Group("/notifications") notifications.GET("", deps.AdminNotifications.List()) diff --git a/backend/openapi.yaml b/backend/openapi.yaml index 6915d34..251fb6d 100644 --- a/backend/openapi.yaml +++ b/backend/openapi.yaml @@ -1144,6 +1144,295 @@ paths: $ref: "#/components/responses/NotImplementedError" "500": $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/messages: + post: + tags: [User] + operationId: userMailSendPersonal + summary: Send a personal diplomatic mail message + description: | + Sends a replyable personal message from the authenticated user + to another active member of the same game. Both sender and + recipient must be active members. Body is plain UTF-8 text + (no HTML processing on the server); `subject` is optional. + Body length is capped at `BACKEND_DIPLOMAIL_MAX_BODY_BYTES` + (default 4096) and subject length at + `BACKEND_DIPLOMAIL_MAX_SUBJECT_BYTES` (default 256). + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailSendRequest" + responses: + "201": + description: Personal message accepted and persisted. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailMessageDetail" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/broadcast: + post: + tags: [User] + operationId: userMailSendBroadcast + summary: Send a paid-tier personal broadcast to a game's active members + description: | + Paid-tier players (`entitlement.is_paid == true`) may send one + personal message that fans out to every other active member of + the game. Free-tier callers receive 403. The resulting rows + carry `kind="personal"`, `sender_kind="player"`, + `broadcast_scope="game_broadcast"`. Recipients reply through + the regular personal-send endpoint; the reply targets the + broadcaster only. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailSendBroadcastRequest" + responses: + "201": + description: Personal broadcast accepted; receipt carries the recipient count. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailBroadcastReceipt" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/admin: + post: + tags: [User] + operationId: userMailSendAdmin + summary: Send a non-replyable admin notification (owner only) + description: | + Owner-only: the caller must be the owner of the private game. + `target="user"` requires `recipient_user_id`; `target="all"` + accepts an optional `recipients` scope (`active` by default, + plus `active_and_removed` and `all_members`). The message + carries `kind="admin"` and is therefore non-replyable. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailSendAdminRequest" + responses: + "201": + description: Admin message persisted; broadcasts return a fan-out receipt. + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/UserMailMessageDetail" + - $ref: "#/components/schemas/UserMailBroadcastReceipt" + "400": + $ref: "#/components/responses/InvalidRequestError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/messages/{message_id}: + get: + tags: [User] + operationId: userMailGet + summary: Read one diplomatic mail message + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + - $ref: "#/components/parameters/MessageID" + responses: + "200": + description: Message addressed to the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailMessageDetail" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + delete: + tags: [User] + operationId: userMailDelete + summary: Soft-delete a previously-read message + description: | + Marks the caller's recipient row for the message as deleted. + The underlying message stays persisted (admin / system mail is + retained for the lifetime of the game). The recipient row must + have `read_at` set first; otherwise the call returns 409. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + - $ref: "#/components/parameters/MessageID" + responses: + "200": + description: Message soft-deleted for the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailRecipientState" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "409": + $ref: "#/components/responses/ConflictError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/messages/{message_id}/read: + post: + tags: [User] + operationId: userMailMarkRead + summary: Mark a diplomatic mail message as read + description: | + Idempotent. Sets `read_at` on the caller's recipient row when + it is still unread; a second call on an already-read row is a + no-op and the existing state is returned. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + - $ref: "#/components/parameters/MessageID" + responses: + "200": + description: Recipient state after the mark-read. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailRecipientState" + "400": + $ref: "#/components/responses/InvalidRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/inbox: + get: + tags: [User] + operationId: userMailInbox + summary: List the caller's inbox for a game + description: | + Returns every non-soft-deleted mail row addressed to the + caller in the given game, newest first. Includes the + per-recipient read state. Soft access: the caller may not be + an active member if every visible row carries + `kind="admin"`. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + responses: + "200": + description: Inbox entries for the caller in the given game. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailInboxList" + "400": + $ref: "#/components/responses/InvalidRequestError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/games/{game_id}/mail/sent: + get: + tags: [User] + operationId: userMailSent + summary: List the caller's sent personal messages in a game + description: | + Returns personal messages authored by the caller in the given + game, newest first. Admin / system messages are not listed + (they have no `sender_user_id`). + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + - $ref: "#/components/parameters/GameID" + responses: + "200": + description: Sent personal messages by the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailSentList" + "400": + $ref: "#/components/responses/InvalidRequestError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/user/lobby/mail/unread-counts: + get: + tags: [User] + operationId: userMailUnreadCounts + summary: Per-game and total unread mail counts for the caller + description: | + Drives the lobby badge: returns one entry per game the caller + has any unread mail in, plus the global total. The response + is empty (and `total == 0`) when there is nothing unread. + security: + - UserHeader: [] + parameters: + - $ref: "#/components/parameters/XUserID" + responses: + "200": + description: Per-game unread counts addressed to the caller. + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailUnreadCountsResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" /api/v1/user/sessions: get: tags: [User] @@ -1704,6 +1993,176 @@ paths: $ref: "#/components/responses/NotImplementedError" "500": $ref: "#/components/responses/InternalError" + /api/v1/admin/mail/broadcast: + post: + tags: [Admin] + operationId: adminDiplomailBroadcast + summary: Multi-game admin broadcast + description: | + Fans out one admin-kind broadcast across the games selected + by `scope`. `scope="selected"` requires `game_ids`; + `scope="all_running"` enumerates every game whose status is + non-terminal. Recipients are resolved per-game via the same + scope vocabulary as the per-game admin send. A recipient + appearing in multiple addressed games receives one + independently-deletable inbox entry per game. + security: + - AdminBasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminDiplomailBroadcastRequest" + responses: + "201": + description: Broadcast accepted; per-game message ids and total recipient count. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminDiplomailBroadcastResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/admin/mail/cleanup: + post: + tags: [Admin] + operationId: adminDiplomailCleanup + summary: Bulk-purge diplomail messages from old finished games + description: | + Removes every `diplomail_messages` row whose game finished + more than `older_than_years` years ago. Cascading FKs prune + the recipient and translation tables in the same transaction. + `older_than_years` must be >= 1. + security: + - AdminBasicAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AdminDiplomailCleanupRequest" + responses: + "200": + description: Cleanup result. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminDiplomailCleanupResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/admin/mail/messages: + get: + tags: [Admin] + operationId: adminDiplomailList + summary: Paginated admin view of diplomail messages + description: | + Returns the canonical message rows for admin observability. + Optional filters: `game_id`, `kind` (personal / admin), + `sender_kind` (player / admin / system). Pagination via + `page` and `page_size`. + security: + - AdminBasicAuth: [] + parameters: + - name: page + in: query + required: false + schema: + type: integer + minimum: 1 + - name: page_size + in: query + required: false + schema: + type: integer + minimum: 1 + - name: game_id + in: query + required: false + schema: + type: string + format: uuid + - name: kind + in: query + required: false + schema: + type: string + enum: [personal, admin] + - name: sender_kind + in: query + required: false + schema: + type: string + enum: [player, admin, system] + responses: + "200": + description: Paginated diplomail messages. + content: + application/json: + schema: + $ref: "#/components/schemas/AdminDiplomailListResponse" + "400": + $ref: "#/components/responses/InvalidRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" + /api/v1/admin/games/{game_id}/mail: + post: + tags: [Admin] + operationId: adminDiplomailSend + summary: Send a diplomatic-mail admin notification to one game + description: | + Site-admin send for the diplomatic-mail subsystem. Body shape + mirrors the owner-only `POST /api/v1/user/games/{game_id}/mail/admin` + endpoint. `target="user"` requires `recipient_user_id`; + `target="all"` accepts an optional `recipients` scope + (`active` / `active_and_removed` / `all_members`). The + authenticated admin username is persisted as `sender_username`. + security: + - AdminBasicAuth: [] + parameters: + - $ref: "#/components/parameters/GameID" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/UserMailSendAdminRequest" + responses: + "201": + description: Admin message persisted; broadcasts return a fan-out receipt. + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/UserMailMessageDetail" + - $ref: "#/components/schemas/UserMailBroadcastReceipt" + "400": + $ref: "#/components/responses/InvalidRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + "501": + $ref: "#/components/responses/NotImplementedError" + "500": + $ref: "#/components/responses/InternalError" /api/v1/admin/runtimes/{game_id}: get: tags: [Admin] @@ -2247,6 +2706,13 @@ components: schema: type: string format: uuid + MessageID: + name: message_id + in: path + required: true + schema: + type: string + format: uuid NotificationID: name: notification_id in: path @@ -3599,6 +4065,405 @@ components: type: array items: $ref: "#/components/schemas/DeviceSession" + UserMailSendRequest: + type: object + additionalProperties: false + required: [recipient_user_id, body] + properties: + recipient_user_id: + type: string + format: uuid + subject: + type: string + description: | + Optional subject. Empty string and missing field are + treated the same. + body: + type: string + description: Plain UTF-8 body. HTML is not parsed on the server. + UserMailSendAdminRequest: + type: object + additionalProperties: false + required: [target, body] + properties: + target: + type: string + enum: [user, all] + recipient_user_id: + type: string + format: uuid + description: | + Required when `target="user"`. Identifies the recipient + of the personal admin message; the recipient may be in + any membership status (admin notifications can reach + kicked players). + recipients: + type: string + enum: [active, active_and_removed, all_members] + description: | + Optional scope when `target="all"`. Defaults to `active`. + subject: + type: string + body: + type: string + UserMailBroadcastReceipt: + type: object + additionalProperties: false + required: + - message_id + - game_id + - kind + - sender_kind + - body + - body_lang + - broadcast_scope + - created_at + - recipient_count + properties: + message_id: + type: string + format: uuid + game_id: + type: string + format: uuid + game_name: + type: string + kind: + type: string + enum: [personal, admin] + sender_kind: + type: string + enum: [player, admin, system] + subject: + type: string + body: + type: string + body_lang: + type: string + broadcast_scope: + type: string + enum: [single, game_broadcast, multi_game_broadcast] + created_at: + type: string + format: date-time + recipient_count: + type: integer + minimum: 0 + UserMailSendBroadcastRequest: + type: object + additionalProperties: false + required: [body] + properties: + subject: + type: string + body: + type: string + AdminDiplomailBroadcastRequest: + type: object + additionalProperties: false + required: [scope, body] + properties: + scope: + type: string + enum: [selected, all_running] + game_ids: + type: array + items: + type: string + format: uuid + recipients: + type: string + enum: [active, active_and_removed, all_members] + subject: + type: string + body: + type: string + AdminDiplomailBroadcastResponse: + type: object + additionalProperties: false + required: [recipient_count, messages] + properties: + recipient_count: + type: integer + minimum: 0 + messages: + type: array + items: + type: object + additionalProperties: false + required: [message_id, game_id] + properties: + message_id: + type: string + format: uuid + game_id: + type: string + format: uuid + game_name: + type: string + AdminDiplomailCleanupRequest: + type: object + additionalProperties: false + required: [older_than_years] + properties: + older_than_years: + type: integer + minimum: 1 + AdminDiplomailCleanupResponse: + type: object + additionalProperties: false + required: [messages_deleted, game_ids] + properties: + messages_deleted: + type: integer + minimum: 0 + game_ids: + type: array + items: + type: string + format: uuid + AdminDiplomailMessage: + type: object + additionalProperties: false + required: + - message_id + - game_id + - kind + - sender_kind + - body + - body_lang + - broadcast_scope + - created_at + properties: + message_id: + type: string + format: uuid + game_id: + type: string + format: uuid + game_name: + type: string + kind: + type: string + enum: [personal, admin] + sender_kind: + type: string + enum: [player, admin, system] + sender_user_id: + type: string + format: uuid + nullable: true + sender_username: + type: string + nullable: true + sender_ip: + type: string + subject: + type: string + body: + type: string + body_lang: + type: string + broadcast_scope: + type: string + enum: [single, game_broadcast, multi_game_broadcast] + created_at: + type: string + format: date-time + AdminDiplomailListResponse: + type: object + additionalProperties: false + required: [total, page, page_size, items] + properties: + total: + type: integer + minimum: 0 + page: + type: integer + minimum: 1 + page_size: + type: integer + minimum: 1 + items: + type: array + items: + $ref: "#/components/schemas/AdminDiplomailMessage" + UserMailMessageDetail: + type: object + additionalProperties: false + required: + - message_id + - game_id + - kind + - sender_kind + - body + - body_lang + - broadcast_scope + - created_at + - recipient_user_id + properties: + message_id: + type: string + format: uuid + game_id: + type: string + format: uuid + game_name: + type: string + kind: + type: string + enum: [personal, admin] + sender_kind: + type: string + enum: [player, admin, system] + sender_user_id: + type: string + format: uuid + nullable: true + sender_username: + type: string + nullable: true + subject: + type: string + body: + type: string + body_lang: + type: string + description: BCP 47 tag. `und` until Stage D adds detection. + broadcast_scope: + type: string + enum: [single, game_broadcast, multi_game_broadcast] + created_at: + type: string + format: date-time + recipient_user_id: + type: string + format: uuid + recipient_user_name: + type: string + recipient_race_name: + type: string + nullable: true + read_at: + type: string + format: date-time + nullable: true + deleted_at: + type: string + format: date-time + nullable: true + translated_subject: + type: string + description: | + Subject rendered into the caller's preferred_language by + the translation cache. Absent when the caller's language + matches `body_lang` or the translator could not produce + a rendering. + translated_body: + type: string + description: | + Body rendered into the caller's preferred_language. Same + absence semantics as `translated_subject`. + translation_lang: + type: string + description: BCP 47 tag of the rendered translation. + translator: + type: string + description: Identifier of the translation engine that produced the cached row. + UserMailSentSummary: + type: object + additionalProperties: false + required: + - message_id + - game_id + - kind + - body + - body_lang + - broadcast_scope + - created_at + properties: + message_id: + type: string + format: uuid + game_id: + type: string + format: uuid + game_name: + type: string + kind: + type: string + enum: [personal, admin] + subject: + type: string + body: + type: string + body_lang: + type: string + broadcast_scope: + type: string + enum: [single, game_broadcast, multi_game_broadcast] + created_at: + type: string + format: date-time + UserMailInboxList: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/UserMailMessageDetail" + UserMailSentList: + type: object + additionalProperties: false + required: [items] + properties: + items: + type: array + items: + $ref: "#/components/schemas/UserMailSentSummary" + UserMailUnreadCount: + type: object + additionalProperties: false + required: [game_id, unread] + properties: + game_id: + type: string + format: uuid + game_name: + type: string + unread: + type: integer + minimum: 0 + UserMailUnreadCountsResponse: + type: object + additionalProperties: false + required: [total, items] + properties: + total: + type: integer + minimum: 0 + items: + type: array + items: + $ref: "#/components/schemas/UserMailUnreadCount" + UserMailRecipientState: + type: object + additionalProperties: false + required: [message_id] + properties: + message_id: + type: string + format: uuid + read_at: + type: string + format: date-time + nullable: true + deleted_at: + type: string + format: date-time + nullable: true responses: NotImplementedError: description: Endpoint is documented but not implemented yet. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6ed6df5..53665ae 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -192,10 +192,12 @@ because they cross domain boundaries: `race_name`) remain `text`. - Foreign keys are intra-domain only: `accounts → entitlement_*` / `sanction_*` / `limit_*`; `games → applications` / `invites` / - `memberships` (with `ON DELETE CASCADE`); `mail_payloads → - mail_deliveries → mail_recipients` / `mail_attempts` / - `mail_dead_letters`; `notifications → notification_routes` / - `notification_dead_letters`. Cross-domain references + `memberships` / `diplomail_messages` (each with + `ON DELETE CASCADE`); `mail_payloads → mail_deliveries → + mail_recipients` / `mail_attempts` / `mail_dead_letters`; + `notifications → notification_routes` / `notification_dead_letters`; + `diplomail_messages → diplomail_recipients` / + `diplomail_translations`. Cross-domain references (`memberships.user_id`, `games.owner_user_id`, etc.) are kept as opaque `uuid` columns because each domain runs its own cleanup through the in-process cascade described in [§7](#7-in-process-async-patterns). Adding a database @@ -456,12 +458,15 @@ committed; SMTP completion is asynchronous to the auth request. Notifications are an in-process pipeline. The closed catalog is defined in `backend/internal/notification/catalog.go` and currently -covers 13 kinds: 10 lobby kinds (invite received/revoked, application +covers 16 kinds: 10 lobby kinds (invite received/revoked, application submitted/approved/rejected, membership removed/blocked, race name -registered/pending/expired) and 3 admin-recipient runtime kinds -(image pull failed, container start failed, start config invalid). -Per-kind delivery channels (push, email, or both) and the admin-vs- -per-user recipient routing live in the same file. +registered/pending/expired), 3 admin-recipient runtime kinds (image +pull failed, container start failed, start config invalid), 2 game +lifecycle kinds (turn ready, game paused), and the +`diplomail.message.received` kind that fans diplomatic-mail send +events out to the recipient's push stream. Per-kind delivery channels +(push, email, or both) and the admin-vs-per-user recipient routing +live in the same file. For every intent, `notification.Submit` performs: @@ -490,6 +495,34 @@ Notification persistence is the auditable record of "we tried to tell this user about this thing"; clients still derive their actual game state through normal user-facing reads. +### 12.1 Diplomatic mail subsystem + +`backend/internal/diplomail` owns the player-to-player message channel +that the in-game mail view consumes. The data lives in three tables: + +- `diplomail_messages` — one canonical row per send. Captures the + game name and the sender IP at insert time so audit rendering + survives game renames and bulk purges. `kind` is `personal` (a + replyable player→player message) or `admin` (a non-replyable + notification produced by an administrator or the system). + `sender_kind` distinguishes `player`, `admin`, and `system` senders. + `broadcast_scope` carries `single`, `game_broadcast`, or + `multi_game_broadcast`. +- `diplomail_recipients` — one row per (message, recipient). Holds + the per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at` + state plus snapshot fields (`recipient_user_name`, + `recipient_race_name`) so admin search and the inbox listing render + correctly even after the source rows are renamed or revoked. +- `diplomail_translations` — cached per-language rendering shared + across every recipient with the same `accounts.preferred_language`. + +Stage A wires the personal subset (single recipient, no language +detection). Lifecycle hooks (paused / cancelled / kicked), paid-tier +player broadcasts, multi-game admin broadcasts, bulk purge, and the +detection / translation cache land in later stages. The package is +the only place that constructs `diplomail.message.received` push +intents; the notification pipeline takes it from there. + ## 13. Container Lifecycle (in-process) `backend/internal/runtime` owns the lifecycle of game-engine containers diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1a2eb36..054317b 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -47,6 +47,7 @@ same scenario when they participate in the same business flow. 8. [Notifications and mail](#8-notifications-and-mail) 9. [Geo signal](#9-geo-signal) 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: [ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox), [§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). diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 2a48ba0..77460dd 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -47,6 +47,7 @@ field-level-валидация — всё это лежит в нижнеуро 8. [Уведомления и почта](#8-уведомления-и-почта) 9. [Гео-сигнал](#9-гео-сигнал) 10. [Администрирование](#10-администрирование) +11. [Дипломатическая почта](#11-дипломатическая-почта) --- @@ -1193,3 +1194,220 @@ dead-letters и malformed notification-намерения. Они также м [ARCHITECTURE.md §11](ARCHITECTURE.md#11-mail-outbox), [§12](ARCHITECTURE.md#12-notification-pipeline) и [Раздел 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).