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 }