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 } recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName) if err != nil { return Message{}, Recipient{}, err } recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, recipientID) 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(ctx, 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(ctx, 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 senderRace := sender.RaceName msgInsert := MessageInsert{ MessageID: uuid.New(), GameID: in.GameID, GameName: sender.GameName, Kind: KindPersonal, SenderKind: SenderKindPlayer, SenderUserID: &callerID, SenderUsername: &username, SenderRaceName: &senderRace, 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(ctx, 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(ctx, 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(ctx, 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_race_name resolved from // Memberships.GetActiveMembership // sender_kind='admin' → CallerKind admin; sender_user_id nil // sender_kind='system' → CallerKind system; sender_username nil func (s *Service) buildAdminMessageInsert(ctx context.Context, 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 // Owner race snapshot is best-effort: a private-game owner who // has an active membership in their own game contributes a // race name; an owner who is not a current member (or whose // membership is removed/blocked) leaves the field nil. The // CHECK constraint accepts both shapes for sender_kind='player'. if ownerMember, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, uid); err == nil { race := ownerMember.RaceName out.SenderRaceName = &race } else if !errors.Is(err, ErrNotFound) { return MessageInsert{}, fmt.Errorf("diplomail: load owner membership: %w", err) } 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) }