From 4cb03736dec5980ae942116b1f93abe93000cf07 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Fri, 15 May 2026 22:32:50 +0200 Subject: [PATCH] Phase 28 (Step 3): gateway translators for user.games.mail.* Adds the gateway-side translation layer that maps the eight new ConnectRPC mail commands onto backend's `/api/v1/user/games/{game_id}/mail/*` REST endpoints. - `gateway/internal/backendclient/mail_commands.go` defines `ExecuteMailCommand` and one helper per command (inbox, sent, message.get, send, broadcast, admin, read, delete). Each helper decodes the FlatBuffers request envelope, issues the REST call via the existing `*RESTClient.do`, decodes the JSON body, and re-encodes a typed FlatBuffers response. Recipient identifiers travel through unchanged so the new `recipient_race_name` shortcut introduced in Step 1 reaches backend untouched. - `routes.go` exposes a `MailRoutes` constructor and a matching `mailCommandClient` implementing `downstream.Client`. - `cmd/gateway/main.go` registers the new routes alongside the existing user / lobby / game-engine routes. - `mail_commands_test.go` covers the inbox, send-by-race-name, and read-state paths end-to-end against an `httptest.Server`, asserting request shapes (path, body, X-User-ID) and the decoded FlatBuffers response. Co-Authored-By: Claude Opus 4.7 (1M context) --- gateway/cmd/gateway/main.go | 6 +- .../internal/backendclient/mail_commands.go | 567 ++++++++++++++++++ .../backendclient/mail_commands_test.go | 209 +++++++ gateway/internal/backendclient/routes.go | 31 + 4 files changed, 812 insertions(+), 1 deletion(-) create mode 100644 gateway/internal/backendclient/mail_commands.go create mode 100644 gateway/internal/backendclient/mail_commands_test.go diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index 8d41527..2c1095b 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -186,7 +186,8 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo userRoutes := backendclient.UserRoutes(backend.REST()) lobbyRoutes := backendclient.LobbyRoutes(backend.REST()) gameRoutes := backendclient.GameRoutes(backend.REST()) - allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes)+len(gameRoutes)) + mailRoutes := backendclient.MailRoutes(backend.REST()) + allRoutes := make(map[string]downstream.Client, len(userRoutes)+len(lobbyRoutes)+len(gameRoutes)+len(mailRoutes)) for k, v := range userRoutes { allRoutes[k] = v } @@ -196,6 +197,9 @@ func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, lo for k, v := range gameRoutes { allRoutes[k] = v } + for k, v := range mailRoutes { + allRoutes[k] = v + } cleanup := func() error { return closeRedisClient() diff --git a/gateway/internal/backendclient/mail_commands.go b/gateway/internal/backendclient/mail_commands.go new file mode 100644 index 0000000..a63e80a --- /dev/null +++ b/gateway/internal/backendclient/mail_commands.go @@ -0,0 +1,567 @@ +package backendclient + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "galaxy/gateway/internal/downstream" + diplomailmodel "galaxy/model/diplomail" + commonfbs "galaxy/schema/fbs/common" + fbs "galaxy/schema/fbs/diplomail" + + flatbuffers "github.com/google/flatbuffers/go" + "github.com/google/uuid" +) + +// ExecuteMailCommand routes one authenticated `user.games.mail.*` +// command into the matching `/api/v1/user/games/{game_id}/mail/...` +// backend REST endpoint. Each command decodes a FlatBuffers request +// payload, issues the REST call, decodes the JSON response, and +// re-encodes the result as a typed FlatBuffers envelope. +func (c *RESTClient) ExecuteMailCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) { + if c == nil || c.httpClient == nil { + return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: nil client") + } + if ctx == nil { + return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: nil context") + } + if err := ctx.Err(); err != nil { + return downstream.UnaryResult{}, err + } + if strings.TrimSpace(command.UserID) == "" { + return downstream.UnaryResult{}, errors.New("backendclient: execute mail command: user_id must not be empty") + } + + switch command.MessageType { + case diplomailmodel.MessageTypeUserGamesMailInbox: + return c.executeMailInbox(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailSent: + return c.executeMailSent(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailMessageGet: + return c.executeMailMessageGet(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailSend: + return c.executeMailSend(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailBroadcast: + return c.executeMailBroadcast(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailAdmin: + return c.executeMailAdmin(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailRead: + return c.executeMailRead(ctx, command.UserID, command.PayloadBytes) + case diplomailmodel.MessageTypeUserGamesMailDelete: + return c.executeMailDelete(ctx, command.UserID, command.PayloadBytes) + default: + return downstream.UnaryResult{}, fmt.Errorf("backendclient: execute mail command: unsupported message type %q", command.MessageType) + } +} + +// mailMessageJSON mirrors the backend's `UserMailMessageDetail` wire +// shape from `backend/openapi.yaml`. Pointer fields are nullable in +// the OpenAPI spec; the encoder treats empty strings as "absent". +type mailMessageJSON 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"` + SenderRaceName *string `json:"sender_race_name,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"` +} + +// mailRecipientStateJSON mirrors `UserMailRecipientState`. +type mailRecipientStateJSON struct { + MessageID string `json:"message_id"` + ReadAt *string `json:"read_at,omitempty"` + DeletedAt *string `json:"deleted_at,omitempty"` +} + +// mailBroadcastReceiptJSON mirrors `UserMailBroadcastReceipt`. +type mailBroadcastReceiptJSON 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"` +} + +type mailInboxJSON struct { + Items []mailMessageJSON `json:"items"` +} + +func (c *RESTClient) executeMailInbox(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.inbox: payload is empty") + } + flat := fbs.GetRootAsInboxRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + if gameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.inbox: game_id is missing") + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/inbox" + respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.inbox: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + var resp mailInboxJSON + if err := json.Unmarshal(respBody, &resp); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail inbox response: %w", err) + } + out := encodeMailMessageList(resp.Items, fbs.InboxResponseStart, fbs.InboxResponseAddItems, fbs.InboxResponseEnd, fbs.FinishInboxResponseBuffer) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: out}, nil +} + +func (c *RESTClient) executeMailSent(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.sent: payload is empty") + } + flat := fbs.GetRootAsSentRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + if gameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.sent: game_id is missing") + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/sent" + respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.sent: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + var resp mailInboxJSON + if err := json.Unmarshal(respBody, &resp); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail sent response: %w", err) + } + out := encodeMailMessageList(resp.Items, fbs.SentResponseStart, fbs.SentResponseAddItems, fbs.SentResponseEnd, fbs.FinishSentResponseBuffer) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: out}, nil +} + +func (c *RESTClient) executeMailMessageGet(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.message.get: payload is empty") + } + flat := fbs.GetRootAsMessageGetRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + messageID := readUUID(flat.MessageId(nil)) + if gameID == uuid.Nil || messageID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.message.get: game_id and message_id are required") + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String()) + respBody, status, err := c.do(ctx, http.MethodGet, target, userID, nil) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.message.get: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + var msg mailMessageJSON + if err := json.Unmarshal(respBody, &msg); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail message response: %w", err) + } + builder := flatbuffers.NewBuilder(512) + msgOff := encodeMailMessage(builder, &msg) + fbs.MessageGetResponseStart(builder) + fbs.MessageGetResponseAddMessage(builder, msgOff) + root := fbs.MessageGetResponseEnd(builder) + fbs.FinishMessageGetResponseBuffer(builder, root) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil +} + +func (c *RESTClient) executeMailSend(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.send: payload is empty") + } + flat := fbs.GetRootAsSendRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + if gameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.send: game_id is missing") + } + body := struct { + RecipientUserID string `json:"recipient_user_id,omitempty"` + RecipientRaceName string `json:"recipient_race_name,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + }{ + RecipientUserID: string(flat.RecipientUserId()), + RecipientRaceName: string(flat.RecipientRaceName()), + Subject: string(flat.Subject()), + Body: string(flat.Body()), + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages" + respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.send: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + var msg mailMessageJSON + if err := json.Unmarshal(respBody, &msg); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail send response: %w", err) + } + builder := flatbuffers.NewBuilder(512) + msgOff := encodeMailMessage(builder, &msg) + fbs.SendResponseStart(builder) + fbs.SendResponseAddMessage(builder, msgOff) + root := fbs.SendResponseEnd(builder) + fbs.FinishSendResponseBuffer(builder, root) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil +} + +func (c *RESTClient) executeMailBroadcast(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.broadcast: payload is empty") + } + flat := fbs.GetRootAsBroadcastRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + if gameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.broadcast: game_id is missing") + } + body := struct { + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + }{ + Subject: string(flat.Subject()), + Body: string(flat.Body()), + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/broadcast" + respBody, status, err := c.do(ctx, http.MethodPost, target, userID, body) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.broadcast: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + var receipt mailBroadcastReceiptJSON + if err := json.Unmarshal(respBody, &receipt); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail broadcast response: %w", err) + } + builder := flatbuffers.NewBuilder(256) + recOff := encodeMailBroadcastReceipt(builder, &receipt) + fbs.BroadcastResponseStart(builder) + fbs.BroadcastResponseAddReceipt(builder, recOff) + root := fbs.BroadcastResponseEnd(builder) + fbs.FinishBroadcastResponseBuffer(builder, root) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil +} + +func (c *RESTClient) executeMailAdmin(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.admin: payload is empty") + } + flat := fbs.GetRootAsAdminRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + if gameID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.admin: game_id is missing") + } + target := string(flat.Target()) + body := struct { + Target string `json:"target"` + RecipientUserID string `json:"recipient_user_id,omitempty"` + RecipientRaceName string `json:"recipient_race_name,omitempty"` + Recipients string `json:"recipients,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + }{ + Target: target, + RecipientUserID: string(flat.RecipientUserId()), + RecipientRaceName: string(flat.RecipientRaceName()), + Recipients: string(flat.Recipients()), + Subject: string(flat.Subject()), + Body: string(flat.Body()), + } + url := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/admin" + respBody, status, err := c.do(ctx, http.MethodPost, url, userID, body) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.admin: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + builder := flatbuffers.NewBuilder(512) + if target == "all" { + var receipt mailBroadcastReceiptJSON + if err := json.Unmarshal(respBody, &receipt); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail admin broadcast response: %w", err) + } + recOff := encodeMailBroadcastReceipt(builder, &receipt) + fbs.AdminResponseStart(builder) + fbs.AdminResponseAddReceipt(builder, recOff) + root := fbs.AdminResponseEnd(builder) + fbs.FinishAdminResponseBuffer(builder, root) + } else { + var msg mailMessageJSON + if err := json.Unmarshal(respBody, &msg); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail admin send response: %w", err) + } + msgOff := encodeMailMessage(builder, &msg) + fbs.AdminResponseStart(builder) + fbs.AdminResponseAddMessage(builder, msgOff) + root := fbs.AdminResponseEnd(builder) + fbs.FinishAdminResponseBuffer(builder, root) + } + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil +} + +func (c *RESTClient) executeMailRead(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.read: payload is empty") + } + flat := fbs.GetRootAsReadRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + messageID := readUUID(flat.MessageId(nil)) + if gameID == uuid.Nil || messageID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.read: game_id and message_id are required") + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String()) + "/read" + respBody, status, err := c.do(ctx, http.MethodPost, target, userID, struct{}{}) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.read: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + return encodeRecipientStateResponse(respBody, fbs.ReadResponseStart, fbs.ReadResponseAddState, fbs.ReadResponseEnd, fbs.FinishReadResponseBuffer) +} + +func (c *RESTClient) executeMailDelete(ctx context.Context, userID string, payload []byte) (downstream.UnaryResult, error) { + if len(payload) == 0 { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.delete: payload is empty") + } + flat := fbs.GetRootAsDeleteRequest(payload, 0) + gameID := readUUID(flat.GameId(nil)) + messageID := readUUID(flat.MessageId(nil)) + if gameID == uuid.Nil || messageID == uuid.Nil { + return downstream.UnaryResult{}, errors.New("execute user.games.mail.delete: game_id and message_id are required") + } + target := c.baseURL + "/api/v1/user/games/" + url.PathEscape(gameID.String()) + "/mail/messages/" + url.PathEscape(messageID.String()) + respBody, status, err := c.do(ctx, http.MethodDelete, target, userID, nil) + if err != nil { + return downstream.UnaryResult{}, fmt.Errorf("execute user.games.mail.delete: %w", err) + } + if status < 200 || status >= 300 { + return projectMailErrorResponse(status, respBody) + } + return encodeRecipientStateResponse(respBody, fbs.DeleteResponseStart, fbs.DeleteResponseAddState, fbs.DeleteResponseEnd, fbs.FinishDeleteResponseBuffer) +} + +// encodeRecipientStateResponse decodes the JSON recipient-state body +// and emits the corresponding FlatBuffers Read/Delete envelope. The +// caller supplies the trio of envelope start / add-state / end / finish +// functions so this helper covers both endpoints with the same shape. +func encodeRecipientStateResponse(respBody []byte, + startFn func(*flatbuffers.Builder), + addStateFn func(*flatbuffers.Builder, flatbuffers.UOffsetT), + endFn func(*flatbuffers.Builder) flatbuffers.UOffsetT, + finishFn func(*flatbuffers.Builder, flatbuffers.UOffsetT), +) (downstream.UnaryResult, error) { + var state mailRecipientStateJSON + if err := json.Unmarshal(respBody, &state); err != nil { + return downstream.UnaryResult{}, fmt.Errorf("decode mail recipient state: %w", err) + } + builder := flatbuffers.NewBuilder(128) + stateOff := encodeMailRecipientState(builder, &state) + startFn(builder) + addStateFn(builder, stateOff) + root := endFn(builder) + finishFn(builder, root) + return downstream.UnaryResult{ResultCode: userCommandResultCodeOK, PayloadBytes: builder.FinishedBytes()}, nil +} + +// encodeMailMessageList is a shared helper that encodes a slice of +// mailMessageJSON items into either an InboxResponse or a +// SentResponse FlatBuffers envelope. The two envelopes have the same +// shape (just a `items` vector of MailMessage) so the trio of +// constructor functions parameterises the helper. +func encodeMailMessageList(items []mailMessageJSON, + startFn func(*flatbuffers.Builder), + addItemsFn func(*flatbuffers.Builder, flatbuffers.UOffsetT), + endFn func(*flatbuffers.Builder) flatbuffers.UOffsetT, + finishFn func(*flatbuffers.Builder, flatbuffers.UOffsetT), +) []byte { + builder := flatbuffers.NewBuilder(1024) + offsets := make([]flatbuffers.UOffsetT, 0, len(items)) + for i := range items { + offsets = append(offsets, encodeMailMessage(builder, &items[i])) + } + // FlatBuffers vectors are built in reverse: prepend each offset. + builder.StartVector(4, len(offsets), 4) + for i := len(offsets) - 1; i >= 0; i-- { + builder.PrependUOffsetT(offsets[i]) + } + itemsVec := builder.EndVector(len(offsets)) + startFn(builder) + addItemsFn(builder, itemsVec) + root := endFn(builder) + finishFn(builder, root) + return builder.FinishedBytes() +} + +// encodeMailMessage builds a MailMessage table inside builder. Returns +// the offset of the finished table. Strings are interned through the +// builder; missing JSON fields (nil pointers, empty strings) yield +// empty FB strings which the readers treat as absent. +func encodeMailMessage(builder *flatbuffers.Builder, m *mailMessageJSON) flatbuffers.UOffsetT { + messageIDOff := builder.CreateString(m.MessageID) + gameIDOff := builder.CreateString(m.GameID) + gameNameOff := builder.CreateString(m.GameName) + kindOff := builder.CreateString(m.Kind) + senderKindOff := builder.CreateString(m.SenderKind) + senderUserIDOff := builder.CreateString(stringPtrValue(m.SenderUserID)) + senderUsernameOff := builder.CreateString(stringPtrValue(m.SenderUsername)) + senderRaceNameOff := builder.CreateString(stringPtrValue(m.SenderRaceName)) + subjectOff := builder.CreateString(m.Subject) + bodyOff := builder.CreateString(m.Body) + bodyLangOff := builder.CreateString(m.BodyLang) + broadcastScopeOff := builder.CreateString(m.BroadcastScope) + recipientUserIDOff := builder.CreateString(m.RecipientUserID) + recipientUserNameOff := builder.CreateString(m.RecipientUserName) + recipientRaceNameOff := builder.CreateString(stringPtrValue(m.RecipientRaceName)) + translatedSubjectOff := builder.CreateString(stringPtrValue(m.TranslatedSubject)) + translatedBodyOff := builder.CreateString(stringPtrValue(m.TranslatedBody)) + translationLangOff := builder.CreateString(stringPtrValue(m.TranslationLang)) + translatorOff := builder.CreateString(stringPtrValue(m.Translator)) + + fbs.MailMessageStart(builder) + fbs.MailMessageAddMessageId(builder, messageIDOff) + fbs.MailMessageAddGameId(builder, gameIDOff) + fbs.MailMessageAddGameName(builder, gameNameOff) + fbs.MailMessageAddKind(builder, kindOff) + fbs.MailMessageAddSenderKind(builder, senderKindOff) + fbs.MailMessageAddSenderUserId(builder, senderUserIDOff) + fbs.MailMessageAddSenderUsername(builder, senderUsernameOff) + fbs.MailMessageAddSenderRaceName(builder, senderRaceNameOff) + fbs.MailMessageAddSubject(builder, subjectOff) + fbs.MailMessageAddBody(builder, bodyOff) + fbs.MailMessageAddBodyLang(builder, bodyLangOff) + fbs.MailMessageAddBroadcastScope(builder, broadcastScopeOff) + fbs.MailMessageAddCreatedAtMs(builder, parseRFC3339Millis(m.CreatedAt)) + fbs.MailMessageAddRecipientUserId(builder, recipientUserIDOff) + fbs.MailMessageAddRecipientUserName(builder, recipientUserNameOff) + fbs.MailMessageAddRecipientRaceName(builder, recipientRaceNameOff) + fbs.MailMessageAddReadAtMs(builder, parseRFC3339Millis(stringPtrValue(m.ReadAt))) + fbs.MailMessageAddDeletedAtMs(builder, parseRFC3339Millis(stringPtrValue(m.DeletedAt))) + fbs.MailMessageAddTranslatedSubject(builder, translatedSubjectOff) + fbs.MailMessageAddTranslatedBody(builder, translatedBodyOff) + fbs.MailMessageAddTranslationLang(builder, translationLangOff) + fbs.MailMessageAddTranslator(builder, translatorOff) + return fbs.MailMessageEnd(builder) +} + +// encodeMailRecipientState builds a MailRecipientState table. +func encodeMailRecipientState(builder *flatbuffers.Builder, s *mailRecipientStateJSON) flatbuffers.UOffsetT { + messageIDOff := builder.CreateString(s.MessageID) + fbs.MailRecipientStateStart(builder) + fbs.MailRecipientStateAddMessageId(builder, messageIDOff) + fbs.MailRecipientStateAddReadAtMs(builder, parseRFC3339Millis(stringPtrValue(s.ReadAt))) + fbs.MailRecipientStateAddDeletedAtMs(builder, parseRFC3339Millis(stringPtrValue(s.DeletedAt))) + return fbs.MailRecipientStateEnd(builder) +} + +// encodeMailBroadcastReceipt builds a MailBroadcastReceipt table. +func encodeMailBroadcastReceipt(builder *flatbuffers.Builder, r *mailBroadcastReceiptJSON) flatbuffers.UOffsetT { + messageIDOff := builder.CreateString(r.MessageID) + gameIDOff := builder.CreateString(r.GameID) + gameNameOff := builder.CreateString(r.GameName) + kindOff := builder.CreateString(r.Kind) + senderKindOff := builder.CreateString(r.SenderKind) + subjectOff := builder.CreateString(r.Subject) + bodyOff := builder.CreateString(r.Body) + bodyLangOff := builder.CreateString(r.BodyLang) + broadcastScopeOff := builder.CreateString(r.BroadcastScope) + fbs.MailBroadcastReceiptStart(builder) + fbs.MailBroadcastReceiptAddMessageId(builder, messageIDOff) + fbs.MailBroadcastReceiptAddGameId(builder, gameIDOff) + fbs.MailBroadcastReceiptAddGameName(builder, gameNameOff) + fbs.MailBroadcastReceiptAddKind(builder, kindOff) + fbs.MailBroadcastReceiptAddSenderKind(builder, senderKindOff) + fbs.MailBroadcastReceiptAddSubject(builder, subjectOff) + fbs.MailBroadcastReceiptAddBody(builder, bodyOff) + fbs.MailBroadcastReceiptAddBodyLang(builder, bodyLangOff) + fbs.MailBroadcastReceiptAddBroadcastScope(builder, broadcastScopeOff) + fbs.MailBroadcastReceiptAddCreatedAtMs(builder, parseRFC3339Millis(r.CreatedAt)) + fbs.MailBroadcastReceiptAddRecipientCount(builder, int32(r.RecipientCount)) + return fbs.MailBroadcastReceiptEnd(builder) +} + +// projectMailErrorResponse maps a non-2xx response into a UnaryResult +// carrying the backend error envelope, reusing the shared user-mail +// error-projection. 503 is bubbled as ErrDownstreamUnavailable. +func projectMailErrorResponse(statusCode int, payload []byte) (downstream.UnaryResult, error) { + if statusCode == http.StatusServiceUnavailable { + return downstream.UnaryResult{}, downstream.ErrDownstreamUnavailable + } + if statusCode >= 400 && statusCode <= 599 { + return projectUserBackendError(statusCode, payload) + } + return downstream.UnaryResult{}, fmt.Errorf("unexpected HTTP status %d", statusCode) +} + +// readUUID converts the common.UUID struct (or its absence) into a +// google/uuid.UUID. Returns uuid.Nil when the input is nil. +func readUUID(u *commonfbs.UUID) uuid.UUID { + if u == nil { + return uuid.Nil + } + var out uuid.UUID + hi := u.Hi() + lo := u.Lo() + for i := 0; i < 8; i++ { + out[i] = byte(hi >> (56 - 8*i)) + out[i+8] = byte(lo >> (56 - 8*i)) + } + return out +} + +// stringPtrValue returns "" for nil and the dereferenced value +// otherwise. Used to flatten nullable JSON strings into the +// always-present FlatBuffers string slot. +func stringPtrValue(p *string) string { + if p == nil { + return "" + } + return *p +} + +// parseRFC3339Millis parses an RFC 3339 timestamp string (the format +// the backend mail handler emits) into Unix milliseconds. Returns 0 +// when the input is empty or unparseable, matching the "absent" +// convention for the *_at_ms wire fields. +func parseRFC3339Millis(s string) int64 { + if s == "" { + return 0 + } + t, err := time.Parse(time.RFC3339Nano, s) + if err != nil { + return 0 + } + return t.UnixMilli() +} diff --git a/gateway/internal/backendclient/mail_commands_test.go b/gateway/internal/backendclient/mail_commands_test.go new file mode 100644 index 0000000..7adfeb5 --- /dev/null +++ b/gateway/internal/backendclient/mail_commands_test.go @@ -0,0 +1,209 @@ +package backendclient_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "galaxy/gateway/internal/backendclient" + diplomailmodel "galaxy/model/diplomail" + commonfbs "galaxy/schema/fbs/common" + fbs "galaxy/schema/fbs/diplomail" + + flatbuffers "github.com/google/flatbuffers/go" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecuteMailInboxDecodesItems(t *testing.T) { + t.Parallel() + + gameID := uuid.MustParse("11111111-2222-3333-4444-555555555555") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodGet, r.Method) + require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/inbox", r.URL.Path) + require.Equal(t, "user-1", r.Header.Get(backendclient.HeaderUserID)) + writeJSON(t, w, http.StatusOK, map[string]any{ + "items": []map[string]any{ + { + "message_id": "00000000-0000-0000-0000-000000000001", + "game_id": gameID.String(), + "kind": "personal", + "sender_kind": "player", + "sender_user_id": "00000000-0000-0000-0000-000000000010", + "sender_username": "alice", + "sender_race_name": "AliceRace", + "subject": "hi", + "body": "hello there", + "body_lang": "en", + "broadcast_scope": "single", + "created_at": "2026-05-15T12:00:00Z", + "recipient_user_id": "00000000-0000-0000-0000-000000000020", + "recipient_user_name": "bob", + "recipient_race_name": "BobRace", + }, + }, + }) + })) + t.Cleanup(server.Close) + + client := newRESTClient(t, server) + payload := buildInboxRequest(gameID) + cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailInbox, payload) + result, err := client.ExecuteMailCommand(context.Background(), cmd) + require.NoError(t, err) + assert.Equal(t, "ok", result.ResultCode) + + resp := fbs.GetRootAsInboxResponse(result.PayloadBytes, 0) + require.Equal(t, 1, resp.ItemsLength()) + var item fbs.MailMessage + require.True(t, resp.Items(&item, 0)) + assert.Equal(t, "00000000-0000-0000-0000-000000000001", string(item.MessageId())) + assert.Equal(t, "AliceRace", string(item.SenderRaceName())) + assert.Equal(t, "BobRace", string(item.RecipientRaceName())) +} + +func TestExecuteMailSendForwardsRaceName(t *testing.T) { + t.Parallel() + + gameID := uuid.MustParse("22222222-3333-4444-5555-666666666666") + var captured struct { + Body string + RecipientUserID string + RecipientRaceName string + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/messages", r.URL.Path) + var req map[string]any + raw, err := io.ReadAll(r.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(raw, &req)) + if v, ok := req["body"].(string); ok { + captured.Body = v + } + if v, ok := req["recipient_user_id"].(string); ok { + captured.RecipientUserID = v + } + if v, ok := req["recipient_race_name"].(string); ok { + captured.RecipientRaceName = v + } + writeJSON(t, w, http.StatusCreated, map[string]any{ + "message_id": "00000000-0000-0000-0000-000000000099", + "game_id": gameID.String(), + "kind": "personal", + "sender_kind": "player", + "sender_user_id": "00000000-0000-0000-0000-000000000010", + "sender_race_name": "Senders", + "body": captured.Body, + "body_lang": "en", + "broadcast_scope": "single", + "created_at": "2026-05-15T12:00:00Z", + "recipient_user_id": "00000000-0000-0000-0000-000000000020", + "recipient_race_name": "Receivers", + }) + })) + t.Cleanup(server.Close) + + client := newRESTClient(t, server) + payload := buildSendRequestByRaceName(gameID, "Receivers", "let us talk") + cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailSend, payload) + result, err := client.ExecuteMailCommand(context.Background(), cmd) + require.NoError(t, err) + assert.Equal(t, "ok", result.ResultCode) + + resp := fbs.GetRootAsSendResponse(result.PayloadBytes, 0) + require.NotNil(t, resp.Message(nil)) + msg := resp.Message(nil) + assert.Equal(t, "let us talk", string(msg.Body())) + assert.Equal(t, "Senders", string(msg.SenderRaceName())) + assert.Equal(t, "Receivers", string(msg.RecipientRaceName())) + + assert.Empty(t, captured.RecipientUserID) + assert.Equal(t, "Receivers", captured.RecipientRaceName) + assert.Equal(t, "let us talk", captured.Body) +} + +func TestExecuteMailReadReturnsState(t *testing.T) { + t.Parallel() + + gameID := uuid.MustParse("33333333-4444-5555-6666-777777777777") + messageID := uuid.MustParse("00000000-0000-0000-0000-0000000000aa") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.Equal(t, "/api/v1/user/games/"+gameID.String()+"/mail/messages/"+messageID.String()+"/read", r.URL.Path) + writeJSON(t, w, http.StatusOK, map[string]any{ + "message_id": messageID.String(), + "read_at": "2026-05-15T12:34:56Z", + }) + })) + t.Cleanup(server.Close) + + client := newRESTClient(t, server) + payload := buildReadRequest(gameID, messageID) + cmd := newAuthCommand(t, diplomailmodel.MessageTypeUserGamesMailRead, payload) + result, err := client.ExecuteMailCommand(context.Background(), cmd) + require.NoError(t, err) + + resp := fbs.GetRootAsReadResponse(result.PayloadBytes, 0) + state := resp.State(nil) + require.NotNil(t, state) + assert.Equal(t, messageID.String(), string(state.MessageId())) + assert.NotZero(t, state.ReadAtMs()) +} + +// buildInboxRequest emits a FlatBuffers InboxRequest envelope with +// the supplied game_id. +func buildInboxRequest(gameID uuid.UUID) []byte { + builder := flatbuffers.NewBuilder(64) + hi, lo := uuidToHiLo(gameID) + fbs.InboxRequestStart(builder) + fbs.InboxRequestAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo)) + root := fbs.InboxRequestEnd(builder) + fbs.FinishInboxRequestBuffer(builder, root) + return builder.FinishedBytes() +} + +// buildSendRequestByRaceName emits a FlatBuffers SendRequest that +// addresses the recipient by race name rather than user_id. +func buildSendRequestByRaceName(gameID uuid.UUID, raceName, body string) []byte { + builder := flatbuffers.NewBuilder(128) + raceOff := builder.CreateString(raceName) + bodyOff := builder.CreateString(body) + hi, lo := uuidToHiLo(gameID) + fbs.SendRequestStart(builder) + fbs.SendRequestAddGameId(builder, commonfbs.CreateUUID(builder, hi, lo)) + fbs.SendRequestAddRecipientRaceName(builder, raceOff) + fbs.SendRequestAddBody(builder, bodyOff) + root := fbs.SendRequestEnd(builder) + fbs.FinishSendRequestBuffer(builder, root) + return builder.FinishedBytes() +} + +// buildReadRequest emits a FlatBuffers ReadRequest envelope. +func buildReadRequest(gameID, messageID uuid.UUID) []byte { + builder := flatbuffers.NewBuilder(64) + gameHi, gameLo := uuidToHiLo(gameID) + msgHi, msgLo := uuidToHiLo(messageID) + fbs.ReadRequestStart(builder) + fbs.ReadRequestAddGameId(builder, commonfbs.CreateUUID(builder, gameHi, gameLo)) + fbs.ReadRequestAddMessageId(builder, commonfbs.CreateUUID(builder, msgHi, msgLo)) + root := fbs.ReadRequestEnd(builder) + fbs.FinishReadRequestBuffer(builder, root) + return builder.FinishedBytes() +} + +// uuidToHiLo splits a 16-byte UUID into the two big-endian uint64 +// halves the common.UUID struct uses. +func uuidToHiLo(u uuid.UUID) (uint64, uint64) { + var hi, lo uint64 + for i := 0; i < 8; i++ { + hi = (hi << 8) | uint64(u[i]) + lo = (lo << 8) | uint64(u[i+8]) + } + return hi, lo +} diff --git a/gateway/internal/backendclient/routes.go b/gateway/internal/backendclient/routes.go index 7b4e37a..ecd9e1f 100644 --- a/gateway/internal/backendclient/routes.go +++ b/gateway/internal/backendclient/routes.go @@ -4,6 +4,7 @@ import ( "context" "galaxy/gateway/internal/downstream" + diplomailmodel "galaxy/model/diplomail" lobbymodel "galaxy/model/lobby" ordermodel "galaxy/model/order" reportmodel "galaxy/model/report" @@ -67,6 +68,27 @@ func GameRoutes(client *RESTClient) map[string]downstream.Client { } } +// MailRoutes returns the authenticated `user.games.mail.*` downstream +// routes served by backend's diplomail subsystem. When client is nil +// every route resolves to a dependency-unavailable client so the +// static router still recognises the message types. +func MailRoutes(client *RESTClient) map[string]downstream.Client { + target := downstream.Client(unavailableClient{}) + if client != nil { + target = mailCommandClient{rest: client} + } + return map[string]downstream.Client{ + diplomailmodel.MessageTypeUserGamesMailInbox: target, + diplomailmodel.MessageTypeUserGamesMailSent: target, + diplomailmodel.MessageTypeUserGamesMailMessageGet: target, + diplomailmodel.MessageTypeUserGamesMailSend: target, + diplomailmodel.MessageTypeUserGamesMailBroadcast: target, + diplomailmodel.MessageTypeUserGamesMailAdmin: target, + diplomailmodel.MessageTypeUserGamesMailRead: target, + diplomailmodel.MessageTypeUserGamesMailDelete: target, + } +} + type unavailableClient struct{} func (unavailableClient) ExecuteCommand(context.Context, downstream.AuthenticatedCommand) (downstream.UnaryResult, error) { @@ -97,9 +119,18 @@ func (c gameCommandClient) ExecuteCommand(ctx context.Context, command downstrea return c.rest.ExecuteGameCommand(ctx, command) } +type mailCommandClient struct { + rest *RESTClient +} + +func (c mailCommandClient) ExecuteCommand(ctx context.Context, command downstream.AuthenticatedCommand) (downstream.UnaryResult, error) { + return c.rest.ExecuteMailCommand(ctx, command) +} + var ( _ downstream.Client = unavailableClient{} _ downstream.Client = userCommandClient{} _ downstream.Client = lobbyCommandClient{} _ downstream.Client = gameCommandClient{} + _ downstream.Client = mailCommandClient{} )