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"` }