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: LangUndetermined, BroadcastScope: BroadcastScopeSingle, } raceName := recipient.RaceName var raceNamePtr *string if raceName != "" { raceNamePtr = &raceName } rcptInsert := RecipientInsert{ RecipientID: uuid.New(), MessageID: msgInsert.MessageID, GameID: in.GameID, UserID: in.RecipientUserID, RecipientUserName: recipient.UserName, RecipientRaceName: raceNamePtr, } 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)) } 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). func (s *Service) GetMessage(ctx context.Context, userID, messageID uuid.UUID) (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 } return entry, nil } // 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. func (s *Service) ListInbox(ctx context.Context, gameID, userID uuid.UUID) ([]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 } if allowed[KindPersonal] && allowed[KindAdmin] { return entries, nil } out := make([]InboxEntry, 0, len(entries)) for _, e := range entries { if allowed[e.Kind] { out = append(out, e) } } return out, nil } // 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 }