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() }