Phase 28 (Step 1): backend support for race-name mail send
Tests · Go / test (push) Successful in 1m56s
Tests · Integration / integration (pull_request) Successful in 1m47s
Tests · Go / test (pull_request) Successful in 2m2s

Phase 28's in-game mail UI groups personal threads by the other
party's race. To support that without an extra membership-listing
RPC, the diplomail subsystem now:

- accepts `recipient_race_name` on `POST /messages` and
  `POST /admin` (target=user) as an alternative to
  `recipient_user_id`; the service resolves it via the existing
  `Memberships.ListMembers(gameID, "active")` and rejects with
  `forbidden` when the matching member is no longer active;
- snapshots `diplomail_messages.sender_race_name` at send time for
  every player sender (admin / system rows stay NULL). The UI keys
  per-race threading on this column.

Schema, openapi, README, and a focused e2e test for the new path
(happy path + dual / missing / unknown / kicked errors) land in
this commit; the gateway + UI legs follow in subsequent commits on
this branch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 22:07:48 +02:00
parent 74c1e7ab24
commit 7b43ce5844
12 changed files with 372 additions and 87 deletions
@@ -60,19 +60,24 @@ func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
ctx := c.Request.Context()
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
var recipientID uuid.UUID
if req.RecipientUserID != "" {
parsed, parseErr := uuid.Parse(req.RecipientUserID)
if parseErr != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
return
}
recipientID = parsed
}
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
GameID: gameID,
CallerKind: diplomail.CallerKindAdmin,
CallerUsername: username,
RecipientUserID: recipientID,
Subject: req.Subject,
Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
GameID: gameID,
CallerKind: diplomail.CallerKindAdmin,
CallerUsername: username,
RecipientUserID: recipientID,
RecipientRaceName: req.RecipientRaceName,
Subject: req.Subject,
Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
})
if sendErr != nil {
respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr)
+52 -33
View File
@@ -87,19 +87,24 @@ func (h *UserMailHandlers) SendPersonal() gin.HandlerFunc {
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
var recipientID uuid.UUID
if req.RecipientUserID != "" {
parsed, perr := uuid.Parse(req.RecipientUserID)
if perr != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
return
}
recipientID = parsed
}
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),
GameID: gameID,
SenderUserID: userID,
RecipientUserID: recipientID,
RecipientRaceName: req.RecipientRaceName,
Subject: req.Subject,
Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
})
if err != nil {
respondDiplomailError(c, h.logger, "user mail send personal", ctx, err)
@@ -341,21 +346,26 @@ func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
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
var recipientID uuid.UUID
if req.RecipientUserID != "" {
parsed, parseErr := uuid.Parse(req.RecipientUserID)
if parseErr != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
return
}
recipientID = parsed
}
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),
GameID: gameID,
CallerKind: diplomail.CallerKindOwner,
CallerUserID: &callerUserID,
CallerUsername: account.UserName,
RecipientUserID: recipientID,
RecipientRaceName: req.RecipientRaceName,
Subject: req.Subject,
Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
})
if sendErr != nil {
respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr)
@@ -449,10 +459,13 @@ func parseMessageIDParam(c *gin.Context) (uuid.UUID, bool) {
}
// userMailSendRequestWire mirrors the request body for SendPersonal.
// Exactly one of `recipient_user_id` and `recipient_race_name` must
// be supplied; the service rejects ambiguous and empty inputs.
type userMailSendRequestWire struct {
RecipientUserID string `json:"recipient_user_id"`
Subject string `json:"subject,omitempty"`
Body string `json:"body"`
RecipientUserID string `json:"recipient_user_id,omitempty"`
RecipientRaceName string `json:"recipient_race_name,omitempty"`
Subject string `json:"subject,omitempty"`
Body string `json:"body"`
}
// userMailSendBroadcastRequestWire mirrors the request body for the
@@ -464,15 +477,16 @@ type userMailSendBroadcastRequestWire struct {
}
// 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`).
// owner-only admin send. `target="user"` requires exactly one of
// `recipient_user_id` and `recipient_race_name`; `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"`
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"`
}
// userMailBroadcastReceiptWire is the response shape returned after a
@@ -524,6 +538,7 @@ type userMailMessageDetailWire struct {
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"`
@@ -597,6 +612,10 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM
s := *entry.SenderUsername
out.SenderUsername = &s
}
if entry.SenderRaceName != nil {
s := *entry.SenderRaceName
out.SenderRaceName = &s
}
if entry.Recipient.RecipientRaceName != nil {
s := *entry.Recipient.RecipientRaceName
out.RecipientRaceName = &s