package server import ( "context" "errors" "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/userid" "galaxy/backend/internal/telemetry" "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 only the // personal-message subset. type UserMailHandlers struct { svc *diplomail.Service logger *zap.Logger } // NewUserMailHandlers constructs the handler set. svc may be nil — in // that case every handler returns 501 not_implemented. func NewUserMailHandlers(svc *diplomail.Service, logger *zap.Logger) *UserMailHandlers { if logger == nil { logger = zap.NewNop() } return &UserMailHandlers{svc: svc, logger: logger.Named("http.user.mail")} } // 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() entry, err := h.svc.GetMessage(ctx, userID, messageID) 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() items, err := h.svc.ListInbox(ctx, gameID, userID) 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)) } } // 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"` } // 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. 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"` } // 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 } _ = 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"` }