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
+22 -1
View File
@@ -26,7 +26,10 @@ Three Postgres tables in the `backend` schema:
- `diplomail_messages` — one row per send (personal, admin, or - `diplomail_messages` — one row per send (personal, admin, or
system). Captures `game_name` and IP at insert time so audit system). Captures `game_name` and IP at insert time so audit
rendering survives renames and purges. rendering survives renames and purges. The `sender_race_name`
column snapshots the sender's race in the game at send time when
the sender is a player with an active membership; the in-game UI
keys per-race thread grouping on this column.
- `diplomail_recipients` — one row per (message, recipient). Holds - `diplomail_recipients` — one row per (message, recipient). Holds
per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at` per-user `read_at`, `deleted_at`, `delivered_at`, `notified_at`
state. Snapshot fields (`recipient_user_name`, state. Snapshot fields (`recipient_user_name`,
@@ -72,6 +75,24 @@ mail to every active member; `Service.changeMembershipStatus` /
`detector.LanguageDetector` (default: `whatlanggo`, body-only, `detector.LanguageDetector` (default: `whatlanggo`, body-only,
≥ 25 runes; shorter bodies stay `und`). ≥ 25 runes; shorter bodies stay `und`).
## Recipient selection
`POST /messages` and `POST /admin` (when `target="user"`) accept the
recipient identifier in one of two shapes:
- `recipient_user_id` (uuid) — explicit user lookup; the recipient
may be any active member of the game.
- `recipient_race_name` (string) — resolves to the active member
with this race name in the game. Race names are unique by lobby
invariant; lobby-removed and blocked members cannot be reached
through the race-name shortcut (they no longer appear in the
active scope). Exactly one of the two fields must be supplied;
supplying both, or neither, returns `invalid_request`.
The race-name path lets the in-game UI compose mail directly off
the engine's `report.races[]` view without an extra membership
round-trip.
## Translation ## Translation
Stage D adds a lazy translation cache. When a recipient reads a Stage D adds a lazy translation cache. When a recipient reads a
+27 -8
View File
@@ -29,7 +29,11 @@ func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInp
return Message{}, Recipient{}, err return Message{}, Recipient{}, err
} }
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, in.RecipientUserID) recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
if err != nil {
return Message{}, Recipient{}, err
}
recipient, err := s.deps.Memberships.GetMembershipAnyStatus(ctx, in.GameID, recipientID)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden) return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not a member of the game", ErrForbidden)
@@ -37,7 +41,7 @@ func (s *Service) SendAdminPersonal(ctx context.Context, in SendAdminPersonalInp
return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err) return Message{}, Recipient{}, fmt.Errorf("diplomail: load admin recipient: %w", err)
} }
msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername, msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle) recipient.GameID, recipient.GameName, subject, body, in.SenderIP, BroadcastScopeSingle)
if err != nil { if err != nil {
return Message{}, Recipient{}, err return Message{}, Recipient{}, err
@@ -84,7 +88,7 @@ func (s *Service) SendAdminBroadcast(ctx context.Context, in SendAdminBroadcastI
} }
gameName := members[0].GameName gameName := members[0].GameName
msgInsert, err := s.buildAdminMessageInsert(in.CallerKind, in.CallerUserID, in.CallerUsername, msgInsert, err := s.buildAdminMessageInsert(ctx, in.CallerKind, in.CallerUserID, in.CallerUsername,
in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast) in.GameID, gameName, subject, body, in.SenderIP, BroadcastScopeGameBroadcast)
if err != nil { if err != nil {
return Message{}, nil, err return Message{}, nil, err
@@ -147,6 +151,7 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas
} }
username := sender.UserName username := sender.UserName
senderRace := sender.RaceName
msgInsert := MessageInsert{ msgInsert := MessageInsert{
MessageID: uuid.New(), MessageID: uuid.New(),
GameID: in.GameID, GameID: in.GameID,
@@ -155,6 +160,7 @@ func (s *Service) SendPlayerBroadcast(ctx context.Context, in SendPlayerBroadcas
SenderKind: SenderKindPlayer, SenderKind: SenderKindPlayer,
SenderUserID: &callerID, SenderUserID: &callerID,
SenderUsername: &username, SenderUsername: &username,
SenderRaceName: &senderRace,
SenderIP: in.SenderIP, SenderIP: in.SenderIP,
Subject: subject, Subject: subject,
Body: body, Body: body,
@@ -217,7 +223,7 @@ func (s *Service) SendAdminMultiGameBroadcast(ctx context.Context, in SendMultiG
zap.String("scope", scope)) zap.String("scope", scope))
continue continue
} }
msgInsert, err := s.buildAdminMessageInsert(CallerKindAdmin, nil, in.CallerUsername, msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindAdmin, nil, in.CallerUsername,
game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast) game.GameID, game.GameName, subject, body, in.SenderIP, BroadcastScopeMultiGameBroadcast)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -356,7 +362,7 @@ func (s *Service) publishGameLifecycle(ctx context.Context, ev LifecycleEvent) e
gameName := members[0].GameName gameName := members[0].GameName
subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason) subject, body := renderGameLifecycle(ev.Kind, gameName, ev.Actor, ev.Reason)
msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "", msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast) ev.GameID, gameName, subject, body, "", BroadcastScopeGameBroadcast)
if err != nil { if err != nil {
return err return err
@@ -385,7 +391,7 @@ func (s *Service) publishMembershipLifecycle(ctx context.Context, ev LifecycleEv
} }
subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason) subject, body := renderMembershipLifecycle(ev.Kind, target.GameName, ev.Actor, ev.Reason)
msgInsert, err := s.buildAdminMessageInsert(CallerKindSystem, nil, "", msgInsert, err := s.buildAdminMessageInsert(ctx, CallerKindSystem, nil, "",
ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle) ev.GameID, target.GameName, subject, body, "", BroadcastScopeSingle)
if err != nil { if err != nil {
return err return err
@@ -417,10 +423,12 @@ func (s *Service) prepareContent(subject, body string) (string, string, error) {
// for every admin-kind send. The CHECK constraint maps sender // for every admin-kind send. The CHECK constraint maps sender
// shapes: // shapes:
// //
// sender_kind='player' → CallerKind owner; sender_user_id set // sender_kind='player' → CallerKind owner; sender_user_id set,
// sender_race_name resolved from
// Memberships.GetActiveMembership
// sender_kind='admin' → CallerKind admin; sender_user_id nil // sender_kind='admin' → CallerKind admin; sender_user_id nil
// sender_kind='system' → CallerKind system; sender_username nil // sender_kind='system' → CallerKind system; sender_username nil
func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.UUID, callerUsername string, func (s *Service) buildAdminMessageInsert(ctx context.Context, callerKind string, callerUserID *uuid.UUID, callerUsername string,
gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) { gameID uuid.UUID, gameName, subject, body, senderIP, scope string) (MessageInsert, error) {
out := MessageInsert{ out := MessageInsert{
MessageID: uuid.New(), MessageID: uuid.New(),
@@ -443,6 +451,17 @@ func (s *Service) buildAdminMessageInsert(callerKind string, callerUserID *uuid.
out.SenderKind = SenderKindPlayer out.SenderKind = SenderKindPlayer
out.SenderUserID = &uid out.SenderUserID = &uid
out.SenderUsername = &uname out.SenderUsername = &uname
// Owner race snapshot is best-effort: a private-game owner who
// has an active membership in their own game contributes a
// race name; an owner who is not a current member (or whose
// membership is removed/blocked) leaves the field nil. The
// CHECK constraint accepts both shapes for sender_kind='player'.
if ownerMember, err := s.deps.Memberships.GetActiveMembership(ctx, gameID, uid); err == nil {
race := ownerMember.RaceName
out.SenderRaceName = &race
} else if !errors.Is(err, ErrNotFound) {
return MessageInsert{}, fmt.Errorf("diplomail: load owner membership: %w", err)
}
case CallerKindAdmin: case CallerKindAdmin:
uname := callerUsername uname := callerUsername
out.SenderKind = SenderKindAdmin out.SenderKind = SenderKindAdmin
@@ -369,6 +369,114 @@ func TestDiplomailPersonalFlow(t *testing.T) {
} }
} }
// TestDiplomailPersonalByRaceName exercises the Phase 28 contract: the
// UI passes a recipient race name (read out of the game report); the
// service resolves it to the active member with that race name and
// snapshots the sender's race onto the message row. Error cases cover
// the validation rules baked into the wire schema.
func TestDiplomailPersonalByRaceName(t *testing.T) {
db := startPostgres(t)
ctx := context.Background()
gameID := uuid.New()
sender := uuid.New()
recipient := uuid.New()
kicked := uuid.New()
seedAccount(t, db, sender)
seedAccount(t, db, recipient)
seedAccount(t, db, kicked)
seedGame(t, db, gameID, "Race-Name Resolution Game")
lookup := &staticMembershipLookup{
rows: map[[2]uuid.UUID]diplomail.ActiveMembership{
{gameID, sender}: {
UserID: sender, GameID: gameID, GameName: "Race-Name Resolution Game",
UserName: "sender", RaceName: "Senders",
},
{gameID, recipient}: {
UserID: recipient, GameID: gameID, GameName: "Race-Name Resolution Game",
UserName: "recipient", RaceName: "Receivers",
},
},
inactive: map[[2]uuid.UUID]diplomail.MemberSnapshot{
{gameID, kicked}: {
UserID: kicked, GameID: gameID, GameName: "Race-Name Resolution Game",
UserName: "kicked", RaceName: "Departed", Status: "removed",
},
},
}
svc := diplomail.NewService(diplomail.Deps{
Store: diplomail.NewStore(db),
Memberships: lookup,
Notification: &recordingPublisher{},
Config: config.DiplomailConfig{
MaxBodyBytes: 4096,
MaxSubjectBytes: 256,
},
})
// Happy path: race name resolves and sender_race_name is snapshotted.
msg, rcpt, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID,
SenderUserID: sender,
RecipientRaceName: "Receivers",
Subject: "Trade",
Body: "Care to talk?",
SenderIP: "203.0.113.4",
})
if err != nil {
t.Fatalf("send by race name: %v", err)
}
if rcpt.UserID != recipient {
t.Fatalf("recipient = %s, want %s", rcpt.UserID, recipient)
}
if msg.SenderRaceName == nil || *msg.SenderRaceName != "Senders" {
t.Fatalf("sender_race_name = %v, want \"Senders\"", msg.SenderRaceName)
}
// Both identifiers supplied → invalid_request.
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID,
SenderUserID: sender,
RecipientUserID: recipient,
RecipientRaceName: "Receivers",
Body: "x",
}); !errors.Is(err, diplomail.ErrInvalidInput) {
t.Fatalf("dual identifier: %v, want ErrInvalidInput", err)
}
// Neither identifier supplied → invalid_request.
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID,
SenderUserID: sender,
Body: "x",
}); !errors.Is(err, diplomail.ErrInvalidInput) {
t.Fatalf("no identifier: %v, want ErrInvalidInput", err)
}
// Race name with no matching active member → invalid_request.
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID,
SenderUserID: sender,
RecipientRaceName: "Strangers",
Body: "x",
}); !errors.Is(err, diplomail.ErrInvalidInput) {
t.Fatalf("unknown race: %v, want ErrInvalidInput", err)
}
// Race name of a lobby-removed member → invalid_request (the
// active-only scope filters them out; the lookup never returns
// them).
if _, _, err := svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID,
SenderUserID: sender,
RecipientRaceName: "Departed",
Body: "x",
}); !errors.Is(err, diplomail.ErrInvalidInput) {
t.Fatalf("kicked race: %v, want ErrInvalidInput", err)
}
}
func TestDiplomailRejectsNonActiveSender(t *testing.T) { func TestDiplomailRejectsNonActiveSender(t *testing.T) {
db := startPostgres(t) db := startPostgres(t)
ctx := context.Background() ctx := context.Background()
+55 -6
View File
@@ -32,15 +32,20 @@ const previewMaxRunes = 120
// ErrForbidden; the inserted Message is never persisted in those // ErrForbidden; the inserted Message is never persisted in those
// cases. // cases.
func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) { func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Message, Recipient, error) {
if in.SenderUserID == in.RecipientUserID {
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
}
subject := strings.TrimRight(in.Subject, " \t") subject := strings.TrimRight(in.Subject, " \t")
body := strings.TrimRight(in.Body, " \t\n") body := strings.TrimRight(in.Body, " \t\n")
if err := s.validateContent(subject, body); err != nil { if err := s.validateContent(subject, body); err != nil {
return Message{}, Recipient{}, err return Message{}, Recipient{}, err
} }
recipientID, err := s.resolveActiveRecipient(ctx, in.GameID, in.RecipientUserID, in.RecipientRaceName)
if err != nil {
return Message{}, Recipient{}, err
}
if in.SenderUserID == recipientID {
return Message{}, Recipient{}, fmt.Errorf("%w: cannot send mail to yourself", ErrInvalidInput)
}
sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID) sender, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.SenderUserID)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
@@ -48,7 +53,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
} }
return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err) return Message{}, Recipient{}, fmt.Errorf("diplomail: load sender membership: %w", err)
} }
recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, in.RecipientUserID) recipient, err := s.deps.Memberships.GetActiveMembership(ctx, in.GameID, recipientID)
if err != nil { if err != nil {
if errors.Is(err, ErrNotFound) { if errors.Is(err, ErrNotFound) {
return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden) return Message{}, Recipient{}, fmt.Errorf("%w: recipient is not an active member of the game", ErrForbidden)
@@ -57,14 +62,17 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
} }
username := sender.UserName username := sender.UserName
senderRace := sender.RaceName
senderUserID := in.SenderUserID
msgInsert := MessageInsert{ msgInsert := MessageInsert{
MessageID: uuid.New(), MessageID: uuid.New(),
GameID: in.GameID, GameID: in.GameID,
GameName: sender.GameName, GameName: sender.GameName,
Kind: KindPersonal, Kind: KindPersonal,
SenderKind: SenderKindPlayer, SenderKind: SenderKindPlayer,
SenderUserID: &in.SenderUserID, SenderUserID: &senderUserID,
SenderUsername: &username, SenderUsername: &username,
SenderRaceName: &senderRace,
SenderIP: in.SenderIP, SenderIP: in.SenderIP,
Subject: subject, Subject: subject,
Body: body, Body: body,
@@ -75,7 +83,7 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
rcptInsert := buildRecipientInsert( rcptInsert := buildRecipientInsert(
msgInsert.MessageID, msgInsert.MessageID,
MemberSnapshot{ MemberSnapshot{
UserID: in.RecipientUserID, UserID: recipientID,
GameID: in.GameID, GameID: in.GameID,
GameName: recipient.GameName, GameName: recipient.GameName,
UserName: recipient.UserName, UserName: recipient.UserName,
@@ -101,6 +109,47 @@ func (s *Service) SendPersonal(ctx context.Context, in SendPersonalInput) (Messa
return msg, recipients[0], nil return msg, recipients[0], nil
} }
// resolveActiveRecipient turns a (user_id, race_name) pair into the
// canonical user id of an active member of gameID. Exactly one of the
// two inputs must be set; both-set or both-empty returns
// ErrInvalidInput. Race-name resolution is restricted to the active
// scope so lobby-removed and blocked members cannot be reached
// through the race-name shortcut. ErrInvalidInput is also returned
// when the race name matches zero members; ErrForbidden when the
// race name matches more than one active row (defence in depth — race
// names are unique within a game by lobby invariant).
func (s *Service) resolveActiveRecipient(ctx context.Context, gameID uuid.UUID, byUserID uuid.UUID, byRaceName string) (uuid.UUID, error) {
byRaceName = strings.TrimSpace(byRaceName)
hasUser := byUserID != uuid.Nil
hasRace := byRaceName != ""
switch {
case hasUser && hasRace:
return uuid.Nil, fmt.Errorf("%w: only one of recipient_user_id, recipient_race_name may be supplied", ErrInvalidInput)
case !hasUser && !hasRace:
return uuid.Nil, fmt.Errorf("%w: recipient_user_id or recipient_race_name must be supplied", ErrInvalidInput)
case hasUser:
return byUserID, nil
}
members, err := s.deps.Memberships.ListMembers(ctx, gameID, RecipientScopeActive)
if err != nil {
return uuid.Nil, fmt.Errorf("diplomail: list active members for race lookup: %w", err)
}
var found []MemberSnapshot
for _, m := range members {
if m.RaceName == byRaceName {
found = append(found, m)
}
}
switch len(found) {
case 0:
return uuid.Nil, fmt.Errorf("%w: no active member with race %q in this game", ErrInvalidInput, byRaceName)
case 1:
return found[0].UserID, nil
default:
return uuid.Nil, fmt.Errorf("%w: race %q matches multiple active members", ErrForbidden, byRaceName)
}
}
// GetMessage returns the InboxEntry for messageID addressed to // GetMessage returns the InboxEntry for messageID addressed to
// userID. ErrNotFound is returned when the caller is not a recipient // userID. ErrNotFound is returned when the caller is not a recipient
// of the message — handlers translate that to 404 so the existence // of the message — handlers translate that to 404 so the existence
+8 -2
View File
@@ -31,7 +31,7 @@ func messageColumns() postgres.ColumnList {
m := table.DiplomailMessages m := table.DiplomailMessages
return postgres.ColumnList{ return postgres.ColumnList{
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind, m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
m.SenderUserID, m.SenderUsername, m.SenderIP, m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP,
m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt, m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.CreatedAt,
} }
} }
@@ -59,6 +59,7 @@ type MessageInsert struct {
SenderKind string SenderKind string
SenderUserID *uuid.UUID SenderUserID *uuid.UUID
SenderUsername *string SenderUsername *string
SenderRaceName *string
SenderIP string SenderIP string
Subject string Subject string
Body string Body string
@@ -101,7 +102,7 @@ func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInse
m := table.DiplomailMessages m := table.DiplomailMessages
msgStmt := m.INSERT( msgStmt := m.INSERT(
m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind, m.MessageID, m.GameID, m.GameName, m.Kind, m.SenderKind,
m.SenderUserID, m.SenderUsername, m.SenderIP, m.SenderUserID, m.SenderUsername, m.SenderRaceName, m.SenderIP,
m.Subject, m.Body, m.BodyLang, m.BroadcastScope, m.Subject, m.Body, m.BodyLang, m.BroadcastScope,
).VALUES( ).VALUES(
msg.MessageID, msg.MessageID,
@@ -111,6 +112,7 @@ func (s *Store) InsertMessageWithRecipients(ctx context.Context, msg MessageInse
msg.SenderKind, msg.SenderKind,
uuidPtrArg(msg.SenderUserID), uuidPtrArg(msg.SenderUserID),
stringPtrArg(msg.SenderUsername), stringPtrArg(msg.SenderUsername),
stringPtrArg(msg.SenderRaceName),
msg.SenderIP, msg.SenderIP,
msg.Subject, msg.Subject,
msg.Body, msg.Body,
@@ -737,6 +739,10 @@ func messageFromModel(row model.DiplomailMessages) Message {
name := *row.SenderUsername name := *row.SenderUsername
out.SenderUsername = &name out.SenderUsername = &name
} }
if row.SenderRaceName != nil {
name := *row.SenderRaceName
out.SenderRaceName = &name
}
return out return out
} }
+31 -19
View File
@@ -23,6 +23,11 @@ type Message struct {
SenderKind string SenderKind string
SenderUserID *uuid.UUID SenderUserID *uuid.UUID
SenderUsername *string SenderUsername *string
// SenderRaceName carries the snapshot of the sender's race in the
// game at send time. Non-nil for sender_kind='player' rows, nil
// for admin and system. The in-game mail UI groups personal
// threads by this name (Phase 28).
SenderRaceName *string
SenderIP string SenderIP string
Subject string Subject string
Body string Body string
@@ -92,16 +97,19 @@ type Translation struct {
} }
// SendPersonalInput is the request payload for SendPersonal: the // SendPersonalInput is the request payload for SendPersonal: the
// caller sending a single-recipient personal message. Validation // caller sending a single-recipient personal message. Exactly one of
// (active membership, body length, etc.) is performed inside the // RecipientUserID and RecipientRaceName must be non-zero; the
// service. // service resolves a non-empty RecipientRaceName to the active
// member with that race in the game. Other validation (active
// membership, body length, etc.) is performed inside the service.
type SendPersonalInput struct { type SendPersonalInput struct {
GameID uuid.UUID GameID uuid.UUID
SenderUserID uuid.UUID SenderUserID uuid.UUID
RecipientUserID uuid.UUID RecipientUserID uuid.UUID
Subject string RecipientRaceName string
Body string Subject string
SenderIP string Body string
SenderIP string
} }
// CallerKind enumerates the privileged sender roles for admin-kind // CallerKind enumerates the privileged sender roles for admin-kind
@@ -116,17 +124,21 @@ const (
// SendAdminPersonalInput is the request payload for an owner / // SendAdminPersonalInput is the request payload for an owner /
// admin / system sending an admin-kind message to a single // admin / system sending an admin-kind message to a single
// recipient. Authorization (owner-vs-admin distinction) is enforced // recipient. Exactly one of RecipientUserID and RecipientRaceName
// by the HTTP layer; the service trusts the caller designation. // must be non-zero; the service resolves a non-empty
// RecipientRaceName to the active member with that race in the
// game. Authorization (owner-vs-admin distinction) is enforced by
// the HTTP layer; the service trusts the caller designation.
type SendAdminPersonalInput struct { type SendAdminPersonalInput struct {
GameID uuid.UUID GameID uuid.UUID
CallerKind string CallerKind string
CallerUserID *uuid.UUID CallerUserID *uuid.UUID
CallerUsername string CallerUsername string
RecipientUserID uuid.UUID RecipientUserID uuid.UUID
Subject string RecipientRaceName string
Body string Subject string
SenderIP string Body string
SenderIP string
} }
// SendAdminBroadcastInput is the request payload for an owner / // SendAdminBroadcastInput is the request payload for an owner /
@@ -20,6 +20,7 @@ type DiplomailMessages struct {
SenderKind string SenderKind string
SenderUserID *uuid.UUID SenderUserID *uuid.UUID
SenderUsername *string SenderUsername *string
SenderRaceName *string
SenderIP string SenderIP string
Subject string Subject string
Body string Body string
@@ -24,6 +24,7 @@ type diplomailMessagesTable struct {
SenderKind postgres.ColumnString SenderKind postgres.ColumnString
SenderUserID postgres.ColumnString SenderUserID postgres.ColumnString
SenderUsername postgres.ColumnString SenderUsername postgres.ColumnString
SenderRaceName postgres.ColumnString
SenderIP postgres.ColumnString SenderIP postgres.ColumnString
Subject postgres.ColumnString Subject postgres.ColumnString
Body postgres.ColumnString Body postgres.ColumnString
@@ -78,14 +79,15 @@ func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomai
SenderKindColumn = postgres.StringColumn("sender_kind") SenderKindColumn = postgres.StringColumn("sender_kind")
SenderUserIDColumn = postgres.StringColumn("sender_user_id") SenderUserIDColumn = postgres.StringColumn("sender_user_id")
SenderUsernameColumn = postgres.StringColumn("sender_username") SenderUsernameColumn = postgres.StringColumn("sender_username")
SenderRaceNameColumn = postgres.StringColumn("sender_race_name")
SenderIPColumn = postgres.StringColumn("sender_ip") SenderIPColumn = postgres.StringColumn("sender_ip")
SubjectColumn = postgres.StringColumn("subject") SubjectColumn = postgres.StringColumn("subject")
BodyColumn = postgres.StringColumn("body") BodyColumn = postgres.StringColumn("body")
BodyLangColumn = postgres.StringColumn("body_lang") BodyLangColumn = postgres.StringColumn("body_lang")
BroadcastScopeColumn = postgres.StringColumn("broadcast_scope") BroadcastScopeColumn = postgres.StringColumn("broadcast_scope")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} allColumns = postgres.ColumnList{MessageIDColumn, GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} mutableColumns = postgres.ColumnList{GameIDColumn, GameNameColumn, KindColumn, SenderKindColumn, SenderUserIDColumn, SenderUsernameColumn, SenderRaceNameColumn, SenderIPColumn, SubjectColumn, BodyColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn} defaultColumns = postgres.ColumnList{SenderIPColumn, SubjectColumn, BodyLangColumn, BroadcastScopeColumn, CreatedAtColumn}
) )
@@ -100,6 +102,7 @@ func newDiplomailMessagesTableImpl(schemaName, tableName, alias string) diplomai
SenderKind: SenderKindColumn, SenderKind: SenderKindColumn,
SenderUserID: SenderUserIDColumn, SenderUserID: SenderUserIDColumn,
SenderUsername: SenderUsernameColumn, SenderUsername: SenderUsernameColumn,
SenderRaceName: SenderRaceNameColumn,
SenderIP: SenderIPColumn, SenderIP: SenderIPColumn,
Subject: SubjectColumn, Subject: SubjectColumn,
Body: BodyColumn, Body: BodyColumn,
@@ -683,6 +683,11 @@ CREATE TABLE diplomail_messages (
sender_kind text NOT NULL, sender_kind text NOT NULL,
sender_user_id uuid, sender_user_id uuid,
sender_username text, sender_username text,
-- sender_race_name is the immutable snapshot of the sender's race
-- in this game, captured at insert time when sender_kind='player'.
-- Admin and system messages carry NULL. The Phase 28 mail UI keys
-- per-race threading on this column.
sender_race_name text,
sender_ip text NOT NULL DEFAULT '', sender_ip text NOT NULL DEFAULT '',
subject text NOT NULL DEFAULT '', subject text NOT NULL DEFAULT '',
body text NOT NULL, body text NOT NULL,
@@ -698,6 +703,13 @@ CREATE TABLE diplomail_messages (
(sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR (sender_kind = 'admin' AND sender_user_id IS NULL AND sender_username IS NOT NULL) OR
(sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL) (sender_kind = 'system' AND sender_user_id IS NULL AND sender_username IS NULL)
), ),
-- sender_race_name is only meaningful for player senders. Admin
-- and system rows never carry a race; player rows carry one when
-- the sender has an active membership at send time (a non-playing
-- private-game owner may legitimately have none).
CONSTRAINT diplomail_messages_sender_race_chk CHECK (
sender_kind = 'player' OR sender_race_name IS NULL
),
CONSTRAINT diplomail_messages_kind_sender_chk CHECK ( CONSTRAINT diplomail_messages_kind_sender_chk CHECK (
(kind = 'personal' AND sender_kind = 'player') OR (kind = 'personal' AND sender_kind = 'player') OR
(kind = 'admin' AND sender_kind IN ('player', 'admin', 'system')) (kind = 'admin' AND sender_kind IN ('player', 'admin', 'system'))
@@ -60,19 +60,24 @@ func (h *AdminDiplomailHandlers) Send() gin.HandlerFunc {
ctx := c.Request.Context() ctx := c.Request.Context()
switch req.Target { switch req.Target {
case "", "user": case "", "user":
recipientID, parseErr := uuid.Parse(req.RecipientUserID) var recipientID uuid.UUID
if parseErr != nil { if req.RecipientUserID != "" {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") parsed, parseErr := uuid.Parse(req.RecipientUserID)
return 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{ msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
GameID: gameID, GameID: gameID,
CallerKind: diplomail.CallerKindAdmin, CallerKind: diplomail.CallerKindAdmin,
CallerUsername: username, CallerUsername: username,
RecipientUserID: recipientID, RecipientUserID: recipientID,
Subject: req.Subject, RecipientRaceName: req.RecipientRaceName,
Body: req.Body, Subject: req.Subject,
SenderIP: clientip.ExtractSourceIP(c), Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
}) })
if sendErr != nil { if sendErr != nil {
respondDiplomailError(c, h.logger, "admin mail send personal", ctx, sendErr) 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") httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "request body must be valid JSON")
return return
} }
recipientID, err := uuid.Parse(req.RecipientUserID) var recipientID uuid.UUID
if err != nil { if req.RecipientUserID != "" {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") parsed, perr := uuid.Parse(req.RecipientUserID)
return 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() ctx := c.Request.Context()
msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{ msg, rcpt, err := h.svc.SendPersonal(ctx, diplomail.SendPersonalInput{
GameID: gameID, GameID: gameID,
SenderUserID: userID, SenderUserID: userID,
RecipientUserID: recipientID, RecipientUserID: recipientID,
Subject: req.Subject, RecipientRaceName: req.RecipientRaceName,
Body: req.Body, Subject: req.Subject,
SenderIP: clientip.ExtractSourceIP(c), Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
}) })
if err != nil { if err != nil {
respondDiplomailError(c, h.logger, "user mail send personal", ctx, err) respondDiplomailError(c, h.logger, "user mail send personal", ctx, err)
@@ -341,21 +346,26 @@ func (h *UserMailHandlers) SendAdmin() gin.HandlerFunc {
switch req.Target { switch req.Target {
case "", "user": case "", "user":
recipientID, parseErr := uuid.Parse(req.RecipientUserID) var recipientID uuid.UUID
if parseErr != nil { if req.RecipientUserID != "" {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID") parsed, parseErr := uuid.Parse(req.RecipientUserID)
return if parseErr != nil {
httperr.Abort(c, http.StatusBadRequest, httperr.CodeInvalidRequest, "recipient_user_id must be a valid UUID")
return
}
recipientID = parsed
} }
callerUserID := userID callerUserID := userID
msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{ msg, rcpt, sendErr := h.svc.SendAdminPersonal(ctx, diplomail.SendAdminPersonalInput{
GameID: gameID, GameID: gameID,
CallerKind: diplomail.CallerKindOwner, CallerKind: diplomail.CallerKindOwner,
CallerUserID: &callerUserID, CallerUserID: &callerUserID,
CallerUsername: account.UserName, CallerUsername: account.UserName,
RecipientUserID: recipientID, RecipientUserID: recipientID,
Subject: req.Subject, RecipientRaceName: req.RecipientRaceName,
Body: req.Body, Subject: req.Subject,
SenderIP: clientip.ExtractSourceIP(c), Body: req.Body,
SenderIP: clientip.ExtractSourceIP(c),
}) })
if sendErr != nil { if sendErr != nil {
respondDiplomailError(c, h.logger, "user mail send admin personal", ctx, sendErr) 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. // 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 { type userMailSendRequestWire struct {
RecipientUserID string `json:"recipient_user_id"` RecipientUserID string `json:"recipient_user_id,omitempty"`
Subject string `json:"subject,omitempty"` RecipientRaceName string `json:"recipient_race_name,omitempty"`
Body string `json:"body"` Subject string `json:"subject,omitempty"`
Body string `json:"body"`
} }
// userMailSendBroadcastRequestWire mirrors the request body for the // userMailSendBroadcastRequestWire mirrors the request body for the
@@ -464,15 +477,16 @@ type userMailSendBroadcastRequestWire struct {
} }
// userMailSendAdminRequestWire mirrors the request body for the // userMailSendAdminRequestWire mirrors the request body for the
// owner-only admin send. `target="user"` requires // owner-only admin send. `target="user"` requires exactly one of
// `recipient_user_id`; `target="all"` accepts the optional // `recipient_user_id` and `recipient_race_name`; `target="all"`
// `recipients` scope (default `active`). // accepts the optional `recipients` scope (default `active`).
type userMailSendAdminRequestWire struct { type userMailSendAdminRequestWire struct {
Target string `json:"target"` Target string `json:"target"`
RecipientUserID string `json:"recipient_user_id,omitempty"` RecipientUserID string `json:"recipient_user_id,omitempty"`
Recipients string `json:"recipients,omitempty"` RecipientRaceName string `json:"recipient_race_name,omitempty"`
Subject string `json:"subject,omitempty"` Recipients string `json:"recipients,omitempty"`
Body string `json:"body"` Subject string `json:"subject,omitempty"`
Body string `json:"body"`
} }
// userMailBroadcastReceiptWire is the response shape returned after a // userMailBroadcastReceiptWire is the response shape returned after a
@@ -524,6 +538,7 @@ type userMailMessageDetailWire struct {
SenderKind string `json:"sender_kind"` SenderKind string `json:"sender_kind"`
SenderUserID *string `json:"sender_user_id,omitempty"` SenderUserID *string `json:"sender_user_id,omitempty"`
SenderUsername *string `json:"sender_username,omitempty"` SenderUsername *string `json:"sender_username,omitempty"`
SenderRaceName *string `json:"sender_race_name,omitempty"`
Subject string `json:"subject,omitempty"` Subject string `json:"subject,omitempty"`
Body string `json:"body"` Body string `json:"body"`
BodyLang string `json:"body_lang"` BodyLang string `json:"body_lang"`
@@ -597,6 +612,10 @@ func mailMessageDetailToWire(entry diplomail.InboxEntry, justCreated bool) userM
s := *entry.SenderUsername s := *entry.SenderUsername
out.SenderUsername = &s out.SenderUsername = &s
} }
if entry.SenderRaceName != nil {
s := *entry.SenderRaceName
out.SenderRaceName = &s
}
if entry.Recipient.RecipientRaceName != nil { if entry.Recipient.RecipientRaceName != nil {
s := *entry.Recipient.RecipientRaceName s := *entry.Recipient.RecipientRaceName
out.RecipientRaceName = &s out.RecipientRaceName = &s
+35 -5
View File
@@ -4068,11 +4068,22 @@ components:
UserMailSendRequest: UserMailSendRequest:
type: object type: object
additionalProperties: false additionalProperties: false
required: [recipient_user_id, body] required: [body]
properties: properties:
recipient_user_id: recipient_user_id:
type: string type: string
format: uuid format: uuid
description: |
Either `recipient_user_id` or `recipient_race_name` must
be supplied; supplying both is rejected as
`invalid_request`.
recipient_race_name:
type: string
description: |
Resolves to the active member with this race name in the
game. Mutually exclusive with `recipient_user_id`. The
server returns `forbidden` when the matching member is no
longer active (lobby-removed / blocked).
subject: subject:
type: string type: string
description: | description: |
@@ -4093,10 +4104,18 @@ components:
type: string type: string
format: uuid format: uuid
description: | description: |
Required when `target="user"`. Identifies the recipient One of `recipient_user_id` and `recipient_race_name` is
of the personal admin message; the recipient may be in required when `target="user"`. Identifies the recipient
any membership status (admin notifications can reach of the personal admin message by uuid; the recipient may
kicked players). be in any membership status (admin notifications can
reach kicked players when addressed by user_id).
recipient_race_name:
type: string
description: |
Optional alternative to `recipient_user_id` when
`target="user"`. Resolves to the active member with this
race name in the game; lobby-removed and blocked members
cannot be reached through the race-name shortcut.
recipients: recipients:
type: string type: string
enum: [active, active_and_removed, all_members] enum: [active, active_and_removed, all_members]
@@ -4323,6 +4342,17 @@ components:
sender_username: sender_username:
type: string type: string
nullable: true nullable: true
sender_race_name:
type: string
nullable: true
description: |
Snapshot of the sender's race name in this game at send
time. Populated when `sender_kind="player"` and the
sender had an active membership at send time; nil for
admin and system messages, and for player messages sent
by a private-game owner who was not an active member at
send time. The in-game UI keys per-race threading on this
field.
subject: subject:
type: string type: string
body: body: