diplomail (Stage A): add in-game personal mail subsystem
Tests · Go / test (push) Successful in 1m44s
Tests · Integration / integration (pull_request) Successful in 1m44s
Tests · Go / test (pull_request) Successful in 2m45s

Phase 28 of ui/PLAN.md needs a persistent player-to-player mail
channel; the existing `mail` package is a transactional email
outbox and the `notification` catalog is one-way platform events.
Stage A lands the schema (diplomail_messages / _recipients /
_translations), a single-recipient personal send/read/delete
service path, a `diplomail.message.received` push kind plumbed
through the notification pipeline, and an unread-counts endpoint
that drives the lobby badge. Admin / system mail, lifecycle hooks,
paid-tier broadcast, multi-game broadcast, bulk purge and language
detection / translation cache come in stages B–D.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-15 18:28:55 +02:00
parent 77cb7c78b6
commit 535e27008f
28 changed files with 3069 additions and 12 deletions
@@ -0,0 +1,424 @@
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"`
}